协程如何实现

在上一篇文章中,我们介绍了协程是什么?并使用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:协程被挂起时的状态。

协程状态转移构成的状态机,如下图所示。

image.png

注意:「协程可能会在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上的源码学习」。

image.png

协程唤醒

讲完了最复杂的协程创建,协程唤醒则相对简单很多,协程的唤醒,就是主协程让出执行权,然后被选中的从协程获得执行权,对应的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。

本文为大厂后端技术专家万木春原创文章。作者更多技术干货,见下方的书籍。

image.png


后端开发工程实践
1k 声望98 粉丝

资深的后端研发工程师,在后端研发领域深耕10多年。曾在腾讯、字节跳动等知名公司从互联网商业化服务中台等领域的工作。