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 上,初始化上下文其实就是设置好 rsprip

// 伪汇编:初始化协程上下文
// 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 资源回收:善始善终

协程执行完了,资源得还回去。不然内存泄漏会像温水煮青蛙一样,慢慢耗死你的系统。

资源回收主要做三件事:

  1. 释放栈空间:调用 stack_free 把栈内存归还给堆。
  2. 清理上下文:如果上下文里有关联的动态资源(比如文件描述符、锁),记得一并释放。
  3. 通知调度器:把协程从调度队列里移除,避免被再次调度。

我曾经接手过一个项目,协程销毁时只释放了栈,却忘了关闭协程里打开的文件描述符。结果跑了三天后,系统报「打开文件过多」。排查了半天才发现是协程泄漏了 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 知识体系总览

下面这张图总结了协程创建与销毁的完整流程:

协程创建与销毁流程 1. 分配栈空间 2. 初始化上下文 3. 注册清理函数 4. 协程执行(调度器管理) 5. 释放栈空间 6. 调用清理回调 7. 从调度器移除 协程生命周期结束 创建 运行 销毁

6.5 常见陷阱与避坑指南

陷阱 后果 解决方案
栈分配后未对齐 某些平台(如 ARM)会触发对齐异常 使用 posix_memalign 或手动对齐到 16 字节
忘记设置 uc_link 协程结束后无法返回主协程,直接跑飞 显式设置 uc_link 指向主上下文
栈空间未清零 残留数据可能被恶意利用(安全漏洞) 分配后用 memset 清零,或使用 calloc
重复销毁协程 double free,程序崩溃 销毁后将指针置 NULL,加状态标记

我曾经遇到过最诡异的一个 bug:协程销毁后,栈指针被其他线程意外修改,导致下次分配时拿到了脏数据。排查了整整两天,最后加了个「栈签名」机制——在栈底写一个魔数,分配时校验。从那以后,我再也没被这种问题坑过。

好了,关于协程的创建与销毁,核心就是这三步:分配栈 → 初始化上下文 → 资源回收。每一步都有细节,每一步都可能踩坑。但只要你把流程理清楚,把边界条件考虑周全,协程的生命周期管理其实并不复杂。

我的建议:刚开始实现时,先用 ucontext 系列函数快速验证逻辑。等跑通了,再考虑手写汇编优化性能。别一上来就搞底层,容易把自己绕进去。


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