进程创建并不是“复制一个程序”,execve() 也不是“再创建一个进程”。真正的生命周期,是一组内核对象在复制、共享、替换和回收之间不断切换。
在嵌入式 Linux 板卡上执行一条 Shell 命令,看起来只是输入程序名、回车、等待结果。但内核内部经历了远比“启动程序”复杂的过程:Shell 先创建一个子任务,子任务暂时继承父进程的大量上下文,再用可执行文件替换自己的地址空间;程序结束后,它还不能立刻彻底消失,必须把退出状态留给父进程回收。
理解这条路径,能够解释很多工程现象:为什么 fork() 后内存没有立刻翻倍,为什么 execve() 成功后不会返回,为什么程序已经退出却仍能看到 Z 状态,为什么容器里 PID 1 没有正确回收子进程会积累僵尸,以及为什么多线程程序在 fork() 后直接调用复杂库函数可能死锁。
一、先建立正确模型:程序、进程和线程不是一回事
程序是磁盘或 Flash 文件系统里的 ELF 文件;进程是一次正在运行的执行实例;Linux 内核真正调度的对象则是任务,核心描述符是 task_struct。
一个 task_struct 不会独自保存所有资源。它通过指针关联多个对象:
mm_structfiles_structfs_structsighand_structcred- 调度实体、CPU 亲和性和状态字段决定任务怎样进入运行队列。
所谓创建进程,本质不是把一切重新构造一遍,而是创建新的任务描述符,并依据创建参数决定哪些对象复制、哪些对象共享。Linux 的“进程”和“线程”因此没有两套完全独立的内核实现:线程通常也是由 clone() 系列系统调用创建,只是通过 CLONE_VM、CLONE_FILES、CLONE_SIGHAND、CLONE_THREAD 等标志共享更多资源。
进程与线程的差别,主要不是有没有 task_struct,而是它们共享了多少执行上下文。
二、fork 进入内核后,核心工作发生在哪里
用户空间调用 fork(),现代 C 库可能借助底层 clone() 语义完成,但内核侧最终都要进入任务复制主路径。理解源码时,可以抓住 kernel/fork.c 中围绕 kernel_clone() 和 copy_process() 展开的逻辑。
这条路径大致可以压缩成:
用户态 fork/clone3 ↓系统调用入口 ↓kernel_clone() ↓copy_process() ↓复制或共享 mm、files、fs、signal、namespaces ↓分配 PID,建立父子关系 ↓wake_up_new_task()
copy_process() 会进行资源限制和参数合法性检查,分配新的任务结构及内核栈,然后逐项准备调度、审计、凭据、文件、信号、内存和命名空间等上下文。任何一步失败,都要按相反顺序撤销已分配资源。因此工程上看到 fork() 返回 EAGAIN,并不一定是“系统完全没内存”,还可能触碰用户进程数、cgroup PID 控制或系统线程上限;ENOMEM 则可能来自任务结构、页表或其他内核对象分配失败。
新任务准备完成后,父进程得到子进程 PID,子进程看到返回值为 0。两者从同一条用户态指令之后继续执行,但谁先运行由调度器决定,应用不能假设固定顺序。
三、为什么 fork 后内存通常不会立刻翻倍
如果父进程有几百兆虚拟内存,fork() 若立即逐字节复制,代价会非常高。Linux 使用写时复制,也就是 Copy-on-Write。
创建子进程时,内核主要复制页表结构,让父子页表暂时指向相同物理页,并把原本可写的私有映射处理成只读/COW 语义。只读访问可以继续共享;当任一方尝试写入,CPU 触发写保护页异常,内核在缺页处理路径中分配新页、复制旧内容、修改当前进程的页表映射,之后重新执行写指令。
因此要区分三个量:
这也解释了“刚 fork 很快,随后大量写内存却突然变慢”的现象。若大进程 fork 后子进程不马上 execve(),反而遍历并修改大量内存,就会触发密集 COW 缺页、内存带宽消耗和物理页增长。在内存紧张的 SoC 上,这可能表现为延迟尖峰、直接回收压力,甚至 OOM。
可以用以下接口观察:
/proc//status/proc//maps/proc//smaps_rollup/proc//pagemapperf stat -e page-faults
VmSize 反映虚拟空间大小,不能直接当作独占物理内存;VmRSS、匿名页、共享页和 PSS 才能帮助判断实际占用。
四、文件描述符为什么能被子进程继承
fork() 后,子进程通常获得父进程文件描述符表的副本,但表项指向的打开文件对象可以共享。这样父子进程中的某个 fd 往往引用同一个 open file description,共享文件偏移和部分状态。
这既是 Shell 管道能够工作的基础,也是常见 bug 来源。父进程建立 pipe 后 fork,子进程把管道端重定向到标准输入输出,再执行新程序;如果父子两边没有关闭不需要的端点,读端可能永远等不到 EOF。
另一个关键标志是 FD_CLOEXEC。带有 close-on-exec 的描述符会在成功执行 execve() 时关闭,否则可能泄漏给新程序。服务进程启动第三方工具时,如果忘记设置该标志,监听 socket、设备节点或敏感文件都有可能被意外继承。现代代码通常优先在创建 fd 时原子地使用 O_CLOEXEC、SOCK_CLOEXEC 或 pipe2(O_CLOEXEC),避免多线程环境中“先创建、后设置”之间的竞态窗口。
五、execve 不是创建新进程,而是给当前进程换一套程序
这是整条路径最容易被误解的一点。execve() 成功后,调用者的 PID 通常保持不变,父子关系也没有因为执行新文件而重新建立。变化的是当前任务的用户态程序映像。
内核的 exec 路径会打开目标文件,检查权限和挂载属性,读取文件头,并通过二进制格式处理框架选择对应加载器。ELF 通常进入 load_elf_binary();脚本的 #! 则让内核寻找解释器,再以解释器执行脚本。
随后旧地址空间被替换,新 ELF 的代码段、数据段、堆栈和动态链接器映射被建立,参数 argv 与环境变量 envp 被复制到新的用户栈。入口寄存器和栈指针准备完毕后,任务返回用户态时执行的已经是新程序入口。
流程可以理解为:
同一个 task_struct / 同一个 PID │ ├── 丢弃旧 mm 中的用户映射 ├── 装入 ELF 或脚本解释器 ├── 建立新代码、数据、栈和动态链接器映射 ├── 处理凭据、安全策略和 CLOEXEC fd └── 从新入口返回用户态
所以 execve() 只有失败才会返回。成功时,原来的调用栈和后续 C 代码已经不属于当前地址空间。调试代码中应当写成:调用 execve() 后紧接错误处理,并使用 _exit() 终止子进程,不能把“返回 0”当成成功分支。
多线程进程执行 execve() 时尤其值得注意:最终只留下执行 exec 的那条执行流,新程序从单线程状态开始。这也是为什么复杂服务常使用成熟的 posix_spawn() 或专门子进程管理模块,而不是随意在任意工作线程里 fork/exec。
六、进程退出时,资源不是一次全部消失
用户程序可以从 main() 返回、调用 exit(),也可能被信号终止。用户态 exit() 会执行 C 库注册的清理函数并刷新标准 I/O 缓冲,随后通过退出系统调用进入内核;_exit() 则直接进入内核退出路径,不执行这些用户态收尾。
内核侧围绕 do_exit() 完成清理:记录退出码和资源统计,释放地址空间、文件、目录上下文等引用,处理线程组关系,将子进程重新托管给合适的 reaper,并通知等待者。
但退出任务仍需保留一小部分身份信息,包括 PID、退出状态和统计数据。原因很现实:父进程可能需要知道孩子是正常返回、被哪个信号杀死,还是生成了 core dump。此时它进入 EXIT_ZOMBIE,在 /proc//status 或 ps 中显示 Z。
僵尸进程不是仍在运行,也通常不再占用原来的用户内存;它占用的是等待父进程领取的退出记录和 PID 槽位。
少量短暂僵尸很正常。持续增长才说明父进程没有正确处理 SIGCHLD 或调用 wait() 系列接口。
七、wait 才完成最后一次回收
父进程调用 wait()、waitpid() 或 waitid(),内核检查符合条件的子进程。如果子进程还没发生目标状态变化,调用者可能进入睡眠;子进程退出时会唤醒等待队列,并向父进程发送 SIGCHLD。父进程取得退出状态后,内核才释放最后的任务信息,PID 才可被复用,这一步常称为 reap。
典型判断不能只检查一个整数:
pid_t ret = waitpid(child, &status, 0);if (ret > 0) { if (WIFEXITED(status)) { /* WEXITSTATUS(status) */ } else if (WIFSIGNALED(status)) { /* WTERMSIG(status) */ }}
守护进程若会持续创建子进程,可以在事件循环中处理 SIGCHLD,反复使用 waitpid(-1, &status, WNOHANG),直到没有更多已退出子进程。只调用一次可能漏掉同时退出的多个孩子。
在容器 PID 命名空间中,PID 1 还承担收养和回收孤儿的责任。把一个完全不处理子进程的普通应用直接放到容器 PID 1,运行久了就可能积累僵尸;精简 init 或应用自身必须承担 reaper 职责。
八、嵌入式现场怎样把整条链路看清楚
第一层看用户态系统调用:
strace -f -e trace=clone,clone3,fork,vfork,execve,exit_group,wait4 ./app
-f 会跟踪子进程。常见输出模式是父任务创建子任务,子任务执行 execve(),父任务阻塞在 wait4(),收到 SIGCHLD 后返回。
第二层看任务状态和关系:
ps -eo pid,ppid,stat,comm,wchancat /proc//statuscat /proc//task//children
R 表示运行或可运行,S 是可中断睡眠,D 多为不可中断等待,T 是停止/跟踪,Z 是僵尸。wchan 能提示任务睡在哪个内核函数附近,但要结合栈、tracepoint 和源码判断,不能只凭名称下结论。
第三层看内核事件。系统具备条件时,可以使用 perf trace、ftrace 或 BPF 工具观察 sched_process_fork、sched_process_exec、sched_process_exit 等 tracepoint。这样无需在业务代码中插日志,就能得到 PID、父子关系和时序。
如果板上出现“启动偶发慢”,应把问题分层:
fork()execve() 慢:检查存储 I/O、ELF 依赖、动态链接、签名/安全钩子和缺页;- 父进程等待久:确认孩子是真没退出,还是 pipe、锁或设备 I/O 阻塞;
- 僵尸增多:找到 PPID,检查
SIGCHLD 与 wait 逻辑; - 内存随后暴涨:观察 COW 缺页,确认 fork 后是否修改大量匿名内存。
九、几个常见误区,一次纠正
误区一:fork 会立即复制父进程全部物理内存。
通常不是。地址空间语义被复制,但大量私有页先通过 COW 共享;页表复制和后续写入仍有真实成本。
误区二:exec 会产生新的 PID。
通常不会。它替换当前任务的程序映像,PID 连续性正是 Shell、服务管理器和调试器追踪进程的基础。
误区三:进程 exit 后所有东西都立刻没了。
大部分资源会释放,但退出记录要等父进程 wait 后才最终回收。
误区四:僵尸占了大量内存,所以系统内存不足。
僵尸主要占 PID 和少量内核记录。真正风险是数量持续增长,耗尽 PID 或暴露父进程管理缺陷。
误区五:多线程进程 fork 后,孩子拥有父进程所有线程。
孩子只保留调用 fork 的线程,却继承了其他线程可能持有锁的内存状态。在 exec 前调用非异步信号安全函数,可能碰到永远无人释放的锁。
十、读内核源码时,不要从头逐行硬啃
这条生命周期跨越多个文件,适合先按“对象发生了什么变化”建立索引,再追具体版本。创建阶段从 kernel/fork.c 的 kernel_clone()、copy_process() 入手,重点观察 copy_mm()、copy_files()、copy_fs()、copy_sighand() 等函数如何根据 clone 标志选择复制或共享;新任务进入调度器时,再跟到 wake_up_new_task()。
执行新程序主要看 fs/exec.c。先找到 do_execveat_common() 如何准备 linux_binprm,再看二进制格式处理器怎样被选择。ELF 的核心加载逻辑位于 fs/binfmt_elf.c,脚本解释器处理则可查看 fs/binfmt_script.c。阅读时应特别留意“旧地址空间何时不可恢复”:exec 前半段失败还能把错误返回给旧程序,跨过关键提交点后,旧程序映像已经无法继续。
退出和回收主要追 kernel/exit.c。do_exit() 负责退出当前任务,do_group_exit() 处理线程组退出语义,wait 系列路径则负责匹配子进程状态并完成最终释放。不同内核版本的辅助函数名和拆分方式可能调整,因此不要死记某一版本的完整调用栈,应把稳定概念记牢:任务创建、资源共享、映像替换、退出通知和父进程回收。
自己做实验时,可以写一个不足百行的小程序:父进程分配并修改一块匿名内存,fork 后分别打印 PID 与页错误计数,子进程再 exec 一个小工具,父进程用 waitpid 读取状态。配合 strace -f、perf stat 和 /proc 快照,抽象源码会立即变成可观察的时间线。
十一、最后记住这一条生命周期
Linux 进程的一生,可以用四个动作概括:
fork/clone:创建新的任务关系,选择复制或共享资源execve:保留任务身份,替换用户态程序映像exit:释放大部分资源,留下可领取的退出状态wait:父进程领取状态,内核完成最终回收
把这四步分清,就不再会把“进程”“程序文件”“地址空间”和“PID”混为一谈。再遇到启动慢、内存涨、子进程挂死或僵尸积累时,也能沿着任务对象、地址空间、文件描述符、状态通知和父子回收关系逐层定位,而不是只盯着一条 fork failed 或 process exited 日志猜原因。