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。这样生产者和消费者就能交替执行,不会出现一方独占的情况。
完整运行流程
下面这张图展示了整个协作过程:
避坑指南
我曾经踩过的坑:
- 忘记yield:生产者一直生产,消费者永远没机会执行。结果缓冲区满了,生产者还在死循环里转。
- 缓冲区太小:生产速度远大于消费速度,缓冲区频繁满。这时候要考虑增大缓冲区,或者让生产者适当休眠。
- 共享数据没保护:虽然协程不会抢占,但如果调度器在多个线程里跑,还是要加锁。我建议一个调度器只在一个线程里跑。
我的个人习惯:
我会在生产者里加一个计数器,记录连续yield的次数。如果连续yield超过一定次数还没生产成功(缓冲区一直满),就说明消费速度太慢。这时候可以打印警告,或者动态调整生产速率。
另外,我建议把缓冲区大小设为2的幂次方。这样取模运算可以用位运算替代,效率高不少。
运行结果示例
跑起来的效果大概是这样:
生产者: 生产 0
消费者: 缓冲区空,等待...
生产者: 生产 1
消费者: 消费 0
生产者: 生产 2
消费者: 消费 1
...
生产者: 生产 19
消费者: 消费 18
消费者: 消费 19
生产者: 完成
消费者: 完成
你看,生产者和消费者交替执行,没有死锁,没有数据丢失。这就是协程的魅力——用最轻量的方式实现协作。
说实话,我做了十几年嵌入式,协程是我最喜欢的并发模型。它不像线程那么重,也不像状态机那么难维护。你只要记住一个原则:每次操作完就yield,让其他人也有机会执行。
嗯,这个模型在实际项目中非常实用。比如传感器数据采集和处理、网络数据包收发、UI事件处理等等。掌握了它,你就掌握了协程协作的精髓。