基于ucontext_t实现非对称协程。本章描述的内容只和协程有关,不涉及协程调度,请对照源码理解:https://github.com/zhongluqiang/sylar-from-scratch/releases/tag/v1.4.0。
参考以下链接:
【协程第一话】协程到底是怎样的存在?_哔哩哔哩_bilibili
【协程第二话】协程和IO多路复用更配哦~_哔哩哔哩_bilibili
libaco: 一个极速的轻量级 C 非对称协程库 🚀 (10 ns/ctxsw + 一千万协程并发仅耗内存 2.8GB + Github Trending) - 燕云 - 博客园
一文彻底弄懂C++开源协程库libco——原理及应用 - 知乎
NtyCo的实现 · wangbojing/NtyCo Wiki
以上协程库最好跑一下测试用例,感受一下协程的使用。
幼麟实验室的个人空间_哔哩哔哩_Bilibili 这个频道出的一系列视频非常精品,一定要多看看。
建议初学者在开始学习协程时,不要尝试深入x86/x64体系结构和汇编语言去了解协程上下文的内容和协程切换的具体操作,只需要了解协程是什么,协程上下文和协程切换是怎么回事即可。另外,特别说明,NtyCo的配套视频又臭又长,不要去看。
看了上面的链接还不了解协程的可以再往下看看。
最简单的理解,可以将协程当成一种看起来花里胡哨,并且使用起来也花里胡哨的函数。
每个协程在创建时都会指定一个入口函数,这点可以类比线程。协程的本质就是函数和函数运行状态的组合 。
协程和函数的不同之处是,函数一旦被调用,只能从头开始执行,直到函数执行结束退出,而协程则可以执行到一半就退出(称为yield),但此时协程并未真正结束,只是暂时让出CPU执行权,在后面适当的时机协程可以重新恢复运行(称为resume),在这段时间里其他的协程可以获得CPU并运行,所以协程也称为轻量级线程。
协程能够半路yield、再重新resume的关键是协程存储了函数在yield时间点的执行状态,这个状态称为协程上下文。协程上下文包含了函数在当前执行状态下的全部CPU寄存器的值,这些寄存器值记录了函数栈帧、代码的执行位置等信息,如果将这些寄存器的值重新设置给CPU,就相当于重新恢复了函数的运行。在Linux系统里这个上下文用ucontext_t结构体来表示,通getcontext()来获取。
搞清楚协程和线程的区别。协程虽然被称为轻量级线程,但在单线程内,协程并不能并发执行,只能是一个协程结束或yield后,再执行另一个协程,而线程则是可以真正并发执行的。其实这点也好理解,毕竟协程只是以一种花里胡哨的方式去运行一个函数,不管实现得如何巧妙,也不可能在单线程里做到同时运行两个函数,否则还要多线程有何用?
因为单线程下协程并不是并发执行,而是顺序执行的,所以不要在协程里使用线程级别的锁来做协程同步,比如pthread_mutex_t。如果一个协程在持有锁之后让出执行,那么同线程的其他任何协程一旦尝试再次持有这个锁,整个线程就锁死了,这和单线程环境下,连续两次对同一个锁进行加锁导致的死锁道理完全一样。
同样是单线程环境下,协程的yield和resume一定是同步进行的,一个协程的yield,必然对应另一个协程的resume,因为线程不可能没有执行主体。并且,协程的yield和resume是完全由应用程序来控制的。与线程不同,线程创建之后,线程的运行和调度也是由操作系统自动完成的,但协程创建后,协程的运行和调度都要由应用程序来完成,就和调用函数一样,所以协程也被称为用户态线程。
所谓创建协程,其实就是把一个函数包装成一个协程对象,然后再用协程的方式把这个函数跑起来;所谓协程调度,其实就是创建一批的协程对象,然后再创建一个调度协程,通过调度协程把这些协程对象一个一个消化掉(协程可以在被调度时继续向调度器添加新的调度任务);所谓IO协程调度,其实就是在调度协程时,如果发现这个协程在等待IO就绪,那就先让这个协程让出执行权,等对应的IO就绪后再重新恢复这个协程的运行;所谓定时器,就是给调度协程预设一个协程对象,等定时时间到了就恢复预设的协程对象。
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指向的上下文,这个函数不会返回,而是会跳转到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中, // 和setcontext一样,swapcontext也不会返回,而是会跳转到ucp上下文对应的函数中执行,相当于调用了函数 // swapcontext是sylar非对称协程实现的关键,线程主协程和子协程用这个接口进行上下文切换 int swapcontext(ucontext_t *oucp, const ucontext_t *ucp); |
ucontext_t的示例代码可参考:https://github.com/zhongluqiang/playground/tree/main/examples/ucontext,这里不再详细介绍。
sylar使用非对称协程模型,也就是子协程只能和线程主协程切换,而不能和另一个子协程切换,并且在程序结束时,一定要再切回主协程,以保证程序能正常结束,像下面这样:
digraph asymmetric_fiber { rankdir=LR; subgraph main_fiber { node [style=filled]; start [label="开始" shape=none style=""]; main1 [label = "主协程"]; main2 [label = "主协程"]; main3 [label = "主协程"]; main4 [label = "主协程"]; end [label="结束" shape=none style=""]; {rank=same; start main1 main2 main3 main4 end;} } sub1 [label="子协程1"]; sub2 [label="子协程2"]; sub3 [label="子协程3"]; {rank=same; sub1 sub2 sub3;} start -> main1; main1 -> sub1; sub1 -> main2; main2 -> sub2; sub2 -> main3; main3 -> sub3; sub3 -> main4; main4 -> end; } |
关于协程模型和对称与非对称可参考下面的链接:
协程学习(对称和非对称) - 知乎
从网上的参考资料来看,相对于对称协程,非对称协程具有代码实现简单的特点。
在对称协程中,子协程可以直接和子协程切换,也就是说每个协程不仅要运行自己的入口函数代码,还要负责选出下一个合适的协程进行切换,相当于每个协程都要充当调度器的角色,这样程序设计起来会比较麻烦,并且程序的控制流也会变得复杂和难以管理。而在非对称协程中,可以借助专门的调度器来负责调度协程,每个协程只需要运行自己的入口函数,然后结束时将运行权交回给调度器,由调度器来选出下一个要执行的协程即可。
非对称协程的行为与函数类似,因为函数在运行结束后也总是会返回调用者。
虽然目前还没有涉及到协程调度,但这里其实可以将线程的主协程想像成线程的调度协程,每个子协程执行完了,都必须切回线程主协程,由主协程负责选出下一个要执行的子协程。如果子协程可以和子协程切换,那就相当于变相赋予了子协程调度的权利,这在非对称协程里是不允许的。
sylar借助了线程局部变量的功能来实现协程模块。线程局部变量与全局变量类似,不同之处在于声明的线程局部变量在每个线程都独有一份,而全局变量是全部线程共享一份。
sylar使用线程局部变量(C++11 thread_local变量)来保存协程上下文对象,这点很好理解,因为协程是在线程里运行的,不同线程的协程相互不影响,每个线程都要独自处理当前线程的协程切换问题。
对于每个线程的协程上下文,sylar设计了两个线程局部变量来存储上下文信息(对应源码的t_fiber和t_thread_fiber),也就是说,一个线程在任何时候最多只能知道两个协程的上下文。又由于sylar只使用swapcontext来做协程切换,那就意味着,这两个线程局部变量必须至少有一个是用来保存线程主协程的上下文的,如果这两个线程局部变量存储的都是子协程的上下文,那么不管怎么调用swapcontext,都没法恢复主协程的上下文,也就意味着程序最终无法回到主协程去执行,程序也就跑飞了。
如果将线程的局部变量设置成一个类似链表的数据结构,那理论上应该也可以实现对称协程,也就是子协程可以直接和子协程切换,但代码复杂度上肯定会增加不少,因为要考虑多线程和公平调度的问题。
sylar的非对称协程代码实现简单,并且在后面实现协程调度时可以做到公平调度,缺点是子协程只能和线程主协程切换,意味着子协程无法创建并运行新的子协程,并且在后面实现协程调度时,完成一次子协程调度需要额外多切换一次上下文。
这里在sylar的基础上进行简化,对每个协程,只设计了3种状态,分别是READY,代表就绪态,RUNNING,代表正在运行,TERM,代表运行结束。
与sylar版本的实现相比,去掉了INIT状态,HOLD状态,和EXCEPT状态。
sylar的INIT状态是协程对象刚创建时的状态,这个状态可以直接归到READY状态里,sylar的HOLD状态和READY状态与协程调度有关,READY状态的协程会被调度器自动重新调度,而HOLD状态的协程需要显式地再次将协程加入调度,这两个状态也可以归到READY状态里,反正都表示可执行状态。sylar还给协程设计了一个EXCEPT状态,表示协程入口函数执行时出现异常的状态,这个状态可以不管,具体到协程调度模块再讨论。
去掉这几个状态后,协程的状态模型就简单得一目了然了,一个协程要么正在运行(RUNNING),要么准备运行(READY),要运行结束(TERM)。
digraph { a [label="READY"]; b [label="RUNNING"]; c [label="TERM"]; a -> b; b -> a; b -> c; } |
状态简化后,唯一的缺陷是无法区分一个READY状态的协程对象是刚创建,还是已经运行到一半yield了,这在重置协程对象时有影响。重置协程时,如果协程对象只是刚创建但一次都没运行过,那应该是允许重置的,但如果协程的状态是运行到一半yield了,那应该不允许重置。虽然可以把INIT状态加上以区分READY状态,但既然简化了状态,那就简化到底,让协程只有在TERM状态下才允许重置,问题迎刃而解。
对于非对称协程来说,协程除了创建语句外,只有两种操作,一种是resume,表示恢复协程运行,一种是yield,表示让出执行。协程的结束没有专门的操作,协程函数运行结束时协程即结束,并且会自动调用一次yield以返回主协程。
非对称协程,每个线程的入口函数作为主协程,其他协程为子协程,协程只能在主协程和子协程之间进行切换,不能在子协程与子协程之间切换。所对,这个协程模块最大的一点限制是,子协程不能创建并运行子协程,所有的协程都只能由主协程进行创建并调用。这个限制在引入调度器后可以通过调度器接口来规避掉,在使用调度器时,协程可以通过向调度器添加调度任务的方式来启动新的协程。
在非对称协程的实现中,每个线程永远只关心两个协程(由线程局部变量记录),一个是线程主函数的协程,另一个是子协程,在任意时间,要么主协程在前台运行、子协程在后台等待,要么子协程在前台运行、主协程在后台等待,绝对不会出现在前台运行和后台等待的协程都是子协程的情况。
协程:用户态的线程,相当于线程中的线程,更轻量级。后续配置socket hook,可以把复杂的异步调用,封装成同步操作。降低业务逻辑的编写复杂度。 目前该协程是基于ucontext_t来实现的,后续将支持采用boost.context里面的fcontext_t的方式实现。
协程原语:
`resume`:恢复,使协程进入执行状态
`yield`: 让出,协程让出执行权
yield和resume是同步的,也就是,一个协程的resume必然对应另一个协程的yield,反之亦然,并且,一条线程同一时间只能有一个协程是执行状态。