网络服务器开发总结
发表于2016-09-02
摘要
经过多年网络服务器开发实战,于此总结实践体会。本文涉及到异步连接、异步域名解析、热更新、过载保护、网络模型与架构及协程等,但不会涉及accept4、epoll等基本知识点。
网络服务器开发总结
一、概述
经过多年网络服务器开发实战,于此总结实践体会。本文涉及到异步连接、异步域名解析、热更新、过载保护、网络模型与架构及协程等,但不会涉及accept4、epoll等基本知识点。
二、可写事件
相信大多数初学者都会迷惑可写事件的作用,可能觉得可写事件没有什么意义。但在网络服务器中监听并处理可写事件必不可少,其作用在于判断连接是否可以发送数据,主要用于当网络原因暂时无法立即发送数据时监听。
当有数据需要发送到客户端时则直接发送。若没能立即完整发送,则先将其缓存到发送缓冲区,并监听其可写事件,当该连接可写时则再发送之且不再监听其可写事件(防止滥用可写事件)。
值得注意的是,对于指定网络连接需要先将发送缓冲区数据发送完成后才能发送新数据,此也可能比较容易忽略,至少本人当年被坑过。
三、连接缓冲区
对于长连接来说,维持网络连接缓冲区也必不可少。目前一些网络服务器(如QQ宠物旧接入层)都没有维持连接的接收与发送缓冲区,更不会在暂无法发送时监听可写事件。其直接接收数据并处理,若处理过程中遇到不完整数据包则直接丢掉,如此则可能导致该连接的后续网络数据包大量出错,从而导致丢包;在发送数据时也会在无法发送时直接丢弃。
对每一网络连接均需要维持其接收与发送数据缓冲区,当连接可读取时则先读取数据到接收缓冲区,然后判断是否完整并处理之;当向连接发送数据时一般都直接发送,若不能立即完整发送时则将其缓存到发送缓冲区,然后等连接可写时再发送,但需要注意的是,若在可写缓冲区非空且可写之前需要发送新数据,则此时不能直接发送而是应该将其追加到发送缓冲区后统一发送,否则会导致网络数据窜包。
连接缓冲区内存分配常采用slab内存分配策略,可以直接实现slab算法(如memcached),但推荐直接采用jemalloc与tcmalloc等(如redis)。
四、accept阻塞性
阻塞型listen监听套接字,其accept时也可能会存在小概率阻塞。
当accept队列为空时,对于阻塞套接字时accept会导致阻塞,而非阻塞套接字则立即返回EAGAIN错误。因此bind与listen后应该将其设置为非阻塞,并在accept时检查是否成功。
此外listen_fd有可读事件时不应仅accept一次,而最好循环accept直到其返回-1。
五、异步连接
网络服务器常需要连接到其它后端服务器,但作为服务器阻塞连接是不可接受的,因此需要异步连接。
异步连接时首先需要创建socket并设置为非阻塞,然后connect连接该套接字即可。若connect返回0则表示连接立即建立成功;否则需要根据errno来判断是连接出错还是处于异步连接过程;若errno为EINPROGRESS则表示仍然处于异步连接连接,需要epoll来监听socket的可写事件(注意不是可读事件)。当可写后通过getsockopt来获取错误码(即getsockopt(c->sfd, SOL_SOCKET, SO_ERROR, &err, (socklen_t*)&len);),若getsockopt返回0且错误码err为0则表示连接建立成功,否则连接失败。
由于网络异常或后端服务器重启等原因,网络服务器需要能够自动异步断线重连,同时也应该避免后端服务器不可用时无限重试,因此需要一些重连策略。假设需要存在最多M条连接到同类型后端服务器集群的网络连接,若当前有效网络连接断开且当前连接数(包括有效和异步连接中的连接)少于M/2时则立即进行异步连接。若该连接为异步连接失败则不能进行再次连接,以防止远程服务器不可用时无限重连。当需要使用连接时,则可在M条连接随机取N次来获取有效连接,若遇到不可用连接则进行异步连接。若N次仍获取不到有效连接则循环M条连接来得到有效连接对象。
六、异步域名解析
当仅知道后端服务器的域名时,异步连接前需要先域名解析出远程服务器的IP地址(如WeQuiz接入层),同样,阻塞式域名解析对于网络服务器来说也不是好方式。
幸好linux系统提供getaddrinfo_a函数来支持异步域名解析。getaddrinfo_a函数可以同步或异步解析域名,参数为GAI_NOWAIT时表示执行异步解析,函数调用会立即返回,但解析将在后台继续执行。异步解析完成后会根据sigevent设置来产生信号(SIGEV_SIGNAL)或启动新线程来启动指定函数(SIGEV_THREAD)。
1 2 3 4 5 6 7 8 9 10 11 12 13 | struct gaicb* gai = (gaicb*) calloc (1, sizeof ( struct gaicb)); gai->ar_name = config_ get_dns_url(); /* url */ struct sigevent sig; sig.sigev_notify = SIGEV_SIGNAL; sig.sigev_value.sival_ptr = gai; sig.sigev_signo = SIGRTMIN; /* signalfd/epoll */ getaddrinfo_a(GAI_NOWAIT, &gai, 1, &sig); |
对于异步完成后产生指定信号,需要服务器进行捕获该信号并进一步解析出IP地址。为了能够在epoll框架中统一处理网络连接、进程间通信、定时器与信号等,linux系统提供eventfd、timerfd与signalfd等。在此创建dns_signal_fd = signalfd(-1, &sigmask, SFD_NONBLOCK|SFD_CLOEXEC));并添加到epoll中;当异步完成后产生指定信号会触发dns_signal_fd可读事件;由read函数读取到signalfd_siginfo对象,并通过gai_error函数来判断异步域名解析是否成功,若成功则可遍历gai->ar_result得到IP地址列表。
七、热更新
热更新是指更新可执行文件时正在运行逻辑没有受到影响(如网络连接没有断开等),但新网络连接处理将会按更新后的逻辑处理(如玩家登陆等)。热更新功能对接入层服务器(如游戏接入服务器或nginx等)显得更加重要,因为热更新功能大部分时候可以避免停机发布,且随时重启而不影响当前处理连接。
WeQuiz手游接入服务器中热更新的实现要点:
1、在父进程中创建listenfd与eventfd,然后创建子进程、监听SIGUSR1信号并等待子进程结束;而子进程将监听listenfd与eventfd,并进入epoll循环处理。
2、当需要更新可执行文件时,发送SIGUSR1信号给父进程则可;当父进程收到更新信号后,其通过eventfd来通知子进程,同时fork出新进程并execv新可执行文件;此时存在两对父子进程。
3、子进程通过epoll收到eventfd更新通知时,则不再监听并关闭listenfd与eventfd。由于关闭listenfd则无法再监听新连接,但现有网络连接与处理则不受影响,不过其处理仍是旧逻辑。当所有客户端断开连接后,epoll主循环退出则该子进程结束。值得注意的是,由于无法通过系统函数来获取到epoll处理队列中的连接数,则需要应用层维持当前连接数,当其连接数等于0时则退出epoll循环。此时新子进程监听listenfd并处理新网络连接。
4、当旧父进程等待到旧子进程退出信号后则也结束,此时仅存在一对父子进程,完成热更新功能。
八、过载保护
对于简单网络服务器来说,达到100W级连接数(8G内存)与10W级并发量(千兆网卡)基本没问题。但网络服务器的逻辑处理比较复杂或交互消息包过大,若不对其进行过载保护则可能服务器不可用。尤其对于系统中关键服务器来说(如游戏接入层),过载可能会导致长时间无法响应甚至整个系统雪崩。
网络服务器的过载保护常有最大文件数、最大连接数、系统负载保护、系统内存保护、连接过期、指定地址最大连接数、指定连接最大包率、指定连接最大包量、指定连接最大缓冲区、指定地址或id黑白名单等方案。
1、最大文件数
可以在main函数中通过setrlimit设置RLIMIT_NOFILE最大文件数来约束服务器所能使用的最大文件数。此外,网络服务器也常用setrlimit设置core文件最大值等。
2、最大连接数
由于无法通过epoll相关函数得到当前有效的连接数,故需要应用服务器维持当前连接数,即创建连接时累加并在关闭时递减。可以在accept/accept4接受网络连接后判断当前连接数是否大于最大连接数,若大于则直接关闭连接即可。
3、系统负载保护
通过定时调用getloadavg来更新当前系统负载值,可在accept/accept4接受网络连接后检查当前负载值是否大于最大负载值(如cpu数* 0.8*1000),若大于则直接关闭连接即可。
4、系统内存保护
通过定时读取/proc/meminfo文件系统来计算当前系统内存相关值,可在accept/accept4接受网络连接后检查当前内存相关值是否大于设定内存值(如交换分区内存占用率、可用空闲内存与已使用内存百分值等),若大于则直接关闭连接即可。
1 2 3 4 5 | g_sysguard_cached_mem_swapstat = totalswap == 0 ? 0 : (totalswap - freeswap) * 100 / totalswap; g_sysguard_cached_mem_free = freeram + cachedram + bufferram; g_sysguard_cached_mem_used = (totalram - freeram - bufferram - cachedram) * 100 / totalram; |
5、连接过期
连接过期是指客户端连接在较长时间内没有与服务器进行交互。为防止过多空闲连接占用内存等资源,故网络服务器应该有机制能够清理过期网络连接。目前常用方法包括有序列表或散列表等方式来处理,但对后端服务器来说,轮询总不是最佳方案。QQ宠物与WeQuiz接入层通过每一连接对象维持唯一timerfd描述符,而timerfd作为定时机制能够添加到epoll事件队列中,当接收该连接的网络数据时调用timerfd_settime更新空闲时间值,若空闲时间过长则epoll会返回并直接关闭该连接即可。虽然作为首次尝试(至少本人没有看到其它项目中采用过),但接入服务器一直以来都比较稳定运行,应该可以放心使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | c->tfd = timerfd_create(CLOCK_REALTIME, TFD_NONBLOCK|TFD_CLOEXEC) ; struct itimerspec timerfd_value; timerfd_value.it_value.tv_sec = g_cached_time + settings.sysguard_limit_timeout; timerfd_value.it_value.tv_nsec = 0; timerfd_value.it_interval.tv_sec = settings.sysguard_limit_timeout; timerfd_value.it_interval.tv_nsec = 0; timerfd_settime(c->tfd, TFD_TIMER_ABSTIME, &timerfd_value, NULL) ; add_event(c->tfd, AE_READABLE, c) ; |
6、指定地址最大连接数
通过维持key为地址value为连接数的散列表或红黑树,并在在accept/accept4接受网络连接后检查该地址对应连接对象数目是否大于指定连接数(如100),若大于则直接关闭连接即可。
7、指定连接最大包率
连接对象维持单位时间内的服务器协议完整数据包数目,读取网络数据后则判断是否为完整数据包,若完整则数目累加,同时若当前读取数据包间隔大于单位时间则计数清零。当单元时间内的完整数据包数目大于限制值(如80)则推迟处理数据包(即仅收取到读取缓冲区中而暂时不处理或转发数据包),若其数目大于最大值(如100)则直接断开连接即可。当然也可以不需要推迟处理而直接断开连接。
8、指定连接最大数率
连接最大数率与连接最大包率的过载保护方式基本一致,其区别在于连接最大包率针对单位时间的完整数据包数目,而连接最大数率是针对单位时间的缓冲区数据字节数。
9、指定连接最大缓冲区
可在recv函数读取网络包后判断该连接对象的可读缓冲区的最大值,若大于指定值(如256M)则可断开连接;当然也可以针对连接对象的可写缓冲区;此外,读取完整数据包后也可检查是否大于最大数据包。
10、指定地址或id黑白名单
可以设置连接ip地址或玩家id作为黑白名单来拒绝服务或不受过载限制等,目前WeQuiz暂时没有实现此过载功能,而将其放到大区logicsvr服务器中。
此外,还可以设置TCP_DEFER_ACCEPT与SO_KEEPALIVE等套接字选项来避免无效客户端或清理无效连接等,如开启TCP_DEFER_ACCEPT选项后,若操作系统在三次握手完成后没有收到真正的数据则连接一直置于accpet队列中,并且当同一客户端连接(但不发送数据时)达到一定数目(如linux2.6+系统16左右)后则无法再正常连接;如开启SO_KEEPALIVE选项则可以探测出因异常而无法及时关闭的网络连接。
1 2 3 4 5 6 7 8 9 | setsockopt(sfd, IPPROTO_TCP, TCP_DEFER_ACCEPT, ( void *)&flags, sizeof (flags)); setsockopt(sfd, SOL_SOCKET, SO_KEEPALIVE, ( int []){1}, sizeof ( int )); setsockopt(sfd, IPPROTO_TCP, TCP_KEEPIDLE, ( int []){600}, sizeof ( int )); setsockopt(sfd, IPPROTO_TCP, TCP_KEEPINTVL, ( int []){30}, sizeof ( int )); setsockopt(sfd, IPPROTO_TCP, TCP_KEEPCNT, ( int []){3}, sizeof ( int )); |
九、超时或定时机制
超时或定时机制在网络服务器中基本必不可少,如收到请求后需要添加到超时列表中以便无法异步处理时能够超时回复客户端并清理资源。对于服务器来说,超时或定时机制并不需要真正定时器来实现,可以通过维持超时列表并在while循环或epoll调用后进行检测处理即可。
定时器管理常使用最小堆(如libevent)、红黑树(如nginx)与时间轮(如linux)等方式。
应用层服务器通常不必自己实现最小堆或红黑树或时间轮等方式来实现定时器管理,而可采用stl或boost中多键红黑树来管理,其中超时时间作为键,相关对象作为值;而红黑树则自动按键排序,检测时仅需要从首结点开始遍历,直到键值大于当时时间即可;当然可以得到首结点的超时时间作为epoll_wait的超时时间。此外,游戏服务器上大区逻辑服务器或实时对战服务器也常需要持久化定时器,可以通过boost库将其持久化到共享内存。
1、定时器管理对象
1 2 3 | typedef std::multimap typedef boost::interprocess::multimap |
2、定时器类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | class clock_timer_t { public : static clock_timer_t &instance() { static clock_timer_t instance; return instance; } static uint64_t rdtsc() { uint32_t low, high; __asm__ volatile ( "rdtsc" : "=a" (low), "=d" (high)); return (uint64_t) high << 32 | low; } static uint64_t now_us() { struct timespec tv; clock_gettime(CLOCK_REALTIME, &tv); return (tv.tv_sec * (uint64_t)1000000 + tv.tv_nsec/1000); } uint64_t now_ms() { uint64_t tsc = rdtsc(); if (likely(tsc - last_tsc <= kClockPrecisionDivTwo && tsc >= last_tsc)) { return last_time; } last_tsc = tsc; last_time = now_us() / 1000; return last_time; } private : const static uint64_t kClockPrecisionDivTwo = 500000; uint64_t last_tsc; uint64_t last_time; clock_timer_t() : last_tsc(rdtsc()), last_time(now_us()/1000) { } clock_timer_t( const clock_timer_t&); const clock_timer_t &operator=( const clock_timer_t&); }; |
3、超时检测函数(while或epoll循环中调用),可以返回超时对象集合,也可以返回最小超时时间。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | timer_values_t xxsvr_timer_t::process_timer() { timer_values_t ret; timer_key_t current = clock_timer_t::instance().now_ms(); timer_map_it it = timer_map->begin(); while (it != timer_map->end()) { if (it->first > current) { return ret; //返回超时对象集合,而return it->first - current返回超时时间则. } ret.push_back(it->second); timer_map->erase(it++); } return ret; } |
十、网络模型
Linux存在阻塞、非阻塞、复用、信号驱动与异步等多种IO模型,但并非每一类型IO模型均能应用于网络方面,如异步IO不能用于网络套接字(如linux)。通过不同设计与相关IO模型可以归纳出一些通用的网络模型,如常用的异步网络模型包括reactor、proactor、半异步半同步(hahs)、领导者跟随者(lf)、多进程异步模型与分布式系统(server+workers)等。
1、reactor
Reactor网络模型常指采用单进程单线程形式,以epoll为代表的IO复用的事件回调处理方式。此网络在网络服务器开发方面最为常用(如redis),尤其对于逻辑相对简单的服务器,因为其瓶颈不在于cpu而在网卡(如千兆网卡)。
2、proactor
Proactor网络模型一般采用异步IO模式,目前常用于window操作系统,如完成端口 IOCP;在linux可以在socket描述符上使用aio,而macosx中无法使用。尝试过socket + epoll + eventfd + aio模式,但无法成功;不过测试socket + sigio(linux2.4主流) + aio则可以。在linux服务器开发方面,异步IO一般只用于异步读取文件方面,如nginx中使用filefd + O_DIRECT + posix_memalign + aio + eventfd + epoll模式(可禁用),但其也未必比直接读取文件高效;而写文件与网络方面基本不采用异步IO模式。
3、半异步半同步(hahs)
半异步半同步模型(HalfAsync-HalfSync)常采用单进程多线程形式,其包括一个监听主线程与一组工作者线程,其中监听线程负责接受请求,并选取处理当前请求的工作线程(如轮询方式等),同时将请求添加该工作线程的队列,然后通知该工作线程处理之,最后工作线程处理并回复。对于hahs模式,所有线程(包括主线程与工作线程)均存在各自的epoll处理循环,每一工作线程对应一个队列,主要用于主线程与工作线程间数据通信,而主线程与工作线程间通知通信常采用pipe管道或eventfd方式,且工作线程的epoll会监听该通知描述符。hahs模式应用也比较广泛,如memcached与thrift等,此外zeromq消息队列也采用类似模型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | /* 主线程main_thread_process */ while (!quit) { ev_s = epoll_wait(...); for (i = 0; i < ev_s; i++) { if (events[i].data.fd == listen_fd) { accept4(….); } else if (events[i].events & EPOLLIN) { recv(…); select_worker(…); send_worker(…); notify_worker(…); } } /* 工作线程worker_thread_process */ while (!quit) { ev_s = epoll_wait(...); for (i = 0; i < ev_s; i++) { if (events[i].data.fd == notify_fd) { read(….); do_worker(…); } } } |
4、领导者跟随者(lf)
领导者跟随者模型(Leader-Follower)也常采用单进程多线程形式,其基本思想是一个线程作为领导者,而其余线程均为该线程的跟随者(本质上为平等线程);当请求到达时,领导者首先获取请求,并在跟随者中选取一个作为新领导者,然后继续处理请求;在实现过程中,所有线程(包括领导者与跟随者线程)均存在自各的epoll处理循环,其通过平等epoll等待,并用加锁方式来让系统自动选取领导线程。lf模式应用也比较广泛,如webpcl与一些java开源框架等。lf模式与hahs模式均能够充分利用多核特性,对于逻辑相对复杂的服务器其有效提高并发量。对于lf模式,所有线程均可平等利用epoll内核的队列机制,而hahs模式需要主线程读取并维持在工作线程的队列中,故本人比较常用lf模型,如QQPet与WeQuiz项目中接入服务器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | while (!quit) { pthread_mutex_lock(&leader); Loop: while (stats.curr_conns && !loop.nready && !quit) loop.nready = epoll_wait(...); if (!quit) { pthread_mutex_unlock(&leader); break ; } loop.nready--; int fd = loop.fired[loop.nready]; conn *c = loop.conns[fd]; if (!c) { close(fd); goto Loop; } loop.conns[fd] = NULL; pthread_mutex_unlock(&leader); do_worker(c); } |
5、多进程异步模型
多进程异步模型(Leader-Follower)常采用主进程与多工作进程形式,主要偏用于没有数据共享的无状态服务器,如nginx与lighttpd等web服务器;其主进程主要用于管理工作进程组(如热更新或拉起异常工作进程等),而工作进程则同时监听与处理请求,但也容易引起惊群,可以通过进程间的互斥锁来避免惊群(如nginx)。
综上所述,常用网络模型各有优缺点,如reacor足够简单,lf利用多核等。但其实有时并不必太过于在意单台服务器性能(如连接数与并发量等),更应该着眼于整体架构的可线性扩容方面等(如网络游戏服务器)。当然一些特定应用服务器除外,如推送服务器偏向连接数,web服务器偏向并发量等。此外,阅读nginx、zeromq、redis与memcached等优秀开源代码来有效提高技术与设计能力,如Nginx可达到几百万连接数与万兆网络环境至少可达50万RPS;zeromq采用相对独特设计让其成为最佳消息队列之一。
十一、架构
系统架构往往依赖于具体业务,限于篇幅仅简述WeQuiz手游服务器的整体架构设计。游戏常采用接入层、逻辑层与存储层的通用三层设计,结合目录服务器与大区间中转服务器等构成整个游戏框架。但不同于端游页游,手游具有弱网络、碎片玩法与强社交性等特点,故整体架构不仅需要优雅解决断线重连,还可以做到简化管理、负载均衡、有效容灾与方便扩容等。架构层面解决:引入转发层。
转发层可以避免因网络环境或碎片玩法等导致玩家频繁换大区而不断加载数据问题,维持玩家在线大区信息,同时管理全部服务器信息与维持其存活性,其连接星状结构也有效解耦服务器间关联性,让内部服务器不需关心其它服务器,从而简化整体架构。
1、断线重连:转发层router维持玩家大区信息,无论从那个接入层进入均可以到达指定大区,从而不会导致玩家数据重新加载等问题。
2、简化管理:仅需要router维持所有服务器信息,其它服务器均不需要任何服务器信息(包括router与同类服务器)。比如大区服务器需要判断两个玩家是否为好友,仅需要调用router提供接口发送即可,不用指定任何地址,也不用关心好友服务器的任何信息(比如服务器的地址与数目及存活等)。其中router接口封装tbus读写功能、自动心跳回复与映射关系回调构建功能,还维持所有router列表与最新存活router服务器。
3、负载均衡:对于router来说,采用最近心跳机制,其它服务器需要转发包时总会向最近收到心跳的router服务器发送。经统计,所有router转发量基本一致。而其它服务器存在多种转发模式,比如大区服务器,若新用户上线则选择大区人数最少大区转发;其它服务器采用取模或随机方式,基本做到负载均衡。
4、有效容灾:主要是基于心跳机制,router会定时发送心跳来探测所有服务器存活,当三次没收到心跳回复,则将其标记为不可用,转发时不再向该服务器转发。同时还会向该服务器发送间隔较大的心跳探测包(目前使用60秒),以便服务器恢复后可以继续服务。如果router挂掉,则其它服务器不会收到该router心跳包,自然不会向其发包。
5、方便扩容:如果需要添加其它服务器,仅需要向router配置文件的对应集群中添加新服务器,router随后会向该服务器发送探测心跳,收到心跳回复后则可以正常服务。如果需要添加router,仅需要复制一份router,其它服务器都不需要修改任何信息。Router会自动重建映射关系(发三次重建请求,如果失败则将该大区去除),成功后再向所有服务器发送心跳包以表示router此时可以正常服务,而其它服务器收到router心跳包则将其维持到router列表(相关功能均由router接口自动完成)。
十二、协程
协程在python、lua与go等脚本语言得到广泛应用,并且linux系统也原生支持c协程ucontext。协程可以与网络框架(如epoll、libevent与nginx等)完美结合(如gevent等);一般做法是收到请求创建新协程并处理,若遇到阻塞操作(如请求后端服务)则保存上下文并切换到主循环中,当可处理时(如后端服务器回复或超时)则通过上下文来找到指定协程并处理之。对于网络层的阻塞函数,可以通过dlsym函数来挂载相应的钩子函数,然后在钩子函数中直接调用原函数,并在阻塞时切换处理,这样应用层则可以直接调用网络层的阻塞函数而不必手动切换。
游戏服务器一般采用单线程的全异步模式,直接使用协程模式可能相对比较少,但在一些cgi调用形式的web应用(如游戏社区或运营活动等)则逐步得到应用。比如QQ宠物社区游戏原来采用apache+cgi/fcgi模式的阻塞请求处理,基本仅能达到每秒300并发量,通过strace观察到时间基本消耗在网络阻塞中,所以需要寻求一种代码尽量兼容但能提高吞吐量的技术,从而协程成为最佳选择,即采用libevent+greenlet+python来开发新业务,而选择nginx+module+ucontext来重用旧代码,最后做到修改不到20行代码则性能提高20倍(siege压测实际业务可达到8kQPS)。
十三、其它
网络服务器方面除了基本代码开发以外,还涉及到构建、调试、优化、压测与监控等方面,但由于最近新手游项目开发任务比较重,将后期再逐步总结,现仅简单罗列一下。
1、构建
一直以来都使用cmake来构建各类工程(如linux服务器与window/macosx客户端程序等),体会到cmake是最优秀的构建工具之一,其应用也比较广泛,如mysql、cocos2dx与vtk等。
1 2 3 4 5 6 7 | project(server) add_executable(server server.c) target_link_libraries(server pthread tcmalloc) cmake .; make; make install |
2、调试
网络服务器开发调试大部分情况都可以通过日志来完成,必要时可以通过gdb调试,当然也可以在Linux系统下直接使用eclipse/gdb来可视化调试。
当程序异常时,有core文件直接使用gdb调试,如bt full查看全栈详细信息或f跳到指定栈p查看相关信息;没有core文件时则可以查看/var/log/message得到地址信息,然后通过addr2line或objdump来定位到相关异常代码。
对于服务器来说,内存泄漏检测也是必不可少的,其中valgrind为最佳的内存泄漏检测工具。
此外,其它常用的调试工具(编译阶段与运行阶段)有nm、strings、strip、readelf、ldd、pstack、strace、ltrace与mtrace等。
3、优化
网络服务器优化涉及算法与技术等多个方面。
算法方面需要根据不同处理场景来选择最优算法,如九宫格视野管理算法、跳跃表排行算法与红黑树定时器管理算法等,此外,还可以通过有损服务来设定最佳方案,如WeQuie中采用到的有损排行榜服务。
技术方面可以涉及到IO线程与逻辑分离、slab内存管理(如jemalloc与tcmalloc等)、socket函数(如accept4、readv、writev与sendfile64等)、socket选项(如TCP_CORK、TCP_DEFER_ACCEPT、SO_KEEPALIVE、TCP_NODELAY与TCP_QUICKACK等)、新实现机制(如aio、O_DIRECT、eventfd与clock_gettime等)、无锁队列(如CAS、boost::lockfree::spsc_queue与zmq::yqueue_t等)、异步处理(如操作mysql时采用异步接口库libdrizzle、webscalesql或mongodb或redis异步接口与gevent类异步框架等)、协议选择(如http或pb类型)、数据存储形式(如mysql的blob类型、mongodb的bjson类型或pb类型等)、存储方案(如mysql、mongodb、redis、bitcask与leveldb及hdfs等)、避免惊群(如加锁避免)、用户态锁(如nginx通过应用层的CAS实现(更好跨平台性))、网络状态机、引用计数、时间缓存、CPU亲缘性与模块插件形式(如python、lua等)。
常用的调优工具有valgrind、strace、perf与gprof及google-perftools等,如valgrind的callgrind工具,可以在需要分析代码段前后加上CALLGRIND_START_INSTRUMENTATION; CALLGRIND_TOGGLE_COLLECT; CALLGRIND_TOGGLE_COLLECT; CALLGRIND_STOP_INSTRUMENTATION;,然后运行valgrind --tool=callgrind --collect-atstart=no --instr-atstart=no ./webdir即可,得到分析结果文件还可用Kcachegrind可视化展示。
除了提高服务器运行效率外,还可以通过一些开发包或开源库来提高服务器开发效率,如采用boost库管理不定长对象的共享内存、python协程与go框架等。
4、压测
对于网络服务器来说,压力测试过程必不可少,其可用于评估响应时间与吞吐量,也可以有效检查是否存在内存泄漏等,为后期修正与优化提供依据。
对于http服务器,常用ab或siege等工具进行压测,如./siege –c 500 –r 10000 –b –q http://10.193.0.102:8512/petcgi/xxx?cmd=yyy。
对于其它类型服务器一般都需要自己编写压测客户端(如redis压测工具),常用方法是直接创建多线程,每一线程使用libevent创建多连接与定时器等来异步请求与统计。
此外,若需要测试大量连接数,则可能需要多台客户机或创建多个虚拟ip地址。
5、高可用性
服务器的高可用性实现策略包括主从机制(如redis等)、双主机制(如mysql+keepalive/heartbeat)、动态选择(如zookeeper)与对称机制(如dynamo)等,如双主机制可由两台等效机器的VIP地址与心跳机制来实现,常常采用keepalive服务,当然也可以由服务器自主实现,如服务器启动时需要指定参数来标识其为主机还是从机,同时主备需要通过心跳包来保持异常时切换,如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | void server_t::ready_as_master() { primary = 1; backup = 0; system ( "/sbin/ifconfig eth0:havip 10.2.2.147 broadcast 10.2.2.255 netmask 255.255.255.0 up" ); //! 虚拟IP system ( "/sbin/route add -host 10.2.2.147 dev eth0:havip" ); system ( "/sbin/arping -I eth0 -c 3 -s 10.2.2.147 10.2.2.254" ); } void server_t::ready_as_slave() { primary = 0; backup = 1; system ( "/sbin/ifconfig eth0:havip 10.2.2.147 broadcast 10.2.2.255 netmask 255.255.255.0 down" ); } |
当然这是相对简单方式(其前提是主备机器均可正常通信),没有考虑到异常情况(如主备机器间的网线断开情况等),此时可以考虑用双中心控制与动态选举择模式等。
6、监控
Linux在服务器监控方面工具非常丰富,包括ps、top、ping、traceroute、nslookup、tcpdump、netstat、ss、lsof、nc、vmstat、iostat、dstat、ifstat、mpstat、pidstat、free、iotop、df、du、dmesg、gstack、strace与sar(如-n/-u/-r/-b/-q等)及/proc等,如ps auxw查看进程标记位(一般地D阻塞在IO、R在cpu、S表示未能及时被唤醒等),gstack pid查看进程当前栈信息,ss -s查看连接信息,sar -n DEV 1 5查看包量,sar -r 1 5查看内存使用情况,vmstat 1 5查看进程切换频率,iotop或iostat -tdx 1或dstat -tclmdny 1查看磁盘信息与mpstat 2查看CPU信息及/proc/net/sockstat查看socket状态等。此外有时最有效的是服务器日记文件。
十四、结束
除了网络服务器基本开发技术之外,系统整体架构更为重要(如可线性扩容性),后期有时间再详细总结,对于网络游戏架构方面可参见WeQuiz手游服务器架构与QQPet宠物架构设计等。
欢迎rtx(baokaichen)或email(chenbk@foxmail.com)指正与讨论。Ths