6、协程的创建与销毁:分配栈空间、初始化上下文、资源回收
协程的创建与销毁,说白了就是「生」和「死」的问题。
你想想看,一个协程从无到有,我们需要给它安排栈空间、设置好上下文环境;等它执行完毕,又得把占用的资源干干净净地还回去。这中间任何一个环节出了岔子,轻则内存泄漏,重则直接崩溃。
我个人习惯把协程的生命周期管理看作「三件套」:分配 → 初始化 → 回收。今天我们就把这三点掰开揉碎了讲。
6.1 栈空间分配:给协程一个「家」
每个协程都需要独立的栈。为什么?因为函数调用、局部变量、返回地址全得靠栈来存。如果多个协程共用同一个栈,那现场就乱套了。
我在项目中遇到过两种主流的栈分配策略:
- 固定大小栈:简单粗暴,每个协程分配固定大小的栈(比如 64KB 或 128KB)。优点是分配快、管理简单;缺点是浪费内存——有的协程可能只用几 KB,有的却不够用。
- 动态增长栈:初始分配一个小栈(比如 4KB),不够时通过 mprotect 或 mmap 扩展。优点是内存利用率高;缺点是实现复杂,而且有性能开销。
我个人更倾向于固定大小栈,尤其是在嵌入式场景下。为什么?因为嵌入式系统的内存是确定的,你很难承受动态增长带来的不确定性。我曾经在一个 RTOS 项目里试过动态栈,结果某个协程递归调用太深,栈直接爆了,系统当场挂掉。从那以后,我对动态栈就格外谨慎。
核心原则:栈大小 = 最大调用深度 × 每层栈帧大小 + 安全余量(建议 20%)。
下面是一个简单的栈分配示例:
// 协程栈结构
typedef struct {
void *stack_base; // 栈底指针
size_t stack_size; // 栈大小
} coroutine_stack_t;
// 分配栈空间
coroutine_stack_t* stack_alloc(size_t size) {
coroutine_stack_t *s = malloc(sizeof(coroutine_stack_t));
if (!s) return NULL;
s->stack_base = malloc(size);
if (!s->stack_base) {
free(s);
return NULL;
}
s->stack_size = size;
return s;
}
// 释放栈空间
void stack_free(coroutine_stack_t *s) {
if (s) {
free(s->stack_base);
free(s);
}
}
小技巧:分配栈时,可以在栈顶放一个「哨兵值」(比如 0xDEADBEEF),定期检查这个值是否被覆盖。如果被改了,说明栈溢出了。这招我在调试阶段屡试不爽。
6.2 初始化上下文:让协程「活」起来
栈有了,接下来得让协程知道从哪里开始执行、怎么切换回来。这就是上下文初始化要做的事。
上下文的核心是 CPU 寄存器状态。包括程序计数器(PC)、栈指针(SP)、以及一些通用寄存器。在 x86-64 平台上,我们通常用 ucontext_t 或者自己手写汇编来保存和恢复上下文。
嗯,这里要注意:不同的平台,寄存器布局完全不同。ARM、RISC-V、x86 各有各的玩法。我建议你封装一层平台抽象,别把平台相关的代码散得到处都是。
下面是一个基于 ucontext_t 的初始化示例:
#include <ucontext.h>
typedef struct {
ucontext_t ctx; // 上下文
coroutine_stack_t *stack; // 栈
void (*entry)(void*); // 入口函数
void *arg; // 入口参数
} coroutine_t;
int coroutine_init(coroutine_t *co,
void (*func)(void*),
void *arg,
size_t stack_size) {
// 1. 分配栈
co->stack = stack_alloc(stack_size);
if (!co->stack) return -1;
// 2. 初始化上下文
getcontext(&co->ctx);
co->ctx.uc_stack.ss_sp = co->stack->stack_base;
co->ctx.uc_stack.ss_size = co->stack->stack_size;
co->ctx.uc_link = NULL; // 结束后返回主协程
// 3. 设置入口函数
makecontext(&co->ctx, (void (*)(void))func, 1, arg);
co->entry = func;
co->arg = arg;
return 0;
}
警告:makecontext 的参数类型是 int,如果你传的是指针,记得做强制转换。我见过有人在这里踩坑——参数传错了,协程启动时拿到的 arg 全是乱码。
如果你不想依赖 ucontext 系列函数(它们在某些平台上已经被标记为废弃),可以自己手写汇编。比如在 x86-64 上,初始化上下文其实就是设置好 rsp 和 rip:
// 伪汇编:初始化协程上下文
// rdi = 协程结构体指针
// rsi = 入口函数地址
// rdx = 参数
coroutine_init_asm:
mov [rdi + offset_rsp], rsp ; 保存当前栈指针
mov [rdi + offset_rip], rsi ; 设置入口地址
mov [rdi + offset_arg], rdx ; 保存参数
ret
说白了,初始化上下文就是「伪造」一个函数调用的现场。让 CPU 以为协程是被正常调用的,这样切换时就能无缝衔接。
6.3 资源回收:善始善终
协程执行完了,资源得还回去。不然内存泄漏会像温水煮青蛙一样,慢慢耗死你的系统。
资源回收主要做三件事:
- 释放栈空间:调用
stack_free把栈内存归还给堆。 - 清理上下文:如果上下文里有关联的动态资源(比如文件描述符、锁),记得一并释放。
- 通知调度器:把协程从调度队列里移除,避免被再次调度。
我曾经接手过一个项目,协程销毁时只释放了栈,却忘了关闭协程里打开的文件描述符。结果跑了三天后,系统报「打开文件过多」。排查了半天才发现是协程泄漏了 fd。从那以后,我强制要求每个协程在退出前必须清理自己的所有资源。
void coroutine_destroy(coroutine_t *co) {
if (!co) return;
// 1. 释放栈
stack_free(co->stack);
co->stack = NULL;
// 2. 清理用户自定义资源(回调)
if (co->cleanup) {
co->cleanup(co->arg);
}
// 3. 从调度器移除
scheduler_remove(co);
// 4. 释放协程结构体本身
free(co);
}
最佳实践:在协程结构体里预留一个 cleanup 函数指针。让使用者自己注册清理逻辑。这样既灵活又安全。
6.4 知识体系总览
下面这张图总结了协程创建与销毁的完整流程:
6.5 常见陷阱与避坑指南
| 陷阱 | 后果 | 解决方案 |
|---|---|---|
| 栈分配后未对齐 | 某些平台(如 ARM)会触发对齐异常 | 使用 posix_memalign 或手动对齐到 16 字节 |
忘记设置 uc_link |
协程结束后无法返回主协程,直接跑飞 | 显式设置 uc_link 指向主上下文 |
| 栈空间未清零 | 残留数据可能被恶意利用(安全漏洞) | 分配后用 memset 清零,或使用 calloc |
| 重复销毁协程 | double free,程序崩溃 | 销毁后将指针置 NULL,加状态标记 |
我曾经遇到过最诡异的一个 bug:协程销毁后,栈指针被其他线程意外修改,导致下次分配时拿到了脏数据。排查了整整两天,最后加了个「栈签名」机制——在栈底写一个魔数,分配时校验。从那以后,我再也没被这种问题坑过。
好了,关于协程的创建与销毁,核心就是这三步:分配栈 → 初始化上下文 → 资源回收。每一步都有细节,每一步都可能踩坑。但只要你把流程理清楚,把边界条件考虑周全,协程的生命周期管理其实并不复杂。
我的建议:刚开始实现时,先用 ucontext 系列函数快速验证逻辑。等跑通了,再考虑手写汇编优化性能。别一上来就搞底层,容易把自己绕进去。
公众号:蓝海资料掘金营,微信deep3321