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;

协程池的工作流程

我画了一张图,帮你理清协程池的核心逻辑:

协程池工作流程图 外部任务提交 任务队列 (FIFO) 协程调度器 有空闲? 空闲协程队列 (环形缓冲区) 无空闲? 创建新协程 (动态扩容) 取出 执行任务 (yield/resume) 完成 归还到空闲队列 加入 加入工作队列 工作协程列表 数据流 扩容路径

协程池的核心操作

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毫秒。对于高频创建销毁的场景,这个优化非常值得做。

总结一下:协程池不是什么高深的技术,但用好了能大幅提升系统性能。核心就三点:预创建、复用、动态扩容。记住,不要频繁创建销毁,让协程活得更久一点


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