封面

🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》【C++】【Linux】
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然

在这里插入图片描述


🏠博主简介
在这里插入图片描述


前言

在多任务操作系统中,“进程”是资源分配和调度的基本单位。理解进程,不仅要明白它在用户态的表现,更要深入内核,探究其状态流转的本质。许多初学者在学习 Linux 进程状态时,往往会卡在“为什么看得到代码在跑,状态却是休眠”、“一个进程怎么同时属于多个队列”等问题上。

本文作为系列文章的上篇,将带你从操作系统的通用进程理论出发,一步步剥离 Linux 内核源码,揭秘 task_struct 状态管理的底层逻辑,并剖析 Linux 独特的侵入式双链表设计。


一、操作系统的进程状态理论

1.1 OS 进程状态通论

在经典的操作系统理论中,进程状态的流动微观上表现为变量值的改变,宏观上则表现为 PCB(进程控制块)在不同数据结构(队列)之间的移动。
在这里插入图片描述

  • 运行状态(Running):并不意味着进程一定正在 CPU 上执行。它表明进程要么正在被 CPU 调度,要么处于运行队列(Run Queue)中,随时准备接受调度。它对应着传统理论中的“运行”“就绪”两个状态。

  • 阻塞状态(Blocked):当进程执行到需要等待某种外设资源或软件事件就绪(例如在 C 语言中调用 scanf 或 C++ 中调用 cin,程序停下来等待用户输入键盘数据)时,进程便无法继续执行。此时,操作系统不会让 CPU 空转等待,而是将该进程切入阻塞状态。

💡 深入理解底层队列管理
操作系统为了管理系统中的各种软硬件资源,采用了“先描述,再组织”的六字箴言。在内核中,每种硬件设备都有对应的数据结构描述。我们可以将其简化模拟为如下结构:

struct device
{
    int id;
    int vender;                  // 厂商信息
    int status;                  // 设备状态(是否就绪)
    void *data;                  // 设备读取到的数据缓冲区
    struct task_struct *wait_queue; // 该设备的等待(阻塞)队列
    struct device *next;
};

当 CPU 正在运行某个进程的代码,发现其需要读取键盘数据,而键盘此时并未被按下(status 为不活跃)时,操作系统就会将该进程的 PCB 从 CPU 的运行队列中移走,链接到键盘的等待队列(wait_queue)中。此时,该进程便永远不会被 CPU 调度,这便是阻塞状态的本质。

当用户按下键盘,硬件就绪,操作系统作为硬件的管理者第一时间触发中断并捕获这一状态。OS 查看对应设备的等待队列,发现指针不为空,于是将等待队列头部的进程状态修改为运行状态,并将其重新链回 CPU 的运行队列中。当 CPU 下一次调度该进程时,进程继续执行后续代码,正式从设备读取数据到自己的上下文空间中。

1.2 挂起状态的本质

当系统的内存资源严重不足时,操作系统为了保证整个系统的赖以生存,必须合理榨取内存空间。有些进程虽然在内存中,但由于处于阻塞状态,其代码和数据在短期内根本不会被访问,却白白占用了物理内存。

  • 阻塞挂起:为了缓解内存压力,操作系统在磁盘中开辟了一块被称为 Swap 分区 的空间。OS 会将处于阻塞状态进程的代码和数据置换出到 Swap 分区中,而在内存中仅仅保留该进程的 PCB。这种只有 PCB 留在内存、代码和数据被剥离到磁盘的进程状态,被称为阻塞挂起。

  • 运行挂起:在极端内存饥饿的情况下,即使是在运行队列末端的进程,其代码和数据也可能被暂时交换到 Swap 分区中,以腾出空间给高优先级的活跃进程。

📌 核心结论:挂起的本质是为了节省物理内存,将进程的代码和数据换出到磁盘的 Swap 分区。当条件具备、进程即将被调度时,OS 会将数据重新换入(加载)到内存中,重新构建指针映射。这一切对用户而言是完全透明的。

1.3 进程属于多个数据结构的奥秘(Linux 内核链表设计)

在经典的数据结构课程中,我们定义的双向链表通常是“业务数据包裹指针”:
在这里插入图片描述

struct Node
{
    int data;          // 业务数据
    struct Node *next; // 指向下一个整个节点的起始地址
    struct Node *prev; // 指向前一个整个节点的起始地址
};

通过这种链表遍历时,由于指针指向的是下一个结构体的起始地址,我们可以直接访问其中的所有属性。然而,在 Linux 内核中,一个进程的 PCB(task_struct)需要同时存在于全局进程链表、CPU 运行队列以及各个外设的等待队列中。如果采用传统设计,一个节点只能属于一种链表。

🔍 Linux 内核的侵入式双链表设计
Linux 内核反其道而行之,采用了“结构体包含链表节点”的侵入式设计:
在这里插入图片描述

// 纯粹的链表节点,不包含任何业务数据
struct list_head 
{
    struct list_head *next, *prev;
};

// 随机定义一个包含链表的数据结构
struct task_struct 
{
    int pid;
    int state;
    // 将链表节点作为结构体的成员
    struct list_head run_queue;  // 挂载到运行队列
    struct list_head dev_queue;  // 挂载到设备等待队列
};

当我们在链表中遍历时,我们只能拿到 list_head 的实际物理地址,如何通过它获取整个 task_struct 的其余属性呢?

在 C 语言中,结构体内部成员的内存布局是连续且线性增长的。Linux 内核利用了基于指针强转的数学偏置原理(即著名的 container_of 宏核心逻辑):

  1. 假设有一个虚拟的 task_struct 结构体变量位于内存的 0 号地址:(struct task_struct*)0
  2. 此时,该成员的物理地址,在数值上就等于它相较于整个结构体起始地址的偏移量(Offset):(&((struct task_struct*)0->run_queue))
  3. 在实际运行中,我们知道当前链表节点 run_queue 的真实物理地址(假设为 ptr),那么用这个实际地址减去偏移量,就能精准反推出整个 task_struct 结构体的起始地址:(struct task_struct*)next-&((struct task_struct*)0->run_queue)

**💡 举个例子:**假设某一时刻 run_queue 节点的实际物理地址是 0x2010,通过编译器计算得知 run_queue 在 task_struct 中的偏移量是 0x0010。那么 0x2010 - 0x0010 = 0x2000,即为该进程 PCB 的起始地址。

这种设计使得一个 PCB 在内核中只存在一份实体,却可以通过在内部嵌入多个 list_head 节点,同时隶属于多个不同的数据结构。

二、Linux 的进程状态详解

Linux 作为具体的操作系统,其进程状态的设计必须严密契合上述理论。在 Linux 内核源码中,进程状态被定义在一个字符指针数组中,其状态本质依然是整数(数组下标):

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 */
};

2.1 Linux 运行状态 R

我们编写一段死循环测试代码:

#include <stdio.h>
int main()
{
    while(1)
    {
        printf("hello world\n");
    }
    return 0;
}

在另一个终端执行 Shell 脚本实时监测该进程状态:while :; do ps axj | head -1; ps axj | grep mycode | grep -v grep; sleep 1; done
在这里插入图片描述
我们可以看到,打印出来的状态绝大多数时间都是 S+(休眠状态),很难捕捉到 R 状态。这是为什么?

  • 原因分析:因为 printf 的本质是向显示器这个硬件写入数据。CPU 的执行速度极快(纳秒级),而显示器作为外设相对极慢。进程在整个生命周期中,有 99.9 % 99.9\% 99.9% 的时间都在阻塞队列里等待显示器硬件就绪,只有不到 0.1 % 0.1\% 0.1% 的时间在 CPU 上跑。
  • 如何看到 R 状态:将代码中的 printf 删掉,纯粹做没有任何 I/O 操作的死循环数学运算,再次查看,进程状态就会稳定保持在 R。

在这里插入图片描述

  • 前后台后缀 +:R+ 或 S+ 表示进程在前台启动运行,此时它占领着 Shell 命令行终端,用户无法输入其他命令,可以通过 Ctrl+C 直接将其终止;若启动命令为 ./mycode &,则代表在后台运行,状态显示为 R 或 S(没有加号),此时不影响命令行交互,且 Ctrl+C 无法终止它,必须使用 kill -9 [PID] 强制杀掉

在这里插入图片描述

2.2 Linux 阻塞状态:S (sleeping) 与 D (disk sleep)

1. S (sleeping) 浅度睡眠 / 可中断睡眠
当进程正在等待某种软件或硬件资源就绪(例如等待键盘输入 scanf、或者调用 sleep() 挂起)时,进程处于 S 状态。这种状态是可以被外部信号中断的。如果进程卡在 S 状态,用户可以通过 Ctrl+C 或发送 kill -9 信号随时强行唤醒并终止该进程。
在这里插入图片描述

2. D (disk sleep) 深度睡眠 / 不可中断睡眠 / Disk Sleep

  • 经典高 I/O 冲突场景:某一进程需要向磁盘写入 100MB 的核心审计数据。写入过程需要一定的时间,进程在内存中进入阻塞状态等待磁盘返回“写入成功”或“写入失败”的结果。此时,操作系统恰好遭遇了极端的内存饥饿,OS 开始疯狂释放内存,看到了这个处于睡眠状态的进程。如果 OS 直接将该进程杀掉,随后磁盘发生空间不足写入失败,磁盘试图把错误报告给进程时,发现进程已经死了,这 100MB 核心数据便石沉大海,造成严重的资产损失。
  • 防误杀机制:为了解决这个问题,Linux 引入了 D 状态。当进程处于 D 状态时,它不响应任何外部信号。此时无论是 Ctrl+C、kill -9 还是系统级别的进程清理命令,通通无法将其杀死。它唯一的苏醒方式是:等待磁盘硬件完成高 I/O 操作,自身主动唤醒并转为 R 状态。

2.3 暂停状态:T (stopped) 与 t (tracing stop)

在多任务作业控制中,Linux 提供了暂停状态:

  • T (stopped):用户可以通过键盘按下 Ctrl+Z,或者向进程发送 SIGSTOP 信号(kill -19 [PID])让一个前台运行的进程强制进入暂停状态。OS 挂起该进程并不再调度,这往往是因为进程可能存在非法操作或需要等待用户的人工审查。可以通过发送 SIGCONT 信号(kill -18 [PID])让其恢复运行(恢复后默认进入后台运行)。
    在这里插入图片描述 t (tracing stop):一种特殊的暂停状态。在使用 gdb 对程序进行调试时,我们在代码某一行打上断点,运行程序后,程序会精准停在断点处。此时通过 ps 查看,该进程的状态便是 t,代表其正处于被追踪调试的暂停状态。

在这里插入图片描述

2.4 死亡状态 X 与 僵尸状态 Z

  • Z (zombie) 僵尸状态:当一个子进程退出(执行完 main 函数或调用 exit),而它的父进程并没有调用 wait() 或 waitpid() 系统调用来读取该子进程的退出状态信息时,该子进程就会转入僵尸状态
  • 存在的必要性:子进程被创建出来的核心目的是为了帮父进程完成某项任务。它死的时候,必须把“任务完成得怎么样(退出码/退出信号)”保留下来交代给父进程。因此,OS 会释放子进程的代码和数据,但强行保留其 PCB(task_struct)
  • 内存泄漏风险:如果父进程一直处于死循环而不调用回收函数,这个 PCB 就会一直驻留在内存中,形成常驻内存的“僵尸”,进而导致严重的系统级内存泄漏。
// 模拟僵尸状态
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{ 
    pid_t id = fork();
    if(id == 0)
    {
        int count = 5;
        while(count)
        {
            printf("我是子进程,我正在运行: %d\n", count);
            sleep(1);
            count--;
        }
        printf("子进程运行结束,准备退出,即将变成僵尸状态...\n");
        exit(0);
    }
    else
    {
        while(1)
        {
            printf("我是父进程,我正在运行,且我不打算回收子进程...\n");
            sleep(1);
        }
    }
    return 0;
}

通过 ps 观察,可以清晰看到退出的子进程状态后面带有 (失效的/僵尸的)字样,状态位变为 Z
在这里插入图片描述

  • X (dead) 死亡状态:一旦父进程成功调用 waitpid 获取到了子进程的退出信息,操作系统就会立刻将该进程的 PCB 彻底释放清除。这个释放的瞬时返回状态即为 X 状态。由于其消失得极快,我们在任务列表中几乎无法捕捉到

💡 内核小知识(SLAB 分配器):
频繁地销毁并重新申请 task_struct 空间会带来高昂的系统开销(内存碎片与初始化时间)。Linux 内部维护了一张废弃对象的缓存池链表(可视为内核对象池)。当进程从 Z 状态走向 X 状态时,内核并不真正 free 掉这块内存,而是将其标记为空闲并挂入缓存链表。当系统需要创建新进程时,直接从链表中摘取一个 PCB 对象进行重新初始化,这种技术被称为 SLAB 机制,大大加快了进程创建和释放的速度。

2.5 孤儿进程

  • 场景对调:如果子进程还在正常跑,而父进程因为某种原因提前退出了,那子进程后续退出进入僵尸状态时,由谁来回收它?
  • 定义与托管:这种父进程先退出、子进程后退出的情况,该子进程被称为“孤儿进程”。为了防止孤儿进程死后无人回收、导致内存泄漏,Linux 系统的 1号进程(init 或 systemd,即系统守护神) 会立刻挺身而出,自动“领养”该进程。
// 模拟孤儿进程
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        // 子进程
        while (1)
        {
            printf("我是一个子进程,pid: %d,ppid: %d\n", getpid(), getppid());
            sleep(1);
        }
    }
    else
    {
        // 父进程
        int cnt = 3;
        while (cnt)
        {
            printf("我是一个父进程,pid: %d,ppid: %d\n", getpid(), getppid());
            cnt--;
            sleep(1);
        }
        printf("父进程提前退出...\n");
    }
    return 0;
}

在这里插入图片描述

在这里插入图片描述
在父进程退出后,我们会观察到子进程的 PPID(父进程ID)骤然变成了 1。同时,由于它失去了原来的前台终端控制,它会自动转入后台运行(状态由 S+ 变为 S)。终止它需要使用 kill -9 [PID]。

总结

至此,我们深入探讨了 Linux 进程的静态行为、数据结构以及纷繁复杂的进程状态流转。理解了 task_struct 的设计精髓,就等于拿到了通往 Linux 内核大门的第一把钥匙。

然而,多进程在系统中交替运行,操作系统是如何保障它们井然有序、高效互不干扰地推进的?面对成千上万的就绪进程,CPU 又是如何在常数级别的时间内选出最适合的那一个?

我们将在为你全景解密多任务调度的动态运行机理。

资源分享:
【硬核Linux】打通OS任督二脉:从冯诺依曼到进程Fork,看完直呼过瘾!

【Linux工具链】从代码托管到精准追踪Bug:Git常用指令+GDB临时变量与调用栈剖析


【Linux工具链】编译效率革命:条件编译优化+动静态库管理+Makefile自动化,解决多场景开发痛点

Logo

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

更多推荐