协程模块
关于协程的第一篇文章发表于1963年,有兴趣的同学可以参考这里
首先本章内容只讨论协程本身,不涉及调度器。在此,建议初次接触协程的同学,不要深入到x86/64
体系架构和汇编语言层面来了解协程的上下文切换,只需要知道什么是协程,什么是上下文以及上下文如何切换。
协程(英语:coroutine)是计算机程序的一类组件,推广了协作式多任务的子例程,允许执行被挂起与被恢复。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。协程更适合于用来实现彼此熟悉的程序组件,如协作式多任务、异常处理、事件循环、迭代器、无限列表和管道。 ---- 维基百科
可以通俗一点理解,协程就是看上去和使用起来的都比较特殊的函数。协程在创建的时候都需要指定一个函数入口,这一点可以类比线程。但是协程与函数的区别在于,函数一旦被调用就会从头执行到函数结束,而协程可以在执行途中退出,后续又从退出处恢复继续执行。
我们都知道CPU
核的基本调度单元是线程,一个线程指的是进程中一个单一顺序的控制流,一个进程中可以有多个线程,当系统具有多个CPU
核时,这些线程可以实现并行。同样一个线程可以有多个协程,但即便在多核CPU
下这些协程也是串行无法并行执行。所以协程与进程和线程不属于一个维度的概念
协程之所以能在执行途中退出,后续又能从退出处恢复继续执行,是因为协程在中途退出时,保存了退出时间点函数执行状态,这个状态就是协程上下文。协程上下文包含了函数在当前执行状态下的全部CPU
寄存器的值,这些寄存器的值记录了函数栈帧、代码的执行位置等信息,如果将这些寄存器的值重新设置给CPU
,就相当于重新恢复了函数的运行
github
https://github.com/huxiaohei/tiger.git
上下文切换
进程、线程、协程都涉及到上下文切换的问题,但是三者上下文切换有很大差异
切换者 | 切换时机 | 切换内容 | 切换内容的保存处 | 切换过程 | 切换成本 | |
---|---|---|---|---|---|---|
进程 | 操作系统 | 系统策略,开发人员无感知 | 页全局目录、内核栈、硬件上下文 | 内核栈 | 用户态-内核态-用户态 | 最高 |
线程 | 操作系统 | 系统策略,开发人员无感知 | 内核栈、硬件上下文 | 内核栈 | 用户态-内核态-用户态 | 高 |
协程 | 开发人员 | 开发人员,手动控制 | 硬件上下文 | 自定义变量(栈/堆) | 用户态 | 底 |
实现
tiger
中的协程模块是基于ucontext
实现。因此在看协程模块代码之前必须对ucontext_t
的结构和相关函数非常熟悉
ucontext_t
-
ucontext_t
的具体结构和平台有关,因为不同平台的寄存器存在一定差异。但不管哪个平台都具备下面四个字段typedef struct ucontext_t { struct ucontext_t *uc_link; // 当前上下文结束后,下一个激活的上下文对象的指针,只在当前上下文是由makecontext创建时有效 stack_t uc_stack; // 当前上下文使用的栈内存空间 sigset_t uc_sigmask; // 当前上下文信号屏蔽掩码 mcontext_t uc_mcontext; // 当前上下文寄存器信息 } ucontext_t;
-
获取当前函数上下文
int getcontext(ucontext_t *ucp);
-
恢复上下文,此函数不会返回,会直接跳转到
ucp
所对于的函数执行int setcontext(const ucontext_t *ucp);
-
创建上下文,此函数会将
ucp
和func
进行绑定,并且支持指定执行func
所需要的参数。但是在调用makecontext
之前必须手动开辟一个空间,并使用ucp
的uc_stack
指定指向这块内存,这一内存空间将作为func
执行所需要的内存空间。ucp
中的uc_link
如果不指定,那么在协程结束时,必须调用setcontext
或swapcontext
重新指定一个有效的上下文,否则程序将跑飞void makecontext(ucontext_t *ucp, void (*)(void)func, int argc, ...);
-
切换上下文,此函数与
setcontext
类似。都不会返回,而是直接跳转到ucp
所对应的函数开始执行,但swapcontext
会将当前函数的上下文保存到oucp
中int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);
非对称协程模型
tiger
采用非对称协程模型,每个线程都只有一个主协程,所有的子协程只可以与主线程交互,即yield
退到主协程和在主协程中被resume
协程状态
tiger
中协程一共定义了5个状态,协程在刚创建时处于INIT
状态;只有协程处于INIT
或YIELD
状态时,协程才能被resume
;只有协程处于RUNNING
状态时,协程才能被yield
;如果协程处于TERMINAL
状态,说明协程已经结束并且可以被回收使用;如果协程处于EXCEPT
状态,说明执行创建协程时所指定的函数执行出错,此协程也可以被回收使用
enum State {
INIT = 0b1, // 协程刚创建或者被重置
RUNNING = 0b10, // 正在运行
YIELD = 0b100, // 被挂起
TERMINAL = 0b1000, // 运行结束
EXCEPT = 0b10000 // 运行出错
};
创建协程
协程类虽然提供了无参默认构造函数,但是作为使用者都应该使用带参数的构造函数。构造协程时,会检测当前线程是否存在主协程,如果存在直接构造新的协程对象;如果不存在会先使用无参构造函数构造主协程,并使用线程变量s_t_main_co
指向主协程
注意在构造函数中调用makecontext
时,指定的函数并不是构造函数参数中的函数,而是协程类的一个静态方法Run
,我们只是将构造函数参数中的函数保存在了m_fn
属性中。因此在这个上下文为激活的时候,会执行Run
方法,而我们就是在Run
方法里面执行使用者在构造协程对象时所指定的函数
这样封装有很多好处。首先,可以降低使用者对协程对象的理解成本。使用者不需要关心协程对象的状态是如何切换,以及函数执行完后如何切换到主协程和函数执行出错的容错处理。其次,在性能测试时也很方便的统计每个协程的执行时间等
Coroutine::Coroutine(std::function<void()> fn, size_t stack_size)
: m_fn(fn), m_state(State::INIT) {
if (!s_t_main_co) {
s_t_main_co = std::make_shared<Coroutine>();
s_t_running_co = s_t_main_co;
}
m_id = ++s_co_id;
m_stack_size = stack_size == 0 ? g_co_stack_size->val() : stack_size;
m_stack = MallocStackAllocator::Alloc(m_stack_size);
if (getcontext(&m_ctx)) {
TIGER_ASSERT_WITH_INFO(false, "[getcontext fail]");
}
m_ctx.uc_stack.ss_sp = m_stack;
m_ctx.uc_stack.ss_flags = 0;
m_ctx.uc_stack.ss_size = m_stack_size;
makecontext(&m_ctx, &Coroutine::Run, 0);
++s_co_cnt;
}
唤醒协程
tiger
中提供了静态方法Resume
和对象方法resume
,分别来唤醒主协程和协程对象。其实静态方法Resume
只是将当前线程的主协程s_t_main_co
作为普通协程对象调用了对应的resume
协程对象只有处于INIT
和YIELD
时才能被唤醒,并且子协程只能在主协程中被唤醒,即resume
只能在主协程中调用。因此在唤醒子协程之前,我们将当前线程的主协程(同时也是正在运行的协程)状态修改为YIELD
,然后将s_t_running_co
(线程变量,用于保存当前线程正在执行的协程)指向我们要唤醒的协程对象。最后调用swapcontext
保存主协程的ucontext_t
并切换到子协程的ucontext_t
。当然,这里要处理如果协程切换失败,我们需要恢复主协程的执行状态到RUNNING
和s_t_running_co
重新指向主协程
void Coroutine::resume() {
TIGER_ASSERT_WITH_INFO(m_state & (State::INIT | State::YIELD), "[coroutine can only be resumed when it is INIT or Yield]");
s_t_main_co->m_state = State::YIELD;
m_state = State::RUNNING;
s_t_running_co = shared_from_this();
if (swapcontext(&(s_t_main_co->m_ctx), &m_ctx)) {
TIGER_ASSERT_WITH_INFO(false, "[swapcontext fail]");
}
s_t_main_co->m_state = State::RUNNING;
s_t_running_co = s_t_main_co;
}
执行协程
在创建协程的时候我们将ucontext_t
保存在协程对象的m_ctx
属性中,并与静态方法Run
绑定在一起。在唤醒协程的时候,我们会激活保存在协程对象上m_ctx
属性所指向的ucontext_t
。因此,协程对象一旦被唤醒,程序就会跳转到Run
方法中执行,而在静态方法Run
中会调用m_fn
所指向的函数
一旦m_fn
所指向的函数执行完,就会将协程对象的状态修改为TERMINAL
;如果m_fn
所指向的函数在执行过程中出错,就会将协程对象的状态修改为EXCEPT
。最终,在静态方法Run
结束前会调用swapcontext
切回到主协程执行
void Coroutine::Run() {
try {
s_t_running_co->m_fn();
s_t_running_co->m_fn = nullptr;
s_t_running_co->m_state = State::TERMINAL;
} catch (const std::exception &e) {
s_t_running_co->m_state = State::EXCEPT;
TIGER_LOG_E(tiger::SYSTEM_LOG) << "[run coroutine fail"
<< " id:" << s_t_running_co->m_id
<< " error:" << e.what() << "]";
} catch (...) {
s_t_running_co->m_state = State::EXCEPT;
TIGER_LOG_E(tiger::SYSTEM_LOG) << "[run coroutine fail"
<< " id:" << s_t_running_co->m_id << "]";
}
swapcontext(&(s_t_running_co->m_ctx), &(s_t_main_co->m_ctx));
}
挂起协程
与唤醒协程类似,只是从子协程切换到主协程
void Coroutine::yield() {
TIGER_ASSERT_WITH_INFO(s_t_running_co != s_t_main_co, "[the main coroutinue cannot be suspended by itself]");
m_state = State::YIELD;
s_t_main_co->m_state = State::RUNNING;
s_t_running_co = s_t_main_co;
if (swapcontext(&m_ctx, &(s_t_main_co->m_ctx))) {
TIGER_ASSERT_WITH_INFO(false, "[swapcontext fail]");
}
}