此文简要分析一下 libco 协程的关键原理
在分析前, 先简单过一些协程的概念, 以免有新手误读了此篇文章
协程是用户态执行单元, 它的创建, 执行, 上下文切换, 挂起, 销毁都是在用户态中完成, 对 linux 系统而言, 其实协程和进程 (注: 在 linux 系统中, 进程是拥有独立地址空间的线程) 一样, 都是 CPU 的执行单元, 只是进程是站在操作系统的层面来看, 操作系统帮我们实现了这一抽象概念, 而协程是站在用户的应用程序层面来看, 协程的实现得靠我们自己我们常说使用协程可以做到以阻塞式的编码方式实现异步非阻塞的效果, 这是因为我们在用户程序层面实现了调度器, 当协程要阻塞的时候切换上下文, 执行其余就绪的协程
下面简要说一下实现一个协程库需要哪几个模块
1 首先当然是操作系统的执行单元, 对于一个执行单元来说, 最基本的其实也就两点, 一是指令, 二是内存空间, 指令定义了操作, 内存用于保存指令中需要的数据, 基于对指令和内存的抽象, 我们这里先牵强的称之为协程
2 有了执行单元后, 当然就需要调度器来负责调度这些执行单元, 如某个协程要阻塞了, 就保存其上下文, 然后运行下一个就绪状态的协程, 当然调度器也是在一个协程单元中运行
3 最后为了实现阻塞式编码实现非阻塞的效果, 需要实现异步 I/O, 而异步 IO 也恰是调度协程的触发器
协程库中有了这三个模块基本就完成了这里有一个关键的点, 那就是当前运行的协程要阻塞了, 我们将其上下文保存, 切换至下一个就绪状态的协程, 这里该如何实现?
要回答这个问题, 我们得先想想什么操作会引起当前协程阻塞? 协程或者说所有的执行单元其实都是指令和数据的有序排列, 指令的执行依赖于数据, 因此协程阻塞的话想必是因数据而起, 说白了就是 I/O 操作 (当然还有 sleep 操作, 这个先以特例看待) 为了避免当前协程阻塞导致整个进程都阻塞掉, 我们可以使用多路 I/O 模型, 例如 epoll, 将所有的 I/O 操都作通过 epoll 模型来进行, 一旦有协程的需要进行 IO, 就保存好它的上下文环境, 加入阻塞队列, 然后再从就绪队列中取出下一个协程运行, 待所有工作协程都陷入阻塞时, 再通过 epoll 进行多路 IO 操作
至于如何保存与恢复上下文这一点正是此文接下来要分析的
我们先简要看一下协程上下文的定义
- //coctx.h
- struct coctx_t
- {
- #if defined(__i386__)
- void *regs[ 8 ];
- #else
- void *regs[ 14 ];
- #endif
- size_t ss_size;
- char *ss_sp;
- };
该结构保存着协程的上下文, 在这里先不解释各个变量的含义, 将其拿出来只是为了解释协程切换的关键函数: coctx_swap, 因为调用该函数时将传入两个 coctx_t 类型指针
协程上下文切换的关键实现位于 coctx_swap.S 文件中初学者可能会疑惑这是什么文件, 这里简单解释一下, 我们写的源代码能变成最终的可执行文件, 是经过多个步骤的, 分别是预处理 ->编译 ->汇编 ->链接 4 个过程, 其中编译这一过程是将源代码转成汇编代码, 那么为什么这里直接提供一个汇编代码文件, 而不是一个. c 或. cpp 文件呢? 因为这个函数跟我们用 c/cpp 写出的函数经过 gnu 编译器编译后生成的函数结构不一致, 另外 c/cpp 的语法糖也无法实现对寄存器 (主要是 rsp 和 rip 寄存器) 的控制
这里只看 x86_64 架构下的实现
- .globl coctx_swap
- #if !defined( __APPLE__ ) && !defined( __FreeBSD__ )
- .type coctx_swap, @function
- #endif
- coctx_swap:
- leaq 8(%rsp),%rax
- leaq 112(%rdi),%rsp
- pushq %rax
- pushq %rbx
- pushq %rcx
- pushq %rdx
- pushq -8(%rax) //ret func addr
- pushq %rsi
- pushq %rdi
- pushq %rbp
- pushq %r8
- pushq %r9
- pushq %r12
- pushq %r13
- pushq %r14
- pushq %r15
- movq %rsi, %rsp
- popq %r15
- popq %r14
- popq %r13
- popq %r12
- popq %r9
- popq %r8
- popq %rbp
- popq %rdi
- popq %rsi
- popq %rax //ret func addr
- popq %rdx
- popq %rcx
- popq %rbx
- popq %rsp
- pushq %rax
- xorl %eax, %eax
- ret
先简单解释一下头部的代码:
- .globl coctx_swap //.global 声明 coctx_swap 是全局可见的
- #if !defined( __APPLE__ ) && !defined( __FreeBSD__ )
- .type coctx_swap, @function //gnu 汇编器定义函数时的规则
- #endif
- coctx_swap: //coctx_swap 函数内容开始
- leaq 8(%rsp),%rax
- leaq 112(%rdi),%rsp
- ...
上面已经提过了, 该函数实际被调用时, 传入了两个参数, 均为 coctx_t 类型指针接下来我们看该函数的上半段:
- coctx_swap:
- leaq 8(%rsp),%rax
- leaq 112(%rdi),%rsp
- pushq %rax
- pushq %rbx
- pushq %rcx
- pushq %rdx
- pushq -8(%rax) //ret func addr
- pushq %rsi
- pushq %rdi
- pushq %rbp
- pushq %r8
- pushq %r9
- pushq %r12
- pushq %r13
- pushq %r14
- pushq %r15
- ...
lea 是取址指令, b,w,l,q 是操作属性限定符, 分别表示 1 字节, 2 字节, 4 字节, 8 字节在 x86_64 架构下, 函数调用时, 参数传递将从左到右分别存入 rdi,rsi,rdx,rcx,r8,r9, 当这 6 个不够用的时候才会借用栈
此处简要提一下 x86_64 架构下 gnu 编译器编译后的 c/cpp 函数调用过程:
1 传参, 主要是传递给寄存器当寄存器不够用时, 会丛右到左压栈, 然后再传参给寄存器
2 将返回地址压栈, 该地址一般指向上一函数中的下一条指令
3 修改 rip 寄存器 (指令寄存器) 为调用函数的起始地址, 新的函数开始了
4 将上个函数的栈帧基址 (rbp 寄存器用于存放栈帧基址) 压入栈中
5 将 rbp 寄存器中的值修改为 rsp 寄存中的值, 即开启了新的栈帧
其中 2,3 是一般由 call 指令做的(当然也可以拆分为 push,jump 两个指令),4,5 为被调函数里面的逻辑
函数返回时是一个逆向的过程, 即恢复到上个函数的栈帧即可
其中 rsp 寄存器为栈顶的地址, 由于栈空间是向下增长的, 每次 push,pop 操作都会对其减少和增加对应的字节数因此上半段相当于是把当前的各寄存器值存入了第一个参数传入的协程上下文的 regs 数组中, 结果如下:
- //low | regs[0]: r15 |
- // | regs[1]: r14 |
- // | regs[2]: r13 |
- // | regs[3]: r12 |
- // | regs[4]: r9 |
- // | regs[5]: r8 |
- // | regs[6]: rbp |
- // | regs[7]: rdi |
- // | regs[8]: rsi |
- // | regs[9]: ret | // 函数的返回地址
- // | regs[10]: rdx |
- // | regs[11]: rcx |
- // | regs[12]: rbx |
- //hig | regs[13]: rsp | // 该值为上个栈帧在调用该函数前的值
其实从这段代码中也能推出来了, 传入的第一个参数必然就是当前工作协程的上下文变量, 那么相应的, 传入的第二个参数必然就是接下来要执行的工作协程接下来看下半段代码:
- movq %rsi, %rsp
- popq %r15
- popq %r14
- popq %r13
- popq %r12
- popq %r9
- popq %r8
- popq %rbp
- popq %rdi
- popq %rsi
- popq %rax //ret func addr
- popq %rdx
- popq %rcx
- popq %rbx
- popq %rsp
- pushq %rax
- xorl %eax, %eax
- ret
第一行即把 rsp(存储栈顶的地址, 改变它的地址, 就相当于改变了栈空间)替换为 rsi 寄存器中的值, 上面提过了 rsi 保存着第二个参数中传入进来的上下文变量, 即接下来要运行的工作协程的上下文接着是一系列的赋值行为(注意栈空间是向下增长的), 将接下来要运行的工作协程的上下文中的 regs 数组中的各值恢复到各寄存器中将返回地址压入栈中, 清 0rax 寄存器的低 32 位后(该寄存器一般用于存储函数返回值, 这行代码并不是要拿它作为返回值使用, 因为 c/cpp 代码在声明该函数时, 它并没有返回值, 个人感觉是出于程序安全考虑), 执行 ret 指令(该指令用于将栈顶的返回地址弹出给 rip 寄存器, 这也是 push %rax 将返回地址压入栈中的原因), 于是下一个工作协程开始运行了
有没有感觉漏了些什么?
是的, 漏了协程的上下文初始化过程我们看一下其初始化函数:
- //coctx.cpp
- enum
- {
- kRDI = 7,
- kRSI = 8,
- kRETAddr = 9,
- kRSP = 13,
- };
- int coctx_make( coctx_t *ctx,coctx_pfn_t pfn,const void *s,const void *s1 )
- {
- char *sp = ctx->ss_sp + ctx->ss_size;
- sp = (char*) ((unsigned long)sp & -16LL );
- memset(ctx->regs, 0, sizeof(ctx->regs));
- ctx->regs[ kRSP ] = sp - 8;
- ctx->regs[ kRETAddr] = (char*)pfn;
- ctx->regs[ kRDI ] = (char*)s;
- ctx->regs[ kRSI ] = (char*)s1;
- return 0;
- }
其中, 下面的两行代码最为重要
- ctx->regs[ kRSP ] = sp - 8;
- ctx->regs[ kRETAddr] = (char*)pfn;
第一行是将 rsp 寄存器替换为了该协程私有的栈空间地址, 这样就保证了每个协程具备独立的栈空间
为什么替换了 rsp 寄存器就保证了该协程将使用自己的栈空间地址呢?
因为栈空间的分配和回收, 是通过 rsp 寄存器来控制的, 如我要分配 4 个字节时, 可执行 sub $0x4,%rsp, 回收 4 个字节时, 可执行 add $0x4,%rsp, 因此当替换了 rsp 寄存器的值时, 即替换了栈空间
第二行是将返回地址 (即下一条执行指令) 替换为了用户创建协程时传入的开始函数地址
当然一个函数的执行少不了传参, 因此接下来的两行代码, 就把参数赋值给了 regs 数组中对应与 rdi 寄存器和 rsi 寄存器的位置
- ctx->regs[ kRDI ] = (char*)s; //rdi 寄存器保存从左到右的第一个参数
- ctx->regs[ kRSI ] = (char*)s1; //rsi 寄存器保存从左到右的第二个参数
到此, 核心部分均分析完毕接下来再回顾核心函数 coctx_swap 的代码, 上面我已经提过了, 这个函数的结构和普通的 c/cpp 写出的函数经 gnu 编译器编译后生成的函数结构不一致, 在接下来的代码中, 我会在注释里将其精简掉的部分写出来
- .globl coctx_swap
- #if !defined( __APPLE__ ) && !defined( __FreeBSD__ )
- .type coctx_swap, @function
- #endif
- coctx_swap:
- //push %rbp // 将上个栈帧的基址压入栈中
- //movq %rsp,%rbp // 将 rbp 赋值为当前的栈顶的值, 即开启了新的栈帧
- // 保存当前工作线程的上下文
- leaq 8(%rsp),%rax
- leaq 112(%rdi),%rsp
- pushq %rax
- pushq %rbx
- pushq %rcx
- pushq %rdx
- pushq -8(%rax) // 函数返回地址, 即下一条指令的执行地址
- pushq %rsi
- pushq %rdi
- pushq %rbp
- pushq %r8
- pushq %r9
- pushq %r12
- pushq %r13
- pushq %r14
- pushq %r15
- // 恢复下一个工作协程的上下文
- movq %rsi, %rsp
- popq %r15
- popq %r14
- popq %r13
- popq %r12
- popq %r9
- popq %r8
- popq %rbp
- popq %rdi
- popq %rsi
- popq %rax // 函数返回地址, 即下一条指令的执行地址
- popq %rdx
- popq %rcx
- popq %rbx
- popq %rsp
- pushq %rax
- xorl %eax, %eax
- //leaveq 该指令将 rbp 赋值给 rsp, 再弹出栈顶的上个栈帧的基址, 并将其赋值给 rbp 寄存器, 从而恢复上个栈帧调用该函数前的结构相当于 movq %ebp, %esp 和 popq %ebp 两条指令
- ret // 相当于 popq %rip
最后再额外提一点, libco 协程库的性能如何? 其实可以看到其切换成本非常的低, 每次切换只有三十多条指令但真正影响切换性能的其实并不是这关键性的上下文切换代码, 而是切换之后可能带来的 cache 缺失问题! 要知道对于现在的 cpu 来说, 一次总线周期已经足够 cpu 执行几十条指令了关于 cpu cache 的知识, 可以查看我的另一篇文章, 从死循环说起关于 libco 如何 hook 第三方库, 实现无缝接入的原理, 可以参考我的另一篇文章, libco hook 原理简析
末尾附上 c/cpp 程序函数调用过程时的栈帧结构以及 i386 架构下的 c/c++ 程序内存结构, 辅助初学者理解
此图为 c/cpp 程序的函数调用栈示意图, 在 x86_64 架构下, 当寄存器足够存放参数时, 是不会对参数进行压栈的, 因此参数 1 到 n(对应函数参数列表是从右到左)是可选的, 当把上个栈帧的基址压入栈中时, 新的栈帧就开始了
下图为 32 位系统 (linux) 下的 c/cpp 程序的内存结构简易图, 32 位系统寻址能力为 4G, 其中 0x0C000000-0xFFFFFFFF 为内核空间, 用户空间只有 3G, 箭头标明了内存的增长方向, 其中堆和动态库都是向上增长的, 栈是向下增长的
来源: http://www.bubuko.com/infodetail-2503587.html