影片解析:
1 導論使用 C++ 來編寫高效能的網路伺服器程式,從來都不是件很容易的事情。在沒有應用任何網路框架,從 epoll/kqueue 直接碼起的時候尤其如此。即便使用 libevent, libev這樣事件驅動的網路框架去構建你的服務,程式結構依然不會很簡單。為何會這樣?因為這類框架提供的都是非阻塞式的、非同步的程式設計介面,非同步的程式設計方式,這需要思維方式的轉變。為什麼 golang 近幾年能夠大規模流行起來呢?因為簡單。這方面最突出的一點便是它的網路程式設計 API,完全同步阻塞式的介面。要併發?go 出一個協程就好了。相信對於很多人來說,最開始接觸這種程式設計方式,是有點困惑的。程式中到處都是同步阻塞式的呼叫,這程式效能能好嗎?答案是,好,而且非常好。那麼 golang 是如何做到的呢?秘訣就在它這個協程機制裡。在 go 語言的 API 裡,你找不到像 epoll/kqueue 之類的 I/O 多路複用(I/O multiplexing)介面,那它是怎麼做到輕鬆支援數萬乃至十多萬高併發的網路 IO 的呢?在 Linux 或其他類 Unix 系統裡,支援 I/O 多路複用事件通知的系統呼叫(System Call)不外乎epoll/kqueue,它難道可以離開這些系統介面另起爐灶?這個自然是不可能的。聰明的讀者,應該大致想到了這背後是怎麼個原理了。語言內建的協程併發模式,同步阻塞式的 IO 介面,使得 golang 網路程式設計十分容易。那麼 C++ 可不可以做到這樣呢?本文要介紹的開源協程庫 libco,就是這樣神奇的一個開源庫,讓你的高效能網路伺服器程式設計不再困難。Libco 是微信後臺大規模使用的 C++ 協程庫,在 2013 年的時候作為騰訊六大開源專案首次開源。據說 2013 年至今穩定執行在微信後臺的數萬臺機器上。從本屆ArchSummit 北京峰會來自騰訊內部的分享經驗來看,它在騰訊內部使用確實是比較廣泛的。同 go 語言一樣,libco 也是提供了同步風格程式設計模式,同時還能保證系統的高併發能力。
2 準備知識2.1 協程(Coroutine)是什麼?協程這個概念,最近這幾年可是相當地流行了。尤其 go 語言問世之後,內建的協程特性,完全遮蔽了作業系統執行緒的複雜細節;甚至使 go 開發者“只知有協程,不知有執行緒”了。當然 C++, Java 也不甘落後,如果你有關注過 C++ 語言的最新動態,可能也會注意到近幾年不斷有人在給 C++ 標準委會提協程的支援方案;Java 也同樣有一些試驗性的解決方案在提出來。在 go 語言大行其道的今天,沒聽說過協程這個詞的程式設計師應該很少了,甚至直接接觸過協程程式設計的(golang, lua, python 等)也在少數。你可能以為這是個比較新的東西,但其實協程這個概念在計算機領域已經相當地古老了。早在七十年代,Donald Knuth 在他的神作 The Art of Computer Programming 中將 Coroutine 的提出者於 Conway Melvin。同時,Knuth 還提到,coroutines 不過是一種特殊的 subroutines(Subroutine 即過程呼叫,在很多高階語言中也叫函式,為了方便起見,下文我們將它稱為“函式”)。當呼叫一個函式時,程式從函式的頭部開始執行,當函式退出時,這個函式的宣告週期也就結束了。一個函式在它生命週期中,只可能返回一次。而協程則不同,協程在執行過程中,可以呼叫別的協程自己則中途退出執行,之後又從呼叫別的協程的地方恢復執行。這有點像作業系統的執行緒,執行過程中可能被掛起,讓位於別的執行緒執行,稍後又從掛起的地方恢復執行。在這個過程中,協程與協程之間實際上不是普通“呼叫者與被調者”的關係,他們之間的關係是對稱的(symmetric)。實際上,協程不一定都是種對稱的關係,還存在著一種非對稱的協程模式(asymmetric coroutines)。非對稱協程其實也比較常見,本文要介紹的 libco 其實就是一種非對稱協程,Boost C++ 庫也提供了非對稱協程。具體來講,非對稱協程(asymmetric coroutines)是跟一個特定的呼叫者繫結的,協程讓出 CPU 時,只能讓回給原呼叫者。那到底是什麼東西“不對稱”呢?其實,非對稱在於程式控制流轉移到被調協程時使用的是 call/resume 操作,而當被調協程讓出 CPU時使用的卻是 return/yield 操作。此外,協程間的地位也不對等,caller 與 callee 關係是確定的,不可更改的,非對稱協程只能返回最初呼叫它的協程。對稱協程(symmetric coroutines)則不一樣,啟動之後就跟啟動之前的協程沒有任何關係了。協程的切換操作,一般而言只有一個操作,yield,用於將程式控制流轉移給另外的協程。對稱協程機制一般需要一個排程器的支援,按一定排程演算法去選擇 yield的目標協程。Go 語言提供的協程,其實就是典型的對稱協程。不但對稱,goroutines 還可以在多個執行緒上遷移。這種協程跟作業系統中的執行緒非常相似,甚至可以叫做“使用者級執行緒”了。而 libco 提供的協程,雖然程式設計介面跟 pthread 有點類似,“類 pthread 的介面設計”,“如執行緒庫一樣輕鬆”,本質上卻是一種非對稱協程。這一點不要被表象蒙了。事實上,libco 內部還為儲存協程的呼叫鏈留了一個 stack 結構,而這個 stack 大小隻有固定的 128。使用 libco,如果不斷地在一個協程執行過程中啟動另一個協程,隨著巢狀深度增加就可能會造成這個棧空間溢位。
3 Libco 使用簡介3.1 一個簡單的例子在多執行緒程式設計教程中,有一個經典的例子:生產者消費者問題。事實上,生產者消費者問題也是最適合協程的應用場景。那麼我們就從這個簡單的例子入手,來看一看使用 libco 編寫的生產者消費者程式(例程程式碼來自於 libco 原始碼包)
建立和啟動生產者消費者協程
struct stTask_t { int id; }; struct stEnv_t { stCoCond_t* cond; queue<stTask_t*> task_queue; }; void* Producer(void* args) { co_enable_hook_sys(); stEnv_t* env = (stEnv_t*)args; int id = 0; while (true) { stTask_t* task = (stTask_t*)calloc(1, sizeof(stTask_t)); task−>id = id++; env−>task_queue.push(task); printf("%s:%d produce task %d\n", __func__, __LINE__, task−>id); co_cond_signal(env−>cond); poll(NULL, 0, 1000); } return NULL; } void* Consumer(void* args) { co_enable_hook_sys(); stEnv_t* env = (stEnv_t*)args; while (true) { if (env−>task_queue.empty()) { co_cond_timedwait(env−>cond, −1); continue; } stTask_t* task = env−>task_queue.front(); env−>task_queue.pop(); printf("%s:%d consume task %d\n", __func__, __LINE__, task−>id); free(task); } return NULL; }
初次接觸 libco 的讀者,應該下載原始碼編譯,親自執行一下這個例子看看輸出結果是什麼。實際上,這個例子的輸出結果跟多執行緒實現方案是相似的,Producer 與Consumer 交替列印生產和消費資訊。再來看程式碼,在 main() 函式中,我們看到代表一個協程的結構叫做 stCoRoutine_t,建立一個協程使用 co_create() 函式。我們注意到,這裡的 co_create() 的介面設計跟pthread 的pthread_create() 是非常相似的。跟 pthread 不太一樣是,創建出一個協程之後,並沒有立即啟動起來;這裡要啟動協程,還需呼叫 co_resume() 函式。最後,pthread 建立執行緒之後主執行緒往往會 pthread_join() 等等子執行緒退出,而這裡的例子沒有“co_join()”或類似的函式,而是呼叫了一個 co_eventloop() 函式,這些差異的原因我們後文會詳細解析。然後再看 Producer 和 Consumer 的實現,細心的讀者可能會發現,無論是 Producer還是 Consumer,它們在操作共享的佇列時都沒有加鎖,沒有互斥保護。那麼這樣做是否安全呢?其實是安全的。在執行這個程式時,我們用 ps 命令會看到這個它實際上只有一個執行緒。因此在任何時刻處理器上只會有一個協程在執行,所以不存在 race conditions,不需要任何互斥保護。還有一個問題。這個程式既然只有一個執行緒,那麼 Producer 與 Consumer 這兩個協程函式是怎樣做到交替執行的呢?如果你熟悉 pthread 和作業系統多執行緒的原理,應該很快能發現程式裡 co_cond_signal()、poll() 和 co_cond_timedwait() 這幾個關鍵點。換作是一個 pthread 編寫的生產者消費者程式,在只有單核 CPU 的機器上執行,結果是不是一樣的?總之,這個例子跟 pthread 實現的生產者消費者程式是非常相似的。透過這個例子,我們也大致對 libco 的協程介面有了初步的瞭解。為了能看懂本文接下來的內容,建議把其他幾個例子的程式碼也都瀏覽一下。下文我們將不再直接列出 libco 例子中的程式碼,如果有引用到,請自行參看相關程式碼。
4. libco的協程透過上一節的例子,我們已經對 libco 中的協程有了初步的印象。我們完全可以把它當做一種使用者態執行緒來看待,接下來我們就從執行緒的角度來開始探究和理解它的實現機制。以 Linux 為例,在作業系統提供的執行緒機制中,一個執行緒一般具備下列要素:(1) 有一段程式供其執行,這個是顯然是必須的。另外,不同執行緒可以共用同一段程式。這個也是顯然的,想想我們程式設計裡經常用到的執行緒池、工作執行緒,不同的工作執行緒可能執行完全一樣的程式碼。(2) 有起碼的“私有財產”,即執行緒專屬的系統堆疊空間。(3) 有“戶口”,作業系統教科書裡叫做“進(線)程控制塊”,英文縮寫叫 PCB。在Linux 核心裡,則為 task_struct 的一個結構體。有了這個資料結構,執行緒才能成為核心排程的一個基本單位接受核心排程。這個結構也記錄著執行緒佔有的各項資源。此外,值得一提的是,作業系統的程序還有自己專屬的記憶體空間(使用者態記憶體空間),不同程序間的記憶體空間是相互獨立,互不干擾的。而同屬一個程序的各執行緒,則是共享記憶體空間的。顯然,協程也是共享記憶體空間的。我們可以借鑑作業系統執行緒的實現思想,在 OS 之上實現使用者級執行緒(協程)。跟OS 執行緒一樣,使用者級執行緒也應該具備這三個要素。所不同的只是第二點,使用者級執行緒(協程)沒有自己專屬的堆空間,只有棧空間。首先,我們得準備一段程式供協程執行,這即是 co_create() 函式在建立協程的時候傳入的第三個引數——形參為 void*,返回值為 void 的一個函式。其次,需要為建立的協程準備一段棧記憶體空間。棧記憶體用於儲存呼叫函式過程中的臨時變數,以及函式呼叫鏈(棧幀)。在 Intel 的 x86 以及 x64 體系結構中,棧頂由ESP(RSP)暫存器確定。所以一個建立一個協程,啟動的時候還要將 ESP(RSP)切到分配的棧記憶體上,後文將對此做詳細分析。co_create() 呼叫成功後,將返回一個 stCoRoutine_t 的結構指標(第一個引數)。從命名上也可以看出來,該結構即代表了 libco 的協程,記錄著一個協程擁有的各種資源,我們不妨稱之為“協程控制塊”。這樣,構成一個協程三要素——執行的函式,棧記憶體,協程控制塊,在 co_create() 呼叫完成後便都準備就緒了。
【文章福利】需要C/C++ Linux伺服器架構師學習資料加群812855908(資料包括C/C++,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等)
5. 關鍵資料結構及其關係libco的協程控制塊stCoRoutine_t
struct stCoRoutine_t { stCoRoutineEnv_t *env; pfn_co_routine_t pfn; void *arg; coctx_t ctx; char cStart; char cEnd; char cIsMain; char cEnableSysHook; char cIsShareStack; void *pvEnv; //char sRunStack[ 1024 * 128 ]; stStackMem_t* stack_mem; //save satck buffer while confilct on same stack_buffer; char* stack_sp; unsigned int save_size; char* save_buffer; stCoSpec_t aSpec[1024]; };
接下來我們逐個來看一下 stCoRoutine_t 結構中的各項成員。首先看第 2 行的 env,協程執行的環境。這裡提一下,不同於 go 語言,libco 的協程一旦建立之後便跟建立時的那個執行緒綁定了的,是不支援在不同執行緒間遷移(migrate)的。這個 env,即同屬於一個執行緒所有協程的執行環境,包括了當前執行協程、上次切換掛起的協程、巢狀呼叫的協程棧,和一個 epoll 的封裝結構(TBD)。第 3、4 行分別為實際待執行的協程函式以及引數。第 5 行,ctx 是一個 coctx_t 型別的結構,用於協程切換時儲存 CPU 上下文(context)的;所謂的上下文,即esp、ebp、eip和其他通用暫存器的值。第 7 至 11 行是一些狀態和標誌變數,意義也很明瞭。第 13 行 pvEnv,名字看起來有點費解,我們暫且知道這是一個用於儲存程式系統環境變數的指標就好了。16 行這個 stack_mem,協程執行時的棧記憶體。透過註釋我們知道這個棧記憶體是固定的 128KB 的大小。我們可以計算一下,每個協程 128K 記憶體,那麼一個程序啟 100 萬個協程則需要佔用高達 122GB的記憶體。讀者大概會懷疑,不是常聽說協程很輕量級嗎,怎麼會佔用這麼多的記憶體?答案就在接下來 19 至 21 行的幾個成員變數中。這裡要提到實現 stackful 協程(與之相對的還有一種 stackless 協程)的兩種技術:Separate coroutine stacks 和 Copying the stack(又叫共享棧)。實現細節上,前者為每一個協程分配一個單獨的、固定大小的棧;而後者則僅為正在執行的協程分配棧記憶體,當協程被排程切換出去時,就把它實際佔用的棧記憶體 copy 儲存到一個單獨分配的緩衝區;當被切出去的協程再次排程執行時,再一次 copy 將原來儲存的棧記憶體恢復到那個共享的、固定大小的棧記憶體空間。通常情況下,一個協程實際佔用的(從 esp 到棧底)棧空間,相比預分配的這個棧大小(比如 libco的 128KB)會小得多;這樣一來,copying stack 的實現方案所佔用的記憶體便會少很多。當然,協程切換時複製記憶體的開銷有些場景下也是很大的。因此兩種方案各有利弊,而libco 則同時實現了兩種方案,預設使用前者,也允許使用者在建立協程時指定使用共享棧。
用於儲存協程執行上下文的 coctx_t 結構
struct coctx_t { #if defined(__i386__) void *regs[8]; #else void *regs[14]; #endif size_t ss_size; char *ss_sp; };
前文還提到,協程控制塊 stCoRoutine_t 結構裡第一個欄位 env,用於儲存協程的執行“環境”。前文也指出,這個結構是跟執行的執行緒綁定了的,執行在同一個執行緒上的各協程是共享該結構的,是個全域性性的資源。那麼這個 stCoRoutineEnv_t 到底包含什麼重要資訊呢?請看程式碼:
struct stCoRoutineEnv_t { stCoRoutine_t *pCallStack[128]; int iCallStackSize; stCoEpoll_t *pEpoll;// for copy stack log lastco and nextco stCoRoutine_t* pending_co; stCoRoutine_t* ocupy_co; };
我們看到 stCoRoutineEnv_t 內部有一個叫做 CallStack 的“棧”,還有個 stCoPoll_t 結構指標。此外,還有兩個 stCoRoutine_t 指標用於記錄協程切換時佔有共享棧的和將要切換執行的協程。在不使用共享棧模式時 pending_co 和 ocupy_co 都是空指標,我們暫且忽略它們,等到分析共享棧的時候再說。stCoRoutineEnv_t 結構裡的 pCallStack 不是普通意義上我們講的那個程式執行棧,那個 ESP(RSP)暫存器指向的棧,是用來保留程式執行過程中區域性變數以及函式呼叫關係的。但是,這個 pCallStack 又跟 ESP(RSP)指向的棧有相似之處。如果將協程看成一種特殊的函式,那麼這個 pCallStack 就時儲存這些函式的呼叫鏈的棧。我們已經講過,非對稱協程最大特點就是協程間存在明確的呼叫關係;甚至在有些文獻中,啟動協程被稱作 call,掛起協程叫 return。非對稱協程機制下的被調協程只能返回到呼叫者協程,這種呼叫關係不能亂,因此必須將呼叫鏈儲存下來。這即是 pCallStack 的作用,將它命名為“呼叫棧”實在是恰如其分。每當啟動(resume)一個協程時,就將它的協程控制塊 stCoRoutine_t 結構指標儲存在 pCallStack 的“棧頂”,然後“棧指標”iCallStackSize 加 1,最後切換 context 到待啟動協程執行。當協程要讓出(yield)CPU 時,就將它的 stCoRoutine_t 從 pCallStack 彈出,“棧指標”iCallStackSize 減 1,然後切換 context 到當前棧頂的協程(原來被掛起的呼叫者)恢復執行。這個“壓棧”和“彈棧”的過程我們在 co_resume() 和 co_yield() 函式中將會再次講到。那麼這裡有一個問題,libco 程式的第一個協程呢,假如第一個協程 yield 時,CPU控制權讓給誰呢?關於這個問題,我們首先要明白這“第一個”協程是什麼。實際上,libco 的第一個協程,即執行 main 函式的協程,是一個特殊的協程。這個協程又可以稱作主協程,它負責協調其他協程的排程執行(後文我們會看到,還有網路 I/O 以及定時事件的驅動),它自己則永遠不會 yield,不會主動讓出 CPU。不讓出(yield)CPU,不等於說它一直霸佔著 CPU。我們知道 CPU 執行權有兩種轉移途徑,一是透過 yield 讓給呼叫者,其二則是 resume 啟動其他協程執行。後文我們可以清楚地看到,co_resume()與 co_yield() 都伴隨著上下文切換,即 CPU控制流的轉移。當你在程式中第一次呼叫co_resume() 時,CPU 執行權就從主協程轉移到了 resume 目標協程上了。提到主協程,那麼另外一個問題又來了,主協程是在什麼時候創建出來的呢?什麼時候 resume 的呢?事實上,主協程是跟 stCoRoutineEnv_t 一起建立的。主協程也無需呼叫 resume 來啟動,它就是程式本身,就是 main 函式。主協程是一個特殊的存在,可以認為它只是一個結構體而已。在程式首次呼叫 co_create() 時,此函式內部會判斷當前程序(執行緒)的 stCoRoutineEnv_t 結構是否已分配,如果未分配則分配一個,同時分配一個 stCoRoutine_t 結構,並將 pCallStack[0] 指向主協程。此後如果用 co_resume() 啟動協程,又會將 resume 的協程壓入 pCallStack 棧。以上整個過程可以用圖1來表示。
在圖1中,coroutine2 整處於棧頂,也即是說,當前正在 CPU 上 running 的協程是coroutine2。而 coroutine2 的呼叫者是誰呢?是誰 resume 了 coroutine2 呢?是 coroutine1。coroutine1 則是主協程啟動的,即在 main 函數里 resume 的。當 coroutine2 讓出 CPU 時,只能讓給 coroutine1;如果 coroutine1 再讓出 CPU,那麼又回到了主協程的控制流上了。當控制流回到主協程上時,主協程在幹些什麼呢?回過頭來看生產者消費者那個例子。那個例子中,main 函式中程式最終呼叫了 co_eventloop()。該函式是一個基於epoll/kqueue 的事件迴圈,負責排程其他協程執行,具體細節暫時略去。這裡我們只需知道,stCoRoutineEnv_t 結構中的 pEpoll 即使在這裡用的就夠了。至此,我們已經基本理解了 stCoRoutineEnv_t 結構的作用。待補充。
6. Libco 協程的生命週期6.1 建立協程(Creating coroutines)前文已提到,libco 中建立協程是 co_create() 函式。函式宣告如下:
1 int co_create(stCoRoutine_t** co, const stCoRoutineAttr_t* attr, void* (routine)(void), void* arg);
同 pthread_create 一樣,該函式有四個引數:@co: stCoRoutine_t** 型別的指標。輸出引數,co_create 內部會為新協程分配⼀個“協程控制塊”,co 將指向這個分配的協程控制塊。@attr: stCoRoutineAttr_t 型別的指標。輸⼊引數,用於指定要建立協程的屬性,可為 NULL。實際上僅有兩個屬性:棧⼤小、指向共享棧的指標(使用共享棧模式)。@routine: void* (*)(void ) 型別的函式指標,指向協程的任務函式,即啟動這個協程後要完成什麼樣的任務。routine 型別為函式指標。8@arg: void 型別指標,傳遞給任務函式的引數,類似於 pthread 傳遞給執行緒的引數。呼叫 co_create 將協程創建出來後,這時候它還沒有啟動,也即是說我們傳遞的routine 函式還沒有被呼叫。實質上,這個函式內部僅僅是分配並初始化 stCoRoutine_t結構體、設定任務函式指標、分配一段“棧”記憶體,以及分配和初始化 coctx_t。為什麼這裡的“棧”要加個引號呢?因為這裡的棧記憶體,無論是使用預先分配的共享棧,還是co_create 內部單獨分配的棧,其實都是呼叫 malloc 從程序的堆記憶體分配出來的。對於協程而言,這就是“棧”,而對於底層的程序(執行緒)來說這只不過是普通的堆記憶體而已。總體上,co_create 函式內部做的工作很簡單,這裡就不貼出程式碼了。
6.2 啟動協程(Resume a coroutine)在呼叫 co_create 建立協程返回成功後,便可以呼叫 co_resume 函式將它啟動了。該函式宣告如下:
void co_resume(stCoRoutine_t* co);
它的意義很明瞭,即啟動 co 指標指向的協程。值得注意的是,為什麼這個函式不叫 co_start 而是 co_resume 呢?前文已提到,libco 的協程是非對稱協程,協程在讓出CPU 後要恢復執行的時候,還是要再次呼叫一下 co_resume 這個函式的去“啟動”協程執行的。從語義上來講,co_start 只有一次,而 co_resume 可以是暫停之後恢復啟動,可以多次呼叫,就這麼個區別。實際上,看早期關於協程的文獻,講到非對稱協程,一般用“resume”與“yield”這兩個術語。協程要獲得 CPU 執行權用“resume”,而讓出 CPU 執行用“yield”,這是兩個是兩個不同的(不對稱的)過程,因此這種機制才被稱為非對稱協程(asymmetric coroutines)。所以講到 resume 一個協程,我們一定得注意,這可能是第一次啟動該協程,也可以是要準備重新執行掛起的協程。我們可以認為在 libco 裡面協程只有兩種狀態,即running 和 pending。當建立一個協程並呼叫 resume 之後便進入了 running 狀態,之後協程可能透過 yield 讓出 CPU,這就進入了 pending 狀態。不斷在這兩個狀態間迴圈往復,直到協程退出(執行的任務函式 routine 返回),如圖2所示(TBD 修改狀態機)。
需要指出的是,不同於 go 語言,這裡 co_resume() 啟動一個協程的含義,不是“建立一個併發任務”。進入 co_resume() 函式後發生協程的上下文切換,協程的任務函式是立即就會被執行的,而且這個執行過程不是併發的(Concurrent)。為什麼不是併發的呢?因為 co_resume() 函式內部會呼叫 coctx_swap() 將當前協程掛起,然後就開始執行目標協程的程式碼了(具體過程見下文協程切換那一節的分析)。本質上這個過程是序列的,在一個作業系統執行緒(程序)上發生的,甚至可以說在一顆 CPU 核上發生的(假定沒有發生 CPU migration)。讓我們站到 Knuth 的角度,將 coroutine 當做一種特殊的subroutine 來看,問題會顯得更清楚:A 協程呼叫 co_resume(B) 啟動了 B 協程,本質上是一種特殊的過程呼叫關係,A 呼叫 B 進入了 B 過程內部,這很顯然是一種序列執行的關係。那麼,既然 co_resume() 呼叫後進入了被調協程執行控制流,那麼 co_resume()函式本身何時返回?這就要等被調協程主動讓出 CPU 了。(TDB 補充圖)
co_resume()函式程式碼實現
void co_resume(stCoRoutine_t *co) { stCoRoutineEnv_t *env = co−>env; stCoRoutine_t *lpCurrRoutine = env−>pCallStack[env−>iCallStackSize−1]; if (!co−>cStart) { coctx_make(&co−>ctx, (coctx_pfn_t)CoRoutineFunc, co, 0); co−>cStart = 1; } env−>pCallStack[env−>iCallStackSize++] = co; co_swap(lpCurrRoutine, co); }
如果讀者對 co_resume() 的邏輯還有疑問,不妨再看一下它的程式碼實現。第 5、6 行的 if 條件分支,當且僅當協程是第一次啟動時才會執行到。首次啟動協程過程有點特殊,需要呼叫 coctx_make() 為新協程準備 context(為了讓 co_swap() 內能跳轉到協程的任務函式),並將 cStart 標誌變數置 1。忽略第 4~7 行首次啟動協程的特殊邏輯,那麼co_resume() 僅有 4 行程式碼而已。第 3 行取當前協程控制塊指標,第 8 行將待啟動的協程 co 壓入 pCallStack 棧,然後第 9 行就呼叫 co_swap() 切換到 co 指向的新協程上去執行了。前文也已經提到,co_swap() 不會就此返回,而是要這次 resume 的 co 協程主動yield 讓出 CPU 時才會返回到 co_resume() 中來。值得指出的是,這裡講 co_swap() 不會就此返回,不是說這個函式就阻塞在這裡等待 co 這個協程 yield 讓出 CPU。實際上,後面我們將會看到,co_swap() 內部已經切換了 CPU 執行上下文,奔著 co 協程的程式碼路徑去執行了。整個過程不是併發的,而是序列的,這一點我們已經反覆強調過了。
6.3 協程的掛起(Yield to another coroutine)在非對稱協程理論,yield 與 resume 是個相對的操作。A 協程 resume 啟動了 B 協程,那麼只有當 B 協程執行 yield 操作時才會返回到 A 協程。在上一節剖析協程啟動函式 co_resume() 時,也提到了該函式內部 co_swap() 會執行被調協程的程式碼。只有被調協程 yield 讓出 CPU,呼叫者協程的co_swap() 函式才能返回到原點,即返回到原來co_resume() 內的位置。在前文解釋stCoRoutineEnv_t 結構 pCallStack 這個“呼叫棧”的時候,我們已經簡要地提到了 yield 操作的內部邏輯。在被調協程要讓出 CPU 時,會將它的 stCoRoutine_t 從pCallStack 彈出,“棧針”iCallStackSize 減 1,然後 co_swap() 切換 CPU 上下文到原來被掛起的呼叫者協程恢復執行。這裡“被掛起的呼叫者協程”,即是呼叫者 co_resume()中切換 CPU 上下文被掛起的那個協程。下面我們來看一下 co_yield_env() 函式程式碼:
co_yield_env() 函式
void co_yield_env(stCoRoutineEnv_t *env) { stCoRoutine_t *last = env−>pCallStack[env−>iCallStackSize − 2]; stCoRoutine_t *curr = env−>pCallStack[env−>iCallStackSize − 1]; env−>iCallStackSize−−; co_swap(curr, last); }
co_yield_env() 函式僅有 4 行程式碼,事實上這個還可以寫得更簡潔些。你可以試著把這裡程式碼縮短至 3 行,並不會犧牲可讀性。注意到這個函式為什麼叫 co_yield_env 而不是 co_yield 呢?這個也很簡單。我們知道 co_resume 是有明確目的物件的,而且可以透過 resume 將 CPU 交給任意協程。但 yield 則不一樣,你只能 yield 給當前協程的呼叫者。而當前協程的呼叫者,即最初 resume 當前協程的協程,是儲存在 stCoRoutineEnv_t的 pCallStack 中的。因此你只能 yield 給“env”,yield 給呼叫者協程;而不能隨意 yield給任意協程,CPU 不是你想讓給誰就能讓給誰的。事實上,libco 提供了一個 co_yield(stCoRoutine_t*) 的函式。看起來你似乎可以將CPU 讓給任意協程。實際上並非如此:
co_yield() 函式
void co_yield(stCoRoutine_t *co) { co_yield_env(co−>env); }
我們知道,同一個執行緒上所有協程是共享一個 stCoRoutineEnv_t 結構的,因此任意協程的 co->env 指向的結構都相同。如果你呼叫 co_yield(co),就以為將 CPU 讓給 co 協程了,那就錯了。最終透過 co_yield_env() 還是會將 CPU 讓給原來啟動當前協程的呼叫者。可能有的讀者會有疑問,同一個執行緒上所有協程共享 stCoRoutineEnv_t,那麼我co_yield() 給其他執行緒上的協程呢?對不起,如果你這麼做,那麼你的程式就掛了。libco的協程是不支援執行緒間遷移(migration)的,如果你試圖這麼做,程式一定會掛掉。這個 co_yield() 其實容易讓人產生誤解的。再補充說明一下,協程庫內雖然提供了 co_yield(stCoRoutine_t*) 函式,但是沒有任何地方有呼叫過該函式(包括樣例程式碼)。使用的較多的是另外一個函式——co_yield_ct(),其實本質上作用都是一樣的。
6.4 協程的切換(Context switch)前面兩節討論的 co_yield_env() 與 co_resume(),是兩個完全相反的過程,但他們的核心任務卻是一樣的——切換 CPU 執行上下文,即完成協程的切換。在 co_resume()中,這個切換是從當前協程切換到被調協程;而在 co_yield_env() 中,則是從當前協程切換到呼叫者協程。最終的上下文切換,都發生在 co_swap() 函式內。嚴格來講這裡不屬於協程生命週期一部分,而只是兩個協程開始執行與讓出 CPU時的一個臨界點。既然是切換,那就涉及到兩個協程。為了表述方便,我們把當前準備讓出 CPU 的協程叫做 current 協程,把即將調入執行的叫做 pending 協程。
coctx_swap.S 彙編程式碼
.globl coctx_swap #if !defined( __APPLE__ ) .type coctx_swap, @function #endif coctx_swap: #if defined(__i386__) leal 4(%esp), %eax //sp movl 4(%esp), %esp leal 32(%esp), %esp //parm a : ®s[7] + sizeof(void*) pushl %eax //esp −>parm a pushl %ebp pushl %esi pushl %edi pushl %edx pushl %ecx pushl %ebx pushl −4(%eax) movl 4(%eax), %esp //parm b −> ®s[0] popl %eax //ret func addr popl %ebx popl %ecx popl %edx popl %edi popl %esi popl %ebp popl %esp pushl %eax //set ret func addr xorl %eax, %eax ret #elif defined(__x86_64__)
這裡擷取的是coctx_swap.S 檔案中針對 x86 體系結構的一段程式碼,x64 下的原理跟這是一樣的,程式碼也在這同一個檔案中。從宏觀角度看,這裡定義了一個名為 coctx_swap的函式,而且是 C 風格的函式(因為要被 C++ 程式碼呼叫)。從呼叫方看,我們可以將它當做一個普通的 C 函式,函式原型如下:
void coctx_swap(coctx_t* curr, coctx_t* pending) asm(“coctx_swap”);
coctx_swap 接受兩個引數,無返回值。其中,第一個引數 curr 為當前協程的 coctx_t結構指標,其實是個輸出引數,函式呼叫過程中會將當前協程的 context 儲存在這個引數指向的記憶體裡;第二個引數 pending,即待切入的協程的 coctx_t 指標,是個輸入引數,coctx_swap 從這裡取上次儲存的 context,恢復各暫存器的值。前面我們講過 coctx_t 結構,就是用於儲存各暫存器值(context)的。這個函式奇特之處,在於呼叫之前還處於第一個協程的環境,該函式返回後,則當前執行的協程就已經完全是第二個協程了。這跟 Linux 核心排程器的 switch_to 功能是非常相似的,只不過核心裡執行緒的切換比這還要複雜得多。正所謂“楊百萬進去,楊白勞出來”,當然,這裡也可能是“楊白勞進去,楊百萬出來”。言歸正題,這個函式既然是要直接操作暫存器,那當然非彙編不可了。組合語言都快忘光了?那也不要緊,這裡用到的都是常用的指令。值得一提的是,絕大多數學校彙編教程使用的都是 Intel 的語法格式,而這裡用到的是 AT&T 格式。這裡我們只需知道兩者的主要差別,在於運算元的順序是反過來的,就足夠了。在 AT&T 彙編指令裡,如果指令有兩個運算元,那麼第一個是源運算元,第二個即為目的運算元。此外,我們前面提到,這是個 C 風格的函式。什麼意思呢?在 x86 平臺下,多數C 編譯器會使用一種固定的方法來處理函式的引數與返回值。函式的引數使用棧傳遞,且約定了引數順序,如圖3所示。在呼叫函式之前,編譯器會將函式引數以反向順序壓棧,如圖3中函式有三個引數,那麼引數 3 首先 push 進棧,隨後是引數 2,最後引數 1。準備好引數後,呼叫 CALL 指令時 CPU 自動將 IP 暫存器(函式返回地址)push 進棧,因此在進入被調函式之後,便形成了如圖3的棧格局。函式呼叫結束前,則使用 EAX 暫存器傳遞返回值(如果 32 位夠用的話),64 位則使用 EDX:EAX,如果是浮點值則使用FPU ST(0) 暫存器傳遞。
在複習過這些組合語言知識後,我們再來看 coctx_swap 函式。它有兩個引數,那麼進入函式體後,用 4(%esp) 便可以取到第一個引數(當前協程 context 指標),8(%esp)可以取到第二個引數(待切入執行協程的 context 指標)。當前棧頂的內容,(%esp) 則儲存了 coctx_swap 的返回地址。搞清楚棧資料佈局是理解 coctx_swap 函式的關鍵,接下來分析 coctx_swap 的每條指令,都需要時刻明白當前的棧在哪裡,棧內資料是怎樣一個分佈。我們把 coctx_swap 分為兩部分,以第 21 行那條 MOVL 指令為界。第一部分是用於儲存 current 協程的各個暫存器,第二部分則是恢復 pending 協程的暫存器。接下來我們逐行進行分析。第 8 ⾏:LEA 指令即 Load Effective Address 的縮寫。這條指令把 4(%esp) 有效地址儲存到 eax 暫存器,可以認為是將當前的棧頂地址儲存下來(實際儲存的地址比棧頂還要⾼ 4 位元組,為了⽅便我們就稱之為棧頂)。為什麼要儲存棧指標呢,因為緊接著就要進⾏棧切換了。第 9~10 ⾏:看到第 9 ⾏,回憶我們前面講過的 C 函式引數在棧中的位置,此時4(%esp) 內正是指向 current 協程 coctx_t 的指標,這裡把它塞到 esp 暫存器。接下來第10 ⾏又將 coctx_t 指標指向的地址加上 32 個位元組的記憶體位置載入到 esp 中。經過這麼13⼀倒騰,esp 暫存器實際上指向了當前協程 coctx_t 結構的 ss_size 成員位置,在它之下有個名為 regs 的陣列,剛好是用來儲存 8 個暫存器值的。注意這是第⼀次棧切換,不過是臨時性的,目的只是⽅便接下來使用 push 指令儲存各暫存器值。第 12 ⾏:eax 暫存器內容壓棧。更準確的講,是將 eax 暫存器儲存到了 coctx_t->regs[7] 的位置注意到在第 8 ⾏ eax 暫存器已經儲存了原棧頂的地址,所以這句實際上是將當前協程棧頂儲存起來,以備下次排程回來時恢復棧地址。第 13~18 ⾏:儲存各通用暫存器的值,到 coctx_t 結構的 regs[1]~regs[6] 的位置。第 19 ⾏:這⼀⾏又有點意思了。eax 的值,從第 8 ⾏之後就變變過,那麼-4(%eax)實際上是指向原來 coctx_swap 剛進來時的棧頂,我們講過棧頂的值是 call 指令自動壓⼊的函式返回地址。這句實際上就是將 coctx_swap 的返回地址給儲存起來了,放在coctx_t->regs[0] 的位置。第 21 ⾏:⾄此,current 協程的各重要暫存器都已儲存完成了,開始可以放⼼地交班給 pending 協程了。接下來我們需要將 pending 協程排程起來運⾏,就需要為它恢復context——恢復各通用暫存器的值以及棧指標。因此這⼀⾏將棧指標切到 pending 協程的 coctx_t 結構體開始,即 regs[0] 的位置,為恢復暫存器值做好了準備。第 23 ⾏:彈出 regs[0] 的值到 eax 暫存器。regs[0] 正該協程上次被切換出去時在第19 ⾏儲存的值,即 coctx_swap 的返回地址。第 24~29 ⾏:從 regs[1]~regs[6] 恢復各暫存器的值(與之相應的是前面第 13~18 ⾏的壓棧操作)。第 30 ⾏:將 pending 協程上次切換出去時的棧指標恢復(與之對應的是第 12 ⾏壓棧操作)。請思考⼀下,棧內容已經完全恢復了嗎?注意到第 8 ⾏我們講過,當時儲存的“棧頂”比真正的棧頂差了⼀個 4 位元組的偏移。⽽這 4 位元組真正棧頂的內容,正是coctx_swap 的返回地址。如果此時程式就執⾏ ret 指令返回,那程式就不知道會跑到哪去了。第 31 ⾏:為了程式能正確地返回原來的 coctx_swap 呼叫的地⽅,將 eax 內容(第19 ⾏儲存⾄ regs[7],第 23 ⾏取出來到 eax)壓棧。第 33~34 ⾏:清零 eax 暫存器,執⾏返回指令。至此,對 32 位平臺的 coctx_swap 分析就到此結束了。細心的讀者會發現一共切換了 3 次棧指標,儘管作者加了一些註釋,但這段程式碼對於很多人來說恐怕還是難以理解的。本文僅僅分析了 32 位的情況,x64 下的程式碼邏輯是類似的,不過涉及的暫存器更多一些,而且函式呼叫時引數傳遞有所區別。有興趣的讀者可以自行分析下,此外,還可以結合 glibc 的 ucontext 原始碼對比分析一下。ucontext 也提供了支援使用者級執行緒的介面,也有類似功能的 swapcontext() 函式,那裡的彙編程式碼比較容易讀懂些,不過執行效率比較低。
6.5 協程的退出這裡講的退出,有別於協程的掛起,是指協程的任務函式執行結束後發生的過程。更簡單的說,就是協程任務函式內執行了return語句,結束了它的生命週期。這在某些場景是有用的。同協程掛起一樣,協程退出時也應將CPU控制權交給它的呼叫者,這也是呼叫co_yield_env() 函式來完成的。這個機制很簡單,限於篇幅,具體程式碼就不貼出來了。
值得注意的是,我們呼叫 co_create()、co_resume()啟動協程執行一次性任務,當任務結束後要記得呼叫co_free()或 co_release()銷燬這個臨時性的協程,否則將引起記憶體洩漏。
7 事件驅動與協程排程7.1 協程的“阻塞”與執行緒的“非阻塞”我們已經分析了 libco 的協程從建立到啟動,掛起、起動以及最後退出的過程。同時,我們也看到,一個執行緒上的所有協程本質上是如何序列執行的。讓我們暫時回到3.1節的例子。在 Producer 協程函式內我們會看到呼叫 poll 函式等待 1 秒,Consumer中也會看到呼叫 co_cond_timedwait 函式等待生產者訊號。注意,從協程的角度看,這些等待看起來都是同步的(synchronous),阻塞的(blocking);但從底層執行緒的角度來看,則是非阻塞的(non-blocking)。在3.1節例子中我們也講過,這跟 pthread 實現的原理是一樣的。在 pthread 實現的消費者中,你可能用 pthread_cond_timedwait 函式去同步等待生產者的訊號;在消費者中,你可能用 poll 或 sleep 函式去定時等待。從執行緒的角度看,這些函式都會讓當前執行緒阻塞;但從核心的角度看,它本身並沒有阻塞,核心可能要繼續忙著排程別的執行緒執行。那麼這裡協程也是一樣的道理,從協程的角度看,當前的程式阻塞了;但從它底下的執行緒來看,自己可能正忙著執行別的協程函式。在這個例子中,當 Consumer 協程呼叫 co_cond_timedwait 函式“阻塞”後,執行緒可能已經將 Producer 排程恢復執行,反之亦然。那麼這個負責協程“排程”的執行緒在哪呢?它即是執行協程本身的這個執行緒。
7.2 主協程與協程的“排程”還記得前文提過的“主協程”的概念嗎?我們再次把它搬出來,這對我們理解協程的“阻塞”與“排程”可能更有幫助。我們講過,libco 程式都有一個主協程,即程式裡首次呼叫co_create() 顯式建立第一個協程的協程。在3.1節例子中,即為 main 函數里呼叫 co_eventloop() 的這個協程。當 Consumer 或 Producer 阻塞後,CPU 將 yield 給主協程,此時主協程在幹什麼呢?主協程在 co_eventloop() 函數里頭忙活。這個 co_eventloop() 即“排程器”的核心所在。需要補充說明的是,這裡講的“排程器”,嚴格意義上算不上真正的排程器,只是為了表述的方便。libco 的協程機制是非對稱的,沒有什麼排程演算法。在執行 yield 時,當前協程只能將控制權交給呼叫者協程,沒有任何可排程的餘地。resume 靈活性稍強一點,不過也還算不得排程。如果非要說有什麼“排程演算法”的話,那就只能說是“基於 epoll/kqueue 事件驅動”的排程演算法。“排程器”就是 epoll/kqueue 的事件迴圈。我們知道,在 go 語言中,使用者只需使用同步阻塞式的程式設計介面即可開發出高效能的伺服器,epoll/kqueue 這樣的 I/O 事件通知機制(I/O event notification mechanism)完全被隱藏了起來。在 libco 裡也是一樣的,你只需要使用普通 C 庫函式 read()、write()等等同步地讀寫資料就好了。那麼 epoll 藏在哪呢?就藏在主協程的 co_eventloop() 中。協程的排程與事件驅動是緊緊聯絡在一起的,因此與其說 libco 是一個協程庫,還不如說它是一個網路庫。在後臺伺服器程式中,一切邏輯都是圍繞網路 I/O 轉的,libco 這樣的設計自有它的合理性。
7.3 stCoEpoll_t 結構與定時器在分析 stCoRoutineEnv_t 結構(程式碼清單5)的時候,還有一個 stCoEpoll_t 型別的pEpoll 指標成員沒有講到。作為 stCoRoutineEnv_t 的成員,這個結構也是一個全域性性的資源,被同一個執行緒上所有協程共享。從命名也看得出來,stCoEpoll_t 是跟 epoll 的事件迴圈相關的。現在我們看一下它的內部欄位:
stCoEpoll_t 結構
struct stCoEpoll_t { int iEpollFd; static const int _EPOLL_SIZE = 1024 * 10; struct stTimeout_t *pTimeout; struct stTimeoutItemLink_t *pstTimeoutList; struct stTimeoutItemLink_t *pstActiveList;co_epoll_res *result; };
@iEpollFd: 顯然是 epoll 例項的⽂件描述符。@_EPOLL_SIZE: 值為 10240 的整型常量。作為 epoll_wait() 系統呼叫的第三個引數,即⼀次 epoll_wait 最多返回的就緒事件個數。@pTimeout: 型別為 stTimeout_t 的結構體指標。該結構實際上是⼀個時間輪(Timingwheel)定時器,只是命名比較怪,讓⼈摸不著頭腦。@pstTimeoutList: 指向 stTimeoutItemLink_t 型別的結構體指標。該指標實際上是⼀個連結串列頭。連結串列用於臨時存放超時事件的 item。@pstActiveList: 指向 stTimeoutItemLink_t 型別的結構體指標。也是指向⼀個連結串列。該連結串列用於存放 epoll_wait 得到的就緒事件和定時器超時事件。@result: 對 epoll_wait()第⼆個引數的封裝,即⼀次 epoll_wait 得到的結果集。我們知道,定時器是事件驅動模型的網路框架一個必不可少的功能。網路 I/O 的超時,定時任務,包括定時等待(poll 或 timedwait)都依賴於此。一般而言,使用定時功能時,我們首先向定時器中註冊一個定時事件(Timer Event),在註冊定時事件時需要指定這個事件在未來的觸發時間。在到了觸發時間點後,我們會收到定時器的通知。網路框架裡的定時器可以看做由兩部分組成,第一部分是儲存已註冊 timer events的資料結構,第二部分則是定時通知機制。儲存已註冊的 timer events,一般選用紅黑樹,比如 nginx;另外一種常見的資料結構便是時間輪,libco 就使用了這種結構。當然你也可以直接用連結串列來實現,只是時間複雜度比較高,在定時任務很多時會很容易成為框架的效能瓶頸。
定時器的第二部分,高精度的定時(精確到微秒級)通知機制,一般使用getitimer/setitimer 這類介面,需要處理訊號,是個比較麻煩的事。不過對一般的應用而言,精確到毫秒就夠了。精度放寬到毫秒級時,可以順便用 epoll/kqueue 這樣的系統呼叫來完成定時通知;這樣一來,網路 I/O 事件通知與定時事件通知的邏輯就能統一起來了。筆者之前實現過的一個基於 libcurl 的非同步 HTTP client,其中的定時器功能就是用 epoll 配合紅黑樹實現的。libco 內部也直接使用了 epoll 來進行定時,不同的只是儲存 timer events 的用的是時間輪而已。
使用 epoll 加時間輪的實現定時器的演算法如下:Step 1 [epoll_wait]呼叫 epoll_wait() 等待 I/O 就緒事件,最⼤等待時長設定為 1 毫秒(即 epoll_wait() 的第 4 個引數)。Step 2 [處理 I/O 就緒事件] 迴圈處理 epoll_wait() 得到的 I/O 就緒⽂件描述符。Step 3 [從時間輪取超時事件] 從時間輪取超時事件,放到 timeout 佇列。Step 4 [處理超時事件] 如果 Step 3 取到的超時事件不為空,那麼迴圈處理 timeout佇列中的定時任務。否則跳轉到 Step 1 繼續事件迴圈。Step 5 [繼續迴圈] 跳轉到 Step 1,繼續事件迴圈。
7.4 掛起協程與恢復的執行在前文的第6.2與第6.3小節,我們仔細地分析了協程的 resume 與 yield 過程。那麼協程究竟在什麼時候需要 yield 讓出 CPU,又在什麼時候恢復執行呢?先來看 yield,實際上在 libco 中共有 3 種呼叫 yield 的場景:
使用者程式中主動呼叫 co_yield_ct();程式呼叫了 poll() 或 co_cond_timedwait() 陷⼊“阻塞”等待;程式呼叫了 connect(), read(), write(), recv(), send() 等系統呼叫陷⼊“阻塞”等待。相應地,重新 resume 啟動一個協程也有 3 種情況:對應使用者程式主動 yield 的情況,這種情況也有賴於使用者程式主動將協程co_resume() 起來;poll() 的目標⽂件描述符事件就緒或超時,co_cond_timedwait() 等到了其他協程的 co_cond_signal() 通知訊號或等待超時;read(), write() 等 I/O 接⼝成功讀到或寫⼊資料,或者讀寫超時。在第一種情況下,即使用者主動 yield 和 resume 協程,相當於 libco 的使用者承擔了部分的協程“排程”工作。這種情況其實也很常見,在 libco 原始碼包的example_echosvr.cpp例子中就有。這也是服務端使用 libco 的典型模型,屬於手動“排程”協程的例子。第二種情況,前面第3.1節中的生產者消費者就是個典型的例子。在那個例子中我們看不到使用者程式主動呼叫 yield,也只有在最初啟動協程時呼叫了 resume。生產者和消費者協程是在哪裡切換的呢?在 poll() 與 co_cond_timedwait() 函式中。首先來看消費者。當消費者協程首先啟動時,它會發現任務佇列是空的,於是呼叫 co_cond_timedwait() 在條件變數 cond 上“阻塞”等待。同作業系統執行緒的條件等待原理一樣,這裡條件變數stCoCond_t 型別內部也有一個“等待佇列”。co_cond_timedwait() 函式內部會將當前協程掛入條件變數的等待佇列上,並設定一個回撥函式,該回調函式是用於未來“喚醒”當前協程的(即 resume 掛起的協程)。此外,如果 wait 的 timeout 引數大於 0 的話,還要向當前執行環境的定時器上註冊一個定時事件(即掛到時間輪上)。在這個例子中,消費者協程co_cond_timedwait 的 timeout 引數為-1,即 indefinitly 地等待下去,直到等到生產者向條件變數發出 signal 訊號。然後我們再來看生產者。當生產者協程啟動後,它會向任務佇列裡投放一個任務並呼叫co_cond_signal() 通知消費者,然後再呼叫 poll() 在原地“阻塞”等待 1000 毫秒。這裡co_cond_signal 函式內部其實也簡單,就是將條件變數的等待佇列裡的協程拿出來,然後掛到當前執行環境的 pstActiveList(見 7.3 節 stCoEpoll_t 結構)。co_cond_signal函式並沒有立即 resume 條件變數上的等待協程,畢竟這還不到交出 CPU 的時機。那麼什麼時候交出 CPU 控制權,什麼時候 resume 消費者協程呢?繼續往下看,生產者在向消費者發出“訊號”之後,緊接著便呼叫 poll() 進入了“阻塞”等待,等待 1 秒鐘。這個poll 函式內部實際上做了兩件事。首先,將自己作為一個定時事件註冊到當前執行環境的定時器,註冊的時候設定了 1 秒鐘的超時時間和一個回撥函式(仍是一個用於未來“喚醒”自己的回撥)。然後,就呼叫 co_yield_env() 將 CPU 讓給主協程了。現在,CPU 控制權又回到了主協程手中。主協程此時要幹什麼呢?我們已經講過,主協程就是事件迴圈 co_eventloop() 函式。在 co_eventloop() 中,主協程週而復始地呼叫epoll_wait(),當有就緒的 I/O 事件就處理 I/O 事件,當定時器上有超時的事件就處理超時事件,pstActiveList 佇列中已有活躍事件就處理活躍事件。這裡所謂的“處理事件”,其實就是呼叫其他工作協程註冊的各種回撥函式而已。那麼前面我們講過,消費者協程和生產者協程的回撥函式都是“喚醒”自己而已。工作協程呼叫 co_cond_timedwait()或 poll() 陷入“阻塞”等待,本質上即是透過 co_yield_env 函式讓出了 CPU;而主協程則負責在事件迴圈中“喚醒”這些“阻塞”的協程,所謂“喚醒”操作即呼叫工作協程註冊的回撥函式,這些回撥內部使用 co_resume() 重新恢復掛起的工作協程。最後,協程 yield 和 resume 的第三種情況,即呼叫 read(), write() 等 I/O 操作而陷入“阻塞”和最後又恢復執行的過程。這種情況跟第二種過程基本相似。需要注意的是,這裡的“阻塞”依然是使用者態實現的過程。我們知道,libco 的協程是在底層執行緒上序列執行的。如果呼叫 read 或 write 等系統呼叫陷入真正的阻塞(讓當前執行緒被核心掛起)的話,那麼不光當前協程被掛起了,其他協程也得不到執行的機會。因此,如果工作協程陷入真正的核心態阻塞,那麼 libco 程式就會完全停止運轉,後果是很嚴重的。為了避免陷入核心態阻塞,我們必須得依靠核心提供的非阻塞 I/O 機制,將 socket檔案描述符設定為 non-blocking 的。為了讓 libco 的使用者更方便,我們還得將這種non-blocking 的過程給封裝起來,偽裝成“同步阻塞式”的呼叫(跟 co_cond_timedwait()一樣)。事實上,go 語言就是這麼做的。而 libco 則將這個過程偽裝得更加徹底,更加具有欺騙性。它透過dlsym機制 hook 了各種網路 I/O 相關的系統呼叫,使得使用者可以以“同步”的方式直接使用諸如read()、write()和connect()等系統呼叫。因此,我們會看到3.1節那裡的生產者消費者協程任務函數里第一句就呼叫了一個 co_enable_hook_sys()的函式。呼叫了 co_enable_hook_sys 函式才會開啟 hook 系統呼叫功能,並且需要事先將要讀寫的檔案描述符設定為 non-blocking 屬性,否則,工作協程就可能陷入真正的核心態阻塞,這一點在應用中要特別加以注意。以 read() 為例,讓我們再來分析一下這些“偽裝”成同步阻塞式系統呼叫的內部原理。首先,假如程式 accept 了一個新連線,那麼首先我們將這個連線的 socket 檔案描述符設定為非阻塞模式,然後啟動一個工作協程去處理這個連線。工作協程呼叫 read()試圖從該新連線上讀取資料。這時候由於系統 read() 函式已經被 hook,所以實際上會呼叫到 libco 內部準備好的read() 函式。這個函式內部實際上做了 4 件事:第一步將當前協程註冊到定時器上,用於將來處理 read() 函式的讀超時。第二步,呼叫 epoll_ctl()將自己註冊到當前執行環境的 epoll 例項上。這兩步註冊過程都需要指定一個回撥函式,將來用於“喚醒”當前協程。第三步,呼叫 co_yield_env 函式讓出 CPU。第四步要等到該協程被主協程重新“喚醒”後才能繼續。如果主協程 epoll_wait() 得知 read 操作的檔案描述符可讀,則會執行原 read 協程註冊的會回撥將它喚醒(超時後同理,不過還要設定超時標誌)。工作協程被喚醒後,在呼叫原 glibc 內被 hook 替換掉的、真正的 read()系統呼叫。這時候如果是正常 epoll_wait 得知檔案描述符 I/O 就緒就會讀到資料,如果是超時就會返回-1。總之,在外部使用者看來,這個 read() 就跟阻塞式的系統呼叫表現出幾乎完全一致的行為了。7.5 主協程事件迴圈原始碼分析前文已經多次提到過主協程事件迴圈,主協程是如何“排程”工作協程執行的。最後,讓我們再來分析下它的程式碼(為了節省篇幅,在不妨礙我們理解其工作原理的前提下,已經略去了數行不相關的程式碼)。
stCoEpoll_t 結構
void co_eventloop(stCoEpoll_t *ctx, pfn_co_eventloop_t pfn, void *arg) {co_epoll_res *result = ctx−>result;for (;;) { int ret= co_epoll_wait(ctx−>iEpollFd, result, stCoEpoll_t::_EPOLL_SIZE, 1); stTimeoutItemLink_t *active = (ctx−>pstActiveList); stTimeoutItemLink_t *timeout = (ctx−>pstTimeoutList); memset(timeout, 0, sizeof(stTimeoutItemLink_t)); for (int i=0; i<ret; i++) { stTimeoutItem_t *item = (stTimeoutItem_t*)result−>events[i].data.ptr; if (item−>pfnPrepare) { item−>pfnPrepare(item, result−>events[i], active); } else { AddTail(active, item); } } unsigned long long now = GetTickMS(); TakeAllTimeout(ctx−>pTimeout, now, timeout); stTimeoutItem_t *lp = timeout−>head; while (lp) { lp−>bTimeout = true; lp = lp−>pNext; } Join<stTimeoutItem_t, stTimeoutItemLink_t>(active, timeout); lp = active−>head; while (lp) { PopHead<stTimeoutItem_t, stTimeoutItemLink_t>(active); if (lp−>pfnProcess) { lp−>pfnProcess(lp); } lp = active−>head; } } }
第 6 ⾏:呼叫 epoll_wait() 等待 I/O 就緒事件,為了配合時間輪⼯作,這裡的 timeout設定為 1 毫秒。第 8~10 ⾏:active 指標指向當前執⾏環境的 pstActiveList 佇列,注意這裡面可能已經有“活躍”的待處理事件。timeout 指標指向 pstTimeoutList 列表,其實這個 timeout 全是個臨時性的連結串列,pstTimeoutList 永遠為空。第 12~19 ⾏:處理就緒的⽂件描述符。如果使用者設定了預處理回撥,則呼叫pfnPrepare 做預處理(15 ⾏);否則直接將就緒事件 item 加⼊ active 佇列。實際上,pfnPrepare() 預處理函式內部也會將就緒 item 加⼊ active 佇列,最終都是加⼊到 active佇列等到 32~40 ⾏統⼀處理。第 21~22 ⾏:從時間輪上取出已超時的事件,放到 timeout 佇列。第 24~28 ⾏:遍歷 timeout 佇列,設定事件已超時標誌(bTimeout 設為 true)。第 30 ⾏:將 timeout 佇列中事件合併到 active 佇列。第 32~40 ⾏:遍歷 active 佇列,呼叫⼯作協程設定的 pfnProcess() 回撥函式 resume掛起的⼯作協程,處理對應的 I/O 或超時事件。這就是主協程的事件迴圈工作過程,我們看到它週而復始地 epoll_wait(),喚醒掛起的工作協程去處理定時器與 I/O 事件。這裡的邏輯看起來跟所有基於 epoll 實現的事件驅動網路框架並沒有什麼特別之處,更沒有涉及到任何協程排程演算法,由此也可以看到 libco 其實是一個很典型的非對稱協程機制。或許,從 call/return 的角度出發,而不是 resume/yield 去理解這種協程的執行機理,反而會有更深的理解吧。
8 效能評測待更新。。。
8.1 echo 實驗結果TBD
8.2 協程切換開銷評測TBD: 精確度量平均每次協程上下文切換的開銷(耗時納秒數與 CPU 時鐘週期數), 與 boost、libtask 的對比。
小結最後,透過本文的分析,希望讀者應該能真正 libco 的執行機理。對於喜歡動手實戰的同學來說,如果你打算在未來的實際專案中使用它的話,能夠做到心中有底,能繞過一些明顯的坑,萬一遇到問題時也能快速地解決。對於那些希望更深入理解各種協程工作原理的同學來說,希望本文的分析能起到拋磚引玉的作用。建議親自動手,下載原始碼編譯,閱讀例子程式碼並執行一下,必要時直接閱讀原始碼可能會更有幫助。