互斥锁解决了多个线程同时修改共享数据的问题,但多线程程序还有另一类需求:有些线程并不是不能访问共享资源,而是当前条件不满足,暂时什么也做不了。
比如,一个线程负责从队列中取任务。如果队列是空的,线程取不到任何东西。它应该怎么办?一直循环检查队列,还是等有任务时再继续执行?
这就涉及线程同步和条件变量。
一、互斥和同步不是一回事
互斥和同步经常一起出现,但它们解决的问题不同。
互斥主要解决访问冲突。 多个线程使用同一份共享数据时,需要限制同时进入临界区的线程数量,避免数据被破坏。
同步主要解决执行顺序和条件等待。 某个操作必须等到特定条件满足之后才能继续,例如消费者必须等队列中有数据,才能取出任务。
假设有两个线程,一个负责生产数据,另一个负责消费数据。
- 互斥要求它们访问队列时不能同时破坏队列结构。
- 同步要求消费者在队列为空时等待,生产者放入数据后再通知消费者。
只有互斥,消费者仍然不知道什么时候该开始工作;只有同步,却没有互斥,多个线程同时修改队列时又可能产生冲突。
所以,实际的多线程程序经常需要同时使用互斥锁和条件变量。
二、什么是条件变量?
条件变量可以让线程在某个条件不满足时进入等待状态,并在其他线程改变条件后被唤醒。
Pthreads 提供了以下接口:
cpp
#include <pthread.h>
// 初始化与销毁
pthread_cond_init(&cond, nullptr);
pthread_cond_destroy(&cond);
// 等待条件
pthread_cond_wait(&cond, &mutex);
// 唤醒一个等待线程
pthread_cond_signal(&cond);
// 唤醒所有等待线程
pthread_cond_broadcast(&cond);
条件变量通常和互斥锁配合使用。共享条件需要由互斥锁保护,线程通过条件变量等待条件变化。
其中,pthread_cond_signal() 用于唤醒一个等待线程,pthread_cond_broadcast() 则用于唤醒所有等待该条件变量的线程。
不过,唤醒并不代表线程一定能够马上继续执行。被唤醒的线程还需要重新获得互斥锁,才能从 pthread_cond_wait() 返回并继续访问受到保护的共享数据。
三、pthread_cond_wait 为什么需要互斥锁?
pthread_cond_wait() 的接口比较特殊:
cpp
int pthread_cond_wait(
pthread_cond_t* cond,
pthread_mutex_t* mutex
);
调用它时,当前线程必须已经持有传入的互斥锁。
当线程进入条件等待时,这个函数会原子地释放互斥锁并进入等待状态;当线程被唤醒后,它会重新竞争这把锁,并在成功获得锁之后返回。
这里的"原子地释放并等待"非常重要。
假设不使用 pthread_cond_wait(),而是自己先解锁,再调用等待函数,中间就可能出现一个时间窗口:其他线程已经修改了条件并发出通知,但当前线程还没有真正开始等待。这样一来,通知就可能被错过,当前线程可能一直等下去。
条件等待接口正是为了解决这种衔接问题而设计的。
四、一个简单的条件变量例子
下面用一个 ready 变量代表条件是否满足。工作线程必须等到 ready 变成 true,才能继续执行。
cpp
#include <iostream>
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
bool ready = false;
void* worker(void* arg)
{
pthread_mutex_lock(&mutex);
while (!ready)
{
pthread_cond_wait(&cond, &mutex);
}
std::cout << "condition is ready" << std::endl;
pthread_mutex_unlock(&mutex);
return nullptr;
}
int main()
{
pthread_t tid;
int ret = pthread_create(&tid, nullptr, worker, nullptr);
if (ret != 0)
{
std::cerr << "pthread_create failed: "
<< ret << std::endl;
return 1;
}
pthread_mutex_lock(&mutex);
ready = true;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
pthread_join(tid, nullptr);
return 0;
}
这里的执行过程是:
- 工作线程获取互斥锁,检查
ready。 - 如果
ready还是false,就调用pthread_cond_wait()。 - 主线程把
ready改成true,并发送通知。 - 工作线程被唤醒,重新获得互斥锁。
- 工作线程再次检查条件,发现条件已经满足,然后继续执行。
即使主线程发出通知时,工作线程还没有真正进入等待,程序也不会因此丢失状态。因为 ready 这个共享条件在锁的保护下被修改,工作线程随后获得锁时仍然可以看到条件已经满足。
五、为什么等待条件时应该用 while,而不是 if?
条件变量编程中,一个特别重要的细节是:被唤醒不等于条件一定满足。
原因主要有两个。
第一,线程可能在条件仍然不满足时被唤醒,例如发生了虚假唤醒。
第二,即使条件曾经满足,也可能在当前线程重新获得锁之前,被其他线程再次改变。
例如,消费者 A 和消费者 B 同时等待队列中的数据。当生产者放入一个任务并唤醒两个消费者后,消费者 A 先获得锁,把这个任务取走。消费者 B 随后获得锁时,队列已经再次变空。
如果消费者 B 只检查一次条件就继续取任务,就可能访问空队列。
因此,推荐的写法是:
cpp
pthread_mutex_lock(&mutex);
while (!condition)
{
pthread_cond_wait(&cond, &mutex);
}
// 条件满足,执行对应操作
pthread_mutex_unlock(&mutex);
每次从 pthread_cond_wait() 返回之后,都重新检查条件。只要条件还不满足,就继续等待。
六、条件变量的通知应该怎么写?
一个常见的使用模式是:修改条件的线程也需要先加锁。
cpp
pthread_mutex_lock(&mutex);
// 修改共享状态,使条件发生变化
condition = true;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
等待线程则采用这样的结构:
cpp
pthread_mutex_lock(&mutex);
while (!condition)
{
pthread_cond_wait(&cond, &mutex);
}
// 条件满足,继续工作
pthread_mutex_unlock(&mutex);
核心并不是一定要先通知还是后通知,而是共享条件需要受到互斥锁保护,等待方必须在锁的保护下检查条件,通知方也需要在锁的保护下修改条件。
这样才能避免共享状态被并发修改,并降低线程错过状态变化的风险。
七、条件变量也需要管理生命周期
条件变量可以使用静态初始化:
cpp
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
也可以动态初始化:
cpp
pthread_cond_t cond;
pthread_cond_init(&cond, nullptr);
对于动态初始化的条件变量,在不再使用后需要调用 pthread_cond_destroy() 销毁。销毁之前,要确保没有线程仍然等待在该条件变量上,也没有线程再尝试使用它。
同样,互斥锁也必须在合适的时候销毁。条件变量和互斥锁往往成对使用,但两者仍然是独立的对象,应分别管理它们的生命周期。
八、总结
互斥锁保证多个线程不会同时破坏共享数据,条件变量则负责让线程在条件不满足时等待,并在状态变化后继续执行。
使用条件变量时,要牢牢记住几个要点:共享条件需要互斥锁保护;pthread_cond_wait() 会在等待时释放锁、返回前重新获取锁;等待条件应该使用 while 反复检查,而不能简单地把收到通知当成条件已经成立。