1. 一个"看着简单"的经典同步问题,为什么能卡住大部分人
如果你在操作系统课上第一次听到理发师问题,多半会觉得它没什么难度——不就是把顾客和理发师用信号量串起来嘛。可真到了要动手把它写成一个能跑的进程模型时,很多人会卡在同一类地方:顾客明明看到有空座位,理发师却继续"睡觉";或者等候区还没坐满,程序却莫名卡死。这个问题在每学期的进程同步章节里反复出现,不是因为概念多复杂,而是因为它把"进程""PV操作""唤醒与阻塞"这几个词背后的时序细节,全压缩进了一个极小的场景里。
先把场景摆清楚,这也是所有推导的地基。一间理发店,配置固定:1 把理发椅、1 位理发师、N 把供顾客等候的椅子(N 通常大于 0)。规则有几条,每一条都对应着程序里的一段逻辑:没有顾客时,理发师就坐在椅子上睡觉;顾客进门时,如果等候椅还有空位,他就坐下等待,同时如果发现理发师在睡觉,要负责把他叫醒;如果等候椅已经坐满,这位顾客只能直接走人,不能站在店里干等;理发师每次只服务一位顾客,服务完顺手把这位顾客从等候区"移走",再看还有没有下一位。
这个描述里的每一句话,在并发代码里都不是"自然而然"成立的。比如"把理发师叫醒"是一个跨进程的动作,顾客进程必须想办法通知理发师进程从阻塞中恢复;"等候椅还有没有空位"是一个共享状态的判断,必须有互斥保护,否则两个顾客同时看到最后一个空位,就会发生计数错误。所以理发师问题的真正看点,是它同时包含了互斥(多个顾客/理发师竞争访问等候区计数)和同步(顾客与理发师之间的等待与唤醒)两种机制,而且这两种机制用同一套 PV 操作表达出来。
适合读这篇内容的人,大致有三类:正在啃操作系统进程同步这一章的学生;准备考试、被"写出理发师问题的 PV 操作"这道题反复折磨的备考者;以及工作里偶尔要写点并发控制、想把信号量那套思维用顺手的人。不管你是哪一类,我建议都别停留在"能背出那几行伪代码"的层面,因为真正决定你答对还是答错的,往往是某两个 V 操作的先后顺序——而这恰恰是背不出来的东西。
1.1 三个角色、两种等待,别把它们混成一锅
在写任何代码之前,先把场景里的角色和他们的状态列清楚,这一步能省掉后面一大半的调试时间。角色其实只有三类:顾客进程(可能有很多个)、理发师进程(通常只有一个)、以及被它们共享的那几个变量(等候区的计数、几个信号量)。
"等待"在这个场景里有两种,性质完全不同,很多人一上来就把它们混为一谈。第一种是理发师等待顾客:理发师无事可做,主动进入阻塞状态,直到有顾客到来才被唤醒,这是一种"服务方等资源"的等待。第二种是顾客等待理发师:顾客已经坐进等候区或理发椅,但理发师还在忙,他必须等理发师空出手来,这是一种"请求方等响应"的等待。这两种等待的触发条件和唤醒条件正好是交叉的,一个用 P 阻塞、另一个用 V 唤醒,方向一旦搞反,程序要么全体睡死,要么空转。
还有一点容易被忽略:顾客"走人"这个动作是没有等待的。他判断等候区满了,直接离开,不进入任何阻塞队列。这意味着顾客进程里存在一个"提前退出"的分支,这个分支同样要释放已经拿到的互斥锁,否则下一个顾客会永远卡在锁上。你看,光是角色和状态这一层,就已经藏了好几个坑点。
1.2 为什么"先判断再行动"的直觉写法一定出问题
把理发店规则直接翻译成自然语言式的代码,很多人的第一反应是这样:顾客进店,看一眼等候区,"如果没满就坐下,顺便叫醒理发师;如果满了就走"。理发师则"循环:如果没顾客就睡,有顾客就理发"。这段逻辑在单线程的世界里完美无缺,但在并发世界里它是错的,原因就藏在"看一眼"这个动作里。
"看一眼等候区"和"坐下"这两个动作,之间必须没有其他进程能插进来修改等候区。假设现在的空位数是 1,顾客 A 和顾客 B 几乎同时执行这段逻辑:A 看到有空位,还没来得及把计数加一,B 也看到了那个"同样"存在的空位,于是两个人都以为自己有位置,计数就被写成了不该有的值,或者等候区里被塞进了超额的顾客。这就是典型的竞态条件,也是为什么要引入一个互斥信号量来保护那段"判断 + 修改"的临界区。
更隐蔽的问题出在"唤醒"上。理发师睡觉,本质上是阻塞在一个信号量上;顾客要唤醒他,必须对正确的信号量做 V 操作。如果你让顾客在没真正占到座位的情况下就去唤醒理发师,那理发师醒来会面对一个空荡荡的等候区,直接导致后续的计数和等待错乱。想清楚这一点,你就会明白:唤醒动作必须和"确实占到一个位置"这件事绑定在一起,并且发生在临界区保护之下。这也是接下来所有推导的核心线索。
2. 把理发店场景翻译成信号量:谁该被唤醒,谁该去睡眠
从生活规则到信号量代码,中间的桥梁是"找共享资源"和"定信号量职责"。很多人写完代码自己都说不清每个信号量到底代表什么,于是遇到问题只能瞎改。我习惯的做法是:先把所有需要保护的共享变量列出来,再决定用几个信号量,每个信号量负责"通知哪一方"。
在这个场景里,共享状态其实很少,但每一个都很关键。等候区的顾客数量是一个共享整数,我们用count表示(注意,它统计的是等候区等待的人,不含正在理发椅上那位)。这个变量会被所有顾客读、被顾客和理发师写,所以必须有一个互斥信号量保护它,我们叫它mutex。
除了共享变量,还有两组"通知关系"要用信号量表达:一组是顾客来了要通知理发师"有人等着,别睡了";另一组是理发师准备好了要通知某位顾客"轮到你了,过来坐"。这两组通知方向相反,所以需要两个独立的信号量:customers负责从顾客到理发师的通知,barber负责从理发师到顾客的通知。名字看着朴素,但你只要记住它们的"指向",就不会用错。
2.1 customers 和 barber 这两个信号量各管一件事
customers这个信号量,本质上是一个"待服务顾客的计数器"。顾客成功占到一个等候位置后,对customers做 V 操作,相当于在告诉理发师:"现在至少有一个人等着了,你该醒了。"理发师在循环开头对customers做 P 操作,如果没有顾客,P 操作会把他阻塞,这就是"睡觉";一旦有顾客 V 过,他就能从 P 里返回,相当于"被叫醒"。它的初值应该是 0,因为开店时没有顾客在等。
barber这个信号量,方向正好相反,它表达的是"理发师是否已经招呼你去坐理发椅"。顾客做完V(customers)之后,要对barber做 P 操作,意思是"我已经占好位了,现在等理发师叫我"。理发师每处理完一位顾客、把等候区的人移走之后,对barber做 V 操作,唤醒一位正在等的顾客。它的初值也是 0,因为一开始没有顾客能等到理发师。
这里有个特别容易记混的点:customers是顾客 V、理发师 P;barber是理发师 V、顾客 P。你可以用一句话记住——"谁被唤醒,谁就在那个信号量上 P"。理发师被顾客唤醒,所以理发师在customers上 P;顾客被理发师唤醒,所以顾客在barber上 P。把这句话刻在脑子里,后面写代码时基本不会把 V 和 P 安反。
2.2 mutex 保护的从来不是理发椅,而是 count 这个数
我见过不少人在讲解时会说"用 mutex 保护理发椅",这个说法很误导。理发椅本身在这一版模型里并没有争用——同一时刻只有一个人能坐上去,这是由barber信号量的等待机制保证的,不需要 mutex 额外保护。真正需要保护的,是count这个共享计数,以及围绕它做的"判断是否满员 + 修改计数"这一整段操作。
为什么这段必须原子?回到上一节说的竞态:两位顾客同时判断"是否还有空位",如果判断和加法之间被别人插进来,就可能出现"超员"或者"幻影空位"。把这段放进P(mutex)和V(mutex)之间,就等于给等候区计数上了一把锁,任何时刻只有一个进程能读写它。
还有一处特别容易被忽略:理发师减少count的那段代码,同样要在 mutex 里。因为理发师把一个顾客从等候区移到理发椅,这个动作会改变count,如果不同步保护,顾客读到的就是旧值,可能出现计数对不上的情况。所以mutex是顾客和理发师共同遵守的规则,不是只给顾客用的。
提示:判断
count是否满员这一句,一定要写在P(mutex)之后、V(mutex)之前。任何把判断挪到锁外的写法,都会让整个互斥保护形同虚设。
2.3 从顾客进门到理发椅空出来的完整状态流转
把信号量职责定清楚后,整个流程就可以顺下来了。理发师进程的主循环是:先P(customers)等待顾客(没顾客就睡),醒来后拿mutex,把count减一(这位顾客已经从等候区移到理发椅),再对barber做 V 唤醒那位顾客,释放mutex,然后开始理发;理完继续下一轮循环。
顾客进程则是一个判断分支:进门先P(mutex),如果count小于等候椅数量 N,就说明有空位,把count加一,接着对customers做 V(告诉理发师有人来了),释放mutex,然后P(barber)等待被招呼去理发椅;如果count已经等于 N,说明满了,直接释放mutex然后离开,不做任何等待。
这一进一出的对称结构,是整个问题的骨架。你会注意到,真正被唤醒去"理发"的顾客,不是那个刚进门的顾客,而是理发师从等候区移走的那一位。这一点在很多讲解里被含糊带过,导致有人以为顾客一进门就直接被理发,从而写错了等待位置。理清这个流转,第 3 节的逐行拆解就会顺畅很多。
3. 逐行拆经典代码:每一句 P/V 背后的时序推演
前面讲的是"应该怎么想",这一节讲"代码到底长什么样,为什么每一句都长这样"。我把信号量初始化和两个进程的代码完整摆出来,然后逐句解释其意图。这里的 P 操作等价于 wait/down,表示申请资源、可能阻塞;V 操作等价于 signal/up,表示释放资源、可能唤醒等待者。
// 共享变量与信号量初始化 semaphore mutex = 1; // 保护 count 的互斥锁 semaphore customers = 0; // 等候的顾客数(理发师等待它) semaphore barber = 0; // 理发师就绪信号(顾客等待它) int count = 0; // 等候区当前人数(不含理发椅上的人) const int CHAIRS = N; // 等候椅数量 // 理发师进程 while (true) { P(customers); // 没有顾客就在这里睡;有顾客则醒来 P(mutex); // 进入临界区 count = count - 1; // 一位顾客离开等候区,坐上理发椅 V(barber); // 招呼这位顾客:轮到你了 V(mutex); // 退出临界区 cut_hair(); // 理发(耗时操作,不占锁) } // 顾客进程(每一个进店的顾客都执行这一段) P(mutex); // 进入临界区,准备检查空位 if (count < CHAIRS) { count = count + 1; // 占到一个等候位 V(customers); // 通知理发师:有顾客在等 V(mutex); // 退出临界区 P(barber); // 等待理发师招呼,去坐理发椅 get_haircut(); } else { V(mutex); // 没位置,释放锁后直接离开 }3.1 理发师主循环为什么必须是"先 P 再抢锁"
理发师循环的第一句是P(customers),而不是先抢mutex。这个顺序不是随意安排的。如果理发师先拿mutex再等顾客,那么当他没有顾客要服务时,会阻塞在P(customers)上却还握着mutex,此时任何顾客进门都无法获得锁去修改count,整个系统直接僵住。所以等待资源的 P 操作必须放在抢互斥锁之前,让它阻塞时手里不持有任何锁。
第二句进来才P(mutex),这时理发师已经确定"确实有顾客存在"了,他需要做的只是把这个顾客从等候区划掉,也就是count = count - 1。紧接着V(barber)唤醒那位等待的顾客,再V(mutex)释放锁。这里V(barber)放在V(mutex)之前是没问题的,因为顾客在barber上的等待和mutex无关,唤醒后顾客会自己去排队拿mutex。
cut_hair()放在临界区之外,是刻意的。理发是个耗时动作,如果把它放进mutex里,那么整个理发过程中没有第二个进程能碰count,等候区的管理就彻底停摆了。耗时的、不需要保护共享数据的操作,一律挪出临界区,这是写并发代码的一条通用准则,不只是理发师问题。
3.2 顾客进程的 if-else 藏着两个完全不同的出口
顾客进程从P(mutex)开始,这段是它的临界区入口。进来第一件事就是判断count < CHAIRS,也就是"还有没有空位"。
走if分支时,顾客占位成功:count加一,V(customers)唤醒理发师,然后释放mutex,最后P(barber)等理发师招呼。注意这里的顺序——先 V(customers) 再 V(mutex),看似无所谓,但其实有讲究。如果先V(mutex)再V(customers),在两者之间理发师可能已经抢到锁、把count减掉了,紧接着才收到customers的通知,逻辑上虽然还能跑通,但时序会变得不那么直观。放在锁内先通知,能保证"占位"和"通知"这两件事对外表现为一个原子动作。
走else分支时,顾客发现满员,唯一要做的是V(mutex)把锁还回去,然后头也不回地离开。这个分支里绝不能出现任何 P 操作,否则这位"本该走人"的顾客会挂在那里,永远不释放,后面所有人都会被拖住。
P(barber)这一句是顾客真正被"服务"的入口。它和理发师的V(barber)一一配对,谁先执行谁后执行都不影响正确性——如果顾客先到,他就在这里等;如果理发师的V先执行,顾客的 P 会直接穿过。信号量的这种"允许先 V 后 P"的性质,正是它能用来做同步的原因。
3.3 用一张时序表把边界情况走一遍
光看代码不够,我建议你拿几种边界情况手动推一遍,尤其是"只有一个顾客""等候区刚好坐满""理发师正在忙时来一串顾客"这三种。下表是我推演"等候椅 N=2,顾客依次到达"时的关键状态。
| 时刻 | 事件 | count | customers | barber | 说明 |
|---|---|---|---|---|---|
| t1 | 顾客 A 进店 | 0→1 | 0→1 | 0 | A 占位并唤醒理发师 |
| t2 | 理发师被唤醒,移走 A | 1→0 | 1→0 | 0→1 | A 得到招呼,坐上理发椅 |
| t3 | 顾客 B 进店 | 0→1 | 0→1 | 0 | B 占位,理发师正在忙 |
| t4 | 顾客 C 进店 | 1→2 | 1→2 | 0 | C 占到第二个位置 |
| t5 | 顾客 D 进店 | 2 | 2 | 0 | D 发现满员,直接离开 |
| t6 | 理发师理完 A,移走 B | 2→1 | 2→1 | 0→1 | B 得到招呼 |
这张表最有价值的地方是 t5 那一行:顾客 D 来的时候count正好等于 N=2,他走的是else分支,什么都没通知就离开了,没有影响任何信号量。很多人写错的地方,就是让 D 也去做了V(customers),结果理发师被唤醒后count却是满的,下一步count = count - 1直接把数字减成了超出实际情况的值,后续全部乱套。只有真正占到位置的顾客,才有资格去唤醒理发师,这句话就是这张表的结论。
4. 真正让人掉坑的三处细节:顺序、位置与初值
信号量的代码往往只有十几行,但只要有一个 P 或 V 放错地方,程序的行为就会从"正确"直接跳到"死锁"或"静默错误"。理发师问题里,出错率高得离谱的就是下面这三类。我把它们和排查方法一起讲,因为它们也是考试和面试里最爱追问的点。
4.1 P 操作顺序一旦写反,死锁立刻出现
最经典的错误是把顾客进程写成"先P(barber)再V(customers)",或者把理发师的两句 P 顺序对调。我们来看理发师这一侧:如果写成先P(mutex)再P(customers),结果就是前面说过的——理发师在没有顾客时会握着锁睡过去,顾客永远拿不到锁去count++,两端互相等待,经典死锁。
判断一段 PV 代码会不会死锁,有个简单的口诀:"资源的 P 放在互斥的 P 之前"。凡是"我在等别人给我东西"的等待,都应该先于"我要抢那个保护共享数据的锁"。理发师等顾客,顾客并不等理发师的锁,所以P(customers)必须在P(mutex)外面、前面。这条口诀同样适用于生产者-消费者等问题,是通用经验。
注意:P 操作顺序错误的死锁,往往不会在程序刚启动时就暴露,而是在某种特定到达顺序下才复现。所以"跑一次没崩"不代表代码是对的,一定要有意构造边界情况去压。
4.2 count 的判断必须待在 mutex 的保护圈里
第二个高频错误,是把"是否满员"的判断搬到P(mutex)之外。有人会觉得,判断又不修改数据,读一下而已,放外面应该没关系。这种想法在并发下是危险的:判断动作读的是count,而count随时可能被别的进程改写。如果你在锁外判断完,进锁后再count++,那么这段时间里count可能已经从 N-1 变成 N,你的"有空位"结论早已失效,结果是等候区被塞进超过 N 个人。
正确的做法是"判断和修改连在一起,整体进临界区"。你可以把它理解成:只要你的逻辑"依赖某个共享变量的值来决定下一步做什么",这个判断就必须和它依赖的那次读取一起被保护起来。这条原则比"保护写操作"更严格,也更接近并发的本质。
排查这个问题有个笨办法但很有效:把临界区里的所有语句都标出来,然后问自己"如果在我判断之后、修改之前,别的进程插进来改了count,会怎样"。如果答案是"会出错",那说明这段必须待在锁里;如果答案是"无所谓",那才可以考虑挪出去。用这个方法扫一遍,绝大多数位置错误都能揪出来。
4.3 信号量初值错一个,程序行为就全变
信号量初值看着是小事,实际上它直接决定了程序在"初始状态"下的行为。理发师问题里,mutex初值是 1(同一时刻只允许一个进程进入临界区),customers初值是 0(开店时没有顾客),barber初值也是 0(没有顾客在等理发师)。这三个初值,改错任何一个都会出事。
假如把customers初值写成 1,那么程序一启动,理发师的第一句P(customers)就能直接穿过,他会"以为"已经有个顾客在等了,于是抢锁、把count减到 -1、V(barber)唤醒一个根本不存在的顾客,然后开始对着空气理发。接着count变成了负数,后续所有判断都错。这种错误的特点是——程序不崩溃,但行为诡异,比直接死锁更难查。
假如把barber初值写成 1,那么第一个顾客在P(barber)时不会等待,他会直接跳过去理发,和理发师的节奏对不上。所以初值这件事,正确的记法不是背数字,而是回到语义:这个信号量在"系统刚启动、所有进程都还没跑"时,应该处于什么状态。mutex初始没有进程持锁,所以是 1;customers初始没有顾客,所以是 0;barber初始没有可招呼的对象,所以是 0。想清楚语义,初值自然就出来了。
5. 从理发师问题抽象出 PV 操作的通用解题框架
理清楚这一个问题之后,我建议你把它往上抽象一层,因为考试或者实际工作中,你会遇到一堆"换了个马甲"的同类问题:图书馆座位、停车场车位、生产者往缓冲区放数据……它们的骨架其实是一样的。掌握了这个骨架,你面对新问题时就不需要从零推导。
5.1 "资源计数 + 唤醒握手"的两段式结构
几乎所有这类同步问题,都可以拆成两块。第一块是资源计数:有一个共享的状态变量,记录"现在还剩多少可用的东西"(空座位、空缓冲区、可读数据),它必须被互斥保护,判断和修改要在同一个临界区里完成。第二块是唤醒握手:等待方和通知方通过一对信号量建立"我等你""我通知你"的关系,等待方 P、通知方 V,方向必须严格对应。
理发师问题里,count和mutex就是资源计数那块;customers和barber这一对就是唤醒握手那块。你以后遇到任何同步题,先问自己:"这里有没有一个共享的计数需要保护?""谁在等谁,通知的方向是从哪到哪?"把这两个问题回答了,代码框架基本就搭起来了。这个方法比死记硬背每种问题的"标准答案"要靠谱得多,因为问题的变体可以无穷多,但骨架就那么几根。
还有一个小技巧:画出"等待图"。把每个进程画成一个节点,如果 A 会等 B 的通知,就从 A 画一条指向 B 的箭头。正确的解法里,箭头应该是"单向"或者有明确的传递顺序的;如果画出来发现出现了环(A 等 B、B 又等 A,且都握着对方需要的锁),那基本就是死锁的信号。这个图不一定画在纸上,脑子里过一遍就够用。
5.2 拿它和生产者-消费者、读者-写者放在一起看
把理发师问题和两个最经典的同步问题对比,能帮你看出共性和差异。生产者-消费者问题里,缓冲区是一个共享的资源池,生产者等"空位"、消费者等"数据",用两个信号量分别表示空位数和数据数,再配一个mutex。你会发现,理发师问题的资源计数块和它几乎是同构的,只是"资源"从缓冲区槽位变成了等候椅。
读者-写者问题的重心则在互斥的粒度上,它要处理的是"多个读者可以同时进,但写者必须独占"这种更细的规则,信号量的用法更偏向"控制并发度"。理发师问题没有这种"多读共存"的需求,所以它的互斥是简单的二元锁。
用一张表对比会更清楚:
| 问题 | 资源计数信号量 | 同步握手信号量 | 互斥信号量 | 核心难点 |
|---|---|---|---|---|
| 理发师问题 | 无独立计数信号量,用 count + mutex | customers、barber | mutex | 唤醒时机与计数一致 |
| 生产者-消费者 | empty、full | 同左 | mutex | 缓冲区空满判断 |
| 读者-写者 | 读者计数 | 写者优先用信号量 | mutex | 并发度控制 |
看这张表你会发现,理发师问题其实把"资源计数"和"互斥"压在了一个mutex里,这也是它常被拿来当考试题的原因——它更考验你对临界区的理解,而不是单纯套信号量。
5.3 常见变体题的破题思路
这类题最常见的变化,是改 N 的值、加一个理发师、或者改成"顾客理完发还要付钱"。面对变体,别急着改代码,先做两件事。第一,确认资源数量有没有变——比如把等候椅从 N 改成 1,那么count的判断上界就是 1,其他不动;如果改成"两个理发师",你就需要重新思考barber这个信号量的语义,因为现在可能同时有两位顾客被招呼。
第二,确认有没有新增的"先后依赖"。如果题目要求"顾客理完发必须付钱后才能离开",那就等于在原来流程后面又接了一段同步,你需要新增一对信号量表达"理发师等付款"和"顾客付款通知",思路和第 5.1 节说的握手完全一致。万变不离其宗,先找新资源,再找新依赖,这是我认为最靠得住的破题顺序。切忌一上来就凭印象改 P/V,那样改出来往往是"看着像但跑不通"。
6. 把代码跑起来:验证互斥与同步的实操方法
纸上推演再多,也不如让程序真跑一遍来得踏实。理发师问题虽然是教学模型,但用一门支持线程和信号量的语言实现出来,能帮你看清很多纸上看不到的时序。下面讲的是一种可复现的实现思路,不绑定具体语言,重点是思路和验证手段。
6.1 用语言级信号量写一个可运行的模拟
在实现层面,你需要三样东西:一个能充当信号量的对象(带 P/wait 和 V/signal)、一组线程/进程、以及一个打印日志的手段。伪代码层面它就是这样:
mutex = Semaphore(1) customers = Semaphore(0) barber = Semaphore(0) count = 0 function barber_thread(): while true: customers.wait() // 没顾客就睡 mutex.wait() count = count - 1 barber.signal() // 招呼顾客 mutex.wait() 替换为 mutex.signal() // 释放锁 log("理发师开始服务") sleep(随机时长) // 模拟理发耗时 log("理发师服务结束") function customer_thread(id): mutex.wait() if count < CHAIRS: count = count + 1 customers.signal() mutex.signal() barber.wait() log("顾客 %d 正在理发" % id) else: mutex.signal() log("顾客 %d 没位置,离开" % id)上面这段伪代码里,我把理发师释放锁那句写成了mutex.signal()(前面标注的地方是笔误提醒,实际就是释放锁)。真正写代码时,注意几个细节:理发耗时用随机 sleep 来模拟,能制造出不同的到达交错;顾客的数量要设得比等候椅多,才能触发"满员离开"的分支;日志里一定要带上时间戳和进程标识,否则并发日志交织在一起会看不懂。
6.2 打印时序日志来验证,而不是靠眼睛盯代码
跑起来之后,最有价值的输出是日志。我建议每条关键动作都打一行日志,格式大致是"时间戳 [进程名] 动作,count=当前值"。跑完之后,把日志按时间排好,逐行检查三件事:第一,count的值有没有始终落在 0 到 N 之间;第二,每一次"理发师开始服务"之前,是不是都有一条对应的"顾客占位";第三,走"离开"分支的顾客,有没有留下任何多余的通知动作。
这三条检查通过,基本可以认为你的互斥和同步是对的。反过来,如果日志里出现count为负数,那多半是初值或者唤醒时机错了;如果出现两个顾客同时打印"正在理发"却没有对应的理发师日志,那就是同步握手出了问题。用日志验证并发程序,比用 IDE 单步调试有效得多,因为单步会破坏真实的时间交错,你看到的执行顺序已经被人为干预了。
为了更有说服力,我还习惯刻意构造几组到达顺序:让 5 个顾客几乎同时来(测满员与竞态),让顾客间隔较均匀地来(测正常流转),让一个顾客在理发师刚开始服务时到达(测边界)。三组都稳定通过,代码才敢说靠谱。
6.3 调试并发问题时我常用的几个手段
最后分享几个我在实际调试这类程序时用着顺手的手段,都是纸面推演给不了的。第一招是降低并发度:把顾客数量设成 2、等候椅设成 1,让所有可能的交错都能被日志穷举出来,先在小规模下确认逻辑,再放大。小规模下死锁更容易定位,因为涉及的交互少。
第二招是在临界区入口和出口加计数器,记录同一时刻进入临界区的进程数。正常情况下这个数最多是 1(因为mutex初值为 1),如果日志里出现 2,那就说明你的互斥根本没生效,可能是某处忘了 P 或者 V 放错了位置。这个手段对排查"静默错误"特别有用。
第三招是故意把耗时操作调长。把理发时间从几毫秒改成几秒,顾客的到达间隔也拉长,那些原本一闪而过的时序问题会被放大成肉眼可见的"卡住"或者"顺序错乱",定位起来容易很多。调完确认逻辑没问题,再把时间改回正常值。
我个人在实现这类问题的体会是,理发师问题的难点从来不在那十几行 PV 代码本身,而在于你有没有真正想清楚"谁在等谁"以及"临界区里到底该放哪几句话"。把这两个问题用日志和边界用例验证过一遍,你就不会再怕它,也不会再被那些换汤不换药的变体题难住。