简介:西安电子科技大学《操作系统》课程配套实验报告,面向计算机相关专业学生,帮助读者通过Linux环境下的C语言编程深入理解进程管理、线程同步与进程间通信等核心机制。报告完整收录了六个实验:进程的建立、线程共享进程数据、信号通信、匿名管道与命名管道通信,以及用信号量实现进程同步,每个实验均包含实验目的、软硬件环境、核心代码、程序分析与心得体会。资源为单个doc文档,压缩包共1个文件,大小约377KB,内容组织紧凑,便于直接阅读和参考。已有1350人学习浏览。读者可从中获得实验的完整代码实现与排错思路,例如fork()创建父子进程、waitpid()等待机制、pthread_create()线程共享全局变量、kill()信号通信及sem_init()/sem_wait()/sem_post()临界区互斥等关键函数的使用方法,对操作系统课程设计、期末复习及并发编程入门均有实用价值。
1. 进程协作的底层逻辑:为什么一次 fork 会返回两次
运行一段调用 fork() 的 C 程序,你会撞见操作系统里最反直觉的现象:函数只调用了一次,却返回了两次,而且两个返回值还不一样。第一次接触的人多半以为是自己眼花了,实际上这正是 Linux 创建进程的核心语义。一套覆盖进程建立、线程共享数据、信号通信、匿名管道、命名管道和信号量同步的操作系统实验,恰好把多执行流协作的几大机制完整走了一遍。搞懂 fork 为什么能返回两次,waitpid 为什么能让父进程安静等待,信号为什么能打断正在运行的程序,管道为什么能像文件一样读写,你才算真正理解进程间通信不是“传数据”这么简单,而是一整套内核态的资源协调逻辑。
2. fork 与 waitpid:父子进程如何做到“一个调用、两次返回”
2.1 fork 的写时复制:子进程拿到的不是拷贝,而是共享页表
很多教程解释 fork 时会说“子进程复制了父进程的地址空间”,这句话让初学者以为 fork 是重操作。真实情况是,Linux 的 fork 使用写时复制(Copy-on-Write,COW)。fork 时内核不逐页复制物理内存,而是复制父进程的页表,把父子进程的页表项都标记为只读,同时增加物理页的引用计数。此后任一方尝试写入某个页,都会触发缺页异常,内核才真正分配一个新物理页、把原内容拷贝过去,并解除这一页的只读标记。
这个设计的动机很直接:大多数进程在 fork 后紧接着就 exec 替换地址空间,如果 fork 时把全部内存复制一遍,这些复制就全浪费了。COW 把复制成本推迟到真正写入的那一刻,让 fork 的代价与进程地址空间大小解耦,只与页表规模相关。常见的实现路径是:fork 走 do_fork → copy_process → dup_mmap,把父子进程的页表项统一清掉写权限,并在缺页异常处理里完成真正的复制。你写一个几 GB 内存的进程再 fork,如果子进程只读不写,fork 几乎瞬间返回,这就是 COW 的功劳。
顺带可以把文件描述符的继承讲透:如果 fork 之前父进程已打开文件,子进程继承的 fd 指向同一个 struct file,文件偏移是共享的;而实验一里的 fopen 发生在 fork 之后,所以父子进程各开各的文件,互不干扰。区分“共享的页”和“独立的文件描述符”,是理解 COW 语义的第一步。
实验一的核心分支结构是这样写的:
pid_t fpid = fork(); if (fpid < 0) { printf("error"); } else if (fpid == 0) { // 子进程分支:读文件并输出 printf("子进程:我是子进程, id=%d\n", getpid()); FILE *fp = fopen("test1file.txt", "r"); // 逐行读取输出... exit(1); } else { // 父进程分支:等待子进程 printf("父进程:我是父进程 id=%d\n", getpid()); waitpid(0, NULL, 0); }fork 返回后,父子进程从同一行代码继续执行,区分它们身份的只有返回值。内核在 copy_process 中构造新任务时,把子进程的返回路径设为返回 0,父进程则从系统调用返回处拿到子进程的 PID。注意实验代码里包含一个不存在的头文件 sys/unistd.h,Linux 下应该直接写 unistd.h,这是教学代码常见的笔误,能编译通过是因为其他头文件间接引入了声明。
2.2 waitpid 的等待队列:父进程阻塞时并没有“空转”
父进程调用 waitpid(0, NULL, 0) 后,如果子进程还没结束,父进程不会在 CPU 上忙等,而是进入可中断睡眠状态,把自己挂到子进程的等待队列上。内核通过 do_wait 遍历子进程,发现没有符合条件的已退出子进程时,就把当前进程设为 TASK_INTERRUPTIBLE,然后调用 schedule 让出 CPU。当子进程最终调用 exit 时,内核会唤醒等待队列上的父进程,并传递子进程的退出状态。
这里有个容易被误读的参数:waitpid 的第一个参数 0,含义是“等待与调用者同属一个进程组的任意子进程”,而不是“任意子进程”。只有一个子进程时两者表现相同,所以实验里直接写“等待子进程”也不算错。第三参数 0 表示阻塞等待;如果传入 WNOHANG 则立即返回,配合轮询可以做出非阻塞的回收逻辑。
子进程退出到父进程 wait 之间还有一个中间态:僵尸进程。子进程 exit 后 task_struct 仍保留,等待父进程读取退出状态,此时用 ps 能看到 defunct 标记。如果父进程一直不 wait,僵尸进程就残留,直到父进程自己退出。这也是实验一里 waitpid 存在的意义——它不只是“安排先后次序”,而是回收进程资源的必经之路。如果关心子进程的退出码,waitpid 的第二个参数要传入 int *status,再用 WEXITSTATUS 宏解析,传 NULL 表示放弃退出状态。
2.3 从实验一推演完整时序:谁先谁后其实不确定
很多人第一次跑实验一会问:为什么有时候先打印子进程的输出,有时候先打印父进程的“我要等待子进程”?原因是 fork 之后,父子进程同时处于就绪态,调度器决定谁先运行。父进程哪怕抢到 CPU,也很快在 waitpid 处阻塞;子进程则可能一路跑完文件读取再 exit。所以最终输出顺序基本稳定为“子进程先读文件、父进程后打印结束”,但中间的交错并不保证。
我用这个“不保证”来理解进程同步的起因:如果父进程不调用 waitpid,直接去读同一个文件,两个进程的文件偏移和读取进度就无法对齐,输出会出现交错混乱。waitpid 的作用是把并发的不确定性收敛成确定的先后关系,让“子进程读取完毕”成为“父进程继续执行”的前提。这也是理解所有进程同步机制的入口——同步的本质不是让进程跑得更快,而是给执行流之间施加约束,把共享资源的访问顺序固定下来。实验一的 waitpid 是内核提供的强力约束,后面实验里的信号、管道、信号量则是不同粒度的约束工具。
3. 线程与信号:共享地址空间和异步事件怎么协同
3.1 线程为什么能直接改全局变量:共享进程地址空间,隔离栈
实验二的核心代码只有几行,背后却是线程模型最重要的性质:同一进程内的多个线程共享进程的地址空间、全局变量、堆、文件描述符表,唯一独立的主要资源是栈和寄存器上下文。pthread_create 创建线程时,底层通过 clone 系统调用创建新的 task_struct,但复用了父进程的 mm_struct,这就是“共享地址空间”的实质。
从内核视角看,Linux 没有单独的线程概念:进程和线程都是 task_struct,差别只在 clone 时的标志位。CLONE_VM 让新线程复用地址空间,CLONE_FILES 共享文件描述符表。对比实验一:fork 不设 CLONE_VM,子进程有独立的地址空间;pthread_create 设了 CLONE_VM,新线程和主线程就是“一个身体里两条执行流”。代码里 shdata 是全局变量,存放于数据段,在所有线程的虚拟地址空间里是同一个地址,物理页也只有一份,所以线程函数里直接 shdata++,主线程就能看到变化。
int shdata = 0; void *creat(void *arg) { printf("new pthread ...\n"); printf("shared data = %d\n", shdata); shdata++; // 直接修改进程级全局变量 printf("after operating: shared data = %d\n", shdata); return (void *)0; } int main() { pthread_t tid; pthread_create(&tid, NULL, creat, NULL); sleep(3); // 用 sleep 等待线程执行 printf("shared data = %d\n", shdata); return 0; }注意这里的 sleep(3)——教学 demo 用休眠代替同步,这是能 work 但很粗糙的做法。sleep 只是让主线程阻塞 3 秒,客观上给新线程执行窗口,但它不保证新线程一定在 3 秒内跑完,也不保证线程创建后立即运行。严谨的做法是 pthread_join(tid, NULL),语义上对应实验一的 waitpid:等待的是“事件结束”,而不是“时间流逝”。如果你删掉 sleep 再跑,主线程很可能在子线程执行 shdata++ 之前就打印了 0,这就是数据竞争的直观演示。
3.2 信号不是数据通信:它是一个“打断你”的异步通知
实验三演示的是信号通信:子进程 kill(getppid(), SIGALRM) 向父进程发送 SIGALRM,父进程用 signal 注册处理函数 ouch,处理函数里把 alarm_fired 置 1。信号与管道、消息队列最大的区别是:它几乎不承载数据,只是一个“事件编号”。内核投递标准信号时不维护队列,pending 位图里同一个信号只有一位,如果信号还没被处理又来一个同款,新的会被合并丢弃。所以信号适合做“有情况”的通知,不适合做精确的数据传递。
static int alarm_fired = 0; void ouch(int sig) { alarm_fired = 1; // 置标志,主循环退出 } int main() { pid_t pid = fork(); if (pid == 0) { kill(getppid(), SIGALRM); // 子进程向父进程发信号 exit(0); } else { signal(SIGALRM, ouch); while (!alarm_fired) { printf("no signal\n"); // 忙等待 } printf("I got a signal %d\n", SIGALRM); } return 0; }信号处理函数的执行时机在“从内核态返回用户态”的路上:内核检查当前线程的 pending 队列,如果存在待处理信号,就把用户态寄存器上下文压栈,修改返回地址指向处理函数,处理函数执行完再恢复原上下文。这就是为什么进程即使在执行一个无限 for 循环,信号也能“插队”进来。实验里父进程的 while 循环每轮都调用 printf 触发系统调用,恰好给了内核频繁检查信号的机会;但这也是个典型坑——忙等待让 CPU 空转,下面排查章会细说。
还有一个藏着的时间窗:signal(SIGALRM, ouch) 注册发生在 fork 之后、父进程分支里。如果子进程抢在父进程执行 signal() 之前就把 SIGALRM 发出来,此时信号的处理动作还是默认的 SIGALRM——默认动作是终止进程,父进程可能直接被杀。信号是异步的,“注册”和“接收”之间的竞态很难靠肉眼抓住,工程上应该用 sigaction 并在 fork 之前完成注册,缩小窗口。
3.3 共享与通知:两种协作哲学的差异
把实验二和实验三放在一起看,能看到两种完全不同的协作方式。线程共享地址空间,天然能“看到”对方的数据,但代价是必须自己保证互斥;信号不共享内存,数据带不过去,但它能在任意时刻打断目标进程,天然是一种异步机制。一个适合“一起改同一份数据”,一个适合“告诉你发生了一件事”。
这也是后面实验六需要信号量的原因。实验二演示了共享,却没演示竞争:主线程在 sleep,新线程在改数据,两个执行流对 shdata 的访问没有真正交叉。一旦去掉 sleep,双方并发访问 shdata,就必然出现数据竞争——读到的值可能是中间状态,非原子操作甚至会让最终结果错乱。这个“缺掉的同步”正是整套实验里最值得深挖的留白。信号处理函数本身也有限制:最好只做异步安全的事,比如置一个 volatile sig_atomic_t 标志,不要在信号处理函数里调用 printf 这类非异步安全函数。实验里 ouch 只置标志是合理的,主循环里的 printf 属于教学噪音。
4. 管道通信:两个文件描述符如何串起数据流
4.1 匿名管道:环形缓冲区与“关掉不需要的那一端”
实验四用 pipe(fd) 创建管道,fd[0] 是读端,fd[1] 是写端。管道的本质是内核里的一个缓冲区,配套一组文件操作函数。调用 pipe 时,内核分配一个 pipe_inode_info 结构,内含环形缓冲区(Linux 默认容量约 64KB),并创建两个 file 对象分别指向读端和写端。用户拿到的只是两个整数 fd,但背后是完整的文件系统接口:read/write 系统调用进入管道驱动,而不是磁盘驱动。
int fd[2]; pipe(fd); child_pid = fork(); if (child_pid == -1) { // 创建失败 } else if (child_pid == 0) { close(fd[1]); // 子进程关闭写端 sleep(3); read(fd[0], readbuf, sizeof(readbuf)); printf("data=%s\n", readbuf); close(fd[0]); exit(0); } else { close(fd[0]); // 父进程关闭读端 write(fd[1], "hello the world", strlen(str)); close(fd[1]); // 关闭写端,通知 EOF return 0; }fork 之后,父子进程各持有一份 fd 数组拷贝,也就是四个描述符都指向同一个管道。如果不做处理,父进程的读端和子进程的写端都开着,内核无法通过“所有写端关闭”来判断读到文件结尾。所以协约是:管道使用者必须先关闭自己不需要的一端,把双向的 fd 对收敛成单向数据流。父进程只留写端,子进程只留读端,数据只能从父流向子。
管道“文件化”的边界语义很关键:读端 read 返回 0 表示写端全部关闭,而不是缓冲区为空。缓冲区空但写端还开着时,read 会阻塞等待;写端全关后 read 立即返回 0。父进程 write 之后 close(fd[1]),这个 close 就是对子进程的“没有更多数据”信号,子进程 read 返回 0 退出循环。这种隐式握手让管道在不需要额外同步原语的情况下完成数据传递。
顺带说一个细节坑:write 只写了 strlen(str) 个字节,不含字符串末尾的 '\0',子进程用 printf("%s", readbuf) 打印,必须读到 '\0' 才停,而 readbuf 是未初始化的栈内存,打印很可能会越界读到随机内容。正确做法是用 read 的返回值在缓冲区末尾手动补 '\0'。教学代码里写出来能跑,不代表行为是定义良好的。
4.2 命名管道:文件系统里的特殊 inode,不占磁盘数据空间
实验五用 mkfifo 创建命名管道。FIFO 在文件系统里是一个特殊文件,inode 类型是 S_IFIFO。创建时并不分配数据存储空间,只是给进程一个“按名字找到同一管道”的入口。匿名管道靠 fork 传递 fd,天然只能在父子或兄弟进程间用;命名管道靠路径名,任意两个知道路径的进程都能 open 它,前提是权限允许。
mkfifo("./fifo", 0666); // 创建命名管道 pid_t fpid = fork(); if (fpid > 0) { int fd = open("./fifo", O_RDONLY); // 父进程读端 read(fd, buf, sizeof(buf)); close(fd); } else if (fpid == 0) { sleep(1); int fd = open("./fifo", O_WRONLY); // 子进程写端 write(fd, "HELLO,WORLD", strlen("HELLO,WORLD")); close(fd); }mkfifo 的第二个参数是权限位,这里藏着一个最常踩的雷:如果当前目录已存在同名文件,mkfifo 直接返回 -1 并设置 EEXIST 错误码。实验里第一次运行创建成功,第二次运行却会走“管道创建失败”分支——路径里的 fifo 文件还在,而程序没有清理。很多人在实验课上都经历过“刚才还好好的,怎么再跑一次就失败”的困惑。
命名管道的 open 语义和普通文件差异很大。父进程先 open O_RDONLY,如果没有写端打开,这个 open 会阻塞,直到另一个进程以 O_WRONLY 打开同一个 FIFO 才返回。实验代码里子进程 sleep(1) 再 open 写端,实际上即使子进程先以 O_WRONLY open,它也会阻塞等待读端出现——无论谁先 open,FIFO 都会等另一方到位才完成握手。这个“双方到齐才会通”的机制,避免了匿名管道必须 fork 才能传 fd 的限制,但也提醒你:FIFO 的连接建立是同步的,读端和写端必须成对出现。工程上用 open 加 O_NONBLOCK 可以改变阻塞行为,读端无写端时立即返回 ENXIO,写端无读端时打开即失败。
4.3 管道读写模型:阻塞与流控
管道的读写不是“发出去就没了”,而是围绕内核缓冲区做流控。写入时如果缓冲区有空间,write 立即返回;缓冲区满时写进程阻塞,直到读端消费掉一部分数据。读端缓冲区为空且写端仍存在时阻塞等待,有数据或写端全部关闭才返回。这套机制让管道天然完成生产者和消费者的速率匹配,不需要额外的信号量。
正因为如此,实验四里子进程 sleep(3) 再 read 并不会丢数据:父进程 write 的内容先落进内核缓冲区,子进程醒来后 read 照样能读到。实验五里如果子进程写入大量数据而父进程一直不读,子进程的 write 会阻塞在缓冲区上,进程状态变成 TASK_INTERRUPTIBLE。这些现象只靠“管道是文件”解释不通,必须看到“缓冲区 + 阻塞”才算真正理解管道。
对比实验四和实验五还有一个差异:匿名管道的生命周期随持有 fd 的进程存在,所有 fd 关闭后 pipe 对象销毁;命名管道创建后是持久化的文件系统对象,即使没有进程打开它也存在于磁盘目录中。所以命名管道用完后要 unlink 清理,否则会积攒一堆垃圾 FIFO 节点。这和普通临时文件的管理习惯一致,我看到不少人在多进程开发里因为忘了清理 FIFO 文件而踩坑。
提示:判断管道读端是否到达流结尾,不要用“缓冲区是否为空”,要用 read 的返回值是否为 0。这是管道文件模型和普通文件模型最容易混淆的地方。
5. 信号量与共享内存:PV 原子性原理与常见问题排查
5.1 semget 与 semop:内核里的计数器与原子操作
实验六使用 System V 信号量接口,这是比 POSIX 信号量更古典的一套 API。semget 创建信号量集合,semctl 做初始化,semop 执行 PV 操作。核心是 struct sembuf 里的三个字段:sem_num 选择集合中的第几个信号量,sem_op 是操作数,sem_flg 是标志。sem_op 为 -1 时执行 P 操作,即申请资源:内核检查信号量值,大于等于 1 则减 1 并立即返回,已经是 0 则进程挂起。sem_op 为 1 时执行 V 操作:值加 1,并唤醒等待队列上的进程。
这套流程全部在 semop 系统调用内完成,内核持锁保护,所以“检查-修改-唤醒”是原子的。两个进程同时对同一个信号量执行 P 操作,绝不会出现两者都看到 val=1 然后都减到 0 的情况。用户态计数器做不到这一点,这也是为什么信号量必须由内核提供——它需要一个不被调度器打断的临界区。
实验里用了三个信号量:fullid 表示缓冲区已有产品数,初始 0;emptyid 表示空位个数,初始 MAXSEM(5);mutxid 是互斥锁,初始 1。生产者先 P(empty) 再 P(mutex),生产后 V(mutex) 再 V(full);消费者先 P(full) 再 P(mutex),消费后 V(mutex) 再 V(empty)。
struct sembuf P = {0, -1, SEM_UNDO}; // P 操作:申请资源 struct sembuf V = {0, 1, SEM_UNDO}; // V 操作:释放资源 // 生产者 semop(emptyid, &P, 1); // 申请一个空位 semop(mutxid, &P, 1); // 进入临界区 array[(*set) % MAXSEM] = i + 1; // 生产产品 (*set)++; semop(mutxid, &V, 1); // 离开临界区 semop(fullid, &V, 1); // 产品数加一 // 消费者 semop(fullid, &P, 1); // 申请一个产品 semop(mutxid, &P, 1); // 进入临界区 *sum += array[(*get) % MAXSEM]; (*get)++; semop(mutxid, &V, 1); // 离开临界区 semop(emptyid, &V, 1); // 空位数加一这个顺序不是随便写的:先 P 资源再 P 互斥锁,保证互斥锁持有时间尽量短。如果反过来先 P(mutex) 再 P(empty),且缓冲区已满,生产者占着锁等空位,消费者需要锁却拿不到,就形成死锁。这是教科书级别的死锁场景,实验代码特意避开了。
SEM_UNDO 标志值得单独说。它让内核在进程异常退出时自动回滚该进程对信号量的所有操作,相当于信号量的“后悔药”:进程 P 了信号量后崩溃,没有 SEM_UNDO 的话信号量值永久少一个,其他进程可能永远等不到资源;传了 SEM_UNDO,内核记录 semadj 调整量,退出时自动补偿。教学实验不传也跑得动,生产代码强烈建议传。
5.2 mmap 与 MAP_SHARED:让 fork 出来的进程共享同一块物理页
实验六里 array、sum、get、set 四个指针都是 mmap 返回的地址。mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0) 的含义是:让内核分配一段匿名内存映射,带 MAP_SHARED 后,对这段内存的写入会直接反映到共享的物理页上,所有映射了同一区域的进程都能看到。
关键是 fork 和 mmap 的交互。fork 复制父进程的地址空间,但 MAP_SHARED 映射的页不会变成 COW 隔离,而是保持父子进程共享同一物理页,页表项的共享计数增加。所以生产者进程里 (*set)++,消费者进程里读 *get 能看到同步变化。如果是普通堆内存或栈内存,fork 后是 COW 隔离,父子各改各的,谁也看不到谁。这就是实验六能用 mmap 传指针、传计数器却不靠管道的原因。
还有一个细节:匿名 mmap 返回的内存由内核清零,所以 sum、get、set 指向的 int 初始值都是 0,代码里没初始化就能直接参与运算。malloc 返回的内存则是随机的,不能这么用。如果这段代码换成 POSIX 共享内存(shm_open + mmap),新创建的共享对象同样是零初始化,规则一致。
实验六里两个消费者进程同时执行 *sum += array[(*get) % MAXSEM],这一行是典型的读-改-写临界区。两个消费者都先 P(full) 再 P(mutex),mutex 保证同一时刻只有一个消费者进入这段代码;但如果某个流程漏了一次 V(mutex),另一个消费者就会永久阻塞在 P(mutex) 上,表现为程序卡住不动。这也是实验报告里提到的“消费者取出生产者还未生产的产品”问题的同源症结——P 和 V 必须严格成对,少一次 P 竞争提前,少一次 V 死锁。
5.3 常见问题排查:实验代码里最容易翻车的五个点
现象:实验五第二次运行,mkfifo 返回 -1,程序直接走“管道创建失败”分支。原因:第一次运行留下的 FIFO 文件还在当前目录,mkfifo 检测到同名文件存在,返回 EEXIST 错误,并不会覆盖创建。解决:运行前 unlink("./fifo") 或 rm -f ./fifo;也可以在 mkfifo 前用 access 检查文件是否存在。养成“用完清理 FIFO”的习惯,否则不仅这次实验翻车,积累的 FIFO 节点还会干扰后续程序的路径匹配。
现象:实验三运行后终端疯狂刷 “no signal”,CPU 占用率飙高。原因:父进程用 while(!alarm_fired) 忙等待,循环里每轮 printf 都触发系统调用,CPU 一直得不到让出机会;信号到达后 alarm_fired 置位循环才退出。解决:用 pause() 让出执行权,或者用 sigsuspend 挂起等待信号。忙等待在教学演示里显得直观,但真实项目里看到 while 循环等标志位,就应当警惕这个坑——可用的 CPU 时间片被无意义地烧掉了。
现象:实验六运行结果不稳定,有时消费者取到生产者还没生产的空位,甚至两个消费者取到同一个产品编号。原因:PV 顺序或数量不匹配。排查方法是把每个信号量的 P/V 列成表格逐行核对:生产路径是 P(empty) → P(mutex) → 生产 → V(mutex) → V(full),消费路径对称;检查有没有漏掉对 empty 的 V,导致生产者永久阻塞;检查 mutex 的 P/V 是否严格成对,漏 V 就是死锁。解决:先 P 资源再 P 锁,先 V 锁再 V 资源,顺序不能反;在临界区结束后立即 V,缩短锁持有时间。
现象:实验二去掉 sleep(3) 后,主线程经常打印 shdata=0,仿佛线程没执行。原因:pthread_create 只负责创建线程并让它进入就绪态,不保证创建后立即运行;主线程可能在子线程执行 shdata++ 之前就运行到 printf 了,属于典型的数据竞争。解决:用 pthread_join(tid, NULL) 阻塞等待线程结束,而不是 sleep。sleep 是猜时间,join 是等事件,后者才是正确的同步原语。
现象:程序结束后 ps 显示一堆 mark 为 defunct 的僵尸进程。原因:子进程已经 exit,但父进程没有调用 wait/waitpid 回收退出状态,子进程的 task_struct 和退出码滞留在内核中。解决:父进程要为每个子进程调用 wait。注意 waitpid(0, NULL, 0) 只回收一个子进程,多子进程场景要在循环里反复调用,直到返回 -1 且 errno 为 ECHILD;另一种做法是 signal(SIGCHLD, SIG_IGN),让内核自动回收。
6. 设计取舍:当教学代码用 sleep 和轮询代替同步原语
回头看这六个实验,会发现一个共同特征:几乎每个代码片段里都有 sleep 的影子。实验二用 sleep 等线程,实验三用忙等循环等信号,实验四用 sleep 让双方错峰,实验五用 sleep 错开写端和读端的 open。这些不是教学上的巧合,而是刻意简化——用足够长的时间窗口掩盖调度不确定性,让初学者先跑通流程,再理解同步。但代价也很明显:sleep 把一个同步问题变成了时间问题,它不保证事件一定发生,只保证“过一段时间再检查”。
实验二的线程如果被调度器延迟,3 秒内没跑完,主线程打印的就是错误值;实验三的忙等会烧 CPU,信号晚到一秒就多空转一秒。真实工程里,这些位置应该用 pthread_join、sigsuspend、阻塞读写和信号量替代,因为它们的语义是“等待条件成立”,而不是“等待时间流逝”。sleep 能作为实验代码跑通,恰恰说明它掩盖了竞态——一旦条件不满足,程序照样错,只是错误被时间窗口推迟了。
从那以后,我每次写并发 demo 都强迫自己走一遍确认流程:先列出所有共享资源,再标出每个临界区,最后一条条写清楚每个信号量的 P/V、每个 wait/join 对应的事件是什么。写完再问自己两个问题:如果对方永远不来,我会不会卡死?如果对方提前结束,我会不会读到脏数据?这两个问题能挡住大部分竞态和死锁。希望这套实验报告里的代码和踩坑记录,也能帮你把注意力从“怎么调通”挪到“为什么能 work”上。
本文还有配套的精品资源,点击获取