26、内存池与协程:协程栈分配、协程切换优化、栈复用

协程这东西,说白了就是用户态线程。我刚开始接触协程时,觉得它挺神奇的——一个函数能挂起、能恢复,还能自己调度自己。但真正深入进去才发现,协程的性能瓶颈往往不在调度逻辑上,而在内存管理上。

嗯,今天我们就聊聊协程和内存池怎么配合。重点有三个:协程栈怎么分配、协程切换怎么优化、栈怎么复用。

协程栈分配:别再 malloc 了

每个协程都需要自己的栈。传统做法是每个协程创建时 malloc 一个固定大小的栈,比如 64KB 或 128KB。这有什么问题?

  • 碎片化严重:频繁创建销毁协程,堆上全是碎片
  • 分配开销大:malloc 本身有锁,高并发下是灾难
  • 栈大小难定:给大了浪费内存,给小了栈溢出

我在项目中遇到过一个问题:一个高并发网关,每个请求开一个协程处理,QPS 到 10 万时,malloc 的锁竞争直接让 CPU 飙到 90%。后来换成内存池分配栈,CPU 降到 40%。

解决方案很简单——用内存池管理协程栈:

// 协程栈内存池
typedef struct coro_stack_pool {
    void     *blocks;       // 预分配的大块内存
    size_t    block_size;   // 每块大小(比如 64KB)
    size_t    block_count;  // 总块数
    uint64_t  free_mask;    // 空闲位图(64 块以内)
    spinlock_t lock;        // 自旋锁(轻量级)
} coro_stack_pool_t;

// 分配栈
void* coro_stack_alloc(coro_stack_pool_t *pool) {
    spin_lock(&pool->lock);
    // 用位图找空闲块
    int idx = __builtin_ctzll(~pool->free_mask);
    pool->free_mask |= (1ULL << idx);
    spin_unlock(&pool->lock);
    return (char*)pool->blocks + idx * pool->block_size;
}

// 释放栈
void coro_stack_free(coro_stack_pool_t *pool, void *stack) {
    int idx = ((char*)stack - (char*)pool->blocks) / pool->block_size;
    spin_lock(&pool->lock);
    pool->free_mask &= ~(1ULL << idx);
    spin_unlock(&pool->lock);
}

核心思路:预分配一大块连续内存,切成等大小的栈块。用位图标记空闲块,分配释放都是 O(1) 操作。

协程切换优化:别让栈拖后腿

协程切换的核心是保存和恢复上下文。上下文包括寄存器、程序计数器,还有——栈指针。

你想想看,每次切换都要把当前栈的内容保存下来,再把目标协程的栈恢复上去。如果栈很大,这个拷贝开销就很大。

我见过一些实现,切换时把整个栈 memcpy 到堆上。这简直是灾难。协程切换本来应该是纳秒级的,结果被 memcpy 拖成微秒级。

正确的做法是:只切换栈指针,不拷贝栈内容。

// 协程上下文(精简版)
typedef struct coro_context {
    void *rsp;      // 栈指针
    void *rip;      // 程序计数器
    void *rbx;      // 被调用者保存的寄存器
    void *rbp;
    void *r12, *r13, *r14, *r15;
} coro_context_t;

// 切换函数(汇编实现)
// 保存当前上下文到 from,恢复 to 的上下文
void coro_switch(coro_context_t *from, coro_context_t *to) {
    __asm__ volatile (
        "movq %%rsp, %0\n\t"      // 保存当前栈指针
        "movq %%rip, 8(%0)\n\t"   // 保存返回地址
        // ... 保存其他寄存器 ...
        "movq %1, %%rsp\n\t"      // 恢复目标栈指针
        "retq\n\t"                // 跳转到目标执行点
        : "=m"(from->rsp)
        : "m"(to->rsp)
        : "memory"
    );
}

技巧:协程切换时,栈指针指向的是协程自己的栈空间。只要保证每个协程的栈是独立的,切换就只是改一个寄存器的事。

栈复用:别创建,用现成的

协程的生命周期通常很短。一个请求来了,创建协程处理,处理完销毁。如果每次创建都分配新栈,每次销毁都释放栈,那内存池也扛不住频繁的分配释放。

更好的做法是:栈复用

说白了就是,协程销毁时栈不还给内存池,而是放到一个空闲队列里。新协程创建时,先从空闲队列拿,拿不到再向内存池申请。

// 栈复用管理器
typedef struct stack_recycler {
    void         *stacks[64];    // 空闲栈队列
    int           count;         // 当前空闲栈数量
    coro_stack_pool_t *pool;     // 底层内存池
} stack_recycler_t;

// 获取栈
void* recycler_get_stack(stack_recycler_t *r) {
    if (r->count > 0) {
        return r->stacks[--r->count];  // 优先复用
    }
    return coro_stack_alloc(r->pool);  // 没有空闲的,才分配
}

// 归还栈
void recycler_put_stack(stack_recycler_t *r, void *stack) {
    if (r->count < 64) {
        r->stacks[r->count++] = stack;  // 放入空闲队列
    } else {
        coro_stack_free(r->pool, stack); // 队列满了,真正释放
    }
}

注意:栈复用时一定要清空栈内容!否则新协程可能读到旧协程的残留数据。我建议在归还栈时,用 memset 把栈清零,或者至少把栈顶附近的关键区域清零。

整体架构图

下面这张图展示了协程栈从分配到复用的完整流程:

协程栈生命周期与复用流程 内存池 预分配 64KB 块 位图管理空闲块 分配 栈复用管理器 空闲栈队列 (最多64个) 优先复用,减少分配 队列满时归还内存池 获取/归还 协程实例 每个协程拥有独立栈 切换时只改栈指针 销毁时栈归还复用器 协程切换流程 保存当前协程上下文(寄存器 + 栈指针) 恢复目标协程上下文 → 切换栈指针 → ret 跳转 优化要点 栈复用减少分配次数 | 位图管理 O(1) 分配 | 切换只改指针不拷贝数据

避坑指南

我曾经踩过一个坑:协程栈复用后,没有清空栈内容。结果新协程运行时,局部变量初始值竟然是旧协程留下的数据。排查了半天才发现是栈残留数据导致的。

后来我加了一条规则:栈归还时,必须把栈顶往下 256 字节清零。为什么是 256 字节?因为协程切换时,栈顶附近是寄存器保存区和局部变量区,这些地方最容易出问题。

总结一下:协程栈管理的关键就三点——内存池预分配、切换只改指针、栈复用减少分配。做到这三点,你的协程调度器性能不会差。

嗯,今天就聊到这里。协程和内存池的配合其实还有很多细节,比如栈大小怎么动态调整、栈溢出怎么检测,这些我们后面有机会再聊。


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