简介这份资源面向高校计算机专业学生及操作系统课程学习者聚焦线程同步这一核心实验主题提供可直接编译运行的完整代码实现帮助读者理解并发编程中的互斥、同步与资源竞争问题。压缩包共199个文件约1.28MB以35个C源文件、29个头文件为主体辅以Makefile构建脚本、汇编与链接文件以及若干编译产物和版本控制记录整体构成一个结构完整的实验工程。内容涉及内核模块、内存管理、文件系统与设备驱动等底层实现适合在真实工程环境中调试线程同步逻辑。目前已有125人学习下载可作为课程实验的参考实现便于对照理解信号量、锁机制与调度策略的代码落地方式也能为后续操作系统课程设计提供可复用的工程骨架与排错思路。1. 操作系统实验线程同步附完整代码从“结果飘忽”到“每次都对”做操作系统实验的同学十个里有八个在“线程同步”这一关卡过壳。现象往往很朴素两个线程各自对一个共享变量加一万次跑十遍结果十遍都不一样有时是 19998有时是 20000偶尔还能蹦出个 18743。你盯着代码看半天逻辑明明没错可它就是不稳定。这不是玄学这是竞态条件在作祟而线程同步实验要解决的正是让这种“飘忽”变成“确定”。这篇笔记面向三类人正在做操作系统课程实验、需要一份能跑通且能讲清楚原理的完整代码的同学面试前想重新梳理互斥锁、信号量、条件变量区别的开发者以及带实验课、需要一份可复现参考实现的助教。我会把线程同步的核心机制拆开给出可直接编译运行的完整代码再把参数怎么调、坑在哪、怎么验证正确性讲透。读完你应该能自己写出一个经得起反复运行、结果稳定的同步程序而不是靠运气。2. 线程同步到底在同步什么共享状态、临界区与三种原语很多人一上来就背“互斥锁保护临界区”但真到写代码时还是不知道该在哪一行加锁。根子在于没想清楚“谁和谁在抢什么”。这一章先把概念落到具体的内存和指令层面再给出三种主流同步原语的选型依据。2.1 竞态条件的物理来源一条i其实是三条指令在单核时代线程切换发生在指令边界i看起来是原子的。但现代 CPU 上i至少对应三条机器指令从内存读到寄存器、寄存器加一、写回内存。如果线程 A 执行完“读”之后被调度走线程 B 完整执行了一遍i然后 A 再把自己寄存器里的旧值加一写回B 的那次自增就被覆盖了。这就是丢失更新。用一段最小复现代码把这个过程暴露出来// race_demo.c —— 故意不加锁观察丢失更新 #include stdio.h #include pthread.h #define LOOPS 100000 static long counter 0; // 共享状态所有线程都读写它 void *worker(void *arg) { for (long i 0; i LOOPS; i) { counter; // 非原子读-改-写三步随时可能被抢占 } return NULL; } int main(void) { pthread_t t1, t2; pthread_create(t1, NULL, worker, NULL); pthread_create(t2, NULL, worker, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf(expect%ld actual%ld\n, 2L * LOOPS, counter); return 0; }编译命令是gcc race_demo.c -o race_demo -lpthread注意-lpthread必须放在源文件后面否则某些链接器会报未定义引用。运行几次你会看到actual在 100000 到 200000 之间乱跳。这里的LOOPS是放大竞态窗口的关键参数值太小比如 1000可能碰巧每次都正确值太大又浪费时间我一般用 10 万到 100 万之间既能稳定复现又不会跑太久。逻辑说明counter不是原子操作两个线程的读-改-写序列可能交错。参数说明LOOPS控制每个线程的迭代次数直接决定竞态出现的概率线程数固定为 2是为了让现象最直观增加到 4 个线程现象更明显但分析更乱。2.2 临界区、原子性与内存可见性是三件不同的事新手最容易混淆的三个概念必须掰开临界区一段访问共享资源的代码同一时刻只允许一个线程进入。它是“范围”概念。原子性一个操作要么全做完要么全不做中间不被观察。它是“操作”概念。内存可见性一个线程的写操作另一个线程何时能看到。它是“时序”概念。互斥锁同时解决临界区和可见性加锁解锁自带内存屏障但如果你用自旋标志位手写锁可能只解决了互斥没解决可见性导致线程 A 改了标志位线程 B 还在读自己 CPU 缓存里的旧值于是两个线程同时进临界区。这就是为什么实验里推荐直接用pthread_mutex而不是自己造轮子。2.3 互斥锁、信号量、条件变量选型看“等待的是什么”三种原语不是随便挑的选型依据是“线程在等什么”原语等待的对象典型场景是否忙等互斥锁 mutex等锁释放保护共享变量、链表否阻塞信号量 semaphore等资源计数 0生产者-消费者、限流否阻塞条件变量 cond等某个条件成立等待队列非空、状态变化否需配 mutex选型口诀只保护一段代码用互斥锁要控制“最多 N 个线程同时做某事”用信号量要等“某个条件成立才继续”用条件变量。生产者-消费者问题里缓冲区空/满的判断用条件变量缓冲区本身的读写用互斥锁两者配合使用这是实验里最常考的组合。提示条件变量必须和互斥锁一起用且判断条件要用while而不是if因为被唤醒后条件可能又被别的线程改回去了这叫虚假唤醒。这是实验扣分重灾区。3. 用互斥锁把丢失更新修掉完整代码与四个必调参数概念讲完直接上能跑的完整代码。这一章给出一个“多线程累加器”的完整实现把上一章的竞态修掉并逐个解释参数。3.1 完整可编译代码加锁版累加器// mutex_demo.c —— 用互斥锁保护共享计数器 #include stdio.h #include pthread.h #define THREADS 4 // 线程数量 #define LOOPS 1000000L // 每个线程的迭代次数 static long counter 0; static pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; // 静态初始化 void *worker(void *arg) { long id (long)arg; for (long i 0; i LOOPS; i) { pthread_mutex_lock(lock); // 进入临界区 counter; // 现在这三步被保护了 pthread_mutex_unlock(lock); // 离开临界区 } printf(thread %ld done\n, id); return NULL; } int main(void) { pthread_t tids[THREADS]; for (long i 0; i THREADS; i) { pthread_create(tids[i], NULL, worker, (void *)i); } for (int i 0; i THREADS; i) { pthread_join(tids[i], NULL); } printf(expect%ld actual%ld\n, THREADS * LOOPS, counter); pthread_mutex_destroy(lock); return 0; }编译gcc mutex_demo.c -o mutex_demo -lpthread。运行结果稳定输出expect4000000 actual4000000跑一百遍都一样。逻辑说明pthread_mutex_lock在锁被占用时会让线程阻塞休眠不消耗 CPUunlock唤醒等待者。PTHREAD_MUTEX_INITIALIZER是静态初始化适合全局锁如果是动态分配的锁要用pthread_mutex_init。参数说明THREADS控制并发度4 个线程足以暴露竞态又便于观察LOOPS是每个线程的迭代次数100 万次配合 4 线程大约跑 0.3 秒既能验证正确性又不拖沓。注意counter用long而不是int因为 4×100 万 400 万int在 32 位平台上够用但边界不保险用long更稳。3.2 锁的粒度为什么把 lock 放进循环里是对的有人会问能不能把整个for循环包在一把锁里只加锁一次能但那样等于把多线程退化成单线程四个线程排队执行完全失去并发意义。锁的粒度原则是只锁必须串行的最小代码段。这里counter是唯一需要保护的所以锁放在循环体内、只包住这一行。反过来如果临界区里有耗时操作比如文件 IO、网络请求锁的粒度就要重新权衡锁太细会导致频繁加解锁开销大锁太粗会阻塞其他线程。实验里通常用“锁内只做内存操作”作为默认策略。3.3 四个必调参数与它们的边界参数作用推荐值调大后果调小后果THREADS并发线程数4锁竞争加剧吞吐下降竞态不易复现LOOPS单线程迭代数100 万运行变慢竞态可能碰巧不出现锁粒度临界区大小仅包住共享写并发度下降保护不足仍出错锁类型普通/递归/自旋普通互斥锁递归锁易误用自旋锁空耗 CPU锁类型这里多说一句PTHREAD_MUTEX_NORMAL是默认的普通锁同一线程重复加锁会死锁PTHREAD_MUTEX_RECURSIVE允许同一线程重复加锁但实验里几乎不需要用了反而掩盖设计问题自旋锁适合临界区极短的场景但用户态pthread没有直接暴露自旋锁需要自己用原子操作实现实验不推荐。3.4 怎么验证“真的同步了”三种验证手段光看一次输出正确不够要证明同步有效用这三招第一压力测试把程序放进for i in $(seq 1 100); do ./mutex_demo; done循环跑一百遍检查是否每次都输出actual4000000。第二对照实验把pthread_mutex_lock/unlock注释掉重编译运行观察结果乱跳证明锁确实起了作用。第三工具检测用gcc -fsanitizethread编译运行时会报告数据竞争。加锁版应该无报告无锁版会明确指出counter那行的竞争。# 用 ThreadSanitizer 检测数据竞争 gcc -fsanitizethread -g mutex_demo.c -o mutex_demo_tsan -lpthread ./mutex_demo_tsan # 无锁版会输出 WARNING: ThreadSanitizer: data race逻辑说明-fsanitizethread在编译期插入检测代码运行时监控内存访问。参数说明-g保留调试符号方便定位到具体行号。这个工具是验证同步正确性的“后悔药”实验报告里附上它的输出说服力比口头描述强得多。4. 生产者-消费者条件变量与信号量的完整落地互斥锁只能保护“同一时刻一个线程进”但生产者-消费者问题里消费者还要等“缓冲区非空”生产者要等“缓冲区非满”。这种“等条件”的场景必须上条件变量或信号量。这一章给出两种实现并对比它们的差异。4.1 条件变量版while 判断 双条件// prodcons_cond.c —— 条件变量实现有界缓冲区 #include stdio.h #include pthread.h #define CAP 5 // 缓冲区容量 #define ITEMS 20 // 总生产/消费数量 static int buffer[CAP]; static int count 0; // 当前元素个数 static int head 0, tail 0; static pthread_mutex_t mtx PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t not_full PTHREAD_COND_INITIALIZER; static pthread_cond_t not_empty PTHREAD_COND_INITIALIZER; void *producer(void *arg) { for (int i 0; i ITEMS; i) { pthread_mutex_lock(mtx); while (count CAP) { // 必须 while防虚假唤醒 pthread_cond_wait(not_full, mtx); } buffer[tail] i; tail (tail 1) % CAP; count; printf(produce %d, count%d\n, i, count); pthread_cond_signal(not_empty); // 唤醒一个消费者 pthread_mutex_unlock(mtx); } return NULL; } void *consumer(void *arg) { for (int i 0; i ITEMS; i) { pthread_mutex_lock(mtx); while (count 0) { pthread_cond_wait(not_empty, mtx); } int val buffer[head]; head (head 1) % CAP; count--; printf(consume %d, count%d\n, val, count); pthread_cond_signal(not_full); pthread_mutex_unlock(mtx); } return NULL; } int main(void) { pthread_t p, c; pthread_create(p, NULL, producer, NULL); pthread_create(c, NULL, consumer, NULL); pthread_join(p, NULL); pthread_join(c, NULL); return 0; }编译gcc prodcons_cond.c -o prodcons_cond -lpthread。逻辑说明pthread_cond_wait会原子地释放互斥锁并阻塞被唤醒时重新获取锁。while循环保证被唤醒后重新检查条件防止虚假唤醒或其他线程抢先消费。pthread_cond_signal只唤醒一个等待者pthread_cond_broadcast唤醒全部这里用 signal 更高效。参数说明CAP是缓冲区容量设为 5 能让“满”和“空”两种等待都容易触发ITEMS是总数量20 足够观察完整流程。head/tail用取模实现环形缓冲区避免数据搬移。4.2 信号量版用两个计数信号量替代条件变量// prodcons_sem.c —— 信号量实现有界缓冲区 #include stdio.h #include pthread.h #include semaphore.h #define CAP 5 #define ITEMS 20 static int buffer[CAP]; static int head 0, tail 0; static sem_t empty_slots; // 空槽位数初值 CAP static sem_t full_slots; // 已填槽位数初值 0 static pthread_mutex_t mtx PTHREAD_MUTEX_INITIALIZER; void *producer(void *arg) { for (int i 0; i ITEMS; i) { sem_wait(empty_slots); // P 操作空槽位减一 pthread_mutex_lock(mtx); buffer[tail] i; tail (tail 1) % CAP; pthread_mutex_unlock(mtx); sem_post(full_slots); // V 操作已填槽位加一 } return NULL; } void *consumer(void *arg) { for (int i 0; i ITEMS; i) { sem_wait(full_slots); pthread_mutex_lock(mtx); int val buffer[head]; head (head 1) % CAP; pthread_mutex_unlock(mtx); sem_post(empty_slots); printf(consume %d\n, val); } return NULL; } int main(void) { sem_init(empty_slots, 0, CAP); // 第二参数 0 表示线程间共享 sem_init(full_slots, 0, 0); pthread_t p, c; pthread_create(p, NULL, producer, NULL); pthread_create(c, NULL, consumer, NULL); pthread_join(p, NULL); pthread_join(c, NULL); sem_destroy(empty_slots); sem_destroy(full_slots); return 0; }编译gcc prodcons_sem.c -o prodcons_sem -lpthread。逻辑说明sem_wait在计数为 0 时阻塞sem_post增加计数并唤醒等待者。empty_slots和full_slots之和恒等于CAP这是信号量解法的核心不变量。互斥锁只保护缓冲区指针的读写不参与条件等待。参数说明sem_init第二个参数为 0 表示信号量在线程间共享非 0 表示进程间共享初值CAP和 0 分别对应“初始全空”和“初始无数据”。4.3 两种实现的差异与选择维度条件变量版信号量版等待条件显式 while 判断隐含在计数里虚假唤醒需手动处理不涉及可读性条件直观计数抽象适用场景复杂条件资源计数选择建议条件复杂比如“队列非空且未关闭”用条件变量纯粹的资源计数缓冲区槽位用信号量更简洁。实验报告里两种都写能体现你对原语本质的理解。5. 线程同步实验避坑五条血泪经验这一章专门讲踩坑。以下五条都是我在实验和代码审查里反复见到的每条按“现象 → 原因 → 解决”写。5.1 现象程序偶尔卡死不动CPU 占用为零原因死锁。最常见的是两个线程以相反顺序获取两把锁或者同一线程重复获取普通互斥锁。比如线程 A 先锁 L1 再锁 L2线程 B 先锁 L2 再锁 L1各持一把等对方释放。解决统一加锁顺序所有线程按同一顺序获取多把锁或者用pthread_mutex_trylock加超时重试。排查时用gdbattach 上去thread apply all bt看每个线程卡在哪一行。5.2 现象结果比预期少但比无锁版多原因锁没包全。比如只锁了counter的读和写中间的计算在锁外或者用了pthread_mutex_trylock失败就跳过导致部分更新丢失。解决确保临界区覆盖完整的读-改-写序列。用 ThreadSanitizer 跑一遍它会精确指出哪一行有竞争。5.3 现象条件变量版偶尔消费到错误数据或死等原因用了if而不是while判断条件。被signal唤醒后条件可能已被其他线程改回去此时直接往下执行就会读到无效数据或者多个消费者被唤醒但只有一个能消费其余继续等却没人再 signal。解决条件判断一律用while如果唤醒后可能影响多个等待者用broadcast而不是signal。5.4 现象编译报undefined reference to pthread_create原因链接时没加-lpthread或者加在了源文件前面。GNU 链接器按顺序解析符号库必须放在使用它的目标文件之后。解决gcc xxx.c -o xxx -lpthread库放最后。新版 glibc 把 pthread 合并进了 libc某些平台不加也能过但为了可移植性还是显式加上。5.5 现象加了锁反而更慢多线程不如单线程原因锁粒度过粗或锁竞争激烈。四个线程抢一把锁大部分时间花在阻塞和唤醒上真正干活的时间很少。解决缩小临界区只锁共享写或者用读写锁pthread_rwlock区分读写再或者用无锁数据结构实验里不要求但值得了解。用perf stat看上下文切换次数如果每秒几万次说明锁竞争严重。注意实验报告里不要只贴正确结果把“无锁版错误结果”和“加锁版正确结果”对照放上去再附上 ThreadSanitizer 的输出论证链条才完整。6. 从能跑到可信用压力测试和竞态检测收尾实验代码能跑出正确结果只是及格线。真正让这份作业“可信”的是你能证明它在各种条件下都正确。这一章讲两个进阶手段一个用来压一个用来查。先说压力测试。单次运行正确不代表永远正确竞态的特点是概率性。我一般会写一个脚本把程序跑上几百遍统计失败次数#!/bin/bash # stress_test.sh —— 反复运行统计失败次数 BIN./mutex_demo EXPECT4000000 FAIL0 for i in $(seq 1 200); do OUT$($BIN | grep actual | awk -F {print $3}) if [ $OUT ! $EXPECT ]; then FAIL$((FAIL 1)) echo run $i failed: got $OUT fi done echo total fail: $FAIL / 200逻辑说明循环 200 次每次抓取actual的值和期望比对。参数说明seq 1 200控制次数200 次大约几十秒足够暴露低概率竞态如果时间紧可以降到 50 次。这个脚本的价值在于它把“我觉得没问题”变成“200 次零失败”的量化结论。再说竞态检测。ThreadSanitizer 是编译期插桩工具能发现那些“碰巧没出错”的隐藏竞争。用法前面提过这里补充一个技巧把优化开到-O2再跑一次。因为编译器优化可能重排指令暴露出-O0下看不到的竞争。如果-O2下 ThreadSanitizer 依然无报告这份代码的同步才算扎实。gcc -O2 -fsanitizethread -g mutex_demo.c -o mutex_demo_o2 -lpthread ./mutex_demo_o2最后给一个我自己的习惯每写完一个同步程序先跑无锁版确认能复现错误再加锁跑压力测试最后过一遍 ThreadSanitizer。三步走完才敢把代码交上去。这个顺序不能反——先证明问题存在再证明方案有效逻辑才闭环。线程同步实验最忌讳的就是“结果对了就完事”因为竞态这东西今天对不代表明天对换个机器、换个核数可能就翻车。把验证做足才是对自己代码负责。希望帮到你。本文还有配套的精品资源点击获取