C++協程概述

    本篇文章記錄了,我在學習C/C++實現協程封裝過程的新得體會,以及對協程的理解。一開始對知道“協程”這個概念實在go語言裏面,很多資料對其的描述都是“輕量級的用戶態線程”。

    首先,用戶態和內核態分別是程序在運行過程中的兩種狀態,如果線程在用戶進程地址空間的狀態下中執行程序,則稱爲用戶態,一旦發生系統調用、中斷、異常等事件,就會由用戶態轉換到內核態,進入到內核地址空間去執行函數(系統調用函數、中斷處理函數、異常處理函數等)。也就是說協程之間的切換是在用戶態下實現的,在用戶態下實現了當前執行停止,保存當前狀態,引導邏輯流去執行另一個函數等一系列切換過程。

協程概述

    協程與線程的關係可以類比線程與進程的關係:

  1. 一個進程中可以有多個線程;同時一個線程中也可以有多個協程
  2. 每個線程有自己的私有棧,用來保存函數參數、返回地址、寄存器狀態等,同時線程也可以共享同一個進程中的資源,如堆、數據段、代碼段等;每個協程也可以有自己的棧,也可以共享線程進程的資源。
  3. 每個線程都有控制塊存儲其屬性,對其進行調度;協程同樣也需要

    協程和線程最大區別在於,線程是操作系統調度的最小單位,也就是說線程在OS的調度下擁有併發性,而協程只是“用戶態的線程“,OS並不會直接對其進行調度干預,也就是說協程是串行執行的,只有一個邏輯流。這時候就會想到,既然是串行的,那協程的高效性體現在哪裏呢?實際上,協程最大的用處在於處理IO密集型任務,而非CPU密集型任務,因爲當需要大量調用系統調用的時候,就可以在主協程中yield,讓cpu邏輯流轉而執行其他協程,當有時鐘觸發或者資源到達時候,再回到主協程繼續執行。因此,目前協程主要用於高性能服務器端處理高併發的情景。

    協程可以根據調度方法主要分成兩種:對稱協程和非對稱協程。對稱協程顧名思義就是每個協程都是平等的,沒有主調協程和被調協程的區別,因此大部分的對稱協程都會有一個調度器,調度器按照一定的調度算法處理協程,go語言中的Goroutine即爲典型。非對稱協程就是存在調用者協程和被調用者協程的區別,只有被調用者主動yield,cpu纔會返回調用者,而不會去調用其他的協程,以libco爲例。

    在C++中,目前實現的上下文切換主要有三種方式:

  1. ucontext、fiber,採用提供的api,這種方式較爲簡單便捷,缺點在於不太高效
  2. 用匯編自己實現上下文切換,性能高效,但是較爲底層以致於移植性較差
  3. setjump、longjump

    協程棧的方式。線程棧的大小是配置文件決定的,默認情況下爲8MB,如果協程也按照線程這種靜態棧的方式,如果一個線程中申請千萬級別的協程就會出現爆棧,非常不方便。目前協程棧實現主要有三種方式:

  1. 靜態棧,固定協程棧的大小
  2. 拷貝棧,先固定協程棧的大小,如果發現棧空間不夠,就新擴展一個大內存,將其都拷貝過去
  3. 共享棧,多個協程共享一個內存棧,每次需要運行當前協程的時候,將協程的上下文拷貝到共享棧中,協程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

 

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