對于協程做一個整體的描述谨胞,從概念、原理蒜鸡、實現三個方面敘述胯努。側重有棧協程。
1 概覽
1.1 什么是協程
有很多與協程相關的名字:輕量級線程逢防、微線程叶沛、纖程、協程忘朝、goroutine等等灰署。其本質,都是大學操作系統(tǒng)課程上學到的用戶態(tài)線程局嘁。
后文統(tǒng)一使用協程來描述它溉箕。
協程,能讓你以同步的方式編寫高性能的異步代碼悦昵,而不用像使用epoll一樣維護大量狀態(tài)機和回調函數约巷;其性能在大多業(yè)務場景(計算密集型業(yè)務以外的場景),都會優(yōu)于操作系統(tǒng)線程旱捧,其優(yōu)勢主要是以下幾點:
- 協程往往在同一線程內執(zhí)行独郎,需要同步語義的場景踩麦,不需要依賴操作系統(tǒng)的互斥鎖等系統(tǒng)調用,用一個全局的bool變量作為鎖即可氓癌,同步操作開銷極低
- 協程棧往往比較小谓谦,只有幾十到幾百KB(無棧協程甚至可能只有幾KB),相比于線程默認的8MB贪婉,同樣的配置反粥,能夠啟動大量的協程,而不影響系統(tǒng)的性能
- 協程的切換由用戶自行實現疲迂,只需要切換寄存器和用戶棧才顿,十分輕量
1.2 有棧協程和無棧協程
協程可以劃分為兩類
- 有棧協程
- 無棧協程
有棧協程,在創(chuàng)建時尤蒿,除了創(chuàng)建用于管理協程的對象郑气,還會預先在heap/mmap上分配固定大小(比如128KB)的椦兀空間尾组,后續(xù)協程棧切換時,只需要修改%rsp和%rbp寄存器示弓,將其由程序的函數椈淝龋空間,指向協程自己的位于堆上的椬嗍簦空間即可跨跨。有棧協程相對比較靈活,能夠在程序任何位置囱皿,隨時隨地進行suspend和resume勇婴。
無棧協程,在創(chuàng)建時铆帽,只分配管理協程的對象咆耿,并不會去heap/mmap上分配協程的椀铝拢空間爹橱,無棧協程與主程序共用同一個棧,其布局與普通的函數調用棧一致窄做。很明顯愧驱,這讓無棧協程的內存占用更加小,它不需要預先分配獨立的椡终担空間组砚,只是在suspend時,才在堆上按需分配空間掏颊,把主程序棧中需要保存的變量restore起來糟红。但這也帶來了更多的限制艾帐,無棧協程不能隨時進行suspend,必須在協程的top-level函數中才能進行進行suspend操作盆偿。(且完全體的無棧協程一般需要編譯器原生支持柒爸。)
有棧協程和無棧協程都支持協程的嵌套調用,都支持將協程綁定到不同線程使用(雖然一般不會這樣做)事扭。
1.3 協程庫
協程其實和函數調用有些像捎稚,其思路就是手動能夠保存函數當前的上下文,將執(zhí)行權讓給其他函數求橄,并能在恰當的場合今野,恢復被保存函數的執(zhí)行。
當前c/c++中罐农,第三方有棧協程的實現比較多条霜,主流的協程庫,都是參考 setjmp/longjmp 或者 swapcontext 兩個系列的系統(tǒng)調用實現的啃匿,比如libco蛔外,boost coroutine。
無棧協程溯乒,一般不需要匯編操作夹厌,在suspend時,需要能夠準確保存要切換的變量裆悄,比如asio stackless coroutine矛纹、C++20引入的協程。
2 函數棧幀和寄存器約定:x64 UNIX - System V ABI
如前文所說光稼,協程調用和函數調用十分類似或南,要理解和實現協程,首先要對函數調用過程的棧布局和寄存器使用約定有所了解艾君。一般linux下的棧幀布局為System V采够,通過readelf命令可以查看elf文件的ABI。
# readelf -h a.out
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x400410
Start of program headers: 64 (bytes into file)
Start of section headers: 7160 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 9
Size of section headers: 64 (bytes)
Number of section headers: 36
Section header string table index: 35
如果你對進程的內存布局不太了解冰垄,建議先去google一下蹬癌,再繼續(xù)閱讀。
函數虹茶,主要有函數棧幀逝薪、寄存器、指令(匯編)幾部分蝴罪。
2.1 函數棧幀
下圖是一個典型的x64函數棧幀布局董济,相對清晰的描繪了函數的棧幀布局。
當一個函數被調用要门,首先要做的是按照ABI規(guī)范虏肾,保存caller函數的相關寄存器廓啊;然后從ABI約定的寄存器和棧中獲取函數的參數,執(zhí)行函數代碼封豪;執(zhí)行完成后崖瞭,將返回值存入指定寄存器,恢復caller函數的相關寄存器撑毛,并從棧幀中獲取函數的返回地址书聚,ret到caller函數繼續(xù)執(zhí)行。
下圖對于%rsp和%rbp的一些細節(jié)做了補充(不要糾結return address
屬于caller函數還是callee函數藻雌,這個只是角度問題雌续,不重要)。%rsp寄存器一般存放棧頂指針胯杭,顧名思義驯杜,指向函數棧的頂端;%rbp寄存器一般存放當前函數的棧幀起始地址做个,作為基址定位棧幀中的其他變量鸽心。
通過callq指令進入callee函數時,其%rsp指向caller函數的return address
居暖。在樸素情形下顽频,接下來的幾條指令一般是將%rbp寄存器入棧,然后將%rbp的值設為%rsp寄存器中的地址太闺。在函數指定retq指令返回之前糯景,會將%rbp指針出棧,此時%rsp指針重新指向caller函數的return address
省骂,調用retq指令返回caller函數蟀淮,完成一輪函數調用。
開啟了一定級別的編譯優(yōu)化后钞澳,上述提到的%rbp寄存器怠惶,往往不在作為棧幀頂部指針,而是作為通用寄存器來使用轧粟。這可以節(jié)省兩條指令策治,并多出一個通用寄存器。棧幀內尋址操作直接使用%rsp指針進行逃延。
The conventional use of %rbp as a frame pointer for the stack frame may be avoided by using
%rsp (the stack pointer) to index into the stack frame. This technique saves two instructions in
the prologue and epilogue and makes one additional general-purpose register (%rbp) available.
另一個優(yōu)化是%rsp览妖,在入棧轧拄、出棧變量時揽祥,需要兩條指令修改%rsp指針的值,這兩條指令同樣可以優(yōu)化檩电。這涉及到上圖最下面的一個區(qū)域red zone
拄丰,先給出x64 System V ABI中對其的描述:
The 128-byte area beyond the location pointed to by %rsp is considered to
be reserved and shall not be modified by signal or interrupt handlers. Therefore,
functions may use this area for temporary data that is not needed across function
calls. In particular, leaf functions may use this area for their entire stack frame,
rather than adjusting the stack pointer in the prologue and epilogue. This area is
known as the red zone.
簡而言之府树,程序可以假定,這128字節(jié)一定不會被其他線程料按、信號奄侠、中斷等異步修改,所以可以直接通過%rsp使用這128字節(jié)的內存载矿,而不需要增減%rsp指針垄潮。但red zone
在函數調用過程會被覆蓋,所以這一優(yōu)化闷盔,一般用于葉子函數(即不會調用其他函數的函數)弯洗。
2.2 寄存器
下圖是x64 System V ABI中對寄存器的一個概覽。
拋開寄存器的用途不談逢勾,從寄存器的易失性(上圖最后一列)角度考慮牡整,寄存器可以分成易失性寄存器和非易失性寄存器。
易失性寄存器可以看作臨時寄存器溺拱,在函數調用過程被調函數可以隨意修改覆蓋逃贝,不需要保留其值。
非易失性寄存器在函數調用過程后迫摔,返回原函數時沐扳,需要保證其值與原來一致,被調函數需要保存其原值句占,并負責恢復迫皱。
根據x64 System V ABI,x64的非易失性寄存器包括:
- %rbp, %rbx, %r12, %r13, %r14, %r15
- %rsp
- mxcsr
- x87 CW
x64 System V ABI規(guī)范中有關非易失性寄存器的相關描述:
Registers %rbp, %rbx and %r12 through %r15 “belong” to the calling function
and the called function is required to preserve their values.
In other words, a called function must preserve these registers’ values for its caller.
Remaining registers “belong” to the called function.
The control bits of the MXCSR register are callee-saved (preserved across
calls), while the status bits are caller-saved (not preserved). The x87 status word
register is caller-saved, whereas the x87 control word is callee-saved.
易失性寄存器一般為參數傳遞寄存器辖众、結果返回寄存器卓起、臨時寄存器等;非易失性寄存器包括棧指針寄存器凹炸、幀指針寄存器戏阅、規(guī)定的一些callee-save寄存器、以及幾個特殊寄存器啤它。
有關各寄存器的詳細用途奕筐,內容比較多,在此不做展開变骡,可以參考官方ABI規(guī)范离赫,描述的很詳細:System V Application Binary Interface AMD64 Architecture Processor Supplement Version 1.0
2.3 指令(匯編)
這里我們搞一個簡單的實例,用下面的程序來展示塌碌。
int fuck(int l, int r) {
return l+r;
}
int main(){
int a(1);
int b(2);
int c = fuck(a, b);
return 0;
}
函數對應的匯編代碼如下(編譯時渊胸,沒有加任何優(yōu)化參數),代碼后附詳細描述台妆。
(gdb) disassemble main
Dump of assembler code for function main():
0x0000000000400511 <+0>: push %rbp
0x0000000000400512 <+1>: mov %rsp,%rbp
0x0000000000400515 <+4>: sub $0x10,%rsp
0x0000000000400519 <+8>: movl $0x1,-0x4(%rbp)
0x0000000000400520 <+15>: movl $0x2,-0x8(%rbp)
0x0000000000400527 <+22>: mov -0x8(%rbp),%edx
0x000000000040052a <+25>: mov -0x4(%rbp),%eax
0x000000000040052d <+28>: mov %edx,%esi
0x000000000040052f <+30>: mov %eax,%edi
0x0000000000400531 <+32>: callq 0x4004fd <fuck(int, int)>
0x0000000000400536 <+37>: mov %eax,-0xc(%rbp)
0x0000000000400539 <+40>: mov $0x0,%eax
0x000000000040053e <+45>: leaveq
0x000000000040053f <+46>: retq
End of assembler dump.
在main函數中:
- 保存%rbp寄存器的值(幀指針)翎猛,然后將%rbp指向%rsp(當前的棧指針)
- 然后為局部變量a和b分配空間并初始化胖翰,將棧指針下移16字節(jié)(因為棧幀指針要求16字節(jié)對齊,所以雖然我們用了12字節(jié)切厘,仍然要分配16字節(jié))
- 因為a和b都是integer類型的變量萨咳,根據ABI,前六個整形參數使用
%rdi,%rsi, %rdx, %rcx, %r8 and %r9
進行傳參疫稿,所以這里將a和b的值培他,分別賦給%edi和%esi寄存器(e表示對應寄存器的低32bit位置) - 因為沒用用到caller-save的寄存器,接下來直接通過callq指令調用將
return address
壓棧遗座,并轉到fuck函數執(zhí)行 - 執(zhí)行fuck函數內容靶壮,在fuck函數末尾通過retq指令將
return address
從棧中取出,并返回到main函數 - 從%eax寄存器中取出函數的返回結果员萍,存入變量c對應的椞诮担空間
- 在函數結束前,通過leaveq指令(等同于
mov %rbp,%rsp
然后pop %rbp
)碎绎,將%rsp寄存器指向%rbp(當前的幀指針)螃壤,并從棧中彈出函數開頭保存的舊幀指針,賦給%rbp寄存器 - 調用retq返回caller函數
(gdb) disassemble fuck
Dump of assembler code for function fuck(int, int):
0x00000000004004fd <+0>: push %rbp
0x00000000004004fe <+1>: mov %rsp,%rbp
0x0000000000400501 <+4>: mov %edi,-0x4(%rbp)
0x0000000000400504 <+7>: mov %esi,-0x8(%rbp)
0x0000000000400507 <+10>: mov -0x8(%rbp),%eax
0x000000000040050a <+13>: mov -0x4(%rbp),%edx
0x000000000040050d <+16>: add %edx,%eax
0x000000000040050f <+18>: pop %rbp
0x0000000000400510 <+19>: retq
End of assembler dump.
在fuck函數中:
- 保存%rbp寄存器的值(幀指針)筋帖,然后將%rbp指向%rsp(當前的棧指針)
- 為a和b在棧上分配空間(這里因為fuck函數是leaf function奸晴,所以直接使用了%rsp棧指針下的128字節(jié)
red zone
,省略了增減%rsp寄存器的步驟)日麸,并從%edi和%esi寄存器中取出參數a和b的值寄啼,存入棧中 - 執(zhí)行運算,并將結果存入%eax寄存器(%rax寄存器用于存儲函數的第一個整型返回值)
- 從棧中恢復%rbp集群器的值
- 調用retq返回caller函數
上面的描述對于我們在棧幀和寄存器兩節(jié)代箭,提到的棧指針%rsp墩划、幀指針%rbp, red zone
、局部變量分配嗡综、函數參數傳遞寄存器乙帮、函數結果返回寄存器等要點都有體現。
唯一不太明朗的是棧幀布局中的return address
的位置极景,我們打個斷點察净,確認下這一點。
在程序的第2行盼樟,也就是fuck函數內部打一個斷點氢卡。按匯編代碼來看,此時晨缴,%rbp寄存器已經入棧译秦,棧頂第一個值為舊%rbp寄存器的值,棧頂第二個值應該為我們的函數返回地址,也就是main函數中callq指令下一條指令的地址:0x0000000000400536
诀浪。
(gdb) disassemble /m fuck
Dump of assembler code for function fuck(int, int):
1 int fuck(int l, int r) {
0x00000000004004fd <+0>: push %rbp
0x00000000004004fe <+1>: mov %rsp,%rbp
0x0000000000400501 <+4>: mov %edi,-0x4(%rbp)
0x0000000000400504 <+7>: mov %esi,-0x8(%rbp)
2 return l+r;
0x0000000000400507 <+10>: mov -0x8(%rbp),%eax
0x000000000040050a <+13>: mov -0x4(%rbp),%edx
0x000000000040050d <+16>: add %edx,%eax
3 }
0x000000000040050f <+18>: pop %rbp
0x0000000000400510 <+19>: retq
End of assembler dump.
(gdb) b 2
Breakpoint 1 at 0x400507: file main.cc, line 2.
(gdb) r
Breakpoint 1, fuck (l=1, r=2) at main.cc:2
2 return l+r;
輸出下棧頂第二個8字節(jié)位置的值,為0x00400536
延都,確實是main函數下一條指令的地址雷猪,與我們預期的一致。
(gdb) x /x $rsp+0x8
0x7fffffffe3b8: 0x00400536
至此晰房,函數棧幀和寄存器相關的內容已經全部理清求摇。
本節(jié)涉及的gdb命令:
# 打印函數匯編代碼
disassemble func_name
# 帶代碼打印函數匯編代碼
disassemble /m func_name
# 打印寄存器列表
info registers
# 上面命令的輸出不包括浮點寄存器和向量寄存器的內容,要打印所有寄存器殊者,使用
info all-registers
# 打印rsp寄存器的值(/x表示以十六進制打佑刖场)
p /x $rsp
# 打印指定地址處的值
x /x $rsp
x /x $rsp+0x8
3 協程實現
理解了前文的函數棧幀和寄存器,對于協程的實現猖吴,會感覺相當自然摔刁。本節(jié)會說明如何實現協程,并給出一個完備的協程切換函數的實現海蔽。
在寄存器一節(jié)我們知道了共屈,x64的非易失性寄存器。
在協程切換時党窜,我們需要保存的上下文拗引,除了這些非易失性寄存器,還有協程未來恢復后要執(zhí)行的下一條指令的地址幌衣,即next instruction
矾削。如下:
- %rbp, %rbx, %r12, %r13, %r14, %r15
- %rsp
- mxcsr
- x87 CW
- next instruction
下面是具體實現的協程切換函數:
- coctx_save保存當前協程的上下文
- coctx_resume恢復指定協程的上下文
- coctx_swap存儲當前上下文,并切換到指定協程執(zhí)行豁护。
coctx_save和coctx_resume僅用于展示如何實現協程上下文的保存和恢復哼凯;并不能真實的用于協程切換。如果確實要使用楚里,需要處理一些細節(jié)挡逼,coctx_save執(zhí)行后悠就,ctx中保存的是調用coctx_save后的返回地址邀窃,但這個地址之后,往往會執(zhí)行coctx_resume以切換到其他協程撬呢,所以這里需要通過一些機制吝梅,保證在當前協程被重新切換回來時虱疏,不會再執(zhí)行一次coctx_resume。
coctx_swap通過將save和resume放到一個函數中苏携,規(guī)避了這個問題做瞪,保存的返回地址就是協程接下來應該執(zhí)行的代碼地址。
這里僅展示了協程切換函數的實現,如果需要實現一套能夠使用的協程庫装蓬,還需要維護協程的棧著拭、狀態(tài)等信息,并需要一個全局的調度器牍帚,某個協程執(zhí)行完成或者切出時儡遮,由調度器決定下一個執(zhí)行的協程。如果要在實際程序中使用暗赶,調度器往往要基于epoll實現鄙币,并要有定時器、hook系統(tǒng)調用等功能蹂随。
coctx_op.h
/* C++ needs to know that types and declarations are C, not C++. */
#ifdef __cplusplus
# define __M_BEGIN_DECLS extern "C" {
# define __M_END_DECLS }
#else
# define __M_BEGIN_DECLS
# define __M_END_DECLS
#endif
#ifndef _COCTX_OP_H
#define _COCTX_OP_H 1
__M_BEGIN_DECLS
typedef unsigned char byte1_t;
typedef unsigned short byte2_t;
typedef unsigned int byte4_t;
typedef unsigned long long byte8_t;
typedef struct {
byte8_t retaddr;
byte8_t registers[7];
byte4_t mxcsr;
byte2_t x87cw;
byte2_t padding; // not used, just padding explictly
} coctx_t;
extern int coctx_save(coctx_t *old_ctx);
extern int coctx_resume(coctx_t *new_ctx);
extern int coctx_swap(coctx_t *old_ctx, coctx_t *new_ctx);
__M_END_DECLS
#endif /* coctx_op.h */
coctx_op.S
#define _retaddr 0
#define _rsp 8
#define _rbp 16
#define _rbx 24
#define _r12 32
#define _r13 40
#define _r14 48
#define _r15 56
#define _mxcsr 64
#define _x87 68
.text
.globl coctx_save
.type coctx_save, @function
coctx_save:
/* save retaddr */
movq (%rsp), %r11
movq %r11, _retaddr(%rdi)
/* save rsp after pop retaddr */
leaq 8(%rsp), %r11
movq %r11, _rsp(%rdi)
/* save callee-save registers */
movq %rbp, _rbp(%rdi)
movq %rbx, _rbx(%rdi)
movq %r12, _r12(%rdi)
movq %r13, _r13(%rdi)
movq %r14, _r14(%rdi)
movq %r15, _r15(%rdi)
stmxcsr _mxcsr(%rdi)
fnstcw _x87(%rdi)
/* return code 0 */
movl $0, %eax
/* pop retaddr from stack to %rip and return */
retq
.size coctx_save, .-coctx_save
.globl coctx_resume
.type coctx_resume, @function
coctx_resume:
/* resume rsp and retaddr*/
movq _rsp(%rdi), %rsp
movq _retaddr(%rdi), %r11
pushq %r11
/* resume callee-save registers */
movq _rbp(%rdi), %rbp
movq _rbx(%rdi), %rbx
movq _r12(%rdi), %r12
movq _r13(%rdi), %r13
movq _r14(%rdi), %r14
movq _r15(%rdi), %r15
ldmxcsr _mxcsr(%rdi)
fldcw _x87(%rdi)
/* return code 0 */
movl $0, %eax
/* pop retaddr from stack to %rip and return */
retq
.size coctx_resume, .-coctx_resume
.globl coctx_swap
.type coctx_swap, @function
coctx_swap:
/* save retaddr */
movq (%rsp), %r11
movq %r11, _retaddr(%rdi)
/* save rsp after pop retaddr */
leaq 8(%rsp), %r11
movq %r11, _rsp(%rsp)
/* save callee-save registers */
movq %rbp, _rbp(%rdi)
movq %rbx, _rbx(%rdi)
movq %r12, _r12(%rdi)
movq %r13, _r13(%rdi)
movq %r14, _r14(%rdi)
movq %r15, _r15(%rdi)
stmxcsr _mxcsr(%rdi)
fnstcw _x87(%rdi)
/* resume rsp and retaddr*/
movq _rsp(%rsi), %rsp
movq _retaddr(%rsi), %r11
pushq %r11
/* resume callee-save registers */
movq _rbp(%rsi), %rbp
movq _rbx(%rsi), %rbx
movq _r12(%rsi), %r12
movq _r13(%rsi), %r13
movq _r14(%rsi), %r14
movq _r15(%rsi), %r15
ldmxcsr _mxcsr(%rsi)
fldcw _x87(%rsi)
/* return code 0 */
movl $0, %eax
/* pop retaddr from stack to %rip and return */
retq
.size coctx_swap, .-coctx_swap
注:有關x87 CW和mxcsr寄存器十嘿,我沒有做深入的研究,僅用一般情形下的參數作為其初始值進行初始化岳锁,并隨coctx切換而保存绩衷,想要了解這兩個寄存器的具體信息,可以參考下面這些文章激率。
https://xem.github.io/minix86/manual/intel-x86-and-64-manual-vol1/o_7281d5ea06a5b67a-197.html
https://docs.roguewave.com/en/totalview/2019/html/index.html#page/Reference_Guide/Intelx86MXSCRRegister_2.html
https://stackoverflow.com/questions/50308366/storing-the-x87-fpu-control-word
https://xem.github.io/minix86/manual/intel-x86-and-64-manual-vol1/o_7281d5ea06a5b67a-214.html
https://wiki.osdev.org/SSE