引言
协程(Coroutine)是一种用户态轻量级线程,允许函数执行过程中主动暂停并在之后恢复。与内核线程相比,协程切换不需要陷入内核(syscall),上下文切换成本仅为保存/恢复几个寄存器(通常 < 10ns>
1. 协程的分类与核心概念
协程可以根据三个维度分类:
- 栈方式:有栈协程(Stackful)vs 无栈协程(Stackless)
- 调度方式:对称协程(Symmetric)vs 非对称协程(Asymmetric)
- 层级关系:栈式嵌套(Stackful nesting)vs 调用者调度(Caller-scheduled)
有栈协程拥有独立运行的调用栈,可以在任意函数嵌套深度中挂起;无栈协程没有独立栈,只能在最顶层函数中挂起(通过状态机转换实现)。Go goroutine 是有栈协程的典型代表,C++20 coroutine 和 Rust async/await 是无栈协程的代表。
2. 有栈协程的原理:上下文切换与栈管理
2.1 上下文切换机制
有栈协程的核心是上下文切换(Context Switch)。一个协程的上下文包含:
- 通用寄存器:RAX、RBX、RCX、RDX、RSI、RDI、RBP、RSP、R8-R15(x86-64 架构共 16 个 64 位寄存器)
- 指令指针:RIP(程序计数器)
- 栈指针:RSP
- 浮点/SSE 寄存器:XMM0-XMM15(可选,惰性保存)
切换时只需保存当前协程的上述寄存器到其控制块(Coroutine Control Block),然后加载目标协程的寄存器。在 Linux 中可以通过 getcontext()/setcontext()/swapcontext() 或直接使用汇编实现。现代的 boost.context 库提供了高度优化的 jump_fctx() 函数,在 x86-64 上仅需 11 个 CPU 周期完成切换。
2.2 协程栈分配
有栈协程每个实例需要分配一块内存作为其调用栈。分配策略有三种:
- 固定大小栈:预分配 4KB-8KB 连续内存。优点是无运行时开销;缺点是可能溢出(深层递归)或浪费内存(大量空闲协程)
- 分段栈(Segmented Stack):Go 1.3 之前使用,栈按需增长(检测到栈顶越界时分配新段)。缺点是热分裂(hot split)问题:函数调用边界处的频繁扩展/收缩导致性能抖动
- 连续栈(Contiguous Stack):Go 1.4+ 采用。当栈不够时分配一块 2 倍大的新栈,将旧内容 memcpy 过去。优点是避免热分裂;缺点是地址失效(栈上指针需要更新),Go 通过栈扫描(GC Stack Scan)来解决
2.3 保护页(Guard Page)
现代协程实现使用 mprotect 在栈底设置一个不可访问的保护页。当协程栈溢出触达保护页时产生 SIGSEGV 信号,运行时捕获该信号并抛出 StackOverflow 异常。这种方法零运行时开销(正常栈操作不吃 page fault)且能精确捕获溢出。
3. 无栈协程:状态机转换模型
无栈协程(以 C++20 coroutine 和 Rust async/await 为例)不维护独立栈,编译器将协程函数体转换为一个状态机。每个 co_await/co_yield 处成为状态转移点(suspend point),局部变量保存在堆分配的协程帧(coroutine frame)中而非栈上。
例如,以下 Rust async 函数:
async fn example() {
let x = read_file().await; // suspend point 1
let y = process(x).await; // suspend point 2
println!("{}", y);
}
将被编译器转换为类似如下的枚举状态机:
enum ExampleStateMachine {
Unstarted,
AfterReadFile { x: Vec, waker: RawWaker },
AfterProcess { y: String },
Finished,
}
无栈协程的优势:
- 零开销抽象:未挂起的协程不分配额外内存
- 无栈溢出风险:不使用独立栈
- 编译器优化:状态机可以被内联和消除死分支
- 内存局部性好:协程帧内联了所有局部变量
劣势:不能在中途嵌套函数中挂起(.await 只能出现在顶层 async fn)、协程帧大小不确定(编译器需要计算所有 suspend point 中活跃变量的并集大小)。
4. Go 的 GMP 调度模型
Go 使用 GMP 模型实现协程调度:
- G (Goroutine):有栈协程,初始栈 2KB(新版),最大可扩展到 1GB
- M (Machine):内核线程,真正执行代码的实体
- P (Processor):逻辑处理器,持有运行队列和缓存,决定并行度(GOMAXPROCS)
关键机制:
- Work Stealing:空闲 P 从其他 P 的本地队列偷取 G 以保持所有 M 忙碌
- Handoff:当 G 阻塞在系统调用时,M 将 P 交给其他 M 继续调度
- Network Poller:基于 epoll/kqueue/IOCP 的异步网络,阻塞的 G 不占 M
- Preemption:Go 1.14+ 引入信号抢占(SIGURG),防止计算密集型 G 饿死其他 G
- Sysmon:后台监控线程处理 netpoll、抢占、强制 GC
5. io_uring 异步运行时
Linux 5.1 引入的 io_uring 是当前最高效的异步 I/O 接口。它通过两个环形缓冲区(Submission Queue SQ 和 Completion Queue CQ)在用户态和内核之间共享数据,避免了每次 I/O 调用的系统调用开销。
核心架构:
- SQ:用户态写入 SQE( Submission Queue Entry),批量提交
- CQ:内核态写入 CQE(Completion Queue Entry),通知完成
- IORING_SETUP_SQPOLL:内核轮询线程自动从 SQ 取工作,用户态完全 0-syscall
- Fixed Buffers:预注册缓冲区,避免每次 I/O 的 get_user_pages 开销
- Fixed Files:预注册文件描述符,避免每次 fget/fput 的引用计数开销
协程 + io_uring 运行时设计:
- 协程执行 I/O 时,向 io_uring SQ 提交一个 read/write 请求,立即让出 CPU
- io_uring 完成 I/O 后产生 CQE,运行时通过 eventfd/epoll 感知完成事件
- 运行时根据 CQE 中的 user_data 找到对应协程,将其放回就绪队列
- 调度器切换回该协程继续执行
代表项目:tokio-uring(Rust)、glommio(C++)、lingerio(Go)。其中 glommio 采用 shared-nothing 架构,每个 CPU 核心独占一个 io_uring 实例,完全消除锁和跨核通信,单机可达 1000 万 IOPS。
6. 实战:用 C 实现最小化有栈协程
#include
#include
#include
#define STACK_SIZE (64 * 1024)
typedef struct {
ucontext_t ctx;
ucontext_t back;
void *stack;
int done;
} co_t;
void co_func(co_t *co, int val) {
printf("start: %d\n", val);
swapcontext->ctx, &co->back); // yield
printf("resume: %d\n", val);
co->done = 1;
}
co_t *co_new() {
co_t *co = malloc(sizeof(co_t));
co->stack = malloc(STACK_SIZE);
getcontext(&co->ctx);
co->ctx.uc_stack.ss_sp = co->stack;
co->ctx.uc_stack.ss_size = STACK_SIZE;
co->ctx.uc_link = &co->back;
return co;
}
void co_yield(co_t *co) {
swapcontext(&co->back, &co->ctx); // 安全:这里应该是 swap->ctx, &co->back
}
注:以上展示核心 swapcontext 用法,完整实现还需处理异常链和 GC roots 注册。现代协程库(boost.fiber、libco、libtask)均在此基础上优化了汇编实现。
7. 关键对比与选型指南
| 特性 | 有栈协程 (Go/Green Thread) | 无栈协程 (Rust/C++20) |
|---|---|---|
| 上下文切换耗时 | ~10ns (汇编优化) | 0ns (直接函数调用) |
| 栈消耗 | 2KB-8KB/协程 | 仅活跃变量 (字节级) |
| 任意深度挂起 | 支持 | 仅顶层 |
| 嵌套调用友好 | 无限制 | 需全链路 async |
| 编译器优化 | 困难(需考虑 switch) | 状态机可内联 |
| 适用场景 | 高并发服务、微服务 | 实时系统、嵌入式、高性能计算 |
8. 结论
协程技术经历了从有栈到无栈、从系统级到编译器级的长期演进。没有绝对最优的方案,选择取决于具体场景:Go 的 GMP 模型适合通用后端服务,x Rust async + io_uring 适合极致性能要求的存储和网络引擎。理解底层上下文切换机制和栈管理策略,是正确使用高级异步框架的前提。

发表评论 取消回复