实战:协程库(ucontext+汇编切换+调度器)

协程这东西,说白了就是用户态线程。我早年做游戏服务器时,第一次接触这个概念——当时用回调处理异步逻辑,代码写得跟意大利面条似的。后来换成协程,整个世界清爽了。今天咱们就手撸一个轻量级协程库,核心用 ucontext 加汇编切换,再搭个调度器。

为什么需要协程?

线程切换要进内核,开销大。协程切换在用户态,快得多。你想想看,一个游戏服务器要同时处理几千个连接,每个连接开一个线程?那系统直接崩了。协程可以轻松做到几万甚至几十万并发。

我个人习惯把协程看作「可以暂停的函数」。你调用它,它跑一会儿,然后主动让出 CPU,过会儿再回来接着跑。这就是协作式多任务。

核心组件:ucontext

Linux 提供了 ucontext.h 这套 API,包含四个关键函数:

函数 作用
getcontext 保存当前上下文
setcontext 切换到指定上下文
makecontext 创建新上下文并绑定函数
swapcontext 保存当前上下文,切换到另一个

嗯,这里要注意:makecontext 需要你提前分配栈空间。我刚开始用的时候忘了给栈,程序直接段错误,排查了半天。

汇编切换:为什么还要用汇编?

ucontext 虽然方便,但每次切换都要保存/恢复全部寄存器,包括浮点寄存器,开销不小。如果你追求极致性能,可以用汇编手写切换函数,只保存必要的寄存器。

我在项目中遇到过这种情况:一个协程每秒切换几万次,用 swapcontext 占了 15% 的 CPU。换成汇编切换后,降到了 3%。

核心思路就是保存 rsprbp 和几个 callee-saved 寄存器,然后跳转到目标协程的栈上执行。

// 汇编切换示例(x86-64)
// 保存当前协程的上下文,切换到目标协程
co_switch:
    // 保存 callee-saved 寄存器
    mov [rdi + 0x00], rsp
    mov [rdi + 0x08], rbp
    mov [rdi + 0x10], rbx
    mov [rdi + 0x18], r12
    mov [rdi + 0x20], r13
    mov [rdi + 0x24], r14
    mov [rdi + 0x28], r15

    // 恢复目标协程的寄存器
    mov rsp, [rsi + 0x00]
    mov rbp, [rsi + 0x08]
    mov rbx, [rsi + 0x10]
    mov r12, [rsi + 0x18]
    mov r13, [rsi + 0x20]
    mov r14, [rsi + 0x24]
    mov r15, [rsi + 0x28]

    ret

这段代码只保存了 7 个寄存器,比 ucontext 轻量得多。当然,如果你的协程里用了浮点运算,还得额外保存 xmm 寄存器。

调度器设计

调度器是协程库的大脑。我设计了一个简单的协作式调度器,核心数据结构就三个:

  • 就绪队列:存放等待执行的协程
  • 等待队列:存放因 I/O 或定时器而阻塞的协程
  • 当前协程:正在运行的协程

调度逻辑很简单:从就绪队列头部取出一个协程,切换过去执行。协程主动调用 yield 时,把自己放回就绪队列尾部,然后调度器选下一个。

关键点:协程必须主动让出 CPU,否则调度器没法强制切换。这就是「协作式」的含义。

完整代码骨架

struct coroutine {
    void *stack;          // 栈空间
    void *context;        // 保存的寄存器
    int status;           // 0: 就绪, 1: 运行, 2: 等待
    void (*func)(void*);  // 协程函数
    void *arg;            // 参数
};

struct scheduler {
    struct coroutine *current;
    struct coroutine *ready_queue[1024];
    int ready_head, ready_tail;
};

void coroutine_yield() {
    // 保存当前上下文到 current
    // 把 current 放入就绪队列尾部
    // 从就绪队列头部取出下一个协程
    // 切换过去
}

void coroutine_create(struct scheduler *sched,
                      void (*func)(void*), void *arg) {
    struct coroutine *co = malloc(sizeof(*co));
    co->stack = malloc(STACK_SIZE);
    co->func = func;
    co->arg = arg;
    co->status = 0;

    // 初始化栈,设置入口函数
    // 把 co 放入就绪队列
}

这里有个坑:协程的栈空间不能太小。我见过有人设 4KB,结果函数调用深一点就栈溢出。建议至少 64KB,如果协程里要递归调用,得更大。

避坑指南

我曾经在协程里用了 alloca,结果栈指针直接飞了。协程的栈是手动分配的,alloca 会修改 rsp,但切换时保存的 rsp 是旧的,恢复回来就乱了。

还有,协程里不要用线程局部存储(TLS)。TLS 是绑定到线程的,协程切换时不会更新 TLS,数据会串。我踩过这个坑,调试了两天才发现。

警告:协程中调用第三方库要小心。如果库内部使用了全局变量或 TLS,协程切换可能导致数据错乱。建议只使用纯函数或显式传入上下文的库。

SVG 结构图:协程调度流程

协程调度流程 就绪队列 等待队列 调度器 选择下一个协程 当前协程 正在执行 yield() 主动让出 I/O 阻塞

调度器核心循环

void scheduler_run(struct scheduler *sched) {
    while (1) {
        // 检查就绪队列是否为空
        if (sched->ready_head == sched->ready_tail) {
            // 如果等待队列也为空,说明所有协程都结束了
            if (all_waiting_empty(sched)) break;
            // 否则等待 I/O 事件唤醒协程
            poll_waiting_events(sched);
            continue;
        }

        // 取出下一个协程
        struct coroutine *next = sched->ready_queue[sched->ready_head];
        sched->ready_head = (sched->ready_head + 1) % 1024;

        // 切换到该协程
        sched->current = next;
        co_switch(&sched->main_context, next->context);

        // 协程返回后,检查其状态
        if (next->status == 2) {
            // 阻塞等待,放入等待队列
            add_to_waiting(sched, next);
        } else if (next->status == 0) {
            // 就绪,放回就绪队列
            sched->ready_queue[sched->ready_tail] = next;
            sched->ready_tail = (sched->ready_tail + 1) % 1024;
        }
        // status == 1 表示协程已结束,释放资源
    }
}

这个循环看着简单,但实际项目中要处理很多细节。比如等待队列里的协程怎么唤醒?我一般用 epoll 监听文件描述符,当数据到达时把对应的协程移回就绪队列。

小技巧:协程的栈可以复用。协程结束后,把栈放回一个空闲栈池里,下次创建新协程直接取用,省去重复分配的开销。我在项目中用这个优化,内存分配次数减少了 80%。

性能对比

切换方式 每次切换耗时(纳秒) 适用场景
pthread 线程切换 ~1000 需要内核抢占
ucontext 切换 ~80 开发简单,兼容性好
汇编切换(本库) ~15 高性能场景

数据是我在 Xeon E5 上测的。汇编切换比 ucontext 快了 5 倍,比线程切换快了 60 多倍。当然,这还没算缓存缺失的影响——协程切换后缓存大概率是冷的,实际性能会打些折扣。

好了,协程库的核心就这些。你想想看,从 ucontext 到汇编切换,再到调度器,其实就三个关键点:保存上下文、切换执行流、管理协程状态。把这三点吃透了,你自己也能写一个生产级的协程库。

公众号:蓝海资料掘金营,微信deep3321