2. 进程生命周期:fork/exec/exit 系统调用、进程状态转换、僵尸进程与孤儿进程
进程的生命周期,说白了就是一个程序从「出生」到「死亡」的全过程。我刚开始接触 Linux 内核时,觉得进程不就是跑个程序嘛,有什么复杂的?直到我在生产环境里遇到一次诡异的服务挂死——进程状态卡在 D 状态,杀不掉、重启不了,才意识到理解进程生命周期有多重要。
这一节,我们就把进程的来龙去脉彻底讲清楚。
2.1 进程的诞生:fork 系统调用
Linux 里创建进程的唯一方式是什么?fork。没有别的入口。
你可能会问:「那 exec 呢?它不是也能启动程序吗?」嗯,exec 确实能加载新程序,但它不创建新进程。它只是把当前进程的地址空间替换掉。真正「生娃」的操作,只有 fork。
核心概念:写时复制(Copy-On-Write, COW)
fork 之后,子进程并不会立刻复制父进程的全部内存。父子进程共享同一份物理内存页,只有当其中一方尝试写入时,内核才真正复制该页。这极大减少了 fork 的开销。
我调优过一个 Java 应用,频繁 fork 导致内存占用飙升。后来发现是 COW 触发了大量缺页中断,优化了共享内存布局才解决。
// 一个最简单的 fork 示例
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("我是子进程,PID = %d\n", getpid());
} else if (pid > 0) {
// 父进程
printf("我是父进程,子进程 PID = %d\n", pid);
} else {
perror("fork 失败");
}
return 0;
}
fork 的返回值很有意思:父进程拿到子进程的 PID,子进程拿到 0。这个设计让代码可以「一分为二」,同一个函数里走两条不同的执行路径。
2.2 程序的加载:exec 系列系统调用
fork 出来的子进程,默认和父进程跑同一份代码。但大多数情况下,我们想让子进程去执行一个全新的程序——比如在 shell 里敲 ls,shell 先 fork 出子进程,然后子进程调用 exec 把 ls 的二进制加载进来。
exec 有 6 种变体:execl、execlp、execle、execv、execvp、execvpe。名字看着乱,其实规律很简单:
- 带 l 的:参数以列表形式传入(可变参数)
- 带 v 的:参数以数组形式传入
- 带 p 的:在 PATH 环境变量中搜索可执行文件
- 带 e 的:可以自定义环境变量
// exec 典型用法:fork + exec 组合
pid_t pid = fork();
if (pid == 0) {
// 子进程:执行 ls -l
execlp("ls", "ls", "-l", NULL);
// 如果 exec 失败,才会走到这里
perror("exec 失败");
exit(1);
}
我的经验:exec 调用成功后不会返回。如果 exec 之后的代码还在执行,说明 exec 失败了。记得检查返回值并处理错误。
2.3 进程的终结:exit 系统调用
进程退出有两种方式:
- 正常退出:main 函数 return,或者调用
exit()、_exit() - 异常退出:收到信号(如 SIGKILL、SIGSEGV)
exit() 和 _exit() 有什么区别?
| 函数 | 行为 | 使用场景 |
|---|---|---|
exit() |
先执行 atexit 注册的清理函数,刷新 stdio 缓冲区,再调用 _exit | 用户态程序正常退出 |
_exit() |
直接进入内核,不做任何清理 | 子进程 fork 后立即退出,避免清理父进程资源 |
我曾经在调试一个内存泄漏问题时发现,子进程退出后父进程没有调用 wait(),结果子进程变成了僵尸进程。嗯,这里就要说到进程状态转换了。
2.4 进程状态转换
Linux 进程有 5 种基本状态,我用一张图来展示它们之间的转换关系。
这张图里有个状态特别值得注意——不可中断睡眠(D 状态)。进程在等待某些内核资源(比如磁盘 I/O)时会进入这个状态,此时任何信号都叫不醒它。我遇到过数据库进程卡在 D 状态超过 30 分钟,最后只能重启机器。
2.5 僵尸进程与孤儿进程
这两个概念是面试高频题,也是实际运维中经常踩的坑。
僵尸进程
进程调用 exit() 退出后,内核会保留它的 PCB(进程控制块),直到父进程调用 wait() 或 waitpid() 来读取子进程的退出状态。在这个「已退出但未被回收」的间隙里,进程就是僵尸状态。
僵尸进程不占用 CPU 和内存(除了 PCB 那一点点空间),但它会占用 PID 号。Linux 的 PID 数量是有限的(默认 32768),如果僵尸进程堆积太多,系统就无法创建新进程。
避坑指南:我曾经接手过一个 Java 服务,每处理一个请求就 fork 一个子进程,但父进程忘记调用 wait()。运行一周后,系统报错 "Cannot fork: Resource temporarily unavailable"。一查,PID 被几万个僵尸进程吃光了。
如何避免僵尸进程?
- 父进程主动 wait():最直接的方式
- 信号处理:父进程注册 SIGCHLD 信号处理函数,在 handler 里调用 waitpid()
- 双重 fork:让子进程再 fork 一个孙进程,然后子进程立即退出,孙进程被 init 进程收养
// 信号处理方式避免僵尸进程
void sigchld_handler(int sig) {
// 非阻塞回收所有已退出的子进程
while (waitpid(-1, NULL, WNOHANG) > 0);
}
int main() {
signal(SIGCHLD, sigchld_handler);
// ... 正常 fork 逻辑
}
孤儿进程
如果父进程先于子进程退出,子进程就成了孤儿。孤儿进程会被 init 进程(PID=1)收养,由 init 负责回收。所以孤儿进程不会变成僵尸——这是内核的兜底机制。
但这里有个坑:如果父进程退出时没有关闭文件描述符、没有释放锁,子进程可能继承这些资源,导致资源泄漏。我建议在父进程退出前显式清理,不要依赖孤儿机制。
2.6 实战:用 strace 观察进程生命周期
理论说完了,我们来点实际的。用 strace 可以跟踪系统调用,亲眼看看 fork、exec、exit 是怎么工作的。
# 跟踪一个简单的 ls 命令
$ strace -f -e trace=clone,execve,exit_group ls
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD) = 12345
# 这里 clone 就是 fork 的底层实现
execve("/usr/bin/ls", ["ls"], 0x7ffc...) = 0
# 子进程加载 ls 程序
exit_group(0) = ?
# 进程退出
+++ exited with 0 +++
我的习惯:排查进程问题时,我第一件事就是 strace -p <PID>,看看进程卡在哪个系统调用上。如果是 read() 调用阻塞,大概率是 I/O 问题;如果是 futex() 阻塞,可能是锁竞争。
进程生命周期看似简单,但每个状态转换背后都涉及内核调度器、内存管理、文件系统等多个子系统的协作。理解这些基础,后面讲调度策略、CFS、实时进程时,你才能游刃有余。