從雲風的coroutine庫學習協程

協程又被稱爲微線程,不過其實這樣的稱呼無形中爲理解協程增加了一點阻礙。協程本質上是在一個線程裏面,因此不管協程數量多少,它們都是串行運行的,也就是說不存在同一時刻,屬於同一個線程的不同協程同時在運行。因此它本身避免了所有多線程編程可能導致的同步問題。

協程的行爲有點像函數調用,它和函數調用的不同在於,對於函數調用來說,假如A函數調用B函數,則必須等待B函數執行完畢之後程序運行流程纔會重新走回A,但是對於協程來說,如果在協程A中切到協程B,協程B可以選擇某個點重新回到A的執行流,同時允許在某個時刻重新從A回到B之前回到A的那個點,這在函數中是不可能實現的。因爲函數只能一走到底。用knuth的話來說:

子程序就是協程的一種特例

既然允許協程中途切換,以及後期重新從切換點進入繼續執行,說明必須有數據結構保存每個協程的上下文信息。這就是ucontext_t,而linux中包含以下幾個系統函數對ucontext_t進行初始化,設置,以及基於ucontext_t進行協程間的切換:

  1. int getcontext(ucontext_t *);
  2. int setcontext(const ucontext_t *);
  3. void makecontext(ucontext_t , (void )(), int, …);
  4. int swapcontext(ucontext_t , const ucontext_t );

下面根據雲風的精簡協程庫來說明如何用這些函數和ucontext_t來實現一個協程池。

coroutine的實現分析

coroutine裏面主要是由協程管理結構schedule和以下幾個核心函數組成 :

  1. coroutine_resume
  2. coroutine_yield

coroutine由用戶協程和一個管理作用的協程組成。每次協程切換時,用戶協程必須先切換到管理協程,然後管理協程再負責切換到其他的用戶協程,不能直接從用戶協程切換到其他用戶協程。從以上兩個函數來說,coroutine_resume就是從管理協程切換到用戶協程的入口,coroutine_yield是從用戶協程切換到管理協程的入口。

下面着重分析一下這兩個函數:

void 
coroutine_resume(struct schedule * S, int id) {
    assert(S->running == -1);
    assert(id >=0 && id < S->cap);
    struct coroutine *C = S->co[id];
    if (C == NULL)
        return;
    int status = C->status;
    switch(status) {
    //第一次被切換到的用戶協程處於COROUTINE_READY
    case COROUTINE_READY:
        getcontext(&C->ctx);
        C->ctx.uc_stack.ss_sp = S->stack;
        C->ctx.uc_stack.ss_size = STACK_SIZE;
        C->ctx.uc_link = &S->main;
        S->running = id;
        C->status = COROUTINE_RUNNING;
        uintptr_t ptr = (uintptr_t)S;
        makecontext(&C->ctx, (void (*)(void)) mainfunc, 2, (uint32_t)ptr, (uint32_t)(ptr>>32));
        swapcontext(&S->main, &C->ctx);
        break;
    case COROUTINE_SUSPEND:
        memcpy(S->stack + STACK_SIZE - C->size, C->stack, C->size);
        S->running = id;
        C->status = COROUTINE_RUNNING;
        swapcontext(&S->main, &C->ctx);
        break;
    default:
        assert(0);
    }
}

coroutine中協程有三種狀態:

#define COROUTINE_DEAD 0
#define COROUTINE_READY 1
#define COROUTINE_RUNNING 2
#define COROUTINE_SUSPEND 3

協程第一次運行之前處於COROUTINE_READY 狀態,正在運行的協程處於COROUTINE_RUNNING 狀態,讓出CPU,切換到其他協程的協程處於COROUTINE_SUSPEND。

當管理協程調用coroutine_resume試圖啓動一個處於COROUTINE_READY 的協程時,coroutine首先初始化這個用戶協程對應的context,然後通過調用makecontext設置這個用戶協程的入口地址。最後調用swapcontext完成管理協程和用戶協程的切換,swapcontext(&S->main, &C->ctx) 將當前的上下文保存在S->main中,然後切換到C->ctx對應的執行流。

這裏先省略掉後面的代碼,先解析coroutine_yield,回頭再繼續看coroutine_resume。


void
coroutine_yield(struct schedule * S) {
    int id = S->running;
    assert(id >= 0);
    struct coroutine * C = S->co[id];
    assert((char *)&C > S->stack);
    _save_stack(C,S->stack + STACK_SIZE);
    C->status = COROUTINE_SUSPEND;
    S->running = -1;
    swapcontext(&C->ctx , &S->main);
}

因爲協程的切換完全在用戶態,和系統內部的線程不同,它是非搶佔式的,因此必須依靠用戶自覺讓出CPU 才能讓其他協程運行,coroutine_yield函數的作用就是主動讓出CPU,每當用戶協程想讓出CPU時,就調用coroutine_yield,coroutine_yield將完成從用戶協程切到管理協程的動作。

回到coroutine_yield函數,程序首先獲得正在運行的協程的控制結構體,然後將該協程的運行上下文(主要是堆棧)保存在這個結構體中,最後通過swapcontext函數切換到控制協程。我們可以看到,爲了能夠讓這個用戶協程下次還能回到這個執行點繼續執行,必須保留它的執行上下文,這部分工作主要是在_save_stack函數中完成。下面看一下_save_stack函數的實現:

static void
_save_stack(struct coroutine *C, char *top) {
    char dummy = 0;
    assert(top - &dummy <= STACK_SIZE);
    if (C->cap < top - &dummy) {
        free(C->stack);
        C->cap = top-&dummy;
        C->stack = malloc(C->cap);
    }
    C->size = top - &dummy;
    memcpy(C->stack, &dummy, C->size);
}

該函數的第二個指針代表當前協程運行棧的棧頂,從coroutine_yield我們知道

top = S->stack + STACK_SIZE

爲什麼會是這樣?回到coroutine_resume函數,在coroutine_resume函數中,對於即將被調度的用戶協程,會做如下的初始化:

        C->ctx.uc_stack.ss_sp = S->stack;
        C->ctx.uc_stack.ss_size = STACK_SIZE;

這兩行代碼的意思就是將該協程的棧頂設置爲S->stack + STACK_SIZE。而下面的

makecontext(&C->ctx, (void (*)(void)) mainfunc, 2, (uint32_t)ptr, (uint32_t)(ptr>>32));

設置該協程的入口地址,也就是說該協程啓動時,將從mainfunc進入,並使用S->stack + STACK_SIZE作爲棧頂控制協程的運行棧。
從上面對用戶協程的棧頂初始化過程中可以看到,用戶協程在運行過程中用的都是struct schedule的stack[STACK_SIZE]作爲棧空間,由於每個協程運行時都會使用到這個空間,因此還必須爲每個協程單獨建立一個屬於自己的棧空間。下面回到_save_stack函數

C->cap表示該控制結構對應的協程的私有棧空間的大小,因此

if(C->cap < top - &dummy)

的作用是判斷該用戶協程的私有棧空間能否大於它運行時棧空間。前面說過top 表示棧頂,而dummy表示該用戶協程當前的棧底(棧由高地址向低地址生長),所有top-dummy就表示該用戶協程運行時棧所佔空間。如果用來保存私有棧的C->stack的大小不能放下運行時棧空間,則需要重新申請。

memcpy(C->stack, &dummy, C->size);

這裏就將運行時棧的數據全部拷貝到該協程控制結構的C->stack中,這樣,以後別的協程使用S->stack做運行時棧空間時,就不會覆寫掉該協程原來棧中的數據了。

回到coroutine_yield,現在當前的協程的棧記錄已經被保存在了C->stack中,最後調用

swapcontext(&C->ctx , &S->main);

切換到控制協程。這個函數會保存上下文相關的寄存器到C->ctx中。下次這個協程被重新啓動時,就會從這裏繼續運行下去(調用coroutine_yield函數的下一條語句)。

在前面的coroutine_resume中我們知道,上次main協程是從

swapcontext(&S->main, &C->ctx);
        break;

切換出去的,因此回到main協程之後,將會從break語句開始執行。一般來說,買你協程會不斷循環調用coroutine_resume啓用協程池中的協程,直到所有的用戶協程結束。因此我們假設main協程再次從coroutine_resume進入,此時main已經選好了下一個被調度的用戶協程,他的控制結構是:

struct coroutine *C = S->co[id];

如前面所說,如果該協程之前沒有運行過,則走

case COROUTINE_READY:

就是一些進行初始化的操作,如果前面運行過了,但還沒有死亡,則走,

case COROUTINE_SUSPEND:

下面我們看一下這個分支:

首先爲了啓動這個協程,必須恢復它的上下文信息,上下文信息包括:
1. 運行棧
2. 寄存器

在case COROUTINE_READY中我們知道

C->ctx.uc_link = &S->main;

C的運行時棧空間始終是在S->main中的,因此恢復棧空間,其實就是將coroutine_yield中保存到各自私有的C->stack空間中的數據恢復到S->main中:

memcpy(S->stack + STACK_SIZE - C->size, C->stack, C->size);

這裏有點講究,因爲棧是從高地址向低地址生長的,所以需要從高地址向低地址拷貝。[記住_save_stack中在保存棧空間時,C->stack中的低地址對應運行時棧空間(S->stack-S->stack+STACK_SIZE )的高地址 : memcpy(C->stack, &dummy, C->size);]

coroutine_resume最後調用swapcontext重新啓動了一個新的用戶協程,這樣就完成了從一個協程切換到另一個協程的所有操作。


綜上

本質上協程就是通過一個記錄上下文的結構和若干可以切換,修改,獲取上下文信息的函數來達到用戶態切換執行流的功能。這裏一個核心是我們可以爲各個協程定義一個連續內存空間,然後將這個連續內存空間的地址(uc_stack.ss_sp = S->stack)和大小(uc_stack.ss_size)設置到上下文結構中作爲該協程的運行棧空間,通過這兩個變量就可以計算出棧頂指針(uc_stack.ss_sp + uc_stack.ss_size),所以本質上在用戶態定義了棧空間。在協程切換是,直接使用上下文結構中的棧頂指針作爲esp,完成多個執行流之間的棧空間分離。自然就可以做到模擬線程的效果。

這裏附一個傳送門,介紹swapcontext, makecontext, setcontext, getcontext的實現,這樣可以更好理解協程的原理。

http://anonymalias.github.io/2017/01/09/ucontext-theory/

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