
简介操作系统课程中的进程间同步/互斥经典案例——银行柜员服务问题适用于学习信号量、P/V 操作与多线程同步的本科生或自学开发者。PDF 共 1 个文件约 370KB完整包含实验题目、实现要求、设计思路与程序结构说明已在 Ubuntu 18.04.5 LTS 环境下以 C/C部分 C11 特性配合 pthread 与 semaphore 库实现并整理成报告已有 1872 人浏览学习。内容覆盖顾客取号、柜员叫号两个互斥对象及顾客—柜员同步机制并给出核心伪码算法Customer 函数、Counter 函数和顾客/柜员结构体、等待队列、互斥锁、信号量的具体定义同时说明测试文本格式与输出要求顾客进入时间、开始服务时间、离开时间及服务柜员号。整份报告将同步互斥概念落到银行服务场景的完整实现上可作为理解操作系统中信号量机制与多线程程序设计的一份有效参考。1. 从一次并发教学实验翻车理解银行柜员服务问题的本质进程间同步互斥问题里的“银行柜员服务问题1”我第一次碰到是在某高校操作系统实验课上。当时的题目很朴素用线程模拟一家银行若干个柜员窗口同时工作客户随机到达后排队等待窗口空闲就从队列里取下一个客户。很快有人交来第一版一个全局标志位锁住队列窗口线程空闲时不停轮询标志位。结果要么客户堆积没人处理要么几个窗口抢同一个客户还有一次CPU占用率直接跑满、控制台半天没反应。那次翻车之后我们才发现银行柜员服务问题不是一个“加个锁就行”的简单互斥它本质上是一个多生产者-多消费者模型客户到达是生产者柜员窗口是消费者共享队列和窗口状态都需要在互斥访问的同时完成同步调度。标题里的“1”对应的是这类问题的第一形态多个柜员共享一条队列按先到先得服务。这篇文章我会把同步模型选型、完整可编译的代码、参数设计和排查经验一次讲透照着做就能在本地复现。2. 同步模型选型信号量、互斥锁还是条件变量2.1 三个候选模型信号量、互斥锁、条件变量的核心差异先看这个问题的约束条件。柜员服务场景里有两类共享资源一类是客户队列本身多线程同时往里面放客户、取客户这必须互斥另一类是“队列里有没有客户”“队列有没有空位”这属于同步条件需要让柜员在队列空时睡下去而不是空转。这三个模型解决的是不同层面的问题。互斥锁只能保证“同一时刻只有一个线程进入临界区”它不能表达“没客户时请等待”的语义——锁不关心临界区里的数据量是多少。条件变量可以做到阻塞等待但它必须搭配一个互斥锁使用代码里要小心处理 wait 时的锁释放与重获。信号量则自带计数能力full 信号量记录队列中的客户数量empty 信号量记录空槽位数量线程通过 P/V 操作天然完成“有货才取”和“有位才放”的同步。对比下来信号量最贴合这个场景的语义。它把“资源数量”直接编码在计数里不需要额外维护一个队列长度的变量而条件变量方案里你至少要维护一个 count 变量还要在 wait 前反复检查谓词。对一次包含 5 到 8 个线程的银行柜员模拟来说信号量的代码量最少、出错模式最直观。2.2 为什么优先选 POSIX 信号量按线程粒度与进程粒度的选型决策POSIX 信号量分两类无名信号量sem_init和有名信号量sem_open。选哪一类取决于你的“进程间同步”到底是不是跨进程。如果你的实验要求是“多进程各自模拟一个柜员”比如用 fork 创建子进程那么必须用有名信号量因为无名信号量只在单个进程内的线程间共享txt 文件或共享内存里的信号量才能让多个进程访问同一个计数器。如果只是多线程模拟柜员我建议一律用无名信号量加 pthread_create。原因很实际sem_open 要指定名字并在结束时限时 sem_unlink名字冲突时会出现 EADDRINUSE 报错教学实验里最容易在这种细节上浪费半小时。下面的选型原则是我在多个模拟项目里验证过的单进程多线程选 sem_init多进程选 sem_open且每个进程都调 sem_open 获取同一个名字如果测试环境对文件系统权限敏感优先选共享内存放置信号量而不是创建实体文件。别一上来就上复杂方案先把线程版跑通再扩展成多进程版。2.3 生命周期与初始化sem_init 和 sem_open 的用法边界初始化这一步藏着很多坑。无名信号量的初始化要指定 pshared 参数0 表示线程共享1 表示进程共享。很多人把 1 当“开启”用结果在单进程多线程程序里也能跑但语义不对换到多进程反而出问题。#include semaphore.h sem_t empty_sem, full_sem, mutex_sem; // 线程共享pshared 0 sem_init(empty_sem, 0, BUFFER_SIZE); // 初始值 空槽数 sem_init(full_sem, 0, 0); // 初始值 0队列为空 sem_init(mutex_sem, 0, 1); // 初始值 1互斥锁 // 进程共享pshared 1多进程场景 // sem_t *s sem_open(/bank_queue, O_CREAT, 0666, BUFFER_SIZE);参数说明第一个参数是信号量对象地址第二个是共享范围第三个是初始计数值。互斥信号量的初始值一定是 1这叫“二进制信号量”但它不等同于 pthread 互斥锁——信号量可以由任何线程发 post互斥锁只能由持锁线程解锁。在柜员场景里客户线程和柜员线程都要操作 empty 和 full所以必须用信号量而不是互斥锁来表达“资源数量”。3. 用 POSIX 信号量实现多柜员服务完整代码与参数说明3.1 模块划分生产者客户到达与消费者柜员窗口的职责整个程序拆成三个角色。客户到达线程负责按随机间隔生成客户编号并放入队列柜员线程负责从队列取出编号并模拟服务耗时主线程负责初始化资源、等待所有线程结束后统一清理。生产者和消费者的 P/V 顺序是核心。以柜员线程为例必须先等待 full确认队列有客户再获取互斥锁 mutex确认当前没有其他线程操作队列。这个顺序不能反否则会出现“拿着锁等资源”的情况——这正是死锁的温床。客户线程则相反先等 empty确认有空间再拿 mutex 入队。两次 P 操作之间不持有任何锁所以两个柜员线程可能同时通过 full 检查但最终只有一个能拿到 mutex 进队列另一个会阻塞在 mutex 上。3.2 可编译运行的 C 实现生产端与消费端#include pthread.h #include semaphore.h #include stdio.h #include stdlib.h #include unistd.h #define BUFFER_SIZE 8 #define WINDOW_COUNT 4 #define CUSTOMER_COUNT 20 int queue[BUFFER_SIZE]; int head 0, tail 0, current_size 0; sem_t empty_sem, full_sem, mutex_sem; void *customer_arrive(void *arg) { for (int i 0; i CUSTOMER_COUNT; i) { // P(empty)确认缓冲区间有空位 sem_wait(empty_sem); // P(mutex)独占队列 sem_wait(mutex_sem); queue[tail] i 1; tail (tail 1) % BUFFER_SIZE; current_size; printf([到达] 客户 %d 进入队列, 队列长度%d\n, i 1, current_size); // V(mutex) 后再 V(full)通知柜员有客户 sem_post(mutex_sem); sem_post(full_sem); usleep(rand() % 500000); // 0~500ms 随机到达间隔 } return NULL; } void *teller_work(void *arg) { long id (long)arg; while (1) { // P(full)没有客户则休眠不空转 sem_wait(full_sem); // P(mutex)独占队列取出一个客户 sem_wait(mutex_sem); int customer queue[head]; head (head 1) % BUFFER_SIZE; current_size--; sem_post(mutex_sem); // V(empty)腾出一个空位唤醒可能阻塞的客户线程 sem_post(empty_sem); printf([服务] 柜员 %ld 开始服务客户 %d, 队列剩余%d\n, id, customer, current_size); usleep(rand() % 400000); // 模拟服务耗时 } return NULL; }这段代码是核心逻辑。注意 full 的 V 操作放在 mutex 之后意思是“入队完成后再通知柜员”避免柜员在队列还没更新完时就被唤醒。同样的empty 的 V 操作放在出队且释放锁之后避免客户线程看到空位但队列实际操作还没完成。柜员线程的 while(1) 是刻意保留的——实验结束时通过主线程直接退出进程简化终止逻辑正式场合应该加退出标志。3.3 线程创建、资源回收与信号量销毁int main(void) { pthread_t tellers[WINDOW_COUNT]; pthread_t customer_thread; long ids[WINDOW_COUNT]; sem_init(empty_sem, 0, BUFFER_SIZE); sem_init(full_sem, 0, 0); sem_init(mutex_sem, 0, 1); // 先启动柜员线程再启动客户线程 for (int i 0; i WINDOW_COUNT; i) { ids[i] i 1; pthread_create(tellers[i], NULL, teller_work, (void *)ids[i]); } pthread_create(customer_thread, NULL, customer_arrive, NULL); pthread_join(customer_thread, NULL); sleep(1); // 给柜员线程一个收尾窗口期 printf([系统] 全部客户已到达强制结束进程\n); return 0; }参数说明先创建柜员线程再创建客户线程是为了让消费端先阻塞在 full 上避免客户到达后没有线程立即消费。这里的 sleep(1) 是演示用的简单做法完整版本应该用“客户数计数器”或退出标志让柜员自然结束。编译命令用gcc -o bank_sem bank_sem.c -pthread跑起来后你第一眼应该看到柜员线程在客户到达前没有任何输出这正是信号量“阻塞等待”的直观体现如果你在这段代码里看到全速空转说明模型选错了。3.4 第一次运行的三件事验证互斥、验证同步、看统计输出跑一次之后不要只看输出热闹。第一连续观察队列长度变化应该始终在 0 到 BUFFER_SIZE 之间不会出现负数或超过 8 的情况第二确认没有两个柜员同时打印“服务同一个客户编号”第三统计最终服务的客户总数应该等于 CUSTOMER_COUNT少一个都说明有竞争问题。我通常会把这个代码再包一层统计逻辑记录每个客户从入队到被取出的时延累加后除以客户数得到平均等待时间。这个数字在参数调优时比肉眼看输出要可靠得多。你不妨把这个最小实现先跑通下一步再动参数。4. 窗口数、队列容量、服务时长三个必调参数与验证方法4.1 三个参数对吞吐和公平性的影响参数调大后的效果调小后的效果典型取值范围WINDOW_COUNT 窗口数并发服务能力提升但 mutex 锁竞争加剧超过阈值后吞吐下降客户排队时间拉长队列堆积2~8取决于 CPU 核数BUFFER_SIZE 队列容量客户几乎不用等空位但内存占用上升极端时掩盖“消费慢”问题客户线程频繁阻塞到达率稍高就丢吞吐4~16SERVICE_TIME 服务耗时柜员占用时间长队列容易堆积线程调度开销占比变大同步开销主导100ms~1s窗口数是最容易“反向优化”的参数。某模拟项目里我在 4 核机器上把窗口数从 4 调到 16吞吐反而掉了 30%。原因是所有柜员共享同一个 mutex_sem出队入队都是串行的窗口线程越多竞争锁的等待开销越大。服务时长也有类似陷阱设成 1ms 以下时usleep 的精度误差加上上下文切换会让信号量同步开销占掉大半时间测出来的吞吐完全失真。4.2 三个度量指标吞吐率、平均等待时延、队列溢出率不要用“看起来快不快”判断参数好坏。我习惯在每个运行周期记录三个指标单位时间完成的客户数吞吐率、客户从入队到被服务的平均时延、队列满导致客户线程等待超时的次数溢出率。对应到代码里就是在客户入队时记录 timeval出队时再取一次时间差。队列溢出率需要额外加一个计数器——当 sem_wait(empty_sem) 前的 trywait 失败时累加。一次实验至少要跑满 200 个客户取平均值单次运行的数据噪声太大。4.3 用命令行参数化实验快速对比不同组合把三个参数全部改成命令行传入是后面所有实验的前提。我的做法是在 main 里用 atoi 解析 argv同时把随机种子也参数化保证同一批客户序列可以复现。下面这段可以直接贴进你的 mainint main(int argc, char *argv[]) { int windows argc 1 ? atoi(argv[1]) : 4; int buffer argc 2 ? atoi(argv[2]) : 8; int customers argc 3 ? atoi(argv[3]) : 20; unsigned int seed argc 4 ? (unsigned int)atoi(argv[4]) : 42; srand(seed); // 后续用 windows / buffer / customers 初始化信号量与数组 }参数说明第一个参数是窗口数第二个是队列容量第三个是客户总数第四个是随机种子。固定种子之后同样的参数跑两次到达序列完全一致这时候比较不同窗口数造成的差异才公平。实验时就一行一行跑./bank_sem 1 8 200 42、./bank_sem 2 8 200 42、./bank_sem 4 8 200 42把三行输出放在一起哪个窗口数收益最大一目了然。5. 柜员服务程序的 5 个踩坑记录死锁、忙等待与信号量失控5.1 现象CPU 占用率 100% 但业务不前进某个版本里柜员线程在 sem_wait(full_sem) 之前加了一个 while 轮询检查 current_size结果每个窗口都在死循环读内存进程的 CPU 占用率直接拉满客户线程反而抢不到时间片。原因是轮询本身没有阻塞while (current_size 0) 在队列空时会把 CPU 时间全部耗尽。解决方法是删掉轮询检查完全依赖 sem_wait 阻塞。信号量的计数机制本身就是在内核里等待唤醒不需要用户态自旋。5.2 现象程序运行到一半静止不动疑似死锁有一次客户线程的代码写成了“先拿 mutex_sem 再等 empty_sem”柜员线程恰好是“先拿 full_sem 再拿 mutex_sem”两边的加锁顺序不一致程序在特定负载下卡死。原因本质是资源获取顺序不具备全局一致性。解决方法是统一规定所有线程都先 P 资源信号量empty 或 full再 P 互斥信号量 mutexV 操作顺序则反过来。把这条规则写在代码注释最顶部作为全文件约定。5.3 现象统计到的服务客户数总是少于到达数在队列满时用了 sem_trywait(empty_sem)失败直接跳过这次入队导致客户丢失。现象是输出里“到达”的客户编号超过 20但“服务”记录永远到不了第 20 个。原因是 trywait 失败后没有重试机制。解决方法是改用阻塞的 sem_wait或者在 trywait 失败后 sleep 一小段再循环尝试。教学演示场景选 sem_wait 最省事它的语义就是“等一个空位”不会丢客户。5.4 现象主线程先退出信号量还没销毁就崩溃刚把 pthread_join 放在客户线程之后柜员线程还在 sleep 服务时主线程就执行到 sem_destroy。现象是程序卡在 join 或者直接 Segmentation Fault。原因是柜员线程没有正常终止信号量还被引用。解决方法是让柜员线程在客户全部到达后通过“总服务数达到 CUSTOMER_COUNT”退出循环再在主线程里逐个 join 所有柜员最后才销毁信号量。5.5 现象偶发一个客户被两个柜员同时打印出队代码里 V(mutex) 提前到了读取客户编号之前另一个柜员线程在队列 head 还没更新时就拿到了锁读到同一个 head 位置的客户。原因是临界区划分不完整。解决方法是把“取客户编号 更新 head 更新 current_size”三件事全部放在同一把 mutex 内完成V(mutex) 只允许出现在完整临界区的最后一行。6. 从信号量到条件变量迁移以及多柜员的吞吐验证技巧6.1 信号量到条件变量的最小改动用 count 替代两个计数器条件变量版本的柜员线程核心代码大概是这样pthread_mutex_t mtx PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond_nonempty PTHREAD_COND_INITIALIZER; int count 0; void *teller_work_cond(void *arg) { while (1) { pthread_mutex_lock(mtx); // 必须用 while 而不是 if防止虚假唤醒 while (count 0) { pthread_cond_wait(cond_nonempty, mtx); } int customer queue[head]; head (head 1) % BUFFER_SIZE; count--; pthread_mutex_unlock(mtx); // 服务客户... } }这个版本的关键差异是条件变量必须与互斥锁配合wait 会自动释放锁、唤醒时自动重获锁。数组里的 head、tail、count 都受同一把锁保护不再需要 empty 和 full 两个信号量。迁移时最容易犯的错是用 if 检查 count应该用 while——唤醒可能来自意外的信号也可能一个广播唤醒了多个线程但客户只有一个。6.2 多柜员吞吐验证画出窗口数与吞吐的关系曲线把第 4 章的命令行参数固定下来只改变窗口数跑 1、2、4、8、12 五组数据记录每组的总耗时。在一台 4 核机器上一般会看到 2 到 4 个窗口时吞吐上升最陡超过核数后增速放缓再往后开始下降。原因是 synchronized 串行区和缓存一致性流量共同作用。每次调参后把这个曲线重新画一遍你就永远不会凭感觉设置窗口数。6.3 一个值得长期保留的检查习惯做并发实验久了我养成了一个习惯任何改动之后先问三句话——加锁顺序是否全局一致P/V 或 lock/unlock 是否一一配对临界区是否只包含必要操作。某次我把 printf 放进临界区里输出量一大整个程序慢了三倍因为锁把打印串行化了。从那以后所有日志输出都放在临界区外面只把队列操作留在锁内。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取