linux c++11高性能協程庫netco

目錄

 

一、開源協程庫調研

1、golang語言自帶協程

2、雲風的coroutine協程庫

3、騰訊的libco協程庫

4、魅族的libgo協程庫

二、netco協程庫概述

三、netco的實現

1、框架

2、Context

3、Coroutine

4、對象池

5、Epoller

6、Timer

7、Processor

8、Scheduler

9、netco_api

四、使用

五、後續


一、開源協程庫調研

1、golang語言自帶協程

       Golang的協程是對稱協程,調度器使用了GMP模型。使用go語言寫一個併發程序極其簡單,例如go func(x,y)即可併發執行一個函數f(x,y),這也是netco的目標:只需要調用接口co_go(func)即可併發執行func函數。

       Golang還有一個通道的概念,不同的協程可以往通道中寫內容讀內容。

當然,重點還是GMP模型,其中G代表的是goroutine協程,Go1.11中協程棧默認是2KB。M代表的是machine,對應的是一個線程。P代表的是processor,當P有任務時需要創建或者喚醒一個系統線程來執行它隊列裏的任務。所以P/M需要進行綁定,構成一個執行單元。

       首先創建一個G對象,G對象保存到P本地隊列或者是全局隊列。P此時去喚醒一個M。P繼續執行它的執行序。M尋找是否有空閒的P,如果有則將該G對象移動到它本身。接下來M執行一個調度循環(調用G對象->執行->清理線程→繼續找新的Goroutine執行)。

       M執行過程中,隨時會發生上下文切換。當發生上下文切換時,需要對執行現場進行保護,以便下次被調度執行時進行現場恢復。Go調度器M的棧保存在G對象上,只需要將M所需要的寄存器(SP、PC等)保存到G對象上就可以實現現場保護。當這些寄存器數據被保護起來,就隨時可以做上下文切換了,在中斷之前把現場保存起來。如果此時G任務還沒有執行完,M可以將任務重新丟到P的任務隊列,等待下一次被調度執行。

 

2、雲風的coroutine協程庫

       風雲的協程庫是使用C語言實現的使用共享棧的一個非對稱協程庫,即調用者和被調用者的關係是固定的,協程A調用B,則B完成後必定返回到A。因爲雲風不希望使用他的協程庫的人太考慮棧的大小,並且認爲,進行上下文切換的大多時候,棧的使用實際並不大,所以使用共享棧每次進行上下文切換時拷貝的開銷其實可以接受。另外,該庫的上下文切換使用的是glibc的ucontext。

 

3、騰訊的libco協程庫

       騰訊的libco協程庫是一個非對稱的協程庫,結合了epoll機制,其接口風格類似pthread,使用起來實際上已經有了使用線程的感覺。其棧空間(Separate coroutine stacks)的固定大小爲128K,也可以使用共享棧(Copying the stack),但默認還是使用固定的棧空間。libco算是給了我極其之大的震撼,因爲還是第一次看到結合epoll和hook系統調用的技術,有些歎爲觀止。另外,該庫是自己使用匯編寫的上下文切換方法。

 

4、魅族的libgo協程庫

       libgo是一個go風格的c++11對稱協程庫,它的命名結構分爲Scheduler,Processer和Task(協程),schedule 負責整個系統的協程調度,協程的運行依賴於執行器 Processer(簡稱 P),因此在調度器初始化的時候會選擇創建 P 的數量(支持動態增長),所有的執行器會添加到雙端隊列中。該庫的命名結構很有意思,所以我在我的netco也採用了類似的命名:Scheduler->Processor->Coroutine。另外,該庫使用的是boost庫的上下文切換方法。

 

二、netco協程庫概述

       基於對上述協程庫的調研,我寫了netco協程庫,它是一個線程風格的純C++11對稱協程庫,並且可以用於高併發網絡編程。

       在使用上,受golang的影響很大,所以我儘可能地減少使用接口,讓使用更加輕便簡潔。目前和協程相關的接口只有三個:co_go(func),運行一個協程,co_wait(time)等待time毫秒後繼續執行當前協程,co_join()等待協程運行結束。

       對於上下文切換,我使用的是glibc的ucontext,這個上下文切換方法有一個缺點,就是執行了一次系統調用,有一定的性能損失,但是因爲對各種機器的瞭解不足,我還是決定使用成熟的上下文切換方案,以屏蔽機器的差異。

       對於棧空間,使用的是Separate coroutine stacks,默認爲8K大小,使用co_go接口時候可指定當前協程棧的大小,也可以在parameter.h中修改默認的協程棧大小重新編譯。沒有使用共享棧主要是爲了性能的考慮。

       源碼地址:https://github.com/YukangLiu/netco 。可以點顆star~

 

三、netco的實現

1、框架

                                     

       模型框架如上圖,netco會根據計算機的核心數開對應的線程數運行協程,其中每一個線程對應一個Processor實例,協程Coroutine實例運行在Processor的主循環中,Processor使用epoll和定時器timer進行任務調度。而Scheduler則並不存在一個循環,它是一個全局單例,當某個線程中調用co_go()運行一個新協程後,實際會調用該實例的方法,選擇一個協程最少的Processor接管新的協程,當然,用戶也可以指定具體某一個Processor來接管新的協程。

       類圖如下:

 

2、Context

       這裏的context類封裝了ucontext上下文切換的一些操作,使所有其他需要使用上下文切換的地方都使用Context類而不去使用被封裝的ucontext,目的是將來想用自己寫的上下文切換或者其他庫的上下文切換方法的時候,只需要實現該類中的方法即可,而不需要修改netco中的其他部分。

 

3、Coroutine

       協程對象,主要實現協程的幾個關鍵方法:resume,yield,實際真正的yield由Processor執行,這裏的yield只是修改當前協程的狀態。

       當然,用戶是無法感知到Coroutine的,因爲其只是更高層封裝的組件。

 

4、對象池

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

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

5、Epoller

       該類功能很簡單,一個是監視epoll中是否有事件發生,一個是向epoll中添加、修改、刪除監視的fd。值得注意的是,該類並不存儲任何協程對象實體,也不維護任何協程對象實體的生命期。另外,該類使用的是LT。

 

6、Timer

       定時器主要使用的linux的timerfd_create創建的時鐘fd配合一個優先隊列(小根堆)實現的,原因是要求效率而沒有移除協程的需求。

        這裏的小根堆中存放的是時間(任務要執行的時刻)和協程對象的pair。

       首先,程序初始化時會timerfd_create一個timefd,然後將該fd放進epoll中,當有地方調用RunAt或RunAfter函數時候,會先將新來的任務函數插入到小根堆中,然後判斷它是不是最近的任務,如果是的話調用timerfd_settime更新時間。

       若出現超時時間,則epoll_wait必然會跳出阻塞,而在Processor的主循環中,第一個處理的就是超時事件,方法就是與當前時間對比並取出小根堆中的協程,直到小根堆中所有任務的時間都比當前大,另外,取出來的協程會放在一個數組中,用於在Processor循環中執行。

       定時器還有另外一個功能,就是喚醒epoll_wait,當有新的協程加入時,實際就是通過定時器來喚醒的processor主循環,並執行新接受的協程。

 

7、Processor

       Processor意爲處理器,對應一個CPU的核心,在netco中即對應一個線程。Processor負責存放協程Coroutine的實體並管理其生命期。更重要地,Processor中存在以下幾個隊列:

       (1)newCoroutines_新協程雙緩衝隊列。使用一個隊列來存放新來的協程,另一個隊列給Processor主循環用於執行新來的協程,消費完後就交換隊列。這裏每加入一個新協程就會喚醒一次Processor主循環,以立即執行新來的協程。

       (2)actCoroutines_被epoll激活的協程隊列。當epoll_wait被激活時,Processor主循環會嘗試從Epoller中獲取活躍的協程,存放在actCoroutine隊列中,然後依次恢復執行。

       (3)timerExpiredCo_超時的協程隊列。當epoll_wait被激活時,Processor主循環會首先嚐試從Timer中獲取活躍的協程,存放在timerExpiredCo隊列中,然後依次恢復執行。

       (4)removedCo_被移除的協程隊列。執行完的協程首先會放在該隊列中,在Processor主循環的最後一次性統一清理。

至於Processor主循環的執行序,首先執行超時的協程,因爲這個對時間要求是最敏感的,其次執行新接管的協程,然後執行epoller中被激活的協程,最後清理上述removedCo中的協程。

 

8、Scheduler

       Scheduler意爲調度器,這裏的調度並非OS中常說的調度(即決定當前執行哪個進程,實際這個工作是在Processor中做的),而是指協程應該運行在哪個計算機核心(線程,或者說Processor)上,netco中的該類爲全局單例,所執行的調度也相對比較簡單,其可以讓用戶指定協程運行在某個Processor上,若用戶沒有指定,則挑選協程數量最少的Processor接管新的協程。

       在libgo中,scheduler還有一個steal的操作,可以將一個協程從一個Processor中偷到另一個Processor中,因爲其Processor的主循環是允許阻塞的,並且協程的運行完全由庫決定。而netco可以讓用戶指定某個協程一直運行在某個Processor上,故沒有實現該功能,未來性能若因爲這個而出現瓶頸時再實現該功能。

 

9、netco_api

       雖然netco是c++11實現的協程庫,但是爲了使用盡量簡單,將Scheduler進一步地封裝成了函數接口而不是一個對象,所以只需要包含netco_api.h,即可調用netco函數風格的協程接口,而無需關心任何庫中的對象。

 

四、使用

       簡單的使用測試,寫了個回覆helloworld的程序:

int main()
{
	netco::Socket listener;
	if (listener.isUseful())
	{
		listener.setTcpNoDelay(true);
		listener.setReuseAddr(true);
		listener.setReusePort(true);
		listener.setBlockSocket();
		if (listener.bind(80) < 0)
		{
			return;
		}
		listener.listen();
	}
	while (1)
	{
		netco::Socket conn(listener.accept());
		conn.setTcpNoDelay(true);
                //accept成功就創建一個協程發送hello
		netco::co_go(
			[conn]
			{
				std::string hello("HTTP/1.0 200 OK\r\nServer: netco/0.1.0\r\nContent-Length: 72\r\nContent-Type: text/html\r\n\r\n<HTML><TITLE>hello</TITLE>\r\n<BODY><P>hello word!\r\n</BODY></HTML>\r\n");
				char buf[1024];
				if (netco::co_read(conn.fd(), buf, 1024) > 0)
				{
					send(conn.fd(), hello.c_str(), hello.size(), MSG_NOSIGNAL);
					netco::co_wait(50);//需要等一下,否則還沒發送完畢就關閉了
				}
			}
			);
	}
    return 0;
}

       測試環境:4核CPU3.70GHz,8G內存3200MHz。

 

五、後續

       hook系統調用,現在如read還是以api形式給出等的,有待hook系統調用。

 

       另外有其它問題或bug可以聯繫[email protected]

發佈了13 篇原創文章 · 獲贊 3 · 訪問量 1988
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章