Coroutine:从异步到栈式协程的实现分析
1. 开场:从“函数染色”想到异步
开头想从 Bob Nystrom 的文章 What Color Is Your Function? 讲起。文章用了一个很形象的比喻:假设函数有红色和蓝色,蓝色函数代表同步函数,红色函数代表异步函数;一旦一个函数调用了异步函数,它自己往往也不得不变成异步函数,颜色会不断向调用链传播。
嘿嘿,这里并不是要继续讨论“函数染色”本身,而是借这个问题引出另一个更底层的思考:
一个函数为什么不能停在某个位置,等条件满足之后再从原来的位置继续执行?这个“暂停”和“继续”究竟是怎么实现的?
这个问题正好把我们带到了异步和协程的实现内部。
1.1 什么是异步?
我们平时写同步代码时,控制流很自然:
调用函数 → 等待函数返回 → 使用返回值 → 继续执行比如读取文件、访问网络或等待定时器时,当前函数会停在那里,直到操作完成。问题在于,等待期间可能并不是 CPU 在工作,而是磁盘、网络设备或其他外部条件还没有准备好。如果让当前线程一直等着,线程本身就被占住了。
异步做的事情,是把“发起操作”和“拿到结果”拆开:
发起操作 → 当前执行单元先去做别的事情 → 操作完成 → 回到原任务处理结果这个“回到原任务”可以通过 callback、future/promise、事件循环,也可以通过 coroutine 的 await 来表达。异步并不等于执行得更快,也不等于多个任务一定在多个 CPU 上并行执行。它首先解决的是:
当一个任务处于等待状态时,不要让执行资源也一起闲置。
所以,从实现角度看,异步并不是一个孤立的关键字。只要程序允许任务暂时离开执行流,就必须有某个机制记录“任务现在等什么、完成后从哪里继续、接下来应该运行谁”。这个机制通常由 runtime 提供,包括事件循环、线程池、IO 回调和协程调度器等。
1.2 为什么需要协程?
早期为了实现“同时处理很多等待中的任务”,一种直接想法是:一个任务开一个 pthread。任务阻塞时,操作系统切换到另一个线程。
这个方法当然能工作,但线程不是免费的。每个线程都需要栈空间、线程控制块和内核调度资源,线程数量增加后还会带来更多的调度和缓存开销。更重要的是,很多任务真正需要 CPU 的时间很短,大部分时间只是在等待 IO;如果每个任务都长期占着一个线程,资源利用率并不理想。
因此,实际系统通常会把线程数量控制在一个有限范围内,再在线程之上调度更多任务。协程就是其中一种思路:任务在 await 某个操作时主动挂起,把当前线程让出来;调度器转而运行另一个没有被阻塞的任务。等 IO 或定时器完成后,runtime 再把原任务放回可运行队列。
协程和操作系统的进程/线程上下文切换在思想上很相似:都需要保存当前执行现场,再恢复另一个执行现场。但边界不同。操作系统切换时还要处理地址空间、特权级、内核栈等问题;用户态协程主要保存寄存器、栈指针和返回地址,并切换到另一块用户栈,通常不需要进入内核。
本项目还没有接入真实 IO,也没有实现完整的 await。测试里的 yield() 可以看成“任务主动表示自己暂时不继续执行”的最小替身。项目先把最核心的问题隔离出来:只要能保存和恢复执行现场,就能让多个任务在同一个线程中交替推进。
2. 整体设计思路
2.1 目标和范围
这个项目不是要实现一个完整的协程库,而是用一个尽可能小的实验回答下面的问题:
如果不依赖线程切换,也不依赖 C++20 编译器生成的 coroutine 状态机,能否只保存必要的执行上下文,让多个函数在各自的栈上交替执行?
为了把问题控制在“上下文切换”本身,当前实现做了几个简化:
- 每个协程使用一块固定大小的独立栈;
- 协程通过显式调用 yield() 主动让出执行权;
- 任务没有参数、返回值和异常传播;
- 调度器在同一个线程中顺序扫描协程;
- 不接入真实 IO,yield() 只模拟等待点。
这些限制不是最终能力,而是为了让实现链路足够短,便于分析“一个函数如何暂停并恢复”。
2.2 核心对象之间的关系
可以把整个实现先抽象成三个层次:
Scheduler
├── 保存主线程的 main_ctx
├── 记录当前协程 current
└── 管理多个 Coroutine
Coroutine
├── 用户函数 func
├── 独立栈 stack
├── 可恢复上下文 ctx
└── 当前状态 state
switch_context(old, next)
├── 把当前寄存器和栈指针保存到 old
└── 从 next 恢复寄存器和栈指针其中最关键的不是 Scheduler,而是 switch_context。调度器只负责决定“下一次运行谁”;真正让函数暂停后继续执行的,是上下文保存和恢复。
2.3 一次完整运行的控制流
以协程 A 为例,第一次运行的大致过程是:
- Scheduler 调用 A 的 resume();
- resume() 设置 current 和 active,然后把主线程现场保存到 main_ctx;
- 汇编切换到 A 的独立栈;
- A 第一次恢复时没有真实的暂停现场,因此通过预设的返回地址进入 coroutine_entry;
- coroutine_entry 调用用户函数 A;
- A 执行到 yield(),把自己的现场保存到 A 的 ctx,再切回 main_ctx;
- Scheduler 继续运行其他协程;
- 下一次 resume(A) 时,恢复 A 的 ctx,A 从上一次 yield() 的下一条路径继续执行;
- A 的函数返回后,入口函数把状态改为 Finished,再切回调度器。
这就是整个项目的核心闭环:
保存主线程现场
↓
切到协程栈并执行
↓
yield:保存协程现场
↓
切回主线程
↓
恢复另一个协程2.4 设计选择
本项目选择的是栈式、协作式协程,而不是状态机式、抢占式协程:
| 选择 | 当前方案 | 选择原因 | 代价 |
|---|---|---|---|
| 协程表示 | 独立栈 + Context | 保留自然的函数调用栈,便于理解暂停/恢复 | 依赖 ABI 和汇编 |
| 调度方式 | 协作式 | 切换点明确,不需要处理中断和任意位置抢占 | 任务不主动 yield() 时会阻塞调度器 |
| 栈管理 | 每个任务固定 64 KiB | 实现简单,栈生命周期直观 | 没有栈溢出检测,空间利用不灵活 |
| 任务入口 | void 函数指针 | 先验证机制,接口最小 | 不支持参数、返回值和捕获 lambda |
所以,这个项目的重点不是证明这种方案最适合工程实践,而是用最少的机制展示协程的底层工作方式。
3. 实现代码
3.1 构建文件:CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(Coroutine)
enable_language(ASM)
set(CMAKE_CXX_STANDARD 20)
include_directories(
include
)
add_executable(
coroutine_test
test/main.cpp
src/coroutine.cpp
src/scheduler.cpp
src/context.S
)3.2 上下文接口:include/context.h
#pragma once
struct Context{
void* sp;
void* x19;
void* x20;
void* x21;
void* x22;
void* x23;
void* x24;
void* x25;
void* x26;
void* x27;
void* x28;
void* fp;
void* lr;
};
extern "C"
void switch_context(
Context* old,
Context* next
);3.3 协程接口:include/coroutine.h
#pragma once
#include "context.h"
class Scheduler;
extern "C"
void coroutine_entry();
void yield();
class Coroutine{
public:
using Func = void(*)();
enum class State{
Ready,
Running,
Suspended,
Finished
};
Coroutine(Func f,Scheduler* scheduler);
void resume();
void run();
void finish();
bool finished()const;
private:
Context ctx{};
Func func;
char stack[64*1024];
State state = State::Ready;
Scheduler* scheduler;
friend class Scheduler;
friend void coroutine_entry();
friend void yield();
};3.4 调度器接口:include/scheduler.h
#pragma once
#include<vector>
#include<memory>
#include"coroutine.h"
#include"context.h"
class Scheduler{
public:
void add(Coroutine::Func func);
void run();
private:
Context main_ctx{};
Coroutine* current = nullptr;
std::vector<std::unique_ptr<Coroutine>>coroutines;
inline static thread_local Scheduler* active = nullptr;
friend class Coroutine;
friend void yield();
friend void coroutine_entry();
};3.5 上下文切换:src/context.S
.text
.global _switch_context
_switch_context:
mov x2,sp
str x2,[x0]
str x19,[x0,#8]
str x20,[x0,#16]
str x21,[x0,#24]
str x22,[x0,#32]
str x23,[x0,#40]
str x24,[x0,#48]
str x25,[x0,#56]
str x26,[x0,#64]
str x27,[x0,#72]
str x28,[x0,#80]
str x29,[x0,#88]
str x30,[x0,#96]
ldr x19,[x1,#8]
ldr x20,[x1,#16]
ldr x21,[x1,#24]
ldr x22,[x1,#32]
ldr x23,[x1,#40]
ldr x24,[x1,#48]
ldr x25,[x1,#56]
ldr x26,[x1,#64]
ldr x27,[x1,#72]
ldr x28,[x1,#80]
ldr x29,[x1,#88]
ldr x30,[x1,#96]
ldr x2,[x1]
mov sp,x2
ret3.6 协程实现:src/coroutine.cpp
#include"coroutine.h"
#include"scheduler.h"
#include<cstdint>
#include<iostream>
bool Coroutine::finished()const{
return state == State::Finished;
}
Coroutine::Coroutine(Func f,Scheduler* scheduler){
func = f;
this->scheduler = scheduler;
state = State::Ready;
void* stack_top = stack + sizeof(stack);
stack_top = reinterpret_cast<void*>(
reinterpret_cast<uintptr_t>(stack_top)&~uintptr_t(0xF)
);
ctx.sp = stack_top;
ctx.lr = reinterpret_cast<void*>(coroutine_entry);
}
void Coroutine::resume(){
if(state==State::Finished){
return;
}
scheduler->current=this;
Scheduler::active=scheduler;
state = State::Running;
switch_context(&scheduler->main_ctx,&ctx);
scheduler->current=nullptr;
Scheduler::active=nullptr;
}
void Coroutine::run(){
func();
}
void Coroutine::finish(){
state = State::Finished;
}
extern "C"
void coroutine_entry(){
Scheduler* scheduler = Scheduler::active;
Coroutine* current = scheduler->current;
current->run();
current->finish();
switch_context(¤t->ctx,&scheduler->main_ctx);
}
void yield(){
Scheduler* scheduler = Scheduler::active;
Coroutine* current = scheduler->current;
current->state = Coroutine::State::Suspended;
switch_context(¤t->ctx,&scheduler->main_ctx);
}3.7 调度器实现:src/scheduler.cpp
#include<scheduler.h>
void Scheduler::add(Coroutine::Func func){
coroutines.push_back(std::make_unique<Coroutine>(func,this));
}
void Scheduler::run(){
bool all_finished = false;
while(!all_finished){
all_finished = true;
for(auto& coroutine:coroutines){
if(!coroutine->finished()){
all_finished=false;
coroutine->resume();
}
}
}
}3.8 测试程序:test/main.cpp
#include <iostream>
#include "scheduler.h"
void taskA()
{
std::cout << "A1\n";
yield();
std::cout << "A2\n";
yield();
std::cout << "A3\n";
}
void taskB()
{
std::cout << "B1\n";
yield();
std::cout << "B2\n";
yield();
std::cout << "B3\n";
}
int main()
{
Scheduler scheduler;
scheduler.add(taskA);
scheduler.add(taskB);
scheduler.run();
std::cout << "scheduler finished\n";
return 0;
}4. 代码讲解
4.1 Context 保存的到底是什么?
Context 不是保存一个抽象的“函数状态”,而是保存恢复机器执行所需要的一组值。当前结构的内存布局如下:
| 偏移 | 字段 | 含义 |
|---|---|---|
| 0 | sp | 当前栈指针 |
| 8 到 80 | x19 到 x28 | ARM64 被调用者保存寄存器 |
| 88 | fp,也就是 x29 | 当前栈帧的帧指针 |
| 96 | lr,也就是 x30 | 函数返回地址 |
这里的字段顺序必须和 context.S 中的偏移完全一致。C++ 编译器并不知道汇编代码的这些约定,因此 Context 实际上是 C++ 和汇编之间的一份手写 ABI 合同。
为什么没有保存所有寄存器?因为 switch_context 本身是一个函数调用。按照 ARM64 调用约定,调用者保存寄存器本来就允许被函数调用破坏;跨函数调用需要保持的状态主要由被调用者保存寄存器承担。当前实现保存 x19 到 x28、x29、x30,以及 sp,正是为了恢复调用链所需要的最小集合。
4.2 context.S 如何完成切换?
switch_context 接收两个指针:
x0 = old:保存当前现场的位置
x1 = next:要恢复的目标现场前半段使用 str 指令,把当前 sp、x19 到 x30 写入 old。后半段使用 ldr 从 next 恢复寄存器,最后执行:
ldr x2,[x1]
mov sp,x2
ret这里的关键是 ret。ret 会跳转到恢复后的 x30,也就是 next.lr 指向的位置。因此:
- 第一次恢复一个新协程时,x30 被预先设置为 coroutine_entry,ret 进入协程入口;
- 从 yield() 恢复时,x30 是上一次切换调用保存的返回地址,ret 回到 yield() 调用之后;
- 从协程结束路径恢复时,x30 指向调度器现场,ret 回到 Scheduler::run()。
同一个 switch_context 因此同时承担了“启动协程”和“恢复协程”两个职责。
另外,代码中的全局符号是 _switch_context,而 C++ 中声明的是 switch_context。这是 Darwin 平台 C 符号命名的结果:C++ 代码中的 C linkage 函数在链接层对应带前导下划线的符号。这个细节也说明,手写汇编和平台 ABI 是绑定在一起的。
4.3 Coroutine 构造函数为什么要设置栈和 lr?
每个 Coroutine 都包含一个 64 KiB 的栈数组:
char stack[64*1024];构造函数取出栈顶并向下对齐到 16 字节边界:
void* stack_top = stack + sizeof(stack);
stack_top = reinterpret_cast<void*>(
reinterpret_cast<uintptr_t>(stack_top)&~uintptr_t(0xF)
);
ctx.sp = stack_top;ARM64 ABI 要求栈保持适当对齐。若恢复后的 sp 不满足要求,简单的输出测试可能仍然通过,但复杂的函数调用、对象操作或向量指令可能出现问题。
新协程还没有真实的暂停现场,所以构造函数人为设置:
ctx.lr = reinterpret_cast<void*>(coroutine_entry);这样第一次 resume() 恢复 ctx 后,汇编中的 ret 会进入 coroutine_entry,而不是跳转到一个不存在的调用者。
4.4 resume() 如何进入和离开协程?
resume() 先处理运行时指针:
scheduler->current=this;
Scheduler::active=scheduler;
state = State::Running;current 表示当前调度器正在运行哪一个协程,active 是线程局部的当前调度器。yield() 和 coroutine_entry() 没有显式接收 Scheduler 参数,因此需要通过 active 找到当前运行环境。
随后调用:
switch_context(&scheduler->main_ctx,&ctx);这次调用并不会立即返回。它先把当前调度器的执行现场保存到 main_ctx,然后恢复协程 ctx,切换栈并通过 ret 进入协程。
当协程之后调用 yield(),或者在 coroutine_entry() 中执行结束,控制权会切回 main_ctx。此时 resume() 从 switch_context 调用之后继续执行,并清理 current 和 active:
scheduler->current=nullptr;
Scheduler::active=nullptr;因此,resume() 的一次调用可以理解为“让这个协程运行到下一次主动挂起或结束”。
4.5 yield() 为什么能从原位置继续?
yield() 首先找到当前协程,并把状态改为 Suspended:
Scheduler* scheduler = Scheduler::active;
Coroutine* current = scheduler->current;
current->state = Coroutine::State::Suspended;然后把当前协程现场保存到自己的 ctx,并恢复调度器现场:
switch_context(¤t->ctx,&scheduler->main_ctx);注意 old 和 next 的方向:
协程 yield:
old = 当前协程 ctx
next = 调度器 main_ctx
调度器 resume:
old = 调度器 main_ctx
next = 协程 ctx下一次 resume() 恢复协程 ctx 时,sp 回到原来的协程栈,lr 回到 yield() 调用之后,所以 yield() 会像普通函数一样返回,用户函数也就从暂停点后面继续执行。
这也是栈式协程最核心的地方:它不是重新调用任务函数,而是恢复上一次函数调用现场。
4.6 coroutine_entry() 为什么不能直接 return?
coroutine_entry() 是所有新协程的统一入口:
void coroutine_entry(){
Scheduler* scheduler = Scheduler::active;
Coroutine* current = scheduler->current;
current->run();
current->finish();
switch_context(¤t->ctx,&scheduler->main_ctx);
}它负责三件事:
- 找到当前调度器和当前协程;
- 调用用户函数,并在正常返回后标记 Finished;
- 把控制权切回调度器。
用户函数执行结束后,不能直接从 coroutine_entry() 返回。因为它是在一块人为构造的协程栈上启动的,并没有一个普通的上层调用者等待它返回。直接 return 可能会使用一个不存在或无效的返回地址。显式切回 main_ctx,才是正确的结束路径。
4.7 Scheduler 如何实现轮转?
Scheduler 通过 vector 和 unique_ptr 持有所有协程:
std::vector<std::unique_ptr<Coroutine>>coroutines;add() 创建协程并保存它。run() 反复扫描这个容器:
for(auto& coroutine:coroutines){
if(!coroutine->finished()){
all_finished=false;
coroutine->resume();
}
}
~~