Context 上下文切换
最近将一个 PHP 项目整体从传统的 PHP-FPM 迁移到了 Swow,写篇文章记录一下协程实现相关内容作为备忘。相较于进程和线程来说,协程的底层实现是非常简单的,仅仅涉及到了用户态栈的切换。Swow 和 Swoole 一样,选择了性能优秀的 boost.context 作为底层实现。
boost.context 是基于汇编的,每个平台的实现都不相同,但总体思路是类似的,因此这里就只关注 Linux x86-64 架构下的 make_fcontext 和 jump_fcontext 两个基础接口。
函数调用和栈帧
fcontext 的结构和函数调用的栈帧非常接近,所以不妨先熟悉一下普通函数调用时栈帧布局:
可以看到,被调函数栈帧的上方就是被调函数的参数和调用结束后的返回地址,被调函数栈帧开头先保存了调用函数的 %rbp 和一些由 Callee 保存的寄存器值。
fcontext 结构
fcontext 的结构可以直接参考 make_x86_64_sysv_elf_gas.S
中的示图:
结构上和栈帧非常相似,翻译成 struct 大致相当于:
struct fcontext {
int32_t fc_mxcsr; //--------------
int32_t fc_x87_cw; //
int64_t r12; // context 负责保存
int64_t r13; // 的寄存器
int64_t r14; //
int64_t r15; //--------------
int64_t rbx; // context_func_t 函数指针
int64_t rbp; // context 返回地址
int64_t rip; // context 入口地址
}
当然,这么类比并不准确,还是直接看到代码更加方便。
make_fcontext
make_fcontext
函数的原型如下:
typedef void *context_t;
typedef struct
{
context_t from_context;
void *data;
} transfer_t;
typedef void (*context_function_t)(transfer_t transfer);
context_t make_fcontext(void *stack, long unsigned int stack_size, context_function_t func);
一共接受三个参数,stack
是为协程分配的栈底,stack_size
是栈的大小,func
是协程的函数指针。可以看到这里并没有 context_t
类型的参数,这是因为 context 结构是保存在栈的底部。
.file "make_x86_64_sysv_elf_gas.S"
.text
.globl make_fcontext
.type make_fcontext,@function
.align 16
/* make_fcontext
*
* @param stack stack 栈底(rdi)
* @param stack_size stack 大小(rsi)
* @param func 入口函数指针(rdx)
*
* @return context_t(rax)
*/
make_fcontext:
/* 将 stack 地址保存到 rax 寄存器 */
movq %rdi, %rax
/* 对齐地址 */
andq $-16, %rax
/* 将 rax 中的地址向低方向移动 64 个字节,相当于在栈底保留了 context 的位置
* -----------------------------------------------------------------
* stack: | | context ||||
* -------------------------------------------------------------|---
* <-----低方向----- %rax 16-align
*/
leaq -0x40(%rax), %rax
/* 将第三个参数保存到 0x28(%rax) 的位置
* -----------------------------------------------------------------
* context: | | | | | | | FUNC | | |
* -----------------------------------------------------------------
* 0 4 8 16 24 32 40 48 56
*/
movq %rdx, 0x28(%rax)
/*
* -----------------------------------------------------------------
* context: |MMX|x87| | | | | FUNC | | |
* -----------------------------------------------------------------
* 0 4 8 16 24 32 40 48 56
*/
stmxcsr (%rax)
fnstcw 0x4(%rax)
/* 保存 context 入口到 0x38(%rax)
* -----------------------------------------------------------------
* context: |MMX|x87| | | | | FUNC | | ENTRY |
* -----------------------------------------------------------------
* 0 4 8 16 24 32 40 48 56
*/
leaq trampoline(%rip), %rcx
movq %rcx, 0x38(%rax)
/* 保存返回地址到 0x30(%rax)
* -----------------------------------------------------------------
* context: |MMX|x87| | | | | FUNC | EXIT | ENTRY |
* -----------------------------------------------------------------
* 0 4 8 16 24 32 40 48 56
*/
leaq finish(%rip), %rcx
movq %rcx, 0x30(%rax)
/* 返回函数,返回值 %rax 中的地址即为 context 地址
* %rax
* |
* -----------------------------------------------------------------
* context: |MMX|x87| | | | | FUNC | EXIT | ENTRY |
* -----------------------------------------------------------------
* 0 4 8 16 24 32 40 48 56
*/
ret
trampoline:
/* 在以前的版本中并没有这部分代码,而是将 func 作为入口 */
/* 提交记录中说是为了对齐 stack,修复某中特殊情况的 BUG,暂时不明 */
push %rbp
jmp *%rbx
finish:
/* 退出 */
xorq %rdi, %rdi
call _exit@PLT
hlt
.size make_fcontext,.-make_fcontext
/* Mark that we don't need executable stack. */
.section .note.GNU-stack,"",%progbits
jump_fcontext
typedef void *context_t;
typedef struct
{
context_t from_context;
void *data;
} transfer_t;
transfer_t jump_fcontext(context_t const to_context, void *data);
jump_fcontext
的原型如上,接受两个参数,目标 context 地址和参数指针 data,返回的 transfer_t
中保存了当前 context 的信息和数据指针 data。
.file "jump_x86_64_sysv_elf_gas.S"
.text
.globl jump_fcontext
.type jump_fcontext,@function
.align 16
/* jump_fcontext
*
* @param context_t 目标 context(rdi)
* @param data 数据参数指针(rsi)
*
* @return transfer_t from_context(rax),data(rdx)
*/
jump_fcontext:
/* 将栈顶向下移动 0x38 个字节,开辟保存当前 context 数据的空间
* 由于这里是函数调用,call 会自动将返回地址入栈,因此只需要 0x38 个字节即可
*
* |<----context---->|
* |<---0x38--->| |
* -------------------------|------------|----|---------------------
* stack: | | | RA | ...... ||||
* -------------------------|------------|----------------------|---
* <-----低方向----- %rsp old_rsp 16-align
*/
leaq -0x38(%rsp), %rsp /* prepare stack */
/* 依次将需要保存的寄存器值保存到 context 对应的位置中
* %rsp
* |
* -----------------------------------------------------------------
* context: |MMX|x87| R12 | R13 | R14 | R15 | RBX | RBP | RA |
* -----------------------------------------------------------------
* 0 4 8 16 24 32 40 48 56
*/
#if !defined(BOOST_USE_TSX)
stmxcsr (%rsp)
fnstcw 0x4(%rsp)
#endif
movq %r12, 0x8(%rsp)
movq %r13, 0x10(%rsp)
movq %r14, 0x18(%rsp)
movq %r15, 0x20(%rsp)
movq %rbx, 0x28(%rsp)
movq %rbp, 0x30(%rsp)
/* 保存当前 %rsp 到 %rax 寄存器 */
movq %rsp, %rax
/* 将 %rsp 切换到 to_context 的地址
* %rsp
* |
* -----------------------------------------------------------------
* context: |MMX|x87| R12 | R13 | R14 | R15 | RBX | RBP | RIP |
* -----------------------------------------------------------------
* 0 4 8 16 24 32 40 48 56
*/
movq %rdi, %rsp
/* 将返回地址(RIP)保存到 %r8 寄存器 */
movq 0x38(%rsp), %r8
/* 依次将 to_context 中的数据恢复到寄存器中 */
#if !defined(BOOST_USE_TSX)
ldmxcsr (%rsp)
fldcw 0x4(%rsp)
#endif
movq 0x8(%rsp), %r12
movq 0x10(%rsp), %r13
movq 0x18(%rsp), %r14
movq 0x20(%rsp), %r15
movq 0x28(%rsp), %rbx
movq 0x30(%rsp), %rbp
/* 将栈顶向上移动 0x40 个字节,回收 context 占用的空间
*
* %rsp
* |
* -------------------------------------------------------------|---
* stack: | | context ||||
* -------------------------------------------------------------|---
* <-----低方向----- old_rsp 16-align
*/
leaq 0x40(%rsp), %rsp
/* 构造返回数据 transfer,此时 %rax 为 from_context 地址 */
#if !defined(_ILP32)
/* RAX == fctx, RDX == data */
movq %rsi, %rdx
#else
/* RAX == data:fctx */
salq $32, %rsi
orq %rsi, %rax
#endif
/* 将 transfer 作为参数传递给 to_context 入口地址 */
#if !defined(_ILP32)
/* RDI == fctx, RSI == data */
#else
/* RDI == data:fctx */
#endif
movq %rax, %rdi
/* 跳转到返回地址开始执行 */
jmp *%r8
.size jump_fcontext,.-jump_fcontext
/* Mark that we don't need executable stack. */
.section .note.GNU-stack,"",%progbits
这里需要注意一下在 jump_fcontext
中保存当前 context 时只在栈上留了 0x38 个字节,并没有预留返回地址的空间。这是因为在调用 jump_fcontext
时 call
指令会自动把返回地址入栈,可以参考前面的栈帧分布图。
测试
写一个简单的程序测序一下上下文切换:
// test.c
#include <stdio.h>
#include <stdlib.h>
#define STACK_SIZE (4 * 1024)
typedef void *context_t;
typedef struct
{
context_t from_context;
void *data;
} transfer_t;
typedef void (*context_function_t)(transfer_t transfer);
context_t make_fcontext(void *stack, size_t stack_size, context_function_t function);
transfer_t jump_fcontext(context_t const to_context, void *data);
void func(transfer_t transfer) {
printf("I am in func.\n");
transfer = jump_fcontext(transfer.from_context, NULL);
printf("I am in func again!\n");
jump_fcontext(transfer.from_context, NULL);
}
int main() {
char stack[STACK_SIZE];
context_t to_context = make_fcontext(stack + STACK_SIZE, STACK_SIZE, func);
transfer_t transfer;
printf("I am in main.\n");
transfer = jump_fcontext(to_context, NULL);
printf("I am in main again!\n");
jump_fcontext(transfer.from_context, NULL);
printf("End of main\n");
return 0;
}
编译并运行:
$ gcc -g -c make_x86_64_sysv_elf_gas.S -o make.o
$ gcc -g -c jump_x86_64_sysv_elf_gas.S -o jump.o
$ gcc -g malloc.c make.o jump.o -o test
$ ./test
I am in main.
I am in func.
I am in main again!
I am in func again!
End of main