libgo 原始碼剖析(3. libgo上下文切換實現)
在 libgo 的上下文切換上,並沒有自己去實現建立和維護棧空間、儲存和切換 CPU 暫存器執行狀態資訊等的任務,而是直接使用了 Boost.Context。Boost.Context 作為眾多協程底層支援庫,效能方面一直在被優化。
Boost.Context所做的工作,就是在傳統的執行緒環境中可以儲存當前執行的抽象狀態資訊(棧空間、棧指標、CPU暫存器和狀態暫存器、IP指令指標),然後暫停當前的執行狀態,程式的執行流程跳轉到其他位置繼續執行,這個基礎構建可以用於開闢使用者態的執行緒,從而構建出更加高階的協程等操作介面。同時因為這個切換是在使用者空間的,所以資源損耗很小,同時儲存了棧空間和執行狀態的所有資訊,所以其中的函式可以自由被巢狀使用。
引用自https://yq.aliyun.com/ziliao/43404
1. fcontext_t
libgo/context/fcontext.h
Boost.Context 的底層實現是通過 fcontext_t 結構體來儲存協程狀態,使用 make_fcontext 建立協程,使用 jump_fcontext 實現協程切換。在 libgo 協程中,直接引用了這兩個介面函式。boost 的內部實現這裡不討論,感興趣的話可以在上面連線中檢視。
// 所有內容和 Boost.Context 中的宣告一致 extern "C" { typedef void* fcontext_t; typedef void (*fn_t)(intptr_t); /* * 從 ofc 切換到 nfc 的上下文 * */ intptr_t jump_fcontext(fcontext_t * ofc, fcontext_t nfc,intptr_t vp, bool preserve_fpu = false); /* * 建立上下問物件 * */ fcontext_t make_fcontext(void* stack, std::size_t size, fn_t fn); }
除此之外,還提供了一系列的棧函式
struct StackTraits { static stack_malloc_fn_t& MallocFunc(); static stack_free_fn_t& FreeFunc(); // 獲取當前棧頂設定的保護頁的頁數 static int & GetProtectStackPageSize(); // 對保護頁的內容做保護 static bool ProtectStack(void* stack, std::size_t size, int pageSize); // 取消對保護頁的記憶體保護,析構是才會呼叫 static void UnprotectStack(void* stack, int pageSize); };
當用戶去管理協程棧當時候,稍不注意,就會出現訪問棧越界當問題。只讀操作還好,但是如果進行了寫操作,整個程式就會直接奔潰,因此,棧保護工作還是十分必要的。
棧保護
libgo 對棧對保護,使用了 mprotect 系統呼叫實現。我們在給該協程建立了大小為 N 位元組對棧空間時,會對棧頂的一部分的空間進行保護,因此,分配的協程棧的大小,應該要大於要保護的記憶體頁數加一。
為什麼提到保護棧,總是以頁為單位呢?因為 mprotect 是按照頁來進行設定的,因此,對沒有對其對地址,應該首先對其之後再去操作。
bool StackTraits::ProtectStack(void* stack, std::size_t size, int pageSize)
{
// 協程棧的大小,應該大於(保護記憶體頁數+1)
if (!pageSize) return false;
if ((int)size <= getpagesize() * (pageSize + 1))
return false;
// 使用 mprotect 保護的記憶體頁應該是按頁對其的
// 棧從高地址向地地址生長,被保護的棧空間應該位於棧頂(低地址處)
// protect_page_addr 是在當前協程棧內取最近的整數頁邊界的地址,如:0xf7234008 ---> 0xf7235000
void *protect_page_addr = ((std::size_t)stack & 0xfff) ? (void*)(((std::size_t)stack & ~(std::size_t)0xfff) + 0x1000) : stack;
// 使用 mprotect 系統呼叫實現棧保護,PROT_NONE 表明該記憶體空間不可訪問
if (-1 == mprotect(protect_page_addr, getpagesize() * pageSize, PROT_NONE)) {
DebugPrint(dbg_task, "origin_addr:%p, align_addr:%p, page_size:%d, protect_page:%u, protect stack stack error: %s", stack, protect_page_addr, getpagesize(), pageSize, strerror(errno));
return false;
} else {
DebugPrint(dbg_task, "origin_addr:%p, align_addr:%p, page_size:%d, protect_page:%u, protect stack success.",
stack, protect_page_addr, pageSize, getpagesize());
return true;
}
}
取消棧保護
取消棧保護只有在釋放該協程空間的時候會呼叫。
void StackTraits::UnprotectStack(void *stack, int pageSize)
{
if (!pageSize) return ;
void *protect_page_addr = ((std::size_t)stack & 0xfff) ? (void*)(((std::size_t)stack & ~(std::size_t)0xfff) + 0x1000) : stack;
// 允許該塊記憶體可讀可寫
if (-1 == mprotect(protect_page_addr, getpagesize() * pageSize, PROT_READ|PROT_WRITE)) {
DebugPrint(dbg_task, "origin_addr:%p, align_addr:%p, page_size:%d, protect_page:%u, protect stack stack error: %s",stack, protect_page_addr, getpagesize(), pageSize, strerror(errno));
} else {
DebugPrint(dbg_task, "origin_addr:%p, align_addr:%p, page_size:%d, protect_page:%u, protect stack success.", stack, protect_page_addr, pageSize, getpagesize());
}
}
mprotect 系統呼叫使用說明
#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);
addr:應該是按頁對其的記憶體地址
len:保護的記憶體頁大小,因此保護的地址範圍應該是[addr, addr+len-1]
prot:保護型別
PROT_NONE The memory cannot be accessed at all.
PROT_READ The memory can be read.
PROT_WRITE The memory can be modified.
PROT_EXEC The memory can be executed.
2. Context
libgo/context/context.h
Context 是 libgo 中封裝的上下文物件,每個協程都會有一份獨有的。
class Context
{
public:
/*
* 構造
* */
Context(fn_t fn, intptr_t vp, std::size_t stackSize);
// 上下文切換介面
ALWAYS_INLINE void SwapIn();
ALWAYS_INLINE void SwapTo(Context & other);
ALWAYS_INLINE void SwapOut();
fcontext_t& GetTlsContext();
private:
fcontext_t ctx_;
fn_t fn_; // 協程執行函式
intptr_t vp_; // 當前上下文屬於的協程 Task 物件指標
char* stack_ = nullptr; // 棧空間
uint32_t stackSize_ = 0; // 棧大小
int protectPage_ = 0; // 保護頁的數量
};
該類除了私有成員,其它的沒有什麼解釋的。大多數的工作都是在建構函式中完成的,包括開闢棧空間、建立上下文、設定保護頁等的操作。
預設配置
關於棧保護頁的頁數設定,還有預設的棧大小,都是在 CoroutineOptions 中配置的。在 coroutine.h 檔案中
#define co_opt ::co::CoroutineOptions::getInstance()
因此,可以直接使用 co_opt 物件來修改預設配置。
可參照
test/gtest_unit/protect.cpp
3. 彙編實現上下文切換
該彙編實現的
雙斜槓後的中文註釋是自己新加的
彙編實現的函式,實際上是
intptr_t jump_fcontext(fcontext_t * ofc, fcontext_t nfc,intptr_t vp, bool preserve_fpu = false);
彙編程式碼如下:
.text
// 宣告 jump_fcontext 為全域性可見的符號
.globl jump_fcontext
.type jump_fcontext,@function
.align 16
jump_fcontext:
// 儲存當前協程的資料儲存暫存器,壓棧儲存
pushq %rbp /* save RBP */
pushq %rbx /* save RBX */
pushq %r15 /* save R15 */
pushq %r14 /* save R14 */
pushq %r13 /* save R13 */
pushq %r12 /* save R12 */
// rsp 棧頂暫存器下移 8 位元組,為新協程 FPU 浮點運算預留
/* prepare stack for FPU 浮點運算暫存器*/
leaq -0x8(%rsp), %rsp
// %rcx 為函式的第四個引數,je 進行判斷,等於則跳轉到標識為1的地方,f(forword)
// fpu 為浮點運算暫存器
/* test for flag preserve_fpu */
cmp $0, %rcx
je 1f
// 儲存MXCSR內容 rsp 暫存器
/* save MMX control- and status-word */
stmxcsr (%rsp)
// 儲存當前FPU狀態字到 rsp+4 的位置
/* save x87 control-word */
fnstcw 0x4(%rsp)
1:
// 儲存當前棧頂位置到 rdi
/* store RSP (pointing to context-data) in RDI */
movq %rsp, (%rdi)
// 修改棧頂地址,為新協程的地址
/* restore RSP (pointing to context-data) from RSI */
movq %rsi, %rsp
/* test for flag preserve_fpu */
cmp $0, %rcx
je 2f
/* restore MMX control- and status-word */
ldmxcsr (%rsp)
/* restore x87 control-word */
fldcw 0x4(%rsp)
2:
// rsp 棧頂暫存器上移 8 位元組,恢復為 FPU 浮點運算預留空間
/* prepare stack for FPU */
leaq 0x8(%rsp), %rsp
// 將當前新協程的暫存器恢復
popq %r12 /* restrore R12 */
popq %r13 /* restrore R13 */
popq %r14 /* restrore R14 */
popq %r15 /* restrore R15 */
popq %rbx /* restrore RBX */
popq %rbp /* restrore RBP */
// 將返回地址放到 r8 暫存器中
/* restore return-address */
popq %r8
// 原協程所屬的 task 作為函式返回值存入 rax 暫存器
/* use third arg as return-value after jump */
movq %rdx, %rax
// 將當前協程的 task 地址放到第一個引數的位置(即替換當前協程的上下文地址)
/* use third arg as first arg in context function */
movq %rdx, %rdi
// 跳轉到返回地址處
/* indirect jump to context */
jmp *%r8
.size jump_fcontext,.-jump_fcontext
切換流程
以從協程 A 切換到協程 B 為例:
intptr_t jump_fcontext(fcontext_t * ofc, fcontext_t nfc, intptr_t vp, bool preserve_fpu = false);
指令說明
# 偽指令
text:
指定了後續編譯出來的內容放在程式碼段【可執行】;
global:
告訴編譯器後續跟的是一個全域性可見的名字【可能是變數,也可以是函式名】;
align num:
對齊偽指令,num 必須是2的整數冪
告訴彙編程式,本偽指令下面的記憶體變數必須從下一個能被Num整除的地址開始分配
暫存器說明
-
X86-64 的所有暫存器都是 64 位,相對於 32 位系統來說,僅僅是識別符號發生變化,如 %ebp->%rbp;
- X86-64 新增 %r8~%r15 8個暫存器;
# X86-64 暫存器說明
%rax 作為函式返回值使用
%rsp 棧指標暫存器,指向棧頂
%rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函式引數,依次對應第1引數,第2引數。。。
%rbx,%rbp,%r12,%r13,%14,%15 用作資料儲存,遵循被呼叫者使用規則,簡單說就是隨便用,呼叫子函式之前要備份它,以防他被修改
%r10,%r11 用作資料儲存,遵循呼叫者使用規則,簡單說就是使用之前要先儲存原值