☰
手写最小协程库:30行汇编实现上下文切换与调度
2026/10/21 21:21:26 网站建设 项目流程

协程这两个字,听起来像是需要啃完一整本编译原理才敢碰的高级货;汇编更不用说,很多人第一反应是先跑为敬。但我这几年反复跟同事、徒弟讲同一个观点:把"协程"和"汇编"放在一起,反而会把两件事都拆穿。协程的核心动作只有一个——把当前执行到一半的"现场"保存下来,下次再原样恢复;汇编在这个故事里只干一件事:保存寄存器、换栈指针。它不神秘,更不需要你会写整个汇编程序。

这篇文章把我自己写过的一个最小实现完整走一遍:x86-64 下用 30 行左右的汇编做一个上下文切换,再用不到 100 行 C 代码包一个能创建、能 yield、能调度的协程库。适合的人群很明确:想搞懂 goroutine 底层为什么能"挂起"的开发者、面试被问到"协程原理"的求职者、以及所有对调用约定只有一个模糊概念但想亲手验证的程序员。跟着写完你会发现,所谓协程原理,其实就是计算机组成原理里"现场保护"那一节的实践版。

1. 先想清楚:协程到底在解决什么问题

1.1 挂起与恢复,本质是一次"现场保护"

普通的函数调用是一条单行道:main调foo,foo调bar,bar返回后foo继续,foo返回后main继续。每一步都严格嵌套,每次返回都会把栈展开到调用前的状态。这套机制非常可靠,但它有一个天然限制:一个函数一旦开始执行,就必须一口气跑完(或者通过 return 提前退出),中间没有"暂停一下、下次接着跑"的选项。

协程打破的正是这个限制。协程可以运行到一半,主动让出 CPU,过一段时间后从让出的那一条指令继续执行,而且所有局部变量、调用栈都保持原样。

这里的关键词是"现场"。一个执行流的现场由两部分组成:CPU 当前执行到哪条指令(也就是 RIP),以及栈当前长什么样(也就是 RSP 加栈上的数据)。函数调用靠压栈和弹栈天然地管理这两样东西,而协程需要的是把这两样东西"冷冻"起来,等下次再"解冻"。

我打个比方:普通函数是一本必须从头读到尾的书,协程是一本带书签的书。想干别的就先夹上书签合上,回头再从那一页接着读。汇编要做的,就是帮你"夹书签"和"翻回那一页"。

1.2 为什么不能靠回调硬拼

有人会说:异步逻辑我用回调不就行了?确实可以,但回调解决的是"结果回来之后干什么",解决不了"调用栈去哪儿了"。异步回调的经典困境是:一个复杂的业务流程被拆成十几个回调函数,每个回调都丢失了前一个上下文的调用栈,状态只能靠全局变量或者参数传来传去。写多了之后,代码就成了一锅粥。

协程的价值在于它不拆你的逻辑。你可以把异步操作写成顺序代码:发起请求、让出、等结果回来、继续往下写。底层由协程库负责保存和恢复现场。这个价值在 IO 密集场景里尤其明显。

当然,这篇文章不打算展开讨论异步框架的架构,我只想传达一件事:协程解决的是"如何在单个线程里维护多个独立的调用栈"。这个问题绕不开的,就是栈和寄存器。

1.3 三条路线对比:setjmp、ucontext、自己写汇编

想实现协程,常见的路线有三条,我把它们的取舍列个表:

方案保存内容能否换新栈缺点
setjmp / longjmp寄存器 + 部分现场不能(换栈属于未定义行为)简单但不够用,只适合错误恢复
ucontext 系列接口完整上下文能,makecontext 建栈涉及系统调用、接口重量级,兼容性参差
自己写汇编做切换6 个寄存器 + rsp + 返回地址能,想怎么建就怎么建要自己处理对齐和红区,但完全可控

setjmp 看起来最省事,但它是为"错误回跳"设计的:它可以让你从深层嵌套里跳回之前的某个位置,但它不负责给你一块新栈。你没法让一个 setjmp 出来的现场跑在另一个栈上。强行改栈指针属于未定义行为,实测经常翻车。

ucontext 是系统提供的完整方案,能建新栈、能切换。但它为了通用性做了很多额外工作,而且它走的是系统库的接口,封装比较重。我在做教学 demo 时总是希望把每一行都讲清楚,ucontext 的黑盒子属性不太符合需求。

自己写汇编是最直白的一条路。因为一个挂起的 C 函数,它的"活状态"在 ABI(二进制接口约定)里写得清清楚楚:被调用者保存的寄存器加栈。把这几个寄存器保存好,再把栈指针换掉,就完成了协程切换。这就是全部原理,没有更多玄机。

2. 切换现场之前,先弄懂"现场"长什么样

2.1 一个函数调用,栈上到底留下了什么

在 x86-64 平台上,一次函数调用的过程是这样的:调用方先把参数放进寄存器,然后执行call指令。call会做两件事:把返回地址(也就是call下一条指令的地址)压到栈上,然后跳转到被调函数的入口。被调函数如果还需要更多空间,就通过修改 rsp 向下扩展栈帧。

所以任何一个函数的栈帧里,最低地址处一定有一个返回地址。函数执行完毕,ret指令把这个地址弹出来,CPU 跳回去。这就是调用链能够层层返回的物理基础。

对于协程切换来说,我们不需要走完整的"一层层返回"流程。我们要做的是:把当前栈指针整体搬走,换一个栈指针,然后让 CPU 跳到一个已经保存好的位置。这个位置对老协程来说是之前让出时的那条指令,对新协程来说是它的入口。

2.2 调用约定:哪些寄存器必须我来保存

这里必须讲清楚一个概念:C 语言的函数调用约定(ABI)里,寄存器被分成两类。

  • 调用者保存的寄存器:rax、rcx、rdx、rsi、rdi、r8-r11。函数调用方在调用前如果需要这些值,必须先自己保存;被调函数可以随意使用并覆盖它们。
  • 被调用者保存的寄存器:rbx、rbp、r12-r15。被调函数如果要用它们,必须先保存原值,在返回前恢复。

这个规则的本意是:每个函数只需要在进出边界保存有限的寄存器,其余的都靠"谁用谁负责"来分摊成本。

这对我们协程库的意义极其重大。我们写的ctx_switch本质上就是一个普通函数调用,只不过它永远不"正常返回"到原来的栈上。因此,只要我们的汇编代码保存并恢复了被调用者保存的那些寄存器,C 编译器就会认为协程的"现场"完整无损。至于调用者保存的寄存器,编译器知道ctx_switch是一次函数调用,它本来就不会把重要的值跨调用放在这些寄存器里,而是会主动存到栈上或者重新计算。

所以,协程切换真正需要的,就是保存:rbx、rbp、r12、r13、r14、r15,外加栈指针 rsp。指令指针 RIP 不需要额外保存,因为它在栈上那个返回地址槽位里。

我把这个对应关系列成表:

类别寄存器谁负责保存
被调用者保存RBX, RBP, R12-R15协程切换代码(也就是我们)
调用者保存RAX, RCX, RDX, RSI, RDI, R8-R11C 编译器自己处理
栈指针RSP切换时显式保存和恢复
指令指针RIP(返回地址)call/ret 指令隐式压栈和弹栈

理解了这张表,后面那 30 行汇编就完全没有理解障碍了。

2.3 红区和 16 字节对齐,为什么和协程有关

有两个细节经常被新手忽略,但它们恰恰是协程实现里最容易踩坑的地方。

第一个是红区(red zone)。在 x86-64 ABI 里,函数可以不用调整栈指针,直接使用 rsp 向下 128 字节的区域。也就是说,编译器可能生成代码往 rsp-128 到 rsp 这个范围内写数据,而不让 rsp 本身发生变化。这通常是叶子函数的优化手段。在我们的协程场景里,如果旧栈被完整保存,红区自然安全;但是新协程的初始栈必须给这个红区留出足够的空间,否则协程第一次冷启动时可能写到未映射的内存上。

第二个是 16 字节对齐。ABI 要求栈在函数调用边界上保持 16 字节对齐。编译器生成的 SSE 指令(比如 movaps)要求内存地址按 16 字节对齐,如果栈指针不对齐,这些指令会直接触发段错误。我们手工给新协程造初始栈时,必须精确地算好每一个字节的位置。

这两个问题我会在第 5 章详细展开,这里先埋个伏笔。

3. 手写上下文切换:30 行汇编搞定核心

3.1 定义上下文结构体:先画好内存布局

一个上下文需要保存 7 个东西:6 个被调用者保存的寄存器,外加栈指针。我用 C 定义一个结构体,让它的内存布局和汇编代码里的偏移量一一对应:

struct ctx { void *rbx; // 偏移 0 void *rbp; // 偏移 8 void *r12; // 偏移 16 void *r13; // 偏移 24 void *r14; // 偏移 32 void *r15; // 偏移 40 void *rsp; // 偏移 48 };

为什么要手动对齐偏移?因为汇编里用的是movq %rbx, 0(%rdi)这种绝对偏移寻址,C 结构体的字段顺序必须和汇编里的偏移严格一致。多一个字段、换一个顺序,整个切换就乱套了。这种"两份代码共享同一个内存布局"的写法,是系统编程里很常见的默契。

3.2 ctx_switch 实现与"ret 即跳转"的魔法

下面是完整的上下文切换代码,放在一个.S文件里:

.text .globl ctx_switch .type ctx_switch, @function # void ctx_switch(struct ctx *from, struct ctx *to) # rdi = from 的指针,rsi = to 的指针 ctx_switch: # 保存当前上下文到 from movq %rbx, 0(%rdi) movq %rbp, 8(%rdi) movq %r12, 16(%rdi) movq %r13, 24(%rdi) movq %r14, 32(%rdi) movq %r15, 40(%rdi) movq %rsp, 48(%rdi) # 从 to 恢复上下文 movq 0(%rsi), %rbx movq 8(%rsi), %rbp movq 16(%rsi), %r12 movq 24(%rsi), %r13 movq 32(%rsi), %r14 movq 40(%rsi), %r15 movq 48(%rsi), %rsp ret .size ctx_switch, .-ctx_switch .section .note.GNU-stack,"",@progbits

这段代码的核心就是最后一个ret。注意,在恢复完 rsp 之后执行ret,CPU 弹出的返回地址不再是"当前栈"上的地址,而是目标栈顶的地址。这个技巧是整个协程实现的心脏:

  • 如果目标是挂起过的协程,目标栈顶存放的是它之前调用ctx_switch时压入的返回地址,ret会让它精确地回到让出后的下一条指令。
  • 如果目标是新创建的协程,目标栈顶存放的是我们预先埋好的入口地址,ret会直接跳进去。

每次切换,CPU 就像在两条不同的"执行线"之间跳了一次频道,而每条线都有自己的栈、自己的返回链。

3.3 新协程的初始栈:伪造一个"正要返回"的现场

新协程还没有调用过任何函数,它的栈是空的,但我们依然要让ctx_switch的ret能跳进它的入口。办法是手动往新栈上"放"一个假返回地址。

假设协程栈是从堆上分配的一块连续内存,地址从 stack_base 到 stack_base + stack_size,栈向下生长。我从高地址开始规划:

高地址 +------------------------+ | | <- 对齐后的栈顶 top | [slot+8] = 调度器指针 | | [slot] = coro_entry | <- 初始 rsp = slot +------------------------+ | 未使用的栈空间 | +------------------------+ <- stack_base 低地址

当调度器第一次切换到新协程时,ret从slot位置弹出coro_entry,然后 rsp 变成slot + 8,CPU 跳进入口。入口处的汇编代码再从这个位置读取预埋的参数:

.text .globl coro_entry .type coro_entry, @function # 新协程第一次被切换进来时,rsp 指向预埋的调度器指针 coro_entry: movq 0(%rsp), %rdi # 取出调度器指针 subq $8, %rsp # 调整对齐,保证 trampoline 入口满足 ABI call coroutine_trampoline ud2 # 正常情况下永远执行不到这里 .size coro_entry, .-coro_entry .section .note.GNU-stack,"",@progbits

这里有一个很容易被忽略的对齐细节。coro_entry是被ret跳进来的,这相当于"函数入口处"的栈状态,rsp 应该是 8 模 16。但接下来它用call调用coroutine_trampoline时,如果 rsp 保持 8 模 16 不变,call再压一个返回地址后,trampoline 入口的 rsp 就变成 0 模 16,与 ABI 要求的 8 模 16 正好相反。所以我在call之前手动subq $8, %rsp,把偏移纠正过来。

这一步是我第一次写这个 demo 时被段错误教育出来的,后面第 5 章还会再提到。

4. 封装成协程库:创建、切换、调度

4.1 协程控制块、状态机和就绪队列

有了最底层的ctx_switch,剩下的就是纯 C 的包装。我先设计协程控制块:

enum co_status { CO_READY, // 就绪,等待被调度 CO_RUNNING, // 正在运行 CO_DEAD // 已结束 }; struct coroutine { struct ctx ctx; void (*fn)(void *arg); // 协程要执行的函数 void *arg; void *stack_base; // 栈底(低地址) size_t stack_size; int status; struct coroutine *next; // 就绪队列的 next 指针 };

调度器本身维护一个 FIFO 就绪队列,以及当前正在运行的协程指针:

struct scheduler { struct coroutine *ready_head; struct coroutine *ready_tail; struct coroutine *running; struct ctx main_ctx; // 主线程的上下文,相当于调度器自己的工作现场 };

这里有个巧妙的地方:调度器自己并不需要单独的栈,它复用主线程的栈。每次从协程切回调度器,就是把现场保存到某个协程的 ctx 里,然后恢复main_ctx。

4.2 trampoline:让结束的协程优雅退场

协程函数是什么时候结束的?我们没办法保证用户写的协程函数不会 return。如果它真的执行了ret,会从栈上弹出一个不知道是什么的地址,程序大概率崩溃。

解决方法是引入一个"蹦床" trampoline 函数:协程真正执行的入口不是用户的 fn,而是这个蹦床。蹦床调用 fn,等 fn 返回后,把协程标记为 DEAD,再主动切回调度器。

__attribute__((noreturn)) static void coroutine_trampoline(struct scheduler *s) { struct coroutine *co = s->running; co->fn(co->arg); co->status = CO_DEAD; s->running = NULL; ctx_switch(&co->ctx, &s->main_ctx); __builtin_unreachable(); }

你看,这里ctx_switch的参数方向是从协程切到主线程。它的语义和调度器里的调用正好对称:ctx_switch(A, B) 的意思就是"把当前现场存进 A,然后恢复 B"。调度器切进协程是ctx_switch(&s->main_ctx, &co->ctx),协程主动让出是ctx_switch(&co->ctx, &s->main_ctx),来回两次调用就把两边的现场都保存好了。

4.3 完整代码:创建、让出、调度

先看协程创建。它负责分配栈、往新栈上埋入口地址和参数、初始化上下文结构体并放进就绪队列:

struct coroutine *co_create(struct scheduler *s, void (*fn)(void *), void *arg, size_t stack_size) { struct coroutine *co = calloc(1, sizeof(*co)); if (!co) return NULL; co->stack_base = malloc(stack_size); if (!co->stack_base) { free(co); return NULL; } co->fn = fn; co->arg = arg; co->stack_size = stack_size; co->status = CO_READY; // 从高地址往下规划初始栈 uintptr_t top = (uintptr_t)co->stack_base + stack_size; top &= ~(uintptr_t)15; // 向下对齐到 16 字节 uintptr_t slot = top - 16; // 预留入口地址 + 参数的位置 *(uintptr_t *)slot = (uintptr_t)coro_entry; *(uintptr_t *)(slot + 8) = (uintptr_t)s; co->ctx.rsp = (void *)slot; // 插入就绪队列尾部 if (s->ready_tail) { s->ready_tail->next = co; s->ready_tail = co; } else { s->ready_head = s->ready_tail = co; } return co; }

让出操作很直接。当前协程把自己放回队列尾部,然后切回主线程:

void co_yield(struct scheduler *s) { struct coroutine *co = s->running; co->status = CO_READY; // 重新入队 co->next = NULL; if (s->ready_tail) { s->ready_tail->next = co; s->ready_tail = co; } else { s->ready_head = s->ready_tail = co; } s->running = NULL; ctx_switch(&co->ctx, &s->main_ctx); }

调度主循环从队头取一个协程,切换进去,等它让出或结束再回到这里:

void co_schedule(struct scheduler *s) { struct coroutine *co; while ((co = s->ready_head) != NULL) { // 出队 s->ready_head = co->next; if (!s->ready_head) s->ready_tail = NULL; s->running = co; co->status = CO_RUNNING; ctx_switch(&s->main_ctx, &co->ctx); // 从协程切回来了 if (co->status == CO_DEAD) { free(co->stack_base); free(co); } } }

我在这里留了个可以再优化的点:协程结束后直接释放它的栈和控制块。如果协程还在队列里(也就是 yield 过),它会在队列里等着被再次调度,status 不会是 DEAD,所以不会被误释放。

4.4 编译运行与预期输出

写两个测试协程,一个让出 3 次,一个让出 5 次,验证局部变量跨切换保持不变:

#include "coroutine.h" #include <stdio.h> #include <stdlib.h> static struct scheduler *g_sched; static void task_a(void *arg) { (void)arg; for (int i = 0; i < 3; i++) { printf("[A] step %d\n", i); co_yield(g_sched); } printf("[A] done\n"); } static void task_b(void *arg) { (void)arg; for (int i = 0; i < 5; i++) { printf("[B] step %d, local=%d\n", i, i * 10); co_yield(g_sched); } printf("[B] done\n"); } int main(void) { struct scheduler sched; g_sched = &sched; sched.ready_head = sched.ready_tail = NULL; sched.running = NULL; co_create(&sched, task_a, NULL, 64 * 1024); co_create(&sched, task_b, NULL, 64 * 1024); co_schedule(&sched); printf("all done\n"); return 0; }

编译命令:

gcc -g -O2 -fno-omit-frame-pointer -Wall -Wextra \ demo.c coroutine.c ctx_switch.S -o demo

运行结果:

[A] step 0 [B] step 0, local=0 [A] step 1 [B] step 1, local=10 [A] step 2 [B] step 2, local=20 [A] done [B] step 3, local=30 [B] step 4, local=40 [B] done all done

两个任务像两个独立的小线程在轮转。注意 task_b 的local变量在每次 yield 之后都保持原值,这说明它的栈帧被完整地保存并恢复了。

5. 实操踩坑:对齐、红区、调试器

5.1 第一次跑就崩:栈对齐与红区的教训

我最早写这个 demo 的时候,协程入口的汇编里没有subq $8, %rsp,测试函数里也没有任何 SSE 指令,所以跑起来像是正常的。后来我把测试函数换成一段带局部数组并且开启了优化编译的代码,就时不时在奇怪的位置段错误。查了很久才发现,问题就是 trampoline 入口的栈没对齐。

这里我想强调一个经验:栈对齐这种问题,不是每次都会崩,而是可能在某个完全无关的改动后突然出现。编译器是否生成对齐敏感的 SSE 指令取决于优化等级、变量类型和代码结构。所以协程切换代码对对齐的严谨性,应该是一开始就写对的,而不是等出问题再修。

红区的问题也类似。第一次冷启动时,rsp 指向 slot 附近,而蹦床函数和用户函数的第一批栈帧都在 slot 往下扩展。如果栈分配得不够大,或者top对齐后把可用空间吃掉了太多,就可能踩到未映射的内存。我的习惯是:演示用 64KB,生产环境至少 1MB 起步,并且用第 6 章的 mmap 方案加 guard page。

5.2 编译选项:哪些能开,哪些会给你挖坑

这个实现用了 C 和汇编混编,编译选项的影响值得专门说。

常规的-O2完全没问题。因为ctx_switch对编译器来说就是一个普通的外部函数调用,编译器会严格按照 ABI 来假定它"破坏了"调用者保存的寄存器、完整保留了被调用者保存的寄存器。这正是我们想要的契约。

-fno-omit-frame-pointer强烈建议加上。带上它之后,每个 C 函数都会维护 rbp 栈帧链,GDB 的bt命令在协程内部也能正确打出调用栈。如果不加,调试协程会痛苦得多。

-fstack-protector一般也能正常工作,因为栈保护用的 canary 值是从线程局部存储读的,和栈本身不冲突。但如果出了问题,第一反应可以先换成-fno-stack-protector排除变量。

-fsanitize=address这个要特别小心。ASan 假设程序只有一条常规栈,它通过影子内存跟踪每一个栈地址的合法性。协程的栈是堆内存,ASan 会把它当成普通堆对象来对待。一旦协程切换进去,ASan 对"栈上操作"的很多假设都会失效,会蹦出一堆看不懂的报告。学习阶段别开 ASan,实在要查内存问题,可以用-fsanitize=undefined先顶着。

5.3 用 GDB 观察协程切换现场

协程的调试和普通程序不太一样,但掌握了方法之后会很有意思。我在 GDB 里一般这么干:

先给ctx_switch下断点,每次切换都会停在这里:

(gdb) break ctx_switch (gdb) run

停下来之后,info registers rbx rbp r12 r13 r14 r15 rsp能看当前寄存器快照,x/4gx $rsp能看当前栈顶的返回地址。要确认某个协程的上下文,可以直接打印它的 ctx 字段:

(gdb) p sched.running->ctx.rsp (gdb) x/4gx sched.running->ctx.rsp

你会发现栈顶上蹲着的、正是它之前让出时的那条指令地址,也就是 ret 之后的下一条指令。这是协程原理最直观的验证:所谓"现场",就是能把执行流精确拉回那一刻的若干字节。

另外,中断在某个协程内部时,bt默认可能显示不出跨协程的调用链,这是正常的。汇编切换回旧栈的行为对调试器来说是"凭空转移",GDB 没有钩子感知协程的存在。这也是很多工业级协程库会带自己调试插件的根本原因。

5.4 常见问题速查表

现象可能原因处理办法
第一次切换到新协程就崩溃入口地址没放对,或者初始 rsp 计算错误打印co->ctx.rsp,检查 slot 处的 8 字节是不是 coro_entry
栈对齐相关的偶发段错误进入 trampoline 时 rsp 不满足 8 模 16在 coro_entry 里 call 之前手动调整 rsp
局部变量值跨 yield 后丢失没有把某个被调用者保存的寄存器存进去对照表检查 rbx/rbp/r12-r15 是否齐全
协程函数 return 后整个程序崩缺少 trampoline,函数把垃圾地址当成返回地址弹了确保入口统一走 trampoline
优化开高后行为变化编译器生成对齐敏感的 SSE 指令先关优化验证,再确认对齐和红区
偶尔越界写坏堆协程栈不够大或红区被下溢加大栈、用 mmap guard page(见第 6 章)

6. 还能往哪走:移植、事件驱动、栈管理

6.1 换到 ARM64:换汤不换药的寄存器名

这套思路换到 ARM64 上几乎不需要重新设计,只需要换一批寄存器和压栈方式。ARM64 的 ABI 规定 x19 到 x28 是被调用者保存的寄存器,返回地址放在 x30(LR)而不是压栈。切换函数看起来是这样:

# void ctx_switch(struct ctx *from, struct ctx *to) # x0 = from, x1 = to ctx_switch: stp x19, x20, [x0, #0] stp x21, x22, [x0, #16] stp x23, x24, [x0, #32] stp x25, x26, [x0, #48] stp x27, x28, [x0, #64] mov x9, sp str x9, [x0, #80] str x30, [x0, #88] # LR ldp x19, x20, [x1, #0] ldp x21, x22, [x1, #16] ldp x23, x24, [x1, #32] ldp x25, x26, [x1, #48] ldp x27, x28, [x1, #64] ldr x9, [x1, #80] mov sp, x9 ldr x30, [x1, #88] ret

ARM64 平台没有 x86 那个"返回地址压栈"的隐式机制,所以结构体里要显式多存一个 LR。新协程的初始上下文就是让 x30 指向 coro_entry,sp 指向预埋参数的位置。原理完全一样,只是搬家的货物从 6 个寄存器变 10 个。

6.2 从轮转调度到事件驱动:调度器进化方向

第 4 章的调度器是无脑轮转:每个协程 yield 就排到队尾。真实世界的协程调度器很少这么简单。常见的进化方向是引入等待机制:协程不 yield 自己,而是声明"我在等某个条件"。比如等待 IO 可读、等待定时器到期、等待一个锁。

做法是给每个协程加一个等待类别和对应的回调。调度器不再只是盲转队列,而是在空闲时去事件循环里等。以单线程网络服务器为例:

  • 协程发起网络请求后把自己挂到"等待可读"列表里,然后让出。
  • 主循环用 epoll/poll 等待所有 fd 的事件。
  • 某个 fd 可读了,主循环把对应的协程重新放回就绪队列。

这样协程的代码看起来就是同步的顺序逻辑,而底层是事件驱动。很多高性能服务器框架的协程模型,本质都是这个结构。走到这一步,你就已经从一个写汇编的"手工党",进化成了能设计调度器的系统程序员。

6.3 协程栈的分配与回收:从 malloc 到 mmap + guard page

第 4 章我直接用 malloc 分配协程栈,演示够用,但不严谨。malloc 是一段连续的大块内存,它的末尾没有保护页。如果协程把栈用穿,会直接写坏堆里的其他数据,而且往往不会立即崩溃,而是等到某个莫名奇妙的时刻才炸出来。

生产级做法是使用内存映射,并在栈的低地址方向放一页不可读写的 guard page:

#include <sys/mman.h> void *stack = mmap(NULL, stack_size + page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); mprotect(stack, page_size, PROT_NONE); // 第一页设为不可访问 // 协程栈的有效区域是 stack + page_size 到 stack + stack_size

栈向下生长,一旦越过 base,就会踩到 PROT_NONE 的那一页,触发段错误。虽然程序还是崩,但崩的位置是栈越界的那一瞬间,而不是几个小时后堆被写坏的现场。这个定位成本差异,在真实项目里能救你无数个夜晚。

栈的回收也有讲究。释放协程栈必须发生在协程彻底死亡、且不在其栈上执行任何代码的时候。我的 co_schedule 已经把释放放在了ctx_switch返回之后,这个时机是对的。更精细的做法是维护一个空闲栈池,协程结束后把栈放回池子而不是立刻 munmap,避免频繁映射的开销。工业级协程库还会做栈的增长和收缩,但这已经超出"人人都可以写协程"的范畴了。

最后分享一点个人体会。这个 demo 我前后写过好几版,第一版抄了很多现成代码,跑通了但心里是虚的;后来逼着自己从空文件开始,一行一行把汇编和 C 拼起来,才真正理解了几件事:为什么调用约定是协程的地基,为什么 ret 的语义可以如此优雅地被复用,为什么栈对齐这种"小事"值回票价。这套东西再往上走,就是各种 fiber 库和轻量级线程的雏形了。建议你也动手敲一遍,遇到段错误时不要急着搜答案,先看看 rsp 和 rbp 指着哪里——那个瞬间,你对计算机的理解会上一个台阶。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询