Libco 总体框架
Libco是腾讯的一款开源的协程库,是一个值得研究的开源库。以下是我通过源代码分析,按照自己的理解和知识结构总结的笔记。
- 逻辑框图
libco 是由协程运行环境、协程集合和事件组织起来的一个树状结构:
- 运行环境:是结构中的根节点,一个结构中只能有一个运行环境,每个运行环境对应一个 Linux 线程;
- 协程集合:运行环境中的所有协程,
- 所有协程都在这个运行环境对应的线程上交替运行;
- 协程集合中分主协程和非主协程,其中主协程在创建运行环境时由系统创建,一般主协程负责事件的查询和分发;
- 事件:libco 以 epoll 事件为基础,将等待队列、超时事件等都挂在 epoll 事件的链表下;
- 栈:每个协程都存在一个栈空间,栈分为共享栈和私有栈
- 共享栈:用户在程序开始时通过 API 函数开辟的多块栈结构,在使用时由系统分配协程与栈的对应关系,可能出现多个协程使用同一个栈的情况;
- 私有栈:如果协程不使用共享栈,则系统会为该协程单独开辟栈空间,在频繁创建和销毁协程时会造成性能损失;
- 流程图
- 协程创建
当协程创建时,首先会检测当前协程的运行环境是否创建;如果没有则创建一个运行环境,并将当前运行的代码做为主协程的运行代码创建主协程。
然后在当前的运行环境下创建一个新协程。新协程的栈按照用户的要求可以选择共享栈也可以选择私有栈(私有栈对栈空间大小有要求,如图所示)。
Tips
新协程挂入到运行环境中是在协程唤醒时( co_resume() 函数中),而不是协程创建时。
- 协程调度
libco 中有三种调度方式:
- co_resume() 函数
- 将指定的协程加入到运行环境中,并将与当前的协程进行上下文切换;
- co_yield_ct() / co_yield()
- 这两个函数底层都是通过 co_yield_env() 函数实现的,不同的是 co_yield_ct() 只针对当前协程;
- 在 co_yield_env() 函数是将当前协程与前一个协程进行切换,即退回到上一个协程;并将当前协程从运行环境中弹出,即下次协程切换时会覆盖前当协程在运行环境中的位置;
- 基于事件的调度
- libco 基于 epoll 事件机制,当有事件触发时 libco 会将所有事件放置到运行环境 pEpoll 的 pstActiveList 链表中;并通过顺序执行该链表中的回调函数来切换到对应的协程;
重点说明
上下文切换说明
- elf代码布局
我们通过上图来理解 elf 可执行文件在加载到内存中后大概的局部情况 —— 随着 ASLR 和其他技术的应用内存布局情况可能有所不同,但此图方便我们理解。
-
代码段(.text):
- 通常是指用来存放程序执行代码的一块内存区域,这部分区域的大小在程序运行前就已经确定;
- 该内存区域通常属于只读;
- 在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等;
-
数据段(.data)
- 静态分配的变量
- 全局变量;
-
未初始化数据段(.bss)
- 通常是指用来存放程序中未初始化的全局变量的一块内存区域;
-
堆(heap):
- 用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减;
-
共享内存区域:
- 共享内存;
- 动态库;
-
栈(stack):
- 用户存放程序临时创建的局部变量;
- 函数参数;
- 调用者的返回地址;
- 栈帧(stack frame)
每一次函数的调用,都会在调用栈上维护一个独立的栈帧(stack frame),栈帧的结构如上图所示。
一个栈帧在 x86 构架上采用 ebp 和 esp 这两个寄存器来划定范围:
- ebp 帧指针,指向当前的栈底;
- esp 栈指针,始终指向当前的栈顶;
栈帧是程序维护的一个区域,不是硬件生成的:
- 硬件负责在函数调用/返回时,将调用者的地址压入/弹出;
- 软件负责按照调用协议将函数参数入栈,调整基址 ebp(x86构架),保存寄存器等操作;
- C 语言与汇编约定(基于 x68 处理器)
-
参数:
- 前 6 个整数和指针参数使用 RDI,RSI,RDX,RCX,R8,R9 寄存器以从左到右的顺序传递。
- 前 8 个 float 通过 xmm0-xmm7 寄存器以从左到右的顺序传递。
- 其他参数会被从右到左的压入栈中。
- 被调用函数负责清栈
-
返回值:
- 对于整型返回值,会放在 “%rax” 中;对于浮点型返回值,会放在 “%xmm0” 中
- libco 上下文切换汇编代码
leaq (%rsp),%rax
movq %rax, 104(%rdi)
movq %rbx, 96(%rdi)
movq %rcx, 88(%rdi)
movq %rdx, 80(%rdi)
movq 0(%rax), %rax
movq %rax, 72(%rdi)
movq %rsi, 64(%rdi)
movq %rdi, 56(%rdi)
movq %rbp, 48(%rdi)
movq %r8, 40(%rdi)
movq %r9, 32(%rdi)
movq %r12, 24(%rdi)
movq %r13, 16(%rdi)
movq %r14, 8(%rdi)
movq %r15, (%rdi)
xorq %rax, %rax
movq 48(%rsi), %rbp
movq 104(%rsi), %rsp
movq (%rsi), %r15
movq 8(%rsi), %r14
movq 16(%rsi), %r13
movq 24(%rsi), %r12
movq 32(%rsi), %r9
movq 40(%rsi), %r8
movq 56(%rsi), %rdi
movq 80(%rsi), %rdx
movq 88(%rsi), %rcx
movq 96(%rsi), %rbx
leaq 8(%rsp), %rsp
pushq 72(%rsi)
movq 64(%rsi), %rsi
ret
该段汇编代码的 C 语言声明为:
extern void coctx_swap(coctx_t*, coctx_t*) asm("coctx_swap");
结合 C 语言声明和汇编可以看出 %rdi
保存的是第一个参数,%rsi
保存的是第二个参数。汇编代码的前半段是将寄存器中的内容保存到%rdi
指向的地址空间中,后半段是将%rsi
指向的地址空间中内容恢复到相应的寄存器中——从而完成了上下文的切换。
需要注意的是:由于此段代码只是实现了寄存器的内容的切换比较简单,所以其中没有对栈帧的调整——即此函数没有栈帧。
Tips
其中 “72(%rsi)” 的位置存放函数的返回地址(详见栈帧部分)。
基于事件的调度实现
libco 底层是基于 linux 的 epoll 机制来实现的事件的触发机制:
- 利用 epoll 的超时等待,在超时后通过读取系统当前的 tick 数实现等待队列;
- 利用 epoll 的事件机制,在事件触发时进行协程的切换,从而实现基于事件的调度;
- 通过 hook 系统的 epoll 接口,可以让两个协程通过条件变量实现同步机制;
重点算法说明
- 时间轮算法
unsigned long long diff = apItem->ullExpireTime - apTimeout->ullStart;
... ...
AddTail( apTimeout->pItems + ( apTimeout->llStartIdx + diff ) % apTimeout->iItemSize , apItem );
通过使用 ( apTimeout->llStartIdx + diff ) % apTimeout->iItemSize
将超时时间放置在以最小时间为间隔的循环时间格中。当一个时刻到来时,根据该时刻对应的时间格就可以知道当前时间格与上次时间格之间的时间格都处于超时状态。
- 共享栈、私有栈 & stack buffer
为了减少由于栈空间分配带来的性能损失,用户可以预先分配栈空间用于本环境中的所有协程共享使用——栈内存池。但是由于栈是共享的,可能会涉及多个协程公用一个栈的情况,这时就需要将该协程所用栈的内容保存到 stack buffer 中以便把栈让渡给需要的协程。
私有栈即不使用栈内存池中的空间,通过内存分配函数给协程分配单独的栈空间——这样可以防止出现栈竞争的问题。
- 主协程与非主协程
- 主协程是在创建协程的环境变量的时候由系统产生,负责协程的 epoll 事件;
- 非主协程负责执行用户的任务;
- 协程的私有数据
私有数据即数据的所有权归本协程所有。在 libco 中通过在协程结构体中增加 aSpec[] 数组来保证协程对私有数据的所有权问题——即使使用相同的成员名称,由于协程不同私有数据的实际地址不同。
Tips
主协程的私有数据保存在线程的私有数据空间中。
总结
libco 采用结构化的方式将协程组织在一起,并通过事件机制对协程进行调度,简化的用户的操作,提高了线程的利用率。
但是 libco 的缺点也很明显:
- 只是实现了在同一个线程内的协程调度的部分,所以当有某个协程长时间占用线程资源的时候,其他线程得不到执行,尤其是当负责事件调度的主线程不能及时运行时,会降低程序对事件的响应及时性;
- 没有采用并行编程的思想充分发挥多核 CPU 的硬件优势;
- 不能实现中断处理——只实现了协程的自愿调度,没有实现被动调度;