ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

理发师问题与PV操作:信号量同步、死锁排查及线程池实践

理发师问题与PV操作:信号量同步、死锁排查及线程池实践 理发师问题是我在操作系统课程里第一次真正意识到PV操作不是背两个字母的地方。它把进程、共享资源、等待队列、唤醒顺序这些概念塞进一个很小的场景一间理发店若干把等待椅一个或几个理发师不断来的顾客。你要用信号量和PV操作保证没有顾客时理发师睡觉有顾客时被叫醒等待区满了顾客自觉离开同一时刻只有一个进程改等待人数。这个问题在期末、考研、面试里出镜率极高也是理解Linux操作系统里等待队列、进程池任务调度的好模型。下面我按实际写代码和排错的顺序把理发师问题从模型、信号量设计、C实现到常见坑全部过一遍。刚接触操作系统进程同步的人可以跟着抄已经会背答案的人可以重点看顺序和变体部分。1. 理发师问题到底在模拟什么从生活场景到进程并发1.1 把理发店拆成共享资源与角色理发师问题的原始描述通常很朴素一家理发店只有一位理发师和若干把等待椅。没有顾客时理发师在理发椅上睡觉顾客进店后如果等待区有空椅子就坐下等待否则直接离开。理发师睡醒后给等待最久的顾客理发理完再接待下一位。这个场景里最容易被忽略的是“等待椅”和“理发椅”的区别等待椅是缓冲区理发椅是服务台理发师本身也是一种资源。把生活场景翻译成操作系统语言顾客就是不断产生的进程或线程理发师是服务进程等待椅是有限共享缓冲区等待人数是共享变量睡觉和叫醒就是阻塞与唤醒。从进程同步角度看至少有三类约束。第一类是互斥约束多个顾客可能同时进店必须互斥地检查等待人数并修改它否则两个顾客可能同时看到只剩一个空位最后等待人数超过椅子数。第二类是同步约束理发师必须等顾客到达顾客必须等理发师空闲两者存在先后依赖。第三类是容量约束等待区满了以后新顾客不能覆盖旧顾客也不能无限堆积必须选择离开。很多同学第一次写这个题只想到一个互斥锁结果要么理发师永远不醒要么顾客坐在锁里等理发师直接死锁。这个模型与生产者消费者模型很像但多了一层“服务者也要被等待”。顾客是生产者理发师是消费者等待椅是缓冲区缓冲区容量有限。不同点在于生产者消费者里消费者通常一直活跃地取任务而理发师没有顾客时会睡觉需要顾客主动叫醒。真实系统里的线程池就是这种结构任务队列相当于等待椅工作线程相当于理发师任务到来相当于顾客进店队列满时提交任务失败或阻塞。理解理发师问题对后面看线程池源码、连接池实现、消息队列背压都有直接帮助。1.2 PV操作不是“锁”那么简单信号量的两个原子动作PV操作里的P通常叫wait、down或sem_waitV通常叫signal、up或sem_post。P操作把信号量的值减1如果减完小于0当前进程进入该信号量的等待队列并阻塞V操作把信号量值加1如果加完小于等于0说明有进程在等待就从等待队列里唤醒一个。关键在于减值和判断、加值和唤醒这些动作都是原子的不会被其他进程打断。你可以把信号量理解成一个带排队机制的停车位计数器P是申请车位没车位就排队V是释放车位有排队者就放一个进来。信号量有两种典型用法。一种是互斥信号量初值通常为1表示“一把钥匙”任何进程想进入临界区都要先P离开时V。另一种是同步信号量初值通常为0表示“某个事件还没有发生”等待事件的进程先P阻塞事件发生后另一个进程V唤醒它。理发师问题里两者都要用mutex是互斥信号量用来保护等待人数customers和barbers是同步信号量用来表达“有顾客到了”和“理发师叫号了”这两个事件。很多教材把P、V讲成两个字母导致初学者只记住形式。实际操作时我建议把每个信号量都翻译成一句人话。比如customers初值为0意思是“当前没有待服务顾客理发师来了也得等”barbers初值为0意思是“当前没有叫号许可顾客坐下后必须等理发师发许可”。每写一个P就问自己“我在等什么资源或事件”每写一个V就问自己“我释放了什么资源或者通知了什么事件”。这样写出来的代码不容易乱也容易排查死锁。1.3 为什么这个模型能映射到真实系统理发师问题不是只为了考试。Linux操作系统里的等待队列、工作队列、完成量本质上都在处理“等资源”和“被唤醒”。比如一个进程读磁盘磁盘数据没准备好时它把自己挂到等待队列上状态变成不可中断睡眠或可中断睡眠磁盘中断到来后内核唤醒等待队列里的进程。这个过程和顾客坐下后等理发师叫号非常像。区别只是内核里的等待队列实现更复杂要考虑惊群、优先级、超时、信号打断等问题。再比如后端服务里的数据库连接池。连接池里有若干连接相当于理发师请求线程相当于顾客等待队列有最大长度队列满时新请求快速失败或降级。连接池参数里的最大连接数、最大等待数、等待超时时间都能在理发师问题里找到影子。如果你把理发师问题的信号量设计清楚再看连接池源码会轻松很多。线程与进程的区别在这种场景里也很关键线程共享地址空间所以全局变量和信号量可以直接放在进程内如果是多进程信号量需要放在共享内存里初始化时pshared要设为1这也是进程通信IPC的一部分。我见过不少同学把理发师问题当成“背代码题”结果题目一变成多理发师、等待区有容量、顾客有耐心值就不会了。真正要抓住的是状态和资源谁在等谁等的条件是什么条件满足后谁负责唤醒共享状态由谁保护。把这四句话想明白无论题目怎么变都能重新推出来。2. 信号量与同步关系设计谁等谁、谁唤醒谁2.1 三类进程顾客、理发师、等待队列在单理发师版本里我们可以抽象出顾客进程和理发师进程。顾客进店后做三件事检查等待区是否有空位、如果有空位就坐下并通知理发师、然后等待理发师叫号。理发师进程循环做三件事等待顾客到来、从等待区取一个顾客、通知该顾客开始理发。等待队列本身不是一个独立进程而是一个共享数据结构通常用一个整数waiting表示当前等待人数再加一个容量常量CHAIRS。不要小看这个整数它必须被互斥保护否则顾客和理发师同时修改会出现计数错误。多理发师版本里理发师进程有多个但等待队列仍然共享。此时顾客等的是“任意一个理发师空闲”理发师等的是“等待区里有顾客”。理发师数量增加后可以同时服务的顾客数增加但等待区容量不一定增加。如果等待区容量小于理发师数量可能出现理发师空闲但顾客进不来的情况如果等待区容量远大于理发师数量顾客等待时间会变长。这些参数关系在真实线程池里就是核心线程数、最大线程数、队列容量的配置问题。等待队列还需要考虑公平性。信号量的等待队列通常不保证严格先进先出至少POSIX信号量没有承诺公平。也就是说先坐下的顾客不一定先被叫号。如果题目要求先来先服务就需要额外维护一个真正的FIFO队列并用互斥锁保护。大多数考试题不要求严格公平但实际工程里如果任务优先级不同就得考虑饥饿问题。一个低优先级顾客可能永远被后来的高优先级顾客插队这在生产环境里会造成请求超时。2.2 需要的信号量数量与初值计算经典理发师问题通常用三个信号量customers、barbers、mutex。很多人第一次看到barbers初值为0会很困惑以为它表示空闲理发师数量。实际上在这个经典写法里barbers表示“理发师已经发出的叫号许可数”顾客需要P(barbers)来等待被叫号理发师在准备服务时V(barbers)来发出许可。customers表示“已经坐下等待的顾客数”顾客入座后V(customers)理发师P(customers)等待顾客。mutex保护waiting和容量判断初值为1。信号量初值谁执行P谁执行V作用customers0理发师顾客入座后理发师等待顾客到达barbers0顾客理发师准备服务时顾客等待理发师叫号mutex1顾客和理发师顾客和理发师保护waiting和容量检查waiting0无无当前等待区人数共享变量CHAIRS常量无无等待区容量比如3初值为什么这样定customers初值为0因为一开始没有顾客理发师必须阻塞等待。barbers初值为0因为一开始没有叫号许可顾客坐下后必须等待理发师发许可。mutex初值为1因为同一时刻只允许一个进程修改waiting。如果barbers初值设成1第一个顾客会直接通过P(barbers)以为理发师已经叫号实际上理发师可能还在睡觉。这个错误在多理发师版本里更隐蔽barbers初值设成理发师数量会导致多个顾客同时开始理发信号量计数完全失控。waiting的修改必须成对出现。顾客入座时waiting加1理发师准备服务时waiting减1。顾客离开时不修改waiting因为离开的顾客没有占用等待椅。这里有一个常见错误顾客发现等待区满离开时执行V(customers)想“通知理发师有人来了”。这是错的因为该顾客并没有坐下也没有占用等待椅理发师被唤醒后会减少waiting导致waiting变成负数或与实际不符。所有V操作都必须对应一个真实发生的事件不能凭感觉加。2.3 互斥与同步的区别别把mutex当万能钥匙mutex只负责保护临界区不负责表达“等顾客”或“等叫号”。临界区里应该只做共享变量的检查和修改做完立刻释放锁。顾客在临界区里判断waiting是否小于CHAIRS如果是就waiting加1然后释放mutex再V(customers)通知理发师最后P(barbers)等待叫号。注意P(barbers)必须在释放mutex之后不能放在临界区里面。如果顾客抱着mutex去P(barbers)理发师被唤醒后要拿mutex减少waiting就会被顾客挡住顾客又在等理发师V(barbers)理发师拿不到mutex就无法V(barbers)双方互相等待死锁。理发师侧也一样。理发师先P(customers)等待顾客被唤醒后拿mutex减少waiting释放mutex再V(barbers)叫号。V(barbers)可以在mutex释放前做也可以在释放后做但推荐先释放mutex再V(barbers)这样被叫号的顾客不会因为mutex还被占着而多等。虽然顾客被叫号后不需要mutex但减少锁持有时间总是好习惯。更重要的是先释放mutex再V同步信号量可以避免“唤醒后立刻阻塞在锁上”的伪唤醒开销。临界区大小也要控制。不要为了省事把打印、睡眠、业务处理都放进mutex里。打印本身可能阻塞睡眠更会长时间占用锁导致其他顾客和理发师全部卡住。正确做法是只用mutex保护waiting和容量判断打印放在锁外服务过程更不要放在锁内。实际写代码时可以把“状态修改”和“日志输出”分开日志输出虽然方便调试但不要让它影响同步逻辑。3. 从伪代码到可运行C程序完整实现与逐行拆解3.1 程序骨架与信号量初始化下面给出一份可以在Linux环境编译运行的C版本使用pthread线程和POSIX信号量。为了更接近真实场景我把理发师数量设为2等待椅数量设为3顾客数量设为10。代码里所有共享状态都放在全局信号量初始化时pshared参数为0表示线程间共享。如果你改成多进程需要把信号量放到共享内存并把pshared设为1。#include stdio.h #include stdlib.h #include pthread.h #include semaphore.h #include unistd.h #include time.h #define CHAIRS 3 #define BARBER_NUM 2 #define CUSTOMER_NUM 10 sem_t customers; sem_t barbers; sem_t mutex; int waiting 0;这段骨架里customers和barbers是同步信号量mutex是互斥信号量waiting是共享计数。CHAIRS是常量不需要信号量保护因为只读。很多人会问为什么不用一个信号量empty表示空椅子可以但经典写法更直接用mutex加waiting就能表达容量。两种写法没有绝对优劣考试里按题目要求来工程里看哪个更容易维护。如果等待区操作很频繁用empty信号量可以减少临界区里的比较判断但会增加信号量数量排查时更费脑子。初始化放在main里必须在创建线程之前完成。sem_init(customers, 0, 0)表示customers初值0sem_init(barbers, 0, 0)表示barbers初值0sem_init(mutex, 0, 1)表示互斥锁初值1。如果初始化失败严格来说要检查返回值并处理但示例代码为了简洁先省略。实际项目中sem_init失败通常说明系统资源不足或参数错误不能继续往下跑。3.2 顾客进程逻辑顾客线程的逻辑可以概括为随机时间后进店拿mutex检查等待区有空位就坐下并通知理发师然后等叫号没空位就离开。代码里先P(mutex)判断waiting CHAIRS满足则waiting加1释放mutex再V(customers)最后P(barbers)。这个顺序要背下来但更重要的是理解每一步在干什么。void* customer_thread(void* arg) { int id *(int*)arg; sleep(rand() % 3); sem_wait(mutex); if (waiting CHAIRS) { waiting; printf(顾客%d进店等待人数%d坐下等待\n, id, waiting); sem_post(mutex); sem_post(customers); sem_wait(barbers); printf(顾客%d被叫号开始理发\n, id); sleep(2); printf(顾客%d理发完成离开\n, id); } else { sem_post(mutex); printf(顾客%d进店等待区满直接离开\n, id); } return NULL; }逐行看sem_wait(mutex)进入临界区waiting CHAIRS判断容量waiting占用等待椅sem_post(mutex)释放锁。接下来sem_post(customers)通知理发师“有顾客坐下了”。然后sem_wait(barbers)是顾客阻塞自己等待理发师叫号。叫号成功后顾客开始理发这里用sleep模拟服务时间。最后顾客离开不需要修改waiting因为理发师在叫号时已经做了waiting--。注意顾客离开时没有V(empty)之类的操作因为等待椅在理发师叫号时已经释放了。有一个细节值得说明为什么先释放mutex再V(customers)因为V(customers)会唤醒理发师理发师醒来后要拿mutex。如果顾客还握着mutex理发师会阻塞在sem_wait(mutex)上。虽然最终顾客会释放但让被唤醒者立刻阻塞在锁上不是好习惯会增加上下文切换和等待时间。先释放mutex再V(customers)理发师醒来后可以直接拿锁效率更高。这个顺序在考试里不一定扣分但在实际代码里是值得坚持的。3.3 理发师进程逻辑理发师线程是一个无限循环先P(customers)等顾客被唤醒后拿mutexwaiting减1释放mutex然后V(barbers)叫号最后模拟理发。这里waiting减1表示从等待区取走一位顾客该顾客不再坐等待椅。V(barbers)是发叫号许可顾客侧正在P(barbers)等待这个许可。void* barber_thread(void* arg) { int id *(int*)arg; while (1) { sem_wait(customers); sem_wait(mutex); waiting--; printf(理发师%d叫号剩余等待人数%d\n, id, waiting); sem_post(mutex); sem_post(barbers); printf(理发师%d开始为顾客服务\n, id); sleep(2); printf(理发师%d服务完成\n, id); } return NULL; }这个循环里最容易错的是信号量顺序。理发师必须先P(customers)再P(mutex)不能反过来。如果先P(mutex)理发师拿着锁等顾客顾客就无法进临界区修改waiting并V(customers)双方死锁。waiting--必须在mutex保护下执行因为它可能和其他顾客的waiting并发。V(barbers)放在mutex释放后理由和顾客侧类似被叫号的顾客不需要mutex但减少锁持有时间总是好的。多理发师版本中每个理发师线程都执行同一段逻辑。customers信号量会把多个理发师串行化地唤醒有多少个待服务顾客就可能有多少个理发师通过P(customers)。barbers信号量则把叫号许可发给顾客。因为barbers初值为0顾客必须等到有理发师V(barbers)才能开始理发。理发师数量为2时最多两个顾客同时理发。如果你把BARBER_NUM改成1就退化成单理发师版本改成5同时服务能力增强但等待区容量仍然是3所以最多3个顾客等待其余离开。3.4 编译运行与验证方法在Linux下编译时需要链接pthread库。命令可以用gcc barber.c -o barber -pthread。运行后你会看到顾客进店、坐下、理发师叫号、理发完成的交错输出。验证要点有几个第一等待人数永远不会超过CHAIRS如果超过说明mutex没保护好或者容量判断有问题第二没有顾客时理发师线程应该阻塞在P(customers)上不会空转打印第三等待区满时新顾客直接离开不会覆盖已有顾客第四所有顾客最终都能被处理不会出现永久阻塞。gcc barber.c -o barber -pthread ./barber如果想做压力测试可以把CUSTOMER_NUM改成100BARBER_NUM改成3CHAIRS改成5然后观察输出。注意输出会非常乱因为多个线程同时打印。实际调试时建议给printf加互斥锁或者把日志写入文件再分析。更好的办法是记录每个顾客的到达时间、入座时间、开始服务时间、离开时间然后统计平均等待时间和离开率。这些指标在真实线程池调优里也很有用队列容量太小会导致任务拒绝率上升队列太大可能导致任务等待时间过长。还有一个验证技巧把sleep时间改成随机值模拟顾客到达间隔和服务时间不均匀。均匀时间下问题不容易暴露随机时间更容易触发竞争。你可以用固定随机种子复现问题比如srand(42)这样每次运行顺序一致方便对比修改前后的行为。这个习惯在做并发调试时非常有用因为并发bug往往难以复现固定种子能大幅提高排查效率。4. 经典坑与排查技巧实录顺序、初值、惊群、忙等4.1 P/V顺序反了会怎样死锁现场复盘最经典的死锁是把P(barbers)放进mutex临界区。错误代码长这样顾客先sem_wait(mutex)然后waitingsem_post(customers)接着在还没sem_post(mutex)的时候执行sem_wait(barbers)。理发师被V(customers)唤醒后执行sem_wait(mutex)但mutex被顾客持有理发师阻塞。理发师无法执行V(barbers)顾客就永远等在P(barbers)上双方永久阻塞。这个死锁在单理发师和多理发师版本里都会出现而且输出上看起来像“程序卡住了什么都没打印”或“打印到一半不动了”。另一种死锁是理发师先P(mutex)再P(customers)。理发师一启动就抢到mutex然后等顾客顾客进店要P(mutex)检查等待区但mutex被理发师占着顾客无法入座也无法V(customers)理发师等不到顾客顾客等不到锁死锁。这种错误在多理发师版本里更严重因为多个理发师可能把mutex全部占住所有顾客都进不来。排查死锁时可以先看每个线程当前阻塞在哪个sem_wait上。如果发现某个线程持有mutex还在等另一个同步信号量基本就是临界区放大了。解决思路很简单临界区只做共享状态修改任何可能阻塞的P操作都放在临界区外面。顾客的P(barbers)放在释放mutex之后理发师的P(customers)放在拿mutex之前。记住一句话锁里面不要等资源等资源之前先放锁。这句话在90%的同步题里都成立。4.2 初值设置错误与计数越界初值错误是另一个高频坑。barbers初值写成1第一个顾客会立即通过P(barbers)直接开始理发哪怕理发师还在睡觉。后续理发师再V(barbers)barbers值增加导致更多顾客误以为可以理发最终多个顾客同时占用同一个理发师或打印混乱。customers初值写成1理发师一启动就以为有顾客执行waiting--waiting变成-1然后V(barbers)叫号但根本没有顾客程序逻辑全乱。mutex初值写成0所有线程第一次P(mutex)都会阻塞程序直接卡死。计数越界通常和mutex保护不完整有关。如果顾客修改waiting时没有加锁两个顾客可能同时读到waiting2同时认为还有空位各自加1最后waiting4超过CHAIRS3。更隐蔽的是理发师减少waiting时没有加锁可能和顾客的waiting交错导致丢失更新。C语言里waiting不是原子操作它包含读、加、写三步。并发环境下必须用mutex或者原子变量保护。考试里写伪代码可以默认PV原子实际写C代码不能想当然。排查初值问题时可以在初始化后打印每个信号量的初值但sem_getvalue的结果在某些系统上不一定可靠因为信号量值可能被并发修改。更好的办法是单步调试或加日志在每次P、V前后打印线程ID和操作名称。虽然日志会改变时序但至少能看清逻辑顺序。如果日志显示某个P永远没有对应的V就检查是不是有分支漏了V或者初值设错导致P直接通过。4.3 多理发师、有限等待区、公平性等变体多理发师版本的核心改动是把理发师线程创建多个barbers信号量初值仍然是0。很多同学会问多个理发师空闲时barbers信号量要不要设成理发师数量不要。barbers表示叫号许可不是空闲理发师数量。理发师空闲时阻塞在P(customers)上而不是增加barbers。顾客坐下后V(customers)唤醒一个理发师理发师准备服务时V(barbers)唤醒一个顾客。这样无论多少理发师barbers初值都为0。如果你把barbers初值设成BARBER_NUM顾客会误以为已经有叫号许可直接开始理发多个顾客可能同时抢同一个理发师。有限等待区还有一种写法是用计数信号量empty表示空椅子初值为CHAIRS。顾客进店先P(empty)占椅子然后P(mutex)修改waitingV(mutex)V(customers)再P(barbers)。理发师叫号后顾客离开等待区V(empty)释放椅子。这种写法把容量控制交给信号量临界区更小但要求理发师和顾客都记得释放empty。如果理发师忘记V(empty)等待区会越来越小最后顾客全部离开。两种写法都可以考试里看题目给的信号量工程里选一种并写清楚注释。公平性问题在信号量层面无法彻底解决。如果严格要求先来先服务可以自己维护一个FIFO队列用mutex保护顾客入队后等待条件变量理发师从队头取。POSIX信号量的等待队列通常不保证FIFO所以高并发下可能出现后到顾客先被叫号。实际系统里如果任务有优先级还需要优先级队列。理发师问题作为教学模型主要训练PV操作的逻辑公平性可以作为一个扩展思考。4.4 常见问题速查表现象可能原因排查方法修复方式程序启动后立刻卡死mutex初值为0或理发师先P(mutex)再P(customers)查看第一个阻塞的线程在哪个sem_wait把mutex初值改1调整P顺序理发师永远不醒customers初值为0且顾客没有V(customers)在顾客入座分支加日志确保入座后V(customers)顾客永远不理发barbers初值为0且理发师没有V(barbers)检查理发师是否执行到V(barbers)确保理发师叫号后V(barbers)等待人数超过椅子数waiting未加锁或容量判断在锁外打印每次waiting变化用mutex保护判断和修改多个顾客同时理发barbers初值设成理发师数量检查初始化代码barbers初值改0等待人数变成负数顾客离开时错误V(customers)或理发师重复减打印waiting增减位置只在入座时加叫号时减输出混乱但逻辑正确多线程并发打印给printf加锁或写日志文件日志互斥不影响同步程序不退出理发师线程是while(1)检查主线程是否join顾客后退出主线程退出或加停止标志这张表里的问题我基本都踩过尤其是barbers初值和mutex顺序。排查时不要一上来就改代码先看日志确定阻塞点。并发问题的日志不要只打印“开始”“结束”要打印线程ID、信号量操作、共享变量值。比如“顾客3 P(mutex) 前 waiting1”“顾客3 入座后 waiting2”“理发师1 P(customers) 成功”“理发师1 waiting-- 后 waiting1”。这些信息能快速定位是哪个环节少了V或者多了V。注意信号量没有“拥有者”概念谁都可以V。这既是灵活之处也是危险之处。每个V都必须对应一个明确的资源释放或事件通知不能为了“保险”随便加。5. 从考试题到工程实践PV操作还能怎么用5.1 期末与面试答题模板如果考试里遇到理发师问题我建议按固定步骤写不要直接写代码。第一步列出所有进程和共享资源顾客进程、理发师进程、等待椅、等待人数、理发椅。第二步找出同步关系理发师等顾客顾客等理发师叫号顾客之间互斥修改等待人数等待区有容量限制。第三步定义信号量和初值customers初值0barbers初值0mutex初值1waiting初值0。第四步写伪代码P和V成对出现。第五步检查死锁和边界等待区满、没有顾客、多个理发师同时叫号、顾客离开。面试里可能会追问如果等待区满了顾客应该阻塞还是离开这取决于题目要求。如果顾客有耐心阻塞等待空位那就需要empty信号量顾客先P(empty)满了就排队。如果顾客没耐心直接离开就用mutex加waiting判断。两种场景的信号量设计不同。还会追问多个理发师时barbers初值是多少答案是0除非你把barbers语义改成“空闲理发师数”那整个代码结构都要改。面试官往往不是看你背没背过而是看你能不能把信号量语义说清楚。答题时可以用表格先列信号量再写伪代码最后用两三句话说明为什么这么设计。这样卷面清晰面试表达也有条理。不要一上来就写while(1)先把角色和资源写清楚。很多丢分不是因为PV写错而是因为共享变量和信号量语义混淆导致后面全错。5.2 在真实后端与嵌入式里的信号量思维后端服务里的线程池是理发师问题的直接变体。任务队列是等待椅工作线程是理发师提交任务的线程是顾客。线程池参数里的核心线程数、最大线程数、队列容量、拒绝策略分别对应理发师数量、等待椅数量、等待区满后的处理。如果你用PV操作思维看线程池会发现很多配置问题都有了解释核心线程数太小任务排队队列太长任务等待超时拒绝策略太激进任务丢失。连接池、数据库连接、Redis连接池也是同样结构。嵌入式系统里信号量常用于中断和任务之间的同步。中断服务程序相当于顾客任务相当于理发师或者反过来。中断里通常不能阻塞所以中断只负责V操作唤醒任务任务在任务上下文里执行P操作等待事件。这跟理发师问题里顾客V(customers)唤醒理发师很像。实时操作系统里还要考虑优先级翻转、中断延迟、超时等待这些比理发师问题复杂但基础模型仍然一致。进程通信IPC里信号量可以跨进程使用但需要共享内存配合否则信号量本身无法传递复杂数据。线程与进程的区别在这里也有体现线程共享地址空间信号量和waiting可以直接是全局变量多进程各自有独立地址空间信号量必须放在共享内存里waiting也必须放在共享内存里。如果只把信号量放在共享内存waiting还在各自进程的私有数据段每个进程看到的waiting不是同一个逻辑照样错。这一点在实际写多进程服务时很容易翻车。5.3 我的实操心得先画状态表再写代码我后来每次写并发同步代码都会先在纸上画一张状态表而不是直接打开编辑器。表里列四样东西线程角色、共享变量、信号量、每个信号量的P和V位置。比如理发师问题里顾客侧写“P(mutex)、判断、waiting、V(mutex)、V(customers)、P(barbers)”理发师侧写“P(customers)、P(mutex)、waiting--、V(mutex)、V(barbers)”。写完以后用一支笔模拟两个线程交替执行专门找“持有锁等资源”的情况。只要发现某个线程在持有mutex时执行P另一个信号量立刻标红。第二个心得是加日志要克制。并发程序加日志会影响时序有时加了日志问题就消失了。我的做法是先加最小日志只记录信号量操作和共享变量变化不记录业务过程。如果问题消失说明是时序敏感问题再用固定随机种子和压力测试复现。也可以用GDB attach到卡住的进程查看每个线程的backtrace看它们分别阻塞在哪个sem_wait上。这个技能在排查线上线程池卡死时非常有用。第三个心得是不要迷信“标准答案”。不同教材的理发师问题写法有差异有的用empty信号量有的用waiting加mutex有的把barbers初值设成1然后语义完全反过来。关键是保持一致如果你把barbers解释为“空闲理发师数”那顾客P(barbers)成功表示直接占用理发师理发师空闲时V(barbers)但初始时理发师线程要先V(barbers)表示自己空闲代码结构会不同。最怕的是抄了A版本的初值又用了B版本的P/V位置逻辑必然错。我一般在代码注释里写清楚每个信号量的语义比如“barbers: 叫号许可初值0理发师V顾客P”这样过两周回来看也不会乱。最后再分享一个小技巧验证理发师问题时把CHAIRS设成1、BARBER_NUM设成1、CUSTOMER_NUM设成3手动推演输出。等待区只有一把椅子最多一个顾客等待一个理发师服务。如果输出里出现两个顾客同时坐下或者理发师连续服务两次没有顾客就说明同步有问题。小参数比大参数更容易暴露逻辑错误因为线程交错少日志看得清。等小参数跑稳了再把参数调大做压力测试。这个从最小可复现例子出发的习惯比直接上100个线程更省时间。
返回列表