简介面向操作系统课程实验场景适合需要动手完成线程同步实验并查看可运行代码的高校本科生或自学者。代码以 C 语言为主共 199 个文件包含 35 个 .c 源文件、29 个 .h 头文件以及 4 个汇编 .s 文件同时给出 makefile 构建脚本、38 个 .o 目标文件和 .bin 内核镜像可支撑从编译到运行的完整链路。压缩包整体仅 1.28MB结构紧凑其中 svn-base 等版本控制记录也为观察工程演化提供了参考。内容覆盖 kbd 键盘输入、graphics 图形输出、tlsf 内存分配、dosfs 文件系统、machdep 机器相关层等内核模块便于把线程同步放回真实操作系统环境中理解。目前已有 124 人学习适合需要完整示例、可运行工程和模块对照的读者快速上手。 操作系统实验这事儿说难不难说简单也不简单。尤其是线程同步这块很多同学第一次做实验的时候代码照着书敲了一遍编译也过了运行也跑起来了但让他解释“为什么需要同步”、“锁到底锁住了什么”就卡壳了。我当年也是从这个阶段过来的后来带过几届学弟学妹做实验发现大家的问题出奇一致不是不会用pthread而是没理解线程同步解决的本质矛盾。这篇文章以经典的“生产者-消费者”模型为例从问题抽象、核心机制到完整代码、踩坑复盘一步步拆给你看代码可以直接复制运行但更重要的是把背后的原理讲透。1. 实验前必须想明白的一件事线程为什么需要同步1.1 一个生活化类比只有一个灶台的厨房想象一个厨房灶台只有一个厨师A负责煎牛排厨师B负责煮意面。如果两个人同时把锅放上去锅就废了。线程同步要解决的本质上就是“多个线程竞争同一个共享资源”的问题。在这个类比里灶台就是共享资源两个厨师就是线程。操作系统里也是一样。多个线程共享进程的地址空间这意味着它们可以同时读写同一个全局变量、同一个缓冲区、同一个文件描述符。如果没有任何约束两个线程同时往一个缓冲区里写数据后果不堪设想数据覆盖、逻辑错乱、程序崩溃甚至死锁。这些都是实验报告里最常见的“运行结果异常”的根源。1.2 没有同步会发生什么我在实验课上见过太多类似的场景代码逻辑明明是对的但运行结果时对时错。比如生产者线程往缓冲区里写了一个数字5消费者线程读出来却变成了乱七八糟的值。原因是两个线程的指令在CPU上交替执行中间穿插了其他操作导致“写一半、读一半”的尴尬局面。用一个具体的例子来说明。假设缓冲区只有一个位置生产者写入需要两步第一步把值放进buffer[0]第二步把count加1。消费者读取也需要两步第一步读count是否为1第二步读buffer[0]的值。如果生产者在第一步执行完、第二步还没执行时消费者恰好来检查count它发现count还是0于是判断缓冲区为空直接跳过了读取——这个数据就丢了。这就是经典的“竞态条件”Race Condition。竞态条件程序的执行结果依赖于多个线程的交替执行顺序导致结果不确定甚至在极端情况下崩溃。明白了这一点你才能真正理解为什么需要在代码里加锁、加条件变量——不是为了“照搬模板”而是为了干掉竞态条件。2. 实验环境与工具准备2.1 环境说明这个实验我推荐在Linux环境下做用C语言加POSIX线程库pthread。Ubuntu、CentOS、Debian都行macOS的终端也可以不过要用clang替代gcc。Windows自带的开发环境不太适合直接跑pthread如果你只有Windows建议装个虚拟机或者用Windows Subsystem for LinuxWSL2。检查环境是否就绪打开终端执行gcc --version如果提示找不到gcc先装编译工具链sudo apt update sudo apt install build-essential安装完成后确认pthread库可用通常libpthread是glibc的一部分默认就带了编译时加-lpthread链接即可。2.2 代码组织结构整个实验的代码我拆成几个模块来写这样清晰也方便调试共享缓冲区一个固定大小的数组加环形队列指针生产者线程函数往缓冲区里放数据消费者线程函数从缓冲区里取数据主函数初始化资源、创建线程、回收线程、销毁资源缓冲区使用环形队列实现这比线性队列更贴近真实场景而且能充分利用数组空间。3. 核心机制剖析互斥锁与条件变量3.1 互斥锁Mutex保护临界区互斥锁是最基础的同步原语。它的作用非常简单保证同一时间只有一个线程进入临界区Critical Section。临界区就是访问共享资源的那段代码。使用互斥锁有三个基本操作pthread_mutex_lock(mutex)加锁。如果锁已被其他线程持有当前线程会阻塞等待pthread_mutex_unlock(mutex)解锁。唤醒等待这个锁的其他线程pthread_mutex_init(mutex, NULL)初始化锁这里有个关键点锁保护的不是“变量”而是“代码区域”。你得保证所有访问共享变量的地方都在锁的保护范围内否则锁就形同虚设。3.2 条件变量Condition Variable解决“忙等待”只有互斥锁还不够。考虑生产者消费者的场景如果缓冲区满了生产者需要等待消费者拿走数据如果缓冲区空了消费者需要等待生产者放入数据。用互斥锁怎么实现这种“等待”两个办法第一个办法是忙等待busy waiting在循环里不停检查缓冲区状态发现不满就插入否则继续循环。问题很明显CPU空转效率极低。第二个办法就是条件变量线程在条件不满足时主动睡眠释放CPU等条件满足时被唤醒。这才是正确的做法。条件变量有三个核心操作pthread_cond_wait(cond, mutex)原子性地释放mutex并阻塞等待被唤醒后重新获取mutexpthread_cond_signal(cond)唤醒一个等待该条件变量的线程pthread_cond_broadcast(cond)唤醒所有等待该条件变量的线程注意pthread_cond_wait的“原子性”非常关键。它保证从“释放锁”到“进入睡眠”这两个动作是一体的不会出现“锁已经释放了但还没睡下去其他线程恰好插进来操作共享变量”的竞态窗口。3.3 为什么必须成对使用很多同学拿到的模板代码里总是锁和条件变量一起出现但没想过为什么。简单说条件变量必须配合互斥锁使用因为条件本身的检查比如count BUFFER_SIZE就是对共享变量的访问这个访问必须放在锁的保护下。而且pthread_cond_wait在阻塞前会释放锁所以不会死锁。我自己总结的一句话是“等待条件前先加锁条件满足后释放锁。”凡是违背这个口诀的代码运行起来迟早出问题。4. 完整代码实现可直接运行4.1 共享缓冲区定义#include stdio.h #include stdlib.h #include pthread.h #include unistd.h #include time.h #define BUFFER_SIZE 5 #define PRODUCER_NUM 2 #define CONSUMER_NUM 2 #define ITER_NUM 10 int buffer[BUFFER_SIZE]; int in 0; // 下一个可写位置 int out 0; // 下一个可读位置 int count 0; // 当前缓冲区中数据个数 pthread_mutex_t mutex PTHREAD_MUTEX_INITIALIZER; pthread_cond_t not_full PTHREAD_COND_INITIALIZER; pthread_cond_t not_empty PTHREAD_COND_INITIALIZER;全局共享变量用static修饰也可以但为了直观这里直接用全局变量。锁和条件变量用宏PTHREAD_MUTEX_INITIALIZER和PTHREAD_COND_INITIALIZER静态初始化省去在main里调init函数。4.2 生产者线程逻辑void *producer(void *arg) { int id *(int *)arg; for (int i 1; i ITER_NUM; i) { int item id * 100 i; // 用id区分不同生产者 usleep(rand() % 100000); // 模拟生产耗时 pthread_mutex_lock(mutex); while (count BUFFER_SIZE) { printf([P%d] buffer full, waiting...\n, id); pthread_cond_wait(not_full, mutex); } buffer[in] item; in (in 1) % BUFFER_SIZE; count; printf([P%d] produce item: %d, count%d\n, id, item, count); pthread_cond_signal(not_empty); pthread_mutex_unlock(mutex); } return NULL; }这里有一个极其重要的细节while而不是if。为什么因为pthread_cond_wait被唤醒后不代表条件一定满足可能是虚假唤醒spurious wakeup也可能是被signal唤醒但另一条线程抢先消费了数据。使用while循环重新检查条件才是安全的做法。这是面试中常考的点也是实验报告里值得写一笔的地方。4.3 消费者线程逻辑void *consumer(void *arg) { int id *(int *)arg; for (int i 1; i ITER_NUM; i) { usleep(rand() % 100000); // 模拟消费耗时 pthread_mutex_lock(mutex); while (count 0) { printf([C%d] buffer empty, waiting...\n, id); pthread_cond_wait(not_empty, mutex); } int item buffer[out]; out (out 1) % BUFFER_SIZE; count--; printf([C%d] consume item: %d, count%d\n, id, item, count); pthread_cond_signal(not_full); pthread_mutex_unlock(mutex); } return NULL; }生产者和消费者的逻辑是对称的一个等“不满”一个等“不空”。生产者生产完唤醒消费者消费者消费完唤醒生产者。这里的signal操作也是关键——释放锁之前唤醒保证唤醒后的线程能顺利拿到锁如果放在解锁之后逻辑上也可以但可能造成一次多余的上下文切换。4.4 主函数与线程创建int main() { pthread_t producers[PRODUCER_NUM]; pthread_t consumers[CONSUMER_NUM]; int producer_ids[PRODUCER_NUM]; int consumer_ids[CONSUMER_NUM]; srand(time(NULL)); // 创建生产者线程 for (int i 0; i PRODUCER_NUM; i) { producer_ids[i] i; pthread_create(producers[i], NULL, producer, producer_ids[i]); } // 创建消费者线程 for (int i 0; i CONSUMER_NUM; i) { consumer_ids[i] i; pthread_create(consumers[i], NULL, consumer, consumer_ids[i]); } // 回收线程资源 for (int i 0; i PRODUCER_NUM; i) { pthread_join(producers[i], NULL); } for (int i 0; i CONSUMER_NUM; i) { pthread_join(consumers[i], NULL); } // 销毁同步原语 pthread_mutex_destroy(mutex); pthread_cond_destroy(not_full); pthread_cond_destroy(not_empty); printf(All threads finished. Final count %d\n, count); return 0; }pthread_create传参时把线程id作为参数传入线程函数这样多个线程就能区分彼此。这里注意传producer_ids[i]不能用循环变量i的地址否则所有线程拿到的可能是同一个值。5. 编译运行与结果分析5.1 编译指令保存代码为prod_cons.c然后执行gcc -o prod_cons prod_cons.c -lpthread -Wall -Wextra说明一下几个编译选项的坑-lpthread必须放在源文件后面因为gcc的链接是顺序执行的它会从左到右扫描文件如果库放在源文件前面库里的符号可能找不到。这个问题我遇到过无数次每次都能坑到人。-Wall -Wextra打开所有警告。强烈建议打开很多内存问题、类型问题都能在编译阶段暴露出来。如果你的系统没有安装gcc或者用的是macOS可以这样编译clang -o prod_cons prod_cons.c -lpthread5.2 运行结果运行程序./prod_cons一次典型的输出如下因为线程调度顺序随机每次输出都会不同[P1] produce item: 101, count1 [P0] produce item: 1, count2 [C0] consume item: 101, count1 [P0] produce item: 2, count2 [C1] consume item: 1, count1 [P1] buffer full, waiting... [C0] consume item: 2, count0 [P1] produce item: 102, count1 ... All threads finished. Final count 0注意最后一行Final count 0。这说明所有生产者生产的数据都被消费者消费完了缓冲区最终为空。这是一个非常关键的验证点如果最终count不为0说明消费者线程没有把数据消费完大概率是逻辑错误。5.3 结果分析观察输出你会发现“buffer full”和“buffer empty”交替出现这是正常的说明条件变量在正常工作。生产者发现缓冲区满了就自我阻塞把CPU让给其他线程消费者消费后唤醒它它再继续生产。还值得注意的一点是两个生产者之间的生产顺序是随机的两个消费者之间的消费顺序也是随机的但这不影响正确性——因为缓冲区里的每个数据都被且仅被消费一次。这就是线程同步要达成的最终目标并发执行但结果可预期。6. 实验中最容易踩的坑与调试心得6.1 坑一数据竞争导致的神秘崩溃还记得我第一次写这个程序时跑了一段时间程序突然segmentation fault。排查了很久发现是缓冲区下标越界了。原因是生产者线程里没用while而是用了if被唤醒后直接往缓冲区写数据但此刻缓冲区其实已经又满了in指针跑出了数组边界。这个教训让我记住了条件变量永远配while循环。6.2 坑二死锁的典型场景死锁在这个实验里不太常见但如果把pthread_cond_signal放在pthread_mutex_lock之前就会死锁。因为signal一个没有锁的条件变量唤醒的线程发现锁还被占着只能继续等而唤醒者又在等待某个条件满足于是两边互相等程序卡死。排查死锁的土办法是打印日志在每个线程的入口和出口都打印一句话。如果发现某条线程长时间没有输出那八成是卡在某个等待上。更高级的做法是用gdb attach到进程上执行thread apply all bt查看所有线程的调用栈。6.3 调试心得用日志定位问题比用调试器更高效线程同步类的问题我个人的经验是打印日志比单步调试好用。单步调试多线程程序时调试器会暂停所有线程但暂停的时机不确定反而掩盖了竞态条件。打印日志则能保留线程间实际的交错顺序。当然日志太多了也会影响性能生产环境慎用但实验阶段随便打。还有一个小技巧在每个printf里都加上线程id和当前时间戳。比如printf([%ld] [P%d] produce item: %d, count%d\n, pthread_self(), id, item, count);这样能更清楚地看出线程的调度顺序。7. 进阶思考信号量方案与多缓冲区扩展7.1 信号量实现生产者-消费者pthread库也提供了信号量sem_t用信号量实现生产者-消费者是另一个经典方案。原理是用两个信号量分别表示“空位数”和“满位数”再配一个互斥锁保护缓冲区本身。核心代码长这样#include semaphore.h sem_t empty_slots; sem_t full_slots; // main中初始化 sem_init(empty_slots, 0, BUFFER_SIZE); sem_init(full_slots, 0, 0); // 生产者 sem_wait(empty_slots); pthread_mutex_lock(mutex); buffer[in] item; in (in 1) % BUFFER_SIZE; pthread_mutex_unlock(mutex); sem_post(full_slots); // 消费者 sem_wait(full_slots); pthread_mutex_lock(mutex); item buffer[out]; out (out 1) % BUFFER_SIZE; pthread_mutex_unlock(mutex); sem_post(empty_slots);信号量方案的优点是不需要手写条件判断信号量内部维护了计数。但缺点是逻辑没那么直观容易用混两个信号量的wait/post位置。7.2 扩展方向读者-写者问题做完生产者-消费者实验后强烈建议再研究一下“读者-写者问题”。这个模型更像是现实世界中的数据库并发访问多个读者可以同时读但写者必须独占。它涉及一个非常重要的权衡——读者优先还是写者优先。理解了这两个经典问题操作系统里95%的并发场景在你眼里都会变得非常清晰。我在带实验时经常说的一句话编程题可以靠背模板过但这种“为什么这么写”的思考才是操作系统实验真正要训练的东西。你把这个道理想透了后面学进程通信、学死锁避免、学文件系统的并发访问都会很顺。本文还有配套的精品资源点击获取
