
简介本资源是一份面向操作系统课程学习者与C/C多线程编程初学者的实践型实验材料聚焦进程间同步与互斥核心机制在真实场景中的落地——以银行柜员服务系统为模型完整实现顾客取号、柜员叫号、队列等待、信号量协调P/V操作及线程安全控制。资源以PDF文档形式呈现共1个文件370KB内容涵盖实验题目要求、设计思路、伪代码算法、C11线程与pthread互斥锁/信号量的具体实现细节、测试数据格式说明及标准输出规范并附有结构体定义、关键函数分析与整体架构图。已有1873人学习下载适合高校学生完成操作系统课程实验、理解Dijkstra信号量原理、掌握Linux环境下多线程并发编程与资源竞争控制方法是理论结合实践的典型教学案例。1. 银行柜员服务模拟一个能跑通、能 debug、能改出新调度策略的 POSIX 线程实战项目你有没有试过在 Linux 下写一个多线程程序编译通过、运行不崩但输出时间戳乱序、顾客被重复服务、甚至两个柜员同时叫了同一个号这不是玄学——这是信号量和互斥锁没对齐的真实现场。这个「银行柜员服务问题」项目就是专治这类多线程血泪经验的硬核练手工程它用最朴素的pthreadsem_tpthread_mutex_t在 Ubuntu 18.04 上完整复现了操作系统课里那个经典同步模型——n 个柜员、m 个顾客、取号排队、叫号服务、空闲等待、服务阻塞全部靠 P/V 操作驱动。它不是玩具 demo而是带真实输入文件解析、带秒级时间模拟、带结构化输出到达/开始/离开/柜员号、带线程生命周期管理顾客线程退出、柜员线程可取消的完整闭环。适合刚学完信号量理论、正卡在「知道概念但写不出正确代码」阶段的开发者也适合想快速验证自己对sem_wait/sem_post时序理解是否到位的熟手——因为它的逻辑足够清晰错误足够典型改一行就能看到效果。别被“课程实验”四个字劝退这项目落地性极强把sleep()换成真实 I/O、把queueCustomer*换成共享内存队列、把柜员线程改成工作窃取模式……它就是你调试线程竞争、压测锁粒度、验证调度策略的第一块真实砖。2. 架构与模块拆解从 main() 到 customer_thread() 的五层控制流这个项目表面看是“顾客取号、柜员叫号”实则暗含五层协同时间驱动层 → 输入解析层 → 线程管理层 → 同步原语层 → 数据结构层。每一层都不可省略且错误常藏在层间交接处。下面按实际执行顺序逐层拆解重点讲清每个模块为什么这么设计、关键变量怎么协作、以及它们在最终输出中如何映射。2.1 主控流程main() 中的初始化与线程生命周期编排main()函数是整个系统的指挥中枢它不处理业务逻辑但决定了所有线程何时启动、如何等待、何时收尾。核心动作只有四步但每一步都踩着同步安全的边界int main(int argc, char *argv[]) { // 1. 初始化全局资源互斥锁、信号量、队列、时间基准 pthread_mutex_init(mutex, NULL); sem_init(customer_sem, 0, 0); // 初始无顾客等待 sem_init(counter_sem, 0, COUNTER_NUM); // 初始所有柜员空闲 time(program_start_time); // 2. 解析输入文件构建 customer_list 和 counter_list parse_input_file(input.txt); // 3. 创建线程先顾客后柜员顺序很重要 for (auto c : customer_list) { pthread_create(c.pID, NULL, customer_thread, c); } for (auto cnt : counter_list) { pthread_create(cnt.pID, NULL, counter_thread, cnt); } // 4. 等待所有顾客完成 清理柜员线程 输出结果 for (auto c : customer_list) { pthread_join(c.pID, NULL); // 必须等顾客线程自然结束 } for (auto cnt : counter_list) { pthread_cancel(cnt.pID); // 主动取消柜员线程 pthread_join(cnt.pID, NULL); } write_output_file(output.txt); cleanup_resources(); return 0; }关键参数说明sem_init(customer_sem, 0, 0)第二个参数0表示该信号量不跨进程共享仅本进程内线程可见第三个参数0是初始值——意味着程序启动时没有顾客在等待队列中柜员线程首次sem_wait(customer_sem)会立即阻塞。sem_init(counter_sem, 0, COUNTER_NUM)初始值设为柜员总数代表初始空闲柜员数。每当一个顾客被叫号就sem_post(counter_sem)通知下一个顾客“有柜员可用”。pthread_join(c.pID, NULL)vspthread_cancel(cnt.pID)顾客线程是自然终止型服务完就 return必须join等待其结束柜员线程是长周期运行型while true需用cancel主动中断且中断点必须在pthread_testcancel()处——这正是代码里pthread_setcancelstate(..., PTHREAD_CANCEL_ENABLE)的作用。2.2 输入解析层从 input.txt 到 vector 的结构化建模测试数据格式极其简单id arrive_time service_duration但解析逻辑直接影响后续线程行为的真实性。项目没有用fscanf硬读而是封装了健壮的parse_input_file()它做了三件事按行读取并校验字段数跳过空行、注释行对每行sscanf(line, %d %d %d, id, arrive, dur)失败则报错退出构造 Customer 对象并 push 到 vectorcustomer_list.emplace_back(id, arrive, dur)注意arrive_time是相对时间单位秒用于sleep(arrive_time)模拟顾客到达时刻预分配内存并记录总数customer_list.reserve(expected_count)避免 vector 动态扩容导致的指针失效这点对后续queueCustomer*安全至关重要。为什么用vectorCustomer而不是vectorunique_ptrCustomer因为所有顾客线程都持有Customer引用void* arg转换而来而vector在push_back时若触发 realloc原有元素地址会变导致线程访问野指针。项目选择在解析完成后不再增删 customer_list并确保customer_list生命周期长于所有线程从而规避此风险。这是 C 多线程中典型的“对象生命周期管理”陷阱新手极易翻车。2.3 线程管理层顾客线程与柜员线程的职责分离与协作契约项目将并发逻辑严格划分为两类线程各自承担明确角色并通过信号量建立双向契约线程类型启动时机终止条件核心职责依赖的同步原语顾客线程main()中pthread_createreturn NULL服务完成模拟到达 → 取号入队 → 等待叫号 → 接受服务mutex保护队列、customer_sem通知柜员、counter_sem等待柜员柜员线程main()中pthread_createpthread_cancel()被主控取消等待顾客 → 叫号出队 → 服务顾客 → 释放柜员mutex保护队列/顾客状态、customer_sem等待顾客、counter_sem通知顾客这个契约的关键在于顾客线程只负责“发布需求”柜员线程只负责“响应需求”。没有一个线程既管入队又管出队彻底避免了单点逻辑复杂化。而customer_sem和counter_sem就是这个契约的“电子合同”——customer_sem的值 当前等待队列长度counter_sem的值 当前空闲柜员数。任何一方修改状态都必须通过sem_post/sem_wait原子更新其他线程才能感知。2.4 同步原语层mutex、customer_sem、counter_sem 的三位一体防御项目用三个同步机制构成防御体系各司其职缺一不可pthread_mutex_t mutex保护临界区数据。哪些算临界区✅customer_waiting.push(c)/customer_waiting.front()/customer_waiting.pop()—— 队列操作本身非原子✅cout ...—— 标准输出流是共享资源多线程混写会乱码✅customer.counter_id cnt.id/customer.service_start ...—— 修改顾客状态需保证原子性。❌sleep()、sem_wait()、sem_post()—— 这些函数本身是线程安全的无需加锁。sem_t customer_sem实现顾客到柜员的单向通知。顾客线程sem_post(customer_sem)后柜员线程sem_wait(customer_sem)才能唤醒。它不保护数据只传递“有活干了”的事件信号。初始值0保证柜员不会在无顾客时瞎忙。sem_t counter_sem实现柜员到顾客的单向通知。柜员线程sem_post(counter_sem)后顾客线程sem_wait(counter_sem)才能继续。它解决的是“顾客取号后如何知道哪个柜员空闲了”的问题。初始值COUNTER_NUM让所有顾客上线就能立刻竞争柜员。为什么不用一个信号量搞定因为customer_sem和counter_sem控制的是不同方向的依赖关系前者是“柜员等待顾客”后者是“顾客等待柜员”。如果强行合并比如用sem_t queue_sem表示队列长度那么当队列为空时柜员sem_wait(queue_sem)会阻塞但顾客sem_post(queue_sem)后柜员唤醒却无法知道“现在有几个柜员空闲”仍需额外机制协调。双信号量设计让责任边界清晰是 Dijkstra 原始思想的忠实实践。2.5 数据结构层queueCustomer* 与 vector 的内存布局真相项目用std::queueCustomer* customer_waiting存储等待顾客而非queueCustomer这是经过深思熟虑的// 正确存储指针指向 vector 中已分配的对象 std::queueCustomer* customer_waiting; // 错误存储对象副本会导致 service_start 等状态修改不生效 // std::queueCustomer customer_waiting;原因在于customer_thread和counter_thread都需要修改同一个Customer实例的状态如service_start,counter_id。如果queue存的是对象副本柜员线程修改的是副本顾客线程看到的仍是原始未修改对象输出必然错乱。而queueCustomer*存的是指针所有线程操作的都是customer_list中同一块内存。内存布局图示简化customer_list: [Customer{id1,...}] [Customer{id2,...}] [Customer{id3,...}] ↑地址A ↑地址B ↑地址C customer_waiting: [ptr→A] → [ptr→B] → [ptr→C]柜员线程Customer c *customer_waiting.front()得到的是地址A处的引用修改c.service_start即直接改写了customer_list[0]的字段。这种设计牺牲了一点安全性需确保指针不悬空但换来了状态一致性是系统编程中的常见权衡。3. 核心线程函数逐行解析customer_thread 与 counter_thread 的执行链路现在我们深入到最核心的两段代码——customer_thread()和counter_thread()。它们不是孤立函数而是构成一个请求-响应闭环的两个齿轮。下面逐行拆解标注每行代码的目的、副作用、潜在风险并解释为何必须按此顺序执行。3.1 顾客线程从 sleep 到 return 的七步闭环void *customer_thread(void *arg) { Customer customer *(Customer *) arg; // 1. 获取引用避免拷贝 sleep(customer.arrive_time); // 2. 【时间模拟】阻塞至顾客到达时刻 // 注意此处 sleep 不在 mutex 内避免阻塞其他顾客取号 pthread_mutex_lock(mutex); // 3. 【临界区开始】保护队列操作 cout [CUSTOMER] id customer.id arrive at time(NULL) - program_start_time endl; customer_waiting.push(customer); // 4. 【入队】将自身地址加入等待队列 sem_post(customer_sem); // 5. 【发信号】通知柜员“有新顾客了” pthread_mutex_unlock(mutex); // 6. 【临界区结束】释放锁允许其他线程操作队列 sem_wait(counter_sem); // 7. 【等待响应】阻塞直到被某个柜员叫号 // 此时 customer.service_start 和 counter_id 已被柜员线程设置 pthread_mutex_lock(mutex); // 8. 【临界区再进入】保护输出 cout [CUSTOMER] id customer.id served by customer.counter_id at customer.service_start . Now time : time(NULL) - program_start_time endl; pthread_mutex_unlock(mutex); // 9. 【临界区结束】 sleep(customer.service_duration); // 10. 【服务耗时】模拟服务过程此时顾客已绑定柜员 return NULL; // 11. 【线程终止】顾客服务完成线程退出 }关键逻辑链路说明步骤 2 (sleep) 必须在锁外如果把它放在pthread_mutex_lock之后那么第一个顾客到达时会独占 mutex 长达arrive_time秒后续顾客无法取号彻底破坏并发性。步骤 4 (push) 和步骤 5 (sem_post) 必须在同一个临界区内否则可能出现“柜员看到信号但队列为空”的竞态顾客刚post信号还没来得及push柜员就wait成功并front()了。步骤 7 (sem_wait) 是顾客线程的“分水岭”之前它主动推进取号之后它被动等待被叫号。sem_wait返回时意味着柜员线程已完成customer.service_start和customer.counter_id的赋值因此步骤 8 的输出必然是最新状态。3.2 柜员线程while(true) 中的原子叫号与服务循环void *counter_thread(void *arg) { Counter counter *(Counter *) arg; while (true) { sem_wait(customer_sem); // 1. 【等待需求】阻塞直到有顾客入队 // 此时 customer_sem 值减1表示一个顾客被认领 pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); // 2. 【防中断】禁止在此刻被 cancel // 因为接下来要修改共享状态必须原子完成 pthread_mutex_lock(mutex); // 3. 【临界区开始】保护队列和顾客状态 Customer customer *customer_waiting.front(); // 4. 【取号】获取队首顾客引用 customer.counter_id counter.id; // 5. 【绑定】记录服务柜员号 customer.service_start time(NULL) - program_start_time; // 6. 【打时间戳】记录开始服务时刻 cout [COUNTER] id counter.id serve customer customer.id at customer.service_start . Now time : time(NULL) - program_start_time endl; customer_waiting.pop(); // 7. 【出队】移除已服务顾客 sem_post(counter_sem); // 8. 【发信号】通知顾客“我空闲了轮到你了” pthread_mutex_unlock(mutex); // 9. 【临界区结束】 sleep(customer.service_duration); // 10. 【服务耗时】模拟服务过程此时顾客已绑定 pthread_mutex_lock(mutex); // 11. 【临界区再进入】保护输出 cout [COUNTER] id counter.id finish serving customer customer.id at time(NULL) - program_start_time endl; pthread_mutex_unlock(mutex); // 12. 【临界区结束】 pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL); // 13. 【恢复中断】允许被 cancel pthread_testcancel(); // 14. 【检查点】若收到 cancel 请求则在此处退出 } return NULL; // 理论上不会执行到这里 }关键设计意图解析步骤 2 和步骤 13 的pthread_setcancelstate是灵魂柜员线程在pop()和sem_post(counter_sem)之间处于“半服务”状态——顾客已出队、柜员已承诺服务但服务尚未开始。如果此时被cancel顾客将永远丢失队列里没了柜员也没服务。所以必须禁用 cancel等sem_post发出信号、顾客线程sem_wait返回后再恢复 cancel 能力。步骤 8 (sem_post(counter_sem)) 的位置很讲究它必须在pthread_mutex_unlock(mutex)之前执行。因为sem_post是原子操作而unlock后其他线程可能立即抢到 mutex 并修改队列。如果sem_post放在unlock之后可能出现“柜员解锁 → 其他柜员抢锁 → 发现队列空 → 自己sem_wait阻塞 → 本柜员才sem_post”的延迟导致顾客多等一轮。步骤 4 的Customer customer *customer_waiting.front()是安全的因为front()返回的是队首元素的引用而customer_waiting存的是指针*ptr解引用得到的就是customer_list中原始对象的引用修改它直接影响最终输出。4. 编译、运行与避坑指南Ubuntu 18.04 下的实操排雷手册这个项目在 Ubuntu 18.04.5 LTS 上编译运行看似简单g -stdc11 -lpthread但实际踩坑率极高。很多同学卡在“编译成功但输出乱码/死锁/段错误”根本原因是忽略了 Linux 线程库的隐式约束和 g 的默认行为。以下是我在某高校实验室带学生调试时整理出的5 条高频翻车记录每条都按「现象 → 原因 → 解决」给出可立即执行的方案。4.1 现象程序启动后立即卡死ps aux | grep bankcounter显示进程状态为Duninterruptible sleep原因sem_wait(customer_sem)在main()创建柜员线程后立即执行而此时customer_sem初始值为0且尚无顾客线程sem_post导致所有柜员线程永久阻塞。但更深层原因是——顾客线程创建顺序晚于柜员线程且顾客sleep(arrive_time)延迟了sem_post。解决强制顾客线程先于柜员线程启动在main()中for循环创建顾客线程后加一句usleep(10000)10ms再创建柜员线程。这给了第一个顾客线程足够时间执行到sem_post(customer_sem)。或更优解用 barrier 同步在main()初始化后声明pthread_barrier_t start_barrier; pthread_barrier_init(start_barrier, NULL, COUNTER_NUM customer_list.size());所有线程启动后第一句都pthread_barrier_wait(start_barrier);确保所有线程“同时起跑”。4.2 现象输出文件output.txt中多个顾客的service_start时间相同且早于其arrive_time原因time(NULL) - program_start_time计算的是绝对时间差但program_start_time是main()开始时记录的而顾客线程的sleep(customer.arrive_time)是相对main()启动后的偏移。当多个顾客arrive_time相同如样例中1 1 11、2 1 4、3 1 7它们几乎同时sleep结束并sem_post柜员线程time(NULL)获取的是同一秒内的值导致service_start相同。解决不要用time()做高精度计时改为用clock_gettime(CLOCK_MONOTONIC, ts)获取纳秒级单调时钟struct timespec ts; clock_gettime(CLOCK_MONOTONIC, ts); long long now_ms ts.tv_sec * 1000LL ts.tv_nsec / 1000000LL; // 然后用 now_ms - program_start_ms 计算毫秒级偏移4.3 现象segmentation fault (core dumped)gdb显示崩溃在customer_waiting.front()或customer_waiting.pop()原因customer_waiting是空队列时调用了front()/pop()。这通常发生在——柜员线程sem_wait(customer_sem)返回后认为队列非空但此时另一个柜员线程已抢先pop()导致本线程面对空队列。根本原因是sem_wait只保证“有顾客”不保证“队列非空”因为信号量和队列操作不是原子的。解决必须在pthread_mutex_lock后、front()前再次检查队列是否为空pthread_mutex_lock(mutex); if (customer_waiting.empty()) { pthread_mutex_unlock(mutex); continue; // 重新 wait避免 crash } Customer customer *customer_waiting.front(); // ... rest of code4.4 现象编译时报错undefined reference to sem_init或pthread_mutex_init原因链接器找不到libpthread和librt。虽然-lpthread参数写了但g 链接顺序要求目标文件必须在库之前。如果写成g -lpthread bankcounter.cpp -o bankcounter链接器会忽略-lpthread。解决严格按g [源文件] -o [输出] [选项]顺序g -stdc11 bankcounter.cpp -o bankcounter -lpthread -lrt # 注意-lrt 必须加上因为 clock_gettime 在 librt 中4.5 现象output.txt输出正常但终端cout日志中[CUSTOMER]和[COUNTER]交错混乱甚至同一行出现两段输出原因cout是缓冲流多线程同时写入时操作符不是原子的。例如cout A B可能被拆成write(A)和write(B)中间被其他线程插入。解决所有cout必须包裹在pthread_mutex_lock/unlock中项目代码已做到但还需禁用缓冲// 在 main() 开头添加 std::ios::sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr); // 并确保每次 cout 后 flush cout ... endl; // endl 自带 flush // 或 cout ... flush;提示以上 5 条全是真实发生过的血泪经验。如果你的程序跑不通优先对照这 5 条排查80% 的问题都能当场解决。别急着重写逻辑先看是不是环境或编译链的问题。5. 二次开发实战从 FIFO 调度到优先级叫号的三步改造这个项目最迷人的地方不是它实现了基础功能而是它的架构像乐高一样可以轻松替换核心调度策略。比如原始版本用queueCustomer*实现先进先出FIFO叫号但现实中银行可能有 VIP 顾客插队、老人优先、或者按服务时长排序短作业优先。下面我就带你用三步把 FIFO 改造成基于顾客服务时长的优先级队列Shortest Job First, SJF全程不碰信号量和线程创建逻辑只改数据结构和叫号算法。5.1 第一步替换 queue 为 priority_queue定义比较规则原始代码用#include queue现在我们要换成#include queue#include functional并定义一个按service_duration升序排列的比较器// 替换原来的 std::queueCustomer* #include queue #include functional // 自定义比较器service_duration 小的优先级高SJF struct CompareByDuration { bool operator()(const Customer* a, const Customer* b) const { return a-service_duration b-service_duration; // min-heap } }; std::priority_queueCustomer*, std::vectorCustomer*, CompareByDuration customer_waiting;为什么用而不是priority_queue默认是大顶堆最大值在 top而我们需要最小服务时长在 top所以比较器返回a-dur b-dur这样a的 dur 更小a就会被“推”到顶部。这是 C STL 里容易混淆的点。5.2 第二步修改柜员线程的叫号逻辑适配 priority_queuepriority_queue没有front()和pop()分离操作它的top()返回引用pop()移除顶部元素。所以柜员线程中相关代码要微调// 原来的 queue 代码 // Customer customer *customer_waiting.front(); // customer_waiting.pop(); // 改为 priority_queue 代码 Customer customer *customer_waiting.top(); // 获取服务时长最短的顾客 customer_waiting.pop(); // 移除他注意priority_queue::top()返回的是const Customer*但因为我们存的是指针*top()得到的是Customer可以安全修改service_start和counter_id。无需任何额外 cast。5.3 第三步验证与对比——用同一份 input.txt 测试 FIFO vs SJF准备一份input_sjf.txt包含服务时长差异大的顾客1 0 10 2 0 2 3 0 5 4 0 1分别编译运行 FIFO 版和 SJF 版对比output.txt顾客 IDFIFO 开始时间SJF 开始时间说明100都是第一个到达FIFO 和 SJF 一致210等1服务完21服务中2最短插队SJF 让短作业先做减少平均等待时间31274173计算平均等待时间FIFO顾客2等待10s顾客3等待12s顾客4等待17s → 平均 ≈ 13sSJF顾客2等待2s顾客3等待7s顾客4等待3s → 平均 ≈ 4s提升 69%。这就是调度算法的价值——不用改并发模型只换一个数据结构性能立竿见影。从那以后我每次做线程调度改造都强制走一遍“数据结构 → 算法接口 → 验证对比”三步。不迷信理论用input.txt和output.txt说话。希望帮到你。本文还有配套的精品资源点击获取