跟羽夏去實現協程

寂静的羽夏發表於2024-05-04

寫在前面

  此係列是本人一個字一個字碼出來的,包括示例和實驗截圖。本人非計算機專業,可能對本教程涉及的事物沒有了解的足夠深入,如有錯誤,歡迎批評指正。 如有好的建議,歡迎反饋。碼字不易,如果本篇文章有幫助你的,如有閒錢,可以打賞支援我的創作。如想轉載,請把我的轉載資訊附在文章後面,並宣告我的個人資訊和本人部落格地址即可,但必須事先通知我

引入

協程(英語:coroutine)是計算機程式的一類元件,推廣了協作式多工的子例程,允許執行被掛起與被恢復。 相對子例程而言,協程更為一般和靈活,但在實踐中使用沒有子例程那樣廣泛。 協程更適合於用來實現彼此熟悉的程式元件,如協作式多工、異常處理、事件迴圈、迭代器、無限列表和管道。 —— 維基百科

  我相信很多人看完這個其實是看不明白什麼是協程的,提起協程,會經常拿執行緒作比較:

使用者級執行緒是協作式多工的輕量級執行緒,本質上描述了同協程一樣的概念。其區別,如果一定要說有的話,是協程是語言層級的構造,可看作一種形式的控制流程,而執行緒是系統層級的構造。 —— 維基百科

  做過併發同步相關程式設計開發的人肯定對執行緒多多少少的都會清楚一些。不過繼續後面的內容之前,我要增加繼續引入一個概念:狀態機。
  什麼是狀態機?維基百科解釋如下:

有限狀態機(英語:finite-state machine,縮寫:FSM)又稱有限狀態自動機(英語:finite-state automaton,縮寫:FSA),簡稱狀態機,是表示有限個狀態以及在這些狀態之間的轉移和動作等行為的數學計算模型。

  看起來很抽象的一個概念。其實狀態機也是一個應用於萬物的一個概念,打個比方:每個人都有狀態,比如心情的好壞、身體健不健康、勞不勞累,這些都是狀態。我們作為人每天的心情、身體狀態等都隨時發生著變化,不同時間都會有不同的狀態,所以人是一個狀態機。
  對於計算機來說,CPU是一個十分重要的器件,它擁有眾多暫存器。在執行程式碼時,暫存器的值不斷的發生變化,所以CPU也是一個狀態機。對於執行緒的切換,其實就是CPU把暫存器的狀態進行儲存,把之前儲存的另一個狀態重新載入到暫存器中繼續執行。

基礎

  好了,按照我寫博文的慣例,我要開始勸退啦。如果基礎不夠的話,要自己參考我的其他博文或者書籍補漏再回來,要不就不要繼續了。

  • AT&T 格式彙編(這次我要用它寫內聯彙編)
  • 64 位 Intel 彙編(32 位的最起碼會)
  • 會 C 語言和使用一些編譯器擴充套件(這次用 gcc)
  • x64 平臺下的呼叫約定

  我的相關博文:

  • 程序執行緒篇——簡述
  • 羽夏筆記—— AT&T 與 GCC
  • 深入 x64
  • x64 簡介

宣告

  當然,看完本篇之後或者在閱讀之前你擁有基礎,你可以實現自己版本的。我們這次介紹在Windows基於 Intel CPU 下的 64 位的實現。你可以實現Linux版本的,或者其他硬體平臺的比如ARM平臺或 32 位的硬體平臺,這都你隨意,因為它們的原理是相通的。
  我也有已經實現的兩個版本,一個是本篇要用的程式碼,我放到文章後面去了。等你寫完了之後再回去看作為完整參考。我還實現了一個跨WindowsLinux作業系統的 Intel CPU 下的 64 位一個協程庫,功能稍微多一點,感興趣你也可以看看,我也附到文章後面。我也會對該庫提出幾個問題來供你思考。
  強調一點:在你沒有親自寫完一個協程之前,不要看我的程式碼參考,一定不要看!寫完再看!
  如果你學過我的跟羽夏學 Win 核心系列的課程,裡面的程序執行緒篇其實就已經介紹了 Intel CPU 下的 32 位的實現。不妨在看完 x64 與 x86 不同之處之後,自己來實現一份協程庫之後,再來看看!

實現

  執行緒切換其實就是把執行緒的當前狀態儲存起來,然後載入到準備切換的執行緒狀態。程序的狀態其實就是CPU裡面一堆暫存器的值,只要對這些值透過某些方式進行儲存,之後載入之前儲存的,不就實現執行緒切換了嗎?
  為了儘可能的簡單,這次用純C程式碼來實現協程,就只實現協程建立和最簡單的排程,以及程式退出之後的善後清理資源操作。
  如果你學過我的跟羽夏學 Win 核心系列的程序執行緒篇,你知道執行緒的狀態是儲存在棧上的。如果你沒學過也沒關係,知道這個結論就行。所以定義一個協程首先有個棧,還得有個棧頂指標表示使用狀態。
  我們建立執行緒的時候必須交給一個函式指標來執行程式碼,當然也可以傳參。傳參就不在這裡搞了,因為涉及呼叫約定的問題,就不搞稍微增加複雜度的東西。
  當然,執行緒也有執行緒的狀態,比如執行中、掛起狀態、死亡狀態。綜上所述,這個最簡單的協程結構體就這麼定義出來了:

typedef void (*thread_pointer)();
typedef enum thread_state { DEAD, RUNNING, SLEEP } thread_state;
typedef struct thread {
    void *stack;
    void *stackpc;
    thread_pointer pc;
    thread_state state;
} thread;

  然後我們再定義一個陣列裝協程(簡單處理),還有一個指標指向當前執行的協程:

thread thread_table[THREAD_TABLE_MAX_SIZE];
thread *current_thread = &thread_table[0];

  如果沒有時鐘中斷,執行緒切換都是主動切換的,這通常發生在呼叫WinAPI的時候。所以,我們需要寫一個協程切換的函式:

#define THREAD_TABLE_MAX_SIZE (5)

static int swap_context_i = 0;

void swap_context_caller() {
    thread *new_thread = NULL;
    thread *old_thread = NULL;
    bool notfounded = true;
    while (true) {
        for (; swap_context_i < THREAD_TABLE_MAX_SIZE; swap_context_i++) {
            if (thread_table[swap_context_i].state == SLEEP) {
                old_thread = current_thread;
                new_thread = &thread_table[swap_context_i];
                current_thread = new_thread;
                swap_context(old_thread, new_thread);
                notfounded = false;
                break;
            }
        }
        if (notfounded) {
            swap_context_i = 0;
        } else {
            break;
        }
    }
}

  如果你有相關知識的學習很容易的發現,這其實是一個最簡單的排程器。swap_context是真正實現我們協程排程的函式,但這個函式是十分特殊的,定義如下:

__attribute__((naked)) __attribute__((fastcall)) void
swap_context(thread *old, thread *new);

  __attribute__((...))是 GNU 系列編譯器宣告屬性的一個擴充套件,naked是聲稱這個函式是裸函式,讓編譯器不要生成棧維護的程式碼。fastcall強調傳參請使用這個呼叫約定,當然在 x64 下,這個宣告是沒有用的,因為預設就是這個。
  好了,不囉嗦了,給出我寫的協程切換的核心函式:

#define DECLARE_STRUCT_OFFSET(type, member) [member] "i"(offsetof(type, member))

__attribute__((naked)) __attribute__((fastcall)) void
swap_context(thread *old, thread *new) {
    asm volatile("pushq %rax;"
                 "pushq %rbx;"
                 "pushq %rcx;"
                 "pushq %rdx;"
                 "pushq %rbp;"
                 "pushq %rsi;"
                 "pushq %r8;"
                 "pushq %r9;"
                 "pushq %r10;"
                 "pushq %r11;"
                 "pushq %r12;"
                 "pushq %r13;"
                 "pushq %r14;"
                 "pushq %r15;"
                 "pushfq");

    // rcx: old, rdx: new
    asm volatile("movq %%rsp,%c[stackpc](%%rcx);" ::DECLARE_STRUCT_OFFSET(
                     thread, stackpc)
                 : "rcx", "rdx");
    asm volatile("movq %0,%c[state](%%rcx);"
                 "incq %1;" ::"i"(SLEEP),
                 "m"(swap_context_i), DECLARE_STRUCT_OFFSET(thread, state)
                 : "rcx", "rdx");

    asm volatile("movq %c[pc](%%rdx),%%rax;"
                 "test %%rax,%%rax;"
                 //> if not first started
                 "jz pcnull%=;"
                 // r8 : function handler
                 "movq %%rax,%%r8;"
                 "xor %%eax,%%eax;"
                 "movq %%rax,%c[pc](%%rdx);"
                 "movq %c[stack](%%rdx),%%rbp;"
                 "movq %%rbp,%%rsp;"
                 // fill up stack info
                 "lea idle%=(%%rip), %%rax;"
                 "push %%rax;"
                 "callq *%%r8;"
                 "pcnull%=:;"
                 //> if first started
                 "movq %1,%c[state](%%rdx);"
                 "movq %c[stackpc](%%rdx),%%rsp;"
                 "jmp sw%=;"

                 "idle%=:;"
                 "call %P0;"
                 "jmp idle%=;"
                 "sw%=:;" ::"i"(swap_context),
                 "i"(RUNNING), DECLARE_STRUCT_OFFSET(thread, state),
                 DECLARE_STRUCT_OFFSET(thread, pc),
                 DECLARE_STRUCT_OFFSET(thread, stack),
                 DECLARE_STRUCT_OFFSET(thread, stackpc)
                 : "rax", "rcx", "rdx");

    asm volatile("popfq;"
                 "popq %r15;"
                 "popq %r14;"
                 "popq %r13;"
                 "popq %r12;"
                 "popq %r11;"
                 "popq %r10;"
                 "popq %r9;"
                 "popq %r8;"
                 "popq %rsi;"
                 "popq %rbp;"
                 "popq %rdx;"
                 "popq %rcx;"
                 "popq %rbx;"
                 "popq %rax;"
                 "ret;");
}

  如果你看不懂DECLARE_STRUCT_OFFSET這個東西,這個是使用了 GCC 的內聯彙編擴充套件。這裡提一嘴,微軟系列的 x64 版本不能直接內聯彙編,需要單獨放到一個彙編程式碼檔案中,這樣十分不方便。
  如果這個協程上下文切換看不懂的話, 基本就是 AT&T 彙編不紮實或者沒有畫堆疊圖,那就自己補補漏吧!
  建立協程的函式也就是填寫一個結構體,沒啥難度,先放到這裡了:

bool create_thread(thread_pointer func) {
    if (func == NULL) {
        puts("create_thread failed: please input invalid excution address");
        return false;
    }

    for (int i = 0; i < THREAD_TABLE_MAX_SIZE; i++) {
        if (thread_table[i].state != DEAD) {
            continue;
        }
        thread *th = &thread_table[i];
        th->pc = func;
        th->stack = (void *)((char *)(malloc(STACK_SIZE)) + STACK_SIZE);
        th->stackpc = th->stack;
        if (th->stack == NULL) {
            puts("create_thread failed: malloc thread stack");
            return false;
        }
        th->state = SLEEP;
        return true;
    }
    return false;
}

  至此,就結束了,剩下的就靠你自己練習了。

練習與思考

  1. 我寫好了,本篇文章的總示例程式碼在哪裡?
🔒 點選檢視答案 🔒

在我的 Gitee 上:https://gitee.com/wingsummer/coroutine


  1. 請自己實現與該文章相同功能的協程實現,並嘗試增加協程終止函式(如果可以增加對 Linux 平臺的支援)。
🔒 點選檢視答案 🔒

參考請看問題 4 。


  1. 在問題 1 中的程式碼中,如果你嘗試改為 C++ 的並使用了標準庫的東西,協程執行時會崩潰,為什麼?
🔒 點選檢視答案 🔒

答案其實就就在問題 4 提供的程式碼中,其實就是呼叫約定沒遵守好的問題:棧幀對齊。


  1. 功能更全版本的程式碼在哪裡獲取? (完成問題 2 之前請不要看)
🔒 點選檢視答案 🔒

在我的 Gitee 上:(2024/5/6 會補檔,程式碼在我另一個裝置還沒上傳)


相關文章