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 把栈清零,或者至少把栈顶附近的关键区域清零。
整体架构图
下面这张图展示了协程栈从分配到复用的完整流程:
避坑指南
我曾经踩过一个坑:协程栈复用后,没有清空栈内容。结果新协程运行时,局部变量初始值竟然是旧协程留下的数据。排查了半天才发现是栈残留数据导致的。
后来我加了一条规则:栈归还时,必须把栈顶往下 256 字节清零。为什么是 256 字节?因为协程切换时,栈顶附近是寄存器保存区和局部变量区,这些地方最容易出问题。
总结一下:协程栈管理的关键就三点——内存池预分配、切换只改指针、栈复用减少分配。做到这三点,你的协程调度器性能不会差。
嗯,今天就聊到这里。协程和内存池的配合其实还有很多细节,比如栈大小怎么动态调整、栈溢出怎么检测,这些我们后面有机会再聊。