面向對象使用的輕量化高併發Linux c++網絡庫kikilib

目錄

一、概述

二、使用

三、實現

1、框架

2、Socket

3、定時器

4、線程池

5、讀寫緩衝區

6、日誌

7、對象池

四、測試

五、遇到的問題mark

六、後續


一、概述

       

       kikilib網絡庫是輕量,高性能,純c++11,更符合OOP語言特點且易於使用的一個Linux服務器網絡庫。併發模型使用的是Reactor模型+非阻塞IO,堅持One Loop One Thread,使用Round Robin派發新連接。

       什麼是面向對象使用的網絡庫呢?

       之前我們使用的網絡庫一般都是寫一個回調函數,然後set_callback進網絡庫中,並配合context上下文指針使用。但是在c++中,爲何不直接寫一個類作爲回調函數和上下文成員共同的載體呢,其中上下文的內容作爲private成員(這也是符合語義的),回調函數作爲類的成員函數,這樣做也更符合C++這個OOP語言的特點,何樂而不爲。基於這個想法,我寫了這個網絡庫。

       Github源碼地址: https://github.com/YukangLiu/kikilib

       老哥們順便去給個star唄~

 

二、使用

       這個網絡庫的使用非常簡單,只需要實現一個EventService子類就可以了。以我的echo爲例:       

    class EchoService : public kikilib::EventService
	{
	public:
		EchoService(kikilib::Socket sock, kikilib::EventManager* evMgr)
			: EventService(sock, evMgr)
		{ };

		~EchoService() {};

		void handleReadEvent()
		{
			std::string str = readAll();
			sendContent(std::move(str));
			forceClose();
		};
	};

	int main()
	{
		kikilib::EventMaster<EchoService> evMaster;
		evMaster.init(4, 80);
		evMaster.loop();
		return 0;
	}

       從頭到尾都不需要設置回調函數和對應一個連接的上下文指針。

       使用這個網絡庫的核心是一個EventService類,提供了一些供用戶使用的API,還有幾個處理各種事件的虛函數。這個類可以理解成“爲一個連接中的各種事件服務”的類,用戶繼承這個類,上下文記錄信息作爲該類的私有成員,實現要處理的事件的處理函數即可。

       這裏就會出現一個問題,網絡庫如何實例化用戶的這個對象呢?有兩種方法,一是使用模板,二是使用工廠。這裏我選擇了將兩種方式融合,即用戶只需要將自己實現的具體EventService子類放在EventMaster的模板中就可以使用了,而在網絡庫內部是用工廠生產對象的。這樣做的原因如下:第一,如果讓用戶再去實例化一個工廠那使用起來太麻煩了,這個是主要原因。第二,那爲何還要加入工廠呢,因爲將生產對象這個動作剝離出來的話,可以讓類的職責更加分明,未來需要加入EventService對象池的話可以嵌到工廠中。

       具體的使用方法可以參看http和chatroom,分別是我用這個庫實現的一個簡單的靜態網頁服務器和一個聊天室(廣播)服務器。

       http的測試站點:http://www.liuyukang.com/

 

三、實現

1、框架

       模型如下:

 

       大體上可以說是Reactor模型+非阻塞IO,One Loop One Thread,使用Round Robin派發新連接。實現這些主要依賴以下一些類:EventService,EventEpoller,EventManager,EventMaster。

       類圖如下:

       用戶繼承EventService實現UsrLmolEventService即可。框架主要是以下幾個類:

(1)EventService

       這個類如它的名字,是一個爲事件服務的類,用戶需要繼承這個類,實現處理事件的方法。當一個連接到來時,網絡庫會實例化一個該類對象,爲這個連接上的事件服務。

       這個類還有一個重要身份,那就是API集合,它封裝了供用戶使用的網絡庫接口,以便處理連接上的各種事件。它提供自身socket的操作API,自身事件相關的操作API,定時器相關的操作API,線程池工具的操作API,socket緩衝區的讀寫操作API。

 

(2)EventEpoller

       該類功能很簡單,一個是監視epoll中是否有事件發生,一個是向epoll中添加、修改、刪除監視的fd。值得注意的是,該類並不存儲事件服務對象實體,也不維護任何事件對象實體的生命期,這個工作是EventManager做的。

       這裏的epoll用的LT模式,原因如下:

       read事件到來時,若server的業務並不會每次readall並進行及時處理,那麼,如果遭遇client瘋狂發送巨大包體,ET模式必須每次將內容讀進內存,而server不及時處理就會導致內容堆積,內存爆滿,使用LT不會出現這個問題。

 

(3)EventManager

       該類是事件服務對象實體的管理器,擁有一個EventEpoller和一個Timer定時器對象,提供插入事件,移除,修改事件的接口(對EventEpoller接口的封裝),提供定時器的使用接口(對Timer接口的封裝)。維護所有事件服務對象實體的生命期,維護定時器和EventEpoller的生命期。

       該類主要就是在Loop函數中創建了一個線程,然後使用EventEpoller循環掃描其管理的事件,若有激活的對象,則首先會按照優先級放到不同隊列中,然後根據事件優先級先後處理事件——根據事件類型調用其相關函數。處理完所有的事件,最後會銷燬被要求銷燬的事件服務對象實體。

 

(4)EventMaster

       該類有三個職責:

       一是循環監聽端口,當有一個新連接到來時,首先使用用戶實現的工廠實例化事件服務對象,然後調用其HandleConnectionEvent()函數,若該連接沒有被關閉,則Round Robin將其插入到一個EventManager中,讓該EventManager一直循環監視其事件。這樣有一個好處,就是不會發生“驚羣”現象,因爲只在EventMaster這一個線程上進行了accept。這裏還有一個問題,就是當使用的fd到達上限,即服務器無法接收新連接的時候,需要close掉那些新來的連接,顯示地告訴客戶端服務器不能再接收連接了。有兩種做法:一是程序啓動時會保留一個fd, 當fd分發滿了時,就把這個保留的fd close掉然後給新的,然後立刻把新的close掉;二是設置fd上限(小於系統設置的上限值),超過了就把該連接close掉。本網絡庫採用的第二種方法,因爲第一種存在競態,測試結果不太好,故使用了第二種。每次來了一個新連接會先判斷fd是不是大於上限值,大於了就close。

       二是管理EventManager生命週期,負責EventManager的創建與銷燬。

       三是負責線程池工具實體的創建與銷燬,這裏還有另外一層含義,即線程池工具可以理解爲是全局唯一的,因爲它僅僅在EventMaster這個主線程中創建,並且不會再增加。

 

2、Socket

       生命期的管理一直都是一個需要考慮的重點,以內存泄漏爲例,當有高併發+ 5x24h運轉的需求時,一點點的內存泄露很容易就會積累,所以在這個網絡庫中,處處強調每個類的生命應該由誰負責。

       設計這個類的初衷也不例外,是爲了管理fd的生命期,防止串話。

       這個類中封裝了一個fd,一個引用計數,一個ip字符串,一個端口號,發生拷貝構造和轉移構造時會將引用計數+1,析構時會將引用計數-1,爲0時會調用::close(fd)。

       同時這個類封裝了一些fd的操作,供用戶使用,當然,用戶更多時候還是應該調用EventService中更高層的封裝。

 

3、定時器

       定時器主要使用的linux的timerfd_create創建的時鐘fd配合一棵紅黑樹實現。每個EventManager中都有一個定時器對象。沒有用優先隊列(小根堆)的原因是考慮到未來可能會有remove定時器任務的需求,這個需求用優先隊列實現比較麻煩。

       這裏的紅黑樹(用的std::map<Time,std::function<void()>>)中存放的是時間(任務要執行的時刻)和任務函數的映射,這裏有一個隱含信息,定時器中存放的不是事件服務,而是任務函數。那麼,雖然Time的精度是微秒,很難有相同的,但是如果確實遇到了相同的如何解決衝突呢?此處會將新的定時器事件設置的時間+1us,如果還有衝突,繼續如此。這種解決衝突的方法簡單粗暴有效。

       首先,程序初始化時會timerfd_create一個timefd,然後由EventManager將該fd放進epoll中,當有地方調用RunAt函數時候,會先將新來的任務函數插入到紅黑樹std::map<Time,std::function<void()>>中,然後判斷它是不是最近的任務,如果是的話調用timerfd_settime更新事件。

       當epoll_wait檢測到定時器事件的時候,會執行TimerEventService(EventService的子類對象)中的handleEvent函數,這個函數實際調用的是定時器的RunExpired函數,該函數會執行當前所有超時的任務函數,方法是每次取紅黑樹的最小元素與當前時間比較,到時間了就執行。另外,定時器事件在該網絡庫中設置的優先級是最高的。

       定時器的實現最開始還有另一個更簡單的方案,就是每個EventManager中的Loop中的epoll_wait的timeout時間間隔很小,每次循環檢查定時器的紅黑樹,超時就執行。兩種方法在高併發的場景下其實區別不大,但是在併發量不大的時候,因爲這種方法的epoll_wait時間間隔很小,相當於一直在Loop,很耗電。所以最後選擇了庫中實現的方法。

       由於定時器類型只有RunAt一個接口,所以EventManager另外對其封裝了三個接口:RunAfter(),RunEvery(),RunEveryUntil()。

       分別是time時間後執行;每過time時間執行;每過time時間執行,直到條件不滿足停止。

 

4、線程池

       使用的雙緩衝隊列,一條隊列給生產者生產,一條給線程池消費。提供一個enqueue接口,用來將任務函數放入線程池中。

       實現線程池的目的主要是爲了讓用戶可以配合定時器將幾乎所有會阻塞的任務異步執行。具體做法下面以數據庫的讀寫舉例:

       有一個數據庫讀函數ReadDB(),一個數據庫寫函數WriteDB()。
       寫數據庫任務:這個比較簡單,因爲不需要返回值,直接將WriteDB放進線程池中就可以了。

       讀數據庫任務:這個需要配合定時器,首先在用戶事件服務類中設置一個用於接收數據庫數據的對象(可以是緩衝區),然後將ReadDB放進線程池,然後設置一個時間t,調用RunEveryUntil接口,讓定時器過t毫秒後檢查這個對象是否已經被ReadDB成功修改,若有,即可處理這個ReadDB產生的數據,若沒有,則繼續檢查。

 

5、讀寫緩衝區

       庫中對讀寫緩衝區進行了更高層的封裝:SocketReader,SocketWritter。這樣做的原因主要是職責更明細,實現起來更容易,使用起來也更方便。

       SocketReader:維護一個vector<char>作爲緩衝區,維護一個左邊界下標,一個右邊界下標,每次read時,會先判斷當前緩衝區是否有足夠用戶需求的數據量,若沒有,則按用戶需求讀取指定個數內容,然後返回給用戶。當緩衝區左邊一塊區域都是已讀數據的時候,會把後面的內容move到前面,具體什麼時候move,可以在Parameter文件中設置當左邊已讀數據佔緩衝區的多少時就會把後面的內容move到前面。注意,這裏每次讀事件到來時網絡庫本身一般只會調用一次read函數,以提高效率。

       SocketWritter:會直接嘗試send,send後若還有數據沒成功發出去,就把剩下內容緩存起來,並關注寫事件,下次可寫時再繼續寫。

 

6、日誌

       日誌系統分爲前端和後端,前端爲生產者,負責將日誌信息寫進日誌系統,後端爲消費者,將日誌信息寫進磁盤。Kikilib的日誌系統用的環形隊列,可在參數文件中設置環形隊列的長度(需要是2的n次冪),當隊列滿了會放棄當前日誌消息(因爲這時候如陳碩老師所說,早期的日誌信息更加有價值),當消息全部寫進了日誌文件,後端會阻塞,有消息時會喚醒。值得一提的是,這裏使用了disruptor的思想,即隔離生產者與消費者,最終實現lock free的方法。因爲是lock free的,測試得到效率比原來的雙緩存隊列要快一倍左右。

       磁盤中的日誌文件會有兩個,Log.txt0,Log.txt1,當一個文件寫滿(參數設定的最大佔用磁盤大小)了之後,會丟棄掉另一個文件,重寫寫另一個文件。這裏的0,1並不代表新舊關係。在日誌文件的第一行會記錄當前日誌文件是服務器本次開機運行至今記錄的第n個文件。

 

7、對象池

       對象池可以爲用戶使用,在庫中主要用在EventService的示例的創建上。

       對象池創建對象時,首先會從內存池中取出相應大小的塊,內存池是與對象大小強相關的,其中有一個空閒鏈表,每次分配空間都從空閒鏈表上取,若空閒鏈表沒有內容時,首先會分配(40 + 分配次數)* 對象大小的空間,然後分成一個個塊掛在空閒鏈表上,這裏空閒鏈表節點沒有使用額外的空間:效仿的stl的二級配置器中的方法,將數據和next指針放在了一個union中。從內存池取出所需內存塊後,會判斷對象是否擁有non-trivial構造函數,沒有的話直接返回,有的話使用placement new構造對象。

       銷燬對象則是先判斷對象是否擁有non-trivial析構函數,若有,則調用其析構函數,然後將內存塊掛在內存池的空閒鏈表上,沒有則直接掛到內存池的空閒鏈表上。

 

四、測試

       首先對函數功能進行了測試,可以參看test文件夾的工程,加上http和chatroom已經將所有函數都使用上了。

       其次對http進行了壓力測試,輕鬆抗住10000client,四核3.7GHz機器,QPS兩萬多(這裏的http實現了方法解析,並判斷了文件的有效性,最後讀取文件併發送):

       對echo進行qps測試,qps四萬左右:

 

五、遇到的問題mark

1、日誌寫爆磁盤導致core dump

       加入兩個日誌文件,估算已寫大小,滿了覆蓋重寫。

 

2、在雲服務器上bind不成功

       雲服務器的公網ip不是本機ip,不能顯示綁定,換用了INANY_ADDR。

 

3、自旋的空while會被編譯器優化掉

       在while裏面加上一個volatile變量。

 

4、多個線程都會訪問的同一個變量一定一定一定要加volatile!!!!血的教訓呀,因爲編譯器會做優化,認爲這個值在這個函數中沒有被修改就認爲這個值在當前函數中不變,殊不知值已經被其它線程修改了,今天被這個坑慘了,調了一晚上這個bug才找到問題。

 

六、後續

       老哥們有什麼問題,bug,需求,都歡迎加我微信liuyukang315反饋,畢竟一個人很難做到面面俱到~

       mark一下下個版本V0.03想改的地方(√表示已完成),持續更新:

1、加入對象池。√

2、加入負載均衡策略,每次的新連接派發給任務最少的EventManager。√

3、持續debug和調優。

4、增加性能測試報告。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章