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

念橋邊紅葯,年年知為誰生

——楊州慢 姜夔

緣起

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

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

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

void A() {n cout << 1 << " ";n cout << 2 << " ";n cout << 3 << " ";n}nnvoid B() {n cout << "x" << " ";n cout << "y" << " ";n cout << "z" << " ";n}nnint main(void) {n A();n B();n}n

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

1 2 3 x y zn

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

void A() {n cout << 1 << " ";n cout << 2 << " ";n co_yield_ct(); // 切出到主協程n cout << 3 << " ";n}nnvoid B() {n cout << "x" << " ";n co_yield_ct(); // 切出到主協程n cout << "y" << " ";n cout << "z" << " ";n}nnint main(void) {n ... // 主協程n co_resume(A); // 啟動協程 An co_resume(B); // 啟動協程 Bn co_resume(A); // 從協程 A 切出處繼續執行n co_resume(B); // 從協程 B 切出處繼續執行n}n

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

1 2 x 3 y zn

可以看出,切出操作是由 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");n

兩個參數都是 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) {n ....n // 從本協程切出n coctx_swap(&(curr->ctx),&(pending_co->ctx) );nn // 此處是返回地址,即協程恢復時開始執行的位置n stCoRoutineEnv_t* curr_env = co_get_curr_thread_env();n ....n }n

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

coctx_swap:n leaq 8(%rsp),%raxn leaq 112(%rdi),%rspn pushq %raxn pushq %rbxn pushq %rcxn pushq %rdxnn pushq -8(%rax) //ret func addrnn pushq %rsin pushq %rdin pushq %rbpn pushq %r8n pushq %r9n pushq %r12n pushq %r13n pushq %r14n pushq %r15nn movq %rsi, %rspn popq %r15n popq %r14n popq %r13n popq %r12n popq %r9n popq %r8n popq %rbpn popq %rdin popq %rsin popq %rax //ret func addrn popq %rdxn popq %rcxn popq %rbxn popq %rspn pushq %raxnn xorl %eax, %eaxn retn

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

leaq 8(%rsp),%raxn leaq 112(%rdi),%rspn

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

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

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

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

pushq %rax n pushq %rbxn pushq %rcxn pushq %rdxn pushq -8(%rax) //ret func addrn pushq %rsin pushq %rdin pushq %rbpn pushq %r8n pushq %r9n pushq %r12n pushq %r13n pushq %r14n pushq %r15n

第一條語句 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] 數組中各元素與其要存儲的寄存器對應關係如下:

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

接下來的彙編語句:

movq %rsi, %rspn popq %r15n popq %r14n popq %r13n popq %r12n popq %r9n popq %r8n popq %rbpn popq %rdin popq %rsin popq %rax //ret func addrn popq %rdxn popq %rcxn popq %rbxn popq %rspn

這裡用的方法還是通過改變%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 %raxn xorl %eax, %eaxn retn

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.

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

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


推薦閱讀:

介紹一下call_in_stack(1)
協程初步筆記
使用coroutine實現狀態機(2)
Kotlin雜談(五) - Coroutines(三): 基本語法
Unity 協程運行時的監控和優化

TAG:Linux开发 | 协程 | 汇编语言 |