一、概述
经过多年网络服务器开发实战,于此总结实践体会。本文涉及到异步连接、异步域名解析、热更新、过载保护、网络模型与架构及协程等,但不会涉及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)。
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接受网络连接后检查当前内存相关值是否大于设定内存值(如交换分区内存占用率、可用空闲内存与已使用内存百分值等),若大于则直接关闭连接即可。
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会返回并直接关闭该连接即可。虽然作为首次尝试(至少本人没有看到其它项目中采用过),但接入服务器一直以来都比较稳定运行,应该可以放心使用。
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选项则可以探测出因异常而无法及时关闭的网络连接。
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)定时器管理对象
typedef std::multimap<timer_key_t, timer_value_t> timer_map_t;
typedef boost::interprocess::multimap<timer_key_t, timer_value_t, std::less<timer_key_t>, shmem_allocator_t> timer_map_t;
(2)定时器类
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() {