12、协程条件变量:CoCond 的实现与 wait/signal 机制
条件变量,说白了就是让协程等一个“条件”的机制。比如某个资源还没准备好,协程就乖乖等着,等别人通知它“好了,你可以上了”。
我在做嵌入式网络协议栈的时候,经常遇到这种场景:一个协程在等接收缓冲区有数据,另一个协程在等发送缓冲区空出来。如果没有条件变量,你只能轮询,白白浪费 CPU。嗯,这显然不是我们想要的。
12.1 为什么需要协程条件变量?
你想想看,线程的条件变量大家都很熟悉了。但协程的条件变量,本质上是一个挂起-唤醒的调度机制。它不涉及内核态切换,纯粹在用户态完成。
我个人的习惯是,把协程条件变量看作一个“等待队列”。每个条件变量维护一个链表,里面挂着一堆在等这个条件的协程。当条件满足时,就把它们一个个唤醒。
核心要点:协程条件变量 ≠ 线程条件变量。线程条件变量需要 mutex 配合,而协程条件变量只需要一个等待队列 + 调度器接口。
12.2 CoCond 的数据结构设计
先看代码。这是我项目中实际用过的结构体,精简了一下:
typedef struct co_cond {
co_cond_node_t *wait_head; // 等待队列头
co_cond_node_t *wait_tail; // 等待队列尾
int wait_count; // 等待的协程数量
} co_cond_t;
typedef struct co_cond_node {
coroutine_t *co; // 挂起的协程
co_cond_node_t *next; // 链表指针
} co_cond_node_t;
为什么用双向链表?其实单向就够了。但我习惯用头尾指针,这样插入和删除都是 O(1)。
我曾经在某个项目中只用了一个简单的数组来存等待协程,结果在大量协程等待时,查找和删除效率极低。后来改成链表,性能直接翻倍。
12.3 wait 机制的实现
wait 操作,说白了就是把当前协程挂起,然后塞进等待队列。等别人 signal 了,再把它摘出来继续跑。
int co_cond_wait(co_cond_t *cond, coroutine_t *co) {
if (!cond || !co) return -1;
// 1. 把当前协程加入等待队列
co_cond_node_t *node = (co_cond_node_t *)malloc(sizeof(co_cond_node_t));
if (!node) return -1;
node->co = co;
node->next = NULL;
if (cond->wait_tail) {
cond->wait_tail->next = node;
cond->wait_tail = node;
} else {
cond->wait_head = cond->wait_tail = node;
}
cond->wait_count++;
// 2. 挂起当前协程
co->state = CO_STATE_SUSPENDED;
// 3. 让出 CPU,切换到调度器
co_yield();
return 0;
}
这里有个细节:co_yield() 之后,当前协程就停了。等它被 signal 唤醒后,会从 co_yield() 的下一条指令继续执行。嗯,这就是协程切换的魔力。
小提示:wait 函数返回后,条件不一定还成立。所以调用方通常要放在 while 循环里:while (!condition) co_cond_wait(cond, co);
12.4 signal 机制的实现
signal 操作,就是从等待队列里取出一个协程,把它唤醒。我一般用 FIFO 顺序,也就是先等先醒。
int co_cond_signal(co_cond_t *cond) {
if (!cond || cond->wait_count == 0) return 0;
// 1. 从队列头部取出一个等待节点
co_cond_node_t *node = cond->wait_head;
cond->wait_head = node->next;
if (!cond->wait_head) {
cond->wait_tail = NULL;
}
cond->wait_count--;
// 2. 唤醒这个协程
coroutine_t *co = node->co;
co->state = CO_STATE_READY;
scheduler_add(co); // 重新加入调度队列
free(node);
return 1;
}
你可能会问:为什么不用 broadcast?broadcast 就是唤醒所有等待的协程。我一般只在“条件一次性满足所有等待者”时才用,比如全局初始化完成。
int co_cond_broadcast(co_cond_t *cond) {
int count = 0;
while (cond->wait_head) {
co_cond_signal(cond);
count++;
}
return count;
}
12.5 完整的使用示例
来看一个实际场景:生产者-消费者模型。一个协程生产数据,一个协程消费数据。
co_cond_t cond;
int data_ready = 0;
void producer(void *arg) {
while (1) {
// 生产数据...
data_ready = 1;
co_cond_signal(&cond); // 通知消费者
co_sleep(100); // 模拟生产间隔
}
}
void consumer(void *arg) {
while (1) {
while (!data_ready) {
co_cond_wait(&cond, co_self()); // 等数据
}
// 消费数据...
data_ready = 0;
}
}
这个模式我在嵌入式网络协议栈里用了无数次。比如一个协程负责收包,一个协程负责处理包。收包协程把包放到队列里,然后 signal 处理协程。处理协程等不到包就挂起,不占 CPU。
12.6 避坑指南
我曾经踩过一个坑:在 signal 之后,被唤醒的协程立刻执行,但此时条件可能已经被另一个协程抢走了。所以一定要用 while 循环检查条件,不能用 if。
注意:永远不要在 signal 之后假设条件仍然成立。这是协程条件变量最常见的 bug,没有之一。
另一个坑是:不要在中断上下文里调用 signal。协程调度器不是线程安全的,中断里调用会导致竞态。我一般用消息队列把 signal 推迟到任务上下文执行。
12.7 性能与调度策略
条件变量的性能,主要看等待队列的实现。我对比过几种方案:
| 实现方式 | 插入复杂度 | 删除复杂度 | 适用场景 |
|---|---|---|---|
| 单向链表 | O(1) | O(n) | 等待协程少(<100) |
| 双向链表 | O(1) | O(1) | 通用场景 |
| 数组+位图 | O(1) | O(1) | 协程ID连续,内存紧张 |
我个人习惯用双向链表。虽然多了一个指针,但代码清晰,不容易出错。在嵌入式场景下,等待队列一般不会超过几十个节点,链表完全够用。
12.8 核心逻辑流程图
下面这张图展示了 CoCond 的完整工作流程,从 wait 到 signal 再到唤醒:
流程很简单:协程A调用 wait() 把自己挂起,加入等待队列。协程B调用 signal(),从队列头部取出协程A,交给调度器唤醒。协程A从 yield 处继续执行。
12.9 总结
协程条件变量,说白了就是一个用户态的等待队列。它让协程在条件不满足时主动让出 CPU,而不是傻傻地轮询。实现上,核心就是 wait 和 signal 两个操作,一个入队挂起,一个出队唤醒。
我在多个嵌入式项目中验证过这套机制,稳定性和性能都很好。你只要记住:用 while 循环检查条件,不要在中断里 signal,基本就不会出问题。
最后一个小建议:刚开始实现时,先做单线程版本。等跑通了,再考虑加锁、加超时、加优先级等高级特性。一步一个脚印,稳得很。
公众号:蓝海资料掘金营,微信deep3321