线程4.2
线程4.2
本文主要内容:
在看本文之前,如果对并发和并行,互斥和同步,以及信号量没什么概念的先看:Linux系统–进程间通信–信号量-CSDN博客
本文主要是偏向于理论—>通过举几个例子让大家更加深入的了解互斥同步问题。
-
文章开始前,我们重申了互斥和同步两个概念的核心和侧重。
-
我们举了两个例子:共享的单人游戏房,以及,饭堂抢饭问题。
- 从而引出线程饥饿问题,或者说线程竞争锁的公平性问题。
- 简单的分析了一下问题。(这个饥饿问题其实在线程4.1就已经有讲过,但没有明确说明,在线程4.1中也有解决饥饿问题的办法,可以手动公平–>比如让刚刚释放锁的线程睡一会,或者使用
sched_yield();// 建议调度器切换线程,这个办法在线程4.1中的修正第二版代码处) - 提出解决方案:引入公平性。简单介绍了公平锁。
-
接下来举了一个大例子,这大例子中分有3部分:
- 第一部分主要侧重讲述问题:线程忙等待问题。
- 第二部分主要侧重解决第一个问题,提出解决方案:引入条件变量,提供“等待-通知”机制。
- 第三部分则是侧重说明:多消费者的同步问题(多消费者,单生产者),展示了:多消费者,单生产者情景下的 “生产-消费” 流程。
-
简单总结了同步问题的层次。
-
详细讲解了什么是竞态条件:(这部分的内容可能有点难理解,看不懂的时候可以直接往后看,把整体看完,多看几遍就明白了)
-
解释清楚:什么是时间窗口?存在时间窗口会有什么危害?
-
然后我们精讲了一种很重要很重要的情况:
-
这个时间窗口处于:生产者发送信号时 → 消费者被唤醒时 和 消费者被唤醒时 → 消费者重新检查条件时。在这个时间窗口,其他线程可能:
- 已经消费了资源(多个消费者)
- 改变了条件状态
- 或者条件本来就不成立(虚假唤醒)
-
基于这个重要的时间窗口,我们详细讲述了:为什么线程被唤醒时检查条件必须使用
while(),而不是单次使用if条件判断。(这点内容十分重要)-
// 为什么必须是 pthread_mutex_lock(&mutex); while (condition_is_false) { // 必须用while,不能用ifpthread_cond_wait(&cond, &mutex); } // 条件满足,处理事件 pthread_mutex_unlock(&mutex);/////////////////////////////////////////////////////////////////////////////// 而不是 // 问题代码 pthread_mutex_lock(&mutex); if (condition == false) { // ① 检查条件(持有锁,安全)// ② 调用wait()的瞬间:释放锁 + 进入等待pthread_cond_wait(&cond, &mutex); // 这是一个原子操作! } // 处理事件 pthread_mutex_unlock(&mutex);
-
-
-
-
最后我补充了一些大家可能会有的疑问:
-
条件变量本身需要互斥访问吗?
-
可不可以把
pthread_cond_signal(&cond);放到pthread_mutex_unlock(&mutex);后面?-
void producer() {pthread_mutex_lock(&mutex);buffer[in] = item;in = (in + 1) % SIZE;count++;pthread_mutex_unlock(&mutex);pthread_cond_signal(&cond); // 在锁外发信号 }
-
-
番外链接:
- 互斥锁相关函数的详细说明在:
- POSIX线程库–罗列核心函数-CSDN博客
- POSIX线程库–详细函数说明2.2–互斥锁-CSDN博客
- 条件变量相关函数的详细说明在:
- POSIX线程库–详细函数说明2.3–条件变量-CSDN博客
线程完整系列:
线程1.1-CSDN博客
线程1.2-CSDN博客
线程2.0-CSDN博客
线程3.1-CSDN博客
线程3.2-CSDN博客
线程4.1-CSDN博客
主题深度重构:同步问题与条件变量
首先,明确一个核心概念:同步问题本质上是关于"执行顺序"的协调问题。
- 互斥 解决的是"能不能"的问题:一个资源在同一时刻"能不能"被多个线程访问?答案是不能。
- 同步 解决的是"什么时候"的问题:一个线程"什么时候"应该执行?它应该在某个条件满足时才执行。
1. 饥饿问题:当互斥不再公平
游戏房和饭堂
我们用一个比较极端的例子来引出问题:过年了,游戏店老板为了引流,开了一个免费的超级VIP游戏房(只能允许一个人在房间里玩)。这个游戏房的钥匙就放在房间外面,谁拿到钥匙,开门进房,这个游戏房就只属于那个开门的人,那个人可以在游戏房里爽玩(进去之后能用钥匙反锁房门)。谁都能来,钥匙就在外面,谁能抢到钥匙,谁就能进去。
同学a知道这件事情之后,哇,兴奋不行,早上4点就起床,飞奔向游戏房,拿到了游戏房的钥匙,进去反锁上门,爽玩起了游戏。到了7,8点左右的时候,别的同学也陆陆续续来到了这个游戏房,发现钥匙已经被拿走了,那他们只能在房门口外面等。到了差不多12点左右,同学a感觉有点肚子饿,想回家吃饭,但是,他刚出房门,挂起钥匙,一看,哇!一堆人啊!他就马上又把钥匙拿走了,马上进房,心想(我去!这么多人,等我吃完饭回来,我得等多久才能再玩到游戏啊)。不过,过了3,4分钟,同学a饿不行了,又出去,又将钥匙挂起来。看到那么多人,又马上抢走钥匙,进门反锁。过了30秒,同学a又顶不住了,他又出去,又挂起钥匙,看到好多人,又把钥匙拿走,进门反锁……
这样的结果就是导致外面排队的那一堆人玩不了游戏。死等。
再举一个比较贴切的例子:饭堂就一个窗口,又是同学a,同学a一下课就冲到这个窗口,找阿姨要饭。阿姨一给他打好饭,他以光的速度,光速清盘,又找阿姨要饭,又光速吃完……这样就会导致,后面排队的同学饿死。
这就是饥饿问题。或者说线程的竞争公平性问题?
游戏房确实只保证了只能一个线程访问:只要有1个线程访问游戏房,那么别的线程就无法访问游戏房。必须等待使用游戏房的那个线程释放锁了(把钥匙归还),这个游戏房才能允许下一个线程进入。这正确的不能再正确了,非常正确!
同理,饭堂吃饭也是一样,就只有一个窗口,一个窗口只能一个人访问,而我就是饭量大,我就是吃的快,那你能拿我怎么办,我就占据着这个窗口,规则就是这么写的,自由竞争嘛,一个窗口只能被一个人访问嘛。这不是很正确嘛!
上面两个例子,相信大家都能比较明显的感觉到了:是的,没错。他们这么做确实符合访问公共资源的规则。但是这样做合理嘛?
不合理嘛!
游戏房和饭堂这两个例子描述的都是典型的饥饿问题。
问题分析:
- 规则正确性:从互斥的角度看,系统完全正确——任何时候只有一个线程在临界区内。
- 公平性缺失:问题在于锁的获取机制是不公平的。它采用自由竞争策略,谁抢到就是谁的。
如图所示,在自由竞争模式下,刚释放锁的线程A立即参与下一轮竞争,由于它已经在CPU上运行,相比其他休眠后被唤醒的线程有巨大优势。
- 线程A:释放锁 → 立即尝试重新获取锁(已经在CPU上运行)
- 线程B、C:需要等待操作系统调度器将它们唤醒并切换到CPU上
这种时间差导致了不公平性。
解决方案:引入公平性
排队机制:让等待锁的线程排成一个队列,按照先进先出(FIFO)的顺序获取锁。
这样确保了:
- 每个等待线程最终都能获得锁
- 线程获取锁的顺序就是它们开始等待的顺序
- 刚释放锁的线程必须重新排队
技术实现:操作系统内部的互斥锁通常都有这种排队机制,比如Linux的futex。
对比:公平锁的流程图
为了更好理解,我们对比一下公平锁的工作流程:
flowchart TDStart([初始状态]) --> Queue[等待队列: 空]subgraph 线程A第一次获取锁A_Get[A获取锁] --> A_Run[A执行临界区]endA_Run --> A_Release[A释放锁,排在队列末尾]A_Release --> EnqueueB[B加入队列头部]EnqueueB --> EnqueueC[C加入队列,排在B后面]EnqueueC --> CheckQueue{检查队列}CheckQueue -- 队列不为空 --> WakeFirst[唤醒队首线程B]CheckQueue -- 队列为空 --> Idle[锁空闲]WakeFirst --> B_Get[B获取锁]B_Get --> B_Run[B执行临界区]B_Run --> B_Release[B释放锁]B_Release --> EnqueueA[B排到队列末尾]EnqueueA --> EnqueueC2[C在队列头部]EnqueueC2 --> CheckQueue2{检查队列}CheckQueue2 -- 队列不为空 --> WakeFirst2[唤醒队首线程C]WakeFirst2 --> C_Get[C获取锁]C_Get --> C_Run[C执行临界区]

在公平锁中:
- 线程释放锁后必须排到队列末尾
- 总是唤醒队列头部的等待线程
- 确保每个线程都有机会执行
总结:
- 饥饿问题的根源在于释放锁的线程可以立即重新竞争
- 解决方案是强制线程释放锁后必须重新排队
2. 条件变量:解决"等待特定条件"的问题
这里我们依旧使用一个比较极端的例子来给大家引出条件变量的概念:
圣诞老头
第一part
圣诞节到了,同学a准备好了一个大袜子,打算接收圣诞老头子的礼物。同学a是非常期待老头子给的礼物。兴奋的睡不着觉。然后同学a以光的速度,隔一会就把袜子拿起来看一下:礼物到手没?看完后躺下一纳秒不到,你又把袜子拿起来看一下:老头子有没有给我礼物啊?……
大家都知道,圣诞老头子,历来都是来无影,去无踪的,不说话,装高手。他看你这样,隔一纳秒就看一眼袜子,那我还咋给你放礼物了?你这不闹嘛。
明明袜子里还没有礼物呢,你就一直狂看,狂看。袜子就tm一个,袜子一直在你手上,我怎么给你礼物?
就因为这个同学a,圣诞老头在同学a家的房顶挂了一晚上……冻死在屋顶了……太沉重了……
第二part
后面,下一届圣诞老头继任了,他想,他不能像上一届圣诞老头一样,因为这个同学a,挂在屋顶上,我得想个办法。
然后,圣诞老头在下一届圣诞节来临前,用村里的大喇叭,喊了一下:“everyone,你们想收到礼物的,家里都给我备上一个铃铛,我把礼物放进你们的袜子里面后,会敲铃告诉你们的!”
这样,同学a再也不光速仰卧起坐检查袜子了,而是乖乖的等着老头敲铃。
好,还没完。
第三part
同学a还有一个弟弟,和妹妹,他们长大懂事后,他们也知道了圣诞老头的故事,他们也想收到礼物,不过他们没有像样的大袜子,装不下什么礼物,他们就找到哥哥,和哥哥 共用 同一个大袜子。
并且,兄弟妹三个人想包养圣诞老头,这个圣诞就给他们发礼物算了。圣诞老头也很乐意,谁都想被包养。
后面,这兄弟妹三人干脆不睡了,就闭着眼睛,排着队等着老头给他们放礼物(一个袜子只能放一个礼物)。
老头放了大哥的礼物,敲就绪铃铛,然后躲屋顶上,大哥拿了礼物之后,又敲空铃铛,告诉老头,我拿好了,然后去妹妹后面排队。
老头又下来放弟弟的礼物,敲就绪铃铛,躲屋顶,弟弟拿了礼物,敲空铃铛,告诉老头,我拿好了,然后去哥哥后面排着队。
老头又下来放妹妹的礼物,敲就绪铃铛,躲屋顶,妹妹拿了礼物很开心,敲空铃铛,叫老头,我拿好了,然后去弟弟后面排队。
接着又放大哥的礼物……弟弟……妹妹……
就这样,这个铃铛解决了老头放不了礼物进袜子的问题,兄弟妹排队,解决了因为大哥出头的比较早,而弟妹没有礼物的问题。排队后,兄弟妹都有礼物。
第一部分:忙等待的问题(错误的做法)
技术对应:
- 袜子 = 共享资源(受互斥锁保护)
- 检查礼物 = 忙等待循环检查条件
- 问题:
- CPU资源浪费(同学A不断检查)
- 死锁风险(圣诞老人永远无法拿到袜子)
- 竞态条件(检查和修改之间有时间窗口)
- 三个致命缺点:
- CPU资源浪费:线程不停地检查条件,占用CPU
- 竞争条件:检查条件和改变条件之间可能存在时间窗口
- 优先级反转:高优先级线程空转,低优先级线程无法运行来改变条件
注意!!!!:
我知道大家不知道竞态条件是什么,不理解什么是时间窗口,所以我在后面有详细讲解这部分。由于这部分涉及的内容很多,就没放到文章中间,断了文章节奏。
第二部分:条件变量的引入(正确的做法)
技术对应:
- 铃铛 = 条件变量
- 等待铃铛 =
pthread_cond_wait() - 敲铃 =
pthread_cond_signal() - 关键改进:
- 等待时自动释放锁
- 被唤醒时自动重新获取锁
- 避免CPU空转
条件变量提供了"等待-通知"机制,包含三个核心操作:
-
wait(cond, mutex):
- 原子性地释放互斥锁并进入等待状态
- 被唤醒后重新获取互斥锁
-
signal(cond):唤醒一个等待该条件的线程
-
broadcast(cond):唤醒所有等待该条件的线程
总结:
我们引入了一个条件变量铃铛,用这个铃铛来协调同学A和圣诞老人对袜子的操作顺序。在老人没有敲铃铛前,同学A处于休眠等待状态,老头敲铃后,同学A再去查看袜子。
从而解决同学A忙等待的问题。
3. 综合案例:兄弟妹三人的礼物系统
第三部分:多消费者同步问题
现在系统变得更加复杂,展示了条件变量和互斥锁的完美配合:
- 多个消费者:兄弟妹三人
- 单一共享资源:一个大袜子
- 生产者-消费者模式:圣诞老人生产,兄弟妹消费
技术实现代码框架
pthread_mutex_t sock_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t gift_ready = PTHREAD_COND_INITIALIZER; // 铃铛1
pthread_cond_t sock_empty = PTHREAD_COND_INITIALIZER; // 铃铛2int sock_has_gift = 0; // 袜子状态:0=空, 1=有礼物
queue_t waiting_queue; // 兄弟妹排队队列// 圣诞老人(生产者)
void* santa_claus(void* arg) {while (1) {pthread_mutex_lock(&sock_mutex);// 等待袜子变空while (sock_has_gift == 1) {pthread_cond_wait(&sock_empty, &sock_mutex);}// 放入礼物put_gift_into_sock();sock_has_gift = 1;// 通知有礼物了pthread_cond_signal(&gift_ready);pthread_mutex_unlock(&sock_mutex);}
}// 兄弟妹(消费者)
void* child(void* arg) {int my_id = (int)arg;while (1) {pthread_mutex_lock(&sock_mutex);// 等待礼物就绪且轮到自己while (sock_has_gift == 0 || !is_my_turn(my_id)) {pthread_cond_wait(&gift_ready, &sock_mutex);}// 取走礼物take_gift_from_sock();sock_has_gift = 0;// 移动到队尾,让下一个兄弟姐妹排队move_to_end_of_queue(my_id);// 通知袜子已空pthread_cond_signal(&sock_empty);pthread_mutex_unlock(&sock_mutex);enjoy_gift(); // 在锁外享受礼物}
}
我们这个条件判断是:符合条件则归还锁,并等待。如果不符合条件则继续往下执行。我们这个条件是退出条件,所以条件为false的时候继续往下执行。-----可能有点别扭。
系统工作流程:
- 圣诞老人(生产者):
- 获取锁 → 检查袜子是否空 → 放入礼物 → 发信号 → 释放锁
- 兄弟妹(消费者):
- 获取锁 → 检查是否有礼物且轮到自己 → 取礼物 → 重新排队 → 发信号 → 释放锁 → 享受礼物
- 队列管理:
- 初始:
[M, B, S] - 大哥取礼后:
[B, S, M] - 弟弟取礼后:
[S, M, B] - 妹妹取礼后:
[M, B, S](回到初始)
- 初始:
关键同步模式总结
-
双条件变量模式:
gift_ready:礼物已放入,消费者可以取sock_empty:袜子已空,生产者可以放
-
排队公平性:
- 兄弟妹按照队列顺序消费
- 取完礼物后重新排到队尾
-
正确的等待模式:
while (condition_is_false) {pthread_cond_wait(&cond, &mutex); }必须用
while而不是if,因为:- 虚假唤醒
- 多个消费者竞争
- 条件可能再次改变
注意!!!:下文就会详细讲解为什么:必须用
while而不是if。 -
锁的范围管理:
- 只在访问共享状态时持有锁
- 处理礼物(enjoy_gift)在锁外进行
这个例子完美展示了如何用条件变量解决复杂的生产者和消费者同步问题,确保了公平性和效率。
总结:同步问题的层次
-
基础层:互斥
- 解决数据竞争问题
- 确保临界区原子性
-
公平性层:防饥饿
- 通过排队机制保证所有线程都有机会
- 解决"霸锁"问题
-
协调层:条件同步
- 线程需要等待特定条件成立
- 避免忙等待,提高CPU利用率
- 解决生产者和消费者之间的时序协调
-
高级层:复杂同步模式
- 读者写者问题
- 屏障同步
- 信号量等更复杂的同步原语
本文讲述的两个大例子比较好地覆盖了前三个层次。理解这些概念的关键是:互斥关心的是"排斥",同步关心的是"时序"。在实际编程中,我们通常需要同时使用互斥锁和条件变量来构建正确的并发程序。
什么是竞态条件?
竞态条件指的是:程序的正确性依赖于事件发生的时序。当多个线程/进程以不可预测的顺序访问和操作共享数据时,最终结果取决于这些操作的精确时序,这就产生了竞态条件。
用圣诞老人例子解释时间窗口
让我们回到第一部分例子,看看具体的时间窗口在哪里:(关于时间窗口的具体解释在“类比讲解”处,那里我讲的比较清楚)
错误的检查模式(存在时间窗口)
// 同学A的代码(错误版本)
while (true) {pthread_mutex_lock(&sock_mutex); // ① 获取锁if (sock_has_gift == 0) { // ② 检查条件// 袜子是空的,什么都不做} else {// 有礼物!取走礼物take_gift();}pthread_mutex_unlock(&sock_mutex); // ③ 释放锁// ④ 这里有一个时间窗口!
}
// 圣诞老人的代码(错误版本)
while (true) {pthread_mutex_lock(&sock_mutex); // ⑤ 获取锁if (sock_has_gift == 0) { // ⑥ 检查条件put_gift(); // ⑦ 放入礼物}pthread_mutex_unlock(&sock_mutex); // ⑧ 释放锁
}
时间窗口的具体分析
真正的问题不是"抢不到锁"
在这个具体例子中,时间窗口不会导致圣诞老人永远抢不到锁。线程调度器会公平地分配CPU时间,圣诞老人最终会获得锁。
真正的问题:效率低下和资源浪费
让我用更准确的图表来展示真正的问题:
真正的时间窗口问题
1. CPU资源浪费(忙等待)
while (true) {pthread_mutex_lock(&sock_mutex); // 消耗CPUif (sock_has_gift == 0) { // 消耗CPU // 什么都不做,但消耗了锁操作} else {take_gift();}pthread_mutex_unlock(&sock_mutex); // 消耗CPU// 立即又开始下一轮循环...
}
这里的意思是:同学A没必要一直检查这个袜子中是否有礼物,因为他这种盲目的循环查询是没有任何意义的,只会浪费CPU资源,所以就要引入条件变量,让同学A在检查到没有礼物的时候直接释放锁,并进入等待队列,等待圣诞老人的通知。
而且同学A如果有抢锁优势,就会导致大部分时间都占用着锁,导致圣诞老人抢不到锁,放不进去礼物。也就是说,整个操作下来,有效部分很少,效率极低。圣诞老人不知道要派多少晚上礼物才能派完。
2. 检查时机的低效
- 同学A可能在圣诞老人即将放入礼物前的瞬间检查
- 也可能在圣诞老人刚刚放入礼物后的瞬间检查
- 但大多数检查都是在"错误的时间"进行的
3. 对比条件变量的高效等待
// 高效版本:使用条件变量
pthread_mutex_lock(&sock_mutex);
while (sock_has_gift == 0) {pthread_cond_wait(&cond, &sock_mutex); // 释放CPU,进入休眠
}
take_gift();
pthread_mutex_unlock(&sock_mutex);
修正的理解
真正的时间窗口问题不是"抢不到锁",而是:
- 无效工作:在条件不满足时不断检查,浪费CPU周期
- 时机不佳:检查的时间点与条件成立的时间点很难对齐
- 资源竞争:多个线程频繁抢锁,增加系统开销
更准确的时间窗口定义
在并发编程中,"时间窗口"通常指:
在多个相关操作之间,其他线程可能插入执行的空隙
在我们的这个例子中,相关操作序列是:
同学A: 释放锁 → 圣诞老人: 获取锁 → 放入礼物 → 释放锁 → 同学A: 再次获取锁
这个序列中的任何间隙都是"时间窗口",但主要问题不是死锁,而是效率。
类比讲解
这里的其中一个时间窗口就是:同学A: 释放锁 → 圣诞老人: 获取锁,同学A释放锁和老人获取锁之间。这个“其他线程可能插入执行的空隙” 就是同学A又抢到锁。😂大概就是这个意思。
因为我们的理想状态就是同学A释放锁之后,老人抢到锁,然后往袜子里放礼物,但是这个时候同学A又抢到锁。这就打乱了我们原本的逻辑节奏。
这个时间窗口越大,可能出现的岔子就越多,程序就越容易出问题,导致程序不按我们设想的路径行进。由于我们这里是单消费者单生产者模型,可能大家比较难以体会。
但是到了多消费者多生产者,或者多消费者,多生产者这些情况,这个问题就会凸显出来。
类比我们现实生活中:第一个操作是你吃饭。第二个操作是你吃完饭想去上厕所。这个时间窗口就是:你吃完饭走去厕所的时间。时间越长越容易出问题,好比:如厕所位置被抢光了或者有个人突然想找你聊业务……
这些都会打乱你原本的生活节奏,你吃完饭就是应该去厕所的,你憋不行了。但是由于你吃饭的地方离厕所很远,有一段时间窗口,这个时间窗口会发生一切事情,这其中发生的事情有很大的概率会打乱你原本的生活节奏,导致你吃完饭上不了厕所。
所以在程序中我们就要控制这个时间窗口,尽量的减少时间窗口,控制好因为时间窗口而可能出现的意外。
同样的在我们生活中,如果你想顺利的吃完饭就可以马上拉屎,可以在厕所吃饭。
小补充是:在程序中,我们通过原子操作和同步原语来消除时间窗口,而不是真的"在厕所吃饭" 😄
技术角度的精确化:
- 时间窗口的类型
在上面的例子中,实际上有两个重要的时间窗口:
窗口1(上面提到的):
同学A释放锁 → 圣诞老人获取锁
- 问题:同学A可能立即重新抢到锁
窗口2(更关键):
圣诞老人释放锁 → 同学A检查条件
- 问题:礼物放入后不能立即被发现
- 条件变量的解决方案
条件变量实际上消除了这两个时间窗口:
// 条件变量内部做了这些事:
1. 释放锁
2. 进入等待队列
3. 等待信号
4. 收到信号后重新获取锁
5. 继续执行
这个过程是原子的,没有时间窗口让其他线程插入破坏逻辑。
总结
在这个具体例子中:
- ✅ 圣诞老人最终会获得锁并放入礼物
- ❌ 但效率极低,因为同学A在盲目循环检查
- ❌ CPU资源浪费在无用的锁操作和条件检查上
- ❌ 响应延迟:礼物放入后不能立即被发现
更精确的时间窗口分析
正确的使用模式
等待方(同学a)的模式:
pthread_mutex_lock(&mutex);
while (condition_is_false) { // 必须用while,不能用ifpthread_cond_wait(&cond, &mutex);
}
// 条件满足,处理事件
pthread_mutex_unlock(&mutex);
通知方(圣诞老人)的模式:
pthread_mutex_lock(&mutex);
// 改变条件
make_condition_true();
pthread_cond_signal(&cond); // 或 broadcast
pthread_mutex_unlock(&mutex);
为什么必须用while循环检查条件?
真正的竞态条件发生在锁被释放后
// 问题代码
pthread_mutex_lock(&mutex);
if (condition == false) { // ① 检查条件(持有锁,安全)// ② 调用wait()的瞬间:释放锁 + 进入等待pthread_cond_wait(&cond, &mutex); // 这是一个原子操作!
}
// 处理事件
pthread_mutex_unlock(&mutex);
实际上,pthread_cond_wait() 本身是原子的 - 它内部会原子性地释放锁并进入等待状态。
那么,为什么还需要while循环?
问题不在于"检查"和"等待"之间,而在于被唤醒后的情况!
场景分析:多个消费者同时竞争(重点是同时)
假设有2个消费者线程(C1, C2)和1个生产者线程§:
// 共享资源
int available_items = 0; // 可用的物品数量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
使用if的错误情况:
梳理代码逻辑:
while(1)
{// 问题代码pthread_mutex_lock(&mutex);if (condition == false) { // ① 检查条件(持有锁,安全)// ② 调用wait()的瞬间:释放锁 + 进入等待pthread_cond_wait(&cond, &mutex); // 这是一个原子操作!}// 处理事件pthread_mutex_unlock(&mutex);
}
程序运行–>
消费者1和消费者2被唤醒抢锁–>消费者1抢到锁–>
判断if (condition == false)–>执行pthread_cond_wait(&cond, &mutex);(释放锁并进入等待)–>
消费者2抢到锁–>判断if (condition == false)–>执行pthread_cond_wait(&cond, &mutex);(释放锁并进入等待)–>
生产者抢到锁–>判断if (condition == false)–>生产一个物品–>执行broadcast()唤醒所有消费者–>释放锁–>
消费者1抢到锁–>(由于在上一次唤醒的时候已经使用if判断过一次条件了)所以直接执行消费物品这个行为(这里没出问题主要是因为恰好有一个物品可以消费)–>释放锁–>
消费者2抢到锁–>由于在上一次唤醒的时候已经使用if判断过一次条件了)所以直接执行消费物品这个行为(但是唯一的物品已经被消费者1消费了,导致消费者2产生了错误的消费,出现未定义行为)
注意:
有的同学会疑惑:
为什么消费者1和消费者2先被唤醒去抢锁呢?
为什么是消费者1和2先抢到锁呢?
为什么不是生产者先抢到锁,然后生产物品呢?
如果是生产者先抢到锁,然后生产好物品就不会有问题了呀?
为什么生产者生产一次只唤醒一个线程呢?(这个问题下面会有专门写)
回答:
第一点:首先,线程被调度的顺序和我们先创建哪一个线程是没有关系的,就算你先创建了生产者线程,但是调度器依旧是有可能先调度消费者线程。所以我说的情况是大概率事件,是会发生的。这种概率事件在工业生产中,一旦发生就是巨大的错误和生产延误。
第二点:就算生产线程先被调度,在第一回会没问题,消费者1正常使用if判断,然后执行消费行为,随后释放锁,接着消费者2抢到锁,正常if条件判断释放锁进入等待队列。
但是第二回呢?生产者又生产了一个物品,这会消费者1又先抢到锁,正常if判断,然后执行消费行为,然后消费者2抢到锁,由于第一回的时候消费者2已经使用if判断过了,从wait返回后,直接执行后续的消费行为------>导致错误消费,出现未定义行为。
使用while的正确情况:
梳理代码逻辑:
while(1)
{pthread_mutex_lock(&mutex);while (condition == false) { // 必须用while,不能用ifpthread_cond_wait(&cond, &mutex);}// 条件满足,处理事件pthread_mutex_unlock(&mutex);
}
程序运行–>
消费者1和消费者2被唤醒抢锁–>
消费者1抢到锁–>判断while (condition == false)–>执行pthread_cond_wait(&cond, &mutex);(释放锁并进入等待)–>
消费者2抢到锁–>判断while (condition == false)–>执行pthread_cond_wait(&cond, &mutex);(释放锁并进入等待)–>
生产者抢到锁–>判断while (condition == false)–>生产一个物品–>执行broadcast()唤醒所有消费者–>释放锁–>
消费者1抢到锁–>
由于是while循环判断,所以从wait()回来的时候,会重新走一遍while循环,重新判断一遍(condition == false)–>
判断到有物品可以消费,所以执行消费物品这个行为–>释放锁–>
消费者2抢到锁–>
由于是while循环判断,所以从wait()回来的时候,会重新走一遍while循环,重新判断一遍(condition == false)–>
判断到没有物品可以消费,所以执行pthread_cond_wait(&cond, &mutex);(释放锁并继续等待)
关键理解:唤醒不代表条件仍然成立
生产者能唤醒消费者,但被唤醒的消费者可能发现条件不再成立。
原因:
- 信号丢失:
pthread_cond_signal()只唤醒当前在等待队列中的线程 - 条件改变:在被唤醒的线程获取锁之前,其他线程可能已经改变了条件
- 虚假唤醒:某些系统会无缘无故地唤醒等待的线程
while循环的核心作用
pthread_mutex_lock(&mutex);
while (condition == false) {pthread_cond_wait(&cond, &mutex);// 当线程从这里被唤醒时:// 1. 它已经重新获得了锁// 2. 它需要重新检查条件是否成立
}
// 只有条件确定成立时,才会执行到这里
process_event();
pthread_mutex_unlock(&mutex);
总结:真正的"时间窗口"
真正的"时间窗口"在这里:
- 生产者发送信号时 → 消费者被唤醒时
- 消费者被唤醒时 → 消费者重新检查条件时
这个时间窗口内,其他线程可能:
- 已经消费了资源(多个消费者)–>相当于改变了条件状态
- 改变了条件状态
- 或者条件本来就不成立(虚假唤醒)
因此,while循环的作用是:确保每次从等待中唤醒后,都重新验证条件是否仍然成立。
这就是为什么条件变量的使用模式必须是:
while (condition_is_false) {pthread_cond_wait(&cond, &mutex);
}
而不是:
if (condition_is_false) {pthread_cond_wait(&cond, &mutex);
}
关键理解要点
-
时间窗口 = 在多个操作步骤之间,其他线程可能插入执行的空隙
-
竞态条件的本质 = 程序的正确性依赖于某些操作"恰好"在另一些操作之前或之后执行
-
条件变量的魔法 =
pthread_cond_wait()将"释放锁"和"进入等待"合并为一个原子操作,消除了中间的时间窗口 -
while循环的重要性 = 即使使用了条件变量,仍需要用while来应对:
- 虚假唤醒
- 多个等待线程竞争
- 条件在获取锁的过程中再次改变
这就是为什么在并发编程中,我们总是强调要消除所有可能的时间窗口,确保关键操作要么全部完成,要么全部不完成。
前面我们讨论的场景是多个消费者同时竞争。那么如果生产者一次只唤醒一个消费者呢?情况又是如何呢?还必须使用while循环吗?
生产者生产一次只唤醒一个消费者
生产者调用signal()只唤醒一个消费者,不会同时唤醒两个消费者。
让我重新绘制正确的流程图,展示真实的情况:
真实情况:生产者只唤醒一个消费者
那么,为什么还需要while循环?
既然生产者只唤醒一个消费者,为什么还会出现多个消费者竞争的情况呢?主要有以下几个原因:
1. 使用broadcast()的情况
也就是我们一开始讨论的情况
2. 虚假唤醒(Spurious Wakeup)
3. 多个生产者的情况
总结:为什么while循环是必须的
即使生产者只唤醒一个消费者,while循环仍然是必须的,因为:
broadcast()场景:当需要唤醒所有等待线程时- 虚假唤醒:系统可能无故唤醒等待线程
- 代码健壮性:即使当前只使用
signal(),未来可能改为broadcast() - 平台兼容性:某些平台可能存在虚假唤醒
正确的模式永远是:
pthread_mutex_lock(&mutex);
while (condition == false) {pthread_cond_wait(&cond, &mutex);
}
// 处理事件 - 确保条件成立
pthread_mutex_unlock(&mutex);
补充:条件变量本身需要互斥访问吗?
提问:条件变量本身就是一个共享资源,所以在多线程或者多进程的情况下,得保证互斥访问条件变量。这句话对吗?
这句话不完全正确,需要澄清一个重要概念。
正确答案:条件变量本身不需要互斥访问
实际上,条件变量本身的设计就是线程安全的,你不需要(也不应该)用额外的互斥锁来保护条件变量。
正确的理解
条件变量的标准使用模式
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int shared_data = 0; // 这才是需要保护的共享资源// 等待方
pthread_mutex_lock(&mutex);
while (shared_data == 0) { // 检查共享数据pthread_cond_wait(&cond, &mutex); // 条件变量操作
}
// 使用 shared_data
pthread_mutex_unlock(&mutex);// 通知方
pthread_mutex_lock(&mutex);
shared_data = 1; // 修改共享数据
pthread_cond_signal(&cond); // 条件变量操作
pthread_mutex_unlock(&mutex);
关键区分:保护的是什么?
为什么条件变量本身是线程安全的?
-
条件变量的操作是原子的:
pthread_cond_wait()pthread_cond_signal()pthread_cond_broadcast()
-
互斥锁的作用是保护"条件",而不是保护"条件变量":
- 保护共享数据的读写
- 保护条件判断(
shared_data == 0) - 确保"检查条件"和"进入等待"的原子性
常见误解澄清
错误理解:
“我需要用互斥锁保护条件变量,因为多个线程可能同时调用pthread_cond_signal()”
正确理解:
“条件变量本身的信号操作是线程安全的,多个线程可以安全地同时调用pthread_cond_signal()。互斥锁保护的是与条件变量关联的共享状态。”
实际例子
// 多个生产者线程可以安全地这样做:
void producer() {pthread_mutex_lock(&mutex);buffer[in] = item;in = (in + 1) % SIZE;count++;pthread_cond_signal(&cond); // 多个生产者可以同时调用这个pthread_mutex_unlock(&mutex);
}// 不需要额外的锁来保护cond
总结
- ❌ 错误:条件变量需要互斥访问
- ✅ 正确:条件变量本身是线程安全的
- ✅ 正确:与条件变量关联的共享数据和条件判断需要互斥保护
- ✅ 正确:互斥锁确保"检查条件"和"进入等待"的原子性
所以那句话应该改为:“与条件变量关联的共享状态需要互斥访问,但条件变量本身是线程安全的。”
这个区分很重要,因为错误的理解可能导致不必要的锁嵌套和性能问题。
再次提问:
提问:既然如此,我可不可以把pthread_cond_signal(&cond); 放到pthread_mutex_unlock(&mutex);后面?
// 多个生产者线程可以安全地这样做:
void producer() {pthread_mutex_lock(&mutex);buffer[in] = item;in = (in + 1) % SIZE;count++;pthread_cond_signal(&cond); // 多个生产者可以同时调用这个pthread_mutex_unlock(&mutex);
}// 不需要额外的锁来保护cond
两种写法的对比
写法一:在锁内发信号(推荐)
void producer() {pthread_mutex_lock(&mutex);buffer[in] = item;in = (in + 1) % SIZE;count++;pthread_cond_signal(&cond); // 在锁内发信号pthread_mutex_unlock(&mutex);
}
写法二:在锁外发信号(可能有问题)
void producer() {pthread_mutex_lock(&mutex);buffer[in] = item;in = (in + 1) % SIZE;count++;pthread_mutex_unlock(&mutex);pthread_cond_signal(&cond); // 在锁外发信号
}
两种写法的区别和影响
让我用序列图来展示这两种写法的区别:
写法一:锁内发信号(安全)
写法二:锁外发信号(可能有问题)
关键分析
锁内发信号的优点:
- 保证一致性:消费者被唤醒时,看到的状态与发信号时的状态一致
- 避免竞态条件:在发信号和状态修改之间没有时间窗口
- 标准做法:大多数文档和示例都采用这种方式
锁外发信号的潜在问题:
- 时间窗口:在释放锁和发信号之间,其他线程可能修改了共享状态
- 虚假唤醒:消费者被唤醒时,条件可能已经不再成立
- 性能考虑:如果消费者立即被唤醒但无法立即获得锁,可能造成不必要的上下文切换
什么时候可以在锁外发信号?
在以下情况下,锁外发信号是安全的:
// 情况1:简单的标志位,没有复杂状态
void producer() {pthread_mutex_lock(&mutex);work_available = 1; // 简单标志pthread_mutex_unlock(&mutex);pthread_cond_signal(&cond); // 锁外发信号安全
}// 情况2:使用while循环重新检查条件的消费者
void consumer() {pthread_mutex_lock(&mutex);while (!work_available) { // 使用while,不是ifpthread_cond_wait(&cond, &mutex);}// 处理工作pthread_mutex_unlock(&mutex);
}
结论
推荐做法:在大多数情况下,保持在锁内发信号,这是最安全的方式。
可以例外的情况:
- 共享状态非常简单(如简单的标志位)
- 消费者使用while循环重新检查条件
- 性能分析表明锁外发信号有显著好处
- 你完全理解并接受了相关的竞态条件风险
