前言:
停更了一段時間。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根本就還沒有,這是一套遠古級別的騰訊代碼流傳至今。