1. 顾客推门进来那一刻,并发问题就开始了
先把场景摆出来。一家只有一位理发师的店,店里有若干把供等待的椅子。理发师没事做的时候会靠在椅子上打瞌睡,这时候处于阻塞状态,不占用任何计算资源;一旦有顾客推门进来,他必须被唤醒开始干活。如果顾客进门发现理发师正忙、而且等待的椅子已经坐满,那这位顾客只能掉头走人;如果还有空椅子,他就坐下等,等理发师忙完当前这位再叫他。
这个模型在操作系统的进程同步里出现频率极高,也是 PV 操作最经典的练习题之一。它要解决的核心矛盾是:多个进程(顾客进程和理发师进程)共享有限的资源(理发椅、等待座位、理发师本人),彼此之间既要有互斥访问,又要能正确协作与唤醒,任何一处信号量用错,程序不是死锁就是出现顾客白白流失或者多个顾客同时被理发这种反常识结果。
我第一次接触它是在啃进程同步那章的时候,当时觉得这不就是个排队问题么,写出来才发现坑比想象中多得多。这篇文章面向两个群体:正在学操作系统、被 PV 操作绕得头晕的同学,以及写了几年业务代码、但没系统梳理过信号量底层逻辑的开发。看完之后你应该能自己动手把这个模型落地跑通,也能顺手看懂线程池、任务队列里那些似曾相识的同步写法到底在干什么。
梳理下来,整个问题可以拆成三层:第一层是角色与状态,谁阻塞、谁唤醒、谁离开;第二层是信号量与计数变量的设计,几个信号量、初值多少、谁保护谁;第三层是代码落地与验证,怎么把伪代码变成能跑的进程。下面我按这三层往下讲,中间会穿插我踩过的坑。
2. 把角色和状态先钉死,再谈信号量
2.1 三类资源与四种动作
很多同学一上来就写 P、V,写着写着把自己绕晕。我的习惯是先把资源和角色列成一张表,写代码时照着表抄,出错率立刻降下来。
| 资源或角色 | 数量 | 说明 |
|---|---|---|
| 理发师 | 1 | 同一时刻只能服务一位顾客,空闲时休眠 |
| 理发椅 | 1 | 正在被服务的那位顾客占用 |
| 等待座位 | N | 常量,可以由我们设定,N=0 时表示无座即走 |
| 顾客 | 任意多 | 动态到达,到达时间不确定 |
对应的动作一共有四种:顾客到达、顾客等待、顾客离开(因为没座位)、理发师服务。四种动作分布在两个进程里,顾客进程负责到达/等待/离开,理发师进程负责服务。谁也不能越俎代庖去操作对方的私有状态,这就是"进程间不能直接读写对方内存"这条铁律带来的约束。
理解了这条约束,你就明白为什么必须引入信号量:进程之间是隔离的,想让理发师知道"有人来了",只能靠信号量这种内核提供的、能被双方同时操作的同步原语。用一个计数器去传递信息,这是操作系统世界里的通用语言。
2.2 它和生产者消费者为什么容易混
过去的学习笔记里,很多人把理发师问题直接当成生产者消费者问题来写,结果翻车。两者的表面相似度确实高:都是一方生产资源、一方消费资源,都用 P/V 做同步。但它们的语义差别很关键。
生产者消费者里,"缓冲区空位"和"缓冲区数据"是一对对称的信号量,生产者关心空位,消费者关心数据,双方都会主动 V 对方需要的那个信号量。而理发师问题里,顾客"生产"的是自己的服务请求,理发师"消费"这个请求,看似对称,但多了一个绕不开的分支:顾客可能因为没座位而根本进不来。
这个分支是理发师问题的灵魂所在。它意味着"等待的顾客数"必须被单独维护,而且这个计数变量的读写会发生在顾客进程里,多个顾客进程并发读写同一个变量,天然就是临界区,必须加互斥保护。生产者消费者一般不会有这个额外的计数保护需求,这就是为什么很多人直接把生产者消费者的答案套过来会出错。
光是对称性的幻觉,就让不少人在考场上丢了分。认清"多了一个可能被拒绝的到达者",才算真正读懂了题目。
2.3 为什么它值得反复咀嚼
有人会问,一个理发店的故事,值得花这么多篇幅吗?值得。因为它把进程同步里最核心的三件事压缩在了一个极小场景里:互斥访问共享计数、条件等待与唤醒、资源不足时的优雅拒绝。
你现在写的每一个线程池、每一个带限流的工作队列,背后都是这三件事的排列组合。理解了理发师问题,你看到BlockingQueue、Semaphore、Condition这些类名的时候,脑子里会自动浮现出"这是理发师在睡觉"、"这是顾客在等空椅子"的画面,调起 bug 来思路清晰得多。
我个人的体会是,这类经典模型的价值不在答案本身,而在于它逼着你去想"如果这里写反了会发生什么"。把反例想透,比背标准答案有用十倍。
3. 信号量设计:三个信号量、一个计数器,缺一不可
3.1 信号量分工与初值逐个推导
标准方案里通常用三个信号量加一个整型计数变量。名字各家教材叫法不同,我按语义自己命名,方便记忆:
customers:表示"当前有多少个顾客在等待被服务",初值 0。理发师对这个信号量做 P 操作,表示"我要等一位顾客来"。barbers:表示"当前是否有空闲理发师",初值 1,因为一开始理发师是空闲的。顾客对这个信号量做 P 操作,表示"我要等理发师空出来"。mutex:保护等待计数的互斥信号量,初值 1。waiting:整型变量,记录当前坐在等待椅子上的顾客数,初值 0。
初值怎么推?我教一个土办法:把"资源数量"翻译成初值。customers的初值应该是"当前已在等待的顾客数",一开始一个都没有,所以是 0;barbers的初值是"当前空闲的理发师数",一开始理发师闲着,所以是 1;mutex是互斥锁,永远是 1。这套翻译法百试百灵,再也不怕记错初值。
这里有个容易忽略的细节:barbers初值为 1 是"一位理发师"的体现。如果以后扩展成两位理发师,初值是 2,顾客 P 一次就能消耗一个空闲理发师名额,逻辑自然成立。这个可扩展性后面还会讲。
3.2 为什么等待计数必须单独保护
waiting这个变量会被所有顾客进程读到、写。判断"还有没有空椅子"要读它,坐下等人要加它,理发师服务完要减它。多个顾客同时推门进来,如果不加保护,很可能出现两个顾客都读到waiting == N-1,都认为还有最后一个空位,结果两个人同时坐下,实际占用超了。
这种现象在并发里有个专门的说法叫竞态,它不一定每次都能复现,往往在压力大的时候才突然冒出来。我在测试环境下按 10 个并发顾客跑,表面上一切正常;把并发数拉到 100,偶尔就冒出一个座位超额分配。排查了大半天才反应过来是计数变量没锁住。
所以mutex的存在不是为了好看,它是实打实防竞态的。凡是"读—判断—写"这种三步不能被打断的操作,都要用互斥信号量包起来。记住这条判断准则,你写同步代码能少踩一半的坑。
3.3 结构体封装还是全局变量
写作业或者考试,全局变量能过;写工程代码,我建议把这一堆信号量和计数封进一个结构体,模拟现实中的"理发店"对象:
typedef struct { sem_t customers; // 等待的顾客数 sem_t barbers; // 空闲理发师数 sem_t mutex; // 保护 waiting int waiting; // 当前等待人数 int chairs; // 等待座位总数 } barber_shop_t;这样做的理由很直接:状态内聚以后,你可以在不同的测试里创建多个互不干扰的"理发店",每个店有自己的计数器,跑并发测试时不怕串数据。全局变量在单实例场景下够用,但一旦你想验证"两个理发店同时开门"这种扩展场景,全局变量就废了。
封装还带来一个隐性好处:初始化的位置固定了,不会出现某个信号量忘了sem_init就P下去,然后程序卡死还找不到原因。初始化遗漏是新手最常见的死锁来源之一,把它塞进统一的shop_init函数里,一眼就能看出有没有漏。
4. 代码落地:从伪代码到能跑的实现
4.1 理发师进程的完整流程
理发师的行为可以概括成一句话:没顾客就睡,有顾客就干活。翻译成代码:
void *barber(void *arg) { barber_shop_t *s = (barber_shop_t *)arg; while (1) { // 1. 等一位顾客来,没有就阻塞在这里睡觉 sem_wait(&s->customers); // 2. 有顾客了,进临界区把等待人数减一 sem_wait(&s->mutex); s->waiting--; sem_post(&s->mutex); // 3. 告诉某位顾客:理发师空出来了,你可以来理发了 sem_post(&s->barbers); // 4. 理发,用 sleep 模拟耗时 printf("[理发师] 开始理发,当前等待人数=%d\n", s->waiting); usleep(500 * 1000); printf("[理发师] 理发结束\n"); } return NULL; }逐条看。sem_wait(&s->customers)是睡觉的开关,只要没人来,它就卡在这行不动,不消耗 CPU。第二次sem_wait(&s->mutex)才开始动共享状态。注意这里的顺序很重要:先 P 顾客信号量、再 P 互斥量,这个顺序是安全的。
为什么waiting--要放在sem_post(&s->barbers)之前?因为顾客那边在sem_wait(&s->barbers)唤醒之后,会读取一份已经更新过的waiting快照(如果它读的话)。把减操作放在唤醒之前,保证被叫醒的顾客看到的是"我进来之后"的最新状态,逻辑上更干净。这个细节后面在排查章节还会展开。
提示:
sem_wait就是 P 操作,sem_post就是 V 操作。不同教材、不同库里叫法五花八门(P/wait/down,V/signal/post),认准语义就行,别被命名迷惑。
4.2 顾客进程与"没座就走"的判定
顾客的行为稍微复杂一点,因为有分支:
void *customer(void *arg) { barber_shop_t *s = (barber_shop_t *)arg; // 1. 进临界区,检查还有没有空椅子 sem_wait(&s->mutex); if (s->waiting < s->chairs) { // 1a. 有座位,坐下等待 s->waiting++; printf("[顾客] 进店坐下,等待人数=%d\n", s->waiting); // 1b. 通知理发师:来人了 sem_post(&s->customers); // 1c. 离开临界区后再等理发师,避免持锁睡眠 sem_post(&s->mutex); // 1d. 等理发师空出来 sem_wait(&s->barbers); // 1e. 接受服务 printf("[顾客] 正在理发\n"); } else { // 2. 没座位,转身离开 sem_post(&s->mutex); printf("[顾客] 没座位,走了\n"); } return NULL; }这段代码里有两个关键决策点,值得单独拎出来讲。
第一个是sem_post(&s->mutex)必须在sem_wait(&s->barbers)之前执行,也就是"先释放互斥锁,再去睡觉"。如果你把sem_wait(&s->barbers)写在临界区里面,顾客就会抱着mutex这把锁去睡觉。问题是理发师想干活,也需要拿mutex来减waiting,结果就是理发师被这个睡着的顾客挡在门外,顾客又在等理发师叫醒他,双方互相等待,经典的死锁就诞生了。这个坑我在第一次实现时踩得结结实实,程序跑起来一秒卡死,gdb挂上去才看清两个线程都堵在信号量上。
第二个是判断和自增必须在同一个临界区内完成。"看看有没有座位"和"占个座位"这两步中间不能有别的顾客插进来,否则几个顾客会同时判断"还有座",然后一起坐下,超员。这就是前面说的读—判断—写三步不可打断原则的具体体现。
4.3 用互斥锁加条件变量等价实现
信号量是操作系统课上的抽象,真实工程里更常见的是互斥锁配条件变量。两者能做等价转换,用条件变量重写一遍,你对这套逻辑的理解会上一个台阶:
void *barber_cv(void *arg) { barber_shop_t *s = (barber_shop_t *)arg; while (1) { pthread_mutex_lock(&s->lock); // 没顾客就睡,且必须用 while 而不是 if 判断 while (s->waiting == 0) { pthread_cond_wait(&s->cond_customer, &s->lock); } s->waiting--; pthread_mutex_unlock(&s->lock); pthread_mutex_lock(&s->lock); pthread_cond_signal(&s->cond_barber); pthread_mutex_unlock(&s->lock); printf("[理发师] 理发中...\n"); } return NULL; }这里最该记住的是while (s->waiting == 0)这个循环。用if判断在多数情况下也能跑,但存在"虚假唤醒"的风险:条件变量可能在条件并没有真正满足时被唤醒,用while再检查一遍,能保证被叫醒之后条件确实成立。这个技巧几乎是条件变量使用的铁律,写错了在低并发下毫无异常,高并发下偶发诡异 bug,非常难查。
pthread_cond_wait会自动释放锁并在返回前重新拿锁,这是它和信号量sem_wait最大的不同。理解了这一点,你就明白为什么条件变量版本里要反复 lock/unlock,而信号量版本里靠的是信号量本身的原子性。
4.4 跑起来验证:日志、并发数与观察指标
代码写完必须跑,而且要压着跑。我一般用下面的方式搭测试:
gcc -o barber barber.c -lpthread ./barber启动参数里放两个变量:顾客到达的间隔、等待椅子数。我会跑几组不同组合并观察三件事:
- 有没有出现"等待人数小于 0"这种不可能的值,出现就说明计数减多了;
- 有没有超过
chairs的情况,出现就说明临界区没锁住; - 程序有没有卡死不动,出现就是死锁,用
gdb挂上去看每个线程停在哪个信号量上。
实测下来最有效的一组压力参数是把顾客到达间隔调到接近 0、并发顾客数拉到 200 以上、椅子数设成 3 到 5 这种小值,这时候分支切换最频繁,bug 最容易暴露。我把椅子数设成 3 跑两万次,成功复现过一次座位超额分配,那次的根因就是临界区写漏了一个sem_post。
5. 最容易翻车的几个地方,逐条拆雷
5.1 P 操作顺序写反的三种后果
P 操作的顺序不是随便排的,写反了后果迥异,我把几种常见错误整理成表:
| 错误写法 | 现象 | 根因 |
|---|---|---|
顾客持 mutex 去P(barbers) | 立刻死锁 | 理发师需要 mutex 才能减 waiting,被睡着顾客挡住 |
顾客先P(barbers)再判断座位 | 顾客凭空少一个理发师名额 | 没座位就该走,却先占用了理发师信号量 |
理发师先P(mutex)再P(customers) | 大家排队抢锁却没人来 | 理发师空等,顾客进来还得先抢 mutex |
第一行是我亲身踩的坑,卡死之后我盯着代码看了半小时才反应过来是持锁睡眠。第二行更隐蔽,它不会死锁,但会让barbers的计数悄悄错乱,跑久了出现"明明理发师闲着手却没顾客能被服务"的怪象。第三行在单理发师场景下问题不大,但一旦有多个理发师,全体理发师会轮流占着 mutex 空转,效率极差。
判断顺序对不对,有个简单的经验:谁在等谁,谁就先 P 谁。理发师等顾客,所以理发师先 Pcustomers;顾客等理发师,所以顾客等barbers之前不能先做别的事。按这条顺,逻辑基本不会错。
5.2 计数只加不减、只减不加的乱账
waiting这个变量要么在顾客坐下时加,要么在理发师服务时减,两边必须严格配对。常见的乱账有两种。
一种是顾客加了没减。顾客坐下waiting++之后因为没座位被拒,忘了在拒绝分支里处理,或者理发师那边减操作放错了位置,跑一会儿waiting就虚高,最后店里明明没人,程序却认为满座,新顾客全被拒之门外。这种 bug 的特点是"越跑越容易复现",前几轮正常,后面越来越不对劲。
另一种是减多了。理发师每服务一个顾客减一次,如果顾客分支里也误减了一次,计数会变成负数,座位判断彻底失效。
对付这类问题,我的办法是每加一次就打印一行带waiting值的日志,让加和减在时间线上成对出现,一旦发现某次加没有对应减,顺着日志往上找就能定位到漏掉的那次操作。日志不是万能的,但在排查计数值错乱时,它比断点调试还快。
5.3 常见问题速查表
把上面这些整理成速查表,出事时照表排查:
| 症状 | 优先怀疑 | 处理动作 |
|---|---|---|
| 程序启动即卡死 | 初始化漏了某个sem_init,或 P 顺序反了 | 检查初值、核对 P 顺序 |
| 偶发座位超员 | 判断与自增没在同一临界区 | 把判断和自增用同一把锁包起来 |
| 顾客数量越跑越少 | 拒绝分支里漏了sem_post(&mutex) | 检查每个分支的解锁路径 |
| waiting 变负数 | 减操作重复执行 | 打印每次加减,定位多余减 |
| 高并发才出问题 | 临界区保护不全 | 把读—判断—写三步整体锁住 |
这张表里的每一条我都在自己的测试代码上验证过,不是拍脑袋写的。
6. 这个玩具模型,其实天天在你项目里出现
6.1 进程池与工作队列里的信号量影子
把理发师问题里的"顾客"换成任务、"理发师"换成工作线程、"等待座位"换成任务队列容量,你会得到什么?一个带队列上限、任务满了就丢弃或拒绝的工作池。线程池的底层逻辑跟理发师问题一模一样。
我见过不少项目里的自定义线程池,任务提交接口内部就是一个Semaphore或BlockingQueue,put的时候如果队列满就根据策略阻塞或拒绝,生产者线程和消费线程之间的握手,跟顾客进店通知理发师是同一套动作。理解了理发师问题,你再去看这些并发工具的源码,会发现它们只是把信号量封装得更漂亮,本质没变。
另一处常见身影是连接池。连接数有限,请求方要借连接就得等,池子空了就在信号量上睡,归还连接时唤醒,这又是一个理发师问题的变体。
6.2 多理发师的扩展与批量唤醒
单理发师的模型扩展成多个,改动其实很小:把barbers的初值改成理发师数量,然后理发师进程从循环里起多个线程各自跑一份。整体逻辑不变,只是同时能接待的人数变了。
更值得琢磨的是"什么时候唤醒"。基础版里每来一位顾客就唤醒一个理发师,信号量天然支持这种一对一唤醒。但如果场景变成"顾客攒够一批再叫理发师",那就得引入批量条件判断,比如等待人数达到某个阈值再统一signal或唤醒全部。这类调度策略在真实的批处理系统里很常见,值得动手改一版试试。
我自己把单理发师改成双理发师跑过一轮,发现如果barbers初值忘了改,第二个理发师线程会一直卡在sem_wait(&customers)上不动,表现就是"招了人却不干活"。初值翻译法在这里再次救场:空闲理发师有几个,初值就是几。
6.3 一点个人踩坑体会
写了这么多版本,我最想分享的一条经验是:同步问题的正确性不靠盯代码看出来,靠跑出来。逻辑再顺,念一百遍也可能漏一个分支,只有把并发压上去、把椅子数调到极限、把日志打开,那些藏在临界区边角处的 bug 才会现形。
第二条是别急着抄标准答案。标准答案背下来能应付考试,但真正让你在真实项目里少加班的,是知道每个 P、每个 V 为什么摆在那。我建议你把这个模型自己写三遍:第一遍照标准版抄,第二遍故意把某个顺序改错看会发生什么,第三遍改成多理发师再跑。三遍下来,进程同步这块你就很难再被绕晕了。
最后留个可玩的方向:把顾客的"没座位就走"改成"没座位就等一会儿再回来试",配上随机退避,这又变成了带重试的限流场景。模型还是那个模型,改的是策略,这也是经典问题最迷人之处——它总能在你意想不到的地方,重新冒出来。