一文吃透进程API:fork、exec、wait与迷你Shell实战
《操作系统导论》这本书我前后翻过三遍。第一遍是按章节从头读到尾第二遍是配合实验重读“插叙”部分第三遍纯粹是给朋友答疑时当参考手册用。每次读都有新收获但给我帮助最大的一段反而最容易被初学者跳过就是这篇“插叙进程 API”。先给没接触过的朋友补个背景。这本《操作系统导论》把操作系统拆成三大块虚拟化、并发、持久性。CPU 虚拟化部分的核心就是进程而进程 API 负责回答三个基础问题进程怎么创建进程怎么替换成别的程序父进程怎么知道子进程干完了落到底层就是 fork、exec、wait 这三个系统调用。为什么作者要专门安排一个“插叙”章节来讲这么一小撮 API因为后续所有 CPU 虚拟化的机制像受限直接执行、上下文切换、调度算法都默认你已经会用这三个调用。我在带新人时感受特别深API 没弄明白就去啃调度基本上两小时后又回来补课反过来把这一章的小代码写熟后面看机制就会有一种“哦原来如此”的通透感。1. 为什么《操作系统导论》要专门留一章讲“进程 API”1.1 这本书的“插叙”到底插在哪先说“插叙”这个设计。这本书的正文章节都在讲机制和策略比如讲 CPU 虚拟化的时候会描述操作系统如何用时间共享如何保存和恢复寄存器。但作者很清楚一件事光看机制你根本写不出一个能创建进程的程序。你必须在一块空地上先把工具拿到手这块空地就是“插叙”章节。“插叙”在英文里是 Interlude直译是幕间休息但内容并不是休息。它把读者从玄乎的概念拉回代码这一章不讨论调度算法也不讨论上下文切换只做一件小事让你用一个真实的 C 程序把进程生命周期从头到尾操控一遍。我觉得这是整本书最“解渴”的安排。很多操作系统教材一上来就讲进程状态图讲完你还是不会调用这里直接反过来先让你写出来再回去看机制。1.2 学这套 API 之前先想清楚三个问题我每次带人学这章都会先问三个问题想清楚再上代码第一个问题调用创建进程之后代码到底从哪一行继续执行很多人以为子进程会从头重新跑一遍其实子进程从 fork() 返回的那一行之后开始。第二个问题谁能把子进程换成一个全新程序这不是“感觉上的替换”而是整个地址空间被覆盖。第三个问题子进程退出后谁负责知道它退出了如果没人处理内核要留着一份“尸检报告”等谁来领。这三个问题正好对应 fork、exec、wait。把三个问题想清楚整章就没有盲点。看不明白不要紧下面代码过一遍就懂。2. 从代码层面拆透 fork、wait、exec2.1 fork系统里的“复印机”fork 的原意是“分叉”。放在操作系统的语境里它把当前进程“复印”成两份。调用一次返回两次第一次是在父进程里返回子进程的 PID第二次是在子进程里返回 0。这不是黑魔法本质是内核把当前进程的地址空间、寄存器上下文、文件描述符表复制了一份然后让两个进程各自继续跑唯一的区别就是 fork 的返回值不一样。#include stdio.h #include unistd.h #include sys/types.h int main(void) { printf(开始执行当前 PID%d\n, getpid()); pid_t ret fork(); if (ret 0) { perror(fork); return 1; } if (ret 0) { printf(我是子进程PID%d父进程 PID%dfork 返回 %d\n, getpid(), getppid(), ret); } else { printf(我是父进程PID%dfork 返回的是子进程 PID%d\n, getpid(), ret); } return 0; }试着编译运行这段代码你会看到两行“我是……”都打出来了顺序不固定因为父子进程谁先被调度不是由你决定的是内核调度器决定的。这里面有一个非常关键的新手误区子进程不是从 main 开头重新执行而是从当前这一行 fork() 返回之后继续走。你可以把 fork 想象成一条岔路口路在 fork 之前是共享的fork 之后才开始分道扬镳。还有个隐藏细节很容易被忽略fork 复制的是调用时刻的进程状态。你之前定义的局部变量、全局变量、甚至缓冲区里的内容都会被拷贝一份过去。之后父子进程各自修改自己的变量互相看不到对方的改动。这不是 bug机制上就是如此。如果你想在两个进程之间传数据就得靠管道、信号、共享内存这些 IPC 手段不能指望 fork 之后变量还能“共享”。真正的 Linux 实现里fork 其实是写时复制Copy-on-Write的创建子进程时并不会把全部物理内存重新拷贝一遍而是让父子先共享同一份物理页只有某一方真正要写的时候才触发缺页把那一页复制一份。这样 fork 一个巨大的进程也很快。不过逻辑上你还是可以按“完整复制”来理解。2.2 wait让残局有人收拾fork 生出来的子进程迟早要退出。子进程退出之后内核会保留一份“退出状态”等待父进程来收。这份收尾工作就是 wait 的活。#include stdio.h #include sys/wait.h #include unistd.h int main(void) { pid_t pid fork(); if (pid 0) { perror(fork); return 1; } if (pid 0) { printf(子进程开始干活PID%d\n, getpid()); sleep(2); printf(子进程干完准备退出\n); return 42; } int status; pid_t waited wait(status); printf(父进程等到了子进程 %d 退出\n, waited); if (WIFEXITED(status)) { printf(子进程正常退出退出码是 %d\n, WEXITSTATUS(status)); } return 0; }这里有几个点要讲透。wait 是阻塞型的父进程调用 wait 之后只要还没有任何子进程退出它就一直睡在那里直到某个子进程终止。返回时wait 会把子进程的 PID 和退出状态信息带回来。所谓退出状态不是简单的小整数而是被压缩进一个 int 里的复合信息。所以你必须用宏去拆开看WIFEXITED 判断是否正常退出WEXITSTATUS 取退出码WIFSIGNALED 判断是不是被信号杀掉WTERMSIG 取那个信号的编号。我见过不少人图省事写 wait(NULL)确实能收尸但排查问题的时候就抓瞎了。比如你启动的应用程序崩溃了你想区分它是自己退出还是被 SIGSEGV 打死的必须解析 status。这个习惯越早养成越好。和 wait 相对的是 waitpid它可以指定等待哪一个子进程还能配合 WNOHANG 做非阻塞轮询。在多子进程场景下wait 只能等到“任意一个”结束waitpid 则可以精确控制比如只等某个关键 worker 退出。后面做多进程服务时你会频繁用到 waitpid。2.3 exec把“复印件”换成新程序fork 只解决了“再有新进程”的问题它生成的还是和爸爸一模一样的进程。要跑 ls、python、node必须用 exec。exec 做的事情非常激进把当前进程的代码段、数据段、堆、栈全部替换成新程序的内容。这个操作不是创建新进程而是让当前进程“变身”。所以调用 exec 成功后不会返回因为它原来的代码已经被覆盖了只有失败时 exec 才会带着 -1 回来同时设置 errno。exec 有一整个家族初学容易被弄晕。其实按三个维度记就行函数名程序参数怎么传是否用 PATH 找程序环境变量execl逐个列出list不用继承当前execv参数数组vector不用继承当前execlp逐个列出用 PATH 搜索继承当前execvp参数数组用 PATH 搜索继承当前execle逐个列出不用自己指定execve参数数组不用自己指定实际写代码时execvp 用得最多因为它会自动去 PATH 目录里找人参数又是数组形式正好适合把命令行字符串拆出来的场景。看个例子#include stdio.h #include unistd.h #include sys/wait.h int main(void) { pid_t pid fork(); if (pid 0) { perror(fork); return 1; } if (pid 0) { execlp(ls, ls, -l, NULL); perror(execlp); return 127; } wait(NULL); printf(父进程ls 已经执行完了\n); return 0; }如果没有 fork直接在父进程 exec 一个 ls那么整个进程本身会变成 ls父进程后续的 printf 永远不会执行。这也是“插叙”章节最让人印象深刻的地方exec 是一条不归路。2.4 为什么 fork 和 exec 必须分开设计很多人第一次看到 fork exec 的组合会觉得别扭既然 exec 能把进程变成别的程序为什么不直接提供一个“创建并运行新程序”的 APIWindows 的 CreateProcess 就是这么干的。POSIX 选择分开不是因为没想到而是因为分开可以产生更强的组合能力。关键就在 fork 返回之后、exec 调用之前这一小段缝隙里子进程可以做很多“手脚”。比如给子进程的 stdin/stdout 重定向到某个文件比如把一个管道的一端塞到子进程的文件描述符表里比如修改环境变量然后再 exec 新程序。你会发现Shell 的管道和重定向本质就是在利用这个缝隙。如果 fork 和 exec 合在一起这些自由度就没了系统调用只能被迫设计成“参数超多的全家桶”。这个设计思想我认为是《操作系统导论》这节课最想传递的给程序员提供简单、可组合的原语而不是一个包罗万象的巨型函数。你拿到 fork、exec、wait 三块积木就能组装出 Shell、守护进程、管道重定向甚至各类容器技术。这一点在后面学管道和文件系统时还会反复体会到。3. 实操把三个 API 串成迷你 Shell3.1 第一次让它跑起来理论讲完必须动手。最经典的练习就是把 fork、exec、wait 组合起来写一个能执行外部命令的最小 Shell。下面这段代码是我压到最简但仍然能跑的版本去掉一切错误处理之外的多余功能方便你逐行吃透#include stdio.h #include string.h #include stdlib.h #include unistd.h #include sys/wait.h #define BUF_SIZE 128 #define MAX_ARGS 16 int main(void) { char line[BUF_SIZE]; while (1) { printf(mysh$ ); fflush(stdout); if (!fgets(line, BUF_SIZE, stdin)) { break; } line[strcspn(line, \n)] \0; if (strcmp(line, exit) 0) { break; } if (strlen(line) 0) { continue; } char *args[MAX_ARGS]; int n 0; char *token strtok(line, ); while (token ! NULL n MAX_ARGS - 1) { args[n] token; token strtok(NULL, ); } args[n] NULL; pid_t pid fork(); if (pid 0) { perror(fork); continue; } if (pid 0) { execvp(args[0], args); perror(args[0]); exit(127); } wait(NULL); } return 0; }这段代码有几个环节值得说明。首先是 fflush(stdout)提示符“mysh$ ”用 printf 打印后马上要等用户输入但标准输出是行缓冲不强制刷新的话提示符可能不显示。其次是 strtok 解析空格把用户输入拆成参数数组注意最后一位必须是 NULL因为 execvp 需要以 NULL 结尾的 argv。然后是 execvp它自己会去 PATH 里找命令比如你输入 ls它在 /bin、/usr/bin 这些目录里找到 /bin/ls 再执行。如果命令不存在execvp 返回 -1子进程就在这里打一条报错然后 exit(127)而不会继续往下执行父进程残留的代码。最后是父进程的 wait(NULL)确保父进程先等子进程跑完再回到循环顶部打印下一个提示符。你可以编译运行这个程序输入 ls、date、echo hello 试试这些命令都能正常执行而且行为和真实终端很像。一个能用的 Shell最核心的骨架其实就是这三行fork 创建子进程子进程 exec父进程 wait。Shell 后续的复杂功能都是在往这三行之间加料。3.2 往里加重定向和管道体会“组合”的意义当你把这个最简版跑通后强烈建议加一个功能输出重定向比如输入 ls out.txt。实现思路并不复杂关键还是在 fork 之后、exec 之前的那段缝隙里做文章if (pid 0) { int fd open(out.txt, O_WRONLY | O_CREAT | O_TRUNC, 0644); dup2(fd, STDOUT_FILENO); close(fd); execvp(args[0], args); perror(args[0]); exit(127); }open 打开文件dup2 把文件描述符复制到 1 号标准输出之后 exec 加载的新程序往标准输出写东西时实际会写进 out.txt。这一步为什么要放在子进程里做因为父进程还要继续用终端输出提示符如果改动父进程的 1 号描述符Shell 自己也会受污染。fork 天然地把这个副作用隔离在子进程里了。这就是 fork/exec 分离设计带来的优势你可以在 exec 之前自由地安排子进程的“环境”。管道稍微复杂一点但你掌握 pipe 系统调用后会发现它和重定向是同一种思路pipe 返回一对文件描述符0 号那头是读端1 号那头是写端父进程把写端塞给前一个命令的子进程把读端塞给后一个命令的子进程中间再用 wait 收拾残局。这里就不把完整代码贴出来了建议你自己写一遍。写完之后你再看《操作系统导论》后面关于管道的插叙章节会觉得亲切无比因为原理你已经亲手碰过了。4. 踩坑现场进程 API 的常见问题与排查技巧4.1 “printf 为什么打印了两遍”——缓冲区复制这是新手遇到最多、也最诡异的问题。代码如下printf(hello\n); fork();如果这段代码写在终端里效果正常但一旦你把输出重定向到文件这个字可能出现两遍。原因在于 stdout 的缓冲模式在终端里是行缓冲遇到 \n 会立即刷新所以 fork 前缓冲区里是空的但重定向到文件后就变成全缓冲内容攒着不写fork 时缓冲区连同数据一起被复制给了子进程。等缓冲区最终刷新时父进程和子进程各打一份你就看到了重复内容。解决办法也很简单fork 之前先 fflush(NULL)把所有缓冲都冲掉或者在 fork 之后的两个分支里都用 exit 收尾而不是 main 返回。这个坑非常适合用来验证“fork 复制用户态状态”的认知。说实话我当年就是在被这个坑折磨了一晚上之后才真正理解了 fork 的复制语义。4.2 僵尸进程与孤儿进程谁在替你擦屁股子进程退出后父进程如果没有 wait内核必须保留子进程的退出状态和部分内核数据结构等着父进程来取。这个状态下的进程叫僵尸进程。僵尸进程不能再被调度只是占着一个 PID。如果父进程一直不来收尸僵尸还会堆积最后可能把 PID 耗尽导致系统无法创建新进程。与僵尸对应的是孤儿进程父进程先退出子进程还没退出。这种情况下子进程会被自动交给一号进程收养也就是 init 或者现代的 systemd由它负责收尸。这个机制保证了任何进程最终都能被回收。在写后台服务时如果你 fork 出一堆子进程一定要有一个稳定的父进程循环调用 waitpid否则服务跑久了系统里的僵尸会越来越多。排查时用ps -el看到 Z 状态八成就是父进程没处理好。4.3 exec 之后代码怎么“消失”了有个用户写的程序这样pid fork(); if (pid 0) { execlp(ls, ls, NULL); } printf(这段代码到底会不会执行\n);如果 exec 成功printf 不会执行因为进程体内已经是 ls 的代码。但如果 exec 失败这个分支会直接掉下去printf 照样执行。很多新手误以为 exec 之后总会继续跑原来进程的代码于是把后面一大坨业务逻辑写在 exec 之后结果程序行为完全乱套。正确的姿势是exec 调用后立即检查失败情况。如果 exec 失败打印错误并 exit绝不能让控制流直接往下漏。if (pid 0) { execlp(ls, ls, NULL); perror(ls); exit(127); }这里 exit(127) 是模仿 Shell 找不到命令时的返回码便于父进程从 status 里提取信息。4.4 文件描述符继承一个隐藏很深的行为fork 会复制父进程的文件描述符表因此子进程会继承父进程所有打开的文件。这里的关键细节是子进程复制出来的 fd 和父进程的 fd 指向同一个内核打开文件描述也就是说它们共享文件读写偏移量。如果父进程和多个子进程同时往同一个文件写偏移量会互相赶着走不会从各自的位置写。这个特性既是坑也是工具。说它是坑是因为如果你某个监听 socket、日志文件 fd 忘了处理fork 出来的子进程也能访问它很可能造成日志重复写或者端口抢占。说它是工具是因为重定向和管道都建筑在这个行为上父进程创建好的文件描述符通过 fork 传给子进程子进程在 exec 时这些描述符默认保持打开新程序就可以直接用。我自己的习惯是子进程里打算 exec 之前凡是不需要的 fd 一律 close 掉只留下 0、1、2 和必要的通信管道。不然上了生产环境调试文件描述符泄漏会非常痛苦。5. 从教学代码到真实系统进程 API 的影响范围5.1 你会看到它的地方比你想象的多很多人学《操作系统导论》的时候觉得 fork、exec、wait 只是刘课作业真实系统里好像用不到。其实到处都在用。传统 Web 服务器像早期 Apache、Nginx 的 master-worker 模型里master 进程会 fork 出一组 worker 进程各自 accept 连接再通过 waitpid 监控 worker 状态挂了就重启一个。容器运行时、CI 执行器里本质上也是 fork 出来一个进程再 exec 进去跑新的命令。日常写代码时即使你用的是 Python 的 subprocess 模块它的底层实现也是 fork exec 那一套。区别只是封装层级更高错误处理更完善。理解了原始 API再去看那些封装很多行为就变得合理了。比如 subprocess 里设置 preexec_fn、start_new_session 这些参数其实就是在 fork 和 exec 之间的缝隙里做操作。还有一类场景是守护进程。传统 daemon 化要做 double fork一次 fork 之后用 setsid 创建新会话再来一次 fork 让你不能重新获取控制终端。你看光一个守护进程就用了好几次 fork。所以说这一章的知识辐射范围极广。5.2 给入门者的一份练习清单如果看完这篇想自己动手巩固我推荐按这个顺序练先写一个 fork 程序验证父子进程的 PID、PPID 和变量修改互不可见。再写一个 wait 程序让子进程返回几种不同的退出码用 WIFEXITED 和 WEXITSTATUS 解析。然后写一个 exec 程序调 execvp 执行 date看 exec 成功后不再返回。把这三者组合成迷你 Shell先跑通 ls再加重定向再加管道。最后用 strace 跟踪一下自己写好的 Shell观察它运行一条命令时底层系统调用的顺序是不是 fork、execve、wait4。这些练习不需要多长时间但每做一步你对进程 API 的理解都会上一个台阶。做完之后再回到《操作系统导论》继续往下读你会发现自己读得快很多因为代码和机制两层已经互相咬合上了。我个人在实际教学中最大的感受是很多人卡在操作系统不是卡在算法难而是卡在不习惯用代码验证概念。进程 API 这章恰好是把“概念变成代码”的第一道门。你要做的不是背下 fork 的返回值而是亲手写出程序看着终端里打印的结果理解正在发生什么。跨过这道门后面虚拟化、并发、持久性的学习都会顺畅不少。如果觉得这份总结有用不妨把问题里的三道思考题拿来问问自己再动手敲一遍代码收获会远超预期。