HNU2026-操作系统-笔记 2-3 Interlude: Process API
Interlude: Process API课件[[第2-3次课-05. Interlude_process_api.pptx]]教材OSTEP Chapter 5在理解“进程是操作系统提供的抽象”之后下一步要回答的问题就是程序员如何实际创建、控制并组合这些进程UNIX 的经典答案是三组核心接口fork()、wait()和exec()。它们组合在一起构成了现代进程控制模型的基础也解释了 Shell 为什么能启动程序、等待程序结束、做重定向以及搭建管道。本章主线fork()先复制出一个新进程wait()父进程需要时可以等待子进程结束exec()让某个进程改为执行另一个程序。这三者配合起来就形成了 UNIX 进程 API 的核心工作流。1.fork()创建一个新进程1.1fork()做了什么fork()是 UNIX 中最经典的进程创建系统调用。它的效果不是“启动一个全新的陌生程序”而是让当前进程复制出一个新的进程。这个新进程称为子进程Child Process原来的进程称为父进程Parent Process。从课件和教材的角度看子进程会得到父进程运行现场的一份副本包括地址空间的副本寄存器状态的副本程序计数器附近的执行位置打开的文件描述符等进程资源的继承关系。因此fork()返回后父子进程看起来都像是“从同一行代码继续往下执行”。1.2 课件示例p1.c课件首先给出一个最基础的示例程序p1.c其核心结构是#includestdio.h#includestdlib.h#includeunistd.hintmain(intargc,char*argv[]){printf(hello world (pid:%d)\n,(int)getpid());intrcfork();if(rc0){fprintf(stderr,fork failed\n);exit(1);}elseif(rc0){printf(hello, I am child (pid:%d)\n,(int)getpid());}else{printf(hello, I am parent of %d (pid:%d)\n,rc,(int)getpid());}return0;}这个例子最关键的观察点是课件特别提示的请注意rc的值。1.3fork()的返回值如何区分父子进程fork()最巧妙的地方在于同一个调用会在两个进程里分别返回。但它返回的值不同返回值 0创建失败返回值 0当前执行流位于子进程中返回值 0当前执行流位于父进程中且返回值就是子进程的 PID。所以在p1.c里子进程会进入rc 0分支父进程会进入rc 0分支父进程打印出来的rc实际上就是它刚创建出的那个子进程的进程号。一句话理解fork()不是“从外部告诉你谁是谁”而是通过不同的返回值让父子进程自己在同一份代码中分流执行。1.4 为什么fork()的输出顺序不确定课件第 3 页展示了p1.c的两种运行结果一种是父进程先打印另一种是子进程先打印。这表明调度结果本来就不确定。因为fork()返回后父进程已经是一个可运行进程子进程也是一个可运行进程接下来谁先获得 CPU由操作系统调度器决定。因此这里体现出的核心概念是并发执行带来的顺序不确定性nondeterminism。只要没有额外同步机制父子进程的打印顺序就不能被程序员强行假定。1.5fork()相关思考题的本质课件第 46 页都是围绕fork()的思考题。虽然具体代码细节不同但它们本质上都在考以下三点变量会不会被复制会。子进程得到的是父进程地址空间的一份副本因此fork()之后父子进程各自修改变量互不影响。某一行代码会被执行几次要看该行位于fork()之前还是之后。在fork()之前通常只执行一次在fork()之后父子进程都可能执行因此往往会执行两次。某个输出由谁打印打印什么值要先判断当前是在父进程还是子进程再结合fork()的返回值与各自 PID 推断。1.6 连续两次fork()会产生多少个进程课件第 7 页给出如下代码#includestdio.h#includeunistd.hintmain(){fork();fork();return0;}这个问题最容易出错的地方是把“调用了两次fork()”误认为“只会多出两个子进程”。实际上第二次fork()是由第一次产生出来的所有进程共同执行的。推导过程初始只有 1 个父进程第 1 次fork()后变成 2 个进程这 2 个进程都会继续执行第 2 次fork()每个进程再复制出一个子进程因此总数变成 4 个。所以答案是包括最初父进程在内共有 4 个进程。记忆规律如果一段代码中有连续n次、且每个进程都会走到的fork()最终进程数通常是2^n。2.wait()父进程等待子进程结束2.1 为什么需要wait()前面的p1.c告诉我们仅靠fork()父子进程执行顺序是不确定的。但很多时候我们希望父进程先别往下走而是等子进程执行完毕再继续。这时就要用到wait()。wait()的语义是父进程阻塞自己直到某个子进程结束。2.2 课件示例p2.c课件在p2.c中把父进程分支改成了先调用wait(NULL)#includestdio.h#includestdlib.h#includeunistd.h#includesys/wait.hintmain(intargc,char*argv[]){printf(hello world (pid:%d)\n,(int)getpid());intrcfork();if(rc0){fprintf(stderr,fork failed\n);exit(1);}elseif(rc0){printf(hello, I am child (pid:%d)\n,(int)getpid());}else{intwcwait(NULL);printf(hello, I am parent of %d (wc:%d) (pid:%d)\n,rc,wc,(int)getpid());}return0;}2.3wait()带来了什么变化和p1.c相比最大的变化是输出顺序变得确定了。原因是父进程调用wait(NULL)后会阻塞在子进程退出之前父进程不会继续往下执行打印语句因此子进程输出一定先于父进程输出。这说明wait()的本质作用是在父子进程之间建立同步关系。2.4wait()的返回值表示什么课件输出中父进程打印了wc的值。这个值等于已经结束的那个子进程的 PID。因此rc是fork()在父进程中返回的子进程 PIDwc是wait()返回的已结束子进程 PID。在只有一个子进程的简单例子里二者通常相同。3.exec()让当前进程去执行另一个程序3.1 为什么仅有fork()不够如果系统只有fork()那么一个进程最多只能复制出“和自己差不多的另一个自己”。但操作系统真正需要的是先创建一个新进程再让它去运行一个完全不同的程序。这就是exec()系列调用的用途。3.2exec()的核心语义exec()是用一个新程序替换当前进程原有的地址空间和执行内容。也就是说进程的 PID 通常不变但它执行的代码、数据、栈、堆等都会被新程序替换成功之后它不再执行旧程序而改为执行新程序。3.3 课件示例p3.c课件通过p3.c展示了这一点。子进程先构造参数数组然后调用execvp()去执行wc p3.c。核心代码逻辑是char*myargs[3];myargs[0]strdup(wc);myargs[1]strdup(p3.c);myargs[2]NULL;execvp(myargs[0],myargs);printf(this shouldnt print out);输出中出现的是wc对p3.c的统计结果例如29 107 1030 p3.c这说明子进程已经不再继续执行原程序后续的“普通逻辑”而是转而执行了wc程序。3.4 为什么this shouldnt print out不会执行因为一旦execvp()成功当前进程的代码段、数据段、栈、堆都会被新程序替换原程序中execvp()之后的那条printf()所在代码已经不再是当前进程要执行的程序内容。因此exec()成功后通常不会返回。只有在执行失败时它才会返回并让调用者继续处理错误情况。重点区分fork()创建一个新进程exec()让某个已有进程改去执行另一个程序fork() exec()先“多出来一个进程”再让这个新进程“改头换面”。4.fork()wait()exec()如何配合工作把前面的内容合起来就得到了 UNIX 里最经典的执行流程父进程先调用fork()子进程在自己的执行分支里调用exec()去运行目标程序父进程根据需要调用wait()等待子进程结束。Shell 启动外部命令本质上就是这么做的。5. 为什么fork()和exec()要分成两个调用这是本章最重要的设计思想之一。很多初学者会问既然最终目的是“运行另一个程序”为什么不直接提供一个一步到位的调用而要拆成先fork()复制出子进程再exec()把它替换成目标程序答案是正因为这两步分开Shell 才能在exec()之前对子进程做定制化设置。而重定向就是最典型的例子。6. 输出重定向示例p4.c这一部分解释了为什么 Shell 不需要修改wc程序本身就能把它的输出从屏幕改到文件里。6.1 先看p4.c到底做了什么课件中的子进程逻辑可以概括成 3 步先调用close(STDOUT_FILENO)关闭当前的标准输出再调用open(./p4.output, ...)打开目标文件最后调用execvp(wc, ...)让子进程去执行wc程序。对应的关键代码是close(STDOUT_FILENO);open(./p4.output,O_CREAT|O_WRONLY|O_TRUNC,S_IRWXU);execvp(myargs[0],myargs);运行现象是在终端里执行程序时屏幕上看不到wc的统计输出但查看文件p4.output时会发现输出内容已经写进去了。这说明wc的输出路径被改掉了。6.2 关键前提进程默认自带 3 个标准文件描述符在一个普通进程刚启动时通常已经默认打开了 3 个文件描述符0标准输入stdin1标准输出stdout2标准错误stderr其中最关键的是当程序调用printf()向标准输出打印内容时本质上就是把数据写到文件描述符 1平时之所以会显示在屏幕上是因为此时文件描述符1恰好连着终端。所以“输出到屏幕”这件事的本质并不是wc知道屏幕在哪而是它只是老老实实地往stdout / fd 1写数据。6.3 第一步为什么先close(STDOUT_FILENO)STDOUT_FILENO就是标准输出对应的文件描述符也就是1。执行close(STDOUT_FILENO);相当于告诉操作系统把当前进程的“标准输出这条通道”先关掉。执行完这一句之后子进程里的文件描述符使用情况就变成了0还在1空出来了2还在这一步非常关键因为它专门给后面的open()腾出了编号1这个位置。6.4 第二步为什么open()打开的文件会自动占据1UNIX 有一个很重要的规则每次分配新的文件描述符时优先使用当前最小的可用编号。此时0已经被占用1刚刚被关闭处于空闲状态2仍然被占用。所以当子进程执行open(./p4.output,O_CREAT|O_WRONLY|O_TRUNC,S_IRWXU);操作系统会发现当前最小可用编号是1于是就把新打开文件p4.output绑定到文件描述符1上。于是子进程内部的映射关系悄悄变成了0终端输入1p4.output2终端错误输出这时文件描述符 1 已经不再指向屏幕而是指向文件。6.5 第三步execvp()为什么会“继承”这个重定向结果接下来执行的是execvp(myargs[0],myargs);这一步会把当前子进程的代码和数据替换成新的程序wc但它不会凭空重新发明一套新的标准输入 / 输出 / 错误环境。也就是说子进程在execvp()之前已经调整好的文件描述符布局会被新程序直接继承。因此当wc开始运行时它看到的仍然是标准输出 文件描述符1而文件描述符1p4.output所以wc并不知道自己“被重定向了”它只是像平常一样往标准输出写结果这些数据自然就进了文件。6.6 把整个过程串起来看一遍可以把整个重定向过程按时间顺序理解为父进程先fork()出一个子进程子进程先把自己的标准输出1关闭子进程再open()一个文件于是这个文件拿到了编号1子进程随后执行execvp()加载wc程序wc运行时依旧只是往标准输出写但此时标准输出已经等于文件p4.output所以输出不会出现在终端而是进入文件。如果用一句话概括就是重定向是“在新程序启动前先把它眼中的标准输出偷偷换成文件”。6.7 为什么这体现了fork()和exec()分离设计的优雅现在就能看出 UNIX 设计的高明之处fork()让 Shell 先得到一个可以自由摆弄的子进程在真正运行目标程序之前Shell 可以先修改这个子进程的 I/O 环境然后再用execvp()把它替换成目标程序目标程序无需感知这一切就能自动享受重定向效果。这意味着Shell 不需要修改wc的源码任何“正常向标准输出写数据”的程序都可以被同样方式重定向管道、输入重定向等机制本质上也都是在操作文件描述符映射关系。7. 本章总结从本章可以看到UNIX 进程 API 的力量不在于某一个调用单独有多复杂而在于它们之间的组合方式极其灵活fork()提供了创建并复制执行现场的能力wait()提供了父子进程之间的同步能力exec()提供了“在已有进程中装入新程序”的能力三者组合后Shell 就能实现命令执行、同步控制、输出重定向进一步还可以扩展到管道等机制。随堂复习自测1.fork()在父进程和子进程中的返回值分别是什么父进程中返回子进程的 PID子进程中返回0失败时返回负值。2. 为什么p1.c中父子进程的输出顺序不固定因为fork()之后父子进程都处于可运行状态谁先获得 CPU 取决于调度器因此输出顺序具有不确定性。3.wait()的作用是什么让父进程阻塞直到某个子进程结束从而建立父子进程之间的同步关系。4.exec()会不会创建新进程不会。exec()不负责创建新进程它是在当前进程内部装入并执行另一个程序。5. 为什么exec()成功后后面的printf(this shouldnt print out)不会执行因为当前进程的地址空间已经被新程序替换原程序后续代码不再属于当前执行内容。6. 为什么p4.c中wc的输出会进入文件而不是终端因为子进程先关闭了标准输出再打开文件使得文件占据了文件描述符1于是程序对标准输出的写入就被重定向到了文件。