
观众老爷们大家好 这里是邪修KING的独家频道 本文属于系列Linux系统篇 ------操作指令 一起学Linux的小伙伴可订阅专栏: Linux系统篇 上一篇我们讲透了进程的 R/S/D/T/t 五大状态、内核链表设计、PCB 与队列的底层逻辑,最后留下了两个特殊状态:Z 僵尸进程 和孤儿进程 。
很多人只知道 "僵尸进程不好""孤儿进程没人管",但很少讲透本质:子进程都退出了,内核为什么不直接回收资源?僵尸进程到底泄漏了什么内存?孤儿进程最后会怎么样?
本篇我们从进程退出的设计初衷讲起,结合代码模拟、状态查看、内核 SLAB 机制,把这两个特殊进程彻底讲透。
前置思考:进程退出,就是彻底消失吗?
很多初学者以为:进程执行完 return 0,就像程序关闭一样,所有东西全部释放,干干净净。
但 Linux 的设计不是这样。
子进程退出时,用户态的代码、数据、栈会全部释放 ,但内核里的 PCB(task_struct)不会立刻删除 。内核会保留进程的退出码、退出原因、运行统计等信息,等着父进程来读取回收。
如果父进程一直不来读,这个 PCB 就会一直残留在内核里,变成 ------僵尸进程。
如果反过来,父进程先退出了,子进程还在运行,子进程就没了 "家长",变成 ------孤儿进程。
一、僵尸进程(Zombie Process)
1.1 什么是僵尸进程?
1.1.1 定义与产生原因
僵尸进程 :子进程已经执行完毕退出,但父进程没有调用 wait() / waitpid() 回收子进程的退出信息,导致子进程的 PCB(task_struct)一直残留在内核中,进程状态标记为 Z。
💡 设计初衷:为什么不直接删掉 PCB?
子进程退出不是悄无声息消失。父进程创建了子进程,通常需要知道子进程是正常结束还是异常崩溃、退出码是多少、运行了多久。
所以内核特意保留 PCB 里的退出信息,等父进程主动来取。父进程取走信息,内核才会销毁 PCB,完成回收。
父进程一直不取,PCB 就一直留着,这就是僵尸进程的来源。
1.1.2 退出信息存在哪里?
全部存在子进程的 task_struct 里面:
exit_code:进程退出码,比如 return 0 就是 0,异常退出是对应信号编号exit_signal:发给父进程的退出信号- 进程运行时间、内存使用统计等信息
父进程调用 wait() 时,内核就把这些信息返回给父进程,然后释放 PCB。
1.2 僵尸进程的危害:内核内存泄漏
1.2.1 为什么会引发内存泄漏?
很多文章说 "僵尸进程泄漏内存",但说得很模糊。这里明确:
✅ 泄漏的是内核内存 ,不是用户态内存。
❌ 子进程的用户态代码、数据、栈已经全部释放了,这部分内存已经还给系统了。
泄漏的是什么?
是内核里的 task_struct 结构体、内核栈、相关的内核数据结构。这些是内核空间的内存,用户碰不到,也不会自动释放。
单个 PCB 不大,也就几 KB,但如果父进程不断创建子进程又不回收,僵尸越积越多,内核内存会被慢慢吃光,最终导致系统无法创建新进程。
1.2.2 进程退出了,内存泄漏还在吗?
在。只要父进程还活着、不调用 wait,僵尸 PCB 就一直占着内核内存。
只有两种情况僵尸会消失:
- 父进程调用 wait/waitpid,主动回收
- 父进程也退出了,僵尸进程变成孤儿僵尸,由 1 号进程领养回收
1.3 观察与模拟验证 Z 状态
1.3.1 编写模拟代码
思路:父进程 fork 出子进程,子进程立刻退出;父进程死循环不做任何回收操作。
c
// zombie.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t pid = fork();
if (pid < 0) {
perror("fork fail");
return 1;
}
else if (pid == 0) {
// 子进程:打印pid后立刻退出
printf("[子进程] pid=%d,执行完毕,即将退出变成僵尸\n", getpid());
exit(0);
}
else {
// 父进程:死循环休眠,不回收子进程
printf("[父进程] pid=%d,运行中,不回收子进程\n", getpid());
while(1) {
sleep(1);
}
}
return 0;
}
1.3.2 编译运行与状态识别
# 编译
gcc zombie.c -o zombie
# 后台运行,方便开另一个终端查看
./zombie &
新开终端执行:
ps aux | grep zombie
你会看到两行:
- 父进程:STAT 为
S,正常休眠 - 子进程:STAT 为
Z+,后面标注defunct(已死亡),这就是僵尸进程。
验证:子进程的代码数据已经释放了,但 PCB 还在,所以 ps 能看到这个进程,但它已经不运行了。
1.3.3 回收僵尸
杀掉父进程,僵尸就会被 1 号进程领养回收:
bash
kill 父进程PID
再 ps 查看,僵尸进程消失。
1.4 深度硬核拓展:内核结构的申请与 SLAB 技术
1.4.1 什么是 SLAB 技术?
进程是系统里最高频创建销毁的对象之一。如果每次创建进程都向内存申请一块空间存 task_struct,销毁就释放,会有两个问题:
- 频繁申请释放,内存碎片越来越多
- 申请释放的系统开销大,性能低
Linux 内核用 SLAB 分配器 解决这个问题:
SLAB 就是内核的「对象缓存池」。针对 task_struct 这种高频使用的内核对象,内核提前申请一批连续内存,切成一个个和 task_struct 一样大的 "槽位",缓存起来。
- 创建进程:直接从 SLAB 池里拿一个空槽位,不用重新申请内存
- 销毁进程:把 PCB 放回 SLAB 池,标记为空,不真正释放内存
下次再创建进程,直接复用槽位。
1.4.2 数据结构对象的缓存机制
SLAB 的核心思想是对象复用 ,类似线程池、连接池的思路。
不止 task_struct,内核里的 inode、file 结构体等高频对象,都有自己的 SLAB 缓存池。
这也是为什么僵尸进程泄漏是个问题:僵尸占着 SLAB 里的槽位,池里可用槽位越来越少,新进程创建就会变慢甚至失败。
二、孤儿进程(Orphan Process)
2.1 什么是孤儿进程?
2.1.1 定义与产生原因
孤儿进程:父进程先退出、终止了,子进程还在运行,失去了父进程,就叫孤儿进程。
2.1.2 谁来托底?一号进程的领养机制
Linux 不会让子进程没人管。父进程退出后,内核会自动给子进程找新的 "养父"------1 号进程(systemd /init) 。
所有孤儿进程都会被 1 号进程领养,之后孤儿进程退出,由 1 号进程负责调用 wait 回收它的 PCB,不会变成僵尸。
💡 为什么是 1 号进程?
1 号进程是系统启动的第一个进程,是所有进程的祖宗。它的职责之一就是 "收尸",领养所有孤儿,回收它们的退出资源,保证系统没有漏网的僵尸。
2.1.3 认识 1 号进程:systemd
现在主流 Linux 发行版(Ubuntu、CentOS7+)的 1 号进程都是 systemd。
它不仅是领养孤儿的大家长,还负责管理系统服务、开机启动、守护进程等,是用户空间的总管。
bash
# 查看1号进程
ps aux | head -2
PID=1,COMMAND 为 /sbin/init 或者 /lib/systemd/systemd。
2.2 观察与模拟验证孤儿进程
2.2.1 编写模拟代码
思路:父进程 fork 后,sleep 1 秒就退出;子进程 sleep 5 秒才退出,中间观察父进程退出后子进程的 PPID 变化。
c
// orphan.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t pid = fork();
if (pid < 0) {
perror("fork fail");
return 1;
}
else if (pid == 0) {
// 子进程:活5秒,前后打印父进程ID
printf("[子进程] 我是%d,初始父进程是%d\n", getpid(), getppid());
sleep(5);
printf("[子进程] 我是%d,现在父进程是%d\n", getpid(), getppid());
}
else {
// 父进程:1秒后退出
printf("[父进程] 我是%d,1秒后退出\n", getpid());
sleep(1);
printf("[父进程] 退出了\n");
exit(0);
}
return 0;
}
2.2.2 运行与数据分析
bash
gcc orphan.c -o orphan
./orphan
输出示例:
bash
[父进程] 我是12345,1秒后退出
[子进程] 我是12346,初始父进程是12345
[父进程] 退出了
# 等待几秒
[子进程] 我是12346,现在父进程是1
父进程退出后,子进程的 PPID 变成了 1,说明已经被 systemd 领养。
子进程退出后,1 号进程会自动回收它,不会变成僵尸。
2.3 必须注意的两个硬核细节
2.3.1 细节一:为什么父进程退出不会产生僵尸?
很多人混淆:父进程退出了,它的子进程会不会变僵尸?
不会。
父进程退出时,内核会遍历它所有的子进程,把还活着的子进程全部交给 1 号领养;已经退出变成僵尸的子进程,也会交给 1 号回收。
所以父进程退出后,它的子进程要么变成孤儿被领养,要么直接被 1 号收尸,不会残留僵尸。
反过来:子进程退出,父进程活着但不回收,才会产生僵尸。这是最常见的场景。
2.3.2 细节二:前台进程与后台进程的转变
我们平时用终端跑程序,终端的 bash 就是父进程。
- 前台运行:程序占着终端,Ctrl+C 终止,bash 回收
- 后台运行:加
&,程序在后台跑,终端还能输入命令
当你关闭终端窗口时,bash 进程退出,它的后台子进程就变成了孤儿,被 systemd 领养,继续在后台运行。
nohup 命令的本质就是让程序忽略终端挂断信号,终端关闭后进程作为孤儿继续跑,被 1 号领养。
三、常见问题与避坑总结
3.1 怎么清理僵尸进程?
- 找到父进程,让它调用 wait:最规范的方式,但一般改代码才做得到
- 杀掉父进程:父进程死了,僵尸被 1 号领养回收,这是线上最常用的临时处理方式
- 重启系统:终极手段,不推荐
❌ 误区:kill -9 僵尸进程。没用!僵尸进程已经死了,只是残留 PCB,信号对它无效,杀不掉。
3.2 怎么避免产生僵尸?
- 父进程里调用
wait()/waitpid(),及时回收子进程 - 父进程忽略 SIGCHLD 信号,内核会自动回收子进程,不用 wait
- 两次 fork:父进程 fork 一次,子进程再 fork 孙子进程,然后子进程立刻退出,孙子进程变成孤儿被 1 号领养,没有僵尸
3.3 孤儿进程有害吗?
孤儿进程本身没害,它就是正常运行的进程,只是父进程变成了 1 号。
只要它正常干活、正常退出,1 号会回收,不会有资源泄漏。
但如果孤儿进程失控、疯狂占资源,因为父进程没了,只能手动 kill。
全文总结
- 僵尸进程 Z:子进程已退出,父进程未回收,PCB 残留内核;泄漏内核内存,kill 无效,杀父进程可解决。
- 孤儿进程:父进程先退出,子进程存活,被 1 号 systemd 领养;无害,退出由 1 号回收。
- 设计本质:保留退出信息是为了让父进程知晓子进程运行结果;领养机制是为了保证所有进程都有最终归宿。
- SLAB 机制:内核高频对象缓存池,task_struct 从 SLAB 申请释放,提升性能减少碎片。
下篇预告:
进程在 CPU 上轮流运行,切换的时候为什么能精准从上次停下的地方继续?下一篇我们深入进程上下文切换 ,拆解 CPU 寄存器、现场保存、切换全流程,搞懂多任务运行的底层秘密。
