Linux 进程状态详解:从理论到实践,僵尸进程与孤儿进程

本文为 Linux 系统编程系列的第二篇,深入讲解进程状态的核心概念(运行、阻塞、挂起),结合 Linux 内核的具体实现(R、S、D、T、t、X、Z 状态),并通过实验演示僵尸进程的形成与危害,最后简要介绍孤儿进程。同时,讲解内核中如何通过 **list_head**实现 PCB 的多数据结构组织,为后续进程调度打下基础。

一、课前复习:进程的本质与 PCB

在正式进入状态之前,我们先快速回顾上节课的核心结论:

  • 进程 = 内核数据结构(PCB)+ 代码和数据

    操作系统为了管理进程,必须先描述,再组织------为每个进程创建 PCB(Process Control Block),再用链表等数据结构将所有 PCB 组织起来。

  • Linux 中的 PCB 具体称为 task_struct ,定义在 <linux/sched.h> 中,包含进程的所有属性(PID、状态、优先级、内存指针等)。

  • 创建进程使用 fork() 系统调用,父子进程代码共享,数据写时拷贝(Copy‑on‑Write),保证了进程的独立性。


二、进程状态的本质:一个整数

进程状态本质上就是 task_struct 结构体内的一个整型变量。

  • 操作系统内核可以用 #define 定义宏(如 #define RUNNING 0#define SLEEP 1),然后把这个整数赋给进程的 state 字段。

  • 调度器或其他内核模块只需要读取/修改这个整数,就能决定进程该被调度、阻塞还是唤醒。

  • 因此,理解进程状态,就是理解这个整数在不同系统下的不同取值及含义


三、课本上的状态模型:运行、阻塞与挂起

在学习具体的 Linux 状态之前,我们先从操作系统理论的角度理解三个通用概念。

3.1 运行(Running)

  • 定义 :一个进程要么正在被 CPU 执行,要么已经准备好随时可以被调度,这两种情况统称为运行状态

  • 在 Linux 中,只要进程的 PCB 位于 CPU 的运行队列(runqueue) 中,就视为 R 状态(可运行)。

3.2 阻塞(Blocked)

  • 产生原因 :当进程需要等待某个资源(例如键盘输入、磁盘 I/O)时,它无法继续执行。

  • 内核如何实现阻塞

    • 操作系统为每一种硬件设备(键盘、磁盘等)都维护了一个设备队列(实际上是一个等待队列)。

    • 当进程(比如执行 scanf())发现键盘没有数据时,操作系统会把该进程的 PCB 从运行队列中移除 ,然后链入到键盘设备的等待队列中。

    • 从此,CPU 调度器再也看不到这个进程,直到设备就绪。

  • 唤醒:当键盘上有按键按下,操作系统(作为硬件管理者)会检查该设备的等待队列,把队列中的进程 PCB 重新放回运行队列。之后调度器就会让该进程继续执行。

类比:你把简历交给了面试官(运行队列),面试官没时间看你,就把你的简历放到了"待定人才池"(设备等待队列)。等面试官有空了(设备就绪),再把简历拿回来。

3.3 挂起(Suspended)

  • 产生原因 :当系统内存资源严重不足 时,操作系统会把一些短期内不会被调度 的进程的代码和数据 换出到磁盘的 swap 交换分区,只保留 PCB 在内存中。这样可以释放物理内存给更需要它的进程。

  • 恢复:当设备就绪(或该进程需要被调度)时,操作系统再把代码和数据从 swap 分区换入内存,重新建立映射,然后把 PCB 重新放回运行队列。

  • 对程序员透明 :挂起状态完全由内核自动管理,用户使用 ps 命令看不到专门的"挂起"标记。


四、Linux 内核中的具体进程状态

Linux 内核将进程状态定义为一个字符串数组(task_state_array),每一种状态对应一个字符和整数值(下标)。我们可以通过 ps 命令查看进程状态(STAT 列)。

复制代码
static const char * const task_state_array[] = {
    "R (running)",      /* 0 */
    "S (sleeping)",     /* 1 */
    "D (disk sleep)",   /* 2 */
    "T (stopped)",      /* 4 */
    "t (tracing stop)", /* 8 */
    "X (dead)",         /* 16 */
    "Z (zombie)",       /* 32 */
};

4.1 R 状态(可运行)

  • 含义:进程处于运行队列中,随时可以被调度(不一定正在占用 CPU)。

  • 验证 :编写一个死循环(没有 I/O 操作)的程序,用 ps aux 查看,大概率看到 R 状态。

  • 注意 :如果程序中有 printf 等输出,大部分时间其实在 S 状态(等待 I/O),只有极少数时间在 R 状态。

4.2 S 状态(可中断睡眠)

  • 含义 :进程处于阻塞状态,等待某个事件(如键盘输入、网络数据)。可以被信号中断 (例如 kill 命令可以杀掉它)。

  • 也称 :浅度睡眠 / TASK_INTERRUPTIBLE

  • 验证 :在程序中调用 scanf()sleep(),进程会进入 S 状态。

由于printf运行太快了查不到r所以为s状态。s左上角有个加号表示进程在前台运行,./myprocess &放到后台运行

4.3 D 状态(不可中断睡眠)

  • 含义 :进程等待磁盘 I/O 等关键操作完成,不能被任何信号中断 (即使 kill -9 也无法杀死)。

  • 为什么需要 D 状态?

    假设一个进程正在向磁盘写入重要数据(例如银行转账记录)。如果此时操作系统允许该进程被杀掉,磁盘可能写入到一半就失败了,而且无法通知用户。D 状态保证进程必须等待 I/O 完成或失败后才能退出,防止数据丢失。

  • 实际场景 :对磁盘进行高负载 I/O(如 dd 命令),可能短暂出现 D 状态。如果系统长期存在 D 状态进程,说明磁盘可能已损坏或 I/O 子系统异常。

4.4 T 状态(暂停 / 停止)

  • 含义 :进程被用户或系统主动暂停(不是等待资源)。

  • 进入方式

    • 按下 Ctrl+Z 将前台进程放入后台并暂停 → 状态显示为 T(大写)。

    • 向进程发送 SIGSTOP 信号(kill -19 PID)也会使其进入 T 状态。

  • 恢复 :发送 SIGCONT 信号(kill -18 PID)可继续运行。

4.5 t 状态(追踪停止)

  • 含义 :进程被调试器(如 gdb)跟踪时,遇到断点而暂停。

  • 表现 :使用 gdb 调试并设置断点,程序停在断点处时,状态显示为 t(小写)。

4.6 X 状态(死亡)

  • 含义 :进程已经终止,且已被父进程回收,PCB 即将被释放。此状态在进程列表中不可见

4.7 Z 状态(僵尸)

  • 产生条件 :子进程先于父进程退出,且父进程没有调用 wait() / waitpid() 回收子进程的退出状态信息。

  • 此时子进程的代码和数据已经释放,但它的 PCB(task_struct)仍然保留,状态为 Z

  • 为什么要保留 PCB?

    父进程需要知道子进程是正常退出还是异常退出(例如从 main 返回的退出码,或者被哪个信号杀死)。这些信息保存在子进程的 PCB 中,父进程通过系统调用读取后才能完全释放该 PCB。

  • 危害 :如果父进程长期不回收,僵尸进程会持续占用内核内存(每个 PCB 约 1~2 KB),导致内存泄漏,严重时系统无法创建新进程。

  • 如何避免 :父进程必须使用 wait()waitpid() 回收子进程;或者让子进程被 init 进程收养(孤儿进程)。


五、僵尸进程实验

5.1 模拟僵尸进程

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

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        //child
        int count = 5;
        while(count)
        {
            printf("我是子进程,我正在运行: %d\n", count);
            sleep(1);
            count--;
        }
    }
    else 
    {
        while(1)
        {
            printf("我是父进程,我正在运行...\n");
            sleep(1);
        }
    }

5.2 查看僵尸状态

编译运行后,在另一个终端使用 ps aux | grep myproc 查看:

复制代码
USER   PID  %CPU  %MEM  STAT  TIME  COMMAND
whb   1234  0.0  0.0   R+   0:00  ./myproc      # 父进程
whb   1235  0.0  0.0   Z+   0:00  [myproc] <defunct>  # 僵尸子进程
  • Z 表示僵尸状态,<defunct> 表示进程已失效但未被回收。

六、孤儿进程(Orphan)

  • 定义 :父进程先于子进程退出,子进程就称为孤儿进程

  • 处理方式 :孤儿进程会被系统的 1 号进程(init / systemd) 收养,成为其子进程。1 号进程会负责在它们退出时回收资源,因此不会产生僵尸

  • 验证 :让父进程 sleep(3) 后退出,子进程无限循环;观察子进程的 PPID 会变为 1。

为什么要收养?

如果不收养,子进程退出后就没有父进程来回收,会造成内存泄漏。操作系统必须成为"孤儿院",由 1 号进程承担回收责任。


七、内存泄漏的补充讨论

7.1 普通进程的内存泄漏

  • 如果程序中用 malloc 分配了堆内存但忘记 free,进程退出时,操作系统会自动回收该进程所持有的所有内存(包括泄漏的堆内存)。因此,短暂运行的程序中的内存泄漏影响不大。

7.2 常驻进程的内存泄漏

  • 常驻进程(如 Web 服务器、数据库、操作系统本身)一旦启动,通常不会退出。如果它们存在内存泄漏,会持续消耗内存,最终导致系统资源耗尽。

  • 僵尸进程就是一种典型的常驻内存泄漏------PCB 不被回收,持续占用内核内存。


八、Linux 内核链表设计:一个 PCB 可属于多个队列

8.1 传统链表 vs Linux 内核链表

  • 传统链表:节点内部包含 next/prev 指针和数据。一个节点只能属于一个链表。

  • Linux 内核链表:将 next/prev 指针单独封装成 struct list_head,然后将该结构体作为成员 嵌入到需要链接的数据结构中(如 task_struct 中的 tasksrun_list)。

复制代码
struct list_head {
    struct list_head *next, *prev;
};

struct task_struct {
    // ... 大量进程属性 ...
    struct list_head tasks;   // 用于全局进程链表
    struct list_head run_list; // 用于运行队列
    // ... 其他链表节点 ...
};

8.2 优点

  • 一个 task_struct 对象可以同时属于多个不同的链表 (例如全局进程链表、运行队列、等待队列等),只需在结构体中包含多个 list_head 成员。

  • 链表操作(增删改查)与具体数据解耦,实现代码复用。

8.3 如何从链表节点找到包含它的结构体?

内核提供了 container_of 宏,通过成员地址反推整个结构体的起始地址:

复制代码
#define container_of(ptr, type, member) ({ \
    const typeof(((type *)0)->member) *__mptr = (ptr); \
    (type *)((char *)__mptr - offsetof(type, member)); \
})
  • 遍历全局进程链表时,可以通过 tasks 节点的地址计算出对应的 task_struct 地址,从而访问所有属性。

8.4 运行队列与调度

  • 每个 CPU 有一个运行队列(runqueue),其中包含多个优先级数组(prio_array),每个优先级对应一个链表。

  • 进程的 run_list 节点被链入对应优先级的队列中。

  • 调度器通过位图快速找到非空最高优先级队列,实现 O(1) 调度


九、本节课总结

状态 含义 可被杀死? 典型场景
R 可运行(在运行队列中) 死循环计算
S 可中断睡眠(阻塞) scanf()sleep()
D 不可中断睡眠 磁盘 I/O 等待
T 暂停(Ctrl+Z 是(需 SIGCONT 恢复) 作业控制
t 追踪暂停(调试) gdb 断点
Z 僵尸 否(只能父进程回收) 子进程退出父进程未回收
X 死亡(不可见) --- 已完全释放

核心概念回顾

  1. 运行、阻塞、挂起:通过 PCB 在不同队列(运行队列、设备等待队列)之间的移动来实现状态切换;挂起时将代码和数据换出到 swap 分区。

  2. 僵尸进程 :子进程已退出,父进程未回收,PCB 残留,造成内存泄漏。必须由父进程调用 wait() 回收。

  3. 孤儿进程:父进程先退出,子进程被 init 收养,自动回收,不会成为僵尸。

  4. 内核链表list_head 的设计使得一个 PCB 可以同时属于多个数据结构,实现了灵活的组织方式。

下节课将介绍进程优先级、调度算法以及进程切换的详细过程。

相关推荐
脆皮炸鸡7552 小时前
进程的程序替换
linux·经验分享·笔记·vim·学习方法
划水的code搬运工小李2 小时前
ubuntu下使用opencode
linux·运维·ubuntu
爱学习的小囧2 小时前
ESXi 环境 NFSv3 与 NFSv4.1 哪个更稳?深度对比 + 选型指南 + 运维全教程
运维·服务器·网络·虚拟化
ZPC82102 小时前
Ubuntu 实时性优化(专属定制版,适配 fast_shm 通信)
linux·数据库·postgresql
郝学胜-神的一滴2 小时前
epoll 边缘触发 vs 水平触发:从管道到套接字的深度实战
linux·服务器·开发语言·c++·网络协议·unix
韩明君2 小时前
OpenClaw安全部署实现
linux·人工智能·安全·debian·本地部署·ai agent·openclaw
YJlio2 小时前
1 4.1 微软商店的使用(Microsoft Store:下载/安装/管理应用与游戏)
运维·hive·hadoop·windows·游戏·microsoft·计算机外设
要做一个小太阳2 小时前
数据库索引
运维·数据库
代码中介商2 小时前
Linux 文件操作系统调用完全指南:从 open 到 close
linux·运维·服务器