本篇文章記錄了,我在學習C/C++實現協程封裝過程的新得體會,以及對協程的理解。一開始對知道“協程”這個概念實在go語言裏面,很多資料對其的描述都是“輕量級的用戶態線程”。
首先,用戶態和內核態分別是程序在運行過程中的兩種狀態,如果線程在用戶進程地址空間的狀態下中執行程序,則稱爲用戶態,一旦發生系統調用、中斷、異常等事件,就會由用戶態轉換到內核態,進入到內核地址空間去執行函數(系統調用函數、中斷處理函數、異常處理函數等)。也就是說協程之間的切換是在用戶態下實現的,在用戶態下實現了當前執行停止,保存當前狀態,引導邏輯流去執行另一個函數等一系列切換過程。
協程概述
協程與線程的關係可以類比線程與進程的關係:
- 一個進程中可以有多個線程;同時一個線程中也可以有多個協程
- 每個線程有自己的私有棧,用來保存函數參數、返回地址、寄存器狀態等,同時線程也可以共享同一個進程中的資源,如堆、數據段、代碼段等;每個協程也可以有自己的棧,也可以共享線程進程的資源。
- 每個線程都有控制塊存儲其屬性,對其進行調度;協程同樣也需要
協程和線程最大區別在於,線程是操作系統調度的最小單位,也就是說線程在OS的調度下擁有併發性,而協程只是“用戶態的線程“,OS並不會直接對其進行調度干預,也就是說協程是串行執行的,只有一個邏輯流。這時候就會想到,既然是串行的,那協程的高效性體現在哪裏呢?實際上,協程最大的用處在於處理IO密集型任務,而非CPU密集型任務,因爲當需要大量調用系統調用的時候,就可以在主協程中yield,讓cpu邏輯流轉而執行其他協程,當有時鐘觸發或者資源到達時候,再回到主協程繼續執行。因此,目前協程主要用於高性能服務器端處理高併發的情景。
協程可以根據調度方法主要分成兩種:對稱協程和非對稱協程。對稱協程顧名思義就是每個協程都是平等的,沒有主調協程和被調協程的區別,因此大部分的對稱協程都會有一個調度器,調度器按照一定的調度算法處理協程,go語言中的Goroutine即爲典型。非對稱協程就是存在調用者協程和被調用者協程的區別,只有被調用者主動yield,cpu纔會返回調用者,而不會去調用其他的協程,以libco爲例。
在C++中,目前實現的上下文切換主要有三種方式:
- ucontext、fiber,採用提供的api,這種方式較爲簡單便捷,缺點在於不太高效
- 用匯編自己實現上下文切換,性能高效,但是較爲底層以致於移植性較差
- setjump、longjump
協程棧的方式。線程棧的大小是配置文件決定的,默認情況下爲8MB,如果協程也按照線程這種靜態棧的方式,如果一個線程中申請千萬級別的協程就會出現爆棧,非常不方便。目前協程棧實現主要有三種方式:
- 靜態棧,固定協程棧的大小
- 拷貝棧,先固定協程棧的大小,如果發現棧空間不夠,就新擴展一個大內存,將其都拷貝過去
- 共享棧,多個協程共享一個內存棧,每次需要運行當前協程的時候,將協程的上下文拷貝到共享棧中,協程yield的時候,再把上下文拷貝出來並保存好。缺點在於,協程切換較慢,需要多次協程棧拷貝工作。
Libco解析
libco是微信最早開源的c++協程庫,利用匯編實現上下文的方式,同時協程棧同時採用靜態棧和共享棧並存的方式。
源碼分析
首先來看幾個重要的函數聲明和結構體。下圖的幾個函數都是最常用的協程調用接口,關於其具體使用,詳見例子example_*。首先libco採用了類pthread的接口設計,使得用過poxis線程的人很容易理解接口含義。
int co_create( stCoRoutine_t **co,const stCoRoutineAttr_t *attr,void *(*routine)(void*),void *arg ); //創建協程,是分配並初始化stCoRoutine_t結構體、設置任務函數指針、分配一段“棧”內存,以及分配和初始化coctx_t
void co_resume( stCoRoutine_t *co ); //開啓非對稱協程,將當前協程掛起,運行目標協程,本質上是串行操作,並沒有併發
void co_yield( stCoRoutine_t *co ); //將cpu讓給當時調用本協程的協程,也就是相當與回退
void co_yield_ct(); //ct = current thread,功能和co_yield一致
void co_release( stCoRoutine_t *co ); //銷燬協程
void co_reset(stCoRoutine_t * co); //重置協程
stCoRoutine_t *co_self(); //返回當前協程
int co_poll( stCoEpoll_t *ctx,struct pollfd fds[], nfds_t nfds, int timeout_ms ); //定時等待,內部也用epoll_wait定時
void co_eventloop( stCoEpoll_t *ctx,pfn_co_eventloop_t pfn,void *arg ); //循環等待,內置epoll_wait,等待事件觸發
接下來,看一下關鍵結構體。 註釋都標得很明確了。
struct stCoRoutineEnv_t //線程級的資源,同一個線程上的協程共享
{
stCoRoutine_t *pCallStack[ 128 ]; //該線程上,每個協程的指針
int iCallStackSize;
stCoEpoll_t *pEpoll; //epoll指針
//for copy stack log lastco and nextco
stCoRoutine_t* pending_co; //pending的協程
stCoRoutine_t* occupy_co; //正在佔用cpu的協程
};
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];
};
共享棧
共享棧這個概念最早源自於這篇論文《A Portable C++ Library for Coroutine Sequencing》。在libco中,用戶在創建新的協程時,可以選擇讓其擁有一個獨佔的協程棧,或者是與其它任意數量的協程一起共享一個執行棧。共享棧的優點在於不用每個協程都佔有獨立的空間,當一個線程中協程數目激增的時候,共享棧不會爆棧,以此同時,共享棧的缺點在於,協程之間的上下文切換較爲花費時間,因爲每次在切換的時候通過把協程棧的內容copy-in/copy-out來實現棧的切換。
共享棧的結構是一個數組,它裏面有 count 個元素,每個元素都是一個指向一段內存的指針 stStackMem_t 。在新分配協程時 (co_create_env) ,它會從剛剛分配的 stShareStack_t 中,按 RoundRobin 的方式(alloc_idx++)取一個 stStackMem_t 出來,然後就算作是這個協程自己的棧。顯然,這個時候這個空間是與其它協程共享的,因此叫"共享棧"。
struct stStackMem_t //私有棧
{
stCoRoutine_t* occupy_co; //每個私有棧都對應一個協程
int stack_size;
char* stack_bp; //stack_buffer + stack_size
char* stack_buffer;
};
struct stShareStack_t //共享棧
{
unsigned int alloc_idx; //index,指向數組中的某個元素
int stack_size;
int count;
stStackMem_t** stack_array; //一維數組,數組大小爲count,數組中的每個元素都是一個stStackMem_t的指針
};
hook
這個方法對於每個協程庫來說都是至關重要,由於協程只是執行在線程上,並沒有併發的特性,所以如果不hook住函數的話,其系統調用依舊會花費大量時間,這樣並沒有體現出協程的高效性。hook的意思就是對於這些系統調用進行重載,讓其能適合我們自己編寫的協程庫,並用定時器、epoll_wait等方法將阻塞在系統調用的協程yield,轉而執行另一個協程,當事件發生時候,再回頭繼續執行。libco中只hook了幾個重要的IO函數,可以發現,協程能體現出性能的地方在於IO,所以說協程主要用於網絡編程,高性能服務器,其對於IO密集型程序有較大的幫助,對於CPU密集型的程序並沒有明顯的幫助。
//5.hook syscall ( poll/read/write/recv/send/recvfrom/sendto )
void co_enable_hook_sys(); //開啓hook
void co_disable_hook_sys(); //關閉hook
bool co_is_enable_sys_hook(); //返回是否開啓hook
ucontext解析
另一種c++實現上下文切換的就是ucontext方法。
ucontext系列四個函數
getcontext(ucontext_t *ucp) 獲取當前的上下文保存到ucp
setcontext(const ucontext_t *ucp) 直接到ucp所指向的上下文中去執行
makecontext(ucontext_t *ucp, void (*func)(), int argc, ...) 創建一個新的上下文
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp) 將當前上下文保存到oucp,然後跳轉到ucp上下文執行
typedef struct ucontext{
struct ucontext* uc_link; 當這個上下文終止後,指向運行的下一個上下文,如果爲NULL則終止當前線程
sigset_t uc_sigmask; 爲該上下文的阻塞信號集合
stack_t uc_stack; 該上下文使用的棧
mcontext_t uc_mcontext; 保持上下文的特定寄存器
...
}ucontext_t
ucontext例子實現交替打印10次,考慮協程之間的切換問題,可以從這裏看出,api使用較方便,但是效率較低。
#include <ucontext.h>
#include <stdio.h>
int stack[1024];
static int num = 1;
ucontext_t mn_cont,func_cont;
void func(){
while(1){
swapcontext(&mn_cont,&func_cont);
printf("func_context\n");
if(num++ >= 10){
break;
}
}
printf("outof func\n");
}
int main(){
getcontext(&mn_cont);
mn_cont.uc_link=nullptr;
mn_cont.uc_stack.ss_sp=stack;
mn_cont.uc_stack.ss_size=sizeof(stack);
makecontext(&mn_cont,func,0);
while(1){
swapcontext(&func_cont,&mn_cont);
printf("main_context\n");
}
printf("out of main\n");
return 0;
}
參考博客:
https://github.com/Tencent/libco
https://www.jianshu.com/p/837bb161793a
https://www.zhihu.com/question/52193579