“主进程退出了”既不能证明它启动的子进程已完成,也不能证明系统已经回收了全部进程状态。将这句话拆成 fork、exec、文件描述符、信号与 wait,才能解释容器里最常见的退出和日志问题。先阅读00:观测对象与实验基线;这一篇只使用普通进程建立可复核的生命周期,不假定容器运行时已经可用。

四个独立动作

fork() 创建子进程:父子最初有独立 PID,子进程继承打开的文件描述符,但调用返回后谁先执行取决于调度,不能把日志先后当程序依赖。execve() 用新程序替换当前进程映像,不会因为替换而分配新的 PID。默认打开的 FD 随之保留;设置 FD_CLOEXEC 才要求 exec 时关闭相应描述符。容器标准输出常作为由运行时提供的 FD 传入应用,因此新程序是否继承它是能否观察日志的关键。别把“关闭路径名对应的文件”和“关闭 FD”混为一谈:先前打开的 FD 指向已打开文件对象,删除路径也未必让引用立刻失效。

子进程结束时留下退出状态,父进程通过 waitpid() 读取并回收;WIFEXITED 与 WEXITSTATUS 用于正常退出,WIFSIGNALED 用于信号终止。如果父进程没有等待,已终止的子进程可短时保持 zombie 状态。父进程本身结束后,孤儿由适当的 init 或 subreaper 接管,不等于它自动变成僵尸。生产环境还必须考虑 signal disposition、后台子进程、超时与父进程竞争,不能把教学程序的调度顺序推广为定律。

fork 复制了哪些关系

父进程在 fork 返回时得到子 PID,子进程得到返回值 0;这两个返回值属于两个执行流。它们的虚拟地址空间起初具有相同可见内容,写入通常通过写时复制等机制分离,不等于父子共享同一份用户态变量。子进程会继承文件描述符表中的入口,而入口通常引用与父进程相同的内核打开文件描述对象;文件偏移等状态可能受双方的读写共同影响。父亲 close(fd) 只关闭父亲自己的那份引用,若子进程仍持有 FD,内核不能仅凭父亲关闭就认定文件无人使用。

用路径名判断资源是否关闭尤其危险。教学探针通过 mkstemp 得到文件 FD 后立即 unlink 临时路径:目录项不再存在,但文件对象仍由已打开的 FD 引用。子进程继承 FD 的情况下,exec 后的新程序仍可通过它访问同一打开对象。必须把“unlink 后 ls 找不到路径”和“所有进程都释放资源”分开记录。若测试意图变为持久性,除了 FD 还要核对文件系统和写盘方式;本篇的证据只涉及描述符生存期。

fork 之后父子执行次序不确定。稳定的局部关系是每个进程内部指令的先后与显式同步建立的顺序。若父子都在 fork 前后 printf 而没有刷新缓冲,就可能出现重复或乱序输出;本篇探针在 fork 前刷新 stdout,子进程在 exec 的新程序中输出,父进程在 waitpid 后输出回收事件。即便如此,PID 数字和调度时延每次都可能不同,文章不能硬编码跨机器唯一的时间线。只有程序的控制流与同步原语确立的事件关系,才能写成可重复的预期。

exec 成功时还留下哪些东西

execve 并不是“再生一个子进程”:它用新程序替换当前进程映像。子进程先 fork 后 exec,子进程的 PID 贯穿前后,父进程仍可以按 fork 得到的 PID 进行 wait。原用户态代码、内存与栈被新映像替换,打开的 FD 默认却可以保留;对该 FD 设置 FD_CLOEXEC 才要求在成功 exec 时将其关闭。容器入口的标准输入、标准输出和标准错误可以作为 FD 传给应用,错误地关闭 stdout,可能让程序继续运行但日志采集路径从一开始就缺失。

“exec 调用失败”和“新程序自己退出”应分别归因。执行路径不存在、没有权限或解释器/动态加载器路径不对,都可能使 execve 返回 -1 并留下 errno,此时旧进程映像尚在运行自己的错误处理逻辑。相反,exec 成功后新程序再以 7 退出,父进程 waitpid 得到的是新程序的退出状态。两者在较高层或许都被打印成“容器退出”,但前者要排查入口、rootfs 与启动参数,后者要查新程序日志,不能仅凭相同的终态合并原因。

信号处理也不是可以原样带到新映像里的全部状态:对信号安装的处理函数在成功 exec 后按系统调用规则重置,其他信号的忽略配置还需按具体规则核对。要研究 TERM,就应在最终真正运行的入口程序中验证它如何处理 TERM,不能在旧程序里装一个 handler 后假设新程序会自动继承。多线程进程调用 fork 还有锁及线程状态方面的额外限制;本篇探针故意保持单线程,不把这一案例推广成多线程程序的通用安全模式。

wait 解决什么,不解决什么

waitpid(child, &status, 0) 等待一个具体的子进程;成功只说明父进程取得状态并完成对应回收,不代表被其他进程继承的 FD 也已全部消失。若改用 WNOHANG,返回 0 可以表示目前尚无可收取的状态,绝不等于子进程的退出码是 0。子进程并不属于调用者时可得到 ECHILD;一个进程不能任意等待宿主中的所有 PID。生产环境的 init 往往要循环回收不同时间退出的多个子进程,处理信号打断、意外孤儿及终止宽限期,本篇只保留单子进程的基本语义。

zombie 准确指进程已经停止执行、只留必要的退出信息供父进程取得。它不会再执行业务代码,也不能再靠发送 TERM 让它“优雅退出一次”。若父进程忽略 SIGCHLD 或设置相关选项,内核可能自动收取,需对当时的实际信号设置做验证;父进程退出后遗留进程会由 init 或最近的 subreaper 接管,也不能写成“僵尸永远属于原父 PID”。判断清理是否正确,应对照 wait 目标、最后进程树和实际时序,而不是拿一张 ps 截图概括整个生命周期。

正常退出、异常返回码、信号终止还有状态来源差别。原始 waitpid 状态经 WIFEXITED 或 WIFSIGNALED 解码;shell 常以 128+信号号 表示命令被信号终止,却不是内核直接标注“OOM=true”。例如 00 的普通探针由脚本明确发送 TERM 后 wait 得到 143;如果只见 143 而没有发送者、目标与信号记录,不能排除应用按自己的逻辑返回相似的数字。若要证明内存 OOM,还要比对发生时的 cgroup 和节点事件。

用同一程序区分继承与回收

最小源码及编译入口在源码包与运行说明;原始编译后运行记录在原始输出。gcc -std=c11 -Wall -Wextra -Werror -O2 process_probe.c -o /tmp/containers-process-probe 成功后执行 inherit、cloexec、zombie 三种模式。文件由 mkstemp 创建并立即 unlink,实验仅操作该进程拥有的临时 FD。inherit 中子进程输出 descriptor=3 open=yes,cloexec 中为 open=no;两次子进程均以 7 退出,父进程经 waitpid 获取结果,程序自身返回 0。这里的 FD 数值和 PID 每次都可能不同,应比较关系而非抄固定值。

边界案例使用 zombie:子进程调用 _exit(7),父进程睡眠至多一秒,从 /proc/<child>/status 观察 State: Z (zombie),然后立即 waitpid。证据日志的 before_wait 是 Z,而退出状态被回收之后不存在持续僵尸;此程序并未制造留在宿主上的长期污染。invalid 模式返回 2,说明入口参数拒绝也有可记录的退出码。信号转发和 PID 1 的特殊语义留给03:PID 1,不要把这里的 _exit(7) 解读为信号终止。

从输出反推行为,而不是反过来填写输出

执行 inherit 之前,先写下三个可检验的预测:父子 PID 不同,成功 exec 的子进程保留 fork 后的 PID,未设置 CLOEXEC 的 FD 仍打开。原始日志中子程序报告 descriptor=3 open=yes,随后父程序报告等到子进程以 7 退出;PID 在该环境中恰好相邻,属于分配结果,不是 fork 的契约。执行 cloexec 只改变 FD 标志,其他步骤尽量保持一致:新程序报告同一数值 FD open=no,而父程序仍可以得到子进程 7。由此能推出“这个 FD 的跨 exec 生存状态不同”,不能推出整个进程被权限隔离或文件内容被销毁。

zombie 模式则有意跳过 exec,使子进程调用 _exit;父进程在等待前读 /proc/<child>/status,原始文本出现 State: Z,随后 waitpid 取得退出状态。若改成父进程过早执行 wait,可能来不及从 /proc 读取 Z;若父进程等待之前子进程尚未退出,可能暂时看到别的状态。这两种变化说明 /proc 快照受采样时刻影响,不意味着 wait 的回收语义改变。反例实验要控制采样窗口,并且绝不能在共享宿主长期留下无人回收的 zombie 来换一张截图。

最后执行 invalid 得到程序自己的 usage 和退出码 2。程序尚未调用 fork,没有子进程,也就不存在“wait 后返回 2”的解释。这给后续运行时诊断提供简单顺序:先判定错误是否出现在解析/创建之前,再检查是否已有实际宿主 PID,最后才讨论这个 PID 发出的退出状态。重新运行或更换机器时用事件关系和明确 errno 校验结论,不照抄日志里的数值 PID、临时目录名与一次输出顺序。

这套判断到了容器里依旧适用,但观察范围会改变:父进程的数字 PID 可能在内外 namespace 中不同,stdout 的另一端可能由 shim 或日志驱动持有,PID 1 还可能负责接收孤儿。Linux 进程接口本身并没有因为 CLI 名为 run 而变成另一套 fork/exec/wait 规则。分层排障时先根据容器 ID 找到宿主真实进程,再在该进程的父子树、FD、信号与退出状态之间建立一致解释;只有证明是哪个 FD 丢失、哪个子进程没被 wait,才能把高层的“日志没了”“停不下来”重新落在可验证的操作上。03 之后再给 PID 1 的信号规则增加限定,避免提前把普通进程的结果说成所有容器的默认行为。

当前环境没有运行时,因此本文已证实的是普通 Linux 进程的三个受控模式及其回收,而不是容器内的标准输出采集或优雅终止。将这个范围写清,读者才能把可迁移的接口语义与尚缺失的具体运行时证据区别开。

现象 最先检查 不可偷换的前提
新程序仍占用旧 FD FD_CLOEXEC、/proc/<pid>/fd exec 不等于 fork
子进程已退出却有 Z 父进程是否调用 wait* Z 不表示程序仍在执行
shell 得到 0,子进程是 7 父进程返回逻辑与子进程状态 退出码属于具体进程

练习

  1. 先预测 execve 前后 PID 与 FD 是否改变,再分别执行 inherit 和 cloexec;修改探针让父进程打印自身 FD 标志,解释两条输出而非仅对照成功字样。
  2. 先预测如果移走父进程的 waitpid,一秒内 /proc/<child>/status 的 State;只在独立用户/进程环境中尝试,并在结束前恢复等待与确认进程被回收,不能留下持续 zombie。

下一篇:02:namespace 的资源视图。

系列总目录:从进程隔离到运行时与编排。

参考资料