放置类游戏后端服务器架构设计与实现
前言:
停更了一段时间。2020年也接近尾声了,调整了一下人生状态,继续前进。
今年完全参与了一款放置类游戏从0到开发上线再到合服。从目前市场上买量游戏的发展线路来看,合服意味着游戏走向压榨玩家的最后一步了。游戏项目也趋于稳定和成熟,最终能不能继续运营下去还是未知数,但是还是想从技术上/业务上做一次总结。
放置类游戏归于休闲游戏类,玩家不需要有太多的操作,只需要点点点即可。因此对于后台服务器来说也不需要太累,虽然不需要后台有太过于酷炫的技术,但是必须要保证不把玩家的数据丢失。不同于竞技类游戏或其他游戏,我觉得对于放置类游戏来说数据是最重要的。竞技类游戏或者MMORPG游戏玩家从操作中、从剧情中得到快感。延迟,同步,数据同样重要。但是对于放置类游戏,玩家通过点点点堆积道具,攒积积分,提升排名本身就是这类玩家的爽点所在,如果这些数据丢失了无异于将玩家的时间付出付之一炬。
对于上述原因,数据传输协议必然就选自传统的TCP可靠传输协议,数据持久化方面也就是传统mysql。当然后台服务器并不是为了完全可靠不顾及速度而直接操作mysql,中间还是会有一层内存的中间层作为过渡。
这篇文章会从技术上和业务上做一个总结,也算是对我项目终的总结吧。
一、MySQL数据库和表结构设计
通用数据库的设计
玩家注册一个账号,后台会为该玩家生成一个独一无二的账户标识UID。
玩家在某个服创建一个角色,后台会为该角色生成一个独一无二的角色标识RID。
因此需要一个数据库(命名为Common数据库),里面存放一些通用的全局变量。例如上述两个UID、RID是以递增的方式为每个账户、每个角色分配。因此需要有个表(命名为ID_CTRL),里面记录了两条数据,分别是UID和RID的当前值。
Common数据库的所有表及作用:
1.ID控制表(命名为XXX_ID_CTRL):初始值可以指定一个比较大的数,记录当前分配到的UID、RID的值。
2.黑名单表(命名为XXX_Black):该表可以以账户标识UID字段为主键,另一个字段可以为封禁时间。
3.兑换码表(命名为XXX_Exchange_Code):该表可以以兑换码字符串为主键,其它字段一般需要包含:兑换码的使用者、兑换码的失效时间、兑换码的类型、兑换码的对应的物品掉落ID、兑换码可用渠道等等。
4.账户-角色信息表(命名为XXX_Uid_Info):一个账户UID下可以在不同服注册角色,因此一个UID就可能对应多个RID。这个表主要用来记录UID对应哪些RID,以及这个RID的基本信息。其中的字段是以UID、SrvId字段为主键,剩余字段包含:RID、创角色时间戳等等。
5.用户名信息表(命名为XXX_Role_Mapping):该表记录了每个角色的名字和对应的服务器ID。该表的作用可用于玩家起名,一个服不应该有相同的名字就是从这个表里面做的判断。但是不同服可以有相同的名字,因此该表的主键是以角色名字的字符串和服务器ID作为联合主键。剩下的表字段为RID。
6.openID-账户信息表(命名为XXX_User_Mapping):OpenID是可以管理员用户自己指定的账户标识,每个普通玩家也会随机生成但是普通玩家并没有机会使用。这个表记录了账户的创建信息。以OpenId和Uid为主键,剩余字段记录账户生成的时间戳。
分库分表的设计
除了通用数据库外,其他数据库就是内容数据库用来存放角色信息的。既然上述的设计角色的RID是以递增的形式,那么为了缓解单个内容数据库的压力自然想到的内容数据库的分库方式就是以RID的尾数作为分库的依据。
这样内容数据库就分了10个:XXX_0、XXX_1、XXX_2、XXX_3、XXX_4、XXX_5、XXX_6、XXX_7、XXX_8、XXX_9。依据玩家RID的尾数将它塞入对应的数据库中。这种分库的方式自然是最均衡的。
内容数据库表的设计及作用:
1.角色信息表(命名为:XXX_Basics):该表的作用主要是记录角色的基本信息,例如:角色名字、等级、性别、职业、充值数量、帮会等等。以角色的独一无二的标识RID作为主键。
2.角色内容表(命名为:XXX_Info):该表的作用是记录角色在游戏内产生的数据。这个表的内容会是最多的,例如:运营充值活动产生的数据、游戏副本产生的数据、养成的属性数据、甚至道具数量等等。该表的字段以Rid、Type(区分类型)、Id为联合主键。剩余字段可自行设置。我们项目内设置是除主键外还有10个int字段。
3.角色扩展内容表(命名为:XXX_Extend_Info):有时候上述的角色内容表的10个int字段不够用,这个表的目的就是为扩展用的。主键依然是Rid、Type、Id为联合主键,剩下的一个字段是data字段为252字节的binary。存什么应该都够了。
4.角色装备/道具表等等的特殊表(命名为:XXX_Equip):该表存有特殊实现的道具或者装备。
5.好友关系表(命名为:XXX_Friend):放置类游戏必不可少的社交属性系统。该表存好友之间的映射关系。
6.邮件表(命名为:XXX_Mail):关于邮件和好友系统的实现可以看另一篇博文:游戏好友系统与邮件系统实现
7.帮会表(命名为:XXX_Union):该表记录每个服的帮会信息,以帮会ID作为主键,其他字段有帮会等级、帮会贡献、帮主RId、帮会人数、帮会创建时间等等信息。
8.帮会成员表(命名为:XXX_Union_Member):该表记录每个帮会下面每个成员的信息。以帮会ID和角色Rid为联合主键。其他字段有帮会职位、角色基本信息、帮会贡献等等。
潜在的问题
以上生成全局唯一的Uid或者Rid的方法很明显需要加锁或者单进程处理,否则就会出现重复的状况。例如:系统会在业务逻辑进程里面为玩家创建角色,此时分配Rid的时候就需要向数据库取当前的Rid值,然后将该值赋值给角色,最后将该值+1写回数据库。业务逻辑进程不止一个的情况下,在第一个业务逻辑进程还未将值写回数据库时,另一个逻辑业务进程又从数据库取Rid的值,这样这两个Rid就会重复。
在我们游戏中确实存在这个问题,业务逻辑进程和Mysql数据库之间还有一层中间内存缓存层。业务逻辑进程向中间缓存层取数据写数据,由中间缓存层存入数据库。可惜我们项目中的这个中间缓存层是闭源的,只提供了接口且并没有锁设计。因此我们的Rid和Uid有重复的可能,一般有打广告的用脚本自动快速注册角色就会导致,普通玩家暂时没有出现过。
二、游戏整体的异步设计
游戏的服务器结构图:
一个完整的游戏流程会经历多个步骤:
1.玩家登录游戏,后台对账号的校验。
2.登陆成功,后台维护一条客户端-服务端的连接。
3.登陆成功,后台将该玩家的数据从数据库载入进内存。
4.游戏正常游玩,后台将玩家产生的数据持久化。
针对以上功能分别设计了不同的进程来处理:
1.transit进程:对用户进行账户校验的,如果校验成功则走后续的加载数据流程。
2.Logic进程:业务逻辑进程,主要处理业务逻辑的。玩家的操作主要就是在这里处理的。也是产生用户数据的地方。
3.DbWriter进程:异步存档进程。由Logic进程产生的数据会通过Tcp协议发送到该进程。该进程调用Mysql数据库的中间缓存层以同步的方式将数据交给中间缓存层。
4.Cache进程:Mysql数据库的中间缓存层,由该进程调用Mysql的接口将数据存入数据库中。
5.Cross进程:游戏的跨服玩法的业务逻辑处理。
6.Center进程:游戏的全服玩法的业务逻辑处理。
7.Access进程:接入服务器进程,主要做的是维护客户端的连接,后面会主要讲解这个进程的处理。
上面中存档数据持久化用的是另外一个进程异步处理,但是在Logic进程中还需要Load档操作。走的是另外一个线程的同步方式load档。这么做的原因是load档的请求量远远少于存档的请求量,所以简单地在另一个线程实现。另一个原因是异步存档可以记录BINLOG来重现数据库。
三、网络I/O
无论是Access进程或者Logic进程还是其他进程,用的都是同一套网络I/O,维持TCP连接,收发数据包处理。这部分主要分享一下Access接入进程和Logic进程的网络I/O设计。
网络I/O的实现类图:
1.几个类的功能作用说明
CPollerUnit:
pollerTable:连接池的头节点指针。连接池是一片空间连续的链表结构。连接池的大小由Access进程的最大连接数maxPollers指定。
freeSlotList:连接池空闲节点指针。每次有一个新的连接过来以后从取出这个指针的节点,并将节点后移。
epfd:epoll体系的文件描述符。
CPollThread:
CPollThread继承自CPollerUnit,主要的调用函数是ThreadLoop()用来调用epoll_wait()返回可读可写事件。当有事件发生后调用void ProcessPollerEvents(void)来处理事件。
CPollerObject:
主要的数据成员是新连接的fd文件描述符,连接池节点的指针以及监听的事件。作为父类定义了可读可写和错误处理虚成员函数,其子类会复写这些函数实现不同的处理逻辑。
CClientAsync:
继承自CPollerObject。每当有一个新的客户端连接的时候,都会new一个该对象。该对象实现了将连接fd纳入到epoll体系的函数。复写了可读可写和事件错误处理函数。
CBattleAsync:
同样继承自CPollerObect。Access进程作为客户端会依据配置主动去连接各个区服的进程即Logic进程,来建立一个Tcp连接。连接成功与否都会new一个该对象并将该对象存放在一个map<uint32,CBattleAsync>容器里面。为什么这样呢后面会详叙。
CListener:
同样继承自CPollerObject,该对象主要是有一个ListenFd来监听新的客户端连接。
2.Access接入进程的连接池设计
接入进程维护了所有客户端的TCP连接,以及与每一个Logic进程的TCP连接。每个新连接到来时都会向连接池申请资源,如果申请失败则连接建立失败。连接池的大小在配置内指定。
之前我尝试过不使用连接池改造过Access进程,发现可行且省去了考虑分配连接池大小的问题,于是和leader讨论了连接池存在的必要性。
得出的结论是连接池还是很有必要的,目的就是为了能对内存的使用掌握主动权。Access接入进程作为维护客户端的连接可能会有成千上万,那么内存的使用就需要能更好的把握。使用连接池能对Access进程的内存使用定量分配,定量掌控,定量分析,定量扩展。
连接池设计:
连接池是一片形如链表结构但空间连续的内存。
连接池中的节点有各种各样的连接,这些各种各样的连接都会被定义成不同类别的对象,但这些不同类别的对象都继承自CPollerObject,这些连接对应的对象大致有以下几类:
1.CClientAsync:和玩家客户端连接的对象
2.CBattleAsync:和Logic进程连接的对象
3.CTransitAsync:和Transit进程连接的对象
4.CDbwriterAsync:和Dbwriter进程连接的对象
5.CCrossAsync:和Cross进程连接的对象
6.CCenterAsync:和Center进程连接的对象
连接池占用内存大小的量化评估:每个节点有两个指针(64*2=128位)+ 一个int类型(32位这个类型可以省略,历史遗留问题)=20B。如果一个Access接入进程支持1万个并发连接数,那么内存池的占用大小是:20B*10000≈200KB≈0.2MB。
3.Access接入进程网络I/O设计和实现
对于Access进程的网络I/O,主要是以上三类的对象:
第一类是CListener对象用来监听新客户端的连接。
第二类是CClientAsync对象:Access作为服务端为每一个新的客户端连接new一个该对象。
第三类是CBattleAsync对象:Access作为客户端依据配置主动去连接每一个区服的进程所new的对象。
每当一个新的连接建立的时候,就会占用一个节点,并将上述的子类指针赋值给CPollerObject *poller。当监听新连接事件(将新的fd纳入到epoll体系)的时候,会将该节点的index索引赋值给 struct epoll_event 的 data 成员然后调用 epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
这样,如果该连接有事件从epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout) 中返回,可以从strcut epoll_event中拿到对应对象指针在连接池的索引,进而可以以 O(1) 的时间复杂度从连接池中拿到对象指针。
因为不同的XXXAsync子类对可读可写错误处理的事件有不同的处理,因此分别重载了父类的可读可写错误处理的调用函数:
virtual void InputNotify (void);
virtual void OutputNotify (void);
virtual void HangupNotify (void);
父类对象指针保存了子类的对象指针,这里用了C++语言的多态特性。
如果不用连接池的设计,那么调用 epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) 时传入的 struct epoll_event 的 data参数中完全可以传对象的指针。上述中已经讨论了连接池存在的必要性。
4.Access接入进程对客户端数据的转发
Access进程的对象功能图:
玩家登入选择区服登入游戏开始游玩。如果你是程序员你可能就会觉得玩家客户端和这个区服的进程建立了一条Tcp连接来收发数据,但是真实情况往往不是这样的。
Access进程作为接入进程,有多少个CClientAsync对象就代表有多少个客户端连接。同时Access接入进程又作为客户端对每一个服的进程(Logic进程)发起Tcp连接并new一个CBattleAsync对象,这些对象的指针存放在以区服ID为Key的容器 map<uint32_t, CBattleAsync*> 内。
客户端连接建立成功后会发送第一个数据包就是区服id并记录在CClienAsync对象的数据成员里面。
玩家角色客户端发送数据包给服务器的流程:通过该角色---该客户端连接---拿到该区服ID---拿到该CBattleAsync对象---拿到该区服的连接,将数据通过区服的连接发送给玩家所在区服的进程。
Access接入进程和Logic进程是一种多对一的关系,那么Logic进程如何区分出不同的客户端连接就是通过原封不动的返回Access接入进程发送过来的包体内容。
一个客户端发起Tcp连接,Access进程的epoll事件触发并由Accep()函数接收文件描述符。Access进程依据文件描述符创建一个CClientAsync对象,并对对象的数据成员fd、srvId、time、microTime进行赋值,将该对象的指针以fd为key放入一个map容器内。当客户端有数据包发给后台时(其实是将数据发送给Logic进程),通过epoll event返回的index找到连接池该对象的指针,调用CClientAsync对象的可读处理事件。依据srvId拿到Access接入进程维护的对不同区服Logic进程的连接的对象。发送给区服前封装包体以让Logic进程能标识出不同的客户端。
Logic进程在恰当的时候会将包头的fd、SrvId、time、microTime记录下来,叫做一条客户端”连接“。
5.连接关闭
由以上客户端的连接可知,如果连接关闭需要做两件事情。
一、Access接入进程从Epoll的监听体系里面剔除要关闭连接的文件描述符。
二、告知Logic进程剔除该客户端的连接映射,并做角色下线操作。
连接关闭的情况分两种:
1.客户端主动断开连接
Access接入进程收到某个客户端连接recv()返回长度为0代表客户端—Access接入进程的TCP连接关闭。Access进程可以从Epoll体系里面移除该文件描述符的监听。然后再构建一个包发给Logic进程告知Logic进程对于的连接映射已关闭让Logic进程对该连接的角色做下线操作。最后Access接入进程将该CClienAsync对象析构。
2.服务器断开客户端连接
这种情况就是Logic进程挂了,那么Access接入进程—Logic进程的TCP连接就会被关闭。Access接入进程在要关闭那个连接的CBattleAsync对象下recv()返回长度为0。Access接入进程做的事情是将该文件描述符从Epoll的监听体系内移除,但是并不析构CBattleAsync对象,因为Access接入进程作为服务端是在进程一启动的时候去连的各个区服的Logic进程,如果析构了CBattleAsync对象那么就变复杂了,Access接入进程需要定时去检测和各个服的连接是否正常,实属多余。
Access接入进程—Logic进程的TCP连接建立成功以后会将CBattleAsync对象的成员变量m_stat设置为CONNECTED,因此当该TCP连接关闭以后,直接将m_stat变量设置为IDLE状态即可,且也不将该对象从容器内剔除。
Access接入进程作为服务端并没有再去和Logic进程建立TCP连接,那么当Logic进程重新启动以后怎么重建Access接入进程—Logic进程的TCP连接?
答应是由连接到该区服Logic的玩家客户端来重建,玩家客户端其实并不知道Access接入进程—Logic进程的TCP连接是个什么状态,他会发协议包给到Access接入进程,然后Access接入进程转交给Logic进程的时候发现该连接的状态m_stat是IDLE状态,于是就会让Access接入进程重新和该Logic进程发起TCP连接。再将包转给Logic进程。
6.Epoll触发方式的选择:
上面讲了整个网络I/O是用的epoll,那么对每个fd设置监听事件采取的触发方式是什么。我们这里使用的是LT(水平触发)+Non_Blocking(非阻塞)的方式。
采用这种触发方式必然也就要会有不同的处理。
1.对于监听新连接到来的ListenFd,一般采用非阻塞的原因有:
a.采用阻塞ListenFd可能会导致其他连接的可读可写事件无法被及时处理(单线程/单进程的情况下)。Tcp完成三次握手将该连接放入一个队列里面。epoll感知到该连接存在返回ListenFd的可读事件,由Accept()函数拿到该连接的文件描述符。如果采用了阻塞的ListenFd,就会导致一种情况:如果Tcp完成三次握手后客户端就发送RST报文直接断开连接,该连接在内核内已经被断开。但是epoll依旧会返回ListenFd的可读事件,如果是阻塞的ListenFd,此时就绪队列内并没有文件描述符返回,那么程序就会阻塞在Accept()函数内,直到下一个连接的到来。如果采用非阻塞ListenFd,在Accept()函数之前连接被RST报文断开,那么Accept()也会返回并指定错误码。
b.ET模式下采用非阻塞模式可以防止有连接未被及时处理的情况。在ET模式下,如果多个连接同时到达,ListenFd对应的内核缓冲区积累了多个。但是Epoll只会触发一次,因此如果要正确及时处理这些堆积的连接就需要在Accept()函数包一层while循环。如果采用阻塞的ListenFd,最后一次循环调用Accept()函数的时候进程就被阻塞了,此时进程就丧失了处理其他事件的能力。正确的方式是采用非阻塞的方式,最后一次Accept()函数返回-1并将errno设置为EAGAIN。
while (true) //对于非阻塞的ListenFd,这里也可不采用循环。因为如果ListenFd内有待处理的连接,会一直触发epoll的可读事件 { peerSize = sizeof (peer); newfd = accept (netfd, &peer, &peerSize); if (newfd == -1) { //如果不是阻塞的系统调用被中断并且不是继续尝试上述函数调用,那么这次的accept函数错误有点严重呀 if (errno != EINTR && errno != EAGAIN ) LOG_NOTICE("[%s]accept failed, fd=%d, %m", name, netfd); //如果错误是因为EMFILE达到了进程可打开的最大文件描述符 //如果错误是因为ENFILE达到了系统可打开的最大文件描述符 if(errno == EMFILE || errno == ENFILE) LOG_NOTICE("max fds reached,rest all,%m"); break; } CClientAsync* async = new CClientAsync(owerThread, newfd); if(async->Attach() == -1) { delete async; } }
2.对于客户端连接有数据到来的可读事件:只需要指定一个缓冲区读就行了。如果没有一次性将内核缓冲区内的数据读完,那么下次epoll_wait返回以后继续读就完事了。
char buf[4096]; int len = recv(netfd,buf,sizeof(buf),0); if(len == 0){ LOG_ERROR("peer close [%s:%d] fd=%d",m_peerAddr,m_peerPort,netfd); errorProcess(SRC_INPUT); return; } else if(len < 0){ errorProcess(SRC_INPUT); return; }
3.对于可写事件:因为采用的是非阻塞的方式,大部分时候内核缓冲区都是空的,即可写事件一直都会发生。因此对于可写事件需要做一个类似水龙头开关的设计,如果有水(即用户态缓冲区有数据需要发送),那么打开水龙头(向epoll注册监听可写事件),将水放干,关闭水龙头(向epoll解除可写事件的监听)
void CClientAsync::OutputNotify (void) { int sendLen = send(netfd,m_outBuf.c_str(),m_outBuf.length(),0); if(0 > sendLen){ { LOG_ERROR("errno=%u EAGIN addr [%s:%d],buflen=%u %m",errno,m_peerAddr,m_peerPort,m_outBuf.length()); return; } errorProcess(SRC_OUTPUT); return; } m_outBuf.erase(0,sendLen); if(m_outBuf.length() == 0){ DisableOutput(); ApplyEvents(); } }
四、服务器优化
1.子线程空转浪费cpu资源问题
Logic进程的子线程主要是Load档操作,加锁从队列里面拿到任务然后工作。如果队列为空,那么释放锁然后调用usleep(1000)睡眠1毫秒。
dbwriter进程的子线程主要是调用数据库缓冲层的阻塞或者慢操作将数据持久化。同样也是加锁从队列里面拿到任务然后工作,如果队列为空,同样睡眠1毫秒。
transit进程的子线程主要是调用阻塞调用http接口等待校验返回,同样也是队列为空以后睡眠1毫秒。
为了避免这些子线程多次无意义的加锁释放锁,引入条件变量即可:
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_cond_wait(),
pthread_cond_timedwait(),
pthread_cond_signal(),
pthread_cond_broadcast(),
pthread_cond_destroy()
子线程加锁以后,判断队列是否为空,如果为空,那么释放锁阻塞在条件变量处等待主线程往队列里面添加任务后再唤醒子线程。
2.dbwrter进程的优化
dbwriter进程是异步存档用的。Logic逻辑进程产生数据以后会将数据发送给dbwriter进程的存档队列里面,dbwriter进程定时从队列里面取数据然后调用数据库缓存层的接口将数据再转交给数据库缓存层。
这里的问题是,如果某个时间段dbwriter进程的存档队列发生了堆积(可能产生的原因是数据库压力过大,或者Logic进程产生数据过快)。这个时候恰好遇到版本更新需要杀掉进程,那么就会导致这些玩家需要存档的数据丢失。
解决这个问题的办法就是利用信号,之前杀掉进程利用的是kill -9 Pid,现在发送kill -USR2 Pid自定义信号,dbwriter捕获自定义信号后等待队列为空以后再退出进程。
3.transit校验进程的优化
transit进程的作用是后台服务器对玩家账户的再一次校验。由于特殊的原因是通过Http请求向第三方请求校验结果。所以transit进程的工作很简单,收到Logic进程转发过来需要校验的玩家数据包以后发起一次Http请求并阻塞等待结果,然后将结果返回给Logic进程。
一开始的transit进程采用的是单线程处理,一次Http请求的时延大概是20ms~100ms之间。也就是说一秒钟最多处理的请求量也就是10~50次。这远远不够呀,一开始游戏上线就遭遇瓶颈了,大部分玩家点击登录的时候要等待很长时间。于是着手优化此处。
1.采用多线程处理。4核cpu采用4个子线程+1个主线程处理,这样就将一秒钟能处理的请求量提升了4倍至40~200次。但是只是单纯地用多个线程去处理,依旧会有瓶颈的存在。于是就搭配了第二种办法。
2.增加校验缓存。如果玩家已经走过一遍登录校验流程,在短时间内重复登录的时候,其实已经完全没有必要再走一遍向第三方请求校验的流程了。因此增加一层缓存层,可以完全解决瓶颈问题。
Transit进程的子线程为了尽可能简单,所以只负责从缓存查找是否命中以及向Http请求结果。将结果发送的操作还是交给主线程去完成。这一套下来,多了3个锁的数据成员和3个队列。
4.定时器实现的优化
Logic进程的定时器设计是开了一个专门的定时器线程,然后定时器线程和主线程之间建立了一个TCP连接。定时器线程在循环里面一直调用select()超时返回以后给主线程发送一个空数据包,主线程收到数据包以后做定时操作。
这个蛋疼的定时器设计问题有二个:
一、新开了一个线程不断循环,浪费了CPU资源。
二、利用TCP连接发包的形式通知主线程定时触发。虽然select()函数的精度可以达到微秒级别,但是引入了TCP/IP,单单是TCP的Nagle特性就足以让定时器的精度难以确定了。更别说网络传输之间的传输延迟了。
这一层利用TCP其实也是为了将定时器纳入到主线程的Epoll体系里面。但是要将定时器的时间概念纳入到Epoll体系里面已经有一个更好的实现了就是Timerfd系列:
timerfd_create, timerfd_settime, timerfd_gettime - timers that notify via file descriptors
Timerfd的时间精度可以达到纳秒级别,将定时器的实现改为Timerfd以后。可以减少一个线程带来的CPU浪费(几百个服就是几百个线程了),省去了网络传输的延迟,定时器的准度更加可控。
Timerfd系列的实现是2008年Linux内核发布版本v2.6.25以后才有的。
当时实现这套定时器功能的时候Timerfd根本就还没有,这是一套远古级别的腾讯代码流传至今。