简介:这份资源是西安电子科技大学操作系统课程的实验报告文档,面向正在学习操作系统原理、需要完成上机实验或复习进程与线程编程的高校学生。报告围绕Linux环境下gcc编程展开,完整记录了进程建立、线程共享进程数据、信号通信、匿名管道与命名管道通信、信号量实现进程同步等六个实验,每个实验均包含实验目的、软硬件环境、程序代码与分析、运行截图及心得体会,其中fork()、waitpid()、pthread_create()、sem_wait()等核心函数的用法讲解较为具体。资源包共1个doc文件,约377KB,内容紧凑、结构完整,可直接作为实验参考或复习提纲。目前已有1350人学习下载,适合希望对照代码理解进程协同、线程数据共享与进程间通信机制的学习者查漏补缺,也能为后续并发编程与系统级编程打下基础。
1. 进程创建:fork 之后为什么会有两个返回值
第一次看到pid_t fpid = fork()这行代码的人,多半会愣一下:一个函数调用,怎么会有两个不同的返回值,而且两个返回值还都“对”?这不是玄学,这是操作系统里最精巧的一次“身份分裂”。fork 的本质不是“调用一个函数创建一个进程”,而是“把当前进程完整复制一份,然后让两个副本从同一行代码继续往下跑”。复制发生的那一刻,调用栈、寄存器、打开的文件描述符、内存映像全部被克隆,唯一不同的是内核给两个副本各塞了一个不同的返回值:子进程拿到 0,父进程拿到子进程的 PID。
这个设计解决的是一个根本问题:父子进程从分裂点开始就是两个独立的执行流,它们需要一种方式知道自己是谁。返回 0 给子进程,是因为子进程没有“自己的 PID”这个概念——它刚出生,内核还没告诉它自己叫什么,0 是一个约定俗成的“你是孩子”的标记。父进程拿到子进程 PID,是因为父进程后续要用这个 PID 去 wait、去 kill、去追踪。如果 fork 返回同一个值,父子进程就无法在代码里分流,整个多进程编程模型就塌了。
值得搞懂这件事,是因为后面所有的进程协同——等待、信号、管道、信号量——全都建立在“fork 之后有两个执行流”这个前提上。搞不清 fork 的返回值语义,后面每一个实验都会变成“为什么我的程序输出顺序不对”的黑匣子。这份实验报告里六个实验,从进程建立到信号量同步,本质上是在一层层拆解这个分裂之后的世界如何重新建立秩序。
2. fork 与 waitpid:父子进程的协同到底靠什么保证顺序
2.1 fork 到底复制了什么,没复制什么
很多人以为 fork 是“创建一个空进程然后执行子进程代码”,这是最大的误解。fork 之后,子进程拥有父进程地址空间的副本,包括代码段、数据段、堆、栈,以及文件描述符表。实验一里子进程能直接fopen("test1file.txt","r"),不是因为子进程自己打开了文件,而是它继承了父进程的文件描述符表副本——虽然这个例子里文件是子进程自己开的,但继承机制是 fork 的默认行为。
没被复制的是进程的“身份”:PID、PPID、挂起的信号、资源使用统计。子进程的 PID 是新分配的,PPID 指向父进程。这就是为什么实验一里getpid()在父子进程中打印出不同的值,而shdata这类全局变量在 fork 之后是各自独立的副本——注意,实验二的线程共享和实验一的进程独立是两回事,后面会拆。
常见的实现思路是写时复制(Copy-On-Write):fork 并不立刻复制物理内存页,而是让父子进程共享同一批只读页,任何一方写入时才触发缺页异常、复制该页。这样 fork 的开销从“复制整个地址空间”降到“复制页表”,这也是为什么 fork 之后立刻 exec 的场景效率很高。实验一的代码没有 exec,父子进程各自跑各自的逻辑,写时复制会在子进程修改数据时逐页触发。
2.2 waitpid 的阻塞语义与僵尸进程
实验一里父进程调用waitpid(0,NULL,0),第一个参数 0 表示“等待同一个进程组里的任意子进程”,第三个参数 0 表示阻塞等待。这行代码背后是内核里的一张子进程状态表:子进程exit(1)之后并不会立刻消失,它变成一个僵尸进程(zombie),保留退出状态码等着父进程来收尸。waitpid 做的就是“收尸”这件事——读取退出状态、释放进程表项。
如果父进程不调用 waitpid 会怎样?子进程退出后变成僵尸,占用一个进程表项,直到父进程也退出才被 init 收养并清理。实验一里父进程如果不 wait,子进程的printf输出可能和父进程的输出交错,更糟的是父进程可能在子进程读完文件之前就退出了,文件读取变成孤儿操作。waitpid 在这里承担的是“顺序保证”的角色:父进程阻塞在 waitpid 上,直到子进程退出才继续,输出顺序才是确定的。
// 实验一核心逻辑:父进程等待子进程完成文件读取 pid_t fpid = fork(); if (fpid == 0) { // 子进程:读文件 FILE *fp = fopen("test1file.txt", "r"); // ... 读取并输出 exit(1); // 退出状态码 1,父进程可通过 status 拿到 } else { // 父进程:阻塞等待任意子进程 int ret = waitpid(0, NULL, 0); // ret 是子进程 PID,NULL 表示不关心退出状态 printf("子进程已经结束,ret=%d\n", ret); }参数说明:waitpid的第一个参数为 0 时等待同组任意子进程,传具体 PID 则等待指定子进程;第二个参数是int *status,传 NULL 表示不获取退出状态,传指针则内核会把退出码和终止信号写进去;第三个参数options传 0 是阻塞,传WNOHANG是非阻塞轮询。实验一用阻塞模式,是因为父进程没有别的事可做,阻塞等待最省 CPU。
提示:子进程用
exit(1)退出,状态码 1 并不表示“出错”,只是这个实验的随意选择。父进程如果传了 status 指针,需要用WIFEXITED(status)和WEXITSTATUS(status)宏来解析,直接读 status 拿到的是编码后的值,不是原始退出码。
3. 线程共享:pthread_create 为什么能改到进程的全局变量
3.1 线程与进程的地址空间差异
实验二里shdata是全局变量,线程函数creat直接shdata++,主线程sleep(3)之后打印发现值变了。这件事在进程模型里是不可能发生的——实验一里 fork 出来的子进程改全局变量,父进程看不到。线程能改到,是因为线程和进程共享同一个地址空间。
进程是资源分配的单位,线程是调度的单位。创建一个线程时,内核只分配栈空间和线程控制块(TCB),代码段、数据段、堆全部与创建它的进程共享。shdata位于数据段,所有线程看到的是同一块物理内存,shdata++改的就是那块内存。这就是为什么实验二不需要任何 IPC 机制就能“通信”——它们本来就在同一个房间里。
但这里有个容易被忽略的细节:shdata++不是原子操作。它编译出来是“读-改-写”三条指令,如果两个线程同时执行,可能都读到 0,各自加 1 写回,最终结果是 1 而不是 2。实验二只有一个线程改,主线程只是读,所以没暴露这个问题。一旦多线程并发写同一个变量,就必须上锁——这正是实验六信号量要解决的问题的线程版本。
3.2 pthread_create 的参数与 sleep 的同步作用
// 实验二:线程修改进程全局变量 int shdata = 0; // 全局变量,位于数据段,所有线程共享 void *creat(void *arg) { printf("shared data = %d\n", shdata); // 读到主线程的初始值 shdata++; // 直接修改共享内存 printf("after: shared data = %d\n", shdata); return (void *)0; } int main() { pthread_t tid; int pth = pthread_create(&tid, NULL, creat, NULL); // 参数:&tid 接收线程 ID,NULL 用默认属性, // creat 是入口函数,NULL 是传给入口函数的参数 if (pth != 0) { /* 创建失败 */ } sleep(3); // 主线程睡眠,给子线程执行时间 printf("shared data = %d\n", shdata); // 看到的是被改后的值 }pthread_create的四个参数里,第二个attr传 NULL 表示用默认线程属性(可连接、非分离、默认栈大小),第四个arg是传给线程入口函数的void *,实验二不需要传参所以是 NULL。主线程的sleep(3)在这里扮演的是“粗糙的同步”:它不保证子线程一定在 3 秒内跑完,只是大概率够用。这种写法在实验里能跑通,在生产代码里是血泪教训的源头——正确的做法是pthread_join(tid, NULL),阻塞到线程真正结束。
注意:
sleep做线程同步是不可靠的。如果子线程因为调度延迟没在 3 秒内执行完,主线程打印的就是旧值,程序看起来“时对时错”。实验环境负载低所以没翻车,换成多核高负载场景,这种写法必然出问题。
4. 信号与管道:异步通知和字节流的两套通信哲学
4.1 信号:kill 不是“杀死”,是“投递一个整数”
实验三里子进程调用kill(getppid(), SIGALRM)给父进程发信号,父进程用signal(SIGALRM, ouch)注册处理函数,ouch里把alarm_fired置 1,父进程的while(!alarm_fired)循环因此退出。这里最反直觉的是kill这个名字——它不负责杀死进程,只负责投递一个信号编号,进程收到后做什么完全由注册的处理函数决定。SIGALRM 默认行为是终止进程,但父进程用signal覆盖了默认行为,所以它变成了一个普通的通知。
信号是异步的:子进程kill之后立刻exit(0),不关心父进程什么时候处理。父进程在while循环里反复检查alarm_fired,这个变量被处理函数修改,处理函数在信号到达时由内核插入执行。这里有个经典的竞态:如果信号在while(!alarm_fired)判断之后、进入循环体之前到达,处理函数把alarm_fired置 1,循环体仍然会执行一次printf("no signal"),然后下一轮判断才退出。实验代码能跑,但输出里可能多一行 “no signal”,这不是 bug,是信号异步性的正常表现。
alarm_fired应该声明为volatile sig_atomic_t,而不是普通int。编译器可能把while(!alarm_fired)优化成“只读一次内存”,因为循环体内没有修改它的代码,编译器不知道信号处理函数会改它。volatile告诉编译器每次都从内存读,sig_atomic_t保证读写是原子的。实验代码用static int在-O0下能跑,开优化就可能死循环——这是信号编程里最常见的坑之一。
4.2 匿名管道:pipe 的读写端与阻塞语义
实验四用pipe(fd)创建匿名管道,fd[0]是读端,fd[1]是写端。父进程关掉读端、写数据,子进程关掉写端、读数据。管道的内核实现是一个环形缓冲区,写端写入的数据挂在缓冲区里,读端按字节流顺序取出。匿名管道没有名字,只能通过 fork 继承文件描述符的方式让父子进程共享——这就是为什么实验四必须先pipe再fork,顺序反了子进程就拿不到 fd。
int fd[2]; pipe(fd); // fd[0] 读端,fd[1] 写端 pid_t child = fork(); if (child == 0) { close(fd[1]); // 子进程关写端,只读 char buf[MAX_LINE]; read(fd[0], buf, sizeof(buf)); // 阻塞直到有数据 close(fd[0]); } else { close(fd[0]); // 父进程关读端,只写 write(fd[1], "hello the world", 15); close(fd[1]); // 关闭写端,子进程 read 才会返回 0 }关键在close的语义:管道读端在“所有写端都关闭”之前,read会一直阻塞等数据;写端在“所有读端都关闭”之前,write可能触发 SIGPIPE。实验四里父进程写完就close(fd[1]),子进程的read读到数据后返回,下一次read才会返回 0 表示 EOF。如果父进程不关写端,子进程的read会永远阻塞——这是管道编程里第二常见的坑。
4.3 命名管道:mkfifo 让无亲缘进程也能通信
实验五用mkfifo("./fifo", 0666)在文件系统里创建一个特殊文件,任何进程只要知道路径就能open它读写。匿名管道靠 fork 继承 fd 共享,命名管道靠文件系统路径共享,这是两者的本质区别。mkfifo的第二个参数是权限位,0666 表示所有用户可读写(实际权限受 umask 影响)。
命名管道的open有阻塞语义:以O_RDONLY打开会阻塞到有进程以O_WRONLY打开,反之亦然。实验五里父进程先open(O_RDONLY)会阻塞,子进程open(O_WRONLY)之后两边才同时返回。这个“会合”机制保证了读写双方都就位才开始传输。如果只想读不想阻塞,可以用O_RDONLY | O_NONBLOCK,但没有写端时read返回 0 而不是阻塞,语义完全不同。
提示:命名管道文件在磁盘上不占数据块,只占一个 inode,数据仍然走内核缓冲区。用
ls -l看到文件大小是 0 不代表管道坏了,这是正常现象。实验结束后记得unlink("./fifo"),否则下次mkfifo会因文件已存在而失败。
5. 信号量同步:生产者消费者为什么必须三个信号量
5.1 full、empty、mutex 各自解决什么问题
实验六是整份报告里最复杂的一个:一个生产者、两个消费者、共享一个容量 5 的环形缓冲区,用三个信号量fullid、emptyid、mutxid协调。很多人第一次看会问:为什么不能只用一个信号量?答案是这三个信号量解决的是三个不同维度的约束。
emptyid初始值 5,表示空槽位数。生产者每次生产前对emptyid做 P 操作(减 1),没有空槽就阻塞。fullid初始值 0,表示已填槽位数。消费者每次消费前对fullid做 P 操作,没有产品就阻塞。这两个信号量解决的是“缓冲区满/空”的流量控制。mutxid初始值 1,保护array、set、get这些共享数据结构的互斥访问——两个消费者可能同时读到同一个get值,都去取同一个产品,mutxid保证同一时刻只有一个进程在操作缓冲区索引。
P 操作是sem_op = -1,V 操作是sem_op = +1,sem_flg = SEM_UNDO表示进程异常退出时内核自动撤销它做的 P 操作,防止死锁。实验代码里struct sembuf P, V只初始化了sem_num = 0,因为每个信号量集只含一个信号量,编号 0 就是它本身。
5.2 共享内存 mmap 与信号量的配合
实验六用mmap(..., MAP_SHARED | MAP_ANONYMOUS, -1, 0)创建共享内存,让父子进程看到同一块array、sum、get、set。MAP_ANONYMOUS表示不关联文件,MAP_SHARED表示 fork 之后父子进程共享同一物理页。如果没有MAP_SHARED,fork 会复制一份,父子各改各的,信号量同步就失去意义了。
// 生产者核心逻辑:先占空槽,再占互斥锁,写数据,释放两者 semop(emptyid, &P, 1); // emptyid - 1,没有空槽则阻塞 semop(mutxid, &P, 1); // 进入临界区 array[(*set) % MAXSEM] = i + 1; // 环形写入 (*set)++; semop(mutxid, &V, 1); // 退出临界区 semop(fullid, &V, 1); // fullid + 1,通知消费者有产品顺序很关键:必须先 Pemptyid再 Pmutxid。如果反过来,生产者先拿到互斥锁再等空槽,而消费者此时正等着互斥锁来取产品释放空槽,两边互相等待,死锁。实验心得里提到“消费者取出生产者还未生产的产品”,大概率就是某个 P/V 顺序写反或者漏了一个 V,导致fullid被提前增加。
消费者侧的顺序是 Pfullid→ Pmutxid→ 读数据 → Vmutxid→ Vemptyid。两个消费者进程共享get指针,mutxid保证*get的读和自增是原子的,否则两个消费者可能取到同一个产品、sum加两次同一个值。
5.3 环形缓冲区的索引取模与终止条件
array[(*set) % MAXSEM]和array[(*get) % MAXSEM]是环形缓冲区的标准写法:set和get单调递增,取模映射到 0~4 的槽位。生产者生产 100 个产品,set从 0 涨到 100,槽位循环使用。消费者判断*get == 100时退出,表示 100 个产品全部取完。这里有个细节:两个消费者都可能读到*get == 100然后break,但break之前已经做了 Pfullid和 Pmutxid,如果没有对应的 V 操作,信号量计数就泄漏了。实验代码在break前没有释放mutxid,严格说是个资源泄漏,只是进程马上exit所以没暴露。
6. 避坑与排查:六个实验里最容易翻车的几处
6.1 子进程输出和父进程交错,顺序完全乱
现象:实验一运行后,父进程的“我要等待子进程”和子进程的文件内容混在一起,甚至父进程的“子进程已经结束”先于子进程的输出出现。
原因:printf的输出先写到 stdio 缓冲区,fork 之后父子进程各有一份缓冲区副本。如果父进程在 fork 之前有过printf且没换行,缓冲区里的内容会被子进程继承并重复输出。另外父子进程谁先被调度是不确定的,没有 wait 的情况下输出顺序完全随机。
解决:fork 之前fflush(stdout)清空缓冲区;父进程必须waitpid阻塞等待,不要用sleep猜时间。实验一的代码在 fork 前没有 printf,所以没触发缓冲区重复,但输出交错仍然可能发生,因为子进程的puts和父进程的printf竞争终端。
6.2 信号处理函数改了标志位,主循环却死等
现象:实验三父进程的while(!alarm_fired)偶尔卡死,信号明明发了,循环不退出。
原因:alarm_fired声明为普通int,编译器在优化时把它缓存到寄存器,循环体里没有修改它的代码,编译器认为值不会变,于是生成“只读一次”的代码。信号处理函数改的是内存,寄存器里的副本不变。
解决:声明为volatile sig_atomic_t alarm_fired = 0;。volatile强制每次从内存读,sig_atomic_t保证单次读写原子。另外signal()在不同 Unix 上的语义有差异,严谨的做法用sigaction()注册处理函数,行为更可预测。
6.3 管道 read 永远阻塞,程序挂住不返回
现象:实验四子进程read(fd[0], ...)一直不返回,父进程已经写完了数据。
原因:父进程写完没有close(fd[1]),或者子进程没有关掉自己的fd[1]副本。管道的read只有在“所有写端关闭”且缓冲区为空时才返回 0,只要还有一个写端开着,read就阻塞等更多数据。fork 之后子进程继承了父进程的fd[1],如果子进程不close(fd[1]),它自己就持有一个写端,read永远等不到 EOF。
解决:每个进程只保留自己需要的那一端,另一端立刻close。父进程关读端、子进程关写端,这是管道编程的铁律。命名管道同理,open之后不用的方向要关掉。
6.4 信号量 P/V 顺序写反,生产者消费者互相卡死
现象:实验六运行后没有任何输出,或者生产几个产品后全部进程挂起。
原因:生产者先 Pmutxid再 Pemptyid,消费者先 Pmutxid再 Pfullid。当缓冲区满时,生产者持有mutxid等emptyid,消费者等mutxid才能取产品释放emptyid,循环等待形成死锁。另一种情况是漏掉某个 V 操作,信号量计数只减不增,最终所有进程阻塞在 P 上。
解决:严格遵循“资源信号量在前、互斥信号量在后”的加锁顺序。生产者的顺序是 P empty → P mutex → 写 → V mutex → V full,消费者的顺序是 P full → P mutex → 读 → V mutex → V empty。用ipcs -s查看信号量当前值,如果fullid或emptyid变成负数(理论上不会,但 semval 可能异常),说明 P/V 配对出了问题。程序异常退出后残留的信号量用ipcrm -s <semid>清理,否则下次semget可能拿到旧的未初始化信号量。
6.5 共享内存没加 MAP_SHARED,父子各改各的
现象:实验六里生产者明明写了array,消费者读到的全是 0。
原因:mmap的 flags 只写了MAP_ANONYMOUS没写MAP_SHARED,fork 之后内核给子进程复制了一份私有映射,父子进程的array指向不同物理页。信号量同步的是“逻辑上的共享”,但数据根本没共享。
解决:mmap的 flags 必须是MAP_SHARED | MAP_ANONYMOUS。MAP_SHARED保证 fork 后映射同一物理页,MAP_ANONYMOUS表示不落盘。验证方法是在父子进程里分别打印array的地址,地址相同不代表共享(虚拟地址可以相同),要看写入后对方能否读到。
7. 从这六个实验看进程同步的设计取舍
这六个实验串起来,其实是一条从“分裂”到“重建秩序”的完整链路:fork 把进程一分为二,waitpid 用阻塞等待重建父子顺序,线程用共享地址空间省掉 IPC,信号用异步通知传递事件,管道用内核缓冲区传字节流,信号量用计数和互斥保护共享资源。每一层都在解决上一层留下的问题,也都在引入新的复杂度。
最值得琢磨的取舍是“同步的粒度”。实验一用 waitpid 做进程级同步,粒度粗但简单;实验二用 sleep 做线程同步,粒度更粗且不可靠,正确做法是 pthread_join;实验六用三个信号量做细粒度同步,灵活但容易死锁。粒度越细,并发度越高,出错概率也越高。生产者消费者用三个信号量而不是一把大锁,是为了让生产者和消费者在缓冲区非满非空时能并行操作,但代价是 P/V 顺序必须严格设计,错一步就是死锁。
另一个取舍是“共享的方式”。匿名管道靠 fork 继承 fd,命名管道靠文件系统路径,共享内存靠 mmap 的 MAP_SHARED,信号量靠内核维护的计数。选择哪种机制,取决于通信双方有没有亲缘关系、数据量大小、是否需要跨机器。实验里父子进程用匿名管道最轻量,跨进程用命名管道,大量数据用共享内存加信号量同步——这个组合在实际系统里也很常见。
我自己的习惯是:每次写多进程或多线程代码,先在纸上画出所有共享资源和同步原语的依赖关系,标出每个 P 操作的等待条件,确认不存在循环等待再动手。实验六那次“消费者取出未生产的产品”,后来复盘就是 P/V 配对漏了一个 V,信号量计数和实际缓冲区状态脱节。从那以后我每次碰信号量,都强制用ipcs看一眼初始值,跑完用ipcrm清干净,再小的 demo 也不省这一步。希望帮到你。
本文还有配套的精品资源,点击获取