libco協程庫上下文切換原理詳解

[轉自] : https://blog.csdn.net/lqt641/article/details/73287231

念橋邊紅藥,年年知爲誰生

​ —— 楊州慢 姜夔

緣起

libco 協程庫在單個線程中實現了多個協程的創建和切換。按照我們通常的編程思路,單個線程中的程序執行流程通常是順序的,調用函數同樣也是 “調用——返回”,每次都是從函數的入口處開始執行。而libco 中的協程卻實現了函數執行到一半時,切出此協程,之後可以回到函數切出的位置繼續執行,即函數的執行可以被“攔腰斬斷”,這種在函數任意位置 “切出——恢復” 的功能是如何實現的呢? 本文從libco 代碼層面對協程的切換進行了剖析,希望能讓初次接觸 libco 的同學能快速瞭解其背後的運行機理。

函數調用與協程切換的區別

下面的程序是我們常規調用函數的方法:

void A() {
   cout << 1 << " ";
   cout << 2 << " ";
   cout << 3 << " ";
}

void B() {
   cout << “x” << " ";
   cout << “y” << " ";
   cout << “z” << " ";
}

int main(void) {
  A();
  B();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

在單線程中,上述函數的輸出爲:

1 2 3 x y z 
  • 1

如果我們用 libco 庫將上面程序改造一下:

void A() {
   cout << 1 << " ";
   cout << 2 << " ";
   co_yield_ct();  // 切出到主協程
   cout << 3 << " ";
}

void B() {
   cout << “x” << " ";
   co_yield_ct();  // 切出到主協程
   cout << “y” << " ";
   cout << “z” << " ";
}

int main(void) {
  ...  // 主協程
  co_resume(A);  // 啓動協程 A
  co_resume(B);  // 啓動協程 B
  co_resume(A);  // 從協程 A 切出處繼續執行
  co_resume(B);  // 從協程 B 切出處繼續執行
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

同樣在單線程中,改造後的程序輸出如下:

1 2 x 3 y z
  • 1

可以看出,切出操作是由 co_yield_ct() 函數實現的,而協程的啓動和恢復是由 co_resume 實現的。函數 A() 和 B() 並不是一個執行完才執行另一個,而是產生了 “交叉執行“ 的效果,那麼,在單個線程中,這種 ”交叉執行“,是如何實現的呢?

Read the f**king source code!

Talk is cheap, show me code.

下面我們就深入 libco 的代碼來看一下,協程的切換是如何實現的。通過分析代碼看到,無論是 co_yield_ct() 還是 co_resume,在協程切出和恢復時,都調用了同一個函數co_swap,在這個函數中調用了 coctx_swap 來實現協程的切換,這一函數的原型是:

void coctx_swap( coctx_t *,coctx_t* ) asm("coctx_swap");
  • 1

兩個參數都是 coctx_t *指針類型,其中第一個參數表示要切出的協程,第二個參數表示切出後要進入的協程。

在上篇文章 “x86-64 下函數調用及棧幀原理” 中已經指出,調用子函數時,父函數會把兩個調用參數放入了寄存器中,並且把返回地址壓入了棧中。即在進入 coctx_swap 時,第一個參數值已經放到了 %rdi 寄存器中,第二個參數值已經放到了 %rsi 寄存器中,並且棧指針 %rsp 指向的位置即棧頂中存儲的是父函數的返回地址。進入 coctx_swap 後,堆棧的狀態如下: 
這裏寫圖片描述

由於coctx_swap 是在 co_swap() 函數中調用的,下面所提及的協程的返回地址就是 co_swap() 中調用 coctx_swap() 之後下一條指令的地址:

void co_swap(stCoRoutine_t* curr, stCoRoutine_t* pending_co) {
     ....
    // 從本協程切出
    coctx_swap(&(curr->ctx),&(pending_co->ctx) );

    // 此處是返回地址,即協程恢復時開始執行的位置
    stCoRoutineEnv_t* curr_env = co_get_curr_thread_env();
    ....
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

coctx_swap 函數是用匯編實現的,我們這裏只關注 x86-64 相關的部分,其代碼如下:

coctx_swap:
   leaq 8(%rsp),%rax
   leaq 112(%rdi),%rsp
   pushq %rax
   pushq %rbx
   pushq %rcx
   pushq %rdx

   pushq -8(%rax) //ret func addr

   pushq %rsi
   pushq %rdi
   pushq %rbp
   pushq %r8
   pushq %r9
   pushq %r12
   pushq %r13
   pushq %r14
   pushq %r15

   movq %rsi, %rsp
   popq %r15
   popq %r14
   popq %r13
   popq %r12
   popq %r9
   popq %r8
   popq %rbp
   popq %rdi
   popq %rsi
   popq %rax //ret func addr
   popq %rdx
   popq %rcx
   popq %rbx
   popq %rsp
   pushq %rax

   xorl %eax, %eax
   ret
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39

可以看出,coctx_swap 中並未像常規被調用函數一樣創立新的棧幀。先看前兩條語句:

   leaq 8(%rsp),%rax
   leaq 112(%rdi),%rsp
  • 1
  • 2

leaq 用於把其第一個參數的值賦值給第二個寄存器參數。第一條語句用來把 8(%rsp) 的本身的值存入到 %rax 中,注意這裏使用的並不是 8(%rsp) 指向的值,而是把 8(%rsp) 表示的地址賦值給了 %rax。這一地址是父函數棧幀中除返回地址外棧幀頂的位置。

在第二條語句 leaq 112(%rdi), %rsp 中,%rdi 存放的是coctx_swap 第一個參數的值,這一參數是指向 coctx_t 類型的指針,表示當前要切出的協程,這一類型的定義如下:

struct coctx_t {
    void *regs[ 14 ]; 
    size_t ss_size;
    char *ss_sp;

};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

因而 112(%rdi) 表示的就是第一個協程的 coctx_t 中 regs[14] 數組的下一個64位地址。而接下來的語句:

   pushq %rax   
   pushq %rbx
   pushq %rcx
   pushq %rdx
   pushq -8(%rax) //ret func addr
   pushq %rsi
   pushq %rdi
   pushq %rbp
   pushq %r8
   pushq %r9
   pushq %r12
   pushq %r13
   pushq %r14
   pushq %r15
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

第一條語句 pushq %rax 用於把 %rax 的值放入到 regs[13] 中,resg[13] 用來存儲第一個協程的 %rsp 的值。這時 %rax 中的值是第一個協程 coctx_swap 父函數棧幀除返回地址外棧幀頂的地址。由於 regs[] 中有單獨的元素存儲返回地址,棧中再保存返回地址是無意義的,因而把父棧幀中除返回地址外的棧幀頂作爲要保存的 %rsp 值是合理的。當協程恢復時,把保存的 regs[13] 的值賦值給 %rsp 即可恢復本協程 coctx_swap 父函數堆棧指針的位置。第一條語句之後的語句就是用pushq 把各CPU 寄存器的值依次從 regs 尾部向前壓入。即通過調整%rsp 把 regs[14] 當作堆棧,然後利用 pushq 把寄存器的值和返回地址存儲到 regs[14] 整個數組中。regs[14] 數組中各元素與其要存儲的寄存器對應關係如下:

//-------------
// 64 bit
//low | regs[0]: r15 |
//    | regs[1]: r14 |
//    | regs[2]: r13 |
//    | regs[3]: r12 |
//    | regs[4]: r9  |
//    | regs[5]: r8  | 
//    | regs[6]: rbp |
//    | regs[7]: rdi |
//    | regs[8]: rsi |
//    | regs[9]: ret |  //ret func addr, 對應 rax
//    | regs[10]: rdx |
//    | regs[11]: rcx | 
//    | regs[12]: rbx |
//hig | regs[13]: rsp |
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

接下來的彙編語句:

   movq %rsi, %rsp
   popq %r15
   popq %r14
   popq %r13
   popq %r12
   popq %r9
   popq %r8
   popq %rbp
   popq %rdi
   popq %rsi
   popq %rax //ret func addr
   popq %rdx
   popq %rcx
   popq %rbx
   popq %rsp   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

這裏用的方法還是通過改變%rsp 的值,把某塊內存當作棧來使用。第一句 movq %rsi, %rsp 就是讓%rsp 指向 coctx_swap 第二個參數,這一參數表示要進入的協程。而第二個參數也是coctx_t 類型的指針,即執行完 movq 語句後,%rsp 指向了第二個參數 coctx_t 中 regs[0],而之後的pop 語句就是用 regs[0-13] 中的值填充cpu 的寄存器,這裏需要注意的是popq 會使得 %rsp 的值增加而不是減少,這一點保證了會從 regs[0] 到regs[13] 依次彈出到 cpu 寄存器中。在執行完最後一句 popq %rsp 後,%rsp 已經指向了新協程要恢復的棧指針(即新協程之前調用 coctx_swap 時父函數的棧幀頂指針),由於每個協程都有一個自己的棧空間,可以認爲這一語句使得%rsp 指向了要進入協程的棧空間。

coctx_swap 中最後三條語句如下:

   pushq %rax
   xorl %eax, %eax
   ret
  • 1
  • 2
  • 3

pushq %rax 用來把 %rax 的值壓入到新協程的棧中,這時 %rax 是要進入的目標協程的返回地址,即要恢復的執行點。然後用 xorl 把 %rax 低32位清0以實現地址對齊。最後ret 語句用來彈出棧的內容,並跳轉到彈出的內容表示的地址處,而彈出的內容正好是上面 pushq %rax 時壓入的 %rax 的值,即之前保存的此協程的返回地址。即最後這三條語句實現了轉移到新協程返回地址處執行,從而完成了兩個協程的切換。可以看出,這裏通過調整%rsp 的值來恢復新協程的棧,並利用了 ret 語句來實現修改指令寄存器 %rip 的目的,通過修改 %rip 來實現程序運行邏輯跳轉。注意%rip 的值不能直接修改,只能通過 call 或 ret 之類的指令來間接修改。

整體上看來,協程的切換其實就是cpu 寄存器內容特別是%rip 和 %rsp 的寫入和恢復,因爲cpu 的寄存器決定了程序從哪裏執行(%rip) 和使用哪個地址作爲堆棧 (%rsp)。寄存器的寫入和恢復如下圖所示:

這裏寫圖片描述

執行完上圖的流程,就將之前 cpu 寄存器的值保存到了協程A 的 regs[14] 中,而將協程B regs[14] 的內容寫入到了寄存器中,從而使執行邏輯跳轉到了 B 協程 regs[14] 中保存的返回地址處開始執行,即實現了協程的切換(從A 協程切換到了B協程執行)。

結語

爲實現單線程中協程的切換,libco 使用匯編直接讀寫了 cpu 的寄存器。由於通常我們在高級語言層面很少接觸上下文切換的情形,因而會覺得在單線程中切換上下文的方法會十分複雜,但當我們對代碼抽絲剝繭後,發現其實現機理也是很容易理解的。從libco 上下文切換中可以看出,用匯編與 cpu 硬件寄存器配合竟然可以設計出如此神奇的功能,不得不驚歎於 cpu 硬件設計的精妙。

libco 庫的說明中提及這種上下文切換的方法取自 glibc,看來基礎庫中隱藏了不少 “屠龍之技”。

看來,想要提高編程技能,無他,Read the f**king source code !

The End.


我就是我,疾馳中的企鵝。

我就是我,不一樣的焰火。

這裏寫圖片描述

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