【Linux进程(二)】深入 Linux 内核:进程状态切换与底层数据结构的秘密

目录

一、前言

二、操作系统学科角度理解进程状态

1.运行状态

1."侵入式链表"

2.偏移量

2.阻塞状态

3.挂起状态

三、细分Linux系统角度理解进程状态

1如何补充:查看进程状态

2.S状态

1.可中断睡眠?

2.补充:前后台进程

3.T状态

补充命令:

4.Z状态

5.X状态

6.孤儿进程

四、下篇预告


一、前言

我们在用 ps 看进程时,常看到 R、S、T、Z 等状态。课本教了我们"运行、阻塞、挂起"的理论,但真实的 Linux 内核究竟是如何组织的?

为什么 task_struct 既能存在于双链表,又能同时挂在 CPU 调度队列中?已知内部成员地址,系统如何反推整个结构体的首地址?

本文将从 OS 理论出发,深入 Linux 内核源码,为你揭秘"侵入式链表"与"偏移量计算"的底层魔法。同时,结合实战代码,带你亲手复现进程的前后台切换、暂停(T状态)、僵尸(Z)与孤儿进程,让你不仅知其然,更知其所以然。

进程状态本质就是进程PCB里的一个整型变量(【进程(一)】中有提到~)


二、操作系统学科角度理解进程状态

下图就是操作系统学科常见的进程状态切换图:

博主在这里重点介绍运行态、阻塞态和挂起状态。

1.运行状态

权威说法:计算机中的CPU会维护一个自己的调度队列,这些处于调度队列中的进程我们就称为运行态。

还有一个说法是:进程已经获得 CPU,正在 CPU 上执行的指令的进程处于运行状态。单核 CPU 同一时刻通常只有一个进程处于运行态;多核可有多个。运行态进程可能因时间片用完转为就绪态,或因等待事件转为阻塞态。

本篇博客按权威说法向大家介绍!

疑问:既然task_struct在Linux内核已经被组织成双链表,它是如何同时作为调度队列的节点存在的呢?

1."侵入式链表"

task_struct 之所以能既属于双链表,又属于调度队列,是因为 Linux 内核使用了**"侵入式链表(Intrusive Linked List)"** 的设计思想。并不是把一个结构体强行塞进多个集合,而是 task_struct 内部内嵌了多个不同的"链表节点(list_head)",这些节点分别属于不同的数据结构。

在常规的编程思维里,如果我们要把数据放进链表,通常会在外部定义一个 Node:

cpp 复制代码
// 传统做法
struct Node {
    void *data;      // 指向你的数据
    Node *prev;
    Node *next;
};

但 Linux 内核反其道而行之,它把链表的"挂钩"直接塞进了业务结构体里面:

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


struct task_struct {
    // ... 大量进程属性 ...
    
    // 1. 这个节点用于把系统里所有进程串成一个双链表(tasks list)
    struct list_head tasks; 
    
    // 2. 这个节点用于放入调度器的运行队列(run queue)
    struct list_head run_list; 
    
    // 3. 这个节点用于放入等待队列(比如等待某个IO事件)
    struct list_head wait_chained; 
    
    // ... 其他属性 ...
};
  • 当系统需要遍历所有进程时,它顺着 task_struct->tasks 这个"钩子"去找;

  • 当 CPU 调度器需要找下一个要执行的进程时,它顺着 task_struct->run_list 这个"钩子"去找。

疑问:我们可以通过当前节点的 next 指针,获取到下一个 task_struct 内部 tasks 成员的地址,在已知struct内部元素地址的情况下,怎么反求结构体变量的起始地址?从而访问其他内部元素?

2.偏移量

概念

偏移量(Offset) :是指结构体中某个成员的地址,距离整个结构体首地址 的字节距离。

成员真实地址 - 偏移量 = 结构体首地址

利用公式得到结构体首地址后,就可以直接使用 C 语言的成员访问操作符(-> 或 .)来读取或修改里面的任意元素了。

博主这里用一个简单的结构体向大家说明是怎么计算一个结构体内部元素的偏移量的:

cpp 复制代码
struct obj
{
    int a;
    int b;
    char c;
    double d;
}

求d的偏移量:

&( ( (struct obj*) 0) ->d )
1.(struct obj*)0 的意思就是告诉编译器:"请把数字 0 当作一个指向 struct obj 类型的内存地址来看待。"

编译器处理过程:

2.当编译器看到 &(((struct obj*)0)->d) 时:

它知道基地址是 0。它去查 struct obj 的内存布局,计算出成员 d 相对于结构体开头的偏移量(假设是 16 字节)。因为有取地址符 &,编译器不会去读取地址里的内容,而是直接做数学加法:0 + 16 = 16。

3. 运行结果:

这行代码在编译阶段,会直接变成一个常量数字(比如 16)。

它不会生成任何访问内存的机器指令。

就是把0强转成结构体指针,是一个假想的结构体首地址。然后再指向内部元素d,因为假想的首元素地址是一个整数,然后取出来的地址就是一个整数。

当我们已知变量d的真实物理地址又知道了变量d的偏移量,按照公式就可以求得该结构体真正的首元素地址。

2.阻塞状态

进程因缺少某种资源无法运行下去,进程的PCB被链入到资源等待队列的状态称为阻塞状态。

与进程一样,操作系统管理底层硬件设备也是先描述再组织:使用结构体描述硬件设备。

1.进程由运行态转换为阻塞态

PCB被移出调度队列,重新链入到硬件结构体里的等待,队列里等待硬件资源状态准备好。

2.进程由阻塞态转换为运行态

硬件设备的状态为ready PCB被重新列入到调度队列。

疑问:操作系统是如何获取硬件设备的状态信息?

因为每个硬件设备维护了自己的一个结构体,结构体内部还包含os组织起来的双链表struct list_ head tasks,还可以通过使用偏移量的方式来访问硬件设备的状态。

3.挂起状态

补充:磁盘中有一块固定分区"swap",用于信息的换入换出。

在内存资源严重不足的极端情况下:

处于阻塞状态的进程PCB在等待队列中等待资源,一时半会儿不会被调回到运行队列,内存就把阻塞状态的进程在内存里的代码段和数据段换出到swap分区,此时进程处于阻塞挂起状态;内存把处于运行状态(在调度队列里排队等待调度)的进程在内存中的代码段和数据段换出到swap分区,此时进程处于运行阻塞状态。


三、细分Linux系统角度理解进程状态

为了弄明⽩正在运⾏的进程是什么意思,我们需要知道进程的不同状态。⼀个进程可以有⼏个状
态(在Linux内核⾥,进程有时候也叫做任务)。
下⾯的状态在kernel源代码⾥定义:

cpp 复制代码
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 */
};

R运⾏状态(running): 并不意味着进程⼀定在运⾏中,它表明进程要么是在运⾏中要么在运⾏ 队列⾥。
S睡眠状态(sleeping): 意味着进程在等待事件完成(这⾥的睡眠有时候也叫做可中断睡眠
(interruptible sleep))。
D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这
状态的进程通常会等待IO的结束。
T停⽌状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停⽌(T)进程。这个被暂停的 进程可以通过发送 SIGCONT 信号让进程继续运⾏。
X死亡状态(dead):这个状态只是⼀个返回状态,你不会在任务列表⾥看到这个状态。

1如何补充:查看进程状态

ps aux / ps axj 命令
a:显⽰⼀个终端所有的进程,包括其他⽤⼾的进程。
x:显⽰没有控制终端的进程,例如后台运⾏的守护进程。
j:显⽰进程归属的进程组ID、会话ID、⽗进程ID,以及与作业控制相关的信息
u:以⽤⼾为中⼼的格式显⽰进程信息,提供进程的详细信息,如⽤⼾、CPU和内存使⽤情况等

2.S状态

S睡眠状态(sleeping): 意味着进程在等待事件完成(这⾥的睡眠有时候也叫做可中断睡眠
(interruptible sleep))。

1.可中断睡眠?

OS有权利杀死S状态的进程:

疑问:状态后面的"+"是什么意思?

2.补充:前后台进程

直接给出结论:状态后面有一个"+",代表该进程是一个前台进程,前台进程有且仅有一个!

  • 当你输入 ./test & :Shell 创建了一个后台作业,分配作业号 [1] 和 PID 232541,然后把控制权立刻还给你。

  • 当你直接输入 ./test :它默认是前台作业。如果此时你按 Ctrl+Z,它会暂停,并且变成后台作业(状态变成 T 或 T+ 表示停止)。

前后台进程的区别

前台进程可以获取键盘数据,而键盘只有一个。所以前台进程有且只能有一个,而前后台进程可以同时向显示器打印数据。当我们运行一个前台进程的时候,我们在键盘里输入bash命令无效,这就是因为当前进程占领了键盘。如果我们仅在运行后台进程,我们此时bash就被调到前台,此时我们输入的bash命令就会被执行。

博主把可以看到S状态的测试代码贴在下面,读者感兴趣可以自己测试前后台进程切换,体会它们的区别:

cpp 复制代码
#include <stdio.h>
#include <unistd.h> // 包含 sleep 和 getpid 的头文件

int main() {
    // 1. 打印当前进程的 PID,方便我们去查它
    printf("进程已启动,PID 是: %d\n", getpid());
    printf("即将进入 sleep(100) ... 此时进程会变成 S 状态。\n");

    // 2. 调用 sleep,让进程进入 S 状态(睡 100 秒)
    // 在这 100 秒内,进程不占用 CPU,在等待定时器/信号唤醒
    sleep(100);

    // 3. 100秒后醒来,打印这句话
    printf("休眠结束,进程退出。\n");

    return 0;
}

3.T状态

如果后台进程非要去读键盘会怎样?

若一个需要获取键盘数据才能运行下去的进程被设置成了后台进程,则操作系统直接将该进程状态设置为T状态。也就是说当进程做不符合要求的工作时,操作系统直接把它设置成T状态。

补充命令:

jobs

作用是查看当前终端下有哪些后台作业或处于暂停状态的作业。执行后,会显示作业号(比如 1)、当前状态(Running 或 Stopped)以及对应的命令。其中带加号的是默认作业,带减号的是下一个作业,方便你直接操作。

fg

全称 Foreground,作用是把后台作业或暂停的作业调回前台运行。用法是 fg %作业号,如果不写作业号,默认调回带加号的那个。比如你之前实验的后台程序因为试图读键盘被暂停成了 T 状态,用 fg 把它拉回前台,它就能继续读键盘了。

kill -l

作用是列出系统支持的所有信号名称和编号。执行后会打印出一张信号表,你可以清楚地看到 9 代表 SIGKILL,15 代表 SIGTERM,19 代表 SIGSTOP,18 代表 SIGCONT 等。

kill -19 pid

作用是向指定进程发送 19 号信号(SIGSTOP),无条件暂停该进程。效果是让进程立刻冻结,状态变成 T。注意,这个信号和 9 号一样霸道,进程无法捕获、阻塞或忽略它,只能乖乖停下。

kill -18 pid

作用是向指定进程发送 18 号信号(SIGCONT),让被暂停的进程继续运行。如果它原本在后台,唤醒后继续在后台运行。它和 19 号是完美搭档,19 负责强制冻结,18 负责强制解冻。

4.Z状态

僵尸状态(Zombie):子进程已经退出,内存里的代码段和数据段已经被os释放。PCB需要维持一段时间,直到父进程来回收子进程的退出信息(调用 wait() 或 waitpid() 进行回收)。

特征:进程状态标识为 Z。大部分内存资源已释放,不占用 CPU;但保留了 task_struct(PCB)和 PID。

原因:父进程可能需要知道子进程是怎么退出的(正常退出还是崩溃),所以操作系统必须保留这个"遗骸"等父进程来读取。

危害:占用系统 PID 资源。大量堆积会导致系统无可用 PID,无法创建新进程。

处理:父进程主动调用 wait 系列函数回收;或父进程先退出,由 init/systemd 进程接管并自动清理。

5.X状态

一旦父进程读取了子进程的退出信息,子进程的状态就由僵尸状态转换为死亡状态,操作系统就会释放掉子进程的PCB。死亡状态一般很难看到,所以我们这里就不做测试了。

6.孤儿进程

1. 定义与产生原因

孤儿进程是指父进程先于子进程退出 ,导致子进程失去父进程的情况。

产生场景通常有两种:

父进程主动执行完毕或调用 exit() 退出;

父进程意外崩溃或被强杀(比如被 kill -9 杀死),而子进程还在继续运行。

2. 与僵尸进程的对比(核心区别)

僵尸进程是子死父未死(且父未回收) ,是个"死进程",保留 PCB 占着 PID。

孤儿进程是父死子未死,是个"活进程"。

该进程通常会被系统领养。系统回收孤儿进程的退出信息,避免内存泄露,孤儿进程默认会被切换为后台进程。

测试代码:

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

int main() {
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork failed");
        exit(1);
    } else if (pid == 0) {
        // 子进程
        printf("我是子进程。我的 PID: %d, 当前 PPID: %d\n", getpid(), getppid());
        printf("子进程开始休眠 30 秒...\n");
        sleep(30);
        // 30秒后醒来,此时父进程早已死亡
        printf("子进程醒来。我的 PID: %d, 现在的 PPID: %d\n", getpid(), getppid());
        exit(0);
    } else {
        // 父进程
        printf("我是父进程。我的 PID: %d, 子进程 PID: %d\n", getpid(), pid);
        printf("父进程立刻退出,让子进程变成孤儿...\n");
        exit(0); // 父进程直接退出
    }
}

通过测试,可以看出该进程被系统领养了~

四、下篇预告

那么,当多个进程同时就绪时,到底该让谁先运行?跑多久会被替换下来?下一篇,我们将继续深入内核,探讨进程优先级与进程切换,揭开 CPU 调度的神秘面纱。

相关推荐
R&L_201810011 小时前
LINUX CPU 热点分析
linux
CodeYp20192 小时前
第6讲:Ext 系列文件系统
linux·文件系统·ext
爱吃香菜的初学者2 小时前
十九.Linux——TCP协议(1)
linux·网络·tcp/ip
Rambo.Fan2 小时前
02 · 重建与修复手册(新服务器 playbook)
运维·服务器·django
Zevalin爱灰灰2 小时前
Linux入门第二部分(ARM裸机篇) 第三章 汇编LED驱动实验
linux·汇编·arm开发
EVEN-09132 小时前
Linux-IPC进程间通信
linux·运维·服务器
多弗朗皮卡丘2 小时前
Linux 进程信号
linux·运维·服务器
^乘风破浪^2 小时前
Kali配置i3wm教程1
linux·kali·i3wm
qeen872 小时前
【Linux】动静态库的链接与加载过程
linux·运维·系统编程