基於雲風協程庫的協程原理解讀

協程原理

協程的本質都是通過修改 ESP 和 EIP 指針來實現的。其理論上還是單線程在運行,要想實現真正的併發,其實是需要多個CPU才能的。併發是並行的充分條件,併發是在程序級別上實現的,而並行是在機器級別上實現的,要想實現並行,程序上必須以併發實現,但是程序上實現了併發並不代表是真正的並行,當只有一個CPU的時候還是屬於單線程。

程序在CPU上運行時依賴2個條件:

堆棧指針 ESP 寄存器,指向當前指令需要的數據

指令指針 EIP 寄存器,指向當前需要運行的指令

這兩個寄存器指針的改變可以修改當前需要加載到 CPU 運行的指令和數據,當某個操作陷入到耗時的等待中時,通過修改這兩個指針即可讓出CPU,交給其他的任務去使用,每個任務都必須主動的讓出CPU,然後等待下一次的調度來繼續未完成的任務,這樣就可以最大程度的利用CPU,當一個任務等待過程非常短的時候,就出現了多個任務並行運行的效果,也就是協程。
目前常見的協程庫有云風的coroutine , libtask 庫,騰訊的 libco ,以下以最簡單的 coroutine 庫講解一下。

雲風的協程庫實現

C語言實現協程主要依賴於一組 glibc 庫裏的上下文操作函數:

getcontext()  : 獲取當前context
setcontext()  : 切換到指定context
makecontext() : 設置 函數指針 和 堆棧 到對應context保存的sp和pc寄存器中,調用之前要先調用 getcontext()
swapcontext() : 保存當前context,並且切換到指定context

可以看下 makecontext 的原理

void makecontext(ucontext_t *uc, void (*fn)(void), int argc, ...)
{
    int i, *sp;
    va_list arg;
    // 將函數參數陸續設置到r0, r1, r2 .. 等參數寄存器中
    sp = (int*)uc->uc_stack.ss_sp + uc->uc_stack.ss_size / 4;
    va_start(arg, argc);
    for(i=0; i<4 && i<argc; i++)
        uc->uc_mcontext.gregs[i] = va_arg(arg, uint);
    va_end(arg);
    // 設置堆棧指針到sp寄存器
    uc->uc_mcontext.gregs[13] = (uint)sp;
    // 設置函數指針到lr寄存器,切換時會設置到pc寄存器中進行跳轉到fn
    uc->uc_mcontext.gregs[14] = (uint)fn;
}

因此我們可以知道一個協程就是一組包含了上下文運行環境和一個私有棧的結構。在設計時一個協程需要有以下數據成員

struct coroutine {
    coroutine_func func;    //協程運行主體函數
    void *ud;               //func 的參數
    ucontext_t ctx;         //該協程的上下文信息
    struct schedule * sch;  //對應的調度器
    ptrdiff_t cap;          //協程容量
    ptrdiff_t size;         //協程實際大小
    int status;             //運行狀態: 初始化->
    char *stack;            //棧
};

由於每個協程需要自己主動讓出CPU,至於讓出的CPU交給誰運行,是由調度器來決定的。所以還需要一個調度者來管理這些協程,包括保存,切換協程等。

struct schedule {
    char stack[STACK_SIZE]; //棧大小
    ucontext_t main;        //當前上下文
    int nco;                //協程數
    int cap;                //協程棧容量
    int running;            //是否正在運行
    struct coroutine **co;  //協程數組
};

協程的運行是一個有限狀態機,可以簡單的將狀態分爲以下四種:

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

協程操作集:

void coroutine_resume(struct schedule *, int id);//恢復協程的運行,從主協程跳到子協程
void coroutine_yield(struct schedule *);         //掛起當前協程,讓出CPU,從子協程跳到主協程

最開始初始化一個協程時,狀態都是READY 的,協程調度器的狀態是非 running 的調度器主要有以下操作:coroutine_resume() : 恢復某個協程的運行。

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) {
    case COROUTINE_READY:
        //ready 狀態下,第一次切換,要設置該協程的棧空間。
        //1. 初始化當前的上下文
        getcontext(&C->ctx); 
        //2. 指定執行函數,
        C->ctx.uc_stack.ss_sp = S->stack;
        C->ctx.uc_stack.ss_size = STACK_SIZE;
        //3. 指定執行完之後要調轉的地方
        C->ctx.uc_link = &S->main;
        S->running = id;
        C->status = COROUTINE_RUNNING;
        uintptr_t ptr = (uintptr_t)S;
        //4. makecontext() 創建協程
        makecontext(&C->ctx, (void (*)(void)) mainfunc, 2, (uint32_t)ptr, (uint32_t)(ptr>>32));
        //5. swapcontext() 將當前上下文保存在 main 中,並切換到 C->ctx 中去執行
        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;
        //suspend 狀態就很簡單了,直接用 swapcontext() 切換到 C->ctx 中去執行。
        swapcontext(&S->main, &C->ctx);
        break;
    default:
        assert(0);
    }  
}

協程的初始化和切換操作都很簡單,主要就是調用 getcontext(), makecontext() , swapcontext() 。

最難理解的是如何保存和恢復協程的運行棧。

在此先看下linux的內存佈局,程序的堆棧是向下生長的,最低部從下到上依次是 text 段,data 段,bss 段之類的。
這裏寫圖片描述

保存現場

協程在從 RUNNING 到 COROUTINE_SUSPEND 狀態時需要保存運行棧,即調用 coroutine_yield 之後掛起協程,讓出CPU的過程。

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);
}

coroutine_yield 調用 _save_stack 來保存運行棧:

static void _save_stack(struct coroutine *C, char *top) {
    printf("top:%p \n", top);
    //獲取當前棧底,top 是棧頂,top-dummy 即該協程的私有棧空間
    char dummy = 0; 
    printf("dummy:%p \n", &dummy);
    assert(top - &dummy <= STACK_SIZE);
    printf("stack_size :%d \n", top-&dummy);
    //如果協程私有棧空間大小不夠放下運行時的棧空間,則要重新擴容
    if (C->cap < top - &dummy) {
        free(C->stack);
        C->cap = top-&dummy;
        C->stack = malloc(C->cap);
    }
    C->size = top - &dummy;
    //從棧底到棧頂的數據全都拷到 C->stack 中
    memcpy(C->stack, &dummy, C->size);
}

top 代表當前協程運行棧的棧頂,從 coroutine_yield 我們知道

top = S->stack + STACK_SIZE

爲什麼是 S->stack + STACK_SIZE 呢,因爲該協程在初始化的時候設置爲:

c
C->ctx.uc_stack.ss_sp = S->stack;
C->ctx.uc_stack.ss_size = STACK_SIZE;
makecontext(&C->ctx, (void (*)(void)) mainfunc, 2, (uint32_t)ptr, (uint32_t)(ptr>>32));

即表示該協程棧的棧頂設置爲 S->stack,mainfunc 的運行使用 S->stack 作爲棧頂,大小爲 STACK_SIZE 。
這裏寫圖片描述

由此可以看到,schedule 的 stack[STACK_SIZE] 是子協程運行的公共棧空間,但是每個協程的棧不一樣,所以需要單獨建立一個私有棧空間來保存執行現場。

下面回到_save_stack函數

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

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

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

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

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

這裏寫圖片描述

恢復現場

接下來再看協程從 SUSPEND 到 RUNNING 狀態時,如何恢復當時的運行棧,由於C 的運行時棧空間始終是在

S->main中的,因此恢復棧空間,其實就是將各自私有的C->stack 空間中的數據恢復到S->main中:

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

最後保存和恢復均要注意棧的生長方向

memcpy(C->stack, &dummy, C->size);//保存
memcpy(S->stack + STACK_SIZE - C->size, C->stack, C->size); //恢復

資料:

http://www.chenzhenianqing.cn/articles/1204.html

http://blog.csdn.net/waruqi/article/details/53201416

打造業務零入侵的自用協程庫(待續)

爲了將協程庫合併到業務中,首先肯定不能讓業務自己調用yield,一個是對使用者的要求高,一個是對現有業務代碼的侵入性太高。實際上考慮到現實場景,真正需要協程切換的地方無非是耗時的IO 操作,而分佈式的IO 操作基本上就是網絡請求,不論是數據庫也好還是業務RPC也好,如果底層都是使用同一套事件觸發機制,就可以嘗試將協程無縫集成到底層中去,做到對業務代碼零入侵。

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