引言

协程(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)

关键机制:

  1. Work Stealing:空闲 P 从其他 P 的本地队列偷取 G 以保持所有 M 忙碌
  2. Handoff:当 G 阻塞在系统调用时,M 将 P 交给其他 M 继续调度
  3. Network Poller:基于 epoll/kqueue/IOCP 的异步网络,阻塞的 G 不占 M
  4. Preemption:Go 1.14+ 引入信号抢占(SIGURG),防止计算密集型 G 饿死其他 G
  5. 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 运行时设计

  1. 协程执行 I/O 时,向 io_uring SQ 提交一个 read/write 请求,立即让出 CPU
  2. io_uring 完成 I/O 后产生 CQE,运行时通过 eventfd/epoll 感知完成事件
  3. 运行时根据 CQE 中的 user_data 找到对应协程,将其放回就绪队列
  4. 调度器切换回该协程继续执行

代表项目: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 适合极致性能要求的存储和网络引擎。理解底层上下文切换机制和栈管理策略,是正确使用高级异步框架的前提。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部