news 2026/10/22 1:26:43

操作系统实验实战:从fork到信号量,六个实验打通并发编程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
操作系统实验实战:从fork到信号量,六个实验打通并发编程

简介:这份西安电子科技大学操作系统实验报告面向高校计算机专业学生及操作系统课程学习者,聚焦Linux环境下进程管理、线程同步与进程间通信三大核心主题,帮助读者通过动手编程加深对操作系统原理的理解。资源包内含1个doc文档,约377KB,完整记录了六个实验的代码、分析与心得体会,涵盖fork()创建父子进程、pthread_create()实现线程共享全局数据、信号通信、匿名管道与命名管道通信,以及用信号量完成进程同步等内容。文档中配有可直接参考的C语言程序清单与逐段分析,并附有实验截图与心得总结,便于读者对照复现实验流程、理解waitpid()、sem_wait()等关键函数的用法,排查进程协同与临界区互斥中的常见问题。目前已有1346人学习下载,适合正在修读操作系统实验课程或希望夯实Linux系统编程基础的学习者参考借鉴。

1. 从一份操作系统实验报告说起:六个实验到底在练什么

很多人第一次翻开操作系统实验指导书,看到 fork、pthread_create、pipe、mkfifo、semget 这一串函数名,第一反应是“这不就是背 API 吗”。但真正把代码敲进去、编译、运行、看到父子进程交替输出、看到管道里数据被读走、看到生产者消费者在信号量控制下有序推进,才会意识到这些实验练的不是函数记忆,而是对“并发执行”这件事的直觉。这份实验报告覆盖了进程建立、线程共享数据、信号通信、匿名管道、命名管道、信号量同步六个实验,环境是 Linux + gcc,语言是 C。它适合正在上操作系统课、需要交实验报告的人,也适合已经工作但想补一补系统编程底子的开发者。下面我按“能跑起来、能看懂、能排错”的顺序,把这六个实验拆一遍。

2. 进程与线程:fork、waitpid、pthread_create 的实操拆解

2.1 fork 之后到底发生了什么

实验一的核心是fork()。调用一次,返回两次:父进程拿到子进程的 pid,子进程拿到 0,出错返回 -1。这个“一次调用两次返回”是理解进程创建的关键。子进程复制父进程的地址空间、文件描述符表、信号处理方式,但 pid 不同、返回值不同。

实验里父进程用waitpid(0, NULL, 0)等待子进程读完文件再继续。这里第一个参数 0 表示等待任意子进程,第二个参数不关心退出状态,第三个参数 0 表示阻塞等待。常见坑是子进程先于父进程结束,父进程没 wait,子进程变成僵尸进程,ps里看到 Z 状态。

#include <sys/unistd.h> #include <sys/wait.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> int main() { pid_t fpid = fork(); // 创建子进程 int count = 0; if (fpid < 0) { printf("error"); } else if (fpid == 0) { // 子进程分支: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 { // 父进程分支:fpid 为子进程 pid printf("父进程:我是父进程id=%d ,我要等待子进程\n", getpid()); int ret = waitpid(0, NULL, 0); // 阻塞等待子进程结束 printf("父进程:子进程 已经结束,ret=%d\n", ret); } return 0; }

编译命令是gcc fork_demo.c -o fork_demo,运行前先建一个test1file.txt,随便写几行内容。运行后你会看到子进程先输出文件内容,父进程再输出等待结束。如果文件不存在,子进程打印“未找到文件”后退出,父进程仍然会等到它结束。

参数说明:waitpid的 pid 参数可以填具体子进程 pid、-1 表示任意子进程、0 表示同进程组的任意子进程。status 传 NULL 表示不关心退出码。options 传 WNOHANG 可以非阻塞轮询,但实验里用 0 就够了。

2.2 线程共享数据的验证与编译坑

实验二用pthread_create创建线程,线程函数里修改全局变量shdata,主进程 sleep 3 秒后打印,发现值变了。这说明线程共享同一进程的地址空间,全局变量、堆内存、文件描述符都是共享的,只有栈和寄存器是线程私有的。

#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; } }

编译要加-lpthread:gcc thread_demo.c -o thread_demo -lpthread。不加这个链接选项会报undefined reference to pthread_create,这是新手最常见的翻车点。另外sleep(3)只是让主线程等一会儿,不是同步手段,线程如果跑得慢,主线程可能先打印。严谨做法是用pthread_join(tid, NULL)替代 sleep。

参数说明:pthread_create第一个参数是线程标识符指针,第二个是线程属性(NULL 表示默认),第三个是线程函数指针,第四个是传给线程函数的参数。线程函数返回void *,这里返回 0。

3. 进程间通信:信号、匿名管道、命名管道的落地细节

3.1 信号通信的异步本质

实验三用kill(getppid(), SIGALRM)让子进程给父进程发信号,父进程用signal(SIGALRM, ouch)注册处理函数,把alarm_fired置 1,主循环退出。信号是异步的,父进程在while(!alarm_fired)里空转,直到信号到达。

#include <unistd.h> #include <sys/types.h> #include <stdlib.h> #include <stdio.h> #include <signal.h> static int alarm_fired = 0; void ouch(int sig) { alarm_fired = 1; // 信号处理函数里只做标记 } int main() { pid_t pid; pid = fork(); if (pid < 0) { perror("fork failed\n"); exit(1); } else if (pid == 0) { kill(getppid(), 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; }

这里有个时序问题:如果子进程在父进程调用signal之前就发了信号,父进程可能收不到,导致死循环。实际写的时候,父进程应该先注册信号处理函数,再 fork,或者用sigaction配合阻塞/解除阻塞来保证时序。实验代码能跑通是因为 fork 之后父进程通常先执行到 signal,但不是绝对可靠。

参数说明:kill的第二个参数是信号编号,SIGALRM 默认动作是终止进程,这里被自定义处理函数覆盖。signal返回旧的处理函数指针,出错返回 SIG_ERR。

3.2 匿名管道的读写端关闭规则

实验四用pipe(fd)创建匿名管道,fd[0] 是读端,fd[1] 是写端。父进程关读端写数据,子进程关写端读数据。关键规则:管道写端全部关闭后,读端 read 返回 0;读端全部关闭后,写端 write 会触发 SIGPIPE。

#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) { printf("子进程创建成功\n"); 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"); } }

注意read读到的数据不一定以\0结尾,printf("%s")可能越界。稳妥做法是读完后手动补\0,或者用write的返回值确定长度。实验里readbuf没初始化,如果管道里数据刚好填满,打印会出乱码。

参数说明:pipe的参数是长度为 2 的 int 数组,成功返回 0。read返回实际读到的字节数,0 表示写端已关闭。write返回实际写入字节数,管道满时阻塞。

3.3 命名管道的文件系统视角

实验五用mkfifo("./fifo", 0666)创建命名管道,它会在文件系统里留下一个类型为 p 的文件。之后父子进程分别open这个路径,子进程以 O_WRONLY 打开写入,父进程以 O_RDONLY 打开读取。

#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); // 创建命名管道 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}; printf("父进程read buffer前 =%s\r\n", buf); int ret = read(fd, buf, sizeof(buf)); if (ret > 0) { printf("父进程read buffer后 =%s\r\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"); int ret1 = write(fd, buf, strlen(buf)); close(fd); } return 0; }

命名管道和匿名管道的区别:匿名管道只能用于有亲缘关系的进程,命名管道通过路径名可以让任意进程打开。open命名管道时,O_RDONLY 会阻塞到有写端打开,O_WRONLY 会阻塞到有读端打开。如果只开一端,进程会卡在 open 上。实验里父子进程都打开后,阻塞解除。

参数说明:mkfifo的 mode 参数 0666 表示所有用户可读写,实际权限受 umask 影响。open的 flags 可以加 O_NONBLOCK 避免阻塞,但读写行为会变。

4. 信号量同步:生产者消费者里的 PV 操作与共享内存

4.1 三个信号量的职责划分

实验六是六个实验里最复杂的。它用semget创建三个信号量:fullid 表示已有产品数量,初值 0;emptyid 表示空余缓冲区数量,初值 MAXSEM=5;mutxid 表示互斥访问缓冲区,初值 1。生产者先对 emptyid 和 mutxid 做 P 操作,写入产品,再对 mutxid 和 fullid 做 V 操作。消费者先对 fullid 和 mutxid 做 P 操作,取出产品,再对 mutxid 和 emptyid 做 V 操作。

#include <sys/mman.h> #include <sys/types.h> #include <sys/sem.h> #include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <errno.h> #include <time.h> #include <stdlib.h> #define MAXSEM 5 union semun { int val; struct semid_ds *buf; unsigned short *array; }; int fullid, emptyid, mutxid; int main() { struct sembuf P, V; union semun arg; int *array, *sum, *get, *set; // 共享内存映射,父子进程共享 array/sum/get/set array = (int *)mmap(NULL, sizeof(int) * MAXSEM, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); sum = (int *)mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); set = (int *)mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); get = (int *)mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); // 创建三个信号量 fullid = semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); emptyid = semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); mutxid = semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); // 初始化 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 操作:sem_op = -1;V 操作:sem_op = 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; pid_t fpid1 = fork(); if (fpid1 == 0) { // 生产者 int i = 0; while (i < 100) { semop(emptyid, &P, 1); // 等待空位 semop(mutxid, &P, 1); // 互斥进入 array[(*set) % MAXSEM] = i + 1; (*set)++; printf("produce %d\n", i + 1); semop(mutxid, &V, 1); // 退出互斥 semop(fullid, &V, 1); // 通知有产品 i++; } sleep(1); printf("producer is over\n"); exit(0); } else { pid_t fpid2 = fork(); if (fpid2 == 0) { // 消费者 A while (1) { semop(fullid, &P, 1); semop(mutxid, &P, 1); if (*get == 100) break; *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); semop(emptyid, &V, 1); sleep(0.5); } printf("consumer A is over\n"); exit(0); } else { // 消费者 B,逻辑同 A while (1) { semop(fullid, &P, 1); semop(mutxid, &P, 1); if (*get == 100) break; *sum += array[(*get) % MAXSEM]; printf("the comsumer B get number %d\n", array[(*get) % MAXSEM]); (*get)++; if (*get == 100) { printf("the sum is %d\n", *sum); break; } semop(mutxid, &V, 1); semop(emptyid, &V, 1); sleep(0.5); } printf("consumer B is over\n"); exit(0); } } }

编译命令:gcc sem_demo.c -o sem_demo。运行后生产者输出 produce 1 到 100,两个消费者交替取出并累加,最后打印 sum。

参数说明:semget的 key 用 IPC_PRIVATE 表示创建新信号量,nsems 为 1,flag 为 IPC_CREAT|0666。semctl的 SETVAL 用 union semun 传初值。semop的 sembuf 结构里 sem_num 是信号量在集合中的下标,sem_op 为 -1 是 P,+1 是 V,SEM_UNDO 让进程退出时自动撤销操作。

4.2 共享内存映射的父子继承

mmap用 MAP_SHARED | MAP_ANONYMOUS 创建匿名共享映射,fork 后父子进程看到同一块内存。array 是 5 个 int 的环形缓冲区,set 是生产者的写下标,get 是消费者的读下标,sum 是累加和。这四个指针本身是局部变量,但指向的映射区域是共享的,所以(*set)++在父进程里改,子进程能看到。

如果不加 MAP_SHARED,fork 后子进程拿到的是写时复制副本,修改不会反映到父进程。这是实验六最容易翻车的地方之一。

5. 避坑与排查:六个实验里最容易翻车的五件事

5.1 僵尸进程与孤儿进程

现象:ps aux看到子进程状态是 Z,或者父进程退出后子进程被 init 收养。原因:父进程没有调用 wait/waitpid 回收子进程退出状态。解决:父进程在 fork 后必须 wait,或者用 signal(SIGCHLD, SIG_IGN) 让内核自动回收。实验一里 waitpid 就是干这个的。

5.2 管道读写端未关闭导致阻塞

现象:读端一直阻塞,或者写端收到 SIGPIPE 退出。原因:管道需要所有写端关闭后读端才返回 0,所有读端关闭后写端才收到 EPIPE。解决:fork 后立即关闭不需要的那一端,父进程关读端、子进程关写端,反之亦然。实验四和实验五都遵循这个规则。

5.3 信号处理函数里的竞态

现象:父进程偶尔收不到信号,死循环。原因:子进程在父进程 signal 注册之前就发了信号。解决:先注册信号处理函数再 fork,或者用 sigprocmask 阻塞信号,注册后再解除阻塞。实验三的代码顺序有隐患,实际写的时候要调整。

5.4 信号量初值设错导致死锁

现象:生产者或消费者卡在 semop 上不动。原因:fullid 初值不是 0、emptyid 初值不是缓冲区大小、mutxid 初值不是 1。解决:初始化后打印三个信号量的值确认,或者用 semctl GETVAL 读回来检查。实验六里 fullid=0、emptyid=5、mutxid=1 是标准配置。

5.5 共享内存未用 MAP_SHARED

现象:子进程修改了变量,父进程看不到。原因:mmap 用了 MAP_PRIVATE,或者用了普通 malloc。解决:确认 mmap 的 flags 包含 MAP_SHARED,匿名映射加 MAP_ANONYMOUS。实验六里四个映射都用了 MAP_SHARED。

6. 从能跑到能讲清楚:实验报告的验证方法与进阶习惯

把六个实验跑通只是第一步,实验报告要能讲清楚“为什么这样写”。我一般会做三件事来验证自己真的懂了。

第一,改参数看行为变化。比如把实验六的 MAXSEM 从 5 改成 1,缓冲区变成单槽,生产者和消费者会严格交替;改成 10,生产者可以连续生产 10 个再阻塞。把 sleep(0.5) 去掉,观察输出顺序是否还稳定。把 mutxid 的 P/V 操作注释掉,看是否出现数据覆盖。这些改动能让你直观感受到信号量的作用。

第二,用 strace 跟踪系统调用。strace -f ./fork_demo可以看到 fork、wait4、read、write 的调用顺序和返回值。strace -f ./sem_demo能看到 semop 的阻塞和唤醒。这比看代码更直接。

第三,整理一份函数速查表。下面这张表是我自己常用的:

函数所属实验关键参数返回值含义
fork实验一无父进程返回子 pid,子进程返回 0,出错 -1
waitpid实验一pid=0 表示任意子进程返回子 pid,出错 -1
pthread_create实验二第三个参数是线程函数成功 0,失败错误码
kill实验三第二个参数是信号编号成功 0,失败 -1
pipe实验四fd[0] 读,fd[1] 写成功 0,失败 -1
mkfifo实验五mode 受 umask 影响成功 0,失败 -1
semget实验六IPC_PRIVATE 创建新信号量返回信号量 id,失败 -1
semop实验六sem_op=-1 是 P,+1 是 V成功 0,失败 -1
mmap实验六MAP_SHARED 才能共享成功返回映射地址,失败 MAP_FAILED

最后说一个习惯:每次写完涉及 fork 或 pthread 的代码,编译时先把-Wall -Wextra加上,让编译器把未使用变量、类型不匹配、隐式声明都报出来。链接 pthread 时确认-lpthread在编译命令末尾。运行前先ulimit -a看一眼进程数限制,避免 fork 炸弹。这些动作花不了几秒钟,但能省掉大量排查时间。从那以后我每次跑并发程序之前,都会先确认信号量初值和管道端关闭顺序,再开始编译。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 9:09:35

【新版架构设计师】1~3章重点知识梳理

第 1 章 计算机组成与体系结构 计算机组成: 外设 (输入设备、输出设备、辅助存储器) 主机(主存储器、运算器、控制器) 控制器组成: 程序计数器 PC:存储下一条要执行指令的地址; 指令寄存器 IR:存储即将执行的指令; 指令译码器ID:对指令中的操作码字段进行分析解释; 时序…

作者头像 李华
网站建设 2026/10/16 3:31:54

普通人如何用DeepSeek大模型API实现文本批处理与检索增强问答

简介&#xff1a;这份PDF是清华大学新闻与传播学院新媒体研究中心推出的DeepSeek应用指南&#xff0c;面向希望借助大模型提升工作与学习效率的普通用户及开发者。内容围绕DeepSeek-R1开源推理模型展开&#xff0c;系统梳理其能力图谱&#xff0c;涵盖智能对话、文本生成、语义…

作者头像 李华
网站建设 2026/10/20 15:21:17

深度学习机制级原理:从梯度信噪比到流形对齐的工程化基础

简介&#xff1a;这是一本由机器学习权威Christopher M. Bishop与Hugh Bishop合著的深度学习前沿教材&#xff0c;面向高校研究生、AI研究人员及具备数学与编程基础的进阶学习者&#xff0c;系统构建从神经网络基础到Transformer、图神经网络等现代架构的理论框架。资源为单文件…

作者头像 李华
网站建设 2026/10/20 0:26:15

Linux常用命令大全怎么用?从man手册到实战排查的进阶路线

简介&#xff1a;这是一份汇聚 Linux 常用命令的 PDF 速查手册&#xff0c;面向刚接触 Linux 的初学者、日常需要命令行操作的开发与运维人员&#xff0c;帮助读者快速定位文件管理、磁盘空间、备份压缩、用户权限、网络操作、查找定位等高频场景的具体命令。资源共 1 个 PDF 文…

作者头像 李华
网站建设 2026/10/13 9:26:48

免费开源:三角铁--AI广告视频生成系统

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华