】深入 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// 传统做法 struct Node { void *data; // 指向你的数据 Node *prev; Node *next; };但 Linux 内核反其道而行之它把链表的“挂钩”直接塞进了业务结构体里面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 语言的成员访问操作符-或.来读取或修改里面的任意元素了。博主这里用一个简单的结构体向大家说明是怎么计算一个结构体内部元素的偏移量的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源代码⾥定义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]和 PID232541然后把控制权立刻还给你。当你直接输入./test它默认是前台作业。如果此时你按CtrlZ它会暂停并且变成后台作业状态变成T或T表示停止。前后台进程的区别前台进程可以获取键盘数据而键盘只有一个。所以前台进程有且只能有一个而前后台进程可以同时向显示器打印数据。当我们运行一个前台进程的时候我们在键盘里输入bash命令无效这就是因为当前进程占领了键盘。如果我们仅在运行后台进程我们此时bash就被调到前台此时我们输入的bash命令就会被执行。博主把可以看到S状态的测试代码贴在下面读者感兴趣可以自己测试前后台进程切换体会它们的区别#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 代表 SIGKILL15 代表 SIGTERM19 代表 SIGSTOP18 代表 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_structPCB和 PID。原因父进程可能需要知道子进程是怎么退出的正常退出还是崩溃所以操作系统必须保留这个“遗骸”等父进程来读取。危害占用系统 PID 资源。大量堆积会导致系统无可用 PID无法创建新进程。处理父进程主动调用wait系列函数回收或父进程先退出由init/systemd进程接管并自动清理。5.X状态一旦父进程读取了子进程的退出信息子进程的状态就由僵尸状态转换为死亡状态操作系统就会释放掉子进程的PCB。死亡状态一般很难看到所以我们这里就不做测试了。6.孤儿进程1. 定义与产生原因孤儿进程是指父进程先于子进程退出导致子进程失去父进程的情况。产生场景通常有两种父进程主动执行完毕或调用exit()退出父进程意外崩溃或被强杀比如被kill -9杀死而子进程还在继续运行。2. 与僵尸进程的对比核心区别僵尸进程是子死父未死且父未回收是个“死进程”保留 PCB 占着 PID。孤儿进程是父死子未死是个“活进程”。该进程通常会被系统领养。系统回收孤儿进程的退出信息避免内存泄露孤儿进程默认会被切换为后台进程。测试代码#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 调度的神秘面纱。