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 再到唤醒:

CoCond 条件变量 wait/signal 核心流程 协程A(等待者) 条件变量 CoCond 等待队列 [协程A] → [协程B] → ... 协程B(发送者) 协程调度器 协程A恢复执行 wait() 挂起 signal() 取出协程A 唤醒 等待协程 发送协程 条件变量 调度器

流程很简单:协程A调用 wait() 把自己挂起,加入等待队列。协程B调用 signal(),从队列头部取出协程A,交给调度器唤醒。协程A从 yield 处继续执行。

12.9 总结

协程条件变量,说白了就是一个用户态的等待队列。它让协程在条件不满足时主动让出 CPU,而不是傻傻地轮询。实现上,核心就是 wait 和 signal 两个操作,一个入队挂起,一个出队唤醒。

我在多个嵌入式项目中验证过这套机制,稳定性和性能都很好。你只要记住:用 while 循环检查条件,不要在中断里 signal,基本就不会出问题。

最后一个小建议:刚开始实现时,先做单线程版本。等跑通了,再考虑加锁、加超时、加优先级等高级特性。一步一个脚印,稳得很。


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