【Linux】线程同步与互斥(二):条件变量与线程同步
本文介绍了Linux线程同步中的条件变量机制,重点解析了同步与互斥的区别、条件变量的必要性及其实现原理。互斥解决资源访问冲突,而同步解决线程协作问题。条件变量避免了轮询检查的低效问题,允许线程在条件不满足时休眠等待。文章详细讲解了pthread_cond_wait的工作原理,指出其必须配合互斥锁使用的原因,并通过生产者-消费者模型演示了条件变量的正确用法。最后强调条件变量使用中的常见陷阱,如虚假唤
文章目录
Linux线程同步与互斥(二):条件变量与线程同步
💬 重磅来袭:上一篇解决了互斥问题——用锁保护共享数据,避免数据竞争。但实际场景往往更复杂:生产者生产数据,消费者消费数据,队列空的时候消费者要等,队列满的时候生产者要等。这种"等待某个条件满足"的场景,单靠互斥锁是做不到的。这就引出了本篇的核心——条件变量(condition variable)。条件变量让线程可以高效地等待某个条件成立,而不是傻傻地轮询检查。我们会从同步与互斥的区别讲起,深入理解条件变量的设计思想,掌握正确使用方法,最后封装成好用的工具类。
👍 点赞、收藏与分享:本篇包含大量原理图示、经典案例、使用陷阱分析,是理解线程同步的关键!如果对你有帮助,请点赞、收藏并分享!
🚀 循序渐进:从概念区分到原理剖析,再到正确使用,一步步掌握条件变量。
一、同步 vs 互斥
1.1 互斥是什么
回顾一下上一篇的内容。互斥解决的是"访问冲突"问题:
互斥(Mutual Exclusion):
- 多个线程要访问同一份数据
- 同一时刻只允许一个线程访问
- 保护临界资源,避免数据竞争
例子:
多个线程同时修改 ticket--
→ 用 mutex 保护,同一时刻只有一个线程能执行
互斥关注的是"谁先谁后",是一种资源保护机制。
同步(Synchronization):
- 多个线程要协同工作
- 某些线程需要等待某个条件满足
- 一个线程的执行依赖另一个线程的结果
例子:
生产者往队列里放数据,消费者从队列里取数据
- 队列空时,消费者要等生产者生产
- 队列满时,生产者要等消费者消费
同步关注的是"协调时序",是一种协作机制
互斥是基础,同步建立在互斥之上
场景分析:
┌─────────────────────────────────────┐
│ 生产者-消费者问题 │
├─────────────────────────────────────┤
│ 需要互斥: │
│ 访问队列时要加锁 │
│ 防止多个线程同时操作队列 │
│ │
│ 需要同步: │
│ 队列空时,消费者等待 │
│ 队列满时,生产者等待 │
│ 生产者生产后,通知消费者 │
│ 消费者消费后,通知生产者 │
└─────────────────────────────────────┘
结论:
- 没有互斥,数据会乱
- 没有同步,线程不知道什么时候该干活
- 两者配合使用,才能实现正确的并发
📌 记住这个区别:互斥是"不让同时访问",同步是"让等待的线程知道可以继续了"。条件变量就是用来实现同步的工具。
二、为什么需要条件变量
2.1 轮询的问题
不用条件变量,怎么让线程等待某个条件?最简单的想法是轮询:
// 消费者线程
while (1) {
pthread_mutex_lock(&mutex);
if (queue.empty()) {
pthread_mutex_unlock(&mutex);
continue; // 队列空,继续循环检查
}
// 队列非空,取数据
int data = queue.front();
queue.pop();
pthread_mutex_unlock(&mutex);
process(data);
}
这样写有三个问题:
问题1:浪费CPU
while 循环不停地检查条件
即使队列一直是空的,CPU也在空转
问题2:响应延迟
如果循环中加 sleep(1) 来减少CPU占用
那么生产者放入数据后,消费者最多要等1秒才能发现
问题3:锁竞争
频繁加锁解锁,增加锁竞争
影响生产者的工作效率
2.2 理想的方案
理想情况应该是这样:
消费者视角:
1. 加锁检查队列
2. 如果队列空:
- 进入等待状态(不占用CPU)
- 等待生产者的通知
3. 被唤醒后,继续执行
4. 取数据,解锁
生产者视角:
1. 加锁
2. 往队列放数据
3. 通知等待的消费者:"队列有数据了"
4. 解锁
关键点:
- 消费者等待时不占用CPU
- 生产者主动通知,消费者立即响应
- 不需要轮询
条件变量就是实现这个机制的工具。
三、pthread_cond 详解
3.1 基本API
3.1.1 初始化和销毁
#include <pthread.h>
// 静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
// 动态初始化
int pthread_cond_init(pthread_cond_t *cond,
const pthread_condattr_t *attr);
// attr 传 NULL 表示默认属性
// 销毁
int pthread_cond_destroy(pthread_cond_t *cond);
返回值:成功返回0,失败返回错误号
3.1.2 等待和唤醒
// 等待条件变量
int pthread_cond_wait(pthread_cond_t *cond,
pthread_mutex_t *mutex);
// 唤醒一个等待线程
int pthread_cond_signal(pthread_cond_t *cond);
// 唤醒所有等待线程
int pthread_cond_broadcast(pthread_cond_t *cond);
返回值:成功返回0,失败返回错误号
关键问题:为什么 wait 需要传入 mutex?
这是条件变量最容易让人困惑的地方,后面会详细解释。
3.2 简单示例
先看一个最简单的例子,理解基本用法:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t mutex;
pthread_cond_t cond;
bool ready = false;
void *thread1(void *arg) {
std::cout << "thread1: 等待条件满足..." << std::endl;
pthread_mutex_lock(&mutex);
while (!ready) {
pthread_cond_wait(&cond, &mutex); // 等待
}
pthread_mutex_unlock(&mutex);
std::cout << "thread1: 条件满足,继续执行!" << std::endl;
return nullptr;
}
void *thread2(void *arg) {
sleep(2); // 模拟一些工作
std::cout << "thread2: 准备好了,通知等待线程" << std::endl;
pthread_mutex_lock(&mutex);
ready = true;
pthread_cond_signal(&cond); // 唤醒等待线程
pthread_mutex_unlock(&mutex);
return nullptr;
}
int main() {
pthread_mutex_init(&mutex, nullptr);
pthread_cond_init(&cond, nullptr);
pthread_t t1, t2;
pthread_create(&t1, nullptr, thread1, nullptr);
pthread_create(&t2, nullptr, thread2, nullptr);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
运行结果:
thread1: 等待条件满足...
thread2: 准备好了,通知等待线程
thread1: 条件满足,继续执行!
基本流程:
- thread1 检查条件不满足(ready = false),调用 wait 进入等待
- thread2 修改条件(ready = true),调用 signal 唤醒 thread1
- thread1 被唤醒,继续执行
四、pthread_cond_wait 的原理
4.1 为什么 wait 需要传入 mutex
要回答这个问题,关键在于理解:
条件变量要同时解决两个问题,而这两个问题单独做都会出错。
一、消费者在条件不满足时,必须同时做到两件事
当消费者发现“队列为空”时,它必须:
1. 让出 mutex(否则生产者进不来 push)
2. 自己进入等待状态(否则只能轮询)
而且这两件事必须 紧密衔接。
如果把它们拆开做,就会出问题。
二、如果不把 mutex 交给 wait,你会怎么写?
假设 pthread_cond_wait 不需要 mutex,那消费者只能自己拼逻辑。
直觉上你会想到两种写法。
❌ 写法一:拿着锁直接 wait(不会丢信号,但会死锁)
pthread_mutex_lock(&mutex);
if (queue.empty()) {
wait(&cond); // 睡着,但还拿着 mutex
}
结果是:
- 消费者睡眠时仍然持有 mutex
- 生产者无法获得 mutex
- 无法 push 数据
- 更不可能 signal
👉 这是死锁,问题很明显。
❌ 写法二:先解锁,再 wait(避免死锁,但会丢信号)
为了避免死锁,你自然会改成:
pthread_mutex_lock(&mutex);
if (queue.empty()) {
pthread_mutex_unlock(&mutex); // 先让出锁
wait(&cond); // 再进入等待
pthread_mutex_lock(&mutex); // 醒来后重新加锁
}
这段代码看起来非常合理,但这里就出现了一个致命的时间窗口。
三、信号丢失是怎么发生的(关键时间线)
假设队列一开始是空的:
时刻 消费者线程 生产者线程 队列状态
T1 lock(mutex)
T2 queue.empty() → true
T3 unlock(mutex)
T4 lock(mutex)
T5 queue.push(1)
T6 signal(cond)
T7 unlock(mutex)
T8 wait(cond) ← 现在才进入等待
结果:
- signal 发生时,消费者还没进入等待队列
- 条件变量不会“记住”这个 signal
- 消费者随后进入睡眠
- 队列里明明已经有数据,但消费者可能永远睡着
👉 这就叫信号丢失(lost wakeup)。
关键不是“有没有加锁”,
而是:
“释放锁”和“进入等待”这两步不是原子的,中间被生产者插进来了。
四、pthread_cond_wait(cond, mutex) 到底解决了什么?
pthread_cond_wait 的真正价值在于:
它把最危险的两步合成了一个原子操作:
pthread_cond_wait(cond, mutex) 保证:
1. 将当前线程加入条件变量的等待队列
2. 释放 mutex
3. 线程进入睡眠
↑
以上步骤对外不可分割
也就是说:
- 不存在“锁已经释放,但线程还没开始等”的窗口
- signal 要么发生在你进入等待之前(你已经在队列里)
- 要么发生在你等待之后(自然能被唤醒)
- 不可能被错过
总结:
wait 之所以必须传入 mutex,是因为线程在等待条件时,
必须原子地完成“释放互斥锁并进入等待队列”这两步。
如果这两步分开执行,就会在解锁与等待之间产生竞态窗口,
从而导致 signal 发生却无人接收,即“信号丢失”。
4.2 wait 的完整流程
pthread_cond_wait(&cond, &mutex) 的详细过程:
前提:调用前必须已经持有 mutex
步骤1:原子地执行以下操作
├─ 将当前线程放入条件变量的等待队列
└─ 释放 mutex
步骤2:线程进入休眠状态
└─ 不占用CPU,等待被唤醒
步骤3:被 signal 或 broadcast 唤醒
步骤4:尝试重新获取 mutex
├─ 如果 mutex 被其他线程持有,继续等待
└─ 获取到 mutex 后,wait 函数返回
步骤5:返回后
└─ 线程重新持有 mutex
流程图:
消费者线程 生产者线程
───────── ─────────
lock(mutex)
│
├─ while (queue.empty())
│
├─ wait(cond, mutex)
│ ├─ 加入等待队列
│ ├─ unlock(mutex) ──────→ lock(mutex) ← 生产者可以拿到锁了
│ │ │
│ ├─ 休眠 ├─ queue.push(data)
│ │ │
│ │ ←────────── ├─ signal(cond) ← 唤醒消费者
│ │ │
│ ├─ 被唤醒 ├─ unlock(mutex)
│ │ ↓
│ ├─ 尝试lock(mutex)
│ │ (等待生产者释放)
│ │
│ └─ lock(mutex)成功
│ wait返回
│
├─ queue.front()
├─ queue.pop()
│
unlock(mutex)
📌 核心理解:wait 调用前后,都是持有 mutex 的;中间释放锁,让其他线程可以工作。这样既避免了信号丢失,又保证了数据访问的安全性。
4.3 为什么用 while 而不是 if
再看一遍正确的写法:
pthread_mutex_lock(&mutex);
while (queue.empty()) { // ← 用 while,不是 if
pthread_cond_wait(&cond, &mutex);
}
// 取数据
pthread_mutex_unlock(&mutex);
为什么要用 while 循环?两个原因:
4.3.1 虚假唤醒(Spurious Wakeup)
定义:
线程在没有被 signal/broadcast 唤醒的情况下
从 wait 中返回
原因:
- Linux 内核的实现细节
- 信号中断等因素
- POSIX 标准允许虚假唤醒存在
例如:
系统收到某个信号(如 SIGCHLD)
可能导致 wait 提前返回
如果用 if:
if (queue.empty()) {
pthread_cond_wait(&cond, &mutex);
}
// 虚假唤醒后,queue 可能还是空的
int data = queue.front(); // ← 错误!可能 crash
用 while:
while (queue.empty()) {
pthread_cond_wait(&cond, &mutex);
}
// 只有 queue 非空才能退出循环
int data = queue.front(); // ← 安全
4.3.2 多消费者竞争
场景:
- 2个消费者线程 C1, C2
- 队列里只有1个数据
- 生产者调用 broadcast 唤醒所有等待者
过程:
1. C1 和 C2 都被唤醒
2. C1 先拿到锁,取走数据
3. C2 拿到锁时,队列已经空了
如果用 if:
// C2 拿到锁后
if (queue.empty()) { // ← 已经检查过了,不会再检查
pthread_cond_wait(&cond, &mutex); // ← 不会执行
}
int data = queue.front(); // ← 错误!queue 是空的
用 while:
// C2 拿到锁后
while (queue.empty()) { // ← 再次检查
pthread_cond_wait(&cond, &mutex); // ← 继续等待
}
int data = queue.front(); // ← 只有非空才执行
📌 推荐模式:while + wait(POSIX 允许虚假唤醒,多等待者也需要重新检查条件),因此实际工程里应默认使用 while。
五、signal vs broadcast
5.1 两者的区别
pthread_cond_signal(&cond):
- 唤醒至少一个等待线程(具体唤醒哪一个、是否公平由实现决定,不做保证)。
pthread_cond_broadcast(&cond):
- 唤醒所有等待线程
- 所有等待者都会被唤醒
- 它们会竞争锁,只有一个能先拿到
5.2 什么时候用 signal
场景:
- 只需要一个线程醒来处理
- 多个等待者的条件相同
- 例如:生产者放入一个数据,只需要一个消费者取
好处:
- 减少不必要的唤醒
- 降低锁竞争
示例:
// 生产者
pthread_mutex_lock(&mutex);
queue.push(data);
pthread_cond_signal(&cond); // 只需要一个消费者醒来
pthread_mutex_unlock(&mutex);
5.3 什么时候用 broadcast
场景:
- 需要所有线程都检查条件
- 不同等待者的条件可能不同
- 例如:程序退出时,通知所有线程结束
好处:
- 确保所有相关线程都能响应
示例:
// 退出通知
pthread_mutex_lock(&mutex);
quit_flag = true;
pthread_cond_broadcast(&cond); // 唤醒所有线程
pthread_mutex_unlock(&mutex);
// 各个线程
while (1) {
pthread_mutex_lock(&mutex);
while (!quit_flag && queue.empty()) {
pthread_cond_wait(&cond, &mutex);
}
if (quit_flag) {
pthread_mutex_unlock(&mutex);
break; // 退出循环
}
// 处理数据...
}
📌 选择建议:默认用 signal,除非明确需要唤醒所有线程时才用 broadcast。signal 更高效,broadcast 更保险。
六、条件变量的封装
6.1 Cond 类设计
继续在上一篇的 Lock.hpp 中添加条件变量的封装:
#pragma once
#include <pthread.h>
namespace LockModule
{
// ... Mutex 和 LockGuard 的定义 ...
class Cond
{
public:
Cond(const Cond &) = delete;
const Cond &operator=(const Cond &) = delete;
Cond() {
pthread_cond_init(&_cond, nullptr);
}
void Wait(Mutex &mutex) {
pthread_cond_wait(&_cond, mutex.GetMutexOriginal());
}
void Signal() {
pthread_cond_signal(&_cond);
}
void Broadcast() {
pthread_cond_broadcast(&_cond);
}
~Cond() {
pthread_cond_destroy(&_cond);
}
private:
pthread_cond_t _cond;
};
}
6.2 使用示例
#include <iostream>
#include <queue>
#include <pthread.h>
#include <unistd.h>
#include "Lock.hpp"
using namespace LockModule;
std::queue<int> data_queue;
Mutex mutex;
Cond cond;
bool quit_flag = false;
void *consumer(void *arg) {
int id = *(int *)arg;
while (1) {
mutex.Lock();
while (data_queue.empty() && !quit_flag) {
std::cout << "消费者" << id << ": 队列空,等待..." << std::endl;
cond.Wait(mutex); // 等待数据
}
if (quit_flag && data_queue.empty()) {
mutex.Unlock();
break; // 退出
}
int data = data_queue.front();
data_queue.pop();
mutex.Unlock();
std::cout << "消费者" << id << ": 消费数据 " << data << std::endl;
usleep(100000); // 模拟处理
}
std::cout << "消费者" << id << ": 退出" << std::endl;
return nullptr;
}
void *producer(void *arg) {
int id = *(int *)arg;
for (int i = 0; i < 10; i++) {
int data = id * 100 + i;
mutex.Lock();
data_queue.push(data);
std::cout << "生产者" << id << ": 生产数据 " << data << std::endl;
cond.Signal(); // 通知消费者
mutex.Unlock();
usleep(50000);
}
return nullptr;
}
int main() {
pthread_t p1, p2, c1, c2;
int pid1 = 1, pid2 = 2;
int cid1 = 1, cid2 = 2;
pthread_create(&c1, nullptr, consumer, &cid1);
pthread_create(&c2, nullptr, consumer, &cid2);
pthread_create(&p1, nullptr, producer, &pid1);
pthread_create(&p2, nullptr, producer, &pid2);
pthread_join(p1, nullptr);
pthread_join(p2, nullptr);
// 通知消费者退出
mutex.Lock();
quit_flag = true;
cond.Broadcast(); // 唤醒所有消费者
mutex.Unlock();
pthread_join(c1, nullptr);
pthread_join(c2, nullptr);
return 0;
}
运行结果(部分):
消费者1: 队列空,等待...
消费者2: 队列空,等待...
生产者1: 生产数据 100
消费者1: 消费数据 100
生产者2: 生产数据 200
消费者2: 消费数据 200
生产者1: 生产数据 101
消费者1: 消费数据 101
...
消费者1: 退出
消费者2: 退出
七、条件变量的使用规范
7.1 标准模式
条件变量的使用遵循固定模式:
等待方:
pthread_mutex_lock(&mutex);
while (条件不满足) {
pthread_cond_wait(&cond, &mutex);
}
// 条件满足,执行操作
pthread_mutex_unlock(&mutex);
通知方:
pthread_mutex_lock(&mutex);
// 修改条件
pthread_cond_signal(&cond); // 或 broadcast
pthread_mutex_unlock(&mutex);
7.2 常见错误
7.2.1 忘记加锁
// 错误
while (queue.empty()) {
pthread_cond_wait(&cond, &mutex); // 调用前没有 lock
}
后果:未定义行为,可能导致死锁或 crash。
7.2.2 用 if 代替 while
// 错误
if (queue.empty()) { // ← 应该用 while
pthread_cond_wait(&cond, &mutex);
}
后果:虚假唤醒或多消费者竞争时,条件不满足也会继续执行。
7.2.3 signal 时不持有锁
// 有争议的写法
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond); // unlock 后再 signal
pthread_cond_signal/broadcast 不一定要求调用时持有互斥锁,
但强烈建议: “持锁修改共享条件 →signal/broadcast → 解锁”作为一个整体完成。 这样能避免竞态窗口,并且更符合条件变量的典型用法。
如果 signal 在 unlock 后:
1. 线程A unlock
2. 等待线程B被唤醒
3. B尝试获取锁,但锁被别人又拿走了
4. B再次等待
5. 锁再次空闲
6. B再次被调度,拿到锁
如果 signal 在 unlock 前:
1. 线程A signal(B被唤醒但还拿不到锁)
2. A unlock(立即释放锁)
3. B拿到锁继续执行
第二种方式更高效,减少了调度
建议:在 unlock 前 signal。
7.3 完整的使用清单
✓ wait 前必须持有锁
✓ 用 while 循环检查条件,不用 if
✓ signal/broadcast 时持有锁
✓ 条件判断和数据访问都在锁的保护下
✓ 用 signal 还是 broadcast 要根据场景选择
✓ 记得初始化和销毁条件变量
📌 记住这个模板:把标准模式背下来,遇到同步问题直接套用,不容易出错。
八、条件变量的底层实现
8.1 Linux futex 机制
条件变量的底层依赖 futex (Fast Userspace Mutex):
futex 是什么:
- Linux 提供的快速用户空间同步机制
- 用户态 + 内核态混合实现
- pthread_mutex 和 pthread_cond 都基于 futex
两个系统调用:
int futex(int *uaddr, int op, int val, ...);
操作:
FUTEX_WAIT:原子地检查*uaddr,如果等于val则休眠
FUTEX_WAKE:唤醒等待在uaddr上的线程
8.2 pthread_cond_wait 的简化实现
以下为“思想级简化示意”,用于理解“等待队列 + futex 休眠/唤醒”的方向,不等价于 glibc 真实实现细节。
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex) {
// 1. 加入等待队列
add_to_wait_queue(cond);
// 2. 释放 mutex(用户态操作)
atomic_store(&mutex->lock, 0);
if (mutex->waiters > 0) {
futex_wake(&mutex->lock, 1); // 唤醒等待 mutex 的线程
}
// 3. 等待条件变量(内核态休眠)
futex_wait(&cond->futex, expected_value);
// 4. 被唤醒,重新获取 mutex
while (!atomic_compare_and_swap(&mutex->lock, 0, 1)) {
futex_wait(&mutex->lock, 1);
}
mutex->owner = gettid();
// 5. 从等待队列移除
remove_from_wait_queue(cond);
return 0;
}
8.3 pthread_cond_signal 的简化实现
int pthread_cond_signal(pthread_cond_t *cond) {
// 检查是否有等待者
if (cond->waiters == 0) {
return 0; // 没有等待者,直接返回
}
// 唤醒一个等待线程
futex_wake(&cond->futex, 1);
return 0;
}
📌 理解关键:在 Linux/glibc 上常见实现会借助 futex 完成阻塞/唤醒(实现细节依版本/库而异),它们在内核里操作等待队列,实现高效的休眠和唤醒。
九、本篇总结
9.1 核心知识点
1. 同步 vs 互斥
互斥:同一时刻只有一个线程访问(保护数据)
同步:线程之间协作配合(协调时序)
关系:同步建立在互斥之上
2. 条件变量的作用
问题:轮询浪费CPU,响应延迟,锁竞争
方案:条件变量让线程高效等待
- 等待时不占CPU
- 被唤醒立即响应
- 配合 mutex 使用
3. pthread_cond API
| 函数 | 作用 |
|---|---|
| pthread_cond_init | 初始化条件变量 |
| pthread_cond_wait | 等待条件满足 |
| pthread_cond_signal | 唤醒一个线程 |
| pthread_cond_broadcast | 唤醒所有线程 |
| pthread_cond_destroy | 销毁条件变量 |
4. pthread_cond_wait的原理
前提:调用前持有 mutex
步骤:
1. 原子地释放 mutex 并加入等待队列
2. 线程休眠
3. 被唤醒,尝试重新获取 mutex
4. 获取成功,wait 返回(仍持有锁)
5. 使用规范
✓ 用 while 循环,不用 if
✓ wait 前必须持有锁
✓ signal/broadcast 时持有锁
✓ 条件判断在锁保护下
9.2 最重要的理解
📌 条件变量的精髓:
1. 条件变量 = 等待队列 + 通知机制
- 不满足条件时,加入队列休眠
- 条件满足时,唤醒等待者
2. 必须配合 mutex 使用
- mutex 保护共享数据
- cond 协调线程时序
- wait 原子地释放锁并等待
3. 标准模式要记牢
- while + wait(等待方)
- 修改 + signal(通知方)
- 永远不用 if
4. signal vs broadcast
- signal:唤醒一个(常用)
- broadcast:唤醒所有(退出通知等)
💬 下篇预告:条件变量的知识掌握后,我们就可以实现经典的生产者-消费者模型了。下篇会从模型原理讲起,实现两种版本:基于阻塞队列(BlockingQueue)的版本,以及基于环形队列(Ring Buffer)+ 信号量(Semaphore)的版本。通过这个经典模型,加深对同步互斥的理解,并学会用信号量解决实际问题。
👍 点赞、收藏与分享:如果这篇文章对你有帮助,请点赞、收藏并分享!
更多推荐

所有评论(0)