协程如何实现
在上一篇文章中,我们介绍了协程是什么?并使用C++11实现了一个开源的协程库(https://github.com/wanmuc/MyCoroutine)
从本篇文章开始,我将带领大家逐步来实现协程库,「为了减少大家的阅读负担,在文章中只展示必要的代码,和当前讲解内容无关的代码在代码块中采用...进行忽略」。
完整的代码可以从我开源在github上的仓库下载,欢迎大家fork和star。废话不多说,我们马上开始。
注意:「后续的内容中会使用“从协程”这个名词,你可以认为它和“协程”的含义是一致的,“从协程”是为了和“主协程”做区分」。
协程结构体
我们先聚焦看一下,协程关联的结构体,它的内容如下所示,它定义在开源库的common.h文件中。
// 协程结构体
typedef struct Coroutine {
int32_t cid{kInvalidCid}; // 从协程id
State state{State::kIdle}; // 从协程当前的状态
function<void()> entry{nullptr}; // 从协程入口函数
ucontext_t ctx; // 从协程执行上下文
uint8_t *stack{nullptr}; // 每个协程独占的协程栈,动态分配
...
} Coroutine;
目前只需要暂时关注Coroutine结构体的「前5个字段:cid(从协程id)、state(从协程当前的状态)、entry(从协程入口函数)、ctx(从协程执行上下文)、stack(每个协程独占的协程栈,动态分配)」。
kInvalidCid为固定的常量值(-1),现在只需要知道,它们表示无效的从协程id即可。
协程状态机
协程是在用户态由应用程序自行调度执行的,所以这里就涉及到协程状态流转,这些状态的流转共同构成了一个状态机。协程的状态集合如下所示。
// 从协程的状态
enum class State {
kIdle = 1, // 空闲
kReady = 2, // 就绪
kRun = 3, // 运行
kSuspend = 4, // 挂起
};
协程一共4个状态。
- kIdle:协程初始化完成或者协程执行完毕时的状态。
- kReady:协程被创建完,等待被调度时的状态。
- kRun:协程获取到执行权时的状态。
- kSuspend:协程被挂起时的状态。
协程状态转移构成的状态机,如下图所示。
注意:「协程可能会在kRun和kSuspend这两个状态之间多次切换」。
协程创建
协程本质上是进程中的一个调用栈,每个调用栈都是独立的执行上下文,而「执行上下文本质上就是cpu中的寄存器」。
cpu中有两个关键的寄存器,它们分别是「程序计数寄存器和调用栈寄存器」。
程序计数寄存器决定了程序从哪里开始执行。调用栈寄存器决定了调用栈从哪里开始分配。
协程切换简单来说,就是执行上下文的切换,就是cpu中寄存器内容的切换(保存+恢复)。
我们使用了getcontext、makecontext和swapcontext这3个库函数来实现协程的创建、唤醒和让出,「这3个函数替我们屏蔽了创建和切换执行上下文时操作寄存器的细节」。
getcontext函数
getcontext函数用于获取当前执行的上下文,并保存执行上下文到传入的ucontext_t*指针指向的上下文结构体ucontext_t中。它的函数原型如下。
#include <ucontext.h>
int getcontext(ucontext_t *ucp);
- ucp
传入参数,它是一个指向ucontext_t结构体的指针,用于保存当前执行上下文的信息。 - 返回值
获取当前执行上下文成功返回0。失败则返回-1,并设置errno。
makecontext函数
makecontext函数用于修改使用getcontext函数获取的执行上下文。在调用之前,需要先修改获取的执行上下文的栈成员uc_stack和后续执行上下文成员uc_link,「让uc_stack指向新的栈空间,uc_link指向新的后续执行上下文」。如果uc_link为nullptr,上下文被激活执行完之后就直接退出当前线程。它的函数原型如下。
#include <ucontext.h>
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);
- ucp
指向ucontext_t结构体的指针,用于指定要修改的执行上下文,调用getcontext函数获取的执行上下文。 - func
函数指针,用于指定上下文后续被激活时,要执行的函数。 - argc
func指定的函数的参数个数。 - ...
变长参数列表,传入argc指定个数的参数,这个变长参数列表就是调用func的参数列表。
swapcontext函数
该函数用于保存当前执行上下文,并切换到新的执行上下文,它的函数原型如下。
#include <ucontext.h>
int swapcontext(ucontext_t *oucp, ucontext_t *ucp);
- oucp
指向ucontext_t结构体的指针,用于保存当前执行上下文。 - ucp
指向ucontext_t结构体的指针,用于指定要切换到的新的执行上下文。 - 返回值
如果swapcontext函数执行成功,它不会返回,否则返回-1,并设置errno变量以指示错误原因。
注意:当oucp指向的上下文被激活时,swapcontext函数就会返回,此时的返回值为0。
因为协程是在用户态中被调用的,所以需要对所有的协程进行统一管理。Schedule类,就是用于实现这个目标。
现在我们来看一下精简之后的Schedule类的声明代码,其中...表示被忽略的代码,完整的代码可以在开源库的mycorutine.h中找到。
class Schedule { // 协程调度器
public:
explicit Schedule(int32_t coroutine_count, int32_t max_concurrency_in_batch = 0);
~Schedule();
// 从协程的创建函数,通过模版函数,可以支持不同原型的函数,作为从协程的执行函数
template <typename Function, typename... Args>
int32_t CoroutineCreate(Function &&func, Args &&...args) {
int32_t cid = 0;
for (cid = 0; cid < coroutine_count_; cid++) {
if (coroutines_[cid]->state == State::kIdle) {
break;
}
}
if (cid >= coroutine_count_) {
return kInvalidCid;
}
Coroutine *routine = coroutines_[cid];
function<void()> entry = bind(forward<Function>(func), forward<Args>(args)...);
CoroutineInit(routine, entry);
return cid;
}
void Run(); // 协程调度执行
void CoroutineYield(); // 从协程让出cpu执行权
void CoroutineResume(int32_t cid); // 主协程唤醒指定的从协程
...
private:
static void CoroutineRun(Schedule *schedule, Coroutine *routine); // 从协程的执行入口
void CoroutineInit(Coroutine *routine, function<void()> entry); // 从协程的初始化
private:
ucontext_t main_; // 保存主协程的上下文
bool is_master_{true}; // 是否主协程
int32_t slave_cid_{kInvalidCid}; // 运行中的从协程的id(运行从协程时才有效)
int32_t not_idle_count_{0}; // 就绪、运行和挂起的从协程数
int32_t coroutine_count_{0}; // 从协程总数
int32_t stack_size_{kStackSize}; // 从协程栈大小,单位字节
Coroutine *coroutines_[kMaxCoroutineSize]; // 从协程数组池
...
};
} // namespace MyCoroutine
先来看一下Schedule类的成员变量,我们暂时只需要关注7个成员变量:「main_(保存主协程的上下文)、is_master_(是否主协程)、slave_cid_(运行中的从协程的id)、not_idle_count_(就绪、运行和挂起的从协程数)、coroutine_count_(从协程总数)、stack_size_(从协程栈大小,单位字节)」。
注意:这里的coroutine_count_是指协程池允许创建的最大协程数,这个值是小于等于kMaxCoroutineSize这个常量的。
提到常量,我们来看一下协程库中使用到的所有常量值,你可以根据自己的需要调整kStackSize、kMaxCoroutineSize这2个变量的值,这些常量定义在开源库的common.h文件中。
constexpr int32_t kInvalidCid = -1; // 无效的从协程id
...
constexpr int32_t kStackSize = 64 * 1024; // 协程栈默认大小为 64K
...
constexpr int32_t kMaxCoroutineSize = 10240; // 允许创建的最大协程池大小
在Schedule类的构造函数中会完成coroutines_数组的分配。
协程创建函数CoroutineCreate,会从头开始遍历coroutines_数组,直到找到一个空闲的协程结构体,然后调用bind函数实现协程启动函数和参数的绑定,「这里使用了C++11的函数变参模版、折叠引用和完美转发的语法,这样就可以使用不同函数原型的协程入口函数」,最后再调用CoroutineInit函数完成协程结构体的初始化。
CoroutineInit函数的实现如下所示。
void Schedule::CoroutineInit(Coroutine* routine, function<void()> entry) {
routine->entry = entry;
routine->state = State::kReady;
if (nullptr == routine->stack) {
routine->stack = new uint8_t[stack_size_];
}
getcontext(&routine->ctx);
routine->ctx.uc_stack.ss_sp = routine->stack;
routine->ctx.uc_stack.ss_size = stack_size_;
routine->ctx.uc_link = &main_;
not_idle_count_++;
// 设置routine->ctx上下文要执行的函数和对应的参数,
// 这里没有直接使用entry,而是多包了一层CoroutineRun函数的调用,
// 是为了在CoroutineRun中entry函数执行完之后,从协程的状态更新kIdle,并更新当前处于运行中的从协程id为无效id,
// 这样这些逻辑就可以对上层调用透明。
makecontext(&(routine->ctx), (void (*)(void))(CoroutineRun), 2, this, routine);
}
CoroutineInit函数的逻辑如下:
- 设置协程入口执行函数entry,并把协程的状态扭转成kReady。
- 在堆上动态分配出大小为stack_size_字节的调用栈。
- 调用getcontext函数,初始化协程执行上下文。
- 使用分配到的堆内存,来设置协程执行上下文的调用栈uc_stack。
- 设置协程执行完之后的要执行的下一个协程的上下文,uc_link指向了main_这个主协程的上下文,「注意此时main_并未真正的指向主协程的上下文,而是在后续的swapcontext调用时,才会真正指向主协程的上下文」。
- 调用makecontext函数,设置CoroutineRun为协程的启动函数,而不是设置entry为启动函数,这么设计的原因,在上面的代码中已经做了注释,这里不再赘述。
我们现在来看一下CoroutineRun函数的实现,对应的代码如下所示。
void Schedule::CoroutineRun(Schedule* schedule, Coroutine* routine) {
assert(not schedule->is_master_);
schedule->is_master_ = false;
schedule->slave_cid_ = routine->cid;
routine->entry(); // 执行从协程的入口函数
assert(routine->state == State::kRun);
routine->state = State::kIdle;
schedule->is_master_ = true;
schedule->slave_cid_ = kInvalidCid; // slave_cid_更新为无效的从协程id
schedule->not_idle_count_--;
...
// CoroutineRun执行完,调用栈会回到主协程,执行routine->ctx.uc_link指向的上下文的下一条指令
// 即从CoroutineResume函数中的swapcontext调用返回了
}
CoroutineRun函数的逻辑如下:
- 设置协程调度对象的状态为从协程,并设置当前从协程的id。
- 执行用户设置的协程入口函数。
- 更新协程调度对象的状态为主协程,更新协程的状态为kIdle,并设置当前从协程的id为无效的id,「因为从协程已经执行完,并回到了主协程执行」。
- 当前非空闲的协程数减1,「通过not_idle_count_的值,我们可以知道当前的协程池中是否还有协程没执行完」。
至此,协程的创建流程就已经全部讲解完了。在本节的最后给出了对应的内存对象的关系简图,以便大家理清核心对象之间的关系。
「如果阅读一遍之后还是无法掌握,建议多读几遍前面的内容,强烈推荐配合着我开源在github上的源码学习」。
协程唤醒
讲完了最复杂的协程创建,协程唤醒则相对简单很多,协程的唤醒,就是主协程让出执行权,然后被选中的从协程获得执行权,对应的CoroutineResume函数内容如下所示。
void Schedule::CoroutineResume(int32_t cid) {
...
Coroutine* routine = coroutines_[cid];
...
routine->state = State::kRun;
is_master_ = false;
slave_cid_ = cid;
// 切换到协程编号为cid的从协程中执行,并把当前执行上下文保存到main_中,
// 当从协程执行结束或者从协程主动yield时,swapcontext才会返回。
swapcontext(&main_, &routine->ctx);
is_master_ = true;
slave_cid_ = kInvalidCid;
}
CoroutineResume函数逻辑如下:
- 设置被唤醒的从协程的状态为kRun。
- 设置当前运行的协程为从协程,并设置从协程的id。
- 调用swapcontext函数,主协程的执行上下文被保存在main_中,从协程获得执行权,「注意此时的main_才真正的指向了主协程的上下文」。
- 从协程执行完或者主动让出执行权时,会从swapcontext函数返回。
- 设置当前运行的协程为主协程,并设置从协程的id为无效的id。
协程让出
最后我们来讲一下协程的让出,协程的让出和协程的唤醒逻辑类似,对应的CoroutineYield函数内容如下所示。
void Schedule::CoroutineYield() {
...
Coroutine* routine = coroutines_[slave_cid_];
...
routine->state = State::kSuspend;
is_master_ = true;
slave_cid_ = kInvalidCid;
swapcontext(&routine->ctx, &main_);
is_master_ = false;
slave_cid_ = routine->cid;
}
CoroutineYield函数逻辑如下:
- 设置从协程的状态为kSuspend。
- 设置当前运行的协程为主协程,并设置从协程的id为无效的id。
- 调用swapcontext函数,从协程的执行上下文被保存在ctx中,主协程获取执行权。
- 从协程被重新唤醒时,会从swapcontext函数返回。
- 设置当前运行的协程为从协程,并设置从协程的id。
本文为大厂后端技术专家万木春原创文章。作者更多技术干货,见下方的书籍。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。