5. 进程的创建、执行与回收
进程是操作系统分配资源与进行安全保护的基本单位。从用户空间的角度审视,进程不仅拥有独立的虚拟地址空间、文件描述符表与信号处理表,还维系着一张由父子关系构成的树状拓扑。在 UNIX 与 POSIX 标准的设计谱系中,新程序的诞生并未采取单一步骤的“创建并运行”接口,而是将其解耦为两组正交的经典机制:首先利用 fork 复制当前进程的执行上下文与资源拓扑,再由子进程借助 exec 函数族将自身的地址空间完全重写为全新的可执行映像;待子进程终止后,父进程通过 wait 或 waitpid 收集其终结状态并回收残留的元数据结构。本篇将系统化梳理进程从派生(fork / posix_spawn)、写时复制(COW)、重载执行(exec)、到退出终止与僵尸进程回收的完整生命周期。
1. 进程创建
在 POSIX 规范中,创建新进程的标准系统调用是 fork()。从语义上看,fork 会在操作系统中完整分裂出一个新的执行实体,称为子进程。子进程在诞生的一瞬间,几乎完整继承了父进程的代码段、数据段、堆栈以及打开文件描述符列表。
最为精妙的是 fork 的“一次调用,两次返回”机制:
- 在子进程中,
fork返回 0(子进程可以通过getppid()查询父进程的 PID); - 在父进程中,
fork返回新创建的子进程的进程标识符(PID,大于 0 的整数); - 若因系统进程数达到上限或物理内存耗尽而失败,
fork在父进程中返回 -1,并设置相应的errno(如EAGAIN或ENOMEM)。
1.1 fork 的资源和内存
在虚拟内存体系中,不同进程原则上享有彼此隔离的地址空间。早期的 UNIX 内核在执行 fork 时采取直观而笨拙的物理拷贝策略——完整复制父进程的所有物理页。随着应用程序规模扩大,这种全量拷贝的开销变得不可接受,更何况绝大多数系统程序在 fork 之后紧接着便会调用 exec 重载新映像,之前耗费大量 CPU 时钟复制的物理页几乎瞬间就会被丢弃。
现代操作系统(包括 Linux)全面引入了写时复制(Copy-on-Write, COW)机制。在调用 fork 的当下,内核并未真正分配新的物理页框,而是直接将父进程的页表项原样复制给子进程,并将所有可写虚拟页的权限全部降级标记为“只读”(并在内核 VMA 元数据中标记该段为写时私有共享)。此时,父子进程的虚拟页实际上同时映射至相同的物理页框上。直到其中任意一方尝试向这些页面执行写入操作时,CPU 的 MMU 硬件捕获到只读保护违例并触发缺页异常(Page Fault);内核接管后识别出 COW 标记,此时才现场分配一个新的物理页框,将原页内容拷贝进去,重新绑定发生写入方的页表并恢复其写权限。借助分页式硬件的协作,fork 的创建开销被压缩到仅需复制页表结构,耗时极短且极度节省物理内存。
在其他系统资源方面,子进程会默认继承父进程所持有的绝大多数上下文,其中最具代表性的便是文件描述符表:
int fd=open(...);//打开文件
pid_t p=fork();
if(p==0){
write(fd,...);//写入一些东西
}2
3
4
5
正如第 2 章第 5 节所述,fork 复制的是进程的描述符表项,而这些表项依然共同指向内核中相同的“打开文件描述(Open File Description)”。这意味着父子进程不仅共享底层的读写权限,更共享同一个文件偏移量指针。这一共享特性在历史上曾催生出早期的 CGI 脚本服务(主进程绑定监听网络套接字,通过 fork 派生子进程后复用该套接字直接处理 HTTP 请求)。
但描述符的隐式继承也带来了严重的泄漏风险:在复杂的服务端架构中,父进程为了内部通信可能打开了大量管道、私有文件或监听套接字,这些属于内部状态的句柄若在 fork 之后无意中泄漏给未来的外部子程序,轻则导致管道另一端由于写端未完全关闭而永远无法读到 EOF,重则引发越权通信漏洞。要规避此类泄漏,通常有两种工程做法:要么在派生出子进程后、执行 exec 之前,在子进程代码中显式遍历并 close 掉无用描述符;要么(更推荐的做法)在父进程最初打开文件时,直接赋予 O_CLOEXEC 标志(或在事后通过 fcntl 设置 FD_CLOEXEC),确保它们在新映像装载时由内核自动关停。
1.2 多线程程序的 fork
在同时包含多线程与多进程的混合编程模型中,fork 的引入往往会带来极为险峻的死锁与数据不一致陷阱。POSIX 规范明确指出:当一个多线程进程调用 fork 时,内核在子进程中仅会复制发起该调用的单一线程,其余所有的并发线程在子进程中全部蒸发。
这一不对称行为会诱发一个致命场景:锁状态的不一致性。考虑如下经典执行时序:
- 进程 A 内部运行着两个并发线程,分别记为线程 B 与线程 C;
- 线程 B 正在执行内存分配
malloc()。为了在多线程环境下保证内部空闲链表与堆元数据的安全,运行库的内存分配器(Allocator)在入口处获取了全局互斥锁(记为互斥锁 M); - 恰在此时,操作系统的时间片调度发生了上下文切换,调度器切至线程 C 执行;
- 线程 C 随即调用了
fork()系统调用,内核克隆出子进程 D; - 在子进程 D 的内存映像中,线程 B 并未被复制存在,但整个地址空间的内存数据(包括全局互斥锁 M 的内部状态字段)却被完整延续了下来——即在 D 看来,互斥锁 M 当前处于“已被持有”的锁定状态,而持有它的合法线程却只存在于原本的父进程中,在子进程的世界里根本不存在;
- 紧接着,子进程 D 自身也尝试调用
malloc()。由于互斥锁 M 处于锁定状态,D 只能陷入阻塞等待该锁释放。然而在子进程中没有任何线程会再去调用free()或解锁函数,子进程 D 随即陷入永久死锁。
基于这一严峻机理,POSIX 提出了极为严苛的标准契约:在多线程程序中调用 fork 之后,在成功调用 exec 系列函数彻底换掉内存映像之前,子进程中只允许安全调用异步信号安全函数(Async-Signal-Safe Functions)。诸如 malloc、free、printf 等内部依赖互斥锁或非重入全局状态的标准库函数,在 fork 之后、exec 之前均属于严禁触碰的未定义行为危险区。
1.3 posix_spawn
鉴于多线程环境下 fork 的高危陷阱以及某些不支持 MMU 的嵌入式架构对写时复制的天然匮乏,POSIX 标准引入了 posix_spawn 与 posix_spawnp 接口。该系列函数将传统的“fork + 配置描述符 + exec”三步操作聚合为一个原子化的受控调用,在概念设计上更加贴近 Windows 体系下的 CreateProcess。通过传入专用的属性对象(posix_spawnattr_t)与文件操作动作清单(posix_spawn_file_actions_t),调用者可以声明式地指定在新进程启动前需要重定向哪些描述符、屏蔽哪些信号,从而彻底绕开在子进程空间运行危险用户态代码的脆弱窗口。
1.4 进程树和孤儿进程
在 POSIX 系统中,所有活动进程均组织在一棵严格的层次化树状结构中(进程树)。除开系统启动时内核直接创建并置于根部的初代进程(通常 PID 为 1,在现代 Linux 系统中普遍为 systemd,历史上称为 init)之外,任何运行中的进程都拥有唯一的父进程。
当一个父进程先行终止退出,而其派生的子进程仍然在继续执行时,这些子进程便失去了原本的父节点,被称为孤儿进程(Orphan Process)。在 UNIX 的资源管理契约中,运行中的进程绝不允许出现父节点缺失的状态。因此,操作系统的内核调度器会自动介入这一变动,将孤儿进程重新挂载到上层的系统根进程(PID 1)或现代服务管理器所配置的子收养进程(Subreaper)之下。这一重新建立父子映射的内核过程,被称为收养(Adoption)。
2. 进程执行
从操作系统的执行视角来看,可执行文件是静态刻录在磁盘上的二进制指令与数据集合,而进程则是该程序载入内存并动态展开的一次活跃执行上下文。
2.1 exec 函数族
如果说 fork 的本质是克隆当前上下文,那么 exec 函数族的使命则是彻底重塑上下文。exec 系统调用的核心机制,在于用全新的目标可执行文件映像,直接且彻底地覆盖掉当前进程的整个虚拟地址空间(包括代码段、数据段、堆栈与动态内存映射),并从新程序的标准入口点重新开始执行。原进程在执行 exec 之后,原本的代码逻辑与数据全部泯灭,但该进程的 PID 保持不变。
POSIX 提供了六个主要变体,它们在函数命名上遵循清晰的助记符规范:
- 字母
v(Vector)与l(List):决定了命令行参数的传递形式。v表示参数通过以 NULL 结尾的字符串指针数组传递(char *const argv[]);l表示通过变长参数列表逐个展开传递,并在末尾追加 NULL 哨兵; - 字母
p(PATH):表示若给定的程序路径不包含斜杠/,系统会自动遍历操作系统的PATH环境配置项去检索定位该可执行文件;若无p则必须提供精确的相对路径或绝对路径; - 字母
e(Environment):表示末尾显式接收一个传递进程环境配置的字符串指针数组(char *const envp[]);若无e则子程序默认继承当前进程所持有的全局环境表。
在 Linux 体系中,上述上层包装最终均统一汇聚至内核底层的核心系统调用 execve。当 execve 启动时,内核首先解析可执行文件的头部格式(在 Linux 上通常为 ELF 格式)。若目标程序属于动态链接程序,内核会将 ELF 解释器(即动态链接器,如 /lib64/ld-linux-x86-64.so.2)率先装载至地址空间,由链接器解析依赖的动态共享对象并执行重定位,随后初始化环境表与命令行参数数组,最后才正式将 CPU 指针转移至程序入口 main 函数。
一旦 exec 成功执行,原进程的全部内存段(包括此前的静态数据、堆分配与共享内存映射)都将被彻底覆写回收;与此同时,所有带有 FD_CLOEXEC(close-on-exec)标志位的文件描述符都会被内核自动切断,未带该标志的描述符则原样保留给新程序继续访问。
2.2 进程调度
在计算机科学的早期发展中,部分教学体系常将 CPU 调度策略置于进程维度展开阐述。而在当代多任务多核操作系统(如 Linux)的现实设计中,资源分配的基本单位是进程,但操作系统内核调度的最小实体则是线程(Task/Thread)。关于抢占式调度、优先级时间片以及线程同步的底层机理,将在第 8 章《操作系统线程与同步》中全面展开。
2.3 进程退出
当进程的业务逻辑处理完毕后,它需要以可预测的方式平稳终结。在标准 C 语言中,最普遍的退出方式是自 main 函数返回或者显式调用标准库函数 exit()。除此之外,C 标准还允许通过 atexit() 注册一组清理钩子函数。
调用 exit() 属于用户态的高级正常退出,它首先会步入 libc 内部的运行时清理流程:
- 按照注册相反的顺序依次触发执行所有由
atexit()注册的钩子函数; - 刷新所有标准 I/O 流的缓冲区(如未落盘的
stdout),并平稳关闭所有打开的FILE *流; - 删除通过
tmpfile()创建的临时文件; - 最后,调用底层的系统调用
exit_group()或_exit(),将终结信号递交给内核。
相比之下,直接调用系统级退出函数 _exit()(或 _Exit())则会立即切断用户态逻辑,直接陷入内核态终止进程,中途绝不会执行 atexit 钩子,也绝不会刷新 stdio 内存缓冲区。
在操作系统底层,进程退出必须经历专门的退出系统调用,以通知内核回收其用户态占用的页表、虚拟内存与物理设备映射。如果一个编译后的汇编程序在代码末尾缺少退出调用,CPU 的指令指针(RIP)便会顺延滑向下一行未定义的未知内存区域,最终由于抓取到非法操作码或触碰非可执行内存页而爆出 SIGILL(非法指令)或 SIGSEGV(段错误)。同时该系统调用接受一个整数参数,main 函数要求返回一个 int 值,核心原因便在于将这一状态码传递给内核。
此外,进程还可能因遭遇外部信号而异常终止。最典型的代表便是强制终止信号 SIGKILL(以及由内核抛出的硬件故障信号如 SIGSEGV)。SIGKILL 属于完全无法被用户代码捕获、也无法被忽略的特权内核信号。一旦向目标进程投递 SIGKILL,内核会在上下文切换的第一时间直接销毁该进程的执行实体,绝不给用户态 libc 任何执行缓冲清理或退场钩子的机会。需要厘清的是,被信号终止的进程本身并不存在常规意义上的业务退出码,Shell 在终端提示符中反馈的诸如 137 的退出状态,实际上是由 Shell 自身依照 UNIX 惯例计算出的合成状态码(基数 128 加上信号编号 9)。
3. 进程回收
当一个进程步入终止流程后,其所占用的页表结构、私有物理内存页、打开文件描述符及 CPU 运行队列调度节点均会被内核迅速剥离并回收。
3.1 进程终结后的元数据残留
然而,操作系统并不能在进程退出的同一瞬间彻底销毁该进程的所有记录。在多进程协同的任务设计中,父进程往往急切需要获知子进程的终结状态:子进程究竟是正常退出还是因信号崩溃?若是正常退出,其传递给 exit() 的退出状态码是多少?若是被信号杀死,诱发致命异常的信号编号又是什么?
为了将这些至关重要的事后诊断信息保留给父进程读取,内核在子进程终结后,会保留一个极小的元数据外壳(主要包括其进程控制块中的退出状态码、CPU 资源消耗统计以及该进程占用的 PID 标识符)。这部分残留的元数据无法在子进程自身调用 exit 时单方面释放,必须耐心驻留,直到父进程通过系统调用正式认领。
3.2 僵尸进程与 wait 机制
子进程已经终结、但其父进程尚未调用系统调用认领其终结状态的这一特殊中间状态,被称为僵尸进程(Zombie Process)。在 ps 或 top 等系统诊断工具中,此类进程的执行状态会明确标记为 Z。
POSIX 体系定义了 wait 与 waitpid 系统调用,专门用于让父进程收集子进程的遗言并完成最终的墓葬回收:
wait(&status):令父进程阻塞休眠,直到任意一个子进程终止退出为止;waitpid(pid, &status, options):提供了细粒度的控制能力。它不仅能够精确等待指定的子进程,还能通过传入WNOHANG标志实现非阻塞轮询,避免父进程的主线程被拖垮。
一旦父进程通过 wait 或 waitpid 成功提取了状态码,内核便会立即注销该子进程的残存控制块,原本被锁定的 PID 编号也随之重返可用池。
在系统软件中,产生僵尸进程的根源几乎无一例外是代码层面的逻辑缺陷:父进程长期运行却遗漏了对子进程退出事件的监听与回收。当子进程退出时,内核实际上会向其父进程派发一个 SIGCHLD 异步通知信号;成熟的服务端程序通常会为此信号注册信号处理函数,在异步通知到达时循环调用带有 WNOHANG 标志的 waitpid 批量收殓已经死亡的子进程。
如果业务架构中确有“派生后即放手”(Fire-and-Forget)的需求、且父进程完全不关心子进程的执行结果,则可以通过显式设置对 SIGCHLD 信号采取忽略策略(signal(SIGCHLD, SIG_IGN))。依据 POSIX 规范,在此配置下,内核会在子进程退出的瞬间直接代为清理其残留控制块,永远不会生成僵尸进程。
若父进程仅对系统中的某一特定任务希望采取无需看管的脱敏处理,工程界广泛采用一种经典而优雅的双重 fork 技巧(Double Fork):
- 在父进程调用 fork;
- 在子进程中再次调用 fork,在孙进程中执行对应的任务,然后子进程结束;
- 父进程对子进程调用 waitpid。
代码结构大致如下:
pid_t p=fork();
if(p==0){
if(fork()==0){
//执行逻辑上的子进程的操作
}
_exit(0);
}
waitpid(p,0,0);2
3
4
5
6
7
8
这么做的理由是,中间子进程在派生出孙进程之后立刻调用 _exit(0) 自行了断;父进程能够即刻 waitpid 收回中间子进程,整个阻塞时间以毫秒计;而承载核心业务的孙进程则因其直接父进程的暴毙,瞬间转变为孤儿进程并被系统的初代进程(PID 1)收养。当孙进程最终执行结束时,负责收敛其遗留元数据的将直接由根进程托管,父进程彻底免除了长期维护状态的心智负担。
4. 习题
下面这段程序把 fork、标准 I/O 缓冲和 atexit 放在了一起。先不运行,按问题写出预测,再编译验证。
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
static void bye(void) {
printf("bye from %ld\n", (long)getpid());
}
int main(void) {
atexit(bye);
printf("before\n");
pid_t p = fork();
if (p == 0) {
printf("child\n");
exit(0);
}
waitpid(p, NULL, 0);
printf("parent\n");
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- 用
./forkbuf > out.txt运行,out.txt里有几行?按顺序写出来。 - 直接在终端运行,输出有几行?与第 1 问的差别来自哪里?
- 把子进程里的
exit(0)换成_exit(0),再重定向一次,输出变成什么? - 不改
_exit,怎样让每个进程只打印一次before? - 子进程的
bye是在父进程的waitpid返回之前还是之后执行的?两个 PID 各是什么?
验收(一次实测的结果,PID 不同):重定向时六行,依次是 before、child、子进程的 bye、before、parent、父进程的 bye;终端上五行,before 只出现一次,子进程的 bye 在 parent 之前;换成 _exit 后只剩三行。
1.1 节说,不希望被继承的描述符可以「在子进程里 close,或者在父进程用 fcntl 设置属性」。这一题把这句话测出来。
写一个 leakfd:以 O_RDONLY(不加其他标志)打开 /etc/hostname,fork 之后子进程 execlp("sh", "sh", "-c", "ls -l /proc/self/fd", NULL),父进程 waitpid 后退出。命令直接写死,不必做成命令行参数。
- 子进程列出的描述符里有没有指向
/etc/hostname的一项?编号是几? open改成O_RDONLY | O_CLOEXEC,重复实验,子进程还看得到它吗?- 保留
O_CLOEXEC,在fork之前加一行int copy = dup(fd);。子进程里copy还在吗?用/proc/self/fdinfo/<编号>的flags字段解释(八进制,close-on-exec 对应02000000)。 - 把
dup换成fcntl(fd, F_DUPFD_CLOEXEC, 0),结果与第 3 问有什么不同? - 用三句话总结:
fork之后子进程继承什么,exec之后保留什么,dup对 close-on-exec 标志做了什么。
验收:第 1 问子进程能看到 -> /etc/hostname(编号取决于程序此前打开了几个描述符,通常是 3);第 2 问看不到;第 3 问副本可见,且 flags 里没有 02000000;第 4 问不可见。
waitpid 拿到的状态是一个打包过的整数,shell 又把它换算成另一套写法。写一个 status CMD [参数...],把这件事一次测清。
要求:
fork之后子进程execvp(CMD, argv);execvp返回说明执行失败,把errno经一根带O_CLOEXEC的管道交回父进程,子进程以 127 退出;- 父进程
waitpid,正常退出打印exited=N,被信号终止打印signal=N; status自身的退出码:子进程正常退出时原样传出它的退出码,子进程被信号终止时用128 + N;- 用
WIFEXITED、WEXITSTATUS、WIFSIGNALED、WTERMSIG判断,不要直接读状态整数。
验收,四个命令的输出与退出码:
$ ./status true; echo $? exited=0,$? 为 0
$ ./status sh -c 'exit 300'; echo $? exited=44,$? 为 44
$ ./status sh -c 'kill -KILL $$'; echo $? signal=9,$? 为 137
$ ./status no_such_cmd; echo $? exec failed: No such file or directory,$? 为 1272
3
4
再回答三个问题:exit(300) 为什么在父进程里读出来是 44?signal=9 与 exited=137 是不是同一件事,shell 里的 137 是谁算出来的?只看子进程以 127 退出,为什么不能断定是「找不到命令」?
双重 fork 与僵尸回收的对照实验。
(1) [4.3] 写 detach CMD:第一次 fork,父进程 waitpid 后立即返回;子进程再 fork,孙进程把 0、1、2 重定向到 /dev/null 后 execvp(CMD),中间的子进程 _exit(0)。运行 ./detach sleep 30,从 ps 的输出里找到那个 sleep,记录它的 PPID。说明它为什么是 1,以及它结束后由谁回收。
(2) [3.2] 写 reap MODE N:MODE 为 default 时启动 N 个各做 _exit(0) 的子进程,父进程睡两秒;为 ignore 时在 fork 之前调用 signal(SIGCHLD, SIG_IGN)。两种模式都用 ps --ppid PID -o stat= 数状态为 Z 的行数,睡醒后调用 waitpid(-1, &st, WNOHANG),打印返回值与 errno。
(3) [2.1] 把 detach 与本页 1.3 节提到的 posix_spawn 对照:同样启动一个不关心结果的子进程,posix_spawn 要做哪几步,双重 fork 省掉了什么、又多做了什么。两三句话即可。
验收:detach 起的 sleep 的 PPID 为 1;default 模式下 Z 行数为 3、waitpid 返回某个子进程的 PID;ignore 模式下 Z 行数为 0、waitpid 返回 -1 且 errno 为 ECHILD。