文章目录

  • 概要&序论
  • 一、僵尸进程(Zombie Process)
    • 1.1 什么是僵尸进程?
      • 1.1.1 僵尸进程的产生原因
      • 1.1.2 子进程退出后,为什么还要保留 PCB?
    • 1.2 僵尸进程的危害
      • 1.2.1 为什么会造成资源泄漏?
      • 1.2.2 僵尸进程占用哪些资源?
    • 1.3 观察与模拟验证僵尸进程
      • 1.3.1 编写模拟代码
      • 1.3.2 使用 ps 命令查看进程状态
    • 1.4 补充:内核如何管理进程控制块?
      • 1.4.1 什么是 SLAB/SLUB?
      • 1.4.2 内核对象的缓存机制
  • 二、孤儿进程(Orphan Process)
    • 2.1 什么是孤儿进程?
      • 2.1.1 孤儿进程的产生原因
      • 2.1.2 孤儿进程由谁接管?
      • 2.1.3 认识 1 号进程 systemd
    • 2.2 观察与模拟验证孤儿进程
      • 2.2.1 编写模拟代码
      • 2.2.2 观察 PPID 的变化
    • 2.3 关于孤儿进程的两个细节
      • 2.3.1 父进程退出后,为什么它自己不会变成孤儿?
      • 2.3.2 孤儿进程是不是一定会变成后台进程?

概要&序论

Hello,大家好,欢迎来到我的 Linux 系统学习笔记。

前面我们学习了进程的创建、进程状态以及进程之间的关系。通过 fork(),父进程可以创建子进程,而父子进程之间又有着明确的关系。

不过,进程并不是一直运行下去的。随着程序执行,子进程可能先于父进程退出,也可能父进程先于子进程退出。

那么问题来了:

  • 子进程退出了,父进程还没有处理它的退出信息,会发生什么?
  • 父进程提前退出了,留下的子进程又由谁管理?

这就引出了 Linux 中两种比较特殊的进程:僵尸进程和孤儿进程。

它们看起来都是进程生命周期中的特殊情况,但产生原因恰好相反。

一个是子进程已经退出,父进程还没有回收;另一个是父进程已经退出,子进程却还在运行。

下面我们分别来看。


一、僵尸进程(Zombie Process)

1.1 什么是僵尸进程?

我们先从一个生活中的例子理解。

假设一个人已经完成了自己的工作,离开了岗位,但是他的工作结果还没有交给负责人确认。

虽然工作已经结束,但是负责人还需要知道:他究竟完成得怎么样?有没有出现问题?

Linux 中的子进程退出,也存在类似的情况。

一个子进程执行完自己的代码后,虽然已经退出了运行,但是父进程可能还没有读取它的退出信息。

这时候,子进程就会进入一个特殊状态:僵尸状态(Z 状态)。

注意,这里有一个容易混淆的地方:

僵尸进程并不是还在运行的进程,它的代码已经不再执行,也不会继续参与 CPU 调度。

它只是暂时保留着一部分内核信息,等待父进程处理。

1.1.1 僵尸进程的产生原因

我们之前学习过,进程在 Linux 中并不只是一个正在执行的程序。

操作系统还会为每个进程维护一个 PCB(进程控制块),Linux 中对应的核心结构是 task_struct。

PCB 中记录了进程的各种信息,比如 PID、状态、优先级、内存管理信息以及其他内核管理数据。

那么,当一个子进程退出时,操作系统应该怎么处理?

如果直接把所有信息全部释放,父进程就没有办法再获取这个子进程的退出结果了。

例如:

exit(0);

这里的 0 表示进程正常退出。

如果子进程退出后,父进程还想知道它是正常退出,还是发生了异常,就必须有地方暂时保存这些退出信息。

因此,Linux 并不会在子进程退出的那一刻,就把它的所有内核信息全部清理掉。

而是会先保留必要的退出信息,等待父进程通过 wait() 或 waitpid() 等待并回收子进程。

如果父进程迟迟不处理,那么这个已经退出的子进程就会一直处于 Z 状态。

所以,僵尸进程产生的核心原因就是:子进程已经退出,但父进程还没有回收它。

1.1.2 子进程退出后,为什么还要保留 PCB?

这里我们再往下想一步。

既然子进程已经退出了,为什么不直接释放 PCB 呢?

因为父进程还需要获取子进程的退出信息。

我们可以把整个过程理解为:

  1. 父进程通过 fork() 创建子进程。
  2. 子进程执行自己的代码。
  3. 子进程执行完毕,调用 exit() 或以其他方式退出。
  4. 操作系统释放子进程不再需要的用户空间资源,并保留必要的退出信息。
  5. 父进程调用 wait() 或 waitpid(),获取子进程的退出状态。
  6. 内核完成对子进程剩余资源的回收。

这里需要注意:

子进程退出时,用户空间的代码和数据等资源会被释放,但用于记录退出状态的必要内核信息还需要暂时保留。

因此,僵尸进程虽然已经不能正常运行,却仍然会占用一定的内核资源。

这也是为什么我们不能忽略僵尸进程。


1.2 僵尸进程的危害

1.2.1 为什么会造成资源泄漏?

我们知道,进程在运行过程中会占用一定的系统资源。

如果一个进程正常退出,操作系统会对它进行资源回收。

但是,如果父进程一直不调用 wait() 或 waitpid(),那么子进程退出后留下的退出信息和相关内核数据就无法及时完成回收。

如果父进程不断创建子进程,而这些子进程退出后又一直没有被回收,那么系统中就会积累越来越多的僵尸进程。

虽然每个僵尸进程占用的资源不一定很多,但数量不断增加,就可能造成问题。

例如:

  • 占用内核内存。
  • 占用进程相关的内核管理资源。
  • 消耗可用的进程号等系统资源。

当僵尸进程数量过多时,就可能影响系统正常创建新进程。

所以,我们不能因为僵尸进程已经退出、不再占用 CPU,就认为它完全没有危害。

1.2.2 僵尸进程占用哪些资源?

这里需要区分一下。

子进程退出后,并不是所有资源都会一直保留。

可以简单理解为:

资源子进程退出后的情况
用户空间代码和数据释放
用户空间动态申请的内存随进程退出而释放
打开的文件等资源按退出清理流程关闭
必要的进程退出信息暂时保留
进程相关的内核管理结构等待最终回收

因此,僵尸进程并不是把整个程序的内存一直占在那里。

它主要保留的是为了让父进程获取退出信息所需要的内核数据。

这也是僵尸进程和普通运行中进程的一个重要区别。


1.3 观察与模拟验证僵尸进程

前面我们从原理上理解了僵尸进程的产生过程,下面通过代码实际观察一下。

1.3.1 编写模拟代码

我们希望模拟这样一个场景:

  • 父进程创建子进程。
  • 子进程运行几秒后退出。
  • 父进程暂时不调用 wait() 或 waitpid()。
  • 让子进程退出后,父进程继续存活一段时间。

代码如下:

#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)
    {
        // 子进程
        std::cout << "我是子进程,pid: "
                  << getpid() << std::endl;

        sleep(3);

        std::cout << "子进程退出!" << std::endl;
        exit(0);
    }
    else
    {
        // 父进程
        std::cout << "我是父进程,pid: "
                  << getpid() << std::endl;

        // 父进程暂时不回收子进程
        sleep(30);

        std::cout << "父进程结束!" << std::endl;
    }

    return 0;
}

这段代码中,最重要的是父子进程的执行顺序。

父进程和子进程通过 fork() 创建后,分别执行自己的分支。

子进程只等待 3 秒就退出,而父进程等待 30 秒。

也就是说,在这段时间内:

子进程已经退出了,但是父进程还没有退出,也没有调用 wait() 回收子进程。

这就给我们留下了观察僵尸进程的机会。

1.3.2 使用 ps 命令查看进程状态

首先编译并运行程序:

g++ test.cpp -o test
./test

然后打开另一个终端,使用 ps 命令查看进程状态:

ps -axj | grep test

在输出结果中,我们重点关注 STAT 这一列。

当子进程还在运行时,可能会看到类似:

S

这里的 S 表示可中断睡眠状态。

等子进程执行完毕后,它就会变成:

Z

其中,Z 就代表 Zombie,也就是僵尸状态。

有些情况下,进程名称后面还会出现:

[test] <defunct>

defunct 可以理解为已经退出、但还没有被回收的进程。

因此,我们可以通过 ps 命令观察到:

子进程已经退出,但是由于父进程还没有回收它,所以它暂时保留在系统中,状态显示为 Z。

这就验证了僵尸进程的产生过程。


1.4 补充:内核如何管理进程控制块?

前面我们提到,进程的 PCB 对应 Linux 内核中的 task_struct。

那么,task_struct 这种内核数据结构是怎么申请和释放的呢?

如果每次创建进程都重新申请内存,进程退出时又立即释放内存,频繁的申请和释放会带来一定的管理开销。

因此,Linux 内核使用了专门的内存分配机制来管理这类内核对象。

1.4.1 什么是 SLAB/SLUB?

SLAB 和 SLUB 都是 Linux 内核中的对象缓存分配器。

我们可以先把它们理解为一种针对内核数据结构的内存管理机制。

例如,内核需要频繁创建某种结构体对象:

  • task_struct
  • mm_struct
  • 其他内核对象

如果每次都从底层申请一块新的内存,再进行初始化,频繁操作会增加开销。

于是,内核可以通过对象缓存机制,提前管理一批适合存放特定对象的内存空间。

当需要创建对象时,可以从对应的缓存中分配;对象释放后,也可以将内存重新放回缓存,以便后续复用。

1.4.2 内核对象的缓存机制

我们可以用一个简单的例子理解。

假设内核需要频繁创建 task_struct。

如果每次都重新申请和释放底层内存,就会产生很多重复的内存管理操作。

而使用对象缓存机制后,可以把一批适合存放 task_struct 的内存组织起来。

当需要创建新的对象时,从缓存中分配合适的空间。

当对象不再使用时,再按照内核内存管理机制进行释放或回收。

这样就能减少重复申请和初始化的开销。

需要注意的是:

对象缓存并不意味着释放后的 task_struct 永远不会归还底层内存。

具体是否保留在缓存中,以及何时归还,都由内核的内存管理机制决定。

这里我们先理解它的核心思想即可:通过缓存和复用,提高内核对象分配与释放的效率。


二、孤儿进程(Orphan Process)

前面我们学习了僵尸进程,它的特点是:

子进程先退出,父进程还没有回收。

那么,如果情况反过来呢?

假设父进程先退出了,但是子进程还在继续执行。

此时,子进程就失去了原来的父进程。

这就引出了另一种特殊情况:孤儿进程。

2.1 什么是孤儿进程?

2.1.1 孤儿进程的产生原因

在 Linux 中,父进程和子进程之间存在着明确的父子关系。

当父进程通过 fork() 创建子进程时,子进程会记录自己的父进程 PID,也就是 PPID。

我们可以通过:

getpid();
getppid();

分别获取当前进程的 PID 和父进程的 PID。

正常情况下,子进程的 PPID 就是创建它的父进程的 PID。

但是,如果父进程提前退出,而子进程还没有执行完毕,那么原来的父子关系就发生了变化。

此时,这个仍然在运行的子进程就被称为孤儿进程。

例如:

父进程
  |
  |-- 子进程

原本父进程负责创建和管理子进程。

但如果父进程先退出:

原父进程:退出

子进程:继续运行

那么子进程就失去了原来的父进程。

这就是孤儿进程的产生过程。

2.1.2 孤儿进程由谁接管?

那么问题来了:

父进程已经退出了,子进程还在运行,它以后退出时,谁来回收它?

Linux 不会让这种情况一直无人管理。

当一个进程的父进程退出时,内核会为它重新安排父进程,也就是我们常说的孤儿进程领养机制。

通常情况下,孤儿进程会被重新托管给系统中的 1 号进程。

在某些情况下,如果存在设置了 PR_SET_CHILD_SUBREAPER 的子收养者进程,孤儿进程也可能交给距离它最近的子收养者进程。

所以,不能简单地认为所有孤儿进程都一定直接交给 PID 为 1 的进程。

这里先记住最常见的情况:

原父进程退出后,内核会为仍然存活的子进程重新安排父进程,避免它们失去后续的管理和回收机制。

2.1.3 认识 1 号进程 systemd

在 Linux 系统中,PID 为 1 的进程具有非常特殊的地位。

它通常是系统启动后创建的第一个用户空间进程。

在现代 Linux 发行版中,例如 Ubuntu,1 号进程通常是 systemd。

我们可以通过命令查看:

ps -p 1 -o pid,ppid,stat,comm

一般可以看到类似:

PID  PPID  STAT  COMMAND
1    0     Ss    systemd

其中:

  • PID:当前进程的进程号。
  • PPID:父进程的进程号。
  • COMMAND:进程名称。

systemd 负责系统初始化、系统服务管理等工作。

同时,在常见的孤儿进程托管场景中,它也会负责处理被重新托管给它的子进程。

当这些子进程以后退出时,systemd 会处理它们的退出状态,完成相应的回收工作。

这样就避免了孤儿进程退出后,因为没有父进程处理退出信息而长期残留。


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

前面我们理解了孤儿进程的产生原因,也知道了它会被重新安排父进程。

下面通过代码观察一下 PPID 的变化。

2.2.1 编写模拟代码

我们希望模拟这样的场景:

  • 父进程创建子进程。
  • 父进程等待几秒后退出。
  • 子进程继续运行。
  • 观察子进程的 PPID 是否发生变化。

代码如下:

#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)
    {
        // 子进程
        std::cout << "子进程 PID: "
                  << getpid()
                  << ", PPID: "
                  << getppid() << std::endl;

        sleep(5);

        std::cout << "父进程退出后,子进程继续运行" << std::endl;

        std::cout << "当前子进程 PID: "
                  << getpid()
                  << ", PPID: "
                  << getppid() << std::endl;

        sleep(5);
    }
    else
    {
        // 父进程
        std::cout << "父进程 PID: "
                  << getpid() << std::endl;

        sleep(2);

        std::cout << "父进程即将退出!" << std::endl;
    }

    return 0;
}

这段代码中:

父进程等待 2 秒后就会退出,而子进程需要等待 5 秒才会继续执行。

所以,当子进程再次打印自己的 PPID 时,原来的父进程已经退出了。

2.2.2 观察 PPID 的变化

编译并运行:

g++ test.cpp -o test
./test

我们可能会看到类似输出:

父进程 PID: 1000

子进程 PID: 1001, PPID: 1000

父进程即将退出!

父进程退出后,子进程继续运行

当前子进程 PID: 1001, PPID: 1

这里的 PID 只是示例,实际运行时会有所不同。

我们重点关注 PPID:

在父进程还没有退出时:

子进程 PPID = 1000

父进程退出后:

子进程 PPID = 1

这说明子进程原来的父进程已经退出,系统为它重新安排了父进程。

在普通 Ubuntu 环境中,通常会看到它被 PID 为 1 的 systemd 接管。

这就验证了孤儿进程的产生和托管过程。


2.3 关于孤儿进程的两个细节

2.3.1 父进程退出后,为什么它自己不会变成孤儿?

这里有一个容易让人疑惑的地方。

我们知道,父进程也是由其他进程创建出来的。

比如,我们在命令行中执行:

./test

实际上,Shell(例如 bash)会负责启动这个程序。

进程关系可以简单理解为:

bash
 |
 |-- test(父进程)
       |
       |-- 子进程

那么,当 test 中的父进程退出时,它自己不也失去了父进程吗?

为什么它没有变成孤儿进程呢?

原因是:

父进程退出时,它的父进程 bash 通常还在运行。

bash 仍然是它的父进程,可以通过等待机制获取它的退出状态,并完成回收。

所以,父进程退出后,真正需要重新安排父进程的是那些仍然存活的子进程。

而已经退出的父进程,则由自己的父进程负责回收。

这里要注意,孤儿进程并不是指所有父进程退出的进程,而是指父进程已经退出、自己仍然存活的进程。

2.3.2 孤儿进程是不是一定会变成后台进程?

这里还需要区分两个概念:

  • 孤儿进程:描述的是进程的父子关系。
  • 前台进程和后台进程:描述的是进程与终端之间的作业控制关系。

它们不是同一个维度。

我们在终端执行程序时,Shell 会根据作业控制机制管理前台进程组和后台进程组。

例如,使用:

./test

通常是在前台运行程序。

而:

./test &

则是让程序在后台运行。

那么,父进程退出以后,子进程是不是就一定变成后台进程了?

并不是。

孤儿进程被重新安排父进程,只改变了它的父子关系,并不意味着它的进程组、会话以及终端控制关系必然发生变化。

例如,键盘上的 Ctrl+C 能不能终止某个进程,主要与终端前台进程组以及信号处理机制有关,而不是简单地看它是不是孤儿进程。

另外,终端关闭时还可能涉及 SIGHUP 等信号。

所以,我们不能直接得出:

父进程退出 → 子进程成为孤儿 → 子进程自动变成后台进程。

这几个概念需要分开理解。


总结

本篇主要学习了 Linux 中的两种特殊进程:僵尸进程和孤儿进程。

它们都是由父子进程退出顺序不同而产生的特殊情况。

僵尸进程:

子进程先退出,但是父进程还没有通过 wait() 或 waitpid() 获取退出信息并完成回收。

因此,子进程会暂时保留必要的内核信息,处于 Z 状态。

孤儿进程:

父进程先退出,但是子进程仍然在运行。

此时,内核会为子进程重新安排父进程,通常由 PID 为 1 的进程接管,或者由符合条件的子收养者进程接管。

最后,我们可以用一句话区分它们:

  • 僵尸进程:子进程已经退出,等待父进程回收。
  • 孤儿进程:父进程已经退出,子进程仍然继续运行。

理解这两种情况,也能帮助我们进一步认识 Linux 中父子进程的关系,以及操作系统如何管理进程的整个生命周期。

好了,本篇内容就到这里,我们下篇继续学习 Linux 进程相关知识!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐