pthread 条件变量解析:原子等待、虚假唤醒与 Completion 机制
核心结论:条件变量不保存业务条件,它负责让线程高效睡眠和被唤醒;真正的条件必须保存在 mutex 保护的共享状态中。
问题背景
假设主线程必须等待 worker 完成某项工作,最直观的写法是不断检查共享变量:
while (!done) {
// 继续检查
}
这种写法存在两个问题:
- 普通变量被多个线程并发读写会产生 data race,属于未定义行为。
- 即使把
done改成原子变量,循环仍会持续占用 CPU,这种方式称为 busy waiting(忙等)。
也可以在循环中调用 sleep(),但这只能降低 CPU 消耗,无法同时兼顾响应延迟。睡眠时间太长会延迟处理,太短又会退化成频繁轮询。
条件变量 pthread_cond_t 的作用,是在条件不满足时把线程挂起,并在共享状态可能发生变化时唤醒它。
“条件”不在 pthread_cond_t 里面
下面是一个简化的 Completion 对象:
#include <pthread.h>
#include <stdint.h>
typedef struct {
uint32_t done;
pthread_mutex_t mutex;
pthread_cond_t cond;
} completion_t;
三个成员的职责完全不同:
| 成员 | 职责 |
|---|---|
done |
保存真正的业务状态,表示有多少次完成事件尚未被消费 |
mutex |
保护 done,保证检查状态和修改状态不会并发冲突 |
cond |
管理等待线程,并在状态可能改变时通知它们重新检查 |
因此,“等待条件”是 done > 0,而不是 cond。pthread_cond_t 本身通常不记录 done 是否为真,也不会理解业务状态的含义。
条件变量这个名称更准确的理解是:用于等待某个条件的变量,而不是“内部装着条件的变量”。
一个完整的 Completion 实现
#include <limits.h>
#include <pthread.h>
#include <stdint.h>
typedef struct {
uint32_t done;
pthread_mutex_t mutex;
pthread_cond_t cond;
} completion_t;
int completion_init(completion_t *completion)
{
int result;
completion->done = 0;
result = pthread_mutex_init(&completion->mutex, NULL);
if (result != 0) {
return result;
}
result = pthread_cond_init(&completion->cond, NULL);
if (result != 0) {
pthread_mutex_destroy(&completion->mutex);
return result;
}
return 0;
}
void wait_for_completion(completion_t *completion)
{
pthread_mutex_lock(&completion->mutex);
while (completion->done == 0) {
pthread_cond_wait(&completion->cond, &completion->mutex);
}
completion->done--;
pthread_mutex_unlock(&completion->mutex);
}
void complete(completion_t *completion)
{
pthread_mutex_lock(&completion->mutex);
completion->done++;
pthread_cond_signal(&completion->cond);
pthread_mutex_unlock(&completion->mutex);
}
void complete_all(completion_t *completion)
{
pthread_mutex_lock(&completion->mutex);
completion->done = UINT32_MAX;
pthread_cond_broadcast(&completion->cond);
pthread_mutex_unlock(&completion->mutex);
}
void completion_destroy(completion_t *completion)
{
pthread_cond_destroy(&completion->cond);
pthread_mutex_destroy(&completion->mutex);
}
这是用于理解机制的简化实现:
complete()产生一次完成事件,并唤醒一个等待者。wait_for_completion()消费一次完成事件,因此会执行done--。complete_all()把所有当前等待者唤醒,并把done设置为一个极大值,使它们都能通过检查。
实际工程实现还需要处理计数溢出、错误码、超时等待和销毁时机等问题,不能直接把这段教学代码当成完整基础库。
pthread_cond_wait() 到底做了什么
调用 pthread_cond_wait() 之前,线程必须已经持有对应的 mutex:
pthread_mutex_lock(&completion->mutex);
while (completion->done == 0) {
pthread_cond_wait(&completion->cond, &completion->mutex);
}
pthread_cond_wait() 内部完成以下过程:
- 把当前线程加入条件变量的等待集合。
- 释放传入的 mutex。
- 挂起当前线程,使其不再占用 CPU 轮询。
- 收到
signal或broadcast后被唤醒。 - 重新竞争并获得 mutex。
- 持有 mutex 后,从
pthread_cond_wait()返回。
最关键的是前两步:加入等待集合和释放 mutex 在逻辑上是一个原子等待操作。
这里的“原子”不是说整个睡眠过程不可打断,而是说通知线程不可能恰好插入“等待线程已经释放锁、但还没有真正进入等待集合”的缝隙中,从而造成永久错过通知。
为什么不能手动解锁后再等待
假设存在一个看似合理的接口:
pthread_mutex_unlock(&mutex);
ordinary_wait();
执行过程可能变成:
- 等待线程检查
done == 0。 - 等待线程释放 mutex。
- worker 获得 mutex,把
done改为1,发送通知后离开。 - 等待线程此时才调用
ordinary_wait()。 - 通知已经发生,等待线程却开始睡眠,并且可能永远无法醒来。
这就是 lost wakeup(丢失唤醒)问题。
pthread_cond_wait() 将“释放锁”和“进入等待状态”绑定在一个协议中。通知线程也必须使用同一个 mutex 修改条件,才能让检查状态、进入等待和发送通知形成正确的同步关系。
条件变量不是一种特殊锁。mutex 负责互斥访问共享状态,条件变量负责在 mutex 之外睡眠并等待状态变化,两者解决的问题不同。
为什么已经被唤醒还要使用 while
下面的写法不正确:
if (completion->done == 0) {
pthread_cond_wait(&completion->cond, &completion->mutex);
}
completion->done--;
被唤醒只表示“状态可能发生了变化”,不保证当前线程一定可以继续执行。必须使用 while 重新检查条件,主要有三个原因。
1. 虚假唤醒
POSIX 允许 pthread_cond_wait() 在没有对应业务事件时返回。程序必须允许这种 spurious wakeup,并重新检查谓词。
2. 多个等待者之间存在竞争
多个线程可能同时被唤醒,但在它们依次重新获得 mutex 时,完成事件可能已经被第一个线程消费。后续线程必须重新进入等待。
3. 通知不等于资源所有权
pthread_cond_signal() 表示“共享状态可能满足条件”,它不把 done 对应的事件直接授予某个指定线程。真正决定能否继续的是受 mutex 保护的谓词。
因此,条件变量的标准模板始终是:
pthread_mutex_lock(&mutex);
while (!predicate) {
pthread_cond_wait(&cond, &mutex);
}
// 在持有 mutex 时使用或消费 predicate 对应的状态
pthread_mutex_unlock(&mutex);
signal 和 broadcast 的区别
pthread_cond_signal(&cond);
pthread_cond_broadcast(&cond);
pthread_cond_signal()至少唤醒一个等待线程,适合一次状态变化只允许一个线程继续的场景。pthread_cond_broadcast()唤醒全部等待线程,适合配置切换、系统关闭或“所有线程都可以继续”的状态变化。
broadcast 不代表所有线程最终都能通过条件检查。所有线程醒来后仍要逐个竞争 mutex,并在 while 中重新检查谓词。
通知通常放在修改谓词所使用的 mutex 临界区内,这样状态变化和通知保持清晰的一致关系:
pthread_mutex_lock(&mutex);
predicate = true;
pthread_cond_broadcast(&cond);
pthread_mutex_unlock(&mutex);
Completion 和 pthread_join() 的区别
pthread_join() 等待的是线程生命周期结束:
pthread_create(&worker, NULL, worker_entry, argument);
pthread_join(worker, NULL);
它有两个主要作用:
- 阻塞调用线程,直到目标线程从入口函数返回或调用
pthread_exit()。 - 回收 joinable 线程的相关资源,并可取得线程返回值。
Completion 等待的则是线程执行过程中的某个事件。线程发出 complete() 后可以继续运行,并不需要退出。
| 对比项 | Completion | pthread_join() |
|---|---|---|
| 等待对象 | 某个阶段或事件完成 | 整个线程退出 |
| 完成后 worker 能否继续运行 | 可以 | 不可以,线程已经结束 |
| 能否在一个线程中通知多次 | 可以 | 不可以,一个线程只能被成功 join 一次 |
| 是否负责回收线程资源 | 否 | 是 |
如果 worker 只计算一次结果,随后立即退出,那么主线程直接 pthread_join() 就能保证结果已经写完,此时额外使用 wait_for_completion() 通常没有必要。
把 pthread_join() 紧跟在 pthread_create() 后面虽然合法,但会让主线程立刻阻塞,整体表现接近串行。通常应先启动所需线程或完成主线程自己的工作,再在确实需要等待退出的位置调用 join。
Completion 更符合本意的场景
worker 启动后需要初始化设备、加载索引或建立网络连接。主线程必须等待初始化完成才能接受请求,但 worker 初始化后还要长期运行:
下面省略初始化、服务循环和停止控制的业务实现,只展示 Completion 与 pthread_join() 各自所处的同步位置:
typedef struct {
completion_t ready;
/* 其他运行状态 */
} service_t;
void *service_worker(void *argument)
{
service_t *service = argument;
initialize_storage_and_network();
complete(&service->ready);
serve_requests_until_stopped();
cleanup_service();
return NULL;
}
int main(void)
{
service_t service;
pthread_t worker;
completion_init(&service.ready);
pthread_create(&worker, NULL, service_worker, &service);
wait_for_completion(&service.ready);
start_accepting_client_requests();
request_service_stop();
pthread_join(worker, NULL);
completion_destroy(&service.ready);
return 0;
}
这里的两个等待点语义不同:
wait_for_completion()等待“服务已经准备好”,返回时 worker 仍在运行。pthread_join()等待“服务已经彻底停止”,返回时 worker 已经退出。
这正是 Completion 存在的意义:把线程内部的阶段性事件,与线程整体生命周期分开同步。
总结
pthread 条件变量的正确模型是“mutex 保护谓词,cond 负责等待通知,while 负责重新验证”;Completion 在其上封装可消费的完成状态,而 pthread_join() 负责的是线程退出和资源回收。