一、进程状态的核心概念

1.1 进程状态概念

进程状态是操作系统对进程“当前在干什么”以及“接下来该怎么对待它”的一个标记。它决定了调度器是否会把CPU分配给这个进程,也决定了它能响应哪些信号。

从内核源码的角度看,进程状态其实就是一个整数——存储在task_struct结构体的state字段中。我们常说的“运行”“睡眠”“僵尸”这些名字,只是为了让人类更容易理解而附加的概念标签。

1.2 内核中的状态定义

在 Linux 内核中,每个进程由一个 struct task_struct 结构体描述。这个结构体庞大而复杂,但与我们今天话题相关的是两个关键字段:

// 简化自 include/linux/sched.h
struct task_struct {
    unsigned int __state;        // 进程运行状态
    // ...
    int exit_state;              // 进程退出状态
    // ...
};
  • __state 字段:运行时的状态

__state 字段定义了进程在执行过程中的状态:

  • exit_state 字段:退出时的状态

当进程终止时,其状态信息存储在 exit_state 字段中:

1.3 用户视角 vs 内核视角

在 Linux 内核源码中,进程状态数组定义如下:
/*
*The task state array is a strange "bitmap" of
*reasons to sleep. Thus "running" is zero, and
*you can test for combinations of others with
*simple bit tests.
*/
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 */
};

底层视角:内核真的在乎这些名字吗?

不,内核只在乎那个整数。

当你在终端按下Ctrl+Z暂停一个前台进程时,内核做的事情本质上就是:

p->state = 4;   /* __TASK_STOPPED */
#p就是指向进程控制块(PCB /task_struct) 的指针

从这一刻起,调度器看到这个进程的state不是0,就不再分配CPU给它。当你输入fg命令继续运行时,内核又执行:

p->state = 0;   /* TASK_RUNNING */

整个过程中,没有任何代码关心“暂停”这个中文词。TASK_STOPPED 只是一个宏,预处理阶段就被替换成了数字 4。最终在 CPU 上执行的,就是一条 MOV 指令,把一个整数写进内存。

二、运行和阻塞状态

2.1概念定义

  • 运行状态(Running)指进程的指令正在 CPU 上被真正执行。此时,CPU 的程序计数器指向该进程的代码段,一条一条地取指、译码、执行。
  • 阻塞状态(Blocked,也称"等待状态"Waiting)是指进程因为等待某个事件发生而主动放弃 CPU,暂时无法继续执行的状态。此时,进程虽然还在内存中,拥有所有资源,但因为缺少某个条件而无法运行。

2.2 运行状态的唯一性

在单核 CPU 系统中,同一时刻最多只有一个进程处于运行状态。这是因为:

  • CPU 只有一个,一次只能执行一条指令

  • 多核 CPU 则每个核心可以有一个运行进程

2.3 TASK_RUNNING 的双重身份

在 Linux 内核中,TASK_RUNNING 状态巧妙地合并了经典模型中的“就绪”和“运行”两个状态。这意味着一个处于 TASK_RUNNING 的进程,要么正在 CPU 上执行,要么正排队等待被调度。判断的依据很简单:它是否在 CPU 的运行队列(Runqueue)中。

2.4 阻塞状态的细分

Linux 内核将阻塞状态进一步细分为两种:

为什么要分两种?

  • 可中断:适合大部分等待场景,用户可以终止。

  • 不可中断:适合关键操作(如等待硬盘 I/O 完成),不能被中断,否则可能导致数据损坏

假如操作系统只有一种阻塞状态(S,可中断睡眠),当进程向磁盘写入数据时会进入 S 状态等待磁盘完成。磁盘开始写入后(可能需要几百毫秒),此时如果操作系统内存严重不足,内核可能会选中这个 S 状态的进程直接杀掉以回收内存。进程被杀了,task_struct 被释放,但磁盘并不知情——它继续执行写入操作,完成后却发现等待它的进程已经不在了。磁盘不知道该把结果(成功或失败)返回给谁,只能将这块数据直接丢弃。最终结果是:数据写入状态不确定(可能半残废),文件系统可能损坏,用户却永远不知道发生了什么。这正是 D 状态(不可中断睡眠)存在的原因——处于 D 状态的进程,内核无论如何都不会杀掉它,从而保证磁盘 I/O 完成后一定能找到对应的进程,把结果安全地返回。

2.5 阻塞的原因

常见导致阻塞的事件包括:

2.6 *挂起状态

物理内存极度不足时,操作系统会将某些进程的内存数据临时移到磁盘交换区(swap分区),以腾出空间给其他进程。

    1. 左侧:阻塞进程与进程控制块(PCB)

    • 代码数据:进程的程序代码、变量、堆栈等数据,此时它们被唤出到了磁盘的 swap 分区。
    • 进程状态:这些进程本身处于阻塞状态(正在等待键盘、设备 2 等 I/O 事件),但因为内存紧张,被系统连带挂起,变成了阻塞挂起状态

    2. 上方:设备等待队列

    • 阻塞进程会被挂在对应设备的等待队列上,等待设备完成操作后唤醒它们。
    • 即使进程被换出到了磁盘,它的 task_struct 依然留在内存里,继续挂在等待队列上。

    3. 下方:磁盘与 Swap 分区

    • swap交换分区:磁盘上的一块专门区域,用来存放被换出的进程数据。
    • 唤出(Swap Out):把进程的代码数据从物理内存复制到磁盘 swap,释放内存空间。
    • 唤入(Swap In):当内存充足、且进程等待的事件发生后,把进程数据从 swap 读回内存,恢复执行。

    关键逻辑:阻塞 + 挂起的叠加

    • 第一步:进程阻塞进程发起 I/O 请求(如读取键盘输入),主动放弃 CPU,进入阻塞状态,并加入设备的等待队列。
    • 第二步:被系统挂起(换出)此时系统内存不足,内核会选择这些长期不运行的阻塞进程,将它们的代码数据换出到磁盘 swap,进程状态变为阻塞挂起

            注意:task_struct 仍然留在内存,挂在设备的等待队列上,只是进程的主体数据在磁盘。

    • 第三步:事件发生 + 唤入内存当键盘输入完成,内核会从等待队列中取出进程,此时:

      1. 若进程在内存:直接唤醒,转为就绪态。
      2. 若进程在 swap:先执行唤入操作,把数据读回内存,再转为就绪态,等待 CPU 调度。

    注意事项

    1. 阻塞进程即使被换出,也不会从设备等待队列中移除:因为 task_struct 还在内存,内核能找到它,设备完成后依然能正确唤醒。
    2. 挂起是内存管理的副产品:系统换出进程的目的是省内存,不是为了暂停它,暂停只是换出后的结果。
    3. 唤入唤出的成本很高:涉及磁盘 IO,所以内核只会在内存严重不足时,才会对阻塞进程执行换出。
    4. 在严重极端情况下,os甚至会将运行队列的末端进程代码数据换入到swap分区,这个称为运行挂起

    总结 

    操作演示

    • 运行状态R

    实时监控进程状态的脚本:

    while :; do ps ajx | head -1; ps ajx | grep process | grep -v grep; sleep 1; done

    printf 在输出到终端时,也会导致进程短暂进入 S 状态。

    演示代码:

    xqq@ubuntu-server:~/linux/moduleV$ cat myprocess.c
    #include <stdio.h>
    #include <unistd.h>
    int main()
    {
        while(1)
        {
            printf("hello world,pid:%d\n",getpid());
        }
        return 0;
    }
    

    我们发现在大量的R+状态中也有少量的S+,这是因为printf的原因,为什么 printf 也能导致 S+:

    printf 最终会调用系统调用 write()。而 write() 往终端写数据时,终端是一个慢速设备。所以:

    • 大部分时间:进程在等终端"吐出"字符,状态是 S+

    • 短暂时间:进程在 CPU 上执行 printf 和循环逻辑,状态是 R+

    但是printf 会让进程睡眠   ”这个结论,在慢速终端(传统 tty)上成立;在现代快速终端模拟器(pts)上,由于缓冲区几乎不会满,进程几乎不会因为输出而睡眠,所以大部分时间保持 R+。是现代系统行为的正常表现

    所以一个进程进入 S 状态,不一定需要显式调用 sleep。只要它执行了可能阻塞的系统调用(比如往终端 writeread 键盘、wait 等),当资源未就绪或对端太慢时,就会被内核挂起,显示为 S

    如果要全部显示R+,就不要等设备了也就是不要I/O直接将printf注释

    +代表什么意思?

    + 表示该进程属于前台进程组。所对应也就有后台进程组(没有+)

    xqq@ubuntu-server:~/linux/moduleV$ ./myprocess & 
    #                                   &表示后台运行

    后台进程的输出仍然默认会显示到终端。所以上面的图片就是输出到终端。我们可以将它重定向,将输出全部丢弃./myprocess > /dev/null 2>&1 &

    • 阻塞状态(S/D):

    阻塞状态分为两种,可中断睡眠(S) 和 不可中断睡眠(D)。两者都是进程“主动让出 CPU、等待某事件发生”的状态,但最核心的区别在于:能否被信号中断

    S状态演示:

    xqq@ubuntu-server:~/linux/moduleV$ cat myprocess.c
    #include <stdio.h>
    #include <unistd.h>
    int main()
    {
        printf("pid:%d ",getpid());
        int b=0;
        scanf("%d",&b);
        //while(1)
        //{
        //    printf("hello world,pid:%d\n",getpid());
        //}
        return 0;
    }
    

    三、停止状态,僵尸状态,死亡状态

    这三个状态都与进程的“终结”或“暂停”有关,但含义完全不同

    1.三个状态的快速对比


    2. 停止状态(TASK_STOPPED)

    含义:进程被暂停执行,但尚未终止,所有资源都在。

    触发方式

    • 用户按 Ctrl+Z(发送 SIGTSTP

    • 其他进程发送 SIGSTOP 信号

    • 调试器(gdb)设置断点(__TASK_TRACED

    恢复方式

    • 执行 fg / bg(发送 SIGCONT

    • 调试器继续执行

    内核代码

    // 收到 SIGSTOP 信号时
    p->__state = __TASK_STOPPED;   // 值为 4
    dequeue_task(rq, p);           // 从运行队列移除

    ps 输出显示T(stopped)

    示例

    • 用户按 Ctrl+Z(发送 SIGTSTP

    • 调试器(gdb)设置断点(__TASK_TRACED

    也就是说从进程调度的角度来看,断点的本质就是让被调试的进程进入暂停状态,不再被 CPU 调度执行。

    暂停状态(T / t)是操作系统提供的一种临时冻结机制。当进程可能在做一些“用户还没准备好”或“需要确认”的事情时,系统不是直接杀死它,而是先暂停,把决定权交还给用户


    3.僵尸状态(EXIT_ZOMBIE)

    含义:进程已经终止(执行了 exit()),但其 task_struct 仍然保留,等待父进程回收退出码。

    为什么需要僵尸状态?

    • 父进程需要知道子进程的退出状态(正常退出还是被信号杀死?退出码是多少?)

    • 子进程退出后,内核保留了 task_struct(包含 exit_code 等字段)

    • 父进程调用 wait() / waitpid() 读取后,内核才彻底释放

    产生条件

    1. 子进程先于父进程退出

    2. 父进程没有调用 wait() 回收

    内核代码

    // do_exit() 中的简化逻辑
    void do_exit(long code) {
        // ... 释放大部分资源 ...
        exit_notify(tsk);           // 通知父进程
        tsk->exit_state = EXIT_ZOMBIE;  // 设为僵尸
        schedule();                 // 让出 CPU,不再回来
    }

    ps 输出显示Z(zombie)

    危害

    • 占用 task_struct(几 KB)和 PID

    • 大量僵尸进程会耗尽 PID 资源,导致无法创建新进程

    如何清理

    • 杀死父进程,僵尸进程会被 init(PID=1)收养并自动回收

    • 修改父进程代码,正确调用 wait()

    操作演示:

    演示代码:

    xqq@ubuntu-server:~/linux/moduleV$ cat process.c
    #include <stdio.h>
    #include <unistd.h>
    int main()
    {
        pid_t id =fork();
        if(id == 0)
        {//子进程
            int count=5;
            while(count)
            {
                printf("i am child,i am running:%d\n",count);
                sleep(1);
                count--;
            }
        }
        else if(id>0)
        { //父进程,什么都不做
            while(1)
            {
                printf("i am parent,i am running...\n");
                sleep(1);
            }
        }
        else//fork失败
        {}
        return 0;
    }
    

    • 僵尸进程本质上就是“父进程欠的债”。只要父进程不调用 wait() 来“还债”(回收退出状态),子进程的 task_struct 就会永远(或直到父进程死亡)挂在进程表里,状态标记为 Z。也就内存一直被占用,也就是内存泄漏了
    • 僵尸进程 = 内核资源泄漏。每产生一个僵尸,就有一个 task_struct 和 PID 被永久占着,父进程不释放就一直占着,直到父进程退出。大量僵尸 = 系统无法创建新进程 = 离重启不远了。
    • 对于瞬间执行的程序,僵尸进程危害极小;对于常驻进程(如服务器、守护进程),僵尸进程是必须严防的死穴。

    内核结构体申请:

    内核不会为每个新进程都重新从系统申请 task_struct 内存,而是通过 SLAB 缓存机制 缓存已释放的 task_struct 对象。SLAB 缓存机制是内核的对象池:预先分配一批 task_struct,用完后不释放,放回空闲链表,下次直接复用。这套机制避免了频繁向伙伴系统申请/释放内存,大幅提高了进程创建和销毁的效率。僵尸进程本身(Z 状态)并不属于这个可复用池,只有在父进程回收后才会放回空闲列表,供后续进程复用。


    4.死亡状态(EXIT_DEAD)

    含义:进程已经被完全回收,task_struct 即将被释放。

    这个状态几乎看不到,因为它只是一个瞬时状态:

    ps 输出显示X(dead)—— 基本看不到,因为一闪而过

    注意TASK_DEAD 是运行时的死亡状态(state 字段),EXIT_DEAD 是退出状态(exit_state 字段),两者含义不同但相关。

    总结

    这边给一个形象的比喻:

    停止状态 = 睡着了(还能叫醒)
    僵尸状态 = 死了但没埋(等亲人来收尸)
    死亡状态 = 埋完了(彻底消失)

    四、调度队列与运行队列

    4.1 一个节点,多个数据结构

    之前说过所有的 PCB 对象被链入到一个全局双向链表中,那这个双链表是不是队列呢?答案是——不是

    在我们之前写的数据结构(链表、二叉树等)中,一个 malloc/new 出来的节点只能属于一种数据结构。但在 Linux 中的做法是:让一个 task_struct 节点,既可以属于一个双链表,又可以把相关进程放在一个队列里。

    核心技术:通过将链表节点嵌入结构体内部,实现一个对象同时隶属于多个数据结构。这就是 Linux 内核中的内嵌双向链表(Intrusive Linked List),它解决了普通链表节点 “一节点只能属于一个链表” 的痛点,是内核数据结构设计的精髓之一。

    先看几段源代码

    struct list_head

    struct list_head {
        struct list_head *next, *prev;
    };
    • 它本身不包含任何业务数据,只有前后指针。
    • 它的设计目标,就是嵌入到其他结构体中,让这些结构体 “自带链表能力”。

    task_struct定义

    struct task_struct {
        // ... 其他字段
        struct list_head run_list;   // 进程调度运行队列
        struct list_head tasks;      // 系统所有进程的全局链表
        struct list_head ptrace_children; // 被调试子进程链表
        struct list_head ptrace_list;     // 调试父进程链表
        // ... 其他字段
    };

    发现:一个task_struct里嵌入了多个独立的list_head成员

    • run_list:用来把进程挂到调度器的运行队列上
    • tasks:用来把进程挂到系统所有进程的全局链表上
    • ptrace_children/ptrace_list:用来挂到调试相关的链表上

    每个list_head成员,都可以独立加入不同的链表,这就实现了:一个进程对象,同时属于 N 个不同的链表 / 队列,而且完全不冲突。

    今天如果我要遍历这个进程,访问某个task_struct方法是使用 container_of 宏。

    container_of 的原理

    #define container_of(ptr, type, member) \
        ((type *)((char *)(ptr) - offsetof(type, member)))

    通过地址减法得到结构体起始地址后,必须强转成 struct task_struct *,编译器才能正确识别类型并允许访问成员。

    offsetof 的实现

    #define offsetof(type, member) ((size_t)&((type *)0)->member)
    • 假装结构体从地址 0 开始

    • 取成员的地址,这个地址值就是偏移量

    • 只在编译时计算,不真正访问地址 0

    核心计算task_struct 地址 = list_head 地址 - tasks 字段在结构体内的偏移量

    示例

    // 已知:链表节点的指针 ptr 指向 task_struct 中的 links 成员
    // 想求:task_struct 的起始地址
    
    // 偏移量 = links 在结构体中的位置
    offset = &((struct task_struct *)0)->links;
    
    // 起始地址 = 当前地址 - 偏移量
    address = (struct task_struct *)((char *)ptr - offset);

      Linux 内核通过让单个 task_struct 嵌入多个 list_head 链表节点,从而将一个二维的树形结构(父子关系)“拉伸”成了一个多维的网状结构。这让操作系统能以 O(1) 的复杂度从任意维度(全局、调度器、父进程、资源等待者)访问到同一个进程。

      4.2 核心机制:Per-CPU 运行队列

      为了高效调度,每个 CPU 核心都维护着自己的运行队列(也被称为调度队列)runqueue。所有处于 TASK_RUNNING 状态的进程(除了当前正在运行的)都挂在对应 CPU 的这个队列里等待。

      • 正在运行:进程被调度器选中,runqueue->curr 指向它,此刻它不在队列中。

      • 等待运行:进程在队列中排队,调度器会按策略(如 CFS 调度器的红黑树)从中选出下一个执行者。

      注意:Linux CFS 调度器不再使用传统普通队列,但由于队列比较好理解我们这边画的使队列的图

      4.3 队列模型下的状态管理

      在队列模型下:

      • 运行队列:存放所有"就绪"的进程(等 CPU)

      • 等待队列:存放所有"阻塞"的进程(等 I/O、等资源、等时间)

      当设备就绪时,内核会唤醒等待队列中的进程,将其从等待队列移到运行队列。

      4.4 阻塞的本质

      阻塞:等待某种设备或者资源就绪,管理设备 → 先描述在组织。

      阻塞是进程因为等待某个事件(如 I/O 完成)而主动让出 CPU,进入"暂停"状态,直到事件发生后才被唤醒,重新回到就绪队列。

      下面我举一个scanf的例子:

      进程 A 正在 CPU 上运行,执行到 scanf() 等待键盘输入。

      步骤 1:发起 I/O 请求

      进程 A 在 CPU 上执行 scanf(),这是一个系统调用。内核发现需要从键盘读取数据。

      步骤 2:检查设备状态

      操作系统去设备管理队列中找到键盘设备。检查键盘驱动程序的状态,发现没有输入数据

      步骤 3:进程状态变更:运行 → 阻塞

      • 内核将进程 A 从 运行队列 中取出。

      • 将进程 A 放入 键盘设备的等待队列 中。

      • 进程 A 的状态从 TASK_RUNNING 改为 TASK_INTERRUPTIBLE(可中断阻塞)。

      • 此时,进程 A 进入阻塞状态,不再参与 CPU 调度。

      步骤 4:进程让出 CPU

      • 内核触发调度,从运行队列中选出下一个进程 B 开始执行。

      • CPU 开始执行进程 B,进程 A 在键盘等待队列中沉睡。

      步骤 5:硬件就绪,发生中断

      • 用户按下键盘按键。

      • 键盘硬件产生中断,CPU 立即暂停当前指令(进程 B 被打断),跳转到键盘中断处理程序。

      关键点:是操作系统第一时间知道硬件就绪,而不是进程 A 自己知道。

      步骤 6:唤醒等待队列中的进程

      • 键盘驱动程序读取扫描码,转换为键值。

      • 内核查看键盘设备的等待队列,发现进程 A 正在等待。

      • 内核将进程 A 的状态改回 TASK_RUNNING

      • 内核将进程 A 从键盘等待队列中移除,重新放回运行队列的尾部

      • 此时,进程 A 进入就绪状态,等待被调度。

      步骤 7:获得 CPU,完成操作

      • 进程 A 在运行队列中排队,最终被调度器选中。

      • runqueue->curr 指向进程 A,进程 A 再次运行。

      • 内核将之前读取到的键盘数据复制到进程 A 的用户态缓冲区(scanf 的变量中)。

      • scanf() 返回,进程 A 继续执行后续代码。

      由此我们可以得出一个结论:线程状态变化,表现之一就是在不同队列中进行流动,本质就是数据结构增删查改

      五、调度算法之一:FIFO

      CPU 调度就是在 runqueue 中按照顺序依次选择一个 task_struct 进行调度。

      重要澄清:运行队列中的进程,状态都是就绪(Ready),而不是运行

      这也是常见的误解来源:Linux 内核将“就绪”和“运行”两个状态合并为一个 TASK_RUNNING,导致了很多人的混淆。

      六、状态流转示例

      1. 被唤醒:进入队列

      • 触发:等待的事件发生(如硬盘数据读好了、网卡数据到了)。

      • 动作:内核调用 wake_up_process()

      • 状态变化:进程从 阻塞 变为 就绪(即 TASK_RUNNING)。

      • 队列变化:进程被放入 运行队列 的尾部(在简化队列模型中)。

      2. 获得 CPU:离开队列并运行

      • 触发:调度器被调用(可能是当前进程主动让出或时间片用完)。

      • 动作:调度器从运行队列的头部选取一个进程。

      • 队列变化

        • 被选中的进程 从运行队列中摘下(出队)。

        • runqueue->curr 指针指向该进程。

      • 状态变化:该进程从 就绪 变为 运行(虽然内核都用 TASK_RUNNING 表示,但此刻它实际在 CPU 上)。

      3. 时间片耗尽:回到队列

      • 触发:时钟中断,内核检查发现当前进程的时间片用完。

      • 动作:调度器被触发,准备切换进程。

      • 队列变化

        • runqueue->curr 指向的当前进程被 放回运行队列的尾部(入队)。

        • 调度器再从队列头部选出下一个进程,让 runqueue->curr 指向它。

      • 状态变化:原进程从 运行 变回 就绪,新进程从 就绪 变 运行

      生活类比:奶茶店买奶茶的例子

      • 店员 = CPU(一次只能服务一个人)

      • 正在点单的顾客 = 运行状态

      • 排队队列 = 运行队列(存放就绪状态的进程)

      关键区别:

      • 正在点单的顾客(运行状态):不在排队队列里,直接占用店员(独享 CPU

      • 排队中的顾客(就绪状态):在队列中等待

      运行队列 = 调度队列 = runqueue(简称 rq

      它们都是指内核中用于管理所有"可运行"(TASK_RUNNING)进程的那个数据结构。

      "运行队列"——强调队列里的人都是"准备买奶茶"的状态

      "调度队列"——强调店员要从这个队列里"调度"下一个人

      Logo

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

      更多推荐