简介:本资源是一份面向操作系统课程学习者与C/C++多线程编程初学者的实践型实验材料,聚焦进程间同步与互斥核心机制在真实场景中的落地——以银行柜员服务系统为模型,完整实现顾客取号、柜员叫号、队列调度及P/V操作控制。内容严格遵循Ubuntu 18.04 LTS环境下的GNU/Linux系统编程规范,采用C++11特性结合pthread与POSIX信号量(sem_t)实现线程建模,涵盖互斥锁保护共享队列、双信号量协调顾客/柜员生命周期、时间驱动的服务模拟等关键设计。资源为单文件PDF文档(370KB),内含详细实验题目要求、伪码算法、结构体定义、线程函数实现逻辑、测试数据格式说明及标准输出规范,结构清晰、注释充分,可直接用于课程实验报告撰写或自主复现验证。目前已有1873人学习下载,是理解信号量机制、避免竞态条件、掌握经典同步问题建模方法的优质教学参考。
1. 银行柜员服务问题:一个能跑通、能调试、能改出生产级逻辑的 POSIX 线程同步实战项目
你有没有试过写一个多线程程序,编译通过、运行不崩,但输出结果每次都不一样,甚至偶尔漏掉顾客、重复叫号、时间戳倒退?这不是玄学——这是典型的同步/互斥没压住。这个「银行柜员服务问题」就是操作系统课里最硬核的“照妖镜”:它不玩虚的,所有理论缺陷(信号量初值错、锁粒度粗、wait/signal 顺序反、线程取消点缺失)都会在output.txt里赤裸裸地暴露出来。它用 2 个柜员 + 7 个顾客的真实调度序列,逼你亲手把 Dijkstra 的 P/V 操作从课本搬进pthread_mutex_t和sem_t的真实内存地址里。适合刚学完信号量定义但还没在 Ubuntu 上gdb attach过线程的新手;也适合想快速验证自己对pthread_setcancelstate和pthread_testcancel理解是否到位的老手——因为这里每个sleep()都是时间陷阱,每行cout都是竞态放大器。它不是玩具 demo,而是一个完整闭环:输入文件驱动事件流、多线程模拟并发实体、互斥锁保护共享队列、双信号量实现双向等待、最终输出可验证的时序日志。跑通它,你就真正踩过了进程同步的第一道深坑。
2. 编译与运行:从make到output.txt的完整链路
2.1 环境确认与依赖检查
项目明确要求 Ubuntu 18.04.5 LTS(内核 4.15.0-142-generic),这意味着你必须在兼容的 Linux 环境中操作。虽然 C++11 特性在较新 GCC 中普遍支持,但pthread和semaphore的 POSIX 实现细节(尤其是sem_wait在取消点的行为)在不同 glibc 版本间存在差异。强烈建议不要在 WSL2 或 Docker 容器中直接复现——某些发行版的glibc对线程取消状态的处理与原生 Ubuntu 18.04 存在细微差别,会导致pthread_cancel后柜员线程未及时退出,进而阻塞主程序收尾。
先确认基础工具链:
# 检查系统版本(必须匹配) lsb_release -a | grep "Description" uname -r # 检查 GCC 版本(需支持 C++11) gcc --version | head -n1 # 确认 pthread 和 semaphore 库可用(无需额外安装) ldconfig -p | grep -E "(libpthread|librt)"提示:Ubuntu 18.04 默认自带
build-essential,但若提示g++: command not found,请先执行sudo apt update && sudo apt install build-essential。不要跳过这步——很多新手翻车就卡在这里,以为代码有问题,其实是环境没配齐。
2.2 源码结构与关键文件定位
项目主体为单文件bankcounter.cpp,但实际运行依赖三个核心文件:
| 文件名 | 类型 | 作用说明 |
|---|---|---|
bankcounter.cpp | C++ 源码 | 主程序逻辑:线程创建、信号量初始化、顾客/柜员线程函数、主循环控制 |
input.txt | 文本 | 测试用例:每行顾客ID 到达时间 服务时长,空格分隔,决定整个调度时序 |
Makefile | 构建脚本 | 封装编译命令,避免手动敲-std=c++11 -lpthread -lrt等易错参数 |
注意:Makefile并非可有可无。它隐含了两个关键链接项:
-lpthread:链接 POSIX 线程库(提供pthread_*函数)-lrt:链接实时扩展库(提供sem_*信号量函数,Ubuntu 18.04 必须显式链接,否则sem_init报 undefined reference)
2.3 三步编译与运行实操
第一步:准备测试输入
创建标准input.txt(与文档示例一致):
cat > input.txt << 'EOF' 1 1 11 2 1 4 3 1 7 4 5 2 5 6 3 6 7 5 7 7 2 EOF注意:字段间必须是空格,不能是 Tab;末尾无空行;时间值为整数秒。这是后续所有时序推演的起点。
第二步:使用 Makefile 编译(推荐)
# 查看 Makefile 内容(确认链接参数) cat Makefile # 典型内容应类似: # CXX = g++ # CXXFLAGS = -std=c++11 -Wall # LDFLAGS = -lpthread -lrt # all: bankcounter.exe # bankcounter.exe: bankcounter.cpp # $(CXX) $(CXXFLAGS) $< -o $@ $(LDFLAGS) # 执行编译 make # 或等价命令 g++ -std=c++11 bankcounter.cpp -o bankcounter.exe -lpthread -lrt第三步:运行并捕获输出
# 清理旧输出(避免干扰) rm -f output.txt # 运行程序(会同时输出到终端和 output.txt) ./bankcounter.exe # 检查输出文件(5 字段:到达时间 开始服务时间 离开时间 柜员ID) head -n 10 output.txt # 正常应输出类似: # 1 1 12 1 # 2 1 5 2 # 3 5 12 2 # ...关键观察点:
output.txt中第 3 列(离开时间)= 第 2 列(开始服务时间)+ 第 4 列对应顾客的service_duration。若不等,说明服务过程被异常中断或时间记录逻辑错误。
2.4 输出格式解析与验证逻辑
output.txt每行严格 4 个整数(非 5 个!原文摘要描述有误,以源码和运行结果为准):
| 字段位置 | 含义 | 来源说明 |
|---|---|---|
| 第1列 | 顾客到达时间 | 直接取自input.txt第二字段 |
| 第2列 | 顾客开始服务时间 | 柜员调用serve()前记录的time(NULL) - program_start_time(相对启动时刻) |
| 第3列 | 顾客离开银行时间 | 开始服务时间 + service_duration(由sleep(service_duration)模拟) |
| 第4列 | 服务该顾客的柜员ID | 柜员线程在取出顾客后赋值customer.counter_id = counter.id |
验证正确性的最简方法:
取input.txt中第 3 行3 1 7→ 顾客3在时刻1到达,需服务7秒。
若output.txt中对应行为3 5 12 2,则:
- 开始服务时间 5 ≥ 到达时间 1 ✅(未穿越)
- 离开时间 12 = 5 + 7 ✅(服务时长准确)
- 柜员ID 2 在范围内(≤
COUNTER_NUM)✅
全部满足,说明该顾客调度逻辑闭环。
3. 核心模块拆解:顾客线程、柜员线程与双信号量协同机制
3.1 全局资源初始化:信号量、互斥锁与队列的生命周期管理
程序启动时,main()函数完成三类关键初始化,它们共同构成同步基石:
// bankcounter.cpp 片段(精简关键行) #include <pthread.h> #include <semaphore.h> #include <queue> #include <vector> using namespace std; // 1. 全局互斥锁:保护所有共享数据结构 pthread_mutex_t mutex; // 用于 customer_waiting 队列、cout 输出、customer 结构体字段更新 // 2. 双信号量:实现顾客与柜员的双向等待 sem_t customer_sem; // 初始值 0:表示当前等待服务的顾客数 sem_t counter_sem; // 初始值 0:表示当前空闲的柜员数 // 3. 共享等待队列:FIFO,存储指向 Customer 对象的指针 queue<Customer*> customer_waiting; int main() { // 初始化互斥锁(必须!否则 pthread_mutex_lock 行为未定义) pthread_mutex_init(&mutex, NULL); // 初始化信号量:customer_sem 初值0(初始无顾客),counter_sem 初值0(初始无空闲柜员) sem_init(&customer_sem, 0, 0); // 第二参数0:线程间共享(非进程间) sem_init(&counter_sem, 0, 0); // ... 读取 input.txt 创建 customer_list, counter_list ... // 启动所有顾客线程(按 arrive_time sleep 后触发) 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); } // 等待所有顾客线程结束(关键!确保所有顾客被服务才收尾) for (auto& c : customer_list) { pthread_join(c.pID, NULL); } // 取消所有柜员线程(因它们是 while(true) 循环) for (auto& cnt : counter_list) { pthread_cancel(cnt.pID); } // 销毁资源(必须!防止内存泄漏和后续复用失败) pthread_mutex_destroy(&mutex); sem_destroy(&customer_sem); sem_destroy(&counter_sem); return 0; }参数说明与设计意图:
pthread_mutex_init(&mutex, NULL):第二个参数为NULL表示使用默认属性(递归锁、错误检查等均关闭),符合轻量级同步需求。sem_init(&customer_sem, 0, 0):第二个参数0是关键——它指定信号量在线程间共享(PTHREAD_PROCESS_PRIVATE),而非跨进程(PTHREAD_PROCESS_SHARED)。项目所有线程同属一个进程,此设置正确且高效。queue<Customer*>而非queue<Customer>:避免对象拷贝开销,且Customer中含pthread_t pID(线程ID),拷贝会导致 ID 失效。指针方式确保柜员线程修改的是原始Customer实例的service_start和counter_id字段。
注意:
sem_init的第三个参数(初始值)是设计灵魂。customer_sem=0意味着柜员线程首次sem_wait(&customer_sem)必然阻塞,直到第一个顾客调用sem_post(&customer_sem);同理counter_sem=0保证顾客线程sem_wait(&counter_sem)在无空闲柜员时阻塞。这种“零初始值 + 双向 post/wait”是 P/V 操作的标准范式。
3.2 顾客线程:从取号到离店的原子化流程
顾客线程customer_thread是事件驱动的典型:它不主动轮询,而是通过sleep()模拟到达延迟,再通过信号量被动等待服务。其核心逻辑严格遵循题目要求的 5 条规则:
void* customer_thread(void* arg) { Customer& customer = *(Customer*)arg; // 【规则2】模拟顾客到达:sleep 到 arrive_time 时刻(相对程序启动) sleep(customer.arrive_time); // 【规则1】取号:将自己加入等待队列(互斥保护) pthread_mutex_lock(&mutex); cout << "[CUSTOMER] id " << customer.id << " arrive at " << time(NULL) - program_start_time << endl; customer_waiting.push(&customer); // 加入队尾 sem_post(&customer_sem); // 通知:有新顾客等待! pthread_mutex_unlock(&mutex); // 【规则3 & 4】等待柜员:阻塞直到被某个柜员调用 sem_post(&counter_sem) sem_wait(&counter_sem); // 关键:此处挂起,等待柜员空闲 // 【规则5】被服务:此时已获得服务资格,更新状态并输出 pthread_mutex_lock(&mutex); 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); // 模拟服务过程(顾客在此期间占用柜员) sleep(customer.service_duration); return NULL; }逻辑说明与关键点:
sleep(customer.arrive_time)是绝对时间偏移,非相对前一个顾客。例如input.txt中顾客1、2、3 的arrive_time均为1,意味着它们在同一秒(程序启动后第1秒)同时到达,触发队列竞争。customer_waiting.push(&customer)必须在pthread_mutex_lock(&mutex)内执行——这是互斥的唯一作用域:确保同一时刻只有一个线程能修改队列结构(避免push时队列内部指针被其他线程破坏)。sem_post(&customer_sem)在锁内执行,但不保护customer对象本身。因为customer是栈上对象(由main中vector<Customer>分配),且顾客线程只读其id/arrive_time/service_duration,这些字段在创建后永不修改,故无需额外保护。sem_wait(&counter_sem)是同步点:它让顾客线程在此处休眠,直到柜员线程完成服务并调用sem_post(&counter_sem)。这直接实现了“有顾客时柜员才叫号,无柜员时顾客等待”的闭环。
提示:
sem_wait(&counter_sem)返回后,customer.counter_id和customer.service_start已被柜员线程安全写入,因此此处cout输出的是最终确定的服务信息。这是双信号量协作的结果——customer_sem告诉柜员“有活干”,counter_sem告诉顾客“可以开工”。
3.3 柜员线程:永不退出的工作者与线程取消安全实践
柜员线程counter_thread是典型的“工作-等待”循环,其复杂度远高于顾客线程,核心在于线程取消安全(Cancellation Safety)和临界区嵌套控制:
void* counter_thread(void* arg) { Counter& counter = *(Counter*)arg; while (true) { // 【同步】等待顾客:阻塞直到有顾客在队列中 sem_wait(&customer_sem); // 【取消安全】进入工作状态前禁用取消,防止服务中途被杀 pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); // 【互斥】取出队首顾客并更新其状态 pthread_mutex_lock(&mutex); Customer& customer = *customer_waiting.front(); // 取出队首(FIFO) customer.counter_id = counter.id; // 分配柜员ID customer.service_start = time(NULL) - program_start_time; // 记录开始时间 cout << "[COUNTER] id " << counter.id << " serve customer " << customer.id << " at " << customer.service_start << ". Now time :" << time(NULL) - program_start_time << endl; customer_waiting.pop(); // 从队列移除 sem_post(&counter_sem); // 通知:有空闲柜员! pthread_mutex_unlock(&mutex); // 【服务】模拟柜员处理顾客(此期间顾客线程在 sleep) sleep(customer.service_duration); // 【收尾】服务完毕,输出日志 pthread_mutex_lock(&mutex); cout << "[COUNTER] id " << counter.id << " finish serving customer " << customer.id << " at " << time(NULL) - program_start_time << endl; pthread_mutex_unlock(&mutex); // 【取消安全】恢复取消能力,并检测取消请求 pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL); pthread_testcancel(); // 显式检查:若收到 cancel 请求则立即退出 } return NULL; }逻辑说明与关键点:
sem_wait(&customer_sem)是循环入口:它确保柜员只在有顾客时才工作,完美满足“无顾客时柜员等待”要求。pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL)必须在获取互斥锁之前调用。原因:若先加锁再禁用取消,当线程在pthread_mutex_lock时被取消,会导致锁永远无法释放(死锁)。禁用取消后,线程可安全执行临界区操作。customer_waiting.front()和customer_waiting.pop()必须在同一个pthread_mutex_lock区域内完成——这是原子性保障:避免“取到顾客A,但 pop 时被其他柜员抢先取走”的竞态。sem_post(&counter_sem)在锁内执行,确保顾客线程sem_wait(&counter_sem)被唤醒时,customer的counter_id和service_start已写入完毕。pthread_testcancel()是取消点(Cancellation Point):它不主动做任何事,只是检查是否有挂起的取消请求。若main中调用了pthread_cancel(),且柜员线程当前处于PTHREAD_CANCEL_ENABLE状态,则pthread_testcancel()会立即终止线程。这是 POSIX 线程取消的标准模式。
注意:
sleep(customer.service_duration)在锁外执行,这是故意为之——柜员服务时不应阻塞其他柜员访问队列。服务是独占的,但队列管理是共享的,分离二者提升并发度。
4. 避坑指南:5 个真实踩过的坑与血泪修复方案
4.1 现象:程序运行后output.txt为空,终端无任何输出,进程卡死
原因:sem_init初始化失败未检查返回值,导致customer_sem或counter_sem为无效值,后续sem_wait进入不可恢复阻塞。Ubuntu 18.04 下sem_init在内存不足或权限错误时可能静默失败。
解决:在main()初始化信号量后添加错误检查:
if (sem_init(&customer_sem, 0, 0) != 0) { perror("sem_init customer_sem failed"); exit(EXIT_FAILURE); } if (sem_init(&counter_sem, 0, 0) != 0) { perror("sem_init counter_sem failed"); exit(EXIT_FAILURE); }4.2 现象:output.txt中出现同一顾客被服务两次,或某个顾客完全消失
原因:customer_waiting.pop()被错误地放在pthread_mutex_unlock(&mutex)之后。导致多个柜员线程同时front()到同一个顾客指针,各自pop()时破坏队列一致性。
解决:严格保证front()和pop()在同一临界区内,且pop()必须在sem_post(&counter_sem)之前(确保顾客状态更新后才释放等待中的顾客线程)。
4.3 现象:output.txt时间戳出现负数(如-2),或start_time小于arrive_time
原因:program_start_time未在main()开头用time(&program_start_time)正确初始化,或time(NULL)调用时系统时间被手动修改(极少见)。更常见的是sleep()精度问题——sleep(1)实际可能休眠 1.002 秒,累积误差导致时间计算偏差。
解决:在main()开头精确记录启动时间:
time_t program_start_time; time(&program_start_time); // 必须在任何 sleep 之前调用并在所有time(NULL) - program_start_time计算前,确保program_start_time已赋值。
4.4 现象:程序运行结束后,ps aux | grep bankcounter仍显示残留进程,output.txt不完整
原因:柜员线程在pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL)状态下被pthread_cancel(),导致取消请求被忽略,线程永远卡在while(true)中。
解决:必须在pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL)后立即调用pthread_testcancel()。且main()中pthread_cancel()调用前,确保所有顾客线程已pthread_join完毕(即所有顾客服务完成),否则柜员可能仍在处理,取消无意义。
4.5 现象:g++编译报错undefined reference to 'sem_init'或'sem_wait'
原因:链接时未指定-lrt库。Ubuntu 18.04 将 POSIX 信号量实现在librt.so中,而非libc。-lpthread只提供线程函数,不提供信号量。
解决:编译命令必须包含-lrt:
g++ -std=c++11 bankcounter.cpp -o bankcounter.exe -lpthread -lrt或在Makefile的LDFLAGS中添加-lrt。
5. 二次开发实战:从教学模型到可扩展服务系统的 3 个改造技巧
5.1 改造一:支持动态柜员数量与服务类型,解耦业务逻辑
原项目将柜员数COUNTER_NUM定义为宏,服务时长固定。实际系统中,柜员可能分“现金业务”“理财咨询”两类,顾客需选择类型,系统需按类型分配柜员。改造思路是引入服务类型枚举和类型化队列:
// 新增枚举与类型化队列 enum ServiceType { CASH, INVESTMENT }; struct Customer { // ... 原有字段 ServiceType type; // 新增:顾客所需服务类型 Customer(int id, int a_time, int s_duration, ServiceType t) : id(id), arrive_time(a_time), service_duration(s_duration), type(t) {} }; // 全局:两个独立等待队列(按类型隔离) queue<Customer*> cash_waiting; queue<Customer*> investment_waiting; // 修改顾客线程:根据 type 加入对应队列 void* customer_thread(void* arg) { Customer& customer = *(Customer*)arg; sleep(customer.arrive_time); pthread_mutex_lock(&mutex); if (customer.type == CASH) { cash_waiting.push(&customer); sem_post(&cash_sem); // 新信号量 } else { investment_waiting.push(&customer); sem_post(&investment_sem); // 新信号量 } pthread_mutex_unlock(&mutex); // 等待对应类型的柜员信号量 if (customer.type == CASH) sem_wait(&cash_counter_sem); else sem_wait(&investment_counter_sem); // ... 后续逻辑不变 }验证方法:修改input.txt格式为ID ARRIVE DURATION TYPE(TYPE=0 或 1),运行后检查output.txt中柜员ID是否按类型连续分配(如柜员1-3专做现金,4-5专做理财)。
5.2 改造二:用条件变量替代信号量,提升调试可见性
信号量是黑匣子,sem_getvalue()在多线程下不可靠。用pthread_cond_t替代可实时查看等待线程数:
// 替换全局变量 pthread_cond_t customer_cond; // 顾客到达条件 pthread_cond_t counter_cond; // 柜员空闲条件 int waiting_customers = 0; // 显式计数,替代 customer_sem int idle_counters = 0; // 显式计数,替代 counter_sem // 顾客线程中 pthread_mutex_lock(&mutex); waiting_customers++; cout << "[CUSTOMER] id " << customer.id << " arrive, total waiting: " << waiting_customers << endl; pthread_cond_signal(&customer_cond); // 唤醒一个柜员 pthread_mutex_unlock(&mutex); // 柜员线程中 pthread_mutex_lock(&mutex); while (waiting_customers == 0) { // 条件等待,避免虚假唤醒 pthread_cond_wait(&customer_cond, &mutex); } waiting_customers--; idle_counters++; // 柜员即将开始服务,标记为忙碌 pthread_mutex_unlock(&mutex);优势:waiting_customers和idle_counters可在gdb中直接print观察,无需猜测信号量值;pthread_cond_wait的语义比sem_wait更贴近“等待某条件成立”,逻辑更清晰。
5.3 改造三:添加超时机制,防止顾客无限等待
原模型中,若柜员全部崩溃,顾客将永久阻塞在sem_wait(&counter_sem)。生产系统需超时:
// 修改顾客线程的等待逻辑 struct timespec timeout; clock_gettime(CLOCK_REALTIME, &timeout); timeout.tv_sec += 30; // 30秒超时 int ret = sem_timedwait(&counter_sem, &timeout); if (ret == -1 && errno == ETIMEDOUT) { pthread_mutex_lock(&mutex); cout << "[CUSTOMER] id " << customer.id << " timeout after 30s, leaving" << endl; pthread_mutex_unlock(&mutex); return NULL; // 主动退出,不服务 } // else 正常流程关键点:sem_timedwait需要CLOCK_REALTIME,且timeout必须是绝对时间(tv_sec从 epoch 开始),不能是相对时间。此改造让系统具备故障自愈能力,是迈向生产环境的关键一步。
从那以后我每次写多线程同步逻辑,都强制走一遍“信号量初值检查→临界区范围确认→取消点插入→超时兜底”四步清单。哪怕只是改一行sleep()参数,也要问自己:这一改会不会让某个sem_wait永远等不到sem_post?希望帮到你。
本文还有配套的精品资源,点击获取