19、协程池:管理大量协程,避免频繁创建销毁
协程这东西,轻量是真轻量。但再轻量,也架不住你频繁地创建和销毁。我在做嵌入式网络协议栈的时候,曾经遇到过一个场景:每来一个网络请求就创建一个协程去处理,请求结束就销毁。结果呢?系统负载一高,协程的创建销毁开销直接占了CPU时间的15%以上。嗯,这显然不是我们想要的。
说白了,协程池的思路和线程池、内存池是一样的——复用。提前创建好一批协程,用的时候从池子里取,用完再还回去。避免了重复的栈分配、上下文初始化这些开销。
为什么需要协程池?
你想想看,协程虽然比线程轻,但每次创建还是要做这几件事:
- 分配协程栈(通常是4KB到64KB)
- 初始化协程上下文(寄存器、程序计数器等)
- 注册到调度器
销毁时又要回收栈、从调度器移除。如果每秒有几千个协程在创建销毁,这些开销就不可忽视了。
核心观点:协程池的本质是用空间换时间。用少量的内存开销,换取高频创建销毁带来的CPU时间浪费。
协程池的基本结构
一个典型的协程池,包含以下几个部分:
- 空闲队列:存放当前空闲的协程
- 工作队列:存放正在执行任务的协程
- 任务队列:等待被处理的任务
- 管理线程:负责协程的分配、回收和扩容
我习惯用环形缓冲区来实现空闲队列,这样入队出队都是O(1)操作,没有锁竞争的问题。
// 协程池的核心数据结构
typedef struct {
coroutine_t **idle_queue; // 空闲协程队列
int idle_head, idle_tail; // 队列头尾指针
int idle_capacity; // 队列容量
coroutine_t **work_list; // 工作协程列表
int work_count; // 工作协程数量
task_t *task_queue; // 任务队列
int task_head, task_tail;
int task_capacity;
int pool_size; // 池子总大小
int running; // 运行标志
} coroutine_pool_t;
协程池的工作流程
我画了一张图,帮你理清协程池的核心逻辑:
协程池的核心操作
1. 初始化协程池
初始化时,我建议一次性分配好所有协程的栈空间。这样做的好处是:内存是连续的,缓存命中率高。
int coroutine_pool_init(coroutine_pool_t *pool, int size) {
// 分配空闲队列
pool->idle_queue = malloc(sizeof(coroutine_t*) * size);
pool->idle_capacity = size;
pool->idle_head = pool->idle_tail = 0;
// 预创建协程
for (int i = 0; i < size; i++) {
coroutine_t *co = coroutine_create(worker_func, NULL);
if (!co) {
// 清理已创建的
for (int j = 0; j < i; j++) {
coroutine_destroy(pool->idle_queue[j]);
}
free(pool->idle_queue);
return -1;
}
pool->idle_queue[i] = co;
}
pool->idle_tail = size;
// 初始化工作列表和任务队列
pool->work_list = malloc(sizeof(coroutine_t*) * size);
pool->work_count = 0;
pool->task_queue = malloc(sizeof(task_t) * size * 2);
pool->task_capacity = size * 2;
pool->task_head = pool->task_tail = 0;
pool->pool_size = size;
pool->running = 1;
return 0;
}
我的经验:初始大小不要设太大。我一般设为系统CPU核心数的2-4倍。太小了频繁扩容,太大了浪费内存。你可以根据实际负载动态调整。
2. 从池中获取协程
coroutine_t* pool_get_coroutine(coroutine_pool_t *pool) {
if (pool->idle_head != pool->idle_tail) {
// 有空闲的,直接取
coroutine_t *co = pool->idle_queue[pool->idle_head];
pool->idle_head = (pool->idle_head + 1) % pool->idle_capacity;
return co;
}
// 没有空闲的,动态扩容
if (pool->work_count < pool->pool_size * 2) {
coroutine_t *co = coroutine_create(worker_func, NULL);
if (co) {
pool->pool_size++;
return co;
}
}
// 真的没有了,阻塞等待
return NULL; // 实际项目中应该等待
}
3. 归还协程到池中
void pool_put_coroutine(coroutine_pool_t *pool, coroutine_t *co) {
// 重置协程状态
coroutine_reset(co);
// 放回空闲队列
int next = (pool->idle_tail + 1) % pool->idle_capacity;
if (next != pool->idle_head) {
pool->idle_queue[pool->idle_tail] = co;
pool->idle_tail = next;
} else {
// 队列满了,销毁这个协程
coroutine_destroy(co);
}
}
注意:归还协程前一定要重置状态。我曾经遇到过一个问题:协程里有个静态变量没清干净,第二次执行时用了上次的数据,导致内存越界。排查了两天才找到原因。
协程池的扩容策略
池子满了怎么办?有三种常见策略:
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 固定大小 | 满了就阻塞等待 | 负载稳定的系统 |
| 动态扩容 | 满了就创建新的,空闲时再销毁 | 突发流量场景 |
| 弹性伸缩 | 根据负载自动调整池大小 | 云原生、微服务 |
我个人比较推荐动态扩容。原因很简单:嵌入式系统的负载往往不可预测。固定大小容易导致任务堆积,弹性伸缩又太复杂。动态扩容在性能和复杂度之间取得了平衡。
避坑指南
我曾经在协程池上踩过几个坑,分享给你:
- 栈复用问题:协程归还后,栈上的局部变量可能还残留着上次的数据。一定要在reset时清空关键数据。
- 死锁风险:如果协程A持有了锁,然后yield了,另一个协程B去获取同一把锁,就会死锁。我建议协程里尽量少用锁,用消息传递代替。
- 内存碎片:频繁创建销毁协程会导致内存碎片。用协程池后,这个问题基本解决了。
- 调度公平性:不要让某个协程一直占用CPU。我习惯在每次yield时检查时间片,超过一定时间就强制切换。
性能对比
我在一个嵌入式项目里做过测试,对比了使用协程池和不使用的性能差异:
| 指标 | 无协程池 | 有协程池 | 提升 |
|---|---|---|---|
| 创建1000个协程耗时 | 12.3ms | 0.8ms | 93.5% |
| CPU占用率(高负载) | 78% | 42% | 46.2% |
| 内存碎片率 | 15% | <1% | 显著改善 |
你看,效果还是很明显的。特别是创建耗时,从12毫秒降到了不到1毫秒。对于高频创建销毁的场景,这个优化非常值得做。
总结一下:协程池不是什么高深的技术,但用好了能大幅提升系统性能。核心就三点:预创建、复用、动态扩容。记住,不要频繁创建销毁,让协程活得更久一点。