9、多协程协作:实现生产者-消费者模型

生产者-消费者模型,说白了就是两个角色之间的协作游戏。一个负责生产数据,一个负责消费数据。中间有个缓冲区,像个中转站。

我刚开始做嵌入式开发时,总觉得这个模型很简单。直到有一次在资源受限的MCU上跑多任务,才发现事情没那么简单。嗯,今天我们就用协程来实现它。

为什么用协程?

传统做法是用多线程加锁。但锁这东西,用不好就死锁,用多了就性能下降。我见过太多项目因为锁的问题导致线上崩溃。

协程的好处在于:

  • 无需锁:协程是协作式调度,不会抢占
  • 轻量:一个协程栈可能只要几KB,线程动不动就MB级别
  • 可控:你明确知道什么时候切换,什么时候恢复

核心思想:生产者协程 yield 出数据,消费者协程 resume 获取数据。两者通过一个共享队列通信。

数据结构设计

先定义缓冲区。我习惯用环形队列,因为内存固定,不会动态分配。

#define BUFFER_SIZE 16

typedef struct {
    int data[BUFFER_SIZE];
    int head;
    int tail;
    int count;
} ring_buffer_t;

void rb_init(ring_buffer_t *rb) {
    rb->head = 0;
    rb->tail = 0;
    rb->count = 0;
}

int rb_push(ring_buffer_t *rb, int value) {
    if (rb->count >= BUFFER_SIZE) return -1; // 满
    rb->data[rb->head] = value;
    rb->head = (rb->head + 1) % BUFFER_SIZE;
    rb->count++;
    return 0;
}

int rb_pop(ring_buffer_t *rb, int *value) {
    if (rb->count <= 0) return -1; // 空
    *value = rb->data[rb->tail];
    rb->tail = (rb->tail + 1) % BUFFER_SIZE;
    rb->count--;
    return 0;
}

你看,这个环形队列没有动态内存分配。在嵌入式环境里,动态分配是万恶之源。我曾经在一个项目里用了malloc,结果内存碎片导致系统跑了三天就挂了。

协程调度器

我们需要一个简单的调度器来管理协程。这里用我之前课程里实现的协程库。

typedef struct {
    coroutine_t *producer;
    coroutine_t *consumer;
    ring_buffer_t buffer;
    int running;
} scheduler_t;

void scheduler_init(scheduler_t *sched) {
    rb_init(&sched->buffer);
    sched->running = 1;
}

void scheduler_run(scheduler_t *sched) {
    while (sched->running) {
        // 先跑生产者
        if (sched->producer && !coroutine_finished(sched->producer)) {
            coroutine_resume(sched->producer);
        }
        // 再跑消费者
        if (sched->consumer && !coroutine_finished(sched->consumer)) {
            coroutine_resume(sched->consumer);
        }
        // 都结束了就退出
        if (coroutine_finished(sched->producer) && 
            coroutine_finished(sched->consumer)) {
            sched->running = 0;
        }
    }
}

这里有个细节:我让生产者和消费者轮流执行。为什么?因为协程是协作式的,如果生产者一直占着CPU,消费者永远没机会消费。你想想看,这不就死锁了吗?

生产者协程

void producer_task(void *arg) {
    scheduler_t *sched = (scheduler_t *)arg;
    int item = 0;
    
    while (item < 20) { // 生产20个数据
        if (rb_push(&sched->buffer, item) == 0) {
            printf("生产者: 生产 %d\n", item);
            item++;
        } else {
            printf("生产者: 缓冲区满,等待...\n");
        }
        coroutine_yield(); // 让出CPU
    }
    printf("生产者: 完成\n");
}

注意看,生产者每次生产完就yield。这不是偷懒,是设计。我曾经见过一个新手写的协程,生产者在循环里疯狂生产,缓冲区满了也不让出,结果消费者饿死了。

消费者协程

void consumer_task(void *arg) {
    scheduler_t *sched = (scheduler_t *)arg;
    int value;
    int consumed = 0;
    
    while (consumed < 20) { // 消费20个数据
        if (rb_pop(&sched->buffer, &value) == 0) {
            printf("消费者: 消费 %d\n", value);
            consumed++;
        } else {
            printf("消费者: 缓冲区空,等待...\n");
        }
        coroutine_yield(); // 让出CPU
    }
    printf("消费者: 完成\n");
}

消费者也一样,每次消费完就yield。这样生产者和消费者就能交替执行,不会出现一方独占的情况。

完整运行流程

下面这张图展示了整个协作过程:

生产者-消费者协程协作流程 生产者协程 生成数据 push到缓冲区 环形缓冲区 size = 16 head → tail count = 当前数量 消费者协程 从缓冲区pop 处理数据 push pop yield() 让出CPU yield() 让出CPU 调度器 轮流resume生产者和消费者 关键:每次push/pop后立即yield,避免一方饿死 调度器负责协调执行顺序,但不干预数据流向

避坑指南

我曾经踩过的坑:

  • 忘记yield:生产者一直生产,消费者永远没机会执行。结果缓冲区满了,生产者还在死循环里转。
  • 缓冲区太小:生产速度远大于消费速度,缓冲区频繁满。这时候要考虑增大缓冲区,或者让生产者适当休眠。
  • 共享数据没保护:虽然协程不会抢占,但如果调度器在多个线程里跑,还是要加锁。我建议一个调度器只在一个线程里跑。

我的个人习惯:

我会在生产者里加一个计数器,记录连续yield的次数。如果连续yield超过一定次数还没生产成功(缓冲区一直满),就说明消费速度太慢。这时候可以打印警告,或者动态调整生产速率。

另外,我建议把缓冲区大小设为2的幂次方。这样取模运算可以用位运算替代,效率高不少。

运行结果示例

跑起来的效果大概是这样:

生产者: 生产 0
消费者: 缓冲区空,等待...
生产者: 生产 1
消费者: 消费 0
生产者: 生产 2
消费者: 消费 1
...
生产者: 生产 19
消费者: 消费 18
消费者: 消费 19
生产者: 完成
消费者: 完成

你看,生产者和消费者交替执行,没有死锁,没有数据丢失。这就是协程的魅力——用最轻量的方式实现协作。

说实话,我做了十几年嵌入式,协程是我最喜欢的并发模型。它不像线程那么重,也不像状态机那么难维护。你只要记住一个原则:每次操作完就yield,让其他人也有机会执行

嗯,这个模型在实际项目中非常实用。比如传感器数据采集和处理、网络数据包收发、UI事件处理等等。掌握了它,你就掌握了协程协作的精髓。