Re:Linux系统篇(十九)进程篇·四:内核托底与生死交错 ,深度硬核剖析“僵尸”与“孤儿”进程


◆ 博主名称: 晓此方-CSDN博客 大家好,欢迎来到晓此方的博客。
⭐️Linux系列个人专栏: 【主题曲】Linux
⭐️此方的GitHub: github_此方
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


文章目录

  • 概要&序論
  • [一、 僵尸进程(Zombie Process)](#一、 僵尸进程(Zombie Process))
    • [1.1 什么是僵尸进程?](#1.1 什么是僵尸进程?)
      • [1.1.1 僵尸进程的定义与产生原因](#1.1.1 僵尸进程的定义与产生原因)
      • [1.1.2 退出信息存在哪里?](#1.1.2 退出信息存在哪里?)
    • [1.2 僵尸进程的危害:内存泄漏](#1.2 僵尸进程的危害:内存泄漏)
      • [1.2.1 为什么会引发内存泄漏?](#1.2.1 为什么会引发内存泄漏?)
      • [1.2.2 进程退出了,内存泄漏还在吗?](#1.2.2 进程退出了,内存泄漏还在吗?)
    • [1.3 观察与模拟验证 Z 状态](#1.3 观察与模拟验证 Z 状态)
      • [1.3.1 编写模拟代码](#1.3.1 编写模拟代码)
      • [1.3.2 命令行监控与状态识别](#1.3.2 命令行监控与状态识别)
    • [1.4 深度硬核拓展:内核结构的申请与 SLAB 技术](#1.4 深度硬核拓展:内核结构的申请与 SLAB 技术)
      • [1.4.1 什么是 SLAB 技术?](#1.4.1 什么是 SLAB 技术?)
      • [1.4.2 数据结构对象的缓存机制](#1.4.2 数据结构对象的缓存机制)
  • [二、 孤儿进程(Orphan Process)](#二、 孤儿进程(Orphan Process))
    • [2.1 什么是孤儿进程?](#2.1 什么是孤儿进程?)
      • [2.1.1 孤儿进程的定义与产生原因](#2.1.1 孤儿进程的定义与产生原因)
      • [2.1.2 谁来托底?一号进程的领养机制](#2.1.2 谁来托底?一号进程的领养机制)
      • [2.1.3 认识 1 号进程:systemd](#2.1.3 认识 1 号进程:systemd)
    • [2.2 观察与模拟验证孤儿进程](#2.2 观察与模拟验证孤儿进程)
      • [2.2.1 编写模拟代码](#2.2.1 编写模拟代码)
      • [2.2.2 命令行监控与数据分析](#2.2.2 命令行监控与数据分析)
    • [2.3 必须注意的两个硬核细节](#2.3 必须注意的两个硬核细节)
      • [2.3.1 细节一:为什么父进程退出不会产生孤儿?](#2.3.1 细节一:为什么父进程退出不会产生孤儿?)
      • [2.3.2 细节二:前台进程与后台进程的转变](#2.3.2 细节二:前台进程与后台进程的转变)

概要&序論

Hello大家好我是此方 ,本文是对上文《Re: Linux系统篇(十八)进程篇·三:深度硬核!全面起底 Linux 进程状态变化与内核链表动态解绑》的延续和补充,重点讲解两类特殊进程"僵尸进程"与"孤儿进程"好了废话少说直接开始。

一、 僵尸进程(Zombie Process)

在 Linux 系统中,进程的生命周期并不是随着代码执行完毕就画上句号的。当一个子进程退出后,它会进入一个特殊的过渡状态------僵尸状态(Z 状态,Zombie)

1.1 什么是僵尸进程?

你正走在路上,一个人突然从你身边跑过,突然!摔地上猝死了。------此时他虽已失去生命体征,但由于死因不明,遗体和随身物品(对应子进程的 task_struct、退出码和信号)必须原封不动地保留在原地 ,任何人不得擅自收尸,这个在原地等待验尸的阶段就是"僵尸状态";

直到你报警后警察赶到,在现场拍照、翻看口袋、登记完死亡信息并查明死因(对应父进程调用 waitpid 获取退出信息) ,这才挥手让救护车把遗体拉走火化,此时死者在世间留下的最后痕迹被彻底抹去,这才真正进入了"死亡状态(X状态)"。

1.1.1 僵尸进程的定义与产生原因

我们创建子进程的目的,本质上是为了让它帮我们完成某种任务。既然是完成任务,那么任务的结果如何(是成功了、失败了,还是异常崩溃了),父进程必须得知道

因此,当子进程退出时,内核会释放该进程所有的代码和数据内存,但允许它的进程控制块(PCB,即 task_struct)暂时保留在系统内核中

核心结论:

你的数据和代码可以释放掉,但是你的 PCB 不能走!

保持 Z 状态,就是为了让父进程来读取子进程退出时的相关信息!

1.1.2 退出信息存在哪里?

在 Linux 内核的 task_struct 结构体中,专门有几个字段用来维护进程的退出状态和信息:

c 复制代码
struct task_struct {
    // ...
    long exit_state;         // 进程的退出状态(如 EXIT_ZOMBIE, EXIT_DEAD)
    int exit_code;           // 退出数字(如 exit(0) 中的 0)
    int exit_signal;         // 退出信号(如果是被信号所杀,则记录该信号)
    // ...
};

当子进程变为僵尸状态时,exit_state 会被设置为相应的值,而父进程通过调用 wait()waitpid() 系统调用(后面进程等待的时候讲 ),就能从这个 task_struct 中拿走 exit_codeexit_signal

1.2 僵尸进程的危害:内存泄漏

1.2.1 为什么会引发内存泄漏?

如果父进程一直处于忙碌状态,或者压根不管、不回收、不获取 子进程的退出信息,那么这个子进程的 Z 状态就会一直存在!

虽然子进程的用户空间内存被释放了,但它的 task_struct 依然常驻在内核空间中。task_struct 本身也是一个结构体对象,是要占用物理内存的。如果一个父进程源源不断地创建子进程,退出后又从不回收,那么内核中的 task_struct 就会堆积得越来越多,从而引发严重的内核内存泄漏!你的程序会越用越卡。

1.2.2 进程退出了,内存泄漏还在吗?

  • 如果是子进程自身在用户空间申请的内存(如 malloc): 随着子进程的退出,这部分内存会被操作系统强制回收,因此不需要担心。
  • 由于 Z 状态残留的内核 task_struct 只要父进程不退出、不回收,这部分内核内存就永远存在
  • 常驻内存的进程更危险: 类似服务器后台服务(Nginx、Database 等)这类需要常年运行的进程,如果产生了僵尸子进程,会持续蚕食内核内存,最终可能导致系统因内存耗尽而崩溃。

1.3 观察与模拟验证 Z 状态

1.3.1 编写模拟代码

我们可以编写一段 C++ 代码来模拟这种"子进程先退出,父进程一直不管"的场景。

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <cstdlib>

int main() {
    pid_t id = fork();
    if (id < 0) {
        std::cerr << "fork error" << std::endl;
        return 1;
    }
    else if (id == 0) {
        // 子进程:只活 5 秒
        int count = 5;
        while (count--) {
            std::cout << "我是子进程,PID: " << getpid() << ", 剩余寿命: " << count << "s" << std::endl;
            sleep(1);
        }
        std::cout << "子进程已退出,进入僵尸状态..." << std::endl;
        exit(0); 
    }
    else {
        // 父进程:一直死循环,不进行回收
        while (true) {
            std::cout << "我是父进程,PID: " << getpid() << ", 正在挂起/忙碌中..." << std::endl;
            sleep(1);
        }
    }
    return 0;
}

1.3.2 命令行监控与状态识别

在另一个终端运行以下监控脚本,观察进程状态的变化:

bash 复制代码
while true; do ps -axj | head -n 1 && ps -axj | grep myprocess | grep -v grep; sleep 1; echo "---------------------------------------"; done

当子进程结束这 5 秒后,你会观察到如下的现象:

  • 子进程的 STAT 栏从 S+(睡眠状态)变成了 Z+(僵尸状态)。
  • 子进程的 COMMAND 栏后面多出了一个特殊的标记:[myprocess] <defunct>defunct 意为:死者、不存在的,即僵尸进程的标志)。


1.4 深度硬核拓展:内核结构的申请与 SLAB 技术

当父进程终于调用 waitpid() 拿走了退出信息后,这个残留的 task_struct 是怎么被销毁的呢?这就涉及到 Linux 内核的高效内存管理机制了。

1.4.1 什么是 SLAB 技术?

在 Linux 操作系统中,类似 task_structmm_structfile 这样的内核结构体对象,在系统运行期间会被频繁地创建(如频繁 fork)和销毁。

如果每次创建都直接向操作系统申请新的物理页,销毁时又直接释放掉,这种底层的内存申请与释放操作会涉及到大量的内核页表映射和虚实地址转换,成本极高,极其耗时

为了解决这个问题,Linux 内核引入了 SLAB 内存分配器(SLAB / SLUB / SLOB) 技术。

1.4.2 数据结构对象的缓存机制

SLAB 技术的本质就是内核级的数据结构对象池(缓存)

  • 回收不等于彻底销毁: 当一个进程的僵尸状态结束,内核要释放其 task_struct 时,并不会直接把这块内存还给物理内存管理系统,而是将其状态标记为"空闲(unused)",并送入一个 unused 队列(或名为 slab 的缓存池) 当中。
  • 创建时的极致复用: 当下次系统又有新的进程被创建,需要新的 task_struct 时,内核会直接去这个缓存池里捞一个现成的、被标记为 unused 的内存块出来,稍微修改一下内部的属性即可使用。

通过这种"对象缓存"的思路,内核规避了频繁申请释放物理内存的开销,极大地提高了系统创建和销毁进程的效率。

二、 孤儿进程(Orphan Process)

在多进程的家族体系中,除了"子进程先走,父进程不管"导致的僵尸进程外,还存在一种截然相反的特殊场景:父进程先于子进程一步退出,留下子进程还在孤独地运行

2.1 什么是孤儿进程?

2.1.1 孤儿进程的定义与产生原因

在 Linux 系统中,如果一个父进程由于提前执行完毕、异常崩溃或被信号杀掉而退出,而它的一个或多个子进程仍在继续运行,那么这些失去父进程的子进程就沦为了孤儿进程

2.1.2 谁来托底?一号进程的领养机制

**操作系统是一个极其注重安全与稳定的系统,绝不允许任何进程在没有"监护人"的情况下自由游荡。**因为如果这些子进程没人管,当它们运行结束退出并进入僵尸状态(Z 状态)时,将永远没有父进程来为它们"收尸",最终必然会引发内存泄漏。

为了解决这个问题,Linux 内核设计了一套领养(Adoption)机制

核心结论:

只要父进程先退出,操作系统(OS)就会强制指派 1 号进程 领养剩下的所有子进程!

这个 1 号进程在老版本的 Linux 中是 init 进程,而在现代 Linux 发行版(如 CentOS 7 及以上、Ubuntu 等)中,它通常被称为 systemd

2.1.3 认识 1 号进程:systemd

1 号进程是 Linux 内核启动后创建的第一个用户空间进程。它是整个系统所有用户进程的"老祖宗",专门用来帮内核打理登录认证、系统服务管理(如处理各种设备和系统初始化问题)等。

当我们登录操作系统的时候,系统就会为我们自定创建一个bash,这个bash的创建者就是一号进程。systemd是它的名字,它就是操作系统的一部分

正因为它的地位极其特殊,由它来接管孤儿进程最合适不过------1 号进程会永远驻留内存,并且在孤儿进程退出时,会非常尽职尽责地自动调用 wait() 族函数释放它们占用的内核资源,绝不产生内存泄漏。

其实还有一个更老的祖宗0号进程,但是0号进程在开机以后就被替换掉了。这个我们不管。

2.2 观察与模拟验证孤儿进程

2.2.1 编写模拟代码

我们可以通过编写一段 C 语言代码,来重现"父进程活 5 秒后主动退出,子进程进入死循环"的场景。

c 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
    pid_t id = fork();
    if (id < 0) {
        perror("fork error");
        return 1;
    }
    else if (id == 0) {
        // 子进程:不会主动退出,疯狂死循环
        while (1) {
            printf("我是子进程,pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
        }
    }
    else {
        // 父进程:只存活 5 秒,随后退出
        int cnt = 5;
        while (cnt) {
            printf("我是父进程,pid: %d, ppid: %d, 剩余寿命: %d\n", getpid(), getppid(), cnt--);
            sleep(1);
        }
        printf("父进程生存期结束,已退出!\n");
    }
    return 0;
}

2.2.2 命令行监控与数据分析

通过在终端编译并运行程序,再开一个窗口使用 ps 命令高频抓取该进程状态,我们会看到如下输出流:

text 复制代码
我是父进程,pid: 22702, ppid: 21931, 剩余寿命: 5
我是子进程,pid: 22703, ppid: 22702
...
父进程生存期结束,已退出!
[whb@bite-alicloud lesson14]$ 我是子进程,pid: 22703, ppid: 1
我是子进程,pid: 22703, ppid: 1
  • 在前 5 秒: 子进程的 ppid 为 22702(即它的亲生父亲)。
  • 在第 5 秒后: 父进程准时退出,命令行回到了 Shell 提示符处。然而此时,子进程依然在疯狂刷屏打印,且其 ppid 瞬间变为了 1 !这证明它已经被 1 号进程 systemd 成功领养。

2.3 必须注意的两个硬核细节

在模拟孤儿进程的过程中,你一定会发现两个让人困惑却十分精妙的现象。

2.3.1 细节一:为什么父进程退出不会产生孤儿?

有人会问:既然"父进程退出时,子进程还在运行,子进程会变成孤儿",那父进程退出时,它的亲生父亲(也就是命令行终端 bash 进程)不也还在运行吗?父进程怎么没变成孤儿?

  • 原因很简单: 父进程退出时,它的子进程(代码中的 child)还没有退出,所以子进程成了孤儿。
  • 而父进程在退出时,它的父亲 bash 一直存活在后台盯着它呢 。父进程一旦退出,bash 会秒回收这个父进程的退出信息,因此父进程会直接被清理干净,根本没有机会触发孤儿机制。

2.3.2 细节二:前台进程与后台进程的转变

注意看你在测试孤儿进程时的终端输入:当父进程退出、控制台显示出 [whb@bite-alicloud...]$ 的提示符后,无论你疯狂按 Ctrl + C ,子进程都完全无动于衷,依然在屏幕上刷屏输出!

这是因为子进程在被 1 号进程领养后,其状态发生了一个隐蔽的转变:

进程阶段 状态标志 键盘交互能力
阶段 1:父进程健在时 S+ / R+ (带 + 号为前台进程) 占据终端,可以通过键盘 Ctrl+C 强制终止
阶段 2:被 1 号进程领养后 S / R (不带 + 号为后台进程) 失去终端控制,无法被 Ctrl+C 杀死,但依然可以向显示器刷屏输出

如何清理这个赖在后台不走的孤儿进程?

由于它已经变成了后台进程,只能通过发送九号强杀信号来解决它。请开启另一个新终端,执行:

bash 复制代码
kill -9 22703  # 这里的PID根据你实际查到的子进程PID填写

好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye!

相关推荐
wj3055853789 小时前
课程 9:模型测试记录与 Prompt 策略
linux·人工智能·python·comfyui
abigriver10 小时前
打造 Linux 离线大模型级语音输入法:Whisper.cpp + 3090 显卡加速与 Rime 中英混输终极调优指南
linux·运维·whisper
wangqiaowq10 小时前
windows下nginx的安装
linux·服务器·前端
YYRAN_ZZU11 小时前
Petalinux新建自动脚本启动
linux
charlie11451419111 小时前
嵌入式Linux驱动开发pinctrl篇(1)——从寄存器到子系统:驱动演进之路
linux·运维·驱动开发
Agent手记11 小时前
异常考勤智能预警与处理与流程优化方案 | 基于企业级Agent的超自动化实战教程
运维·人工智能·ai·自动化
于小猿Sup11 小时前
VMware在Ubuntu22.04驱动Livox Mid360s
linux·c++·嵌入式硬件·自动驾驶
cen__y11 小时前
Linux12(Git01)
linux·运维·服务器·c语言·开发语言·git
不仙52013 小时前
VMware Workstation 26.0.0 在 Ubuntu 24.04 (内核 6.17.0) 上的安装与内核模块编译问题
linux·ubuntu·elasticsearch