简介:西安电子科技大学操作系统实验报告,完整记录Linux环境下六个操作系统核心实验:进程建立、线程共享进程数据、信号通信、匿名管道通信、命名管道通信,以及信号量实现进程同步。每项实验均包含C语言程序源码、运行结果截图和心得体会,可对照学习fork()创建父子进程、waitpid()同步等待、pthread_create()共享全局变量、kill()信号交互、pipe()管道传输、sem_wait()/sem_post()临界区互斥等关键机制。资源包含1个doc文档,大小377KB,内容紧凑、结构清晰,便于快速查阅。既适合西安电子科技大学操作系统课程学生对照实验要求查缺补漏,也适合Linux系统编程与并发编程初学者通过实际代码理解多进程协作、数据共享及同步互斥原理。已有1350人学习,能够帮助读者系统掌握从进程创建到通信同步的完整实践路径。
1. 操作系统实验项目:从 fork 到信号量的完整实践链路
某个学期操作系统课的上机实验,六次实验从进程创建一路做到信号量同步,内容不算多,但每一步踩的坑都挺真实。这份实验报告把「进程的建立、线程共享进程数据、信号通信、匿名管道、命名管道、信号量同步」六个实验的完整 C 代码、运行截图和实验心得都记了下来,适合三类人:正在补操作系统实验的学生、想快速复习 Linux 进程与 IPC 机制的开发者,以及要给课程设计找素材的人。反直觉的是,这几个实验代码看着都短,跑起来却全是细节:一个写错的头文件能让 gcc 直接拒编,一个 sleep(0.5) 会因为参数截断让并发节奏完全失控,信号注册和发送之间还藏着竞态。这份拆解就按「先看懂结构,再跑通,最后改造成自己的东西」的顺序,把每个实验的代码逐段拆开,顺带把那些不跑一遍根本发现不了的坑点标出来。
2. 项目结构与运行方式:六个实验的编译清单和依赖关系
2.1 实验矩阵:两个执行单元、三种 IPC、一种同步的递进设计
先给一张总表,把六个实验的脉络一次性理清:
| 实验 | 主题 | 核心 API | 知识点 |
|---|---|---|---|
| 一 | 进程的建立 | fork / waitpid | 父子进程协同工作 |
| 二 | 线程共享进程数据 | pthread_create | 同地址空间下全局变量共享 |
| 三 | 信号通信 | signal / kill | 异步事件通知 |
| 四 | 匿名管道 | pipe / read / write | 亲缘进程单向数据传输 |
| 五 | 命名管道 | mkfifo / open | 任意进程间通信 |
| 六 | 信号量同步 | semget / semop / mmap | PV 操作与共享内存 |
这张表其实是一条递进主线。实验一、二解决的是「执行单元怎么创建」:fork 造进程,pthread_create 造线程,顺便引入父子进程的等待关系。实验三到五解决的是「执行单元之间怎么传数据」:信号是最轻量的通知,匿名管道让父子进程能传字节流,命名管道把通信能力扩展到任意两个进程。实验六把前面所有概念收口:生产者消费者问题,既要用共享内存传数据,又要用信号量保证互斥和次序。
课程设计这个顺序是有道理的。如果先讲信号量再讲 fork,很难理解为什么需要同步原语;当你在实验四里看到 read 因为对方没关写端而卡死,自然会明白「多进程之间不能靠猜对方进度」这件事。报告的心得部分也是这么写的:实验六里消费者最初会取到还没生产的产品,查了半天发现是 PV 顺序错了一步,这种教训只有真正跑过才记得住。
2.2 运行环境与编译方式:Linux + gcc 一行命令跑通
环境就三样:一台 Linux 发行版(任意)、gcc、没了。不依赖 IDE,不用 CMake,每个实验是一个独立 C 文件。建议按 exp1.c 到 exp6.c 命名放在同一个目录。编译命令见下表:
| 实验 | 编译命令 | 注意事项 |
|---|---|---|
| 一 | gcc -o exp1 exp1.c | 运行前先在当前目录建 test1file.txt |
| 二 | gcc -o exp2 exp2.c -lpthread | 不加 -lpthread 链接不到 pthread_create |
| 三 | gcc -o exp3 exp3.c | 无特殊依赖 |
| 四 | gcc -o exp4 exp4.c | 无特殊依赖 |
| 五 | gcc -o exp5 exp5.c | 第二次运行前先 rm ./fifo |
| 六 | gcc -o exp6 exp6.c | 老系统加 -lrt,新 glibc 不需要 |
编译命令里的 -lpthread 值得单独说。从 glibc 2.34 开始 pthread 函数已经合进 libc,不加也能链接,但为了兼容老系统,我一般还是会写上。实验六的 mmap 同理:老版本 Linux 需要 -lrt 链接 librt,现代系统直接编过,报错再说。
运行层面有几件事要提前做。实验一依赖 test1file.txt 存在于当前目录,我一般先echo "hello os lab" > test1file.txt再跑。实验五会在目录里留下一个 fifo 文件,mkfifo 对已存在的文件会返回 -1,所以第二次跑之前要rm -f ./fifo,否则你会看到「管道创建失败」然后整个程序退出。每个实验的预期输出也要心里有数:实验一应该先打印子进程的读取过程,再打印父进程的「子进程已经结束」;实验二先打印线程里的几次输出,最后 main 再打印一次 shared data = 1;实验六的最终结果里两个消费者打印的产品编号应覆盖 1 到 100 且不重复,最后 sum 一定是 5050。拿这个做验收标准,比自己看日志靠谱得多。
提示:实验五的 mkfifo 在只读目录下会失败,别把代码放在挂载只读的路径下跑。
这份实验报告本身是一份完整的上机报告,六个实验的源码、运行截图和心得体会都在里面。拆解时把它们整理成独立文件,主要是为了编译方便——直接照抄报告里的代码也能跑,但得自己处理头文件和 test1file.txt 这些外围依赖,新手很容易在这一步就被劝退。
3. 核心模块拆解:进程建立与线程共享的两段关键代码
3.1 fork 双返回值:一个函数调用,三个执行分支
实验一是整个报告的地基。完整代码经过整理、修正了几处头文件问题后如下:
#include <unistd.h> #include <sys/wait.h> #include <stdio.h> #include <stdlib.h> int main() { pid_t fpid = fork(); // 一次调用, 在父子进程里各返回一次 if (fpid < 0) { printf("error\n"); // 返回值<0: 创建失败 } else if (fpid == 0) { // 子进程分支: 读文件并输出 printf("子进程: 我是子进程,id=%d,开始读取文件\n", getpid()); FILE *fp = fopen("test1file.txt", "r"); if (fp == NULL) { printf("未找到文件\n"); } else { char text[2000]; while (fgets(text, 2000, fp) != NULL) { puts(text); } } printf("子进程: 读取文件结束\n"); exit(1); } else { // 父进程分支: 等子进程结束 printf("父进程: 我是父进程id=%d,我要等待子进程\n", getpid()); int ret = waitpid(0, NULL, 0); printf("父进程: 子进程已经结束, ret=%d\n", ret); } return 0; }fork() 的语义是这一段代码的第一个重点:调用一次,返回两次。父进程拿到的返回值是子进程的 PID,子进程拿到的返回值是 0,所以 if / else if 三路分支天然把父子逻辑分开。子进程里用 getpid() 打印自己的 id,父进程打印自己的 id,通过输出顺序能直观看到两个进程各自执行了哪一段。文件读取部分用的是标准 C 的 fopen / fgets / puts,和进程 API 无关,子进程读完文件主动 exit(1),退出码随便设的,反正父进程 waitpid 时传了 NULL,不关心退出状态。
这段代码还有个容易被忽视的点:printf 的输出是带缓冲的,fork 时缓冲区会被一并复制,可能造成输出重复,这就是为什么有些同学实验一运行结果里日志出现了两遍——不是代码写错,是缓冲区复制带来的现象。子进程 exit 之后并不会立刻消失,它会变成僵尸进程,直到父进程调用 wait 系列函数回收,才真正被系统清理,这正好引出下一节。
3.2 waitpid:父进程的同步点,三个参数逐个抠
waitpid(0, NULL, 0) 在这个场景下的三个参数,值得逐项拆开:
- 第一个参数 pid=0:等待与调用进程属于同一进程组的任意子进程,而不是只等某一个。这里只有一个子进程,所以效果等同于 wait()。如果想精确等待某个子进程,应该把 fork 返回的 fpid 传进去。
- 第二个参数 NULL:不接收子进程退出状态。如果要拿退出码,传一个 int* 进去,配合 WEXITSTATUS 宏解析。
- 第三个参数 options=0:阻塞等待。换成 WNOHANG 则立即返回 0,配合轮询可以做非阻塞回收。
waitpid 的返回值是实际结束的那个子进程的 PID,所以实验一的输出里 ret 应该等于前面打印的子进程 id。用这个值可以验证「等的确实是那个子进程」。如果把 waitpid 换成 wait(),行为在这个场景下完全一样,但 waitpid 的优势是能指定 pid、能控制阻塞行为,是实际工程里的首选。
这个实验要回答的问题其实只有一个:父进程凭什么知道子进程干完了?答案就是 waitpid 阻塞在那里,子进程退出后内核给父进程发 SIGCHLD,waitpid 返回,父进程才继续往下走。这是进程同步最朴素的形态,也是后面信号量实验的远房亲戚。
3.3 线程共享:pthread_create 与全局变量
实验二的代码把「线程共享进程数据」这回事演示得很直接:
#include <stdio.h> #include <stdlib.h> #include <pthread.h> #include <unistd.h> 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; int pth = pthread_create(&tid, NULL, creat, NULL); if (pth != 0) { printf("creat thread error\n"); return -1; } else { sleep(3); // 等线程跑完, 这种写法只能用在教学场景 printf("creat thread success\n"); printf("shared data = %d \n", shdata); return 0; } }pthread_create 的四个参数分别是:线程 id 存储地址、线程属性(传 NULL 用默认)、线程函数指针、传给线程函数的参数(这里传 NULL)。返回值 0 表示成功,非 0 是错误码。线程函数里对全局变量 shdata 做了一次自增,main 线程睡 3 秒后再读,看到的是 1,这就证明了线程共享同一份进程地址空间——如果是 fork 出来的子进程,修改的只是自己的内存副本,父进程不可能看到变化。
但这段代码的教学味道很重,有个明显的偷懒:main 里用 sleep(3) 等线程执行完毕。如果注释掉这行,大概率 main 先打印 shared data = 0,线程还没跑完呢。真实程序里应该用 pthread_join(&tid, NULL) 阻塞等待线程结束,这跟实验一用 waitpid 等子进程是同一个道理,很多人到实验六都还没改过来。另一个隐患是 shdata++ 本身不是原子操作,多个线程同时改同一个变量会有竞争,实验二只有一个线程在改所以看不出来,到实验六的信号量才真正解决这个问题。
实验一和实验二放在一起看,正好回答操作系统课程里最经典的一问:进程和线程到底差在哪。进程是资源分配的基本单位,fork 出来的子进程有自己的地址空间副本,改数据互不可见;线程是调度的基本单位,和创建它的进程共享同一份地址空间,一个全局变量两边都能改。后面实验四到六的管道、共享内存、信号量,本质都是在为「跨进程共享数据」打补丁,线程天然共享反而简单,这也是并发编程里线程同步总比进程同步省事的原因。
4. IPC 与同步实战:信号、匿名管道、命名管道、信号量四连
4.1 信号通信:kill 与 signal 的异步处理
实验三是三个通信实验里最轻量的一个。信号本质是内核给进程发的一条「你该注意点什么了」的通知,不带数据载荷,值在触发动作。核心代码:
#include <unistd.h> #include <stdlib.h> #include <stdio.h> #include <signal.h> static int alarm_fired = 0; // 信号处理函数设置, main 循环读取 void ouch(int sig) { alarm_fired = 1; // 处理函数里只做最少的操作 } int main() { pid_t pid = fork(); if (pid < 0) { perror("fork failed\n"); exit(1); } else if (pid == 0) { kill(getppid(), SIGALRM); // 子进程向父进程发 SIGALRM exit(0); } else { signal(SIGALRM, ouch); // 注册处理函数, 替换默认动作 while (!alarm_fired) { // 忙等待, 直到收到信号 printf("no signal\n"); } if (alarm_fired) printf("\nI got a signal %d\n", SIGALRM); } return 0; }kill 函数第一个参数是目标进程 PID,子进程想发给父进程,所以用 getppid() 拿父进程 id;SIGALRM 的默认动作是终止进程,父进程必须先调用 signal(SIGALRM, ouch) 把默认动作替换成自定义处理函数,否则信号一来父进程直接没了。处理函数 ouch 只做一件事:置标志位。这是信号处理的标准姿势——在信号处理函数里做复杂操作很容易出问题,官方建议就是「设置标志,主程序响应」。
忙等待 while(!alarm_fired) 会一直空转打 "no signal",直到 ouch 把标志位置 1。这种写法教学上直观,但实际工程里浪费 CPU,更优的做法是用 pause() 挂起进程等信号。这段代码还藏着一个真正的竞态:signal() 注册发生在 fork 之后,如果子进程抢先执行了 kill,父进程还没来得及注册 handler,默认动作就直接把它终止了。我第一次跑的时候连续两次正常,第三次父进程莫名消失,查了半天才意识到是时序问题,后面避坑章专门展开。
4.2 匿名管道:pipe 的读写端与关闭时机
匿名管道是只在进程家族内部流通的管道,没有文件系统入口,靠 fork 时复制文件描述符实现共享。实验四的代码:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <string.h> #include <sys/types.h> #define MAX_LINE 80 int main() { int fd[2]; pid_t child_pid; if (pipe(fd) == 0) { // 先创建管道 printf("管道创建成功\n"); child_pid = fork(); if (child_pid == -1) { printf("子进程创建失败\n"); } else if (child_pid == 0) { // 子进程: 关闭写端, 只从管道读 close(fd[1]); sleep(3); // 等父进程写入 char readbuf[MAX_LINE]; read(fd[0], readbuf, sizeof(readbuf)); printf("子进程读取数据成功\n"); printf("data=%s\n", readbuf); close(fd[0]); exit(0); } else { // 父进程: 关闭读端, 只往管道写 close(fd[0]); char str[] = "hello the world"; write(fd[1], str, strlen(str)); printf("父进程写入数据成功\n"); close(fd[1]); // 写完立即关写端 printf("父进程关闭写端成功\n"); return 0; } } else { printf("管道创建失败\n"); } return 0; }pipe(int fd[2]) 创建两个描述符:fd[0] 读端,fd[1] 写端,数据按 FIFO 顺序单向流动。关键约束是 pipe() 必须写在 fork() 之前:先建管道,再 fork,父子进程各自继承一对读写端,然后通过「关闭自己不需要的那一端」来明确各自角色。子进程要读数据,就把写端 close(fd[1]) 关掉;父进程要写数据,就把读端 close(fd[0]) 关掉。
那为什么父进程写完之后还要再 close(fd[1]) 一次?因为 read 在管道读端会一直阻塞,直到所有写端都被关闭才能读到 EOF 并返回。如果父进程不关写端,子进程的 read 会一直挂着。这就是数据流方向之外的第二条铁律:写端必须全部关闭,读端才能结束。实测把父进程最后的 close(fd[1]) 注释掉,子进程果然卡死在 read 上,只能 Ctrl+C 收场。
子进程开头的 sleep(3) 也是个隐患。它假设「睡 3 秒父进程肯定写完了」,这是经验值不是同步保证。如果父进程被调度延迟,数据就没写进去。正确的做法是让读端在数据可读时自然返回——管道本来就自带阻塞同步,多此一举的 sleep 反而引入了不确定性。
4.3 命名管道:mkfifo 打破亲缘限制
匿名管道只能让有亲缘关系的进程用,命名管道(FIFO)在文件系统里有一个真实路径,任何对该路径有权限的进程都能打开,通信范围一下打开了。实验五:
#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main() { int ret = mkfifo("./fifo", 0666); // 在文件系统创建 FIFO if (ret < 0) { printf("管道创建失败\n"); exit(0); } else { printf("命名管道创建成功\n"); } pid_t fpid = fork(); if (fpid < 0) { printf("创建子进程失败\n"); } else if (fpid > 0) { // 父进程: 只读打开, 会阻塞到有写端打开 int fd = open("./fifo", O_RDONLY); if (fd < 0) { printf("读数据出错\n"); exit(0); } char buf[BUFSIZ] = {0}; int ret = read(fd, buf, sizeof(buf)); if (ret > 0) { printf("父进程read buffer后 =%s\n", buf); } close(fd); } else { // 子进程: 只写打开 int fd = open("./fifo", O_WRONLY); if (fd < 0) { printf("写数据出错\n"); exit(0); } char buf[100] = "HELLO,WORLD"; sleep(1); printf("子进程写入管道\n"); write(fd, buf, strlen(buf)); close(fd); } return 0; }mkfifo("./fifo", 0666) 在当前目录创建一个类型为 FIFO 的文件,第二个参数是权限位。注意权限 0666 不是最终权限,它要跟进程的 umask 做一次按位与,所以实际上多数情况是 0644。更严谨的写法是创建后 chmod 显式修正,但实验场景不必纠结。
open 的行为是理解 FIFO 的关键:只读方式 open 一个 FIFO 会阻塞,直到有进程以只写方式打开同一 FIFO;反过来同理。实验五里父进程先 O_RDONLY 打开并阻塞,子进程稍后 O_WRONLY 打开,两边同时解锁继续。这个「open 即握手」的语义,是命名管道和普通文件最大的区别。数据方向仍然是单向的,想双向通信得开两个 FIFO,这是后续改造时要记住的。
4.4 信号量同步:生产者消费者的 PV 操作
实验六是全报告最重的一节,用 System V 信号量加共享内存,实现 1 个生产者 + 2 个消费者。先看初始化部分:
#include <sys/mman.h> #include <sys/sem.h> #include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #define MAXSEM 5 union semun { // glibc 默认不导出, 必须自己定义 int val; struct semid_ds *buf; unsigned short *array; }; int fullid, emptyid, mutxid; // 三个信号量 id int main() { struct sembuf P, V; union semun arg; // 共享内存: 环形缓冲、总和、取货游标、放货游标 int *array = (int *)mmap(NULL, sizeof(int) * MAXSEM, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); int *sum = (int *)mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); int *get = (int *)mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); int *set = (int *)mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); // 创建三个 System V 信号量 fullid = semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); emptyid = semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); mutxid = semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); // 初始化: full=0(无产品), empty=5(五个空位), mutex=1(未锁) arg.val = 0; semctl(fullid, 0, SETVAL, arg); arg.val = MAXSEM; semctl(emptyid, 0, SETVAL, arg); arg.val = 1; semctl(mutxid, 0, SETVAL, arg); // 封装 P/V 参数: P=-1, V=+1 V.sem_num = 0; V.sem_op = 1; V.sem_flg = SEM_UNDO; P.sem_num = 0; P.sem_op = -1; P.sem_flg = SEM_UNDO; ...mmap 用 MAP_SHARED | MAP_ANONYMOUS 在子进程间共享内存,比 System V 的 shmget 简单,新代码基本都用 mmap。共享了四样东西:环形缓冲区 array[5]、累加器 sum、取货游标 get、放货游标 set。因为 fork 出来的子进程会完整复制父进程地址空间,只有 MAP_SHARED 的区域才是真正共享的,普通局部变量各写各的,这一点是理解整个实验的基础。
三个信号量是典型的三元组:full 统计「已有产品数」初始 0;empty 统计「空闲槽位数」初始 5;mutex 是互斥量初始 1。full 和 empty 负责同步,mutex 负责互斥,合起来就是经典 PV 模型。然后看生产者和消费者的主循环:
pid_t fpid1 = fork(); if (fpid1 == 0) { // 生产者: 生产 100 个产品 int i = 0; while (i < 100) { semop(emptyid, &P, 1); // P(empty) 占一个空槽 semop(mutxid, &P, 1); // P(mutex) 进临界区 array[(*set) % MAXSEM] = i + 1; (*set)++; printf("produce %d\n", i + 1); semop(mutxid, &V, 1); // V(mutex) 出临界区 semop(fullid, &V, 1); // V(full) 产品数+1 i++; } printf("producer is over\n"); exit(0); } else { pid_t fpid2 = fork(); if (fpid2 == 0) { // 消费者 A: 取产品并累加 while (1) { semop(fullid, &P, 1); // P(full) 没有产品就阻塞 semop(mutxid, &P, 1); // P(mutex) if (*get == 100) break; // 取完 100 个就退出 *sum += array[(*get) % MAXSEM]; printf("the comsumer A get number %d\n", array[(*get) % MAXSEM]); (*get)++; if (*get == 100) { printf("the sum is %d\n", *sum); break; } semop(mutxid, &V, 1); // V(mutex) semop(emptyid, &V, 1); // V(empty) 释放空槽 sleep(0.5); // 参数被截断, 见避坑章 } printf("consumer A is over\n"); exit(0); } else { // 消费者 B: 逻辑与 A 完全相同 ... } }生产者每轮执行 P(empty) 拿一个空槽,P(mutex) 进临界区,写入缓冲并推进 set 游标,V(mutex) 出临界区,V(full) 通知消费者「有一个新产品」。消费者对称:P(full) 等产品,P(mutex) 进临界区,从 get 游标取数累加,V(mutex),V(empty) 释放空槽。环形缓冲靠 *set % MAXSEM 和 *get % MAXSEM 取模实现,5 个槽位循环复用。当 get 走到 100,消费者打印累加结果 sum 并 break。
两个消费者竞争同一个 get 游标,但每次取值都在 mutex 保护的临界区内,所以 100 个产品不会重复取,最终 sum 恒等于 5050,这就是验收标准。不过这个版本有两个明显的坑:一是 break 直接跳过 V(mutex) 和 V(empty),会把互斥量锁死在手里;二是 sleep(0.5) 的 0.5 是 double,而 sleep 原型参数是 unsigned int,0.5 直接截断成 0,消费者之间几乎没有间隔,竞争更激烈,问题更难排查。这两处到避坑章再展开。
5. 避坑与排查:fork、管道、PV 操作里 5 个真实的翻车现场
这章的每一条都是实际运行才能暴露出来的,按「现象 → 原因 → 解决」的顺序写。
5.1 编译就翻车:sys/unistd.h 不存在
现象:gcc 编译实验一直接报错,fatal error: sys/unistd.h: No such file or directory,后面跟一串头文件连锁报错。
原因:Linux 下进程控制相关的头文件是 unistd.h,不在 sys/ 子目录下。实验报告原稿里写的是 #include <sys/unistd.h>,这多半是照着 Windows 平台或某本教材的习惯写出来的,在 Linux 上必挂。类似容易混淆的还有 sys/wait.h 和 sys/types.h,这两个确实在 sys/ 下,别一刀切全改。
解决:改成 #include <unistd.h>。顺带养一个好习惯:拿到任何实验代码,先跑一遍 gcc,编译过了再谈逻辑。后来我每次都会用 gcc -Wall 编一遍,用警告把可疑的隐式转换、未初始化变量提前筛掉。
5.2 信号没等到,父进程直接消失
现象:实验三跑了几次,有一次父进程连 "no signal" 都没打完就整个终止了;另一次是一直在刷 no signal,手动 Ctrl+C 才停。
原因:两个问题叠加。一个是竞态:signal(SIGALRM, ouch) 在 fork 之后才注册,子进程如果抢先执行 kill(getppid(), SIGALRM),父进程此时还持有 SIGALRM 的默认动作——终止进程,于是父进程直接被信号干掉。另一个是忙等待:while(!alarm_fired) 如果信号一直没来,就会无限空转。
解决:把 signal() 注册挪到 fork() 之前,保证子进程发信号时父进程已经准备好了 handler;忙等改用 pause() 挂起,收到信号后再继续。这类时序问题在单次运行里是玄学,多跑几次才现形,排查时先怀疑「注册顺序对不对」,再怀疑逻辑。
5.3 匿名管道 read 卡死不返回
现象:实验四子进程打印完「子进程读取数据成功」之前就卡住了,整个进程挂起,只能 Ctrl+C。
原因:read 在管道上会阻塞,直到所有写端都被关闭。常见两个来源:一是父进程写完数据后没有 close(fd[1]),写端还开着;二是 pipe() 写在了 fork() 之后,子进程继承不到管道描述符,拿到的 fd 跟父进程不是同一个管道。
解决:先 pipe() 再 fork(),父子各自都 close 自己不需要的那一端,父进程写完立即 close(fd[1])。记住一句话:read 从管道返回 EOF 的唯一前提是所有写端关闭,少关一个就多卡一分钟。
5.4 消费者取到还没生产的产品
现象:实验六第一次跑,消费者输出的编号出现乱序,甚至取到还没生产的产品,或者两个消费者合计次数超过 100,sum 不是 5050。
原因:原代码的 sleep(0.5) 是个隐蔽炸弹。sleep 参数类型是 unsigned int,0.5 被隐式截断为 0,消费者几乎无间隔地在临界区进进出出,竞争被放大;再加上 P/V 顺序如果写反(比如先 P(mutex) 再 P(empty)),就会出现「先锁住缓冲区却没产品可取」的空读或死锁。
解决:把 sleep(0.5) 换成 usleep(500000) 或 nanosleep,给消费者留出真实间隔;再检查每个操作里 P 的次序——对 full/empty 这类同步信号量的 P 应该在外层,对 mutex 的 P 在内层,保证「先确认资源可用,再加锁操作」。
5.5 消费者 break 时没释放互斥量
现象:程序能跑完大半,但最后一个消费者打印完 sum 之后进程不退出,或者另一个消费者一直停在等待状态,整个进程挂住。
原因:消费者循环里判断 *get == 100 后直接 break,跳过了本应执行的 V(mutex) 和 V(empty)。此时 mutex 还锁在手里,另一个消费者如果正阻塞在 P(mutex),就永远不会被唤醒;资源也没释放,属于教科书级的「锁泄漏」。实验场景下所有子进程马上退出,不一定每次都能看到挂死,但多跑几次或者调整延时后就会踩中。
解决:在 break 之前手动补上 V(mutex) 和 V(empty);更稳的写法是把「是否取完」的判断放到 V 操作之后,或者用 continue 结构把收尾逻辑统一,保证每次迭代 P 和 V 都成对出现。从那以后我看到 while(1) 里带 break 的临界区,第一反应就是数一遍 P 和 V 配不配对。
6. 二次开发思路:把生产者消费者模型改成可复用并发骨架
这份实验报告最有改造价值的点就在实验六。原题是 1 生产者 + 2 消费者,宏观上是一个完成品,但代码基本功上还有三处可以立刻改进。第一,把 P/V 封装成两个函数,而不是在每个循环里重复构造 struct sembuf;第二,消费者数量固定写死,改成命令行参数;第三,补齐 break 前的 V 操作。
我自己改的时候优先做了封装,代码是这样:
// 封装一次 P 操作: 信号量值减 1, 小于 0 则阻塞 void sem_p(int semid) { struct sembuf op = {0, -1, SEM_UNDO}; semop(semid, &op, 1); } // 封装一次 V 操作: 信号量值加 1, 唤醒等待者 void sem_v(int semid) { struct sembuf op = {0, 1, SEM_UNDO}; semop(semid, &op, 1); }从此 main 里所有 semop(fullid, &P, 1) 变成 sem_p(fullid),可读性直接上一个台阶。接着把消费者数量做成参数:用 atoi(argv[1]) 拿 N,在父进程里循环 fork N 个消费者,每个子进程执行同一段消费逻辑,共享的 get 游标依旧靠 mutex 保护,N 个消费者也不会重复消费。
验证方法有两个层次。功能层:连跑 20 次,每次 sum 都必须等于 5050,且所有产品编号覆盖 1 到 100 不重不漏;同步层:注释掉 sleep 后仍能正常跑完,不出现挂死,说明 P/V 配对是对的。如果想看得更细,可以用 strace -f -e trace=semop ./exp6 跟踪每个 semop 调用序列,检查 P 和 V 是否严格成对。
还有一条容易忽略的清理工作:System V 信号量用 semget 创建后不会随进程退出自动销毁,反复运行会在系统里积累垃圾,需要在所有子进程回收后调用 semctl(fullid, 0, IPC_RMID, arg),并对 emptyid、mutxid 做同样处理;mmap 的共享内存则用 munmap 释放。我一开始没加,跑了二十多次后 ipcs 一查,发现了一堆孤儿信号量,这才意识到清理跟创建一样重要。
从那以后我每次拿到别人的并发示例代码,都会先做两件事:把所有 P/V 或 lock/unlock 抄在一张纸上数配对,再检查有没有清理逻辑,确认这两点才敢往工程里搬。这份实验报告的价值就在这儿——代码短,但坑是完整的,跑通一遍等于把进程、线程、IPC、同步全部过了一遍。希望帮到你。
本文还有配套的精品资源,点击获取