在 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
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 用作数据存储,遵循调用者使用规则,简单说就是使用之前要先保存原值