第 30 章 条件变量

锁并不是并发程序设计所需的唯一原语。在很多情况下,线程需要检查某一条件(condition)满足之后,才会继续运行。例如,父线程需要检查子线程是否执行完毕(join())。

定义与操作

条件变量(condition variable) 是一个显式队列,当某些执行状态(即条件)不满足时,线程可以把自己加入队列,等待(waiting)该条件。当某个线程改变了上述状态时,可以唤醒一个或者多个等待线程(通过在该条件上发信号),让它们继续执行。

POSIX 调用如下:

pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m);
pthread_cond_signal(pthread_cond_t *c);
  • wait():释放锁,并让调用线程休眠(原子地)。当线程被唤醒时,它必须重新获取锁,再返回调用者。
  • signal():唤醒等待在某个条件变量上的睡眠线程。

关键规则

  1. 调用 wait 时必须持有锁
  2. 调用 signal 时最好持有锁(虽然不总是严格需要,但为了简单和安全,建议持有)。

父线程等待子线程

使用条件变量实现 join 功能:

int done = 0;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t c = PTHREAD_COND_INITIALIZER;
 
void thr_exit() {
    Pthread_mutex_lock(&m);
    done = 1;
    Pthread_cond_signal(&c);
    Pthread_mutex_unlock(&m);
}
 
void *child(void *arg) {
    printf("child\n");
    thr_exit();
    return NULL;
}
 
void thr_join() {
    Pthread_mutex_lock(&m);
    while (done == 0)
        Pthread_cond_wait(&c, &m);
    Pthread_mutex_unlock(&m);
}
  • 状态变量 done 是必须的:如果子线程先运行并发送信号,若没有 done 标记,父线程随后调用 wait 会因为错过信号而永久休眠。
  • 锁是必须的:防止竞态条件(例如父线程检查 done 后但在调用 wait 前被中断,子线程此时修改 done 并发送信号,父线程随后 wait 将永久休眠)。

生产者/消费者(有界缓冲区)问题

生产者把数据放入缓冲区,消费者取走数据。必须保证:

  1. 缓冲区满时,生产者等待。
  2. 缓冲区空时,消费者等待。

1. 有问题的方案:单个条件变量 + If

// 错误示例
void *producer(void *arg) {
    Pthread_mutex_lock(&mutex);
    if (count == 1) // 缓冲区满
        Pthread_cond_wait(&cond, &mutex);
    put(i);
    Pthread_cond_signal(&cond);
    Pthread_mutex_unlock(&mutex);
}
 
void *consumer(void *arg) {
    Pthread_mutex_lock(&mutex);
    if (count == 0) // 缓冲区空
        Pthread_cond_wait(&cond, &mutex);
    int tmp = get();
    Pthread_cond_signal(&cond);
    Pthread_mutex_unlock(&mutex);
}

问题Mesa 语义。 当消费者被唤醒后,但在运行前,缓冲区状态可能已被其他线程改变(例如被另一个消费者抢先消费)。if 语句只检查一次,导致消费者在缓冲区为空时继续执行 get(),引发错误。

2. 改进但仍有问题的方案:单个条件变量 + While

if 改为 while

// 仍有问题的示例
while (count == 1) // 生产者
    Pthread_cond_wait(&cond, &mutex);
 
while (count == 0) // 消费者
    Pthread_cond_wait(&cond, &mutex);

规则总是使用 while 循环(Always use while loop)。 这解决了 Mesa 语义问题和假唤醒(spurious wakeup)问题。

新问题: 假设两个消费者(Tc1, Tc2)先运行并睡眠。生产者(Tp)运行,放入数据,唤醒 Tc1,Tp 睡眠。 Tc1 醒来消费数据,缓冲区变空。Tc1 发信号,可能唤醒的是 Tc2(而不是 Tp)。 Tc2 醒来发现缓冲区空,继续睡眠。 结果:Tc1, Tc2, Tp 都在睡眠,死锁

3. 正确方案:两个条件变量

使用两个条件变量 emptyfill,区分唤醒对象。

cond_t empty, fill;
mutex_t mutex;
 
void *producer(void *arg) {
    Pthread_mutex_lock(&mutex);
    while (count == MAX)
        Pthread_cond_wait(&empty, &mutex); // 等待“有空位”
    put(i);
    Pthread_cond_signal(&fill);        // 发出“有数据”信号
    Pthread_mutex_unlock(&mutex);
}
 
void *consumer(void *arg) {
    Pthread_mutex_lock(&mutex);
    while (count == 0)
        Pthread_cond_wait(&fill, &mutex);  // 等待“有数据”
    int tmp = get();
    Pthread_cond_signal(&empty);       // 发出“有空位”信号
    Pthread_mutex_unlock(&mutex);
}
  • 生产者等待 empty,发送 fill
  • 消费者等待 fill,发送 empty
  • 消费者永远不会唤醒消费者,生产者永远不会唤醒生产者。

覆盖条件 (Covering Conditions)

在某些场景下(如内存分配),线程等待的条件不同(例如申请 100 字节 vs 10 字节)。当释放 50 字节内存时,如果只用 signal,可能唤醒了申请 100 字节的线程(仍然不满足条件而继续睡眠),而申请 10 字节的线程却没被唤醒。

解决方案:使用 pthread_cond_broadcast()

  • 唤醒所有等待线程。
  • 所有线程检查条件,满足的执行,不满足的继续睡眠。
  • 缺点是性能开销(惊群效应),但在这种不知道该唤醒谁的场景下是必要的。