# Linux 进程全景:从 PCB 到 O(1) 调度队列

Linux 进程全景:从 PCB 到 O(1) 调度队列

一、进程的身份证:PCB 与 task_struct

进程是程序的一次执行实例,而操作系统要管理进程,首先必须"认识"进程------也就是需要一种数据结构来记录描述进程属性的全部信息。这种数据结构被称为 PCB(Process Control Block,进程控制块),可以理解为每个进程的"身份证"。

PCB 中通常保存的信息包括:

  • 进程标识信息:PID、PPID(父进程 ID)、进程组、会话等;
  • 处理器状态信息:寄存器值、程序计数器(PC)、栈指针等,即"现场";
  • 进程调度信息:进程状态、优先级、时间片、所在队列指针等;
  • 内存管理信息:页表指针、虚拟地址空间布局等;
  • 文件与 I/O 信息:打开的文件描述符表、当前工作目录、信号处理信息等;
  • 记账信息:CPU 使用时间、用户/系统时间等。

在 Linux 中,PCB 的具体实现就是 task_struct 结构体 (定义于内核源码中)。它非常庞大,包含上百个字段,上面的每一个类别都能在其中找到对应成员。这里有一个值得建立的认知:操作系统课本上的 PCB 是抽象概念,task_struct 是 Linux 对这个概念的具体落地。学任何"理论概念"时都可以带一个习惯:它在内核源码里的实体长什么样?

每个 task_struct 通过 list_head 类型的双向链表节点挂在各种内核链表上(进程链表、等待队列、运行队列......),进程的组织本质上是结构体对象的组织。

我们可以在用户态直观地"看到"PCB 的影子:/proc/<pid>/ 目录下的 statusstatsched 等文件,内核会将 task_struct 中的字段格式化输出到这些伪文件中。


一·补、task_struct 的组织艺术:侵入式链表与 container_of

第一节说"进程的组织本质上是结构体对象的组织",这句话值得展开成一节,因为它牵出了内核代码中最精巧的一套设计------而它的起点,正是那个看似诡异的表达式 &((struct task_struct *)0)->tasks

补.1 &((T *)0)->m:一个表达式在算什么

先把这个式子钉死:对一个空指针假装解引用取成员、再取地址,编译器并不真的访问内存,而是直接在编译期做地址运算:

&((T∗)0)→m  =  0+offset⁡(T,m)  =  offset⁡(T,m) \&((T *)0) \rightarrow m \;=\; 0 + \operatorname{offset}(T, m) \;=\; \operatorname{offset}(T, m) &((T∗)0)→m=0+offset(T,m)=offset(T,m)

即「成员 mmm 在结构体 TTT 内部的偏移量」。这就是标准库 offsetof 宏的实现原理,内核进一步包装出 container_of(ptr, T, m)

container_of⁡(p)  =  p−offset⁡(T,m) \operatorname{container\_of}(p) \;=\; p - \operatorname{offset}(T, m) container_of(p)=p−offset(T,m)

即「拿着成员的地址,反推整个结构体的起始地址」。这是整套设计的数学核心------后面所有的链表操作都建立在这一步 O(1)O(1)O(1) 的地址减法上。

补.2 为什么不给链表配独立节点,而是把节点嵌进 task_struct

观察 task_struct 会发现一个奇特的现象:里面嵌着多个 list_head 成员------tasksrun......这不是冗余,而是设计的主旨。

一个进程同时要挂在多张链表上 :全局所有进程的双链表(tasks)、就绪运行队列、阻塞等待队列、兄弟/子进程树......每张表都要容纳这个进程。如果链表节点是"外部独立对象",每个节点里存 void *data 指回 task_struct,那么:

  • 每上一张链表就要额外分配一个节点(堆分配、内存碎片、失败处理);
  • 从节点找进程要一次间接跳转,而从进程反向找"我在哪张表的哪个节点"则完全做不到------只能遍历链表逐个比对 data
  • 摘除一个进程需要先搜索定位它是哪个节点。

换成侵入式链表(intrusive list,把节点嵌入对象本体)后,局面完全反转:

  • 节点即对象 。从链表上摘下一个节点,container_of 一步就拿到 task_struct,零搜索、零分配;
  • 删除天然是 O(1)O(1)O(1) 。进程结束时要把自己从运行队列、等待队列、全局进程表全部摘除------内核手里握着 task_struct 指针,每个 list_head 成员的地址唾手可得,直接做双向链表的指针缝合即可;
  • 一张表一条链,职责分离tasks 串起"所有进程的大表",回答"系统里有哪些进程";运行队列串起"就绪集合",回答"接下来谁上 CPU"。这两个问题在操作系统里是正交的两件事,用两个独立节点分别回答,互不干扰。

这也是"统一大表"与"调度队列"的关系:大表用于遍历与枚举(ps 背后的 /proc、kill 时的进程查找),调度用的是就绪/阻塞等专用队列,两者并存,靠的就是一个结构体里嵌入多个链节点。

补.3 不同的结构体对象也能用同一条链表吗------能,这正是精髓

能。list_head 只关心 next/prev 两个指针,它对"宿主是谁"一无所知------同一个节点类型可以嵌入 task_struct,也可以嵌入 inodefilepage......只要把节点地址按对应类型做 container_of,就能还原出任何宿主。这是一种异构链表

但这种异构有一个必须理解到位的前提:从链表里取出指针后,必须用节点真正所属的结构体类型去 container_of 链表本身不做类型检查------把 inode 的节点按 task_struct 还原,编译不报错(强转过了),运行时就是灾难。所以内核的约定是:每张链表语义上绑死一种对象,比如这条链是运行队列,那上面的节点一律来自 task_struct.run,由使用该链的代码保证类型一致。

链条的最后一环在于:list_head 节点自身不带任何数据,它唯一的用途就是被嵌入、被 container_of

节点地址  →− offset⁡  对象地址 \text{节点地址} \;\xrightarrow{-\ \operatorname{offset}}\; \text{对象地址} 节点地址− offset 对象地址

这就是 &((struct A*)0)->c 在内核里的全部意义:它是这条"地址 → 对象"回程路的标尺。 没有它,侵入式链表在 C 里无法泛型化;有了它,一个 16 字节的 list_head 就能服务于内核里成千上万种结构体,撑起了进程调度、文件系统、内存管理几乎全部的队列设施。

面试衔接:如果面试官问你 offsetof 的实现,写 &((type *)0)->member;如果再问 container_of,就是在此基础上做一次减法。能把这两个宏和"进程为什么要挂在多张链表上"联系起来讲,远比单独背宏定义有说服力。

二、进程的创建:fork 与写时拷贝

2.1 fork 的基本行为

fork() 用于创建一个子进程。调用一次 fork,系统中多出一条执行流:

  • 在父进程中,fork 返回子进程的 PID(一个大于 0 的值);
  • 在子进程中,fork 返回 0
  • 创建失败时返回 -1(如进程数达到上限)。

由此产生了经典的判断模式:

cpp 复制代码
pid_t pid = fork();
if (pid == 0) {
    // 子进程执行流
} else if (pid > 0) {
    // 父进程执行流
} else {
    // 创建失败
}

2.2 不是"一个函数返回了两个值"

初学者最大的困惑是:一个函数怎么会返回两个不同的值?

本质上,这是两个执行流各自执行了一次 return 。在 fork 的 return 语句执行之前,子进程的执行流(内核上下文)就已经在内核中被创建出来了------更准确地说,内核为子进程拷贝了父进程的内核栈、寄存器现场,并故意在两个内核上下文的返回寄存器中写入了不同的值(父进程那份写入子进程 PID,子进程那份写入 0)。之后父子两个执行流各自从 fork 返回处继续运行,读到的是各自上下文里准备好的返回值。

所以"一个函数返回两个值"只是表象,本质是:

  1. fork 之后存在两个执行流;
  2. 两个执行流"各自 return 了一次";
  3. 返回值的不同,是内核在创建子进程时往两份内核上下文中写入了不同数据。

这也可以解释为什么子进程中 fork 的返回值是 0 而不是子进程自己的 PID------0 是内核给子进程的"标记",子进程想知道自己的 PID 可以调用 getpid()

2.3 代码区与数据区:共享与写时拷贝

fork 之后,父子进程"共享代码区和数据区"这个说法需要精确化,否则容易产生误解:

  • 代码区:父子确实共享同一份物理代码页。代码区本身是只读的,不会触发写时拷贝,这种共享没有任何风险。
  • 数据区(以及堆、栈) :子进程并不是立刻获得一份数据拷贝 。fork 之后,父子进程的页表指向同一批物理页 ,只是内核把这些页表项标记为只读。任何一方尝试写入时,硬件触发缺页异常,内核这时才真正分配新的物理页、复制内容、重新映射,然后让写入继续执行。

这个"先标记只读、写时再真正复制"的机制就是 写时拷贝(Copy-On-Write, COW)

COW 的设计动机很朴素:fork 之后子进程常常立刻调用 exec 替换掉整个地址空间,如果 fork 时老老实实复制全部数据,这份拷贝就白白做了。COW 把复制推迟到"非复制不可"的那一刻,是惰性思想的典型应用。

这也解释了为什么父进程中 pid = fork() 这个赋值动作本身就会触发一次写时拷贝:pid 变量位于父子共享的只读数据页上,父进程写入返回值的瞬间触发缺页,内核为父进程复制该页------父进程拿到的 PID 就写在属于自己的新页上。而子进程一侧返回 0 的写入,同样会触发属于自己的那次拷贝。注意这两个机制的因果链是:返回值不同是内核写入内核上下文造成的,写时拷贝是用户空间变量赋值造成的,二者恰好都在 fork 返回前后发生,容易混在一起,但属于两个独立的环节。

可以用一段代码验证 COW 的存在:

cpp 复制代码
#include <unistd.h>
#include <cstdio>

int g_val = 100;

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        g_val = 200;                    // 子进程修改全局变量
        printf("child : g_val = %d, &g_val = %p\n", g_val, &g_val);
    } else {
        sleep(1);                        // 让子进程先跑
        printf("parent: g_val = %d, &g_val = %p\n", g_val, &g_val);
    }
    return 0;
}

输出中父子进程的 g_val 值不同,但地址相同------因为打印的是虚拟地址,虚拟地址相同而物理页已经是两份,这正是写时拷贝生效后的直接证据。


三、进程状态

Linux 内核中进程状态(task_struct->state)的核心取值及含义:

3.1 R(Running / Runnable)------ 运行 & 就绪

R 状态合并了运行和就绪两种情形:进程要么正在 CPU 上执行,要么被挂在运行队列(runqueue)中等待被调度。处于运行队列中就绪,一旦被调度器选中就转为运行。就绪与运行的区分对进程而言是透明的,都是 R。

3.2 S(Interruptible Sleep)------ 浅度睡眠(可中断睡眠)

S 状态是一种等待资源的阻塞状态:进程等待的某种条件尚不满足,比如等待键盘输入、等待网络数据到达、等待锁释放等。此时进程不会占用 CPU,而是被内核挂在对应资源的等待队列上(例如挂在键盘设备驱动的等待队列上)。

"可中断"的含义是:这种睡眠可以被信号 唤醒。比如你在终端 sleep 100 后按 Ctrl+C,SIGINT 信号就能把进程从 S 状态中唤醒并终止它。绝大多数阻塞场景都是 S 状态。

3.3 D(Uninterruptible Sleep)------ 深度睡眠(不可中断睡眠)

D 状态同样是等待资源,但不可被信号打断 。什么样的任务必须如此?------不完成就会产生严重后果的操作,最典型的例子是磁盘 I/O:进程向磁盘发起写请求后进入 D 状态等待 I/O 完成。如果这时允许信号把它唤醒,数据就只写了一半,文件系统一致性将被破坏。所以内核规定:必须等它等的那个资源真正就绪,进程才会脱离 D 状态。

一个 D 状态的进程连 kill -9 都杀不掉------这不是 bug,而是设计:杀死它的后果比等待它更糟。

3.4 T / t(Stopped / Traced)------ 暂停

  • T(Stopped) :进程被信号强行暂停,典型的是 SIGSTOP / SIGTSTP。与阻塞的本质区别是:阻塞时代码本身没有出错 ,进程是在正常等待资源;而 T 是被外部信号强制停下的,与资源是否就绪无关。SIGCONT 可以让进程继续。
  • t(Traced) :被跟踪暂停,专门服务于调试器。用 gdb 调试时打一个断点、进程运行到断点处,进程就进入 t 状态------此时调试器(gdb)通过 ptrace 系统调用附着在它身上,可以随时检查、修改它的现场。除了断点,strace 跟踪系统调用时也会使进程处于被跟踪状态。

3.5 Z(Zombie)------ 僵尸状态

进程退出时并不会立刻从系统中消失。它会保留一份退出信息 (exit status):如果正常结束,就是 main 的返回值或 exit() 的参数;如果被信号杀死,就是终止信号编号。这份信息保存在 task_struct 中,等待父进程通过 wait() / waitpid() 来读取------因为父进程往往需要知道子进程死得"正不正常"。

子进程已退出、但退出信息尚未被父进程读取的这段时期,进程就处于 Z 状态。此时进程的地址空间等资源已经被内核回收了,但保存退出信息的 PCB(task_struct)还在内核中占着位置

如果父进程从不调用 wait 回收,僵尸进程的 task_struct 就会一直堆积------这构成的是内核资源的泄漏(俗称内存泄漏),数量多了会耗尽 PID 空间或内核内存,导致无法创建新进程。

如果父进程先于子进程退出 ,子进程不会变成无人认领的僵尸:父进程退出时,子进程会被 init 进程(PID 为 1,现代系统中是 systemd)领养 ,成为"孤儿进程"。之后子进程退出时,由 init 负责回收,不会长期停留在 Z 状态。注意顺序:领养发生在父进程退出的那一刻,而不是子进程进入 Z 状态之后------init 领养一个还在正常运行的孤儿,并继续当它的"养父"。

验证方法:写一个 fork 后父进程不 wait 且不退出的程序,ps aux 可以看到子进程状态为 Z;在另一个终端 cat /proc/<zombie_pid>/status 能看到 State: Z (zombie)


四、进程优先级:PRI 与 NICE

Linux 的进程优先级由两个值共同刻画:

  • PRI(priority):进程的实际优先级,数值越小优先级越高;
  • NI(nice) :对优先级的"修正值",取值范围 −20∼19-20 \sim 19−20∼19,默认为 0。nice 值越小,进程越"不客气",优先级越高。

两者的关系是:

PRI=80+NIPRI = 80 + NIPRI=80+NI

两个值得注意的设计细节:

  1. 每次调整 NICE 值时,PRI 都从基准值 80 重新计算,而不是在旧 PRI 上累加。修改 NI 从 0 到 5,PRI 从 80 变 85;再把 NI 从 5 改到 -5,PRI 直接变为 75,而不是"在 85 上减 10"。这避免了多次调整后 PRI 漂移失控的问题。
  2. 由公式可知,PRI 的实际范围被限制在 60∼9960 \sim 9960∼99 之间。

修改 nice 值的命令:nice -n 5 ./a.out(启动时指定)、renice -n 5 -p <pid>(运行时调整)。普通用户只能把自己的进程 nice 值往大了调(降低优先级),只有 root 可以调小(提高优先级)------防止普通用户争抢系统资源。

这个 −20∼19-20 \sim 19−20∼19 共 40 档的范围先记住,它和下一节调度队列的槽位设计是一一对应的。


五、进程切换与时间片

5.1 分时系统与时间片

Linux 是典型的分时操作系统 :CPU 不会因为一个进程没执行完就一直占着它,而是给每个进程分配一段运行时间------时间片(time slice)。时间片耗尽,调度器就把当前进程从 CPU 上换下来,调度另一个就绪进程运行。由于时间片很短(毫秒级),用户宏观上感觉到的是多个程序"同时"在跑,这就是并发。

5.2 上下文与上下文切换

进程被换下去之后,下次回来时必须从被打断的那一点继续执行,而不是从头再来。所以进程切换(上下文切换)时,内核必须保存和恢复两样东西:

  • CPU 上下文(硬件上下文):程序计数器、各通用寄存器、栈指针等 CPU 现场,直接保存在 task_struct 中;
  • 地址空间信息:进程页表的指针(如 CR3 寄存器的值),恢复后进程的虚拟地址空间才重新生效。

保存现场 → 调度新进程 → 恢复新进程现场,这一套动作就是一次上下文切换。上下文切换是有成本的(保存恢复寄存器、刷新 TLB/缓存局部性损失),这也是"线程比进程轻量"的根本原因------线程切换不需要换地址空间。

5.3 /proc:一切皆文件

进程运行期间,可以在 /proc/<pid>/ 下看到以 PID 命名的目录,里面是描述该进程的各种伪文件:

bash 复制代码
ls /proc/1/          # 查看 init 进程的信息目录
cat /proc/<pid>/status   # 进程状态、PID/PPID、优先级等
cat /proc/<pid>/stat   # 进程在运行队列中的统计信息
cat /proc/<pid>/sched  # 调度相关统计

/proc 并不是磁盘上的真实文件,而是内核把内存中的数据结构(task_struct 等)动态映射成文件系统形式的接口。这正是 Linux **"一切皆文件"**设计哲学的体现:进程、内核参数、设备、内存信息......统统可以用统一的文件操作(open/read/write)来访问和配置。


六、调度队列:O(1) 调度器的位图与双数组

本节描述的是 Linux 2.6 的经典 O(1) 调度器结构(即配图所示),它已经被后来的 CFS(完全公平调度器)取代。但 O(1) 调度器把"如何组织大量不同优先级的就绪进程"这道题解得非常漂亮,至今仍是理解调度器设计的最佳入口。

6.1 140 个优先级队列

每个 CPU 都有自己独立的运行队列 runqueue(见题图)。runqueue 的核心是一个 prio_array_t 结构,其中包含一个 140 个槽位的队列数组 queue[140],每个槽位是一条进程链表:

cpp 复制代码
struct prio_array {
    unsigned int nr_active;         // 该数组中的进程总数
    unsigned long bitmap[5];        // 位图:160 bit,覆盖 140 个队列
    struct list_head queue[140];    // 140 条优先级队列
};

为什么恰好是 140?因为这些槽位与内核的优先级编号一一对应:

槽位范围 服务对象
0∼990 \sim 990∼99 实时进程(SCHED_FIFO / SCHED_RR,实时优先级)
100∼139100 \sim 139100∼139 普通进程(就是我们用户能调 nice 值影响的那批)

重点在 100∼139100 \sim 139100∼139 这 40 个槽位:它们与 nice 值的 −20∼19-20 \sim 19−20∼19 正好也是 40 档,对应关系为:

队列槽位=100+NI\text{队列槽位} = 100 + NI队列槽位=100+NI

nice 为 0 的进程挂入 100 号队列,nice 为 -20 的进程挂入 80 号队列,nice 为 19 的进程挂入 119 号队列。这不是巧合,而是刻意设计:用户态的 nice 接口与内核的优先级编号本来就是同一套东西的两种表达,调整 nice 值本质上就是把进程在内核中挪到对应的队列里去,调度器始终"根据进程的优先级进行调度"。

6.2 位图:O(1) 找到最高优先级

如果每次调度都挨个检查 140 条队列哪条非空,查找成本就退化了。O(1) 调度器的做法是为 140 个槽位配一张位图 bitmap:某条队列非空,就将其对应比特位置 1;空队列对应位置 0。

bitmap 共 5 个 unsigned long(5×32=1605 \times 32 = 1605×32=160 bit,足以覆盖 140 个槽位)。调度时只需找到 bitmap 中最低的那个 1 (有专门的 CPU 指令如 bsf 做这件事,一两条指令搞定),就能直接定位最高优先级非空队列,然后取该队列的队首进程。整个过程与系统中有多少进程无关,调度复杂度恒定------这就是 "O(1)" 名字的由来。

6.3 双数组:active 与 expired

图中可以看到,runqueue 中有两个 prio_array_t,分别由 activeexpired 两个指针指向。这是解决"饥饿"问题的核心机制:

  • active 数组:当前正在参与调度的进程集合。调度器只从 active 中挑选进程运行。
  • expired 数组 :时间片耗尽的进程,重新分配时间片后被移入这里;新创建的进程也被放入 expired 队列,等待下一轮调度周期。

这自然引出一个疑问:如果高优先级进程源源不断地进入 active,低优先级进程岂不是永远轮不到,造成饥饿?

答案是不会,关键在于两个数组的交换机制:

  1. active 数组中的进程总数只会越来越少------每个进程的时间片都是有限的,跑完就必须离开 active 进入 expired,没有任何进程能在 active 中无限续命;
  2. 当 active 为空(或调度器判断该切换周期了)时,内核执行一次 activeexpired 指针的交换------原来等着排队的 expired 摇身一变成为新的 active,所有进程在新一轮中重新获得时间片。

这个设计让"高优先级进程插队"有了一个硬边界:无论优先级多高,它只能在当前这一轮里优先,跑完本轮同样要去 expired 排队,低优先级进程最多等一轮时间就能被执行。饥饿在机制上被根除。

6.4 修改 nice 值的效率考量

active/expired 双数组还有一个附带的好处,体现在运行时调整 nice 值(renice)的场景:

如果直接修改一个正在 active 队列中排队进程的优先级,就必须把它从当前链表中摘除(断链),再按新优先级插入另一条队列 。而双数组设计允许内核"推迟执行"这个移动:进程在 active 中暂按旧的优先级 把当前时间片跑完,等它时间片耗尽、重新入队时,再按新的优先级插入 expired 队列。如此一来,省掉了一次运行中的断链重插操作。

配合第四节"PRI 从 80 重新计算"的规则------修改 nice 立即生效于 PRI 数值本身,但队列位置推迟到下次入队时才变更------数值语义与队列位置解耦,这正是 nice 值体系设计得干净的地方之一。


七、总结

主题 核心结论
PCB 描述进程属性的数据结构,Linux 具体实现为 task_struct,可在 /proc/<pid>/ 中窥见其内容
进程组织 list_head 侵入式节点嵌入 task_struct,container_of 一步还原对象地址,一个进程可同时挂在全局表、运行队列、等待队列等多张链表上
fork 一次调用产生两条执行流;返回值不同是内核往两份内核上下文写了不同数据;用户空间的变量赋值会触发写时拷贝
写时拷贝 数据页先共享并标记只读,写入时缺页、复制、重映射;fork 后立刻 exec 的典型场景下避免了无谓拷贝
进程状态 R(运行/就绪)、S(可中断浅睡眠)、D(不可中断深睡眠,如磁盘 I/O)、T/t(信号暂停 / gdb 追踪)、Z(僵尸,待父进程 wait 回收)
孤儿进程 父进程先退出时由 init 领养(此时领养,而非进 Z 后),退出后由 init 回收
优先级 PRI=80+NIPRI = 80 + NIPRI=80+NI,每次调整从 80 重新计算,NI 范围 −20∼19-20 \sim 19−20∼19
进程切换 时间片耗尽触发切换,上下文信息(寄存器现场 + 地址空间)保存于 task_struct,保证断点续跑
O(1) 调度队列 140 个 FIFO 队列对应内核优先级(100~139 对应 nice -20~19),位图定位最高非空队列,active/expired 双数组轮换杜绝饥饿
相关推荐
H_oRIZoN_1 小时前
Linux入门DAY38(手写 HTTP 客户端抓取天气 + 简易 HTTP 服务器实现元器件 Web 系统)
linux·linux应用编程
fangjianj1 小时前
windows环境反弹shell
linux·windows·web安全
明月不会写代码2 小时前
Ubuntu 下 Kitty 终端配置记录
linux·ubuntu
weipt2 小时前
利用wsl给windows电脑上安装一个ubuntu
linux·运维·ubuntu
xiaoye-duck2 小时前
《Linux 网络编程》深入理解 TCP 协议(二):序号、确认应答与流量控制机制详解
linux·网络·tcp
楚疏笃2 小时前
linux下给OpenCloudOS根目录扩容
linux·运维·服务器
dog2502 小时前
网络的时延抖动
linux·运维·网络
阿钱真强道2 小时前
01 嵌入式操作系统 | 课程导论与虚拟机安装 Ubuntu
linux·ubuntu·嵌入式·虚拟机
devpotato2 小时前
高可用系统如何定义可用性
服务器·网络·可用性测试