基于ucontext_t实现的非对称协程,本章对应源码:zhongluqiang/sylar-from-scratch at 4419dbda8cf40d9b9d3bb0b0b14242a4d1b8aa2c

协程概述

参考以下链接:

C++ 协程的近况、设计与实现中的细节和决策 - 简书

微信开源C++Libco介绍与应用(一) - 知乎

微信开源C++Libco介绍与应用(二) - 知乎

libaco: 一个极速的轻量级 C 非对称协程库 🚀 (10 ns/ctxsw + 一千万协程并发仅耗内存 2.8GB + Github Trending) - 燕云 - 博客园

一文彻底弄懂C++开源协程库libco——原理及应用 - 知乎

NtyCo的实现 · wangbojing/NtyCo Wiki

以上协程库最好跑一下测试用例,感受一下协程的使用。

建议初学者在开始学习协程时,不要尝试深入x86/x64体系结构和汇编语言去了解协程上下文的内容和协程切换的具体操作,只需要了解协程是什么,协程上下文和协程切换是怎么回事即可。另外,特别说明,NtyCo的配套视频又臭又长,不要去看。

看了上面的链接还不了解协程的可以再往下看看。


最简单的理解,可以将协程当成一种看起来花里胡哨,并且使用起来也花里胡哨的函数。

每个协程在创建时都会指定一个入口函数,这点可以类比线程。协程的本质就是函数和函数运行状态的组合 。

协程和函数的不同之处是,函数一旦被调用,只能从头开始执行,直到函数执行结束退出,而协程则可以执行到一半就退出(称为yield),但此时协程并未真正结束,只是暂时让出CPU执行权,在后面适当的时机协程可以重新恢复运行(称为resume),在这段时间里其他的协程可以获得CPU并运行,所以协程也称为用户态线程。

协程能够半路yield、再重新resume的关键是协程存储了函数在yield时间点的执行状态,这个状态称为协程上下文。协程上下文包含了函数在当前执行状态下的全部CPU寄存器的值,这些寄存器值记录了函数栈帧、代码的执行位置等信息,如果将这些寄存器的值重新设置给CPU,就相当于重新恢复了函数的运行。在Linux系统里这个上下文用ucontext_t结构体来表示,通getcontext()来获取。

搞清楚协程和线程的区别。协程虽然被称为轻量级线程,但在单线程内,协程并不能并发执行,只能是一个协程结束或yield后,再执行另一个协程,而线程则是可以真正并发执行的。其实这点也好理解,毕竟协程只是以一种花里胡哨的方式去运行一个函数,不管实现得如何巧妙,也不可能在单线程里做到同时运行两个函数,否则还要多线程有何用?

因为单线程下协程并不是并发执行,而是顺序执行的,所以不要在协程里使用线程级别的锁来做同步,比如pthread_mutex_t。如果一个协程在持有锁之后让出执行,那么同线程的其他任何协程一旦尝试再次持有这个锁,整个线程就锁死了,这和单线程环境下,连续两次对同一个锁进行加锁导致的死锁道理完全一样。

同样是单线程环境下,协程的yield和resume一定是同步进行的,一个协程的yield,必然对应另一个协程的resume,因为线程不可能没有执行主体。

所谓创建协程,其实就是把一个函数包装成一个协程对象,然后再用协程的方式把这个函数跑起来;所谓协程调度,其实就是创建一批的协程对象,然后再创建一个调度协程,通过调度协程把这些协程对象一个一个消化掉(协程可以在被调度时继续向调度器添加新的调度任务);所谓IO协程调度,其实就是在调度协程时,如果发现这个协程在等待IO就绪,那就先让这个协程让出执行权,等对应的IO就绪后再重新恢复这个协程的运行;所谓定时器,就是给调度协程预设一个协程对象,等定时时间到了就恢复预设的协程对象。

ucontext_t接口

sylar的协程模块基于ucontext_t实现,在学习之前,必须对ucontext_t和ucontext_t的操作函数非常熟悉。关于ucontext_t的定义和相关的接口如下:

// 上下文结构体定义
// 这个结构体是平台相关的,因为不同平台的寄存器不一样
// 下面列出的是所有平台都至少会包含的4个成员
typedef struct ucontext_t {
    // 当前上下文结束后,下一个激活的上下文对象的指针,只在当前上下文是由makecontext创建时有效
    struct ucontext_t *uc_link;
	// 当前上下文的信号屏蔽掩码
    sigset_t          uc_sigmask;
    // 当前上下文使用的栈内存空间,只在当前上下文是由makecontext创建时有效
    stack_t           uc_stack;
    // 平台相关的上下文具体内容,包含寄存器的值
    mcontext_t        uc_mcontext;
    ...
} ucontext_t;

// 获取当前的上下文
int getcontext(ucontext_t *ucp);

// 恢复ucp指向的上下文
int setcontext(const ucontext_t *ucp);

// 修改由getcontext获取到的上下文指针ucp,将其与一个函数func进行绑定,支持指定func运行时的参数,
// 在调用makecontext之前,必须手动给ucp分配一段内存空间,存储在ucp->uc_stack中,这段内存空间将作为func函数运行时的栈空间,
// 同时也可以指定ucp->uc_link,表示函数运行结束后恢复uc_link指向的上下文,
// 如果不赋值uc_link,那func函数结束时必须调用setcontext或swapcontext以重新指定一个有效的上下文,否则程序就跑飞了
// makecontext执行完后,ucp就与函数func绑定了,调用setcontext或swapcontext激活ucp时,func就会被运行
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);

// 恢复ucp指向的上下文,同时将当前的上下文存储到oucp中
// 这个函数是sylar非对称协程实现的关键,调度协程和子协程用这个接口进行上下文切换
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);

ucontext_t的示例代码可参考:todo,这里不再详细介绍。

sylar协程模块设计

首先是协程模型。sylar实现的是非对称协程,也就是子协程只能和线程主协程切换,而不能子协程和子协程切换,并且在程序结束时,一定要再切回主协程,以保证程序能正常结束,像下面这样:

todo.

注意,这里虽然是以main函数举例,但实际上,main代表的是线程主协程,在多线程环境下,可以认为每个线程都有一个main函数。

也可以将线程主协程想像成调度协程,每个子协程执行完了,都必须切回线程主协程,由线程主协程负责选出下一个要执行的子协程。如果子协程可以和子协程切换,那就相当于变相给子协程赋予了调度的权利,这是非对称协程里是不允许的。

sylar将协程模块设计成非对称协程的最大原因就是每个线程只有两个线程局部变量用于存储协程上下文,而且又由于sylar只使用swapcontext来做协程切换,那就意味着,这两个线程局部变量必须有一个是用来保存线程主协程的,如果这两个变量存储的都是子协程的上下文,那么不管怎么调用swapcontext,都没法恢复主协程的上下文,也就意味着程序最终无法回到主协程去执行,程序也就跑飞了。这点可以参考test_fiber2.cc示例。

todo.


协程状态

协程原语


线程局部变量,记录当前协程,和上一次swapcontext时保存的老的协程上下文。

sylar协程模块实现


注意事项




非对称协程,每个线程的入口函数作为主协程,其他协程为子协程,协程只能在主协程和子协程之间进行切换,不能在子协程与子协程之间切换。所对,这个协程模块最大的一点限制是,子协程不能创建并运行子协程,所有的协程都只能由主协程进行创建并调用。这个限制在引入调度器后可以通过调度器接口来规避掉,在使用调度器时,协程可以通过向调度器添加调度任务的方式来启动新的协程。

在非对称协程的实现中,每个线程永远只关心两个协程(由线程局部变量记录),一个是线程主函数的协程,另一个是子协程,在任意时间,要么主协程在前台运行、子协程在后台等待,要么子协程在前台运行、主协程在后台等待,绝对不会出现在前台运行和后台等待的协程都是子协程的情况。


协程:用户态的线程,相当于线程中的线程,更轻量级。后续配置socket hook,可以把复杂的异步调用,封装成同步操作。降低业务逻辑的编写复杂度。 目前该协程是基于ucontext_t来实现的,后续将支持采用boost.context里面的fcontext_t的方式实现。

协程原语:

`resume`:恢复,使协程进入执行状态  
`yield`: 让出,协程让出执行权

yield和resume是同步的,也就是,一个协程的resume必然对应另一个协程的yield,反之亦然,并且,一条线程同一时间只能有一个协程是执行状态。

























  • 无标签