☰
并发程序设计实验避坑指南:从进程线程到死锁排查
2026/10/28 12:03:48 网站建设 项目流程

并发程序设计这门课,很多人第一次接触时都会有种"每个字都认识,连起来就不懂了"的感觉。尤其是操作系统实验课,教材上讲进程、线程、同步、互斥讲了一大堆概念,真到动手写代码的时候,面对一个空白的源文件,完全不知道第一行该写什么。我带过几届学生的实验课,也帮不少同学排查过他们卡住的代码,发现大家踩的坑高度集中:要么是编译命令里忘了加-pthread,要么是信号量初值设错导致死锁,要么是共享变量没加保护跑出了匪夷所思的结果。这篇内容就把"并发程序设计"这个实验从头到尾拆开讲清楚——它到底在练什么、每个任务背后的原理是什么、代码怎么写才能跑通、以及那些只有实际动手才会遇到的坑。不管你是刚接触 Linux 下 C 编程的新手,还是已经写过一些多线程代码但总在同步问题上翻车的同学,下面这些内容应该都能帮你少走不少弯路。

1. 这个实验到底在训练什么能力

1.1 从"顺序思维"切换到"并发思维"

平时写代码,脑子里默认的执行模型是线性的:第一行做完做第二行,函数 A 调用完才轮到函数 B。这种顺序思维根深蒂固,以至于很多同学第一次写多线程程序时,会下意识地认为"我创建了线程,它就应该立刻开始跑"。实际上pthread_create返回成功只代表线程被创建出来了,至于它什么时候被调度到某个 CPU 核心上执行、执行到哪一行被切走,完全由调度器说了算。

并发程序设计的核心训练目标,就是让你建立一种新的心智模型:多个执行流在时间上重叠,彼此的执行顺序不确定,共享状态需要显式保护。这个模型和顺序编程的差异,不是"多学几个 API"能弥补的,它要求你在写每一行涉及共享数据的代码时,都问自己一句:如果此刻另一个线程也执行到这里,会发生什么?

我见过太多同学,代码逻辑本身没毛病,但就是因为脑子里没有这根弦,两个线程同时对一个计数器做自增,跑一百次结果一百次不一样,然后对着屏幕怀疑人生。这不是代码写错了,是思维方式还没转过来。实验课设置这些任务,本质上就是在逼你把这个弯转过来。

1.2 实验任务背后的知识地图

这类并发实验通常会覆盖几个层层递进的知识块,我把它们的关系梳理成一张表,方便你建立全局感:

知识层次核心概念典型任务形态主要考察点
进程基础fork、exec、wait创建子进程并观察输出理解进程地址空间独立
线程基础pthread_create、join多线程各自打印或计算掌握线程创建与回收
互斥同步mutex、semaphore计数器累加、生产者消费者理解临界区与竞态
线程通信条件变量、共享内存有界缓冲区、流水线协作掌握等待/唤醒机制
并发正确性死锁、活锁、饥饿排查卡死或结果异常建立正确性意识

这张表不是让你背的,而是让你在动手前先知道自己站在哪一层。很多同学一上来就写生产者消费者,结果连pthread_join是干嘛的都没搞清楚,线程还没跑完主函数就 return 了,程序直接退出,然后纳闷"为什么我的打印一条都没有"。先把底层的地基打牢,上面的楼才盖得稳。

1.3 为什么用 C 和 POSIX 线程来做这件事

有同学会问,Python 有threading,Java 有Thread,为什么实验偏偏要用 C 加 pthread?原因很实在:C 语言贴近系统底层,pthread 是 POSIX 标准的一部分,在 Linux 上就是操作系统原生提供的线程接口。用它能最直接地看到线程创建、调度、同步的原始面貌,没有语言运行时帮你做额外的封装和隐藏。

举个具体的例子,Python 因为有全局解释器锁的存在,多线程在 CPU 密集型任务上其实并不能真正并行,你写出来的"并发"和操作系统层面的并发是两回事。而 C 加 pthread 写出来的程序,多个线程可以实实在在地跑在不同核心上,你能亲眼观察到竞态条件带来的后果。这种"看得见摸得着"的体验,对建立并发直觉非常关键。所以别嫌 C 麻烦,指针和手动内存管理确实劝退,但它给你的反馈是最真实的。

2. 动手之前必须搞定的环境与编译细节

2.1 Linux 环境与工具链确认

实验基本都在 Linux 环境下完成,无论是机房的双系统、虚拟机还是远程服务器,你需要确认几样东西到位。打开终端,依次敲下面几条命令看看输出:

gcc --version make --version uname -r nproc

gcc --version确认编译器存在,make --version确认构建工具可用,uname -r看内核版本,nproc看有几个 CPU 核心。最后这条尤其重要——如果你机器只有单核,多线程程序的并发效果会大打折扣,很多竞态问题可能因为线程实际串行执行而"碰巧"不出现,这会给你虚假的安全感。我建议尽量在至少双核的环境下做实验,观察到的现象才具有代表性。

如果gcc没装,Debian 系用sudo apt install build-essential,RedHat 系用sudo yum groupinstall "Development Tools"。装完之后再跑一遍上面的命令确认。这一步看着琐碎,但我见过有同学折腾半天编译报错,最后发现是根本没装编译器,白白浪费一晚上。

2.2 那个让无数人翻车的 -pthread 参数

这是并发实验里最高频的翻车点,没有之一。你代码写得再对,编译时忘了加-pthread,链接阶段就会报一堆undefined reference to 'pthread_create'之类的错误。正确的编译命令长这样:

gcc -o demo demo.c -pthread

注意-pthread要放在源文件后面,虽然多数情况下放前面也能过,但养成放后面的习惯更稳妥。这里有个细节值得说清楚:-pthread和-lpthread不是一回事。-lpthread只是链接 libpthread 库,而-pthread除了链接库之外,还会定义_REENTRANT宏,这个宏会影响一些头文件里函数的行为,让它们变成线程安全的版本。所以别图省事用-lpthread替代,老老实实用-pthread。

提示:如果你用 Makefile 管理构建,记得把-pthread同时加到编译选项(CFLAGS)和链接选项(LDFLAGS)里,只加一处有时会出问题。

我建议你第一次就把编译命令写进一个简单的 Makefile,省得每次手敲还容易漏。一个够用的模板如下:

CC = gcc CFLAGS = -Wall -g -pthread TARGET = demo SRCS = demo.c $(TARGET): $(SRCS) $(CC) $(CFLAGS) -o $(TARGET) $(SRCS) clean: rm -f $(TARGET)

-Wall打开所有警告,-g保留调试信息方便后面用 gdb 排查。这两个选项在并发调试时价值极高,别省。

2.3 用 gdb 观察线程状态的基本姿势

并发程序出问题时,光靠 printf 往往定位不到根因,因为打印本身会改变时序。这时候 gdb 就派上用场了。编译时带上-g之后,用gdb ./demo启动,几个常用命令记一下:

(gdb) info threads # 查看所有线程 (gdb) thread 2 # 切换到 2 号线程 (gdb) bt # 查看当前线程调用栈 (gdb) thread apply all bt # 查看所有线程的调用栈

thread apply all bt这条在排查死锁时特别好用——程序卡住时按 Ctrl+C 中断,然后执行它,你能看到每个线程都停在哪个函数上。如果两个线程都停在等待锁的位置,死锁基本就实锤了。这个技巧我在后面讲死锁排查时还会展开,先记住这个命令。

3. 从进程到线程:先把地基打牢

3.1 fork 之后到底发生了什么

进程部分通常是实验的第一个任务,核心就是理解fork的行为。fork调用一次返回两次:父进程里返回子进程的 PID,子进程里返回 0,出错返回 -1。这个"一次调用两次返回"的特性是很多同学第一个懵的点。看这段代码:

#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main() { pid_t pid = fork(); if (pid < 0) { perror("fork failed"); return 1; } else if (pid == 0) { printf("child process, pid = %d, parent = %d\n", getpid(), getppid()); } else { printf("parent process, pid = %d, child = %d\n", getpid(), pid); wait(NULL); } return 0; }

关键要理解的是:fork之后父子进程拥有各自独立的地址空间。父进程里有个变量x = 10,子进程里改成了 20,父进程看到的还是 10。这是写时复制机制保证的,两个进程的修改互不影响。很多同学在这里会误以为子进程改了父进程也能看到,结果调试半天。记住一句话:进程之间默认不共享内存,要共享得用共享内存或者管道这些显式的 IPC 机制。

wait(NULL)的作用是父进程等待子进程结束并回收其资源。如果不 wait,子进程结束后会变成僵尸进程,虽然不影响程序逻辑,但用ps能看到一堆<defunct>状态的进程,实验报告里被问到就尴尬了。

3.2 线程和进程的本质区别

理解了进程的独立性,再来看线程就顺了。线程是进程内部的执行单元,同一个进程内的多个线程共享地址空间——全局变量、堆内存都是共享的,只有栈和寄存器是各自独立的。这个"共享"正是并发编程一切麻烦的根源,也是它强大的地方。

用一个生活化的类比:进程像是一栋独立的房子,每栋房子有自己的水电表(地址空间),互不干扰;线程像是同一栋房子里的不同房间,大家共用一个总水表(共享内存),一个房间漏水了(改了共享变量),其他房间也会受影响。所以线程间通信极其方便(直接读写全局变量就行),但必须加锁保护,否则就会出乱子。

这个区别直接决定了编程模型:进程间通信要靠管道、消息队列、共享内存这些系统调用,麻烦但安全;线程间通信直接读写变量,方便但危险。实验里让你分别用进程和线程实现类似功能,就是要你亲身体会这个差异。

3.3 线程创建与回收的正确姿势

pthread_create和pthread_join是线程编程的两个基本动作,一创建一回收,必须配对。看标准写法:

#include <stdio.h> #include <pthread.h> void *worker(void *arg) { int id = *(int *)arg; printf("thread %d is running\n", id); return NULL; } int main() { pthread_t tid; int id = 1; if (pthread_create(&tid, NULL, worker, &id) != 0) { perror("pthread_create"); return 1; } pthread_join(tid, NULL); printf("main thread done\n"); return 0; }

这里有几个容易出错的细节。第一,pthread_create的第四个参数是传给线程函数的参数,类型是void *,传int的地址进去,线程函数里要强转回int *再解引用。第二,如果你传的是主函数里局部变量的地址,要确保这个变量在线程执行期间一直有效——如果主函数提前返回,栈被销毁,线程访问的就是野指针。第三,pthread_join会阻塞等待线程结束,如果忘了写,主线程可能先于子线程退出,整个进程结束,子线程被强制终止,你连打印都看不到。

注意:主线程 return 等同于调用 exit,会终止整个进程包括所有线程。想让子线程跑完,要么 join,要么在主线程里用 pthread_exit 而不是 return。

4. 竞态条件:并发世界里最隐蔽的敌人

4.1 一个计数器引发的血案

竞态条件是并发编程的第一课,也是最经典的坑。看这段代码,你觉得最后 counter 是多少?

#include <stdio.h> #include <pthread.h> int counter = 0; void *increment(void *arg) { for (int i = 0; i < 100000; i++) { counter++; } return NULL; } int main() { pthread_t t1, t2; pthread_create(&t1, NULL, increment, NULL); pthread_create(&t2, NULL, increment, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("counter = %d\n", counter); return 0; }

理论上两个线程各加十万次,结果应该是 200000。但你实际跑一下,大概率得到一个小于 200000 的数,而且每次跑还不一样。这就是竞态条件。原因在于counter++这个看似原子的操作,在机器层面其实是三步:读取 counter 到寄存器、寄存器加一、写回内存。如果两个线程在这三步之间交错执行,就会丢失更新。

具体来说,线程 A 读到 counter=100,还没写回;线程 B 也读到 100,加一写回 101;线程 A 再把它的结果 101 写回。两次自增只让 counter 涨了 1,丢了一次。这种交错发生的概率随循环次数增加而升高,所以十万次循环下几乎必然出现偏差。

4.2 用互斥锁把临界区保护起来

修复方案是给临界区加互斥锁。所谓临界区,就是访问共享资源的那段代码。用pthread_mutex_t保护起来:

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; void *increment(void *arg) { for (int i = 0; i < 100000; i++) { pthread_mutex_lock(&lock); counter++; pthread_mutex_unlock(&lock); } return NULL; }

加锁之后,同一时刻只有一个线程能进入临界区,counter++ 的三步操作不会被其他线程打断,结果稳定是 200000。这里的关键是锁的粒度:锁保护的范围要刚好覆盖临界区,太小起不到保护作用,太大又会让并发退化成串行,失去多线程的意义。

我见过有同学把整个 for 循环都包在锁里,结果两个线程完全串行执行,虽然结果对了,但性能还不如单线程。正确的做法是只锁住真正访问共享数据的那几行。这个权衡在后面的生产者消费者任务里会更明显。

4.3 锁的开销与性能权衡

加锁不是免费的。每次 lock/unlock 都涉及原子操作和可能的内核态切换,开销不小。如果临界区里只有一行counter++,加锁的开销可能比操作本身还大。那为什么还要加?因为正确性优先于性能,结果错了再快也没意义。

实际工程里,减少锁开销的常见手段有几个:一是减小临界区,只锁必要的部分;二是用原子操作替代锁,比如 C11 的_Atomic或者 GCC 的__sync_fetch_and_add,对于简单的计数场景,原子操作比互斥锁轻量得多;三是减少锁竞争,比如每个线程维护局部计数,最后再汇总。这些优化思路在实验里不一定要求实现,但理解了能帮你写出更合理的代码。

提示:实验报告里如果被问到"为什么加锁后变慢了",别只答"因为锁有开销",要能说清楚锁涉及原子指令、缓存一致性协议、可能的上下文切换这几个层面,这样才显得你真懂了。

5. 信号量与条件变量:让线程有序协作

5.1 信号量的 PV 操作到底怎么理解

互斥锁解决的是"同一时刻只能一个线程进"的问题,但有些场景需要更灵活的协调,比如"缓冲区满了生产者要等,空了消费者要等"。这时候信号量就登场了。信号量本质上是一个带原子操作的计数器,配两个操作:P(wait,申请资源,计数减一,减到负数就阻塞)和 V(post,释放资源,计数加一,唤醒等待者)。

用生活类比:信号量就像一个停车场,初始计数是车位数量。车来了执行 P 操作,有空位就停进去(计数减一),没空位就在门口等(阻塞);车走了执行 V 操作,空出一个位子(计数加一),并通知等待的车可以进了。这个模型比互斥锁更通用,互斥锁其实就是初值为 1 的信号量。

在 C 里用 POSIX 信号量,需要包含<semaphore.h>,编译时同样要-pthread。基本用法:

#include <semaphore.h> sem_t sem; sem_init(&sem, 0, 1); // 第二个参数 0 表示线程间共享,初值 1 sem_wait(&sem); // P 操作 // 临界区 sem_post(&sem); // V 操作 sem_destroy(&sem);

sem_init的第二个参数如果是 0,表示信号量在当前进程的线程间共享;非 0 表示在进程间共享(需要放在共享内存里)。实验里基本都是线程间,填 0 就行。

5.2 生产者消费者:并发协作的经典模型

生产者消费者是并发实验的必考题型,也是信号量最典型的应用。场景是有界缓冲区,生产者往里放数据,消费者从里取数据,缓冲区满了生产者要等,空了消费者要等。用三个信号量就能优雅解决:

#include <stdio.h> #include <pthread.h> #include <semaphore.h> #define N 5 int buffer[N]; int in = 0, out = 0; sem_t empty_sem; // 空槽位数量 sem_t full_sem; // 已填充数量 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; void *producer(void *arg) { for (int i = 0; i < 20; i++) { sem_wait(&empty_sem); // 等一个空槽 pthread_mutex_lock(&mutex); // 保护缓冲区指针 buffer[in] = i; in = (in + 1) % N; pthread_mutex_unlock(&mutex); sem_post(&full_sem); // 通知消费者有数据了 } return NULL; } void *consumer(void *arg) { for (int i = 0; i < 20; i++) { sem_wait(&full_sem); // 等一个数据 pthread_mutex_lock(&mutex); int val = buffer[out]; out = (out + 1) % N; pthread_mutex_unlock(&mutex); sem_post(&empty_sem); // 通知生产者有空位了 printf("consumed %d\n", val); } return NULL; }

这段代码的精髓在于三个同步原语的分工:empty_sem和full_sem负责"数量"层面的协调,mutex负责"指针操作"层面的互斥。为什么不能只用信号量不用 mutex?因为in和out这两个指针的更新也需要保护,如果两个生产者同时进来,可能算出同一个in值,互相覆盖。为什么不能只用 mutex 不用信号量?因为 mutex 只能保证互斥,没法表达"缓冲区空/满"这种状态条件,消费者在缓冲区空时无法优雅等待。

5.3 条件变量与信号量的选择逻辑

除了信号量,条件变量(pthread_cond_t)也是常用的同步手段。它通常和互斥锁配合使用,表达"某个条件满足前一直等"的语义。和信号量的区别在于:信号量自带计数,适合表达"资源数量";条件变量不带计数,适合表达"状态变化",而且它必须配合一个谓词(条件判断)在循环里检查,防止虚假唤醒。

看一个用条件变量实现的生产者消费者片段:

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER; pthread_cond_t not_full = PTHREAD_COND_INITIALIZER; int count = 0; // 生产者 pthread_mutex_lock(&mtx); while (count == N) { pthread_cond_wait(&not_full, &mtx); } // 放入数据,count++ pthread_cond_signal(&not_empty); pthread_mutex_unlock(&mtx);

注意while而不是if——这是条件变量的铁律。pthread_cond_wait可能因为虚假唤醒返回,也可能被唤醒后条件又被别的线程改回去了,所以必须重新检查条件。用if只检查一次,是新手最常犯的错误之一,会导致缓冲区溢出或者越界读取。

那到底该用信号量还是条件变量?我的经验是:如果逻辑天然是"资源计数"模型(有几个空位、几个数据),信号量更直观;如果逻辑是"等待某个状态成立"(队列非空、任务完成),条件变量更合适。两者都能实现生产者消费者,选哪个看你对哪种模型更顺手。实验里两种都写一遍,体会差异,比死记硬背强得多。

6. 死锁:程序卡死时的排查链路

6.1 死锁的四个必要条件

死锁是并发程序最让人抓狂的问题——程序不报错,就是卡住不动。它的产生需要四个条件同时满足:互斥(资源不能共享)、占有并等待(拿着一个锁还等另一个)、不可剥夺(锁不能被强行抢走)、循环等待(A 等 B,B 等 A)。这四个条件缺一不可,所以破坏其中任何一个就能避免死锁。

最典型的死锁场景是两个线程以相反顺序获取两把锁:

// 线程 1 pthread_mutex_lock(&lockA); pthread_mutex_lock(&lockB); // 等 lockB // 线程 2 pthread_mutex_lock(&lockB); pthread_mutex_lock(&lockA); // 等 lockA

如果线程 1 拿到 A、线程 2 拿到 B,然后各自等对方手里的锁,就形成了循环等待,谁也动不了。这种 bug 在代码量小的时候容易看出来,一旦锁多了、调用层次深了,就非常隐蔽。

6.2 一次真实的卡死排查过程

我印象很深的一次,是有同学的生产者消费者程序跑着跑着就停住了,没有任何输出,Ctrl+C 才能退出。他一开始怀疑是循环条件写错了,改了半天没用。后来我让他用 gdb 挂上去看,步骤是这样的:

先gdb ./demo启动,然后run,等程序卡住后按 Ctrl+C 中断,执行thread apply all bt。输出显示两个线程都停在__lll_lock_wait上,一个在等empty_sem,一个在等mutex。再结合代码一看,问题清楚了:消费者在sem_wait(&full_sem)之后、pthread_mutex_lock之前,如果此时缓冲区恰好为空,它其实不该走到这里,但因为full_sem的初值设错了(设成了 0 但生产者还没开始生产就先 post 了一次),导致消费者误以为有数据,拿了 mutex 之后又去等一个永远等不到的条件,而生产者那边因为 mutex 被占着也进不来,双双卡死。

这个案例说明两点:一是 gdb 的thread apply all bt是排查死锁的利器,能直接看到每个线程卡在哪;二是死锁的根因往往不在锁本身,而在同步逻辑的前置条件错了。光盯着锁看是找不到问题的,要顺着调用栈往回捋业务逻辑。

6.3 预防死锁的几条实用规则

与其等死锁发生了再排查,不如从编码习惯上预防。我总结了几条实操性强的规则:

第一,固定加锁顺序。如果程序里有多把锁,给它们定一个全局顺序(比如按地址大小、按编号),所有线程都按这个顺序加锁,循环等待就不可能形成。这是最有效的一招。

第二,缩小锁的持有时间。拿到锁之后尽快做完该做的事就释放,别在持锁期间做耗时操作(比如 IO、sleep),否则其他线程等得越久,出问题的窗口越大。

第三,避免嵌套锁。能用一个锁解决的问题就别用两个,锁的数量和死锁风险是正相关的。如果非要嵌套,确保嵌套顺序全局一致。

第四,设置超时。pthread_mutex_timedlock可以带超时时间,拿不到锁就返回错误而不是无限等待,这样至少程序不会彻底卡死,能给你报错信息去定位。

注意:死锁和活锁、饥饿要区分开。活锁是线程一直在重试但没法推进(比如两个线程互相谦让),饥饿是某个线程长期抢不到资源。三者现象不同,排查思路也不同,实验报告里如果要求分析,别混为一谈。

7. 那些只有动手才会遇到的坑

7.1 打印顺序混乱不是 bug

很多同学第一次跑多线程程序,看到输出顺序乱七八糟、交错在一起,就以为程序写错了。其实这恰恰是并发执行的正常表现——多个线程同时往标准输出写,谁先谁后由调度决定,交错是必然的。标准输出本身是带缓冲的,多个线程同时写还可能把一行的内容拆开,出现半行半行拼接的情况。

如果你想让输出清晰可读,有两个办法:一是给打印也加锁,保证一次打印完整;二是每个线程的输出带上线程标识,方便区分。但要注意,加锁打印会改变程序时序,可能掩盖某些竞态问题,调试阶段要心里有数。

7.2 线程参数的生命周期陷阱

前面提过传局部变量地址的风险,这里展开说。看这段有问题的代码:

for (int i = 0; i < 5; i++) { pthread_create(&tids[i], NULL, worker, &i); }

循环变量i的地址被传给五个线程,但i只有一个,循环结束后i的值是 5,五个线程拿到的都是同一个地址,读出来全是 5(甚至更糟,读到的是循环过程中变化的值)。正确做法是给每个线程分配独立的参数空间:

int *args = malloc(5 * sizeof(int)); for (int i = 0; i < 5; i++) { args[i] = i; pthread_create(&tids[i], NULL, worker, &args[i]); }

或者更简单,直接把整数值强转成指针传进去(利用指针宽度足够存小整数这个事实),线程函数里再转回来。这个坑非常隐蔽,因为程序不崩溃,只是结果不对,新手很难联想到是参数传递的问题。

7.3 忘记 join 导致的资源泄漏

pthread_join不只是等待线程结束,它还负责回收线程占用的资源。如果创建了线程却从不 join,也不设置分离属性(detached),线程结束后资源不会释放,长时间运行的程序会持续泄漏。实验里循环创建大量线程时这个问题尤其明显。

两种处理方式:要么老老实实 join,要么在创建时设置PTHREAD_CREATE_DETACHED属性让线程结束后自动回收。后者适合那种"发射后不管"的线程,但要注意分离线程不能被 join,也不能再访问它的返回值。选哪种取决于你是否需要线程的执行结果。

7.4 编译警告不能视而不见

-Wall打开的警告里,有几条在并发编程中特别值得关注。比如"变量可能未初始化就被使用",在多线程环境下可能意味着某个线程读到了另一个线程还没写完的数据;比如"隐式声明函数",往往是因为忘了包含头文件,导致函数签名不匹配,运行时行为诡异。我强烈建议把警告当错误对待,加-Werror让警告直接中断编译,逼自己把每个警告都处理掉。这个习惯一旦养成,能帮你挡掉大量低级错误。

8. 让实验报告和代码都经得起推敲

8.1 用数据说话而不是空谈原理

实验报告最容易犯的毛病是通篇抄教材概念,什么"互斥锁保证临界区互斥访问"翻来覆去说,但没有任何自己的观察。真正加分的写法是:给出具体实验数据,用对比说明问题。比如竞态条件那部分,你可以跑十次不加锁的版本,记录每次的 counter 值,列成表格,展示结果的随机性;再加锁跑十次,展示结果的稳定性。这种"现象—分析—验证"的闭环,比任何理论描述都有说服力。

再比如性能对比,你可以用time命令测量不同线程数下的执行时间,画个简单的表:

线程数无锁耗时加锁耗时结果正确性
1基准略高正确
2更快但结果错接近基准正确
4更快但结果错因竞争变慢正确

这张表能直观说明"并发不是线程越多越快"以及"正确性优先"这两个道理,比干巴巴的文字强太多。

8.2 代码可复现是底线

提交的代码必须能在标准环境下一条命令编译运行,这是基本要求。我见过有同学代码里写死了自己机器上的绝对路径,换台机器就编译不过;也有人依赖了某个非标准库却没在报告里说明。稳妥的做法是附一个 Makefile 或者一段清晰的编译说明,把依赖列清楚。代码里关键位置加注释,尤其是同步逻辑那几行,说明为什么这里要加锁、那个信号量初值为什么是这个数,方便别人(也方便未来的你)理解。

8.3 主动分析边界情况

高分报告和普通报告的区别,往往在于有没有主动分析边界情况。比如生产者消费者,缓冲区大小为 1 和大小为 N 时行为有什么不同?生产者和消费者数量不等时会不会出问题?信号量初值设错会怎样?这些"如果……会怎样"的思考,体现的是你对机制的理解深度,而不是照着模板填代码。我在实际排查中发现的很多 bug,恰恰就藏在这些边界条件里,提前想清楚能省下大量调试时间。

最后分享一个我自己常用的调试习惯:写完并发代码后,先别急着跑,拿张纸把每个线程的执行路径画一遍,标出所有共享变量的读写点,逐个检查有没有保护。这个"纸上推演"的过程看着笨,但能提前发现大部分逻辑漏洞,比跑起来再抓瞎高效得多。并发程序的正确性,从来不是靠运气跑出来的,而是靠对每个共享状态的清醒认识一点点抠出来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询