假设你经营着一家热闹的食堂。窗口师傅刚炒好一盘菜,可客人还没到齐;大厅里座位有限,后到的人只能站在门口张望。为了让这家食堂高效运转、又不出乱子,你需要两样法宝:一样是“人齐了再开饭”的开饭通知——先到的人耐心等,最后一位进门的人喊一声“开饭”,大家才一起动筷;另一样是门口发号员的座位牌——发牌员手里攥着一摞号码,进一位客人发一张,出去一位收一张,牌子发光了就请来客稍候。这两样法宝,正是并发编程世界里大名鼎鼎的条件变量(Condition Variable)与信号量(Semaphore)。前者协调“等多人的状态变化”,后者控制“限量资源的使用”。这篇教程带你走进这家食堂,把两个并发利器玩得明明白白。
对 AI 从业者而言,多线程编程是模型训练、推理服务、数据管道优化的底层功。理解条件变量与信号量,能帮你调试隐藏的并发 bug、设计高吞吐的数据加载系统,让 GPU 永不空转。
目录
可视化演示
\u5c55\u793a\u8fdb\u7a0b\u5728\u65b0\u5efa\u3001\u5c31\u7eea\u3001\u8fd0\u884c\u3001\u963b\u585e\u3001\u7ec8\u6b62\u4e94\u79cd\u72b6\u6001\u95f4\u7684\u8f6c\u6362\u8fc7\u7a0b\uff0c\u70b9\u51fb\u72b6\u6001\u8282\u70b9\u53ef\u89e6\u53d1\u8f6c\u6362\u3002
本节配有交互动效(进入视口后自动加载,约 3KB)。
\u8fdb\u7a0b\u72b6\u6001\u8f6c\u6362 \u5c31\u7eea\u6001 \u8fd0\u884c\u6001 \u963b\u585e\u6001 PCB \u70b9\u51fb\u8282\u70b9\u89e6\u53d1\u72b6\u6001\u8f6c\u6362
并发危机:没有规矩的食堂开饭
食堂刚开业的第一天,乱成一锅粥。上午十一点,窗口刚端出第一屉包子,二十多位客人“轰”地全冲上去。有人伸手抓、有人挤前面,包子撒了一地,谁也没吃上。你很快发现,问题不在包子不够,而在没有规矩:大家同时涌向一个资源,谁也不让谁。这就是并发编程里最经典的麻烦——竞态条件(Race Condition),多个线程同时读写共享数据,结果取决于谁先谁后,像极了抢包子时“谁的手快谁占便宜”的混乱。
为了解决争抢,你在窗口前划了一条黄线:临界区(Critical Section)——任何时刻只允许一位客人站在线内取餐。保证这条线真正有效的方法,是给窗口装上一道互斥锁(Mutex)闸机:线程进入临界区前必须先“上锁”,离开时“解锁”。闸机同一时间只放一个人过去,原子操作(Atomic Operation)随之诞生——“取包子”这个动作从伸出手到拿到手,必须一气呵成,中间不许任何人插入。
互斥锁解决了“同抢一个窗口”,但食堂的矛盾不止这一种。我们把这套规矩写成形式化条件,理解它就能理解锁的本质:
有了互斥锁,食堂总算不再抢包子了。但你很快发现新问题:有位客人提前一小时就来等糖醋排骨,窗口师傅却不知道他在等,排骨做好后一声不吭放在窗口,等他发现时菜早凉了。锁只能挡人,不能传话——我们需要一种“人齐了喊一嗓子”的机制,这正是条件变量的登场时刻。
- ∀t(任意时刻)把时间当成一条连续轴,这句话对轴上每一个瞬间都成立。
- 指示函数 1括号里的命题为真记 1,为假记 0——某个线程此刻在不在临界区。
- Σ求和把所有线程的指示值加起来。
- ≤1(至多一个)任意时刻最多一个人在线内,黄线规矩被数学锁死。
条件变量:等人的“开饭通知”
你想起家里的规矩:逢年过节,全家都要等人齐了才开饭。先到的人坐在桌边等,不先动筷;最后一个进门的人喊一声“开饭啦!”,所有人这才开始吃。在这个约定里,“等人齐”和“喊开饭”是配套的——等待的人把期待交给一个通知,而这个通知由改变状态的人发出。条件变量(Condition Variable)就是这个“开饭通知”。
条件变量抽象出两个核心动作。等待语义(Wait):线程主动把自己挂起,交出自己的执行权;通知语义(Signal):另一个线程在条件满足时唤醒一个或所有等待者。回到食堂:客人发现“糖醋排骨还没好”时,调用 wait(cv) 坐在桌前等待;厨师做好菜后,调用 notify(cv) 喊一嗓子,客人醒来取菜。但这里有个致命细节:判断“菜没好”和“坐下等待”这两步必须是一个原子动作,不然就会发生惨案——客人刚判断完“菜没好”,还没坐下,厨师就喊了“菜好了!”;结果客人坐下后睡大觉,永远等不到那声已经喊过的通知。这就是著名的通知丢失问题。
条件变量解决它的方式是配合互斥锁使用:把“检查条件 + 挂起等待”放在锁的保护之下。wait 操作不是简单地睡过去,而是先把锁释放、再挂起、被唤醒后再重新抢回锁,三步合为一体。从此,“判断菜没好”和“坐下等通知”之间再没有缝隙:
条件变量还有一桩烦心事:虚假唤醒(Spurious Wakeup)。现实中线程偶尔会无缘无故被唤醒,或者被 notifyAll 一次性吵醒后发现条件其实还没满足。防御方法很简单——把 if 换成 while:醒来后重新检查条件,不满足就继续睡。客人听见有人喊“开饭啦”抬头一看,菜还没上齐,那就继续等着。食堂的等待哲学至此完备:配锁、用 while、等通知。
wait 不是“睡一辈子”,而是“放锁、睡觉、醒后拿锁回来”,三步一气呵成。- 原子块 ⟨…⟩尖括号表示大括号,内部三个操作必须连续执行,不允许插入。
- unlock(m)先释放互斥锁 m,否则睡着的线程会锁死整个临界区。
- sleep进入条件变量的等待队列,线程态变为阻塞,交出 CPU。
- relock(m)被通知唤醒后,重新抢回互斥锁,返回调用者时锁已就位。
信号量机制:门口的“座位牌”
食堂生意越来越好,你又在门口安排了发号员。规则很简单:发号员手里攥着 N 张座位牌,餐厅里有多少个座位就印多少张。新客人进门,发号员发他一张牌,同时把牌堆变薄;客人吃完离开,把牌还给发号员,牌堆变厚。一旦牌堆空了,说明座满,新客人只能在门口站着等。这位发号员,就是信号量(Semaphore)。
信号量的内核是一个计数器(Counter)加一个等待队列。计数器的值永远等于“当前可用资源数”。围绕计数器有两个操作:P 获取操作(P,Proberen,荷兰语“测试”)——如果计数器大于 0 就减一并放行,否则挂起;V 释放操作(V,Verhogen,“增加”)——计数器加一,如果有线程在等待,就唤醒一个。与条件变量不同,信号量自带“计数器 + 队列”这两个部件,不需要再配对互斥锁就能独立完成同步,这是它最大的性格差异。
信号量的美丽之处在于它永远守着一个不变量——计数器不会越界、不会变负。我们把这条规矩写下来:
当 N 等于 1 时,信号量退化为二值信号量(Binary Semaphore),此时“计数器只有 0 和 1”,看起来很像互斥锁。但别被外表迷惑:互斥锁讲究“谁锁的谁开”,而二值信号量可以 A 线程执行 P、B 线程执行 V——这种“跨线程释放”的能力,让信号量的用途比互斥锁更广。发号员手里的牌,既是座位凭证,也是线程之间的接力棒。
- 边界 0 ≤ count ≤ N任意时刻座位牌数量都在 0 到 N 之间,这是信号量的立身之本。
- count(t)时刻 t 的可用资源数,也就是发号员手里的牌数。
- V 操作 +1客人离开,归还一张牌,同时唤醒一个等待者。
- P 操作 −1客人进来,取走一张牌,牌不够就排队。
生产者消费者:炒菜与吃菜的大循环
食堂的终极形态来了:后厨大师傅不停地炒菜,大厅里客人不停地吃菜,中间放一条取餐台。师傅做好一盘就放上去,客人吃完一盘就取走。你很快发现两个矛盾:取餐台放满了,师傅着急——“台子满了没人端,我再炒放哪?”;取餐台空了,客人着急——“台子空了没菜吃,我干等啥?”。这个场景就是并发世界最经典的生产者-消费者问题(Producer-Consumer Problem)。
我们把它抽象成一个模型:生产者是厨师,消费者是客人,有界缓冲区(Bounded Buffer)是能放 N 盘菜的取餐台。规则只有两条:缓冲区满时,生产者必须等待;缓冲区空时,消费者必须等待。用条件变量来实现时,经典做法是准备两个条件变量:一个 not_full 通知“台子有空位”,一个 not_empty 通知“台子有新菜”。为什么是两个而不是一个?因为如果一个条件变量同时给两边用,notifyAll 一次会把厨师和客人全吵醒——厨师醒来看见台子还是满的,客人醒来看见台子还是空的,一半人被白吵醒,这就是“无意义唤醒”。
用信号量也能解决,而且更对称:设置两个信号量 empty(初始为 N,表示空位)和 full(初始为 0,表示菜数)。生产者每次放入一盘菜,先 P(empty) 占用一个空位,再 V(full) 增加一份菜;消费者先 P(full),再 V(empty)。这里的 buffer 空满关系可以用一个公式概括:
食堂的大循环就此跑通:台子满则师傅等通知,台子空则客人等通知;两个条件变量各管一端,默契配合。用信号量的解法,则让空位和菜数互相咬合,像两个齿轮一样精准传动。这套模式,几乎是所有并发系统的万能骨架。
- 总和 N菜数和空位数此消彼长,加起来恒定不变。
- count_items当前台子上菜的盘数,由 V(full) 增加、P(full) 减少。
- count_spaces当前空位数,由 V(empty) 增加、P(empty) 减少。
- 0 ≤ items ≤ N菜数下不越零、上不超容量,饥饿和溢出都进不来。
死锁与饥饿:互相等锅的两位厨师
食堂里还有一幕噩梦。两位厨师大李和老张,大李拿着炒锅等着要蒸笼,老张拿着蒸笼等着要炒锅。大李说“你把蒸笼给我,我就把锅给你”,老张说“你把锅给我,我就把蒸笼给你”。两人谁也不肯先松手,就这么干瞪眼到天黑。这就是死锁(Deadlock):每个线程都持有一个资源,同时等待对方手里的资源,谁也不释放,形成一个僵局。
死锁不是玄学,它需要四个条件同时成立:互斥(资源不能共享)、占有并等待(拿着一个还要另一个)、非抢占(资源不能被硬抢)、循环等待(你等我、我等你)。破坏任何一个条件,死锁就破解。经典的哲学家就餐问题把这个困局摆在圆桌上:五位哲学家围坐,每人左手拿一根筷子,还需要右手边那根才能吃饭;结果五个人同时拿起左手筷,都在等右手筷——全员饿死。破解之道五花八门:让一个人先放下筷子、规定拿筷顺序、用信号量限制最多四人同桌……核心思想是打破“循环等待”。
信号量世界里还有个隐藏陷阱:信号量陷阱(Semaphore Trap)。两个信号量 A、B,线程 1 执行“P(A)、P(B)“,线程 2 执行”P(B)、P(A)”——双双卡死在半路上。这就是把互斥锁顺序用错造成的死锁。而饥饿(Starvation)比死锁更隐蔽:某个线程一直得不到资源,不是因为死锁,而是因为别的线程总是抢先。发号员每次都优先放行熟人,有位客人等了三小时还没进店。信号量的等待队列通常不保证公平,急需要处理饥饿问题时,可以用带公平队列的信号量实现,或者给等待加超时机制。
死锁和饥饿的根源,都在于“等等看”变成了“永远等”。食堂的教训是:拿资源的顺序要统一,等待要有耐心更要有时限。统一顺序 + 超时重试 + 公平调度,三管齐下,才能让所有客人都吃上饭。
选型权衡:什么时候用哪样法宝
食堂开久了,你终于摸清两样法宝的脾气。条件变量像“家庭聚餐约定”:它本身不存任何资源数量,只是让人等一个状态——“菜好了吗?”“人齐了吗?”它需要外部配一把互斥锁,把条件的检查和等待捆绑成原子操作。信号量则像“门口的座位牌”:它自身就是资源计数器,不依赖外部的锁,天然就能表达“最多允许 N 个”的限额。
从性能上说,条件变量更轻:它就是“等待 + 唤醒”两个动作,没有计数器增减的开销。信号量则自带原子计数,每次 P/V 都要操作计数器,略重一点。但信号量的好处是语义简洁,一行 P(sem) 就把“占一个名额”表达清楚了,省去手动配锁的麻烦。调试难度上,条件变量更直观——你能清楚看到谁在等、谁被唤醒;信号量的问题在于它的状态藏在一个计数器里,你只看到 count=3,却不知道这 3 背后是“3 个客人坐着”还是“1 个客人坐了 3 个位子”。我们从语义上给它们画一条清晰的分界线:
使用场景上,一句话总结:状态同步用条件变量,资源限额用信号量。因为条件变量不记录数量,所以它特别适合“等待某个布尔条件成立”;因为信号量自带计数器,所以它天然是限流器、连接池、令牌桶的底层实现。在系统设计中,成熟团队往往组合使用:互斥锁管临界区、条件变量管状态唤醒、信号量管资源配额——三兄弟各有分工,食堂才能既有序又高效。记住:没有银弹,只有最合适的工具。
- state ∈ {WAIT, READY}条件变量只关心两种状态——等待中或就绪。
- wait 状态线程在条件变量上挂起,等别人通知。
- ready 状态条件满足,线程被唤醒准备执行。
- count ∈ [0,N]信号量记的是一个连续整数区间,表达资源数量。
AI 应用场景
AI 训练 DataLoader 数据管道
阶段:数据准备与加载
解决的问题:GPU 训练速度极快,磁盘读取跟不上,GPU 常常饿着肚子等数据。传统同步加载方式让 GPU 利用率掉到 40% 以下,AI 工程师急需一个“厨师不停炒菜、客人随时有菜”的管道。
具体案例:PyTorch 的 DataLoader 开启 num_workers=8 时,本质就是启动多个生产者线程从磁盘读图片、做增强,塞进一个有界缓冲队列;GPU 主进程作为消费者从队列取 batch。Queue 内部正是用条件变量 + 互斥锁实现:队列满时生产者 wait,队列空时消费者 wait。调整 prefetch_factor 就是在调整取餐台的大小,让它刚好吞下磁盘突发速度又不撑爆内存。
大模型推理服务并发限流
阶段:模型推理与部署
解决的问题:大模型服务并发请求过高时,GPU 显存或算力会被打爆,导致请求排队、超时雪崩。传统做法是硬编码最大并发数,但无法精确控制“同时正在推理的请求数”。
具体案例:某 AI 平台用信号量实现令牌桶限流:初始化信号量 count=显存能容纳的最大并发 batch 数。每个推理请求进来先执行 P(S),占一个“GPU 席位”;推理完成执行 V(S) 归还席位。当并发达到上限时,新请求自动排队等待——这正是食堂发号员的数字化版本。Hugging Face 的 Text Generation Inference、vLLM 的请求调度都采用类似思路。
多卡分布式训练同步屏障
阶段:分布式训练
解决的问题:数据并行训练中,各 GPU 算完一轮梯度后必须“人齐了”才能做全局梯度同步,否则参数更新会不一致。传统 Barrier 用 busy-wait 轮询,白白烧掉 CPU 和功耗。
具体案例:在 PyTorch DDP 的 AllReduce 前,每张卡完成梯度计算后到达一个“集合点”,调用 dist.barrier() 等待。这个屏障底层就是条件变量+互斥锁:每张卡到达后 count+1,当 count 等于世界大小时,由最后到达的卡 notify_all 唤醒所有人,大家同时进入 AllReduce。发牌员手里的“名额”就是参与同步的卡数量,人齐了才放行。
Python 演示
import threadingimport timeimport random # 全局共享的有界缓冲区BUFFER_SIZE = 5buffer = []cond = threading.Condition() def producer(name, total): """生产者线程:把数据放入缓冲区(相当于厨师往取餐台放菜)""" for i in range(total): with cond: while len(buffer) >= BUFFER_SIZE: print(f" 缓冲区满了,{name} 等待...") cond.wait() # 台子放满,厨师挂起 buffer.append(i) print(f"{name} 生产了 {i},缓冲区 {len(buffer)}/{BUFFER_SIZE}") cond.notify() # 通知等待的消费者 time.sleep(random.uniform(0.1, 0.5)) def consumer(name, total): """消费者线程:从缓冲区取出数据(相当于客人从取餐台取菜)""" for _ in range(total): with cond: while len(buffer) == 0: print(f" 缓冲区空了,{name} 等待...") cond.wait() # 台子空了,客人挂起 item = buffer.pop(0) print(f"{name} 消费了 {item},缓冲区 {len(buffer)}/{BUFFER_SIZE}") cond.notify() # 通知等待的生产者 time.sleep(random.uniform(0.1, 0.5)) if __name__ == "__main__": threads = [] threads.append(threading.Thread(target=producer, args=("厨师A", 10))) threads.append(threading.Thread(target=producer, args=("厨师B", 10))) threads.append(threading.Thread(target=consumer, args=("客人X", 10))) threads.append(threading.Thread(target=consumer, args=("客人Y", 10))) for t in threads: t.start() for t in threads: t.join() print("全部生产与消费完成!")
wait/notify 的配合与 while 防虚假唤醒的标准写法。buffer = []共享缓冲区,在 Python 中由threading.Condition内部锁保护,保证所有操作互斥。with cond:进入临界区:自动获取条件变量关联的互斥锁,离开时自动释放,配锁动作零遗漏。while len(buffer) >= BUFFER_SIZE:缓冲区满则循环等待。用while而非if,醒来后重新检查条件,防虚假唤醒。cond.wait()原子地“释放锁 + 挂起线程”,被notify唤醒后重新抢回锁,与公式中的三步操作一一对应。cond.notify()唤醒一个等待的线程。这里用notify而非notify_all,每次只需唤醒对端一个,避免“无意义群吵”。buffer.pop(0)消费者取出最早放入的数据,模拟“先到先吃”。time.sleep(random.uniform(0.1, 0.5))模拟真实场景中生产/消费耗时的波动,让线程交错更自然。
常见误区
误区“想停 3 秒就让线程 `wait` 3 秒,条件变量不就是增强版 sleep 吗?”
正解条件变量和 sleep 完全不是一回事。sleep 是“纯计时器”,到点自动醒,不依赖任何外部状态;条件变量是“状态通知器”,它等待的是一个外部条件——“缓冲区有货吗?”“人齐了吗?”。唤醒者是另一个线程,而不是时钟。类比:睡觉的人定闹钟靠自己醒;在食堂等“开饭通知”的人必须等别人喊一嗓子。用条件变量做定时,就像在食堂等一个永远不存在的开饭通知,线程会一直睡下去。
为何常见:新手第一次接触 wait 时,看到线程“暂停了”,很容易把它和 sleep 画等号。只有深入理解“谁唤醒我”之后才能分清:计时器唤醒靠时间,条件变量唤醒靠信号。
误区“信号量初始值设成 10,那 10 个线程就可以同时写同一个文件了吧?信号量已经帮我做好互斥了。”
正解信号量控制的是“同时进入的线程数量上限”,它不区分读写。10 个线程同时写同一个文件照样会把数据写花。信号量更像是“限流器”而不是“读写保护锁”。正确的理解是:信号量负责“最多 N 个人进去”,至于进去的人会不会互相打架,那是互斥锁的事。食堂发号员只控制“店里最多坐多少人”,不管客人之间是否争抢同一道菜。
为何常见:把“计数”和“互斥”混为一谈。二值信号量看起来像互斥锁,让人误以为所有信号量都有互斥能力。实际上信号量的本质只是“计数器 + 排队器”。
误区“我用 `if` 判断条件后调用 `wait`,只要 `notify` 被调用,醒来时条件必然满足,直接往下走就行。”
正解这是最危险的一个误区。现实中存在虚假唤醒(spurious wakeup)——线程可能被操作系统错误地唤醒,或者被 notifyAll 吵醒后发现条件早被别人抢走了。标准写法永远是 while (条件不满足) wait();,醒来后重新检查,不满足就继续睡。食堂里有人说“开饭啦”,你抬头一看菜还没上齐,那就继续等,这才是正确的生存之道。
为何常见:教科书上画唤醒时序图时,总是“一唤一醒、条件刚好满足”的理想图景。真实世界中多线程竞争激烈,等唤醒后条件早已被其他线程改变,必须用 while 兜底。
误区“`pthread_cond_t` 里不是有锁吗?我 `cond_wait` 的时候它自动帮我把临界区保护好了,不用再单独建 mutex。”
正解条件变量不包含互斥锁,它只是一个“等待队列 + 信号”的载体。wait 操作需要一个外部的互斥锁参数,目的是让“检查条件”和“挂起等待”成为原子操作,防止通知丢失。锁是闸机,条件变量是传声筒,两者是不同的工具。正确的模型是:while (条件不满足) { CondWait(cv, mutex); }——闸机和传声筒配合使用,谁也不能替代谁。
为何常见:各种 API 把 wait(cv, mutex) 写成“传入两个参数”,让人误以为 mutex 是 cv 的一部分。其实传参恰恰说明它们是两个独立对象,只是需要临时合作。
误区“信号量自带计数,不需要配对互斥锁,比条件变量简单又安全,我全用信号量。”
正解信号量确实是“独立同步原语”,但这不代表它无副作用。信号量的 P/V 操作本身是原子的,但它保护的数据并不自动安全。用它保护共享数据时,你仍要自己保证“检查-修改”的互斥。更麻烦的是,信号量的 count 隐藏了等待者的身份、等待的原因,出了问题极难排查。条件变量搭配锁,结构一目了然;信号量的隐式状态,往往让 bug 藏得极深。食堂发号员知道“店里满了”,但他不知道满座是因为菜难吃大家赖着不走,还是上菜太慢——这就是隐式状态的代价。
为何常见:看到“信号量不需要锁”就以为它免除了所有并发责任。实际上它只是把互斥责任从“锁”转移到了“使用者的纪律”,错误使用信号量(如 P/V 不成对)比条件变量更容易出隐蔽的死锁。
面试题
Q1. 条件变量的 wait 操作必须传入一个互斥锁,最主要的原因是?
- A.防止多个线程同时调用 wait 造成栈溢出
- B.让“检查条件”和“挂起等待”成为原子操作,防止通知丢失← 正确答案
- C.让 wait 的线程优先获得 CPU
- D.为了在唤醒时自动释放锁,省去手动解锁
答案:B。wait 传入互斥锁的核心目的,是把“判断条件不满足”和“线程挂起”两步捆成原子操作。若中间有缝隙,另一个线程可能先发出通知再让本线程睡下,造成“通知丢失”——客人在睡下前漏掉了“开饭啦”的喊声。选项 C、D 是额外副作用而非目的,A 与问题无关。这就像食堂里“看菜没好”和“坐下等通知”必须一气呵成,中间不能眨眼。
Q2. 信号量执行 P 操作时,如果计数器为 0,线程会发生什么?
- A.继续往下执行并返回错误码
- B.挂起等待,直到其他线程执行 V 操作← 正确答案
- C.自旋空转,反复检查计数器
- D.自动销毁信号量避免死锁
答案:B。P 操作(获取)在计数为 0 时表示资源耗尽,线程进入等待队列挂起;当别的线程执行 V 操作释放资源时,计数器加一并唤醒一个等待者。这就是“座位牌发光了,新客人只能站在门口等”。选项 A 混淆了“非阻塞 try_lock”与阻塞 P;C 是自旋锁的行为而非信号量;D 更是无稽之谈。
Q3. 下列哪些做法能有效应对条件变量的“虚假唤醒”问题?
- A.用 while 循环重新检查条件,不满足就继续 wait← 正确答案
- B.完全去掉互斥锁,让条件变量独立使用
- C.配合使用 notify_all 减少信号丢失的影响← 正确答案
- D.用信号量完全替代条件变量,永不用 wait
答案:AC。防虚假唤醒的标准手段是 while 循环——被唤醒后重新检查谓词,不满足则继续等,这是核心防御。notify_all 虽不能阻止虚假唤醒本身,但能减少“通知丢失”导致的长时间无谓等待,配合 while 使用更加健壮。B 去掉锁会引发竞态和通知丢失,D 是错误认知——信号量同样存在唤醒后条件变化的场景。
Q4. 条件变量的 notify(signal)操作的行为是?
- A.唤醒一个正在等待该条件变量的线程← 正确答案
- B.唤醒所有正在等待该条件变量的线程
- C.销毁条件变量并释放所有等待者
- D.给条件变量加锁,防止并发访问
答案:A。notify(或 signal)语义是唤醒一个等待线程;唤醒所有等待线程的操作叫 notify_all(broadcast)。一个通知一个,像有人喊“轮到下一位进场”;通知所有是“所有等待的人都可以进来了”。B 是 notify_all 的功能,C 是销毁操作,D 是互斥锁的职责,与条件变量本身无关。
Q5. 二值信号量与互斥锁的最大区别是?
- A.二值信号量更快,互斥锁更慢
- B.信号量可被任何线程执行 V 释放,互斥锁要求持锁者本人解锁← 正确答案
- C.互斥锁的计数可以大于 1,信号量只能等于 1
- D.信号量不能阻塞线程,互斥锁可以
答案:B。二值信号量的计数只能是 0 或 1,但它的 P/V 操作可以来自不同线程——A 线程执行 P 获取,B 线程执行 V 释放,这是“跨线程接力”能力;互斥锁则规定“谁锁的谁解开”,强制所有者在同一上下文。这种差异让信号量能表达线程间同步(如“线程 B 等线程 A 完成”),而互斥锁只能表达互斥。B 正确,A、C、D 均错误。
Q6. 死锁的四个必要条件包括哪些?
- A.互斥:资源不能同时共享← 正确答案
- B.占有并等待:线程持有一个资源还等另一个← 正确答案
- C.非抢占:资源不能被强行抢走← 正确答案
- D.循环等待:每个线程等下一个线程的资源← 正确答案
答案:ABCD。死锁四条件缺一不可:互斥(座位一次只能坐一人)、占有并等待(大李拿着炒锅还要蒸笼)、非抢占(老张不能硬抢大李的锅)、循环等待(大李等老张、老张等大李)。四者同时满足才形成死锁,打破任何一个就能拆解僵局。这就像两位厨师互相等对方的炊具,任何一人先松手,厨房就能重新运转。
Q7. 用条件变量实现生产者-消费者时,为什么通常使用两个条件变量而不是一个?
- A.两个条件变量可以让缓冲区扩得更大
- B.避免 notify_all 时生产者和消费者互相无效唤醒,造成“群吵”← 正确答案
- C.两个条件变量可以分别加不同锁,提高并发度
- D.一个条件变量无法实现阻塞功能
答案:B。若共用一个条件变量,notify_all 会同时唤醒生产者和消费者。但缓冲区满时只有消费者能干活、缓冲区空时只有生产者能干活,结果每次唤醒都有一半线程醒来后条件不满足,重新休眠——白白浪费大量上下文切换。用 not_full 和 not_empty 两个条件变量,生产者只等“有空位”,消费者只等“有新菜”,各唤各的人,互不干扰。这就像食堂里“提醒厨师加菜”和“通知客人取餐”用两个不同的喇叭,谁也别吵醒谁。
Q8. 一个信号量初始值为 5,运行一段时间后计数值为 3,这表示?
- A.有 3 个线程正在等待这个信号量
- B.当前还有 3 个可用资源,已有 2 个被占用← 正确答案
- C.信号量已经损坏,需要重新初始化
- D.有 3 个线程已经获得了资源且不再释放
答案:B。信号量计数值恒等于“剩余可用资源数”。初始 5 表示 5 个座位全空;计数值降到 3,说明有 2 个资源被占用(2 个客人入座),还剩 3 个空座。A 描述的是等待队列长度,与计数值无关;D 把占用当等待,完全反了。记住:发号员手里的牌数,永远是当前能发出去的名额数。
Q9. 下列哪些场景更适合用信号量而非条件变量?
- A.管理一个连接池,限制最大 10 个并发连接← 正确答案
- B.实现多读单写场景下的读者-写者保护
- C.搭建 API 网关的令牌桶限流器← 正确答案
- D.通知一个线程“初始化已完成,可以开始工作”
答案:AC。信号量的核心优势是“自带资源计数器”,适合任何“上限 N 个名额”的控制:连接池(A)最多 10 个连接、令牌桶(C)每秒最多放行 N 个请求,都是典型信号量场景。B 的读者-写者保护需要区分读写优先级和读者并发,应使用读写锁或互斥锁+条件变量。D 是纯粹的状态通知,条件变量是更贴切的选择。发号员的“座位牌”管的是数量,不管“谁在等、等什么”。
Q10. 条件变量 wait 执行期间,与其关联的互斥锁处于什么状态?
- A.继续被当前线程持有,以保护条件变量不被别人动
- B.被原子地释放,线程睡眠;被唤醒后重新抢回锁再返回← 正确答案
- C.被永久释放,线程醒来后需要手动重新加锁
- D.由操作系统随机决定保持或释放
答案:B。wait(cv, mutex) 是三条原子指令:先释放 mutex(防止睡着的线程锁死整个临界区)、然后睡眠挂起、被唤醒后重新竞争 mutex,抢到后才返回调用者。整个过程对程序员透明,所以“with cond“包裹的代码块在 wait 期间看起来”锁被松开了、醒来又锁上了“。选项 C 的错误在于”看似需要手动加锁“——实际上 wait 返回时锁已重新持有,手动加锁反而造成死锁。这对应食堂场景:客人等通知时先把座位让出来,听到”开饭”后再回座位——让位和回座都是自动完成的。