brpc 中 bthread 源码分析

brpc 引入 m:n 的线程模型,固定的内核线程调度运行大量的 bthread 以避免内核线程上下文切换带来的开销。bthread 类似协程,即用户态线程,bthread 的切换不会陷入内核,不会进行一系列内存同步等耗时操作,因此 bthread 的切换在 100-200ns,相比内核线程的微秒级别有着数量级的提升。

brpc 的精华全在 bthread

有人说 brpc 的精华全在 bthread 上了,bthread 可以理解为 “协程”。pthread 大家都不陌生,是 POSIX 标准中定义的一套线程模型。在 Linux 系统上,其实没有真正的线程,其采用的是 LWP(轻量级进程)实现的线程。而 bthread 是 brpc 实现的一套 “协程”,当然这并不是传统意义上的协程。就像 1 个进程可以开辟 N 个线程一样。传统意义上的协程是一个线程中开辟多个协程,也就是通常意义的 N:1 协程。而 bthread 是 M:N 的 “协程”,每个 bthread 之间的平等的,所谓的 M:N 是指协程可以在线程间迁移。Go 语言的 goroutine 也是 M:N 的。

incubator-brpc/src/bthread/stack_inl.h:

1
2
3
inline void jump_stack(ContextualStack* from, ContextualStack* to) {
bthread_jump_fcontext(&from->context, to->context, 0/*not skip remained*/);
}

incubator-brpc/src/bthread/context.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef void* bthread_fcontext_t;

#ifdef __cplusplus
extern "C"{
#endif

intptr_t BTHREAD_CONTEXT_CALL_CONVENTION
bthread_jump_fcontext(bthread_fcontext_t * ofc, bthread_fcontext_t nfc,
intptr_t vp, bool preserve_fpu = false);
bthread_fcontext_t BTHREAD_CONTEXT_CALL_CONVENTION
bthread_make_fcontext(void* sp, size_t size, void (* fn)( intptr_t));

#ifdef __cplusplus
};
#endif

bthread_jump_fcontext () 其实是汇编函数,在 bthread/context.cpp 中,功能就是进行栈上下文的切换(跳转)。与之配套的还有一个 bthread_make_fcontext (),负责创建 bthread 的栈上下文。这两个函数是实现栈上下文切换的核心。它们的代码其实并非 brpc 的原创,而是出自开源项目 libcontext。libcontext 是 boost::context 的简化实现。libcontext - a slightly more portable version of boost::context。

首先看下协程栈的结构,如下,context 指向协程栈顶,stacktype 表示栈的类型 (大小),storage 为栈空间。
栈分配时会通过 mmap 匿名映射一段空间,然后将高地址位赋值给 bottom。

incubator-brpc/src/bthread/stack.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct ContextualStack {
bthread_fcontext_t context;
StackType stacktype;
StackStorage storage;
};
……
struct StackStorage {
int stacksize;
int guardsize;
void* bottom;
unsigned valgrind_stack_id;

// Clears all members.
void zeroize() {
stacksize = 0;
guardsize = 0;
bottom = NULL;
valgrind_stack_id = 0;
}
};
……
// Jump from stack `from' to stack `to'. `from' must be the stack of callsite
// (to save contexts before jumping)
void jump_stack(ContextualStack* from, ContextualStack* to);

接着看下创建一个 bthread 的过程,函数入参分别为协程栈底,栈大小,以及这个 bthread 要执行的函数,返回值,即 context 为协程栈顶,具体看如下代码。

1
context = bthread_make_fcontext(storage.bottom, storage.stacksize, entry);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#if defined(BTHREAD_CONTEXT_PLATFORM_linux_x86_64) && defined(BTHREAD_CONTEXT_COMPILER_gcc)
__asm (
".text\n"
".globl bthread_make_fcontext\n"
".type bthread_make_fcontext,@function\n"
".align 16\n"
"bthread_make_fcontext:\n"
" movq %rdi, %rax\n"
" andq $-16, %rax\n"
" leaq -0x48(%rax), %rax\n"
" movq %rdx, 0x38(%rax)\n"
" stmxcsr (%rax)\n"
" fnstcw 0x4(%rax)\n"
" leaq finish(%rip), %rcx\n"
" movq %rcx, 0x40(%rax)\n"
" ret \n"
"finish:\n"
" xorq %rdi, %rdi\n"
" call _exit@PLT\n"
" hlt\n"
".size bthread_make_fcontext,.-bthread_make_fcontext\n"
".section .note.GNU-stack,\"\",%progbits\n"
".previous\n"
);
#endif
  • 将 rdi 赋值给 rax,rdi 保存的是第一个参数,即 stack 的 bottom
  • 对 rax 进行 16 字节对齐
  • 将 rax 下移 72 字节
  • 将 rdx 保存至 rax + 56,rdx 为第三个参数,即函数 fn 的地址
  • 保存 MXCSR 寄存器 (sse 浮点数运算状态寄存器,32 位) 到 rax 所在位置
  • 将 FPU 控制字的当前值存储到 rax + 4
  • 计算 finish 的绝对地址,保存到 rcx 中
  • 将 rcx 保存到 rax + 64
  • 函数退出
  • rax 保存的是栈顶,因此 context 现在指向协程栈的栈顶

这一过程如下图所示:上面为高地址,下面为低地址;左侧为调用此函数的 bthread/pthread 栈,右侧为执行完成后,新 bthread 栈空间结构,大框表示 64 位,小框表示 32 位,这里关于各个寄存器在栈中保存的位置是为了和协程切换函数里保持一致,具体下面会提到。

img

接下来看下协程的切换过程,首先看下代码,ofc 为旧协程的栈顶,nfc 为新协程的栈顶。

1
2
3
inline void jump_stack(ContextualStack* from, ContextualStack* to) {
bthread_jump_fcontext(&from->context, to->context, 0/*not skip remained*/);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#if defined(BTHREAD_CONTEXT_PLATFORM_linux_x86_64) && defined(BTHREAD_CONTEXT_COMPILER_gcc)
__asm (
".text\n"
".globl bthread_jump_fcontext\n"
".type bthread_jump_fcontext,@function\n"
".align 16\n"
"bthread_jump_fcontext:\n"
" pushq %rbp \n"
" pushq %rbx \n"
" pushq %r15 \n"
" pushq %r14 \n"
" pushq %r13 \n"
" pushq %r12 \n"
" leaq -0x8(%rsp), %rsp\n"
" cmp $0, %rcx\n"
" je 1f\n"
" stmxcsr (%rsp)\n"
" fnstcw 0x4(%rsp)\n"
"1:\n"
" movq %rsp, (%rdi)\n"
" movq %rsi, %rsp\n"
" cmp $0, %rcx\n"
" je 2f\n"
" ldmxcsr (%rsp)\n"
" fldcw 0x4(%rsp)\n"
"2:\n"
" leaq 0x8(%rsp), %rsp\n"
" popq %r12 \n"
" popq %r13 \n"
" popq %r14 \n"
" popq %r15 \n"
" popq %rbx \n"
" popq %rbp \n"
" popq %r8\n"
" movq %rdx, %rax\n"
" movq %rdx, %rdi\n"
" jmp *%r8\n"
".size bthread_jump_fcontext,.-bthread_jump_fcontext\n"
".section .note.GNU-stack,\"\",%progbits\n"
".previous\n"
);
#endif

先将对应寄存器 push 到旧的协程栈中
将 rsp 下移 8 字节
比较 rcx 和 0,因为 rcx 为 0,所以 zf 为 1
因为 zf 为 1,所以跳转
将 rsp 保存至 rdi 中,rsp 指向当前协程栈栈顶,rdi 为第一个入参,即 ofc
将 rsi 保存到 rsp 中,rsi 为第二个参数,即 nfc,此时栈顶指针 rsp 指向了新的协程栈
将 rsp 上移 8 字节
将协程栈中 r12-rbp 依次 pop 到对应寄存器
将 rdx 保存到 rax,rdx 为第三个参数,rax 为返回值
将 rdx 保存到 rdi,rdi 为第一个入参,因此将作为新协程运行的入参
跳转到 r8 对应的寄存器运行。
在协程切换过程中有两种情况,第一种为新协程是通过 bthread_make_fcontext 函数刚刚创建的栈,另一种是已经运行过的栈,这两种过程分别如下图所示

img

上图表示切换到新协程的过程,因为 pop 到 r8 的是 fn,所以接下来会运行创建协程时的函数 fn,当协程运行结束后 ret 时会 pop rip,此时便会执行 finish。

img

上图表示切换到已经运行一段时间的协程的过程,因为右侧协程在上一次被切换的时候会将下一条指令地址 push 到栈中,然后在这次切换过程中被 pop 到 r8,因此便会继续执行上次被切换后的代码。