Linux 进程创建与终止全解析:fork 原理 + 退出机制实战
Linux 中进程创建和终止是操作系统最核心、最基础的行为之一,理解清楚这两个过程,对理解进程管理、资源分配、僵尸进程、孤儿进程、wait/waitpid、信号处理等都至关重要。
本文将从原理到实践,完整梳理 Linux 进程的创建(主要是 fork)与终止(_exit / exit / return / 信号)全流程。
一、进程创建的核心机制:fork / vfork / clone
Linux 中创建新进程最常用的系统调用是 fork(),现代实现中还有 vfork() 和 clone()(后者是 glibc 和 pthread 的底层)。
1. fork() 是如何工作的?
fork() 的核心语义是:“复制当前进程,得到一个几乎完全相同的子进程”。
- 调用一次,返回两次(这是 fork 最经典的描述)
- 父进程返回子进程的 pid(>0)
- 子进程返回 0
- 出错返回 -1
fork 后父子进程的内存布局对比(关键点)
| 内容 | 父进程 | 子进程 | 说明 |
|---|---|---|---|
| 代码段 (text) | 相同 | 相同(共享) | 现代 Linux 使用写时复制(COW) |
| 数据段 (data/bss) | 拷贝一份 | 拷贝一份 | 写时复制:父子首次都只读,写时才真正复制页面 |
| 堆 | 拷贝一份 | 拷贝一份 | 同上,写时复制 |
| 栈 | 拷贝一份 | 拷贝一份 | 子进程有独立的栈,但初始内容几乎相同 |
| 打开的文件描述符 | 复制(共享同一文件表项) | 复制 | 父子共享打开文件的偏移量 |
| 进程 ID (pid) | 不变 | 新的(由内核分配) | 子进程获得新的 PID |
| 父进程 ID (ppid) | 不变 | 变为调用 fork 的进程 ID | 子进程的父进程变为 fork 的调用者 |
| 信号处理方式 | 复制 | 复制 | 但未决信号集被清空 |
| 内存映射(mmap) | 复制映射描述 | 复制 | 共享映射仍然共享,私有映射写时复制 |
写时复制(Copy-On-Write, COW) 是现代 fork 高效的关键:
- fork 刚完成时,父子进程的页表指向相同的物理页面(只读)
- 任何一个进程写内存时,触发页面错误 → 内核复制一份物理页面 → 修改页表指向新页面
- 这使得 fork 本身非常快(只需要复制页表和少量内核结构)
2. vfork() 与 fork() 的区别
vfork() 是 fork 的一个变种,专为 “立刻 exec” 的场景设计。
| 特性 | fork() | vfork() |
|---|---|---|
| 子进程是否复制父进程地址空间 | 是(写时复制) | 否(共享父进程地址空间) |
| 子进程能否修改父进程变量 | 是(写时复制后独立) | 父进程阻塞,子进程修改会影响父进程 |
| 子进程是否能返回父进程继续执行 | 可以 | 不允许(必须 exec 或 _exit) |
| 父进程何时继续执行 | fork 返回后立即继续 | 等待子进程 exec 或退出 |
| 典型使用场景 | 普通创建子进程 | 紧接着 exec 家族函数 |
vfork 的使用场景(现已较少使用):
if (vfork() == 0) {
// 子进程
execl("/bin/ls", "ls", "-l", NULL);
_exit(1); // 一定不能 return
}
现代建议:大多数情况下直接用 fork() + exec 即可,内核已经高度优化。
3. clone() —— pthread 和容器技术的底层
clone() 是 Linux 提供的最底层进程创建接口,fork() 和 vfork() 都是基于 clone 实现的。
int clone(int (*fn)(void *), void *child_stack,
int flags, void *arg, ...);
flags 最关键,可以控制共享哪些资源:
- CLONE_VM:共享虚拟内存(线程)
- CLONE_FILES:共享打开文件表
- CLONE_FS:共享文件系统信息(根目录、工作目录)
- CLONE_SIGHAND:共享信号处理方式
- CLONE_THREAD:创建线程(LWP)
- CLONE_PARENT_SETTID / CLONE_CHILD_SETTID:设置 tid 地址
Docker / LXC / runc 容器 就是大量使用 clone() + 各种 namespace + cgroups 实现的。
二、进程终止的完整机制
Linux 中进程终止的方式有很多,但底层都最终走到 do_exit()。
1. 用户态常见的退出方式
| 方式 | 实际调用链 | 是否执行 atexit / on_exit | 是否刷新 stdio 缓冲区 | 是否调用信号 SIGCHLD |
|---|---|---|---|---|
return 从 main | exit() | 是 | 是 | 是 |
exit(int status) | exit() → _exit() | 是 | 是 | 是 |
_exit(int status) | 系统调用 exit_group / exit | 否 | 否 | 是 |
_Exit(int status) | 同 _exit | 否 | 否 | 是 |
| 被信号杀死 | 内核直接 do_exit | 否 | 否 | 是 |
2. 进程退出时的核心动作(do_exit)
内核函数 do_exit() 做以下事情(简化版):
- 设置进程状态为 TASK_ZOMBIE(僵尸态)
- 释放大部分资源(文件描述符、内存映射、信号处理等)
- 把退出码保存到 task_struct->exit_code
- 通知父进程(发送 SIGCHLD)
- 如果父进程不 wait,则进入僵尸状态
- 调度器不会再调度该进程,但 task_struct 保留直到父进程 wait
3. 僵尸进程、孤儿进程、wait/waitpid
| 术语 | 定义 | 如何产生 | 如何消除 |
|---|---|---|---|
| 僵尸进程 | 已终止但父进程未 wait,task_struct 仍存在 | 子进程 exit,父未 wait | 父进程 wait / waitpid |
| 孤儿进程 | 父进程先终止,子进程仍在运行 | 父进程先 exit | init(pid=1)或 kthreadd 收养 |
| 僵尸+孤儿 | 父进程先 exit,子进程再 exit,但 init 未及时 wait | 极端情况 | init 会回收 |
wait / waitpid 核心区别
pid_t wait(int *wstatus); // 等待任意子进程
pid_t waitpid(pid_t pid, int *wstatus, int options);
pid > 0:等待指定 pid 的子进程pid = 0:等待同一进程组的任意子进程pid = -1:等待任意子进程(等价于 wait)pid < -1:等待进程组 |pid| 的任意子进程
常用 options:
- WNOHANG:非阻塞
- WUNTRACED:报告停止的子进程(SIGSTOP)
三、实战代码示例
1. 普通 fork + wait
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
exit(1);
}
if (pid == 0) {
// 子进程
printf("我是子进程,pid=%d, ppid=%d\n", getpid(), getppid());
sleep(1);
exit(42);
} else {
// 父进程
printf("我是父进程,pid=%d, 子进程pid=%d\n", getpid(), pid);
int status;
pid_t wpid = waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("子进程正常退出,退出码:%d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("子进程被信号杀死,信号:%d\n", WTERMSIG(status));
}
}
return 0;
}
2. 僵尸进程演示
int main() {
pid_t pid = fork();
if (pid == 0) {
printf("子进程 pid=%d 退出\n", getpid());
exit(0);
}
// 父进程不 wait,进入死循环
while (1) sleep(1);
}
运行后 ps aux | grep Z 即可看到僵尸进程。
四、总结:最核心的几句话
- fork 是复制进程,现代 Linux 使用写时复制,开销很小。
- exec 家族函数替换进程映像(加载新程序)。
- 进程结束最终调用 do_exit,变成僵尸态。
- 父进程必须 wait / waitpid 回收子进程,否则产生僵尸进程。
- 父进程先死,子进程成为孤儿进程,会被 init(pid=1)收养。
如果你想继续深入某个具体方向(例如:多级管道实现、信号在 fork 中的继承与重置、exec 家族完整对比、clone 系统调用的 flags 组合、僵尸进程危害与清理、waitpid 的 options 使用技巧等),可以直接告诉我,我可以继续展开详细代码与讲解。