锁并不是并发程序设计所需的唯一原语。在很多情况下,线程需要检查某一条件(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():唤醒等待在某个条件变量上的睡眠线程。
关键规则:
- 调用
wait时必须持有锁。 - 调用
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. 有问题的方案:单个条件变量 + 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. 正确方案:两个条件变量
使用两个条件变量 empty 和 fill,区分唤醒对象。
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()。
- 唤醒所有等待线程。
- 所有线程检查条件,满足的执行,不满足的继续睡眠。
- 缺点是性能开销(惊群效应),但在这种不知道该唤醒谁的场景下是必要的。