简介:操作系统课程中的进程间同步与互斥专题资料,以银行柜员服务问题为实例,讲解如何用P、V操作(信号量)与互斥锁解决多线程并发场景下的资源竞争和同步等待。内容明确了顾客取号与柜员叫号的互斥关系,以及顾客等待柜员、柜员等待顾客的同步逻辑,适合操作系统课程设计、实验或考研复习中需要理解同步互斥模型的读者。压缩包仅1个PDF文件,大小370KB,涵盖实验题目、实现要求、测试格式、设计思路、伪码算法、整体结构图、程序结构定义,以及顾客线程和柜员线程的详细分析,并给出bankcounter.cpp源码指引。还解释了在Ubuntu 18.04 LTS下用C++11、pthread和semaphore库搭建实验,以及测试数据与输出要求的处理方式。资源虽精简但知识点密集,从互斥对象、同步对象、等待队列三个层面拆解题意,可帮助读者掌握信号量机制的具体应用,并迁移到其他同步互斥实验或项目中。目前已有1872人学习下载。
1. 银行柜员服务问题:进程间同步互斥最容易被低估的入门题
如果你调试过一个线上服务,见过一半线程卡死、CPU 却纹丝不动的场景,那你其实已经遇到过银行柜员服务问题的生产版本。标题看起来像操作系统课的课后作业,但它不是作业题,它是并发正确性的最小复现单元:多个客户到达、多个柜员服务、一条共享队列,资源有限,线程之间既要互斥访问队列,又要同步等待"有人可服务"。把这个模型写明白,信号量、互斥锁、条件变量之间的关系就通了;写不明白,后面做消息队列、连接池、任务调度都会反复踩同一个坑。这篇笔记按我实际跑通这个问题的顺序来写:先建模,再给双信号量实现,然后调多柜员并发参数,最后说四个最容易翻车的现场。
2. 问题建模:把柜台业务抽成三个同步对象
2.1 谁生产、谁消费:客户和柜员的角色容易倒挂
银行柜员服务问题的核心是生产者消费者模型,但第一次接触时很容易把角色搞反。客户到达网点,取号,排队,等柜员叫号——客户是生产者,生产的是"服务请求";柜员是消费者,消费请求并完成服务。这里有一个反直觉的点:柜员服务完一个客户之后,并不是结束,而是回到空闲状态继续等待下一个客户。换句话说,柜员既是消费者,又在一个更长的循环里"生产"自己的空闲窗口。
建模时我会把对象拆成三份。第一份是客户线程,它的生命周期很短:取号、入队、被服务、离开。第二份是柜员线程,生命周期无限循环:等待叫号、出队、服务、回到等待。第三份是共享的等待队列(缓冲区),它被所有客户和柜员共同访问,是临界资源。这三个对象一旦分清楚,后面写代码时谁该加锁、谁该等待就各就各位了。
这里还要注意一个细节:客户线程在入队之后不一定立刻被服务,所以客户需要一种"等待自己被叫到"的机制;柜员在没有客户时也不能空转,需要一种"等待有人到达"的机制。这两条等待方向相反,单靠一把互斥锁表达不了,必须有同步原语介入。这就是为什么这个问题叫"同步互斥"而不是单纯叫"互斥保护"——互斥管的是队列不能同时被改,同步管的是两边线程的推进节奏要能对上。
2.2 为什么纯互斥锁解决不了:忙等待和条件等待的区别
很多人拿到这个问题,第一反应是"给队列加一把锁不就行了"。从内存安全角度,确实不会出现两个线程同时改队列导致数据损坏,但程序不会正常推进。原因在于:柜员线程拿到锁后发现队列是空的,这时候怎么办?如果释放锁再继续循环,它会在一个 while 循环里反复加锁、检查、解锁,这就是忙等待。忙等待在单核机器上会抢 CPU 导致其他线程饿死,在多核机器上表现为某几个核心占用率打满,性能监控直接报警。
更麻烦的是语义问题。锁表达的是"这个资源正被占用,请稍后再试",但它表达不了"我在等一个条件成立"。"队列非空"是一个条件,不是一把锁能描述的。你可以在锁里面检查条件,但检查完发现不成立时,你没有任何原语可以让线程去睡、等条件成立后再被唤醒。用锁来实现这种等待只能靠自旋或者 sleep,而 sleep 时间设短了还是忙等,设长了又拖慢响应。这个矛盾正是信号量存在的理由。
信号量在这里承担两个职责:一个是记录"队列里还有多少容量",另一个是记录"队列里有多少等待服务的客户"。这两个值是有业务含义的计数,不是简单的锁状态。用信号量之后,柜员线程在没有客户时进入阻塞态,CPU 不空转;客户入队后通过信号量唤醒一个柜员,响应路径最短。理解了"锁管互斥、信号量管同步"这个分工,接下来写代码就顺了。
2.3 临界区划分:从取号到入队要包住哪些操作
我在纸上画这个模型时,会把临界区精确到"改队列结构的那几行代码",绝不扩大。取号、入队、出队、叫号,四个动作不是都要锁。取号只是拿一个递增的号码,如果取号机有原子自增指令,它自己就是安全的;叫号是柜员从自己的窗口喊号码,不涉及共享队列,也不需要锁。真正需要锁的只有两处:客户把号码放进队列(写操作),柜员把号码从队列取出(读改写操作)。
临界区外的操作越少越好,这是一条写并发代码的通用准则。因为锁的持有时间越长,其他线程等待的时间就越长,系统整体吞吐越低。银行柜员问题里,临界区里只是入队或出队的一次指针操作,耗时可以忽略不计,这时锁的粒度很容易被忽略,但养成"最小临界区"的习惯,后面做数据库事务、分布式锁时能少踩很多坑。
还有一个容易忽略的边界:队列满的时候,客户不能入队,要等容量信号量释放;队列空的时候,柜员不能出队,要等服务信号量。这两个条件分别由两个信号量守卫,如果只用一个信号量同时当容量和数量用,队列的"满"和"空"两个方向都会出问题。这一点在下一章的代码里会体现得很明显。
3. 双信号量实现:从单柜员到多柜员的最小改造
3.1 核心代码:容量信号量加服务信号量
我把常见做法写成一个 C 风格伪代码,接口上对应 pthread 或 Windows 信号量 API 都可以移植。队列用数组加头尾指针实现,信号量两个:queue_slots表示队列剩余容量,初始化为队列最大长度;customers_waiting表示当前排队人数,初始化为 0。互斥锁一把,只保护入队和出队。
#include <pthread.h> #include <semaphore.h> #define MAX_QUEUE 50 // 队列最大长度 #define NUM_TELLERS 3 // 柜员数量 typedef struct { int queue[MAX_QUEUE]; int head, tail, count; } BoundedQueue; BoundedQueue q; sem_t queue_slots; // 剩余排队容量,初值 MAX_QUEUE sem_t customers_waiting; // 排队客户数,初值 0 pthread_mutex_t lock; // 保护队列 void init() { q.head = 0; q.tail = 0; q.count = 0; sem_init(&queue_slots, 0, MAX_QUEUE); sem_init(&customers_waiting, 0, 0); pthread_mutex_init(&lock, NULL); } void* customer_thread(void* arg) { int ticket = *(int*)arg; sem_wait(&queue_slots); // 1. 先占一个排队名额 pthread_mutex_lock(&lock); // 2. 加锁保护队列 q.queue[q.tail] = ticket; // 3. 入队 q.tail = (q.tail + 1) % MAX_QUEUE; q.count++; pthread_mutex_unlock(&lock); // 4. 释放锁 sem_post(&customers_waiting); // 5. 通知柜员有新客户 return NULL; } void* teller_thread(void* arg) { while (1) { sem_wait(&customers_waiting); // 1. 等有客户可服务 pthread_mutex_lock(&lock); // 2. 加锁保护队列 int ticket = q.queue[q.head]; // 3. 出队 q.head = (q.head + 1) % MAX_QUEUE; q.count--; pthread_mutex_unlock(&lock); // 4. 释放锁 sem_post(&queue_slots); // 5. 释放一个排队名额 serve(ticket); // 6. 在临界区外执行服务 } return NULL; }这段代码的通行顺序有讲究。客户线程第一步是sem_wait(&queue_slots),先占名额再进临界区,而不是先进锁再等名额。如果反过来,客户持有锁等容量、柜员拿着容量等锁,两个线程就互相卡死,这是教科书级的死锁四条件现场。柜员线程同理,先sem_wait(&customers_waiting)再拿锁,保证"队列里有东西"这个前提成立之后才访问队列。
另一个关键点是serve(ticket)放在锁外面。服务一个客户可能要花几十秒,如果这段代码在锁里面,其他客户连入队都要排队等锁,系统退化成单线程。把服务动作挪出临界区,锁的持有时间只有几次数组操作,这才是多线程并发该有的样子。
3.2 参数与边界设定:队列长度、柜员数、服务时长的选取
代码能跑起来之后,参数设多少决定了这个实验能不能暴露问题。我一般会这样设置初值:队列长度MAX_QUEUE取 20 到 50 之间,太小容易触发满队列阻塞,太大看不出容量信号量的作用;柜员数NUM_TELLERS从 1 开始测,然后分别试 3 和 10,对比不同消费者数量下的唤醒行为;每位客户的"服务时长"用sleep(0.05)到sleep(0.2)随机模拟,模拟真实业务的不均匀到达。
一个值得记录的经验是:客户到达速率要大于服务速率,否则队列长期为空,信号量同步的路径走不到。我常用两个线程组配合:客户线程每隔 10 到 30 毫秒生成一个,柜员线程每服务一个固定 sleep 100 毫秒,这样队列会周期性地出现积压和清空,两个信号量的 wait 和 post 都能被完整触发。
还要设定运行终止条件。柜员线程是无限循环,实验时要有一个全局计数器,统计完成的客户数,达到目标值后给柜员线程发送退出标志。这里要小心:不能直接强制结束柜员线程,否则正好卡在临界区里,队列数据会损坏。正确做法是完成数达标后,让柜员在sem_wait返回后再检查标志位,保证它不会在阻塞状态被终止。
3.3 从单柜员扩展到多柜员:代码不需要改,但要重新看初值
单柜员版本跑通之后,把NUM_TELLERS改成 3,代码一行都不用改,customers_waiting信号量会保证每个客户只唤醒一个柜员。但这里有一个隐蔽的语义变化:在单柜员场景里,queue_slots和customers_waiting是"你减我加"的镜像关系;在多柜员场景里,多个柜员同时消费,两个信号量的值不再严格互补,中间可能有一个瞬间队列被多个柜员同时访问。这个瞬间正是互斥锁存在的理由。
多柜员还有一个边界:如果柜员数大于队列长度,比如 10 个柜员、5 个排队位,会出现柜员永远等不到活干的情况。这不是 bug,是参数设计问题,但如果在实验报告里能写清楚"柜员数超过队列容量时,部分柜员线程饥饿",说明你对系统行为的理解到了位。我通常会把柜员数和队列长度保持在同一数量级,比如队列 20、柜员 3 到 5,这样既能观察排队现象,又不会让太多线程长期阻塞。
4. 多柜员并发中的隐藏问题:信号量初值与线程唤醒策略
4.1 信号量初值为什么不能随便写:queue_slots 和 customers_waiting 的语义边界
queue_slots初始化为MAX_QUEUE,表示一开始整个队列都是空的,所有容量可用;customers_waiting初始化为 0,表示一开始没有客户。这两个初值一个取最大值、一个取零,不是随意定的,它们分别对应资源的"可用上限"和"当前存量"。如果把queue_slots初始化为 0,第一个客户进来就阻塞,系统直接死掉;如果把customers_waiting初始化为 5,柜员会先凭空处理 5 个不存在的客户,这叫虚假唤醒。
还有一个容易犯的错是:在重新初始化队列时忘记重置信号量。运行多轮实验时,上一轮的customers_waiting值可能不是 0,新一轮实验一开始柜员就消费旧值,日志里会出现"还没客户就服务完了"的假象。我一般在init()里先sem_destroy再sem_init,确保状态干净。信号量没有"清零"接口,只能销毁重建,这一点在写测试脚本时要记住。
信号量还有一个值得注意的性质:sem_post不会因为当前值等于初值上限就报错,它会继续加一。所以如果你在释放队列容量时逻辑写重复了,queue_slots的值可能超过MAX_QUEUE,队列实际容量被撑爆,数组越界。排查这类问题要看信号量值的长期走势,而不是只看某一瞬间的日志。
4.2 唤醒策略与惊群:为什么不能假设"一次只唤醒一个"
教科书里常把sem_post描述成"使一个等待线程变为就绪",但到真实系统层面,不同平台的实现不一样。有的信号量在post时只唤醒一个等待者,有的会唤醒多个,让它们各自去尝试竞争。当多个柜员线程同时阻塞在customers_waiting上时,如果实现是"唤醒全部",就会出现惊群效应:10 个柜员全部醒来,抢同一把锁,最终只有 1 个能拿到客户,其他 9 个回到阻塞状态。短时间大量上下文切换,性能会掉一截。
这件事在银行柜员问题里不明显,因为柜员数量一般只有几个。但把柜员数放大到 100,信号量统一唤醒的代价就非常明显了。如果你用的是条件变量(pthread_cond_t),要注意signal和broadcast的区别:signal唤醒一个,broadcast唤醒全部。业务上"来一个客户叫一个柜员"应该用signal,只有在"所有等待线程都需要检查状态变化"时才用broadcast,比如关门前通知所有柜员下班。
实际测试中我发现,这个问题的性能瓶颈往往不在唤醒方式,而在锁竞争。10 个客户同时到达,它们会排队抢queue_lock,虽然临界区很短,但线程间锁争抢仍然存在。如果想进一步优化,可以把入队改成无锁队列(比如 CAS 实现),但那就超出信号量教学范围了。作为入门实验,理解"锁保护结构、信号量控制节奏"就够了。
4.3 服务完成后的容量释放:顺序和位置都要固定
柜员执行serve(ticket)之后,什么时候释放queue_slots,看似只是一个位置问题,但放错了会出两类事故。第一类是提前释放:柜员刚出队就sem_post(&queue_slots),然后才去serve。这时候新客户可能立刻入队,而柜员还在服务旧客户,队列看起来没满,实际服务能力已经透支,延迟曲线会突然抬高。第二类是遗留释放:柜员退出线程前忘了最后释放一个queue_slots,运行多轮后容量信号量越减越少,最终所有客户阻塞在入队环节。
我的习惯是把sem_post(&queue_slots)放在出队完成、解锁之后的固定位置,这样语义最清晰:客户一被柜员从队列取走,这个排队名额立即归还,新客户可以补位。至于serve是否在释放名额之后执行,取决于你想让"队列名额"代表"正在排队"还是"正在被服务"。在银行场景里,取号后就算被叫到,柜台也会办一会儿,所以名额代表"排队或正在办理"更贴近直觉。如果你在做一个任务执行器,名额代表"任务已被接收"更合适——语义不同,代码位置就不同,但必须写成显式注释,防止后人乱移。
5. 避坑:银行柜员问题最容易翻车的四个现场
5.1 现象:程序跑着跑着所有线程都停住,日志最后一条是"客户入队成功"
原因:互斥锁在某个线程里被加锁后,因为异常分支提前return,锁没有释放。在客户线程里,如果入队后紧接着有一步校验失败并且直接 return,锁就永远握在这个消失的线程手里,其他柜员全部阻塞在pthread_mutex_lock。这个问题在代码里隐藏得很深,因为正常路径都正确,只有异常路径才会触发。
解决:所有需要提前退出的分支,在return前都必须pthread_mutex_unlock。更稳妥的做法是改变结构,把锁范围缩到最小,锁内代码不包含任何可能产生分支的逻辑。比如入队就是赋值、改指针、计数三行,不做校验;校验放到入队之前做完,拿着结果再进锁。如果代码里锁和分支嵌套过多,可以用 RAII 风格或者语言层面的锁管理器来兜底,避免人肉保证解锁路径。
5.2 现象:客户线程和柜员线程互相等待,双方都阻塞在 sem_wait 上
原因:加锁和等待的顺序颠倒。客户线程先拿queue_lock再sem_wait(&queue_slots),柜员线程先拿同样的锁再sem_wait(&customers_waiting)。当队列满且队列空同时出现时,客户持有锁等容量,柜员持有锁等客户,谁也等不到谁——这是死锁的经典四条件:互斥、持有并等待、不可剥夺、循环等待。
解决:统一约定"先等待信号量,后获取互斥锁"的顺序。客户先sem_wait(&queue_slots)确认有容量,再拿锁入队;柜员先sem_wait(&customers_waiting)确认有客户,再拿锁出队。两个线程的加锁顺序一致后,循环等待的条件被破坏。排查时用调试器挂到进程上,查看每个线程的栈,如果两个线程分别阻塞在不同的sem_wait但都持有一把锁,基本就是这个原因。
5.3 现象:测试时正常,一加压力就出现重复服务或漏服务
原因:用sleep代替同步原语。常见写法是客户入队后sleep(0.1)等柜员来取,或者柜员检查到队列为空时sleep(0.1)再重试。这种代码在单核低并发下能跑通,因为恰好 sleep 的时间够长,掩盖了竞态;一旦客户到达间隔缩短,两个客户可能在同一个 sleep 窗口内入队,柜员醒来看队列时只处理了其中一个,另一个状态就乱了。
解决:不要用时间等待来表达数据依赖,用信号量或条件变量表达。sem_wait(&customers_waiting)本身就是"队列从空到非空"的事件通知,比任何 sleep 都可靠。睡眠只保留在serve()里模拟服务耗时,模拟的是业务时延,不是同步机制。一个自查技巧是:把 sleep 时间设成 0,看程序是否还能正确跑完——如果依赖 sleep 才能正确,那竞态就在。
5.4 现象:队列 count 和实际元素对不上,偶发出现负数
原因:判空和取号两个动作之间有窗口期。比如柜员线程写成"先pthread_mutex_lock检查count > 0,如果为真则sem_wait(&customers_waiting)"——在检查完 count 到 sem_wait 之间,另一个柜员可能把最后一个客户取走,这个柜员拿到锁时队列已经空了。反过来,客户线程如果先检查count < MAX_QUEUE再入队,也会被另一个客户插进来导致容量超限。
解决:不要用共享变量判断条件来决定是否等待信号量。信号量本身就是条件计数器,customers_waiting大于 0 就说明队列里至少有一个客户,queue_slots大于 0 就说明还有位置。把"检查"和"等待"合并成同一个sem_wait操作,锁内只做事后记账不做事前判断。如果确实需要在拿锁后再确认状态,就要用条件变量而不是信号量——条件变量允许你在锁内检查谓词并原子地释放锁等待,这正是它存在的原因。
6. 进阶:优先级客户、运行验证和一点排查习惯
6.1 让 VIP 客户插队:优先级队列与信号量的组合改造
基础版本跑通后,最常见的学习延伸是加优先级。银行场景里 VIP 客户可以优先办理,对应到代码就是队列变成优先级队列。改造方案:保留customers_waiting和queue_slots两个信号量的基本框架,只把q.queue[q.tail] = ticket的入队逻辑换成按优先级插入。锁内操作多了一次移位,但临界区仍然很短。
这里有两个坑。第一是优先级反转:一个 VIP 客户入队后,普通客户正在被服务,新到的普通客户先被安排到前面的窗口,VIP 反而要等。优先级队列解决的是排队顺序,但服务水平(柜员分配)不受队列控制,需要额外的调度策略才能真正确保 VIP 优先。第二是信号量的计数语义不变:customers_waiting只记数量不记优先级,如果队列里全是 VIP、优先级字段没参与计数,那优先级只影响排位,不影响唤醒顺序——想要按优先级唤醒,就要用优先级条件变量或就绪队列,那已经超出信号量的能力边界了。
6.2 三个验证手段:从"能跑"到"确实正确"
我跑这个实验时,不会只看"没死锁"就收工,我会做三件事验证正确性。第一是序号一致性检查:给每个客户一个全局递增编号,服务完成后记录编号,最后比对"服务完成的编号集合"是否等于"生成的编号集合",并且每个编号恰好出现一次。第二是运行时长压力测试:客户线程持续生成 10 万个任务,中途手动调整生成速率(从慢到快再变慢),观察是否出现队列满阻塞或线程饥饿,用这个方式验证信号量初值和唤醒逻辑在动态负载下是否稳定。第三是竞态检测:如果用支持工具链的环境,可以开线程消毒器类检测工具跑一遍小规模用例,它能在运行期报告数据竞争和死锁风险,比自己肉眼盯日志高效得多。
这三个验证手段各有分工。序号检查验证功能正确性,压力测试验证系统稳定性,竞态检测验证内存安全,三者合起来才敢说这个同步实现是可信的。我个人的习惯是每改一次参数就跑一遍序号检查,压力测试放在晚间机器空闲时跑长任务,平时调试依赖日志输出——在入队、出队、服务完成的点各打一行日志,配合线程号就能还原完整时间线。这个排查习惯我留了很久,后来做连接池和任务调度系统时,遇到诡异的偶发问题,第一反应都是先还原时间线而不是猜原因。如果你也卡在并发 bug 上,从这个角度排查一般一小时内能定位,希望帮到你。
本文还有配套的精品资源,点击获取