6、条件变量:条件变量的原理、生产者-消费者模型、条件变量的使用技巧

条件变量,说白了就是线程之间的一种「信号灯」。它本身不保护数据,它的作用是让一个线程在某个条件不满足时「睡大觉」,等另一个线程把条件满足了,再把它「叫醒」。

我刚开始学多线程时,总觉得条件变量有点绕。后来在项目中写一个网络请求队列,才真正体会到它的价值。嗯,咱们今天就把这东西彻底讲透。

条件变量的核心原理

条件变量必须配合互斥锁使用。为什么?因为条件变量的「检查条件」和「等待条件」这两个动作必须是原子的。你想想看:

  1. 线程A检查条件:队列为空,准备等待
  2. 就在这一瞬间,线程B往队列里放了个数据,发信号
  3. 线程A开始等待,但信号已经错过了
  4. 线程A永远等不到数据了

这就是经典的「丢失唤醒」问题。互斥锁就是用来防止这种情况的。

核心API就三个:

  • pthread_cond_wait() — 释放锁,等待信号,收到信号后重新获取锁
  • pthread_cond_signal() — 唤醒一个等待线程
  • pthread_cond_broadcast() — 唤醒所有等待线程

生产者-消费者模型

这是条件变量最经典的应用场景。我当年面试第一家公司,面试官就让我手写这个模型。说实话,当时写得磕磕绊绊,但后来在项目中写消息队列,天天跟它打交道。

先看一个完整的实现:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define BUFFER_SIZE 5

typedef struct {
    int buffer[BUFFER_SIZE];
    int count;
    int in;
    int out;
    pthread_mutex_t mutex;
    pthread_cond_t not_full;   // 生产者等待:队列不满
    pthread_cond_t not_empty;  // 消费者等待:队列不空
} queue_t;

queue_t q = {
    .count = 0,
    .in = 0,
    .out = 0,
    .mutex = PTHREAD_MUTEX_INITIALIZER,
    .not_full = PTHREAD_COND_INITIALIZER,
    .not_empty = PTHREAD_COND_INITIALIZER
};

void produce(queue_t *q, int item) {
    pthread_mutex_lock(&q->mutex);

    // 队列满了,等待
    while (q->count == BUFFER_SIZE) {
        pthread_cond_wait(&q->not_full, &q->mutex);
    }

    q->buffer[q->in] = item;
    q->in = (q->in + 1) % BUFFER_SIZE;
    q->count++;

    printf("生产者: 生产 %d, 队列中有 %d 个\n", item, q->count);

    // 通知消费者
    pthread_cond_signal(&q->not_empty);
    pthread_mutex_unlock(&q->mutex);
}

int consume(queue_t *q) {
    pthread_mutex_lock(&q->mutex);

    // 队列空了,等待
    while (q->count == 0) {
        pthread_cond_wait(&q->not_empty, &q->mutex);
    }

    int item = q->buffer[q->out];
    q->out = (q->out + 1) % BUFFER_SIZE;
    q->count--;

    printf("消费者: 消费 %d, 队列中还有 %d 个\n", item, q->count);

    // 通知生产者
    pthread_cond_signal(&q->not_full);
    pthread_mutex_unlock(&q->mutex);

    return item;
}

这里有个细节我特别想强调:为什么用 while 而不是 if 来检查条件?

我曾经在这个坑里摔过一次。当时用 if 判断,测试时一切正常,结果线上高并发时偶尔出现消费者拿到空数据。查了两天才发现:pthread_cond_wait 返回时,条件不一定成立!可能有多个消费者被唤醒,或者信号是「假唤醒」。

重要规则:永远用 while 循环检查条件,不要用 if。这是条件变量使用的铁律。

条件变量的使用技巧

这些年写了不少多线程代码,总结几个实用技巧:

1. 信号和广播的选择

signal 只唤醒一个线程,broadcast 唤醒所有。我个人的习惯是:

  • 如果所有等待线程的条件都一样,用 signal 就够了,效率高
  • 如果等待线程的条件各不相同(比如读线程和写线程),用 broadcast

2. 锁的粒度控制

不要在持有锁的时候做耗时操作。比如上面的例子,生产者和消费者在打印日志时还拿着锁,这其实不太好。我一般会:

void produce(queue_t *q, int item) {
    pthread_mutex_lock(&q->mutex);
    while (q->count == BUFFER_SIZE) {
        pthread_cond_wait(&q->not_full, &q->mutex);
    }
    // 快速操作
    q->buffer[q->in] = item;
    q->in = (q->in + 1) % BUFFER_SIZE;
    q->count++;
    pthread_mutex_unlock(&q->mutex);

    // 锁外做耗时操作
    printf("生产者: 生产 %d\n", item);
    pthread_cond_signal(&q->not_empty);
}

3. 避免死锁

条件变量本身不会死锁,但用不好就会。最常见的场景是:线程A等待条件变量时持有另一个锁。我曾经在代码审查时见过这样的写法:

// 错误示例
pthread_mutex_lock(&lock_a);
pthread_mutex_lock(&lock_b);
while (condition) {
    pthread_cond_wait(&cond, &lock_b);  // 只释放了 lock_b!
}
// lock_a 还拿着,别人进不来

pthread_cond_wait 只会释放传入的那个锁。如果你持有多个锁,那就麻烦了。

知识体系总览

下面这张图把条件变量的核心逻辑串起来了:

条件变量核心逻辑 生产者线程 消费者线程 共享队列 互斥锁保护 条件变量 放入数据 取出数据 条件变化 队列不满时唤醒 队列不空时唤醒 核心流程:生产者放入数据 → 条件变化 → 条件变量唤醒消费者 → 消费者取出数据

我的经验之谈:刚开始用条件变量时,建议先画清楚「谁等谁」的关系。生产者等「不满」,消费者等「不空」。把这两个条件写清楚,代码基本不会错。

避坑指南

最后分享几个我踩过的坑:

  • 忘记在 while 里检查条件 — 这是最常见的 bug,我犯过,我团队的新人也犯过
  • 信号发早了 — 条件还没满足就发信号,接收方醒来发现条件不成立,白忙一场
  • 多个条件变量共用一个互斥锁 — 容易造成信号混乱,建议每个条件变量配一个锁
  • 在信号处理函数里用条件变量 — 信号处理函数是异步的,条件变量不是异步安全的

条件变量这东西,说白了就是「等」和「通知」的配合。把这两个动作想清楚,代码自然就稳了。


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