談談協程那些事

什麼是協程?

協程被稱為“輕量級線程”或者“用戶態線程”。最近協程在高併發編程領域大放異彩,如Golang天生就支持協程,Lua和Python也支持協程。但其實協程並不是最近才出現的新技術,恰恰相反,協程是一項古老的技術。早期版本的Linux並不支持線程,這時就出現代替線程的輕量級線程--協程。比較有名的有: GNU PthLibtask(Go語言的作者之一Russ Cox的作品)。下面我們會以Libtask作為分析案例來解釋協程的原理。

>以下內容會引起部分讀者情緒不安,敬請留意。

基本概念

要理解協程,首先要知道程序是怎麼運行的。所以下面我們先來聊聊程序是怎麼跑起來的。

我們知道CPU的使命就是執行程序中的指令,而且CPU內部有很多用於存放數據的寄存器,其中比較重要的一個寄存器叫EIP寄存器,它用於存儲下一條要執行的指令。除了EIP寄存器之外,還有一個比較重要的寄存器叫

ESP寄存器,它用於保存程序的棧頂位置。除此之外,CPU還有很多其他用途的寄存器,如:通用寄存器EAX、EDX和段寄存器CS、DS等等。

當一個程序被執行(稱為進程)的時候,這些寄存器的值通常會被修改。所以當要切換進程執行的時候,只需要把這些寄存器的值保存下來,然後把新進程寄存器的值賦值到CPU中,那麼就完成進程切換了,通常我們把這個過程稱為上下文切換,協程的切換也類似。


談談協程那些事



協程原理

上面討論過,只需要切換上下文就可以切換協程。而上下文就是CPU寄存器的值,所以要創建一個協程,首先要創建一個保存CPU寄存器值的對象。在Libtask中,使用mcontext結構體來保存寄存器的值。mcontext結構體定義如下:

struct mcontext {
int mc_gs;
int mc_fs;
int mc_es;
int mc_ds;
int mc_edi;
int mc_esi;
int mc_ebp;
int mc_isp;
int mc_ebx;
int mc_edx;
int mc_ecx;
int mc_eax;
int mc_trapno;
int mc_err;
int mc_eip;
int mc_cs;
int mc_eflags;
int mc_esp;
int mc_ss;
};

mcontext結構體用來保存寄存器的值,從mcontext的成員可以看到要保存的寄存器很多,包括CS、DS、EIP、EAX、EBX等等。

C函數調用原理

因為協程切換一般是通過調用一個swapcontext()的C函數來進行,這個函數的作用就是保存舊的協程上下文和替換新的協程上下文來進行協程切換,而新舊協程上下文就是通過C函數的參數來傳遞的,所以我們先來了解下C函數調用過程的原理。

C函數是通過棧空間來傳遞參數的,通過下圖有個感性的認識:


談談協程那些事


在上圖中,淺綠色部分是調用函數時把參數入棧的。入棧時,C語言是從右到左開始入棧的。例如我們調用swapcontext(old, new)這個函數時,會先把new參數入棧,然後再把old參數入棧。

另外,在調用一個函數時,CPU會自動把當前指令的下一條指令入棧。所以,在上圖可以看到在參數後面還有返回地址。在返回地址下面保存的是函數的局部變量。

注意

棧空間是從內存的高地址向地址增長的

協程切換

現在到了重頭戲--協程的切換。協程的切換是通過保存舊協程的上下文和替換新協程的上下文來實現的。

在Libtask庫中,保存協程上下文通過getcontext()實現,而替換協程上下文是通過setcontext()實現。這兩個函數都是使用匯編語言實現的。所以要看明白這兩個函數就必須有彙編的基礎。我們來看看這兩個函數的實現:

getcontext()

gexcontext:
movl 4(%esp), %eax
movl %fs, 8(%eax)
movl %es, 12(%eax)
movl %ds, 16(%eax)
movl %ss, 76(%eax)
movl %edi, 20(%eax)
movl %esi, 24(%eax)
movl %ebp, 28(%eax)
movl %ebx, 36(%eax)
movl %edx, 40(%eax)
movl %ecx, 44(%eax)
movl $1, 48(%eax)
movl (%esp), %ecx
movl %ecx, 60(%eax)
leal 4(%esp), %ecx
movl %ecx, 72(%eax)
movl 44(%eax), %ecx
movl $0, %eax
\t ret

getcontext()函數的原型如下:

int getcontext(struct mcontext *ctx);

其作用是把當前寄存器的值保存到參數ctx中。上面這段彙編代碼就不詳細解說了,有興趣可以根據C函數參數傳遞的原理來對照一下就很容易理解。

需要說明的一點是,“movl 4(%esp), %eax”這行彙編代碼的作用是把ctx參數放置到EAX寄存器中,後面的操作都是通過mcontext結構體的偏移量來賦值的。

setcontext()

setcontext:
movl 4(%esp), %eax
movl 8(%eax), %fs
movl 12(%eax), %es
movl 16(%eax), %ds
movl 76(%eax), %ss
movl 20(%eax), %edi
movl 24(%eax), %esi
movl 28(%eax), %ebp
movl 36(%eax), %ebx
movl 40(%eax), %edx
movl 44(%eax), %ecx
movl 72(%eax), %esp
pushl 60(%eax)
movl 48(%eax), %eax
ret

setcontext()函數是協程切換的切換點,原型如下:

int setcontext(struct mcontext *ctx); 

其作用是把ctx參數中寄存器的值替換成CPU寄存器的值來實現切換。

最後,我們就可以通過getcontext()和setcontext()這兩個函數來實現swapcontext()函數了,實現很簡單:

int swapcontext(struct mcontext *new, struct mcontext *old)
{
getcontext(old);
setcontext(new);
return 0;
}

以後我們就可以通過swapcontext()函數來進行協程的切換了。

總結

在本文中,我們只要解釋了協程的基本原理,但是要真正實現一個可以使用的協程庫還需要做很多細節的工作,例如切換協程的棧空間(因為每個協程都需要有自己獨立的棧空間才不會影響其協程)。

另外,一個完善的協程庫還應該支持定時器和I/O阻塞自動切換協程等功能。對於怎麼實現一個完善的協程庫可以參考Libtask的源代碼。


分享到:


相關文章: