教你在 C 語言上編寫自己的協程

thomaston發表於2019-05-13

協程介紹

總所周知,協程這個概念已經是服務端開發領域中耳熟能詳的名詞了。說協程是一組程式元件,以往的多執行緒程式設計有個特點是需要來回進行系統級別的來回上下文切換,造成很大的系統開銷,不僅如此,很多操作我們還需要保證原子性,加鎖,鎖這個東西嘛,本來就是個坑,能不能最好還是不要用了。協程就是這麼牛,能解決上述出現的所有問題,因為協程是使用者態輕量級的多執行緒,上下文切換的開銷是非常小的,而且更重要的是,是使用者主動去進行切換的,因此不存在這個操作執行到一半,就被另一個執行緒給打斷了。那麼協程常見的用例都有哪些呢?當然可以用來做狀態機,可讀性很高。也可以用來做角色模型和產生器。大牛們也在不斷在造輪子,比如有非常牛逼的雲風大叔也造了一個簡易的協程框架,程式碼非常精簡,非常適合學習,真心點個贊!後面我們會著重分析雲風大叔的程式碼。

ucontext 上下文

下面我們來介紹幾個相關的 C 庫函式:
setcontextgetcontextmakecontextswapcontext 是用來做 context 控制的。setcontext 可以被看做是一個 setjmp/longjmp 的高階版本。

在 ucontext.h 這個系統的標頭檔案上定義了 ucontext 的結構體,我們可以看到結構體如下所示:

typedef struct ucontext {
    struct ucontext *uc_link;
    sigset_t         uc_sigmask;
    stack_t          uc_stack;
    mcontext_t       uc_mcontext;
    ...
} ucontext_t;

這是最重要的結構體,讓我們來分析一下這個這個結構體。
如果上下文被用 makecontext 來建立時,uc_link 指向的是當前上下文退出時候將會被 resumed 的上下文。uc_sigmask 被用來儲存在上下文中一組被阻塞的訊號, uc_stack 是一個被上下文使用的 stackuc_mcontext 用來儲存執行狀態,包括所有的暫存器和 CPU flags、指令指標和棧指標。

函式介紹

int setcontext(const ucontext_t *ucp)

這個函式會把當前上下文轉移到上下文 ucp 中。該函式不會返回,從 ucp 這個指標中執行。

int getcontext(ucontext_t *ucp)

該函式會儲存當前的上下文資訊到 ucp 中。

void makecontext(ucontext_t *ucp, void *func(), int argc, ...)

在被之前使用 getcontext 初始化後的 ucp 中設定一個替代的控制執行緒, ucp.uc_stack 成員應該被指向合適大小的棧,常量 SIGSTKSZ 通常會被使用。當使用 setcontextswapcontext 跳轉的時候,執行將從 func 指向的函式的入口點開始,當然別忘了指定 argc 引數,表示引數個數。當 func 終止的時候,控制權被返回到 ucp.uc_link

int swapcontext(ucontext_t *oucp, ucontext_t *ucp)

轉到 ucp 上下文中執行並儲存當前上下文到 oucp

下面我們來看一個簡單的示例:

#include <stdio.h>
#include <ucontext.h>
#include <unistd.h>

int main(int argc, const char *argv[]){
    ucontext_t context;
    
    getcontext(&context);
    puts("Hello world");
    sleep(1);
    setcontext(&context);
    return 0;
}

結果的輸出是:
Hello world
Hello world
Hello world
Hello world

是不是感覺這個世界很奇妙!

cloudwu C 協程

現在請把目光轉移到 c 協程
主要定義了幾個資料結構和函式,現在來分析一下如何實現的。
先建立一個結構體

struct schedule {
    char stack[STACK_SIZE];   // 執行的協程的棧
    ucontext_t main;          // 下個要切換的協程的上下文狀態
    int nco;                  // 當前協程的數目
    int cap;                  // 協程總容量
    int running;              // 當前執行的協程
    struct coroutine **co;    // 協程陣列,指向指標的指標 co
};
struct coroutine {
    coroutine_func func;      // 呼叫函式
    void *ud;                 // 使用者資料
    ucontext_t ctx;           // 儲存的協程上下文狀態
    struct schedule * sch;    // 儲存struct schedule指標
    ptrdiff_t cap;            // 上下文切換時儲存的棧的容量
    ptrdiff_t size;           // 上下文切換時儲存的棧的大小 size <= cap
    int status;               // 協程狀態
    char *stack;              // 儲存的棧
};

先呼叫 coroutine_open 來建立一個 schedule 結構體

struct schedule * 
coroutine_open(void) {
    struct schedule *S = malloc(sizeof(*S));  // S 是指標,*S 就是指標指向的結構體。
    S->nco = 0;
    S->cap = DEFAULT_COROUTINE;
    S->running = -1;
    S->co = malloc(sizeof(struct coroutine *) * S->cap);
    memset(S->co, 0, sizeof(struct coroutine *) * S->cap);
    return S;
}

後面呼叫 coroutine_new 來建立協程,如果當前的協程數目小於容量,直接加進去,否則,擴容為當前的2倍,並返回 id
後面就可以開始 resume 了,內部的實現細節是,先看看要執行的協程的狀態是什麼,如果是 ready 的話,那就先獲取當前的上下文資訊到協程的ctc中,設定棧,設定改協程終止時下一個要執行的協程,此處為 &S->main。設定狀態為正在執行。設定該上下文指向的函式,此處為 mainfunc,利用 swapcontext 去執行上下文 &C->ctx 並儲存當前的上下文資訊到 &S->main

總的來說,雲風大叔寫的程式碼十分通俗易懂,如有不明白的地方請留言,我將會盡快幫助您解答。

相關文章