从零手写协程:C语言与汇编实现上下文切换
协程这个词很多同学都是先会用、再了解到底怎么回事。我最初在项目里写co_yield的时候只当它是某个框架提供的神奇关键字直到后来被一位折腾过底层的老同事按在屏幕前看他从pushq一路画到ret才意识到所谓“协程”核心不过是一段可以随时保存、稍后恢复的执行现场。这篇文章就想把那段被点通的过程复述一遍不用现成协程库只用少量 C 语言加一段汇编上下文切换手写一个能够轮询调度多个任务的协程框架。读完你不仅能看懂那些协程库在忙什么也能自己从头实现一个。1. 协程的“神秘感”从哪来它只是保存和恢复现场1.1 状态机和回调为什么让人头疼如果你写过网络请求的处理多半经历过回调嵌套或者状态机的痛苦。假设有一步操作要“读数据 → 解析 → 写回”用回调写代码会被拆成三段每一段都要通过结构体传递“当前进行到哪一步”这类元信息。任务稍微复杂一点结构体里就开始堆满stage、retry_count、temp_buffer这样的字段每个分支都要小心翼翼地判断“我现在到底在哪”。这不是程序员不够细心而是回调模型天然把一件连续的事切碎了。你心里知道这段逻辑是“先读后解析再写回”代码却必须把它们平铺成不同时机触发的碎片状态一旦多起来阅读和修改都像走迷宫。状态机也是同理。为了管理那些“下一步该做什么”你得显式维护一个状态枚举在每个可能被中断的节点手动转换。这是把操作系统线程帮你做的事情重新用人脑做了一遍写着写着就会怀念“代码就该从上往下跑”的直觉。1.2 协程想解决的就是这件事协程的思路非常朴素既然函数执行到一半时CPU 上还躺着当前指令地址、栈上还有局部变量那我们干脆把这一整套“现场”保存下来想做别的事就去运行另一套“现场”等空了再切换回来代码层面看起来就像函数被暂停之后又恢复了。拿读书来打比方你不可能同时读两本书但你可以各自夹一个书签。线程切换好比把两本书从头读到尾再换开销大协程切换则是很轻地换个书签位置顺手把书翻到那一页。关键点在于切换时真正要保存的“书签”并没有想象中那么多——无非是指令该从哪儿继续、栈指针在哪、几个关键的寄存器是什么。这也解释了为什么协程常被称为“用户态线程”我们不依赖操作系统去调度而是自己在应用层保存和恢复执行现场。既然是自己保存那就先得弄清楚硬件层面到底有哪些东西参与了“干活”。2. 切换点的物理基础寄存器、栈和调用约定2.1 一段 C 代码在 CPU 上是怎么活下去的任何一段程序的执行本质上都受两个指针驱动指令指针PC指向下一条要执行的指令栈指针SP指向当前函数调用栈的顶部。局部变量、函数参数、返回地址都靠栈来存放。函数调用call做两件事把下一条指令的地址压进栈然后跳转到目标函数函数返回ret则恰好相反从栈里弹出这个地址再跳回去。在这一层看协程切换就要解决三个问题下一秒执行哪条指令这靠栈里的返回地址记录。局部变量在哪这靠栈指针确定。通用寄存器里的中间值怎么办这要靠保存和恢复。保存指令执行位置时有个小技巧我们不用专门去捞 PC因为 PC 本来就藏在返回地址链里。只要把栈完整地切换掉ret一执行程序自然就去到目标协程的上一次暂停点。这就是“换栈即换执行流”这句话的由来。2.2 为什么只保存“被调用者保存”寄存器就够了不过寄存器里的临时值该怎么办这就涉及到调用约定。x86-64 的 System V ABI 把寄存器分成两类调用者保存寄存器caller-saved包括rax、rcx、rdx、rsi、rdi、r8到r11和被调用者保存寄存器callee-saved包括rbx、rbp、r12到r15。前者的意思是你调用一个函数之前如果想保住某个值就得自己先存到栈上后者则是被调函数承诺返回时这些寄存器还跟调用前一样。寄存器归属典型用途rax调用者保存返回值、临时计算rdi / rsi / rdx / rcx / r8 / r9调用者保存前 6 个整数参数rbx被调用者保存长期存活变量rbp被调用者保存栈帧基址r12 - r15被调用者保存长期存活变量rsp特殊栈指针读者可能会问切换协程发生在一个任意时刻万一正是在mov 8(%rax), %rcx这条指令执行到一半时被切走rax和rcx里装的可是中间结果不保存不就坏了吗答案是我们的协程切换只发生在函数调用边界。当你调用ctx_switch时C 编译器已经把需要跨调用存活的临时量腾到栈上或者被调用者保存寄存器里了。换句话说只要不在一条复杂表达式的计算过程中强行切换那些调用者保存寄存器就是“无所谓”的垃圾值想保留的自然会由编译器放在该放的地方。这也是所有应用层协程库惯用的前提没有这个前提就得把所有寄存器、甚至浮点寄存器全部保存一遍代价会大得多。3. 让协程有“应许之地”控制块与初始栈布置3.1 一个协程控制块至少要有哪些东西要让一段代码能暂停、恢复我们必须为它维护一个对象。最典型的结构长这样typedef enum { CORO_READY, CORO_RUNNING, CORO_SUSPENDED, CORO_DEAD } coro_status_t; typedef struct coroutine coroutine_t; struct coroutine { void *rsp; /* 保存的栈指针切换时读写 */ coro_status_t status; /* 生命周期状态 */ void (*entry)(void *); /* 入口函数 */ void *arg; /* 入口参数 */ void *stack; /* 协程私有栈基址便于回收 */ }; /* 汇编写的上下文切换函数 */ extern void ctx_switch(coroutine_t **from, coroutine_t *to);rsp必须放在结构体的第一个字段。理由很单纯汇编里访问它时不需要额外偏移量movq (%rsi), %rsp一行就能把目标协程的栈指针加载进来。如果有心你会看到很多成熟协程库的上下文结构体也都是把寄存器保存区放在最前面就是为了汇编代码可以直接用一个基址访问。每个协程还需要一块私有栈。为什么不能用主线程的同一个栈因为一旦切到另一个协程原协程在栈上的局部变量会被新协程的调用覆盖。所以协程创建时就要分配独立内存做栈大小按业务定示例里我用 64KB实际框架里往往是可配置的。3.2 怎么伪造一个“切换返回现场”一个新协程从来没有运行过它没有所谓的“上一次暂停点”。我们希望第一次切换时汇编代码能统一地执行 pop、ret像恢复一个旧协程一样把它启动起来。这就需要在初始化栈时人为地铺出一个“刚刚被切换走”的假现场。在设计 x86-64 的ctx_switch时进入函数后我们会按顺序push六个被调用者保存寄存器然后在ret前又按相反顺序pop出来。对刚创建的协程来说栈上应当预先放好六个“假寄存值”和一个“返回地址”让第一次切换时也能像模像样地 pop 六次再 ret 跳进去。栈布局示意如下高地址 ------------- | 未使用 | - 16 字节对齐的栈顶 ------------- | trampoline | - 返回地址槽地址对齐到 ≡8 (mod 16) ------------- | 6×8 字节 | - 6 个 dummy 槽位配合 pop 指令 ------------- | 自由空间 | - 协程函数真正的栈增长区 低地址创建协程的代码是这样的static coroutine_t *current; static coroutine_t sched_ctx; static void coro_trampoline(void); static void coro_make(coroutine_t *co, void (*entry)(void *), void *arg, size_t stack_size) { void *mem malloc(stack_size); if (!mem) { perror(malloc); exit(1); } uintptr_t *sp (uintptr_t *)((char *)mem stack_size); sp (uintptr_t *)((uintptr_t)sp ~(uintptr_t)15); *--sp (uintptr_t)coro_trampoline; /* 返回地址 */ for (int i 0; i 6; i) *--sp 0; /* 六个 dummy 寄存器槽 */ co-rsp sp; co-status CORO_READY; co-entry entry; co-arg arg; co-stack mem; }为什么返回地址槽要放在≡8 (mod 16)的位置这是 x86-64 ABI 的对齐要求。当这段伪现场被ret恢复后新协程的rsp会自然变成 16 字节对齐这样 trampoline 内部再去调用任何 C 函数栈对齐都是合法的。很多人的协程神秘崩溃最后查出来都是这里错了 8 字节。4. 核心切换函数一段看得懂的汇编 switch4.1 完整实现与逐行解读在 x86-64 System V 调用约定下第一个参数在rdi第二个参数在rsi。ctx_switch的功能是把当前协程的栈指针保存到*from然后从to-rsp加载目标栈指针最后恢复目标协程的现场。# void ctx_switch(coroutine_t **from, coroutine_t *to) # rdi fromrsi to .text .globl ctx_switch ctx_switch: # 保存当前协程的被调用者保存寄存器 pushq %rbx pushq %rbp pushq %r12 pushq %r13 pushq %r14 pushq %r15 # 把当前栈指针保存到 *from movq %rsp, (%rdi) # 加载目标协程的栈指针 movq (%rsi), %rsp # 恢复目标协程的寄存器 popq %r15 popq %r14 popq %r13 popq %r12 popq %rbp popq %rbx ret这一小段代码就是整个协程机制的心脏。你可以发现它不过做了两件看起来很简单的事存现场、装新现场。执行到ret时栈上弹出的地址已经属于另一个协程程序流就此切换。有个有趣的小知识ctx_switch这个函数理论上可以永远不“返回”到它的调用者因为它返回的是别人的现场。只有等那个协程主动切换回来调用它的 C 代码才会继续往下走。这跟普通函数的直觉完全不同但正是协程的关键机制。4.2 几个容易想不通的细节有人会问为什么只保存六个寄存器不保存rdi和rsi前面已经解释过它们属于调用者保存寄存器C 编译器会在调用ctx_switch之前把它们认为有价值的内容放进栈或被调用者保存寄存器。只要把协程切换点放在函数调用边界这两个寄存器就是临时垃圾不需要保存。还有人问push和pop的顺序是不是必须严格对称是的但初学容易写反结果恢复时寄存器和栈位置错位整个协程一跑就乱。汇编是容错率极低的代码一个pushq顺序写错就可能让程序无声无息地跳到错误地址。这个函数建议单测先用两个简单协程互相切换验证再拿去接业务逻辑。5. 调度器与第一个协程把代码跑起来5.1 trampoline 与 yield新协程第一次被切换进去时会从返回地址槽跳到coro_trampoline。trampoline 的作用是把“汇编入口”翻译成普通的 C 函数调用并负责处理协程退出static void coro_trampoline(void) { coroutine_t *co current; co-status CORO_RUNNING; co-entry(co-arg); co-status CORO_DEAD; /* 协程函数返回说明执行结束切回调度器 */ ctx_switch(co-rsp, sched_ctx); }协程主动让出 CPU 的yield也非常简单就是把当前协程的栈指针保存下来再恢复调度器的现场void coro_yield(void) { coroutine_t *co current; ctx_switch(co-rsp, sched_ctx); }这段代码看起来很短背后却完成了“暂停当前协程回到主调度循环”的完整动作。等你再切回这个协程时ctx_switch会从它上次让出的那一点附近继续执行仿佛是函数里的一次普通返回。5.2 一个极简的 round-robin 调度主循环有了控制块、栈、切换汇编剩下的事情就是调度。写两个玩具协程让它们轮流打印static void coro_a(void *arg) { (void)arg; for (int i 0; i 3; i) { printf(A%d\n, i); fflush(stdout); coro_yield(); } } static void coro_b(void *arg) { (void)arg; for (int i 0; i 3; i) { printf(B%d\n, i); fflush(stdout); coro_yield(); } } static int alive(coroutine_t *c) { return c-status ! CORO_DEAD; } int main(void) { coroutine_t a, b; coro_make(a, coro_a, NULL, 64 * 1024); coro_make(b, coro_b, NULL, 64 * 1024); while (alive(a) || alive(b)) { if (alive(a)) { current a; ctx_switch(sched_ctx.rsp, a); } if (alive(b)) { current b; ctx_switch(sched_ctx.rsp, b); } } free(a.stack); free(b.stack); return 0; }这段代码的调度策略是轮流让每个还活着的协程跑一小段协程自己调用yield后调度器就接着调度下一个。输出会自然交错成A0 B0 A1 B1 A2 B2看起来就像两个任务在并发执行。这种调度器虽然简陋但结构上是完整的。实际框架里还会加上事件等待、超时唤醒、定时器等机制本质上都是在调度循环里多判断几个状态罢了。5.3 切换过程中现场是怎么流转的如果把整个过程展开看会清晰很多步骤当前在哪动作现场去向1main调用ctx_switch(sched_ctx.rsp, a)main 的现场存入sched_ctx.rsp2ctx_switch从a.rsp恢复栈跳到 trampoline3trampoline调coro_a正常函数调用栈4coro_a执行打印后调coro_yield协程 a 的现场存入a.rsp5ctx_switch从sched_ctx.rsp恢复栈回到 main 的调度循环每次切换都是同样的操作保存旧栈指针恢复新栈指针。理解了这张表也就理解了协程的所有秘密。6. 真实跑起来的坑对齐、保护页与调试心得6.1 第一个坑初始栈对齐错了 8 字节我刚开始手写这个实现时偷懒没有仔细算对齐直接把返回地址放在栈顶地址处。结果协程第一次运行没问题第二次调用printf时莫名其妙段错误。查到最后发现问题出在 trampoline 入口处rsp不是 16 字节对齐库函数里凡是涉及 SSE 指令的代码都会因未对齐的栈访问崩溃。x86-64 上函数通过call进入时rsp % 16 8而我们用ret模拟进入时要保证进入后rsp % 16 0才能顺利调用下一层 C 函数。这也是为什么创建栈时要把返回地址槽放在≡8 (mod 16)的位置。建议初始化时直接使用posix_memalign或aligned_alloc分配栈内存省去后患。6.2 第二个坑栈方向搞反不少同学第一次写协程栈初始化会从malloc返回的低地址开始往上压东西。但 x86-64 的栈是向下增长的初始rsp应该偏向高地址也就是从stack_base stack_size开始往下布置。把方向搞反你第一次切换进去看到的就是乱七八糟的现场。这个错误很好排查打印一下切换前后的rsp和栈地址范围就立刻能发现。6.3 栈溢出与保护页固定大小栈的最大敌人是递归和超大局部变量。协程里一旦写过深的递归很可能直接踩到别的内存而且这种破坏往往在很久以后才爆发极难定位。常见的加固方案是给每块栈额外多映射一页并把这一页设为不可读写#include sys/mman.h static void *alloc_stack_guarded(size_t stack_size) { long page sysconf(_SC_PAGESIZE); size_t total stack_size page; char *base mmap(NULL, total, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (base MAP_FAILED) return NULL; /* 把最低地址那一页设为保护页栈向下增长先撞到它 */ mprotect(base, page, PROT_NONE); return base page; /* 真正可用的栈起点 */ }这样一旦协程栈溢出程序会立刻在保护页上触发段错误调试时能立即抓到现场而不是等到内存被踩坏很久之后才显现问题。6.4 调试协程栈的小经验在 GDB 里看协程切换最开始会很不适应当前线程的栈明明还在main里一转眼就跳到了另一个协程的栈上。这时不要去看线程堆栈而是直接看rsp和当前协程控制块里的rsp字段。可以在ctx_switch打上断点每次断住之后用x/gx查看保存的目标指针配合bt往往能定位出是哪个协程、在哪一步出问题。如果协程栈里出现乱码优先怀疑退栈逻辑pushq和popq的顺序是否对称、返回地址槽是否被别人覆盖、栈是否跨越了分配边界。7. 移植到 ARM64再对比一下开销7.1 AArch64 上的上下文切换协程核心思想不限于 x86-64。ARM64 的被调用者保存寄存器是x19到x28以及帧指针x29和链接寄存器x30。它的 ARM64 切换函数同样很紧凑# void ctx_switch(coroutine_t **from, coroutine_t *to) # x0 fromx1 to ctx_switch: stp x19, x20, [sp, #-16]! stp x21, x22, [sp, #-16]! stp x23, x24, [sp, #-16]! stp x25, x26, [sp, #-16]! stp x27, x28, [sp, #-16]! stp x29, x30, [sp, #-16]! mov x9, sp str x9, [x0] ldr x9, [x1] mov sp, x9 ldp x29, x30, [sp], #16 ldp x27, x28, [sp], #16 ldp x25, x26, [sp], #16 ldp x23, x24, [sp], #16 ldp x21, x22, [sp], #16 ldp x19, x20, [sp], #16 retARM64 下ret使用x30寄存器里的地址所以保存x30就等于保存了协程返回点。初始栈布置大同小异只是要从 6 个 dummy 槽变成 6 对被保存寄存器再把 trampoline 地址放进x30对应位置。写一遍之后最大的感受是寄存器和调用约定了解清楚了换架构只是换张表的事。7.2 它比 ucontext 快在哪不少库最早用 POSIX 的ucontext实现协程后来都换成了手写汇编。原因在于ucontext会保存和恢复完整的上下文包括信号掩码、辅助状态之类部分实现还要陷入内核处理单次切换常常要到几百纳秒甚至更高。而我们这段汇编只 push/pop 少量寄存器单次切换的量级在几十纳秒完全在普通函数调用开销附近。协程切换越轻量单位时间里可调度次数就越高。做高并发网络服务时这个差距会直接体现在吞吐量上。不过也要理性看待切换快并不等价于程序快如果协程内部逻辑大部分时间都在等待外部 IO真正的瓶颈往往是事件循环而非切换本身。协程库的部署还要考虑跨平台汇编维护成本这也是为什么很多框架会用组合方案支持热门架构用汇编冷门架构退回通用实现。跑通这个最小协程之后我花了两个晚上把 ARM64 版本也验证了一遍。最大的收获不是“我也会写汇编了”而是从此再看任何协程框架的源码脑子里会自然浮现栈指针在几个控制块之间跳动的画面。如果你也想彻底吃透并发机制强烈建议按这个路线自己写一遍先用 C 搭出控制块再换成汇编切换最后加保护页、调试工具每一步都会让你对“暂停与恢复”这四个字有更直接的理解。