从 fork 到 exec:进程创建中的重复输出、僵尸进程与排查指南

发布时间:2026/9/18 8:19:10
从 fork 到 exec:进程创建中的重复输出、僵尸进程与排查指南 课堂练习3.2进程的创建——这个标题摆在实验讲义上看着像那种十分钟就能交差的题目。我当年也这么想直到在终端里看到同一个 printf 输出了两遍而代码里明明只写了一次。那一刻我才明白 fork() 这个名字起得有多诚实它真把整个进程分叉成了两份连那个还没刷出去的 printf 缓冲区一起复制了。后面又陆续踩了僵尸进程收不掉、exec 找不到可执行文件、waitpid 返回 -1 一连串的坑才算把这道课堂练习从头到尾走通一遍。这篇就把这些东西摊开讲fork 的返回值为什么是三个分支、写时复制到底省下了什么、exec 六兄弟怎么选、僵尸和孤儿怎么收尾以及当输出和预期对不上的时候具体该敲哪几条命令把问题钉死。第一次做进程创建实验的同学可以对着抄已经做过的可以看看有没有漏掉的细节。1. fork 之后同一个 printf 打印两遍把分叉这件事说透绝大多数人第一次看到重复输出第一反应是编译器出问题了或者终端抽风了。都不是。这是 fork 语义的正常表现只是它跟我们对函数调用的直觉冲突太大。1.1 返回值为什么设计成父进程拿子进程 pid、子进程拿 0fork() 最反直觉的地方是一次调用、两次返回。它没有返回值这一说——准确讲是父子两个进程各自带着一个返回值从同一个位置继续往下跑。设计上它用了三个分支返回值含义典型处理 0当前是父进程值等于新创建子进程的 pid记录 child pid后续 waitpid 用 0当前是子进程走子进程逻辑getpid()拿自己的号 0创建失败一般是资源限制perror看 errno为什么子进程拿到的是 0 而不是它自己的 pid想一下就通了子进程要拿自己的 pid调 getpid() 就行成本极低但父进程如果不通过返回值拿到子进程 pid它就没有任何途径知道这个号了——内核不会主动塞给它。所以这个不对称是精心设计的把稀缺信息子进程的 pid给父进程把自足信息0给子进程。初学时最容易写的错误代码是if (fork() 0) { /* 父进程逻辑 */ } /* 想当然地认为下面只有子进程会执行 —— 错父进程也会执行到这里 */ printf(这行父子都会打印\n);正确写法是把两个分支写成互斥的 if/else或者子进程分支结尾必须exit()或_exit()显式退出别让它穿透到父进程的代码里。1.2 写时复制fork 那一刻内核究竟拷贝了什么很多教材一句话带过fork 会复制父进程的地址空间。这句话在语义上没错但在实现上已经过时了。现代 Linux 上一个占了几百 MB 内存的进程 fork 出子进程耗时通常在毫秒级如果真把几百 MB 物理内存拷一遍不可能这么快。真实发生的是写时复制Copy-On-WriteCOW。fork 的时候内核干三件事给子进程造一个新的task_struct进程描述符复制父进程的绝大部分字段复制父进程的页表但不复制页表指向的物理页而是让父子页表项指向同一批物理页框把所有可写页的页表项标记成只读父子共享。等任意一方对某个页执行写操作CPU 触发缺页异常内核的缺页处理程序发现这是个 COW 页才真正分配一个新物理页、把内容拷过去、把页表改回可写。也就是说只有被写过的页才会被复制。子进程如果 fork 完立刻 exec 去跑另一个程序那父进程的绝大部分数据页从头到尾都没被复制过。这个机制解释了实验里几个常见现象fork 后子进程里的全局变量、堆上数据修改之后父进程看不到因为已经各写各的页了而只读的代码段、字符串常量在整个生命周期里都是父子共享同一份物理内存。提示写时复制的粒度是页不是变量。所以哪怕你只改了结构体里的一个 int只要它和别的字段落在同一页4KB 内整页都会被复制。反过来说把高频写入的数据和一大坨只读数据放一起会白白增加 COW 开销——这是高性能场景下才需要考虑的优化做课堂练习知道有这么回事就行。1.3 task_struct、页表与 pid 分配的几个反直觉细节fork 在用户态看是一次函数调用在内核态其实是一条很长的链路。真正干活的是kernel_clone()老内核里叫do_fork()它内部又会走copy_process()做资源复制、copy_mm()处理地址空间、copy_files()处理打开的文件表、copy_sighand()处理信号处理函数表。几个容易在实验报告里写错的地方打开的文件描述符会被复制。父进程打开的 fd子进程默认也有一份且指向同一个struct file共享同一个文件偏移量。所以父子同时往同一个 fd 写会互相干扰偏移这一点在做管道实验时会直接暴露出来。pid 不是随机的是递增分配的。内核用 bitmap 管理 pid默认上限由/proc/sys/kernel/pid_max决定一般是 32768 或 4194304。跑完一批实验进程再ps会发现号在涨这属于正常现象。进程号回收有延迟。pid 用满一圈后会从低位重新分配但内核会尽量避开最近用过的号避免短时间内出现同名同号的混乱。fork 失败是真的会发生。ulimit -u限制用户最大进程数写个死循环 fork 就能把机器跑满然后 fork 开始返回 -1 并置 errno 为 EAGAIN。实验机上恶意 fork 是会把整台机器拖垮的测试前先ulimit -u看一眼上限。2. 动手前的环境准备一份能同时看见父子进程的实验骨架进程创建这个实验有个特殊之处代码写对了输出看起来可能还是一团乱。所以环境准备不只是装个编译器而是要准备好观察进程的工具。2.1 平台怎么选Linux 虚拟机、WSL2 与 Windows 原生先把一个硬性事实摆出来Windows 原生 API 里没有 fork。Windows 用CreateProcess()创建进程父进程和子进程之间不共享地址空间天生就是另起炉灶的模型。C 运行时的_spawn系列函数是另一套东西。所以在 Windows 上用 MSVC 或者 MinGW 直接编译 fork 的代码最好的结果也是编译报错坏的结果是链接到一个模拟实现、行为完全不对。方案可行性适合人群需要注意Ubuntu 虚拟机VirtualBox/VMware最完整想认真做实验、后面要写内核模块的装完记得装增强工具能直接拖文件WSL2很完整日常在 Windows 上开发、想快速跑通内核是真 Linuxfork/exec/信号都正常macOS 本机可用只有 Mac 的同学部分系统调用行为有差异pstree需要自己装在线 Linux 环境够用临时验证一段代码一般不给 rootstrace可能受限我自己的组合是主力用 WSL2 写代码遇到和内核行为有关的比如想看/proc/pid/status里完整字段切到虚拟机里跑一遍。两边结果不一致的时候八成是你对某个接口的理解有偏差这种时候差异本身就是很好的教材。2.2 目录结构、Makefile 与第一份骨架代码不建议把所有代码堆在一个 main.c 里。这门课的实验通常会有四五个递进的小题早点把结构理清楚后面改起来省事mkdir -p ~/os-lab/lab3.2 cd ~/os-lab/lab3.2 mkdir src bin一个够用的 MakefileCC : gcc CFLAGS : -Wall -Wextra -g -O0 -stdgnu11 TARGETS : $(patsubst src/%.c,bin/%,$(wildcard src/*.c)) all: $(TARGETS) bin/%: src/%.c mkdir -p bin $(CC) $(CFLAGS) -o $ $ clean: rm -rf bin几个编译参数值得解释一下-O0关掉优化避免编译器把某些变量优化进寄存器导致调试时看不到-g带上调试信息后面用 gdb 单步 fork 会方便很多-Wall -Wextra把警告打开进程相关的代码里忽略返回值是很常见的问题警告能帮你抓出来。第一份骨架代码我习惯写成能同时看见父子的版本#include stdio.h #include stdlib.h #include unistd.h #include sys/types.h #include sys/wait.h int main(void) { printf([before] pid%d\n, getpid()); fflush(stdout); /* 关键先刷干净避免缓冲区被复制 */ pid_t pid fork(); if (pid 0) { perror(fork); return EXIT_FAILURE; } if (pid 0) { printf([child ] pid%d ppid%d\n, getpid(), getppid()); sleep(3); /* 留住子进程方便另开终端观察 */ printf([child ] exiting\n); _exit(EXIT_SUCCESS); /* 用 _exit 而不是 exit */ } printf([parent] pid%d child%d\n, getpid(), pid); int status 0; pid_t done waitpid(pid, status, 0); if (done 0) { perror(waitpid); return EXIT_FAILURE; } printf([parent] reaped child%d, exited%d, code%d\n, done, WIFEXITED(status), WEXITSTATUS(status)); return EXIT_SUCCESS; }这段代码里有三个我特意写出来的细节都是踩过坑才知道的fflush(stdout)放在 fork 之前子进程结尾用_exit()而不是exit()父进程用waitpid而不是wait。这三点的原因分别在 1.2、5.1、4.2 三个地方展开先照着写后面自然就懂了。2.3 三条命令看清父子关系ps、pstree 与 /proc子进程sleep(3)的那三秒是你观察它的黄金窗口。开一个终端跑程序切到另一个终端执行# 1. 按进程树展示能看到缩进的父子关系 pstree -p $(pgrep -n lab 2/dev/null || pgrep -n a.out) # 2. 确认父子关系与状态 ps -eo pid,ppid,stat,cmd | grep -E PID|a.out # 3. 看内核眼里这个进程长什么样 cat /proc/child_pid/status ls -l /proc/child_pid/fdps输出里的STAT列要会读S是可中断睡眠就是 sleep 状态R是运行/就绪Z是僵尸这个最关键第四部分专门讲s后缀表示会话首进程表示在前台进程组。ppid一列直接验证了父子关系——如果程序跑完父进程退出了你再去看子进程的 ppid会发现它变成了 1这就是下面要说的孤儿。/proc/pid/fd这个目录非常值得一看。fork 之后打开它你会发现父子进程的 fd 编号和指向完全一样。这是文件描述符表被复制最直观的证据比看任何文档都管用。提示pstree很多精简版发行版默认没装。Debian/Ubuntu 系apt install psmiscRHEL 系dnf install psmisc。装不上也不影响实验ps -eo pid,ppid,cmd一样能看出关系只是不那么好看。3. exec 六兄弟怎么挑进程创建之后的换脑操作fork 只是把进程复制了一份复制出来的还是同一个程序。要让子进程去干点别的得靠 exec 系列。这里有个必须刻进肌肉记忆的点exec 调用成功之后原来的进程映像被完全替换exec 之后的代码永远不会执行。它返回值这件事本身只发生在失败的时候。3.1 execl、execv、execvp……后缀字母的速查逻辑六个函数的命名不是乱的是三个维度的笛卡尔积维度字母含义参数传递方式l(list)参数逐个列出以 NULL 结尾v(vector)参数放在字符串数组里传是否搜索 PATHp(path)只给文件名自动查 PATH无p必须给完整路径环境变量e(env)显式传入 envp 数组无e继承当前environ组合起来就是execl / execlp / execle / execv / execvp / execvpe。记法很简单l 和 v 二选一p 和 e 可加可不加。注意没有execve吗有execve是唯一真正的系统调用其他五个都是 glibc 在它上面包的壳。#include unistd.h /* 必须给完整路径参数逐个写 */ execl(/bin/ls, ls, -l, -a, (char *)NULL); /* 自动查 PATH参数用数组 */ char *argv[] { ls, -l, -a, NULL }; execvp(ls, argv); /* 自己指定环境变量 */ char *envp[] { PATH/usr/bin:/bin, LANGC, NULL }; execle(/usr/bin/env, env, (char *)NULL, envp);两个高频坑argv[0]按惯例应该是程序名虽然传什么都能跑起来除了少数会检查 argv[0] 的程序但传错了ps里看到的名字会很奇怪参数数组必须以 NULL 结尾忘了这一条exec 会一路读到栈上的垃圾数据表现为随机崩溃或者参数莫名其妙多出来几个。还有个小知识execvp找不到文件时会去试 PATH 里每个目录并把每次尝试的目录名拼进错误信息。所以用execvp(lss, ...)报错你会看到一长串No such file or directory每行对应一个 PATH 目录——这不是 bug是设计。3.2 forkexec 之外posix_spawn 与 vfork 的适用边界fork 再 exec是 Unix 里创建新程序的经典组合但它有个明显浪费fork 复制了一整套页表紧接着 exec 又把整个地址空间丢掉。为此历史上出现过vfork它让子进程直接借用父进程的地址空间父进程挂起直到子进程 exec 或退出。vfork的规则很硬子进程在调用 exec 或_exit之前不能修改任何数据不能调用任何函数不能从当前函数返回。违反了它父进程的数据会被悄悄改掉这类 bug 极难排查。所以除了对性能极其敏感、且你完全清楚自己在干什么的场景不要用 vfork。现在的推荐做法是posix_spawn#include spawn.h extern char **environ; int main(void) { pid_t pid; char *argv[] { ls, -l, NULL }; int rc posix_spawnp(pid, ls, NULL, NULL, argv, environ); if (rc ! 0) { /* 注意这里返回的是错误码本身不是 -1 */ } return 0; }它有两个好处一是实现上可以在支持的系统里直接走内核的优化路径避免复制二是可以一次性指定文件动作重定向 fd、关闭 fd和属性进程组、调度策略不用在 fork 和 exec 之间写一堆容易出错的代码。接口设计上还用了返回错误码而非置 errno的约定注意别按 errno 的习惯去判断。代价也有posix_spawn表达能力有限做不了 fork 之后、exec 之前那些复杂的自定义准备动作比如要在子进程里改用户 ID、设置资源限制、操作继承来的内存数据结构。所以这门课的实验还是老老实实用 forkexecposix_spawn知道有这么个东西就够了。3.3 手写一个 mini shell把 forkexecwait 串起来把三个接口串起来最好的练习就是写个最小 shell。它的主循环只有五步#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/wait.h int main(void) { char line[1024]; while (1) { printf(mysh ); fflush(stdout); if (!fgets(line, sizeof line, stdin)) { /* Ctrl-D 退出 */ putchar(\n); break; } line[strcspn(line, \n)] \0; /* 去掉换行 */ if (line[0] \0) continue; if (strcmp(line, exit) 0) break; char *argv[64]; int argc 0; char *save NULL; for (char *tok strtok_r(line, \t, save); tok argc 63; tok strtok_r(NULL, \t, save)) { argv[argc] tok; } argv[argc] NULL; if (argc 0) continue; pid_t pid fork(); if (pid 0) { perror(fork); continue; } if (pid 0) { execvp(argv[0], argv); /* 只有 exec 失败才会走到这里 */ perror(execvp); _exit(127); /* 127 是 shell 里命令未找到的约定 */ } int status 0; if (waitpid(pid, status, 0) 0) { perror(waitpid); continue; } if (WIFEXITED(status)) printf([exit %d]\n, WEXITSTATUS(status)); else if (WIFSIGNALED(status)) printf([killed by signal %d]\n, WTERMSIG(status)); } return 0; }这段代码里有几个点特别值得琢磨_exit(127)而不是exit(127)。子进程刚 fork 出来继承的 stdio 缓冲区里可能有内容exit()会把这些内容刷出去造成重复输出。_exit()直接走系统调用退出不碰缓冲区。这是第 5 部分要展开的坑。strtok_r而不是strtok。虽然这里父进程单线程用没关系但_r版本用起来并不会有额外成本养成习惯。参数里那个save指针不能省它保存了遍历状态。execvp失败后一定要退出。如果只是 perror 然后不管子进程会继续往下跑一遍父进程的 wait 逻辑行为彻底乱掉。这个错误在很多初学者代码里都能见到。127 这个退出码。这是 shell 界的约定俗成其他 shell 也用这个值表示命令找不到。你的 mini shell 跟上了这个约定脚本里if ! cmd; then之类的判断才能正常工作。写完之后拿它跑几个命令试试ls -l、echo hello、cat /etc/hostname、sleep 2再故意跑一个不存在的命令看看 127 分支。这个 shell 也就四十行但它把 fork、exec、wait 三个环节全都串起来了。4. 僵尸与孤儿进程退场时最容易丢分的两件事进程的死亡不是一瞬间的事。子进程调用了_exit它的执行体确实停了但内核里那点残留信息——退出码、资源统计——还得有人来收。谁来收父进程。父进程不收会怎样这就有了僵尸。4.1 一个必然产生僵尸进程的最小实验#include stdio.h #include unistd.h #include stdlib.h int main(void) { pid_t pid fork(); if (pid 0) { printf(child %d will exit immediately\n, getpid()); _exit(0); } printf(parent %d sleeping 60s, child is %d\n, getpid(), pid); sleep(60); /* 故意不 wait */ return 0; }编译运行然后在另一个终端里ps -eo pid,ppid,stat,cmd | awk $3 ~ /Z/ # 或者 ps aux | grep -w Z你会看到那一行Z状态、defunct的进程。它的 pid 还在占着父进程 pid 就是你的程序。几个必须知道的结论僵尸进程杀不掉。对着它kill -9完全无效因为它早就死了没有代码在运行也没有信号处理逻辑剩下的只是一个还没被回收的内核结构。想消灭它唯一的办法是让它的父进程调用 wait/waitpid 去收。僵尸会占用 pid。单个僵尸危害有限但如果父进程长期运行比如服务器而不断产生子进程又不回收pid 会被吃光最终导致 fork 失败报 EAGAIN。看 STAT 列Z开头的都是僵尸。有的地方写成Z那个加号表示它在前台进程组里跟状态无关。那杀掉父进程能不能解决问题能但方式很绕父进程一死僵尸子进程就变成孤儿被 init/systemd 收养由它代为回收。所以这条路可行但属于用更大的动作解决小问题服务端肯定不能这么干。4.2 wait 与 waitpid 的参数陷阱与状态宏回收子进程有两个接口差别在于waitpid能指定等哪个、能非阻塞。三个参数各自的坑#include sys/wait.h pid_t waitpid(pid_t pid, int *wstatus, int options);第一个参数 pid的取值语义比较绕值得单独列表取值含义 0只等这个具体的子进程 0等同一个进程组里的任意子进程 -1等任意一个子进程等价于wait -1等进程组 id 等于|pid|的任意子进程第二个参数 wstatus是一个位打包的整数千万不要直接和退出码比较。必须用宏解包WIFEXITED判断是否正常退出、WEXITSTATUS取退出码、WIFSIGNALED判断是否被信号杀死、WTERMSIG取信号编号。有的平台还有WIFSTOPPED和WSTOPSIG处理暂停状态。第三个参数 options最常用的两个值是0和WNOHANG。WNOHANG表示非阻塞如果没有已退出的子进程立刻返回 0。这个返回值必须和-1区分开——返回 0 表示暂时还没有子进程退出返回 -1 表示出错了或者压根没有子进程errno 为 ECHILD。初学者常犯的错是把 0 和 -1 当成一回事处理结果要么漏了已退出的子进程要么在循环里死转。还有一个容易忽略的细节wait/waitpid一次只能回收一个子进程。如果你 fork 了 5 个只调用一次 waitpid剩下 4 个还会变成僵尸。正确的做法是循环回收int status; pid_t done; while ((done waitpid(-1, status, 0)) 0) { printf(reaped %d\n, done); } /* done -1 且 errno ECHILD 时说明所有子进程都收完了 */4.3 孤儿进程为什么杀不掉父进程反而更安全孤儿进程和僵尸正好相反子进程还活着父进程先退出了。这时候内核会给孤儿找一个养父传统上是 initpid 1用 systemd 的系统里也是 pid 1 或者被指定为 subreaper 的进程。之后子进程的getppid()就会返回 1。孤儿不会留下任何残留结构也不会占用 pid 之外的系统资源等它自己退出时会被 pid 1 正常回收。所以从系统角度看孤儿比僵尸安全得多。但从程序设计角度看孤儿往往意味着逻辑漏洞你在子进程还没干完活的时候就把父进程退出了这通常会带来更隐蔽的问题比如子进程还写着一个已经不该再写的日志文件、或者对已经关闭的管道写数据导致 SIGPIPE。复现一下pid_t pid fork(); if (pid 0) { printf(child ppid before %d\n, getppid()); sleep(5); printf(child ppid after %d\n, getppid()); /* 大概率变成 1 */ _exit(0); } /* 父进程立刻退出不等子进程 */判断一个小技巧如果在ps里看到一个进程的PPID是 1而它不是系统服务八成就是你程序留下来的孤儿。顺带一提容器环境要小心——有些容器里的 pid 1 是你的应用程序本身如果它不处理 SIGCHLD孤儿和僵尸都会堆在它名下这时候要做的是在应用里显式处理子进程回收。5. 结果和预期对不上时的排查链路进程相关的实验里代码看着没错但结果不对是常态。下面是我总结的排查顺序按最可能到最不可能排。5.1 输出重复先怀疑缓冲再怀疑循环看到重复输出不要急着改逻辑先按这个顺序查第一步确认是不是缓冲区被复制了。标准输出在连接终端时是行缓冲遇到换行就刷但重定向到文件或管道时变成全缓冲默认 4096 或 8192 字节才刷。所以 fork 之前如果有未刷出的内容这段内容会被复制到子进程的缓冲区里父子各刷一次屏幕上就出现两遍。验证方法有两个# 方法一把输出重定向到文件看重复是否更明显 ./bin/exp out.txt; cat out.txt # 方法二用 write 系统调用直接写绕开 stdio 缓冲 # 在代码里把 printf 换成 write(1, msg\n, 4);如果换成write之后就不重复了那基本可以确诊是缓冲问题。修法就是 fork 之前fflush(NULL)或者子进程统一用_exit。第二步确认不是循环里 fork。这个错误特别隐蔽for (int i 0; i 3; i) { if (fork() 0) { /* 有人以为这里只有一个子进程会进来 —— 错 */ do_child_work(); } }子进程不会跳出循环它会继续往下迭代也跟着 fork 新的子进程。三轮下来就是 2³8 个进程全部执行do_child_work()输出自然多到离谱。修法是在子进程分支里_exit()或者把break加进去——但break只是跳出循环子进程还是会继续执行循环后面的代码所以_exit才是标准答案。第三步才是怀疑系统调用本身的语义。比如write返回值小于请求长度部分写、read被信号中断返回 -1 并置 EINTR这些在高并发场景才会遇到课堂练习里极少。5.2 输出顺序每次都不同这是 bug 还是正常父子进程谁先跑由调度器决定没有任何保证。你写完代码第一次运行看到父进程先输出不能据此认为父进程一定先输出换个负载环境顺序可能就反了。这不是 bug是并发的基本事实。实验里要看到固定顺序只能靠同步手段父进程waitpid之后子进程肯定已经执行完这是 wait 的语义保证或者用管道、信号来协调。注意waitpid保证的是子进程已终止不是子进程的输出已经全部落到终端上——如果子进程有未刷的缓冲你在父进程里先看到自己的输出仍然可能发生。要严格保证还是得两边都fflush。还有一个容易忽略的输出源stdout 和 stderr 是两个独立的流。printf走 stdout有缓冲perror走 stderr无缓冲。所以perror的输出常常插队到printf前面。调试的时候如果被这个现象迷惑很容易往错误的方向怀疑。要么统一用fprintf(stderr, ...)要么在每次printf后加fflush(stdout)。5.3 用 strace 把系统调用的顺序钉死前面都是推理strace是证据。它能把进程执行的每一个系统调用按时间顺序打出来父子进程用-f一起跟# 只看进程创建、程序加载、等待相关的调用 strace -f -e traceclone,clone3,fork,vfork,execve,wait4,exit_group ./bin/exp # 输出可能长这样关键行 # clone(child_stackNULL, flagsCLONE_CHILD_CLEARTID|... ) 12345 # [pid 12345] execve(/usr/bin/ls, [ls,-l], ...) 0 # [pid 12345] exit_group(0) ? # wait4(12345, [{WIFEXITED(s) WEXITSTATUS(s) 0}], 0, NULL) 12345从这几行能直接读出父进程调了 clone 创建 1234512345 调 execve 换了程序映像12345 退出父进程 wait4 回收了它。整个过程一目了然比看代码猜测靠谱得多。几个strace的使用技巧-f跟子进程是必须的不加只会看到父进程的调用-e trace过滤很重要不加会把所有系统调用都打出来几百行起步-o file把输出写文件方便搜索-T显示每个调用的耗时排查卡在哪一步特别有用有些系统上fork已经不再作为独立系统调用出现strace里看到的是clone这很正常。用strace还有个副作用它会给被跟踪进程带来明显的性能开销并且改变进程间的时序。所以加了 strace 之后顺序就稳定了这种现象完全可能发生不要因此认为问题解决了。6. 把课堂练习 3.2 做深的几个方向如果只是交作业上面那些够用了。但这道题其实是个很好的跳板往下挖还能挖出不少东西。6.1 用管道让父子进程真正对话fork 之后父子共享的东西里最能体现进程间通信的就是管道。核心思路是在 fork之前把管道建好让子进程继承两端的 fd然后各自关闭用不到的那一头int fd[2]; if (pipe(fd) 0) { perror(pipe); return 1; } pid_t pid fork(); if (pid 0) { close(fd[0]); /* 子进程只写关掉读端 */ const char *msg hello from child\n; write(fd[1], msg, strlen(msg)); close(fd[1]); _exit(0); } close(fd[1]); /* 父进程只读关掉写端 */ char buf[128]; ssize_t n read(fd[0], buf, sizeof buf - 1); if (n 0) { buf[n] \0; printf(parent got: %s, buf); } close(fd[0]); waitpid(pid, NULL, 0);这里有个必须理解的机制读端读到 EOFread 返回 0的条件是所有写端都被关闭。如果父进程忘了关自己的fd[1]写端就永远有一个开着read 会一直阻塞程序挂死。反过来也一样子进程不关fd[0]管道就不会被完全拆除。这个关掉不用的那一头是管道编程里最高频的错误来源。6.2 SIGCHLD 与自动回收省心的同时也埋着雷除了主动 wait还有一种被动回收的路子把 SIGCHLD 的处理方式设为忽略。#include signal.h signal(SIGCHLD, SIG_IGN);在 Linux 上这么写之后子进程退出时会被内核自动回收不再产生僵尸父进程也不需要实际上也很难wait 到它们。看起来很方便但有几个坑这是实现相关的行为。POSIX 标准没有规定忽略 SIGCHLD 就必须自动回收Linux 和部分 BSD 这么做别的系统不一定。写跨平台代码时不能依赖它。自动回收之后父进程拿不到退出码。因为内核已经把它收走了wait 系列会返回 -1 并置 ECHILD。如果你需要知道子进程是正常退出还是被信号杀死这条路就走不通。信号会打断系统调用。SIGCHLD 是异步到达的如果处理函数里什么都不做仍然可能打断正在阻塞的 read/write让它们返回 -1 并置 EINTR。健壮的代码需要在循环里判断 EINTR 并重试。更规范的做法是注册一个 SIGCHLD 处理函数在里面循环调用waitpid(-1, status, WNOHANG)。要注意处理函数里能用什么、不能用什么是有讲究的——printf这类非异步信号安全的函数不应该在里面调用标准做法是只设置一个volatile sig_atomic_t标志位让主循环去处理。6.3 一道经典的fork 到底创建了几个进程推理题题目很常见for (int i 0; i 3; i) { fork(); } printf(X\n);问X会被打印几次答案是8 次也就是 2³。推理方式有两种我推荐用每个进程都是一棵独立的执行线来想第 1 轮 fork进程数从 1 变 2第 2 轮 fork这 2 个进程各自再 fork 一次变成 4第 3 轮 fork这 4 个各自再 fork变成 8。于是打印 8 次。如果题目改成for (int i 0; i 3; i) { if (fork() 0) break; } printf(X\n);这时候父进程每轮 fork 出一个子进程并break跳出自己继续下一轮一共创建 3 个子进程加上原来的父进程总共 4 个进程打印 4 次。这两个变体放在一起对比就很容易看出子进程会不会回到循环才是决定指数增长的关键。再补一个容易算错的如果 fork 是在if的条件里仍然会执行fork() || fork()这类写法会创建 3 个进程。这种题在笔试里出现频率不低手推的时候最好画个表格把每轮结束后当前有几个进程记清楚比心算靠谱。6.4 交作业前对照这份错误清单自查最后把实验里出现频率最高的错误集中列一下交之前逐条对一遍能省掉大量返工现象高概率原因修法输出成倍重复fork 前缓冲区没刷子进程用了exitfork 前fflush(NULL)子进程用_exit子进程逻辑被父进程也执行了分支没写互斥或子进程没退出补else或在子进程末尾_exit进程数远超预期子进程回到了 for 循环里继续 fork子进程分支末尾_exit或break 退出waitpid返回 -1参数写错、子进程已被回收、errnoEINTR检查 pid 取值判断 errno 后重试程序卡死不返回管道读端没关写端read 一直阻塞每端关掉自己用不到的那一头exec 之后代码还在跑exec 失败没处理或者路径写错exec 后必须跟 perror _exit编译报警告「隐式声明」忘了#include sys/wait.h等补头文件别忽略警告ps里一堆 defunct父进程没回收或缺循环 wait用while(waitpid(-1,...,0) 0)输出顺序飘忽不定父子竞争调度属于正常用 wait 或管道同步子进程 ppid 变成 1父进程提前退出产生孤儿检查父进程分支是否有提前 return我个人在带实验的时候发现这一页最花时间的从来不是写代码而是搞清楚为什么结果和我想的不一样。上面这张表里前四条大概覆盖了八成的提问。剩下那两成基本都是没意识到 stdout 有缓冲、以及没意识到父子进程的执行顺序没有保证——这两个认知一旦建立起来后面再学线程、学并发、学 IPC都会顺很多。进程创建这个练习真正想让你带走的不是记住几个接口的参数而是建立起一个程序可能对应多个执行流而它们之间的先后、共享、回收都需要显式管理这个心智模型。这个模型建立起来之后后面那些更复杂的东西就不那么吓人了。