ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

线程同步实验避坑指南:从竞态复现到互斥锁、信号量与条件变量

线程同步实验避坑指南:从竞态复现到互斥锁、信号量与条件变量 简介这份资源面向高校计算机专业学生及操作系统课程学习者聚焦线程同步这一核心实验主题提供可直接编译运行的完整代码实现帮助读者理解并发编程中的互斥、同步与资源竞争问题。压缩包共199个文件约1.28MB以35个c源文件与29个h头文件为主体涵盖内核模块、内存管理、文件系统与图形接口等实现另有38个o目标文件、74个svn-base版本文件及makefile、ld链接脚本、img镜像等构建产物便于对照编译流程与工程组织。目前已有125人学习下载适合作为课程实验参考或自学练手素材。读者可从中获取线程同步的完整代码框架、模块划分思路与构建配置结合内容预览中的内核与库函数实现快速搭建实验环境并排查编译链接问题加深对操作系统底层机制的理解。1. 线程同步实验到底在练什么从一次数据对不上的翻车说起很多人做操作系统实验做到线程同步这一节第一反应是“不就是加个锁吗”。真动手才发现两个线程各加十万次最后结果不是二十万而是十九万三千多而且每次跑出来的数还不一样。这不是玄学这是竞态条件在真实发生。线程同步实验要练的就是让你亲眼看到这种不确定性再用信号量、互斥锁、条件变量这些工具把它摁住。这个实验通常出现在操作系统课程的中段前置是进程与线程的概念、PCB、调度后续是死锁、内存管理。它解决的核心问题是多个执行流共享同一份数据时如何保证结果可预期。适合两类人一类是要交实验报告的学生需要能跑通的完整代码和能解释清楚的现象另一类是刚转到底层或后端、想补并发基本功的工程师需要一套最小可复现的验证环境。下面我按“先跑出错误、再修对、再看清代价”的顺序把 Ubuntu 下用 pthread 做线程同步的完整路径讲一遍代码可以直接抄。2. 先复现竞态不加锁的计数器为什么每次都少一点2.1 竞态的本质是「读-改-写」被切开counter在 C 里看着是一行编译成汇编通常是三条把内存里的值 load 到寄存器寄存器加一再 store 回内存。两个线程如果交错执行就可能出现线程 A load 到 100线程 B 也 load 到 100A 加完写回 101B 加完也写回 101。两次加法只涨了 1。丢的次数取决于调度时机所以每次结果都不同。这不是 bug是共享可变状态在没有同步时的必然结果。实验的第一步不是急着加锁而是先把这个错误稳定复现出来后面加锁才有对照。2.2 最小复现代码与编译命令// race.c —— 故意不加锁复现竞态 #include pthread.h #include stdio.h #define N 100000 int counter 0; // 共享变量所有线程都动它 void *worker(void *arg) { for (int i 0; i N; 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%d actual%d\n, 2 * N, counter); return 0; }编译要带上 pthread 库否则链接阶段会报 undefined referencegcc -O0 -g -pthread race.c -o race ./race这里-O0是关键。如果你用-O2编译器可能把循环优化掉或者把 counter 提到寄存器里现象会变甚至“看起来对了”。做同步实验一律先用-O0保证你看到的是真实的并发行为。-g是为了后面用 gdb 看线程状态。多跑几次./race你会看到 actual 在 17 万到 20 万之间浮动这就是竞态的直接证据。2.3 用 helgrind 把隐藏的竞态揪出来光看结果对不上还不够实验报告里最好能指出“哪一行发生了竞态”。Valgrind 的 helgrind 工具能做这件事sudo apt install valgrind valgrind --toolhelgrind ./race输出里会明确标出Possible data race during write of size 4以及对应的源码行号。注意 helgrind 会拖慢程序几十倍N 设成十万可能要跑一会儿调试时可以把 N 临时改成 1000。这一步的价值在于它把“结果不对”翻译成了“第 12 行有数据竞争”是从现象到定位的跨越。3. 三种同步原语怎么选互斥锁、信号量、条件变量的适用边界3.1 互斥锁保护临界区最常用也最容易用错互斥锁mutex解决的是“同一时刻只有一个线程能进这段代码”。把上面 worker 里的counter用锁包起来// mutex_fix.c —— 用互斥锁修正 #include pthread.h #include stdio.h #define N 100000 int counter 0; pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; // 静态初始化 void *worker(void *arg) { for (int i 0; i N; i) { pthread_mutex_lock(lock); // 进临界区前加锁 counter; pthread_mutex_unlock(lock); // 出临界区必须解锁 } 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%d actual%d\n, 2 * N, counter); return 0; }编译命令和之前一样gcc -O0 -g -pthread mutex_fix.c -o mutex_fix。这次无论跑多少遍actual 都稳定等于 200000。参数上要注意PTHREAD_MUTEX_INITIALIZER只能用于静态分配的锁。如果锁是 malloc 出来的或者放在结构体里动态创建必须用pthread_mutex_init并在销毁前调用pthread_mutex_destroy。忘记 destroy 在短命程序里看不出问题但在长时间运行的服务里是资源泄漏。锁的粒度是这里最需要权衡的参数。上面把锁放在循环内部每次加一都加解锁锁开销可能比加法本身还大。如果业务允许把整段循环放进一个临界区性能会好很多但临界区越长并发度越低。这个取舍没有标准答案取决于临界区里到底做了什么。3.2 信号量能当锁用也能做线程间的“信号”信号量semaphore比互斥锁多一个能力它的值可以大于 1能控制“同时最多 N 个线程进入”。POSIX 信号量分无名和有名两种线程同步用无名信号量就够// sem_fix.c —— 用信号量实现互斥 #include pthread.h #include semaphore.h #include stdio.h #define N 100000 int counter 0; sem_t sem; // 无名信号量 void *worker(void *arg) { for (int i 0; i N; i) { sem_wait(sem); // P 操作值减一为 0 则阻塞 counter; sem_post(sem); // V 操作值加一唤醒等待者 } return NULL; } int main(void) { sem_init(sem, 0, 1); // 第二参数 0 表示线程间共享初值 1 等价于互斥锁 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%d actual%d\n, 2 * N, counter); sem_destroy(sem); return 0; }sem_init的第二个参数是pshared0 表示这个信号量在当前进程的线程之间共享非 0 表示进程间共享。做线程实验填 0。第三个参数是初值填 1 时行为上等价于互斥锁但信号量不要求“谁加锁谁解锁”这个灵活性在生产者消费者模型里非常有用。编译时信号量在较新的 glibc 里已经并入 pthread但保险起见还是带-pthread。如果报undefined reference to sem_init检查是不是漏了这个标志。3.3 条件变量解决“等某个条件成立”而不是“抢一把锁”条件变量condition variable常和互斥锁配对使用专门处理“线程要等到某个条件为真才能继续”的场景。典型例子是生产者消费者消费者发现缓冲区空了不能一直空转抢锁应该睡下等生产者放数据后把它叫醒。// cond_demo.c —— 条件变量配合互斥锁的生产者消费者骨架 #include pthread.h #include stdio.h int buffer 0; // 简化成容量 1 的缓冲区 pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond PTHREAD_COND_INITIALIZER; void *producer(void *arg) { pthread_mutex_lock(lock); buffer 1; // 放数据 pthread_cond_signal(cond); // 叫醒一个等待者 pthread_mutex_unlock(lock); return NULL; } void *consumer(void *arg) { pthread_mutex_lock(lock); while (buffer 0) { // 必须用 while不能用 if pthread_cond_wait(cond, lock); // 睡下并原子地释放锁 } buffer 0; // 取数据 pthread_mutex_unlock(lock); return NULL; }这里最容易翻车的是while和if的区别。pthread_cond_wait返回后条件不一定真的成立了可能被虚假唤醒也可能被 signal 后锁又被别人抢走改了状态。用if只检查一次就会在条件不成立时继续往下跑产生难以复现的错误。用while重新检查是标准写法。pthread_cond_wait的第二个参数必须是当前持有的锁它内部会原子地释放锁并进入等待被唤醒时再重新获取锁。这个“释放-等待-重获”的原子性是条件变量能正确工作的关键自己手动实现很容易出错。4. 把实验做扎实从能跑到能解释、能测量4.1 用 time 和 perf 量化同步的代价加锁之后结果对了但实验报告如果只写“结果正确”就太薄了。同步是有代价的把这个代价测出来实验的深度就上来了。# 对比不加锁、互斥锁、信号量三种版本的耗时 time ./race time ./mutex_fix time ./sem_fix不加锁的版本虽然结果错但通常最快加锁版本会慢几倍。这个差距就是同步开销。想看得更细用 perf 统计上下文切换和锁竞争sudo apt install linux-tools-common linux-tools-generic perf stat -e context-switches,cpu-migrations ./mutex_fixcontext-switches是上下文切换次数。如果锁竞争激烈线程频繁阻塞唤醒这个数会明显偏高。把 N 从十万加到一百万观察这个数怎么变就能直观理解“锁竞争随并发量放大”这件事。4.2 用 gdb 看线程卡在哪程序如果卡死不动多半是死锁。用 gdb 挂上去看每个线程的调用栈gdb ./mutex_fix (gdb) run # 程序卡住后按 CtrlC (gdb) info threads # 列出所有线程 (gdb) thread apply all bt # 打印每个线程的调用栈如果看到两个线程都停在pthread_mutex_lock或__lll_lock_wait基本就是死锁。常见成因是加锁顺序不一致线程 A 先锁 L1 再锁 L2线程 B 先锁 L2 再锁 L1各持一把等对方那把。解决办法是全局约定一个加锁顺序所有线程都按同一顺序获取。4.3 原子操作轻量场景下可以绕开锁如果临界区只是对一个整数做加减用 C11 的原子类型比互斥锁轻得多// atomic_fix.c —— 用原子操作替代锁 #include pthread.h #include stdatomic.h #include stdio.h #define N 100000 atomic_int counter 0; // 原子整型 void *worker(void *arg) { for (int i 0; i N; i) { atomic_fetch_add(counter, 1); // 原子加一无需加锁 } return NULL; }编译要加-stdc11。atomic_fetch_add底层通常编译成一条带 lock 前缀的指令没有线程阻塞和唤醒的开销。但它的适用范围很窄只能保护单个变量的简单操作。一旦临界区里有多步依赖关系还是得回到锁。实验里把原子版本也跑一遍对比耗时能帮你建立“什么场景用什么工具”的判断力。5. 线程同步实验的避坑清单五个我踩过的坑5.1 编译没带 -pthread报一堆 undefined reference现象pthread_create、sem_init、pthread_mutex_lock全部报 undefined reference to。原因pthread 不是默认链接的库。解决编译和链接都加上-pthread注意是-pthread而不是-lpthread前者还会设置必要的编译宏。用 CMake 的话写target_link_libraries(你的目标 pthread)。5.2 开了 -O2 之后竞态“消失”了现象不加锁的 race.c 用-O2编译结果居然对了。原因编译器把循环里的 counter 优化到寄存器或者直接算出了结果根本没有发生内存层面的并发读写。解决做同步实验固定用-O0。如果确实要看优化后的行为用volatile修饰共享变量但 volatile 不保证原子性只是阻止编译器把它缓存到寄存器不能替代锁。5.3 条件变量用 if 判断偶发卡死或数据错乱现象生产者消费者程序大部分时候正常偶尔消费者拿到空数据或者永久阻塞。原因pthread_cond_wait存在虚假唤醒且被唤醒后到重新拿到锁之间条件可能又被改变。解决等待条件一律写成while (!条件) pthread_cond_wait(...)被唤醒后重新判断。这是 POSIX 标准明确要求的写法不是可选项。5.4 忘记解锁导致死锁且难以定位现象程序跑着跑着不动了CPU 占用为 0。原因某个分支提前 return 或抛异常跳过了pthread_mutex_unlock。解决保证加锁和解锁成对出现临界区内避免提前返回。C 里没有 RAII可以自己封装一个带 cleanup 属性的宏或者用pthread_cleanup_push。排查时用 gdb 的thread apply all bt看谁卡在 lock 上。5.5 信号量初值设错互斥失效或直接死锁现象用信号量做互斥结果和没加锁一样或者程序一启动就卡住。原因sem_init第三个参数初值填错。填 0 的话第一次sem_wait就阻塞直接死锁填大于 1 的话多个线程能同时进临界区互斥失效。解决做互斥时初值固定填 1。做“最多 N 个并发”时才填 N。改完初值记得重新编译别拿着旧二进制反复跑。6. 把实验变成可复用的验证套路一个脚本跑完全部对照实验做完代码散在好几个文件里每次改参数都要手动编译运行效率很低。我一般会写一个 Makefile 加一个对照脚本把不加锁、互斥锁、信号量、原子操作四个版本一次性编译、运行、对比结果和耗时。这样改 N 或者改线程数时一条命令就能看到全部变化。# Makefile —— 一键编译所有版本 CC gcc CFLAGS -O0 -g -pthread -stdc11 all: race mutex_fix sem_fix atomic_fix race: race.c $(CC) $(CFLAGS) $ -o $ mutex_fix: mutex_fix.c $(CC) $(CFLAGS) $ -o $ sem_fix: sem_fix.c $(CC) $(CFLAGS) $ -o $ atomic_fix: atomic_fix.c $(CC) $(CFLAGS) $ -o $ clean: rm -f race mutex_fix sem_fix atomic_fix配套的对照脚本把每个版本跑五遍统计结果是否稳定#!/bin/bash # compare.sh —— 对照四个版本的输出与耗时 for prog in race mutex_fix sem_fix atomic_fix; do echo $prog for i in 1 2 3 4 5; do /usr/bin/time -f time%es ./$prog 21 | tr \n echo done done/usr/bin/time的-f参数用来定制输出格式%e是实际耗时秒数。注意要用绝对路径/usr/bin/time因为 shell 内置的 time 不支持-f。跑完这个脚本你会看到 race 的结果每次不同、耗时最短另外三个结果稳定、耗时略高atomic_fix 通常比 mutex_fix 快一截。这张对照表放进实验报告比单纯贴代码有说服力得多。几个可以继续调的方向把 N 从十万加到一千万看锁竞争怎么放大耗时把线程数从 2 改成 8观察上下文切换次数的变化把互斥锁换成读写锁pthread_rwlock_t在“读多写少”的场景下对比吞吐。每改一个参数先想清楚预期是什么再跑脚本验证对不上就回去看代码。这个“假设-验证-解释”的循环才是线程同步实验真正要练的东西。我自己做这类实验最大的教训是不要满足于“结果对了”。结果对可能只是运气好、N 太小、或者编译器帮你兜了底。把 N 调大、把优化关掉、把线程加多让错误稳定暴露出来再用工具定位、用数据量化这套流程走顺了后面遇到真实的并发问题才不会慌。希望帮到你。本文还有配套的精品资源点击获取
返回列表