课堂练习3.2:进程的创建——这个标题摆在实验讲义上,看着像那种十分钟就能交差的题目。我当年也这么想,直到在终端里看到同一个 printf 输出了两遍,而代码里明明只写了一次。那一刻我才明白 fork() 这个名字起得有多诚实:它真把整个进程分叉成了两份,连那个还没刷出去的 printf 缓冲区一起复制了。后面又陆续踩了僵尸进程收不掉、exec 找不到可执行文件、waitpid 返回 -1 一连串的坑,才算把这道"课堂练习"从头到尾走通一遍。这篇就把这些东西摊开讲:fork 的返回值为什么是三个分支、写时复制到底省下了什么、exec 六兄弟怎么选、僵尸和孤儿怎么收尾,以及当输出和预期对不上的时候,具体该敲哪几条命令把问题钉死。第一次做进程创建实验的同学,可以对着抄;已经做过的,可以看看有没有漏掉的细节。
1. fork 之后同一个 printf 打印两遍:把"分叉"这件事说透
绝大多数人第一次看到重复输出,第一反应是"编译器出问题了"或者"终端抽风了"。都不是。这是 fork 语义的正常表现,只是它跟我们对"函数调用"的直觉冲突太大。
1.1 返回值为什么设计成父进程拿子进程 pid、子进程拿 0
fork() 最反直觉的地方是"一次调用、两次返回"。它没有返回值这一说——准确讲,是父子两个进程各自带着一个返回值从同一个位置继续往下跑。设计上它用了三个分支:
| 返回值 | 含义 | 典型处理 |
|---|---|---|
> 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-Write,COW)。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 上开发、想快速跑通 | 内核是真 Linux,fork/exec/信号都正常 |
| macOS 本机 | 可用 | 只有 Mac 的同学 | 部分系统调用行为有差异,pstree需要自己装 |
| 在线 Linux 环境 | 够用 | 临时验证一段代码 | 一般不给 root,strace可能受限 |
我自己的组合是:主力用 WSL2 写代码,遇到和内核行为有关的(比如想看/proc/<pid>/status里完整字段),切到虚拟机里跑一遍。两边结果不一致的时候,八成是你对某个接口的理解有偏差,这种时候差异本身就是很好的教材。
2.2 目录结构、Makefile 与第一份骨架代码
不建议把所有代码堆在一个 main.c 里。这门课的实验通常会有四五个递进的小题,早点把结构理清楚,后面改起来省事:
mkdir -p ~/os-lab/lab3.2 && cd ~/os-lab/lab3.2 mkdir src bin一个够用的 Makefile:
CC := gcc CFLAGS := -Wall -Wextra -g -O0 -std=gnu11 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 psmisc,RHEL 系dnf install psmisc。装不上也不影响实验,ps -eo pid,ppid,cmd一样能看出关系,只是不那么好看。
3. exec 六兄弟怎么挑:进程创建之后的"换脑"操作
fork 只是把进程复制了一份,复制出来的还是同一个程序。要让子进程去干点别的,得靠 exec 系列。这里有个必须刻进肌肉记忆的点:exec 调用成功之后,原来的进程映像被完全替换,exec 之后的代码永远不会执行。它"返回值"这件事本身只发生在失败的时候。
3.1 execl、execv、execvp……后缀字母的速查逻辑
六个函数的命名不是乱的,是三个维度的笛卡尔积:
| 维度 | 字母 | 含义 |
|---|---|---|
| 参数传递方式 | l(list) | 参数逐个列出,以 NULL 结尾 |
v(vector) | 参数放在字符串数组里传 | |
| 是否搜索 PATH | p(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", "LANG=C", 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 fork+exec 之外: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、设置资源限制、操作继承来的内存数据结构)。所以这门课的实验还是老老实实用 fork+exec,posix_spawn知道有这么个东西就够了。
3.3 手写一个 mini shell,把 fork+exec+wait 串起来
把三个接口串起来最好的练习就是写个最小 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 孤儿进程为什么杀不掉父进程反而更"安全"
孤儿进程和僵尸正好相反:子进程还活着,父进程先退出了。这时候内核会给孤儿找一个"养父",传统上是 init(pid 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 trace=clone,clone3,fork,vfork,execve,wait4,exit_group ./bin/exp # 输出可能长这样(关键行): # clone(child_stack=NULL, flags=CLONE_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 创建 12345;12345 调 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);这里有个必须理解的机制:读端读到 EOF(read 返回 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 前缓冲区没刷,子进程用了exit | fork 前fflush(NULL),子进程用_exit |
| 子进程逻辑被父进程也执行了 | 分支没写互斥,或子进程没退出 | 补else或在子进程末尾_exit |
| 进程数远超预期 | 子进程回到了 for 循环里继续 fork | 子进程分支末尾_exit或break+ 退出 |
waitpid返回 -1 | 参数写错、子进程已被回收、errno=EINTR | 检查 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,都会顺很多。进程创建这个练习真正想让你带走的,不是记住几个接口的参数,而是建立起"一个程序可能对应多个执行流,而它们之间的先后、共享、回收都需要显式管理"这个心智模型。这个模型建立起来之后,后面那些更复杂的东西就不那么吓人了。