简介西安电子科技大学操作系统实验报告完整记录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 / mmapPV 操作与共享内存这张表其实是一条递进主线。实验一、二解决的是「执行单元怎么创建」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) 在这个场景下的三个参数值得逐项拆开第一个参数 pid0等待与调用进程属于同一进程组的任意子进程而不是只等某一个。这里只有一个子进程所以效果等同于 wait()。如果想精确等待某个子进程应该把 fork 返回的 fpid 传进去。第二个参数 NULL不接收子进程退出状态。如果要拿退出码传一个 int* 进去配合 WEXITSTATUS 宏解析。第三个参数 options0阻塞等待。换成 WNOHANG 则立即返回 0配合轮询可以做非阻塞回收。waitpid 的返回值是实际结束的那个子进程的 PID所以实验一的输出里 ret 应该等于前面打印的子进程 id。用这个值可以验证「等的确实是那个子进程」。如果把 waitpid 换成 wait()行为在这个场景下完全一样但 waitpid 的优势是能指定 pid、能控制阻塞行为是实际工程里的首选。这个实验要回答的问题其实只有一个父进程凭什么知道子进程干完了答案就是 waitpid 阻塞在那里子进程退出后内核给父进程发 SIGCHLDwaitpid 返回父进程才继续往下走。这是进程同步最朴素的形态也是后面信号量实验的远房亲戚。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() 拿父进程 idSIGALRM 的默认动作是终止进程父进程必须先调用 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 上只能 CtrlC 收场。子进程开头的 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); // 初始化: full0(无产品), empty5(五个空位), mutex1(未锁) 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, V1 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 统计「已有产品数」初始 0empty 统计「空闲槽位数」初始 5mutex 是互斥量初始 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 int0.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手动 CtrlC 才停。原因两个问题叠加。一个是竞态signal(SIGALRM, ouch) 在 fork 之后才注册子进程如果抢先执行 kill(getppid(), SIGALRM)父进程此时还持有 SIGALRM 的默认动作——终止进程于是父进程直接被信号干掉。另一个是忙等待while(!alarm_fired) 如果信号一直没来就会无限空转。解决把 signal() 注册挪到 fork() 之前保证子进程发信号时父进程已经准备好了 handler忙等改用 pause() 挂起收到信号后再继续。这类时序问题在单次运行里是玄学多跑几次才现形排查时先怀疑「注册顺序对不对」再怀疑逻辑。5.3 匿名管道 read 卡死不返回现象实验四子进程打印完「子进程读取数据成功」之前就卡住了整个进程挂起只能 CtrlC。原因read 在管道上会阻塞直到所有写端都被关闭。常见两个来源一是父进程写完数据后没有 close(fd[1])写端还开着二是 pipe() 写在了 fork() 之后子进程继承不到管道描述符拿到的 fd 跟父进程不是同一个管道。解决先 pipe() 再 fork()父子各自都 close 自己不需要的那一端父进程写完立即 close(fd[1])。记住一句话read 从管道返回 EOF 的唯一前提是所有写端关闭少关一个就多卡一分钟。5.4 消费者取到还没生产的产品现象实验六第一次跑消费者输出的编号出现乱序甚至取到还没生产的产品或者两个消费者合计次数超过 100sum 不是 5050。原因原代码的 sleep(0.5) 是个隐蔽炸弹。sleep 参数类型是 unsigned int0.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 tracesemop ./exp6 跟踪每个 semop 调用序列检查 P 和 V 是否严格成对。还有一条容易忽略的清理工作System V 信号量用 semget 创建后不会随进程退出自动销毁反复运行会在系统里积累垃圾需要在所有子进程回收后调用 semctl(fullid, 0, IPC_RMID, arg)并对 emptyid、mutxid 做同样处理mmap 的共享内存则用 munmap 释放。我一开始没加跑了二十多次后 ipcs 一查发现了一堆孤儿信号量这才意识到清理跟创建一样重要。从那以后我每次拿到别人的并发示例代码都会先做两件事把所有 P/V 或 lock/unlock 抄在一张纸上数配对再检查有没有清理逻辑确认这两点才敢往工程里搬。这份实验报告的价值就在这儿——代码短但坑是完整的跑通一遍等于把进程、线程、IPC、同步全部过了一遍。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?