Linux 进程概念深度解析:从 `task_struct` 到进程状态、优先级、调度与上下文切换

程序躺在磁盘上时,只是一份静态文件;

当它被加载到内存、被操作系统管理、开始参与 CPU 竞争时,它才成为"进程"。

学习 Linux 系统编程,进程是绕不开的核心概念。

这篇文章会从操作系统如何"管理"讲起,逐步串起:

text 复制代码
冯诺依曼体系 -> 操作系统管理 -> 进程概念 -> PCB/task_struct
-> fork 创建进程 -> 进程状态 -> 僵尸/孤儿进程
-> 优先级 -> 并发/并行 -> 上下文切换 -> 调度队列

一、先问一个问题:程序和进程是一回事吗?

先看一段最简单的操作:

bash 复制代码
[zdt@lavm-ljd6tsvm2x process]$ ls -l myproc
-rwxrwxr-x 1 zdt zdt 16840 Jun 13 10:20 myproc

[zdt@lavm-ljd6tsvm2x process]$ ./myproc
hello process

myproc 在磁盘上时,它只是一个可执行文件;当我们输入 ./myproc 并让它运行起来,它才变成进程。

对比项 程序 program 进程 process
本质 磁盘上的静态文件 程序运行后的动态执行实体
是否有 PID 没有
是否参与调度 不参与 参与 CPU 调度
是否有状态 没有运行状态 有 R/S/D/T/Z 等状态
是否占用内存 不一定 代码和数据需要被加载到内存

所以第一句结论来了:

text 复制代码
程序 = 静态文件
进程 = 运行起来的程序 + 操作系统为它维护的管理信息

更准确一点:

text 复制代码
进程 = task_struct + 程序代码和数据

二、为什么程序必须进入内存才能运行?

这要从冯诺依曼体系说起。

CPU 不会直接从磁盘上执行程序。程序想运行,必须先从磁盘加载到内存,然后 CPU 按照指令地址一条一条取指、译码、执行。

可以把执行流程理解成:

text 复制代码
磁盘上的可执行文件
        |
        v
加载到内存
        |
        v
CPU 取指令、执行指令
        |
        v
操作系统管理这个运行实体

这也解释了为什么关机后正在运行的进程会消失:进程依赖内存中的运行现场,而内存是易失性存储。


三、操作系统怎么管理进程?

学习进程时,最重要的思维不是"背定义",而是理解操作系统的管理方法。

操作系统管理任何对象,基本都遵循一个套路:

text 复制代码
先描述
再组织

比如学校管理学生:

text 复制代码
描述学生 -> 学号、姓名、学院、成绩、状态
组织学生 -> 班级、年级、学院、名单

Linux 管理进程也是一样:

text 复制代码
描述进程 -> task_struct
组织进程 -> 链表、队列、调度结构

所以进程不是一个孤零零的程序,而是被操作系统描述、组织、调度和管理起来的执行实体。


四、PCB 与 task_struct:进程的"档案袋"

进程控制块叫 PCB,全称是 Process Control Block。

在 Linux 中,PCB 主要体现为内核里的 task_struct 结构体。它保存了操作系统管理一个进程所需要的大量信息。

task_struct 中的信息 作用
PID 进程的唯一标识
PPID 父进程 ID
状态 R、S、D、T、Z 等
优先级 调度时的重要依据
地址空间 进程看到的虚拟内存布局
文件信息 打开的文件、工作目录等
信号信息 信号处理方式与 pending 信号
上下文信息 被切换时保存 CPU 现场

一句话理解:

text 复制代码
task_struct 是操作系统眼里的进程。

用户看到的是程序输出,内核看到的是一份份 task_struct


五、用 Linux 命令观察进程

理论讲再多,不如直接观察一个进程。

先写一个一直运行的小程序:

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    while(1)
    {
        printf("I am a process, pid=%d\n", getpid());
        sleep(1);
    }
    return 0;
}

编译运行:

bash 复制代码
[zdt@lavm-ljd6tsvm2x process]$ gcc proc.c -o proc
[zdt@lavm-ljd6tsvm2x process]$ ./proc
I am a process, pid=21345
I am a process, pid=21345

另开一个终端观察:

bash 复制代码
[zdt@lavm-ljd6tsvm2x ~]$ ps axj | grep proc
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
21001 21345 21345 21001 pts/0    21345 S     1000   0:00 ./proc

常见字段说明:

字段 含义
PPID 父进程 ID
PID 当前进程 ID
PGID 进程组 ID
SID 会话 ID
TTY 关联终端
STAT 进程状态
COMMAND 启动命令

还可以看 /proc

bash 复制代码
[zdt@lavm-ljd6tsvm2x ~]$ ls /proc/21345
cmdline  cwd  environ  exe  fd  maps  status  task

[zdt@lavm-ljd6tsvm2x ~]$ cat /proc/21345/status | head
Name:   proc
State:  S (sleeping)
Tgid:   21345
Pid:    21345
PPid:   21001

/proc 是 Linux 留给学习者的一扇窗。很多内核维护的信息,都可以通过这个伪文件系统观察到。

/proc/[pid] 文件 作用
status 进程状态、PID、PPID、内存等信息
cmdline 启动该进程的命令行
environ 进程环境变量
cwd 当前工作目录
exe 可执行程序路径
fd 打开的文件描述符
maps 虚拟地址空间映射

六、PID 与 PPID:进程也有"家谱"

Linux 中每个进程都有 PID,每个普通进程也有父进程 PPID。

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    printf("pid : %d\n", getpid());
    printf("ppid: %d\n", getppid());
    return 0;
}

运行:

bash 复制代码
[zdt@lavm-ljd6tsvm2x process]$ gcc id.c -o id
[zdt@lavm-ljd6tsvm2x process]$ ./id
pid : 22001
ppid: 21001

为什么要有父子关系?

因为很多进程并不是凭空出现的,而是由已有进程创建出来的。比如你在 bash 中运行一个程序,通常就是 bash 创建了一个子进程来执行它。


七、fork:创建子进程

Linux 中创建子进程最经典的接口是 fork()

c 复制代码
#include <stdio.h>
#include <unistd.h>

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

    printf("hello process, pid=%d, ret=%d\n", getpid(), ret);

    return 0;
}

运行结果可能是:

bash 复制代码
[zdt@lavm-ljd6tsvm2x process]$ gcc fork_demo.c -o fork_demo
[zdt@lavm-ljd6tsvm2x process]$ ./fork_demo
hello process, pid=22001, ret=22002
hello process, pid=22002, ret=0

你会看到一行代码打印了两次,因为 fork() 之后,父子进程都会从 fork() 之后继续执行。

fork() 返回值规则:

情况 返回值
创建失败 小于 0
子进程 等于 0
父进程 子进程 PID

通常写法:

c 复制代码
pid_t ret = fork();
if(ret < 0)
{
    perror("fork");
}
else if(ret == 0)
{
    printf("I am child, pid=%d\n", getpid());
}
else
{
    printf("I am father, pid=%d, child=%d\n", getpid(), ret);
}

这里有一个非常关键的点:

text 复制代码
fork 不是让同一个进程返回两次,
而是创建了一个新进程,让父子两个进程都从 fork 之后继续执行。

八、写时拷贝:为什么父子进程地址一样,值却不同?

先看一个实验:

c 复制代码
#include <stdio.h>
#include <unistd.h>

int g_val = 100;

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        g_val = 200;
        printf("child : pid=%d, g_val=%d, &g_val=%p\n", getpid(), g_val, &g_val);
    }
    else
    {
        sleep(1);
        printf("father: pid=%d, g_val=%d, &g_val=%p\n", getpid(), g_val, &g_val);
    }
    return 0;
}

运行:

bash 复制代码
[zdt@lavm-ljd6tsvm2x process]$ gcc cow.c -o cow
[zdt@lavm-ljd6tsvm2x process]$ ./cow
child : pid=22102, g_val=200, &g_val=0x404040
father: pid=22101, g_val=100, &g_val=0x404040

现象很怪:地址一样,值却不一样。

这正是理解 Linux 进程独立性的好入口。

Linux 为了效率,fork() 后通常不会立刻把父进程的所有数据完整复制一份,而是采用写时拷贝 Copy-On-Write:

text 复制代码
fork 后父子先共享物理页
如果都只是读,就不复制
一旦某一方要写,内核再复制对应物理页

结论:

text 复制代码
父子进程代码共享,数据各自私有。

九、进程地址空间:用户看到的是虚拟地址

上面实验还有一个更深的问题:既然父子进程数据已经独立,为什么 &g_val 打印出来还是一样?

因为程序打印的是虚拟地址,不是物理地址。

每个进程都有自己独立的虚拟地址空间。进程以为自己拥有一整套连续的地址区域,但这些虚拟地址最终映射到哪里,由页表和内核决定。

可以用 /proc/[pid]/maps 查看进程的地址空间:

bash 复制代码
[zdt@lavm-ljd6tsvm2x process]$ ./proc
pid: 24100

[zdt@lavm-ljd6tsvm2x ~]$ cat /proc/24100/maps | head
00400000-00401000 r--p 00000000 fd:01 123456 /home/zdt/process/proc
00401000-00402000 r-xp 00001000 fd:01 123456 /home/zdt/process/proc
00402000-00403000 r--p 00002000 fd:01 123456 /home/zdt/process/proc
00600000-00621000 rw-p 00000000 00:00 0      [heap]
7ffdf6c9c000-7ffdf6cbd000 rw-p 00000000 00:00 0      [stack]

常见区域:

区域 说明
代码区 text 存放机器指令,通常可读可执行
数据区 data/bss 存放全局变量、静态变量
堆 heap malloc/new 动态申请的空间
共享库/mmap 动态库、内存映射区域
栈 stack 局部变量、函数调用现场

所以:

text 复制代码
虚拟地址相同,不代表物理地址相同。

这是进程隔离、安全性、写时拷贝都能成立的重要基础。


十、Linux 进程状态:R/S/D/T/t/Z/X

Linux 中常见进程状态:

状态 含义 常见场景
R Running,运行中或在运行队列中 CPU 密集型程序
S Sleeping,可中断睡眠 sleep、等待输入、等待事件
D Disk sleep,不可中断睡眠 等待底层 I/O
T Stopped,暂停 kill -STOP、Ctrl+Z
t Tracing stop,调试暂停 gdb 追踪
Z Zombie,僵尸 子进程退出,父进程未回收
X Dead,死亡 通常观察不到

注意一个高频误区:

text 复制代码
R 不一定表示正在 CPU 上运行,
也可能只是处于运行队列中,等待被调度。

1. 观察 S 状态

如果程序里有 sleep(1),大部分时间会处于 S 状态:

bash 复制代码
[zdt@lavm-ljd6tsvm2x ~]$ ps axj | grep proc
21001 21345 21345 21001 pts/0 21345 S 1000 0:00 ./proc

2. 制造 T 状态

bash 复制代码
[zdt@lavm-ljd6tsvm2x ~]$ kill -STOP 21345
[zdt@lavm-ljd6tsvm2x ~]$ ps axj | grep proc
21001 21345 21345 21001 pts/0 21345 T 1000 0:00 ./proc

[zdt@lavm-ljd6tsvm2x ~]$ kill -CONT 21345
[zdt@lavm-ljd6tsvm2x ~]$ ps axj | grep proc
21001 21345 21345 21001 pts/0 21345 S 1000 0:00 ./proc

3. 观察 R 状态

写一个纯 CPU 死循环:

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    printf("pid=%d\n", getpid());
    while(1)
    {
    }
    return 0;
}

运行后用 topps 观察,通常能看到它处于 R 状态,并且 CPU 占用较高。

bash 复制代码
gcc busy.c -o busy
./busy

十一、僵尸进程:子进程退出,父进程不回收

僵尸进程的形成条件:

text 复制代码
子进程已经退出
父进程还活着
父进程没有调用 wait/waitpid 读取子进程退出状态

示例代码:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    pid_t id = fork();
    if(id > 0)
    {
        printf("parent[%d] is sleeping...\n", getpid());
        sleep(30);
    }
    else if(id == 0)
    {
        printf("child[%d] exit now\n", getpid());
        exit(0);
    }
    return 0;
}

另开终端观察:

bash 复制代码
[zdt@lavm-ljd6tsvm2x ~]$ ps axj | grep zombie
PPID   PID  PGID   SID TTY      STAT COMMAND
23001 23002 23001 23001 pts/0    Z+   [zombie] <defunct>

僵尸进程不是"还在运行的进程",而是:

text 复制代码
程序已经退出,但退出信息还没被父进程读取,PCB 还没有被完全释放。

为什么内核要保留它?

因为父进程可能需要知道子进程的退出结果,比如任务是否成功、退出码是多少、是否异常退出。

正确回收方式:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        printf("child[%d] will exit with code 7\n", getpid());
        exit(7);
    }

    int status = 0;
    pid_t ret = waitpid(id, &status, 0);
    if(ret > 0)
    {
        if(WIFEXITED(status))
        {
            printf("wait child success, child=%d, exit code=%d\n",
                   ret, WEXITSTATUS(status));
        }
    }
    return 0;
}

运行:

bash 复制代码
[zdt@lavm-ljd6tsvm2x process]$ gcc wait_demo.c -o wait_demo
[zdt@lavm-ljd6tsvm2x process]$ ./wait_demo
child[23002] will exit with code 7
wait child success, child=23002, exit code=7

十二、孤儿进程:父进程先退出

孤儿进程形成条件:

text 复制代码
父进程先退出
子进程还在运行

示例:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        printf("child before: pid=%d, ppid=%d\n", getpid(), getppid());
        sleep(10);
        printf("child after : pid=%d, ppid=%d\n", getpid(), getppid());
    }
    else
    {
        printf("parent pid=%d exit soon\n", getpid());
        sleep(3);
        exit(0);
    }
    return 0;
}

运行后可能看到:

bash 复制代码
parent pid=24001 exit soon
child before: pid=24002, ppid=24001
child after : pid=24002, ppid=1

如果你的系统使用 systemd,也可能看到 PPID 变成 systemd 相关进程。

僵尸和孤儿很容易混,用表格区分:

对比项 僵尸进程 孤儿进程
谁先退出 子进程先退出 父进程先退出
子进程是否还在运行 不运行 还在运行
PCB 是否残留 残留 正常存在
谁来处理 原父进程 wait/waitpid 1 号进程或 systemd 领养
是否一定是问题 是,需要回收 不一定是问题

一句话记忆:

text 复制代码
僵尸:子先死,父不收。
孤儿:父先走,子还活。

十三、命令行参数与环境变量:进程启动时带了什么?

进程不是空着启动的。它启动时通常会携带命令行参数和环境变量。

c 复制代码
#include <stdio.h>
#include <string.h>

int main(int argc, char* argv[], char* env[])
{
    int i = 0;
    printf("argc = %d\n", argc);

    for(i = 0; i < argc; ++i)
    {
        printf("argv[%d] = %s\n", i, argv[i]);
    }

    for(i = 0; env[i] != NULL; ++i)
    {
        if(strncmp(env[i], "PATH=", 5) == 0)
        {
            printf("PATH = %s\n", env[i] + 5);
            break;
        }
    }
    return 0;
}

编译运行:

bash 复制代码
gcc args_env.c -o args_env
./args_env hello linux process

可能看到:

bash 复制代码
argc = 4
argv[0] = ./args_env
argv[1] = hello
argv[2] = linux
argv[3] = process
PATH = /usr/local/bin:/usr/bin:/bin

也可以从 /proc 观察:

bash 复制代码
cat /proc/$$/cmdline
cat /proc/$$/environ | tr '\0' '\n' | head

这里的 $$ 表示当前 shell 的 PID。


十四、进程优先级:PRI 与 NI

调度器要决定"下一个让谁上 CPU",优先级就是重要依据之一。

查看优先级:

bash 复制代码
[zdt@lavm-ljd6tsvm2x ~]$ ps -l
F S UID   PID  PPID C PRI NI ADDR SZ WCHAN  TTY          TIME CMD
0 S 1000 21345 21001 0  80  0 - 1050 hrtime pts/0    00:00:00 proc

字段说明:

字段 含义
PRI priority,进程优先级
NI nice 值,优先级修正值

nice 的直觉很好理解:

text 复制代码
nice 越大 -> 越"谦让" -> 越倾向于降低优先级
nice 越小 -> 越"不谦让" -> 越倾向于提高优先级

nice 值通常范围:

text 复制代码
-20 ~ 19

调整 nice:

bash 复制代码
[zdt@lavm-ljd6tsvm2x ~]$ nice -n 10 ./proc
[zdt@lavm-ljd6tsvm2x ~]$ renice 5 -p 21345
21345 (process ID) old priority 0, new priority 5

注意:

text 复制代码
nice 不是优先级本身,而是影响优先级的修正值。

普通用户通常只能把 nice 调大,降低自己进程的优先级;想调成负数提高优先级,一般需要 root 权限。


十五、竞争、独立、并发、并行

进程之间的关系可以从几个关键词理解。

概念 含义
竞争 多个进程争夺 CPU、内存、磁盘、网络等资源
独立 每个进程有自己的地址空间,互不直接干扰
并发 一个 CPU 在多个进程之间快速切换,看起来同时推进
并行 多个 CPU 核心真正同时执行多个进程

并发像一个厨师轮流炒三口锅;并行像三个厨师同时炒三口锅。


十六、上下文切换:CPU 为什么能轮流跑多个进程?

前面我们讲过,并发并不一定代表多个进程真的在同一时刻一起执行。对于单核 CPU 来说,同一时刻只能运行一个进程,但我们平时却感觉很多程序都在"同时运行"。

这背后的关键机制就是:CPU 在多个进程之间快速切换。

不过问题来了:CPU 里的寄存器只有一套,进程 A 要用,进程 B 也要用。那进程 A 被切走之前,它当前的寄存器数据、执行位置、栈信息应该保存到哪里?

答案是:保存到进程自己的上下文数据中。

从图中可以看到,CPU 内部的寄存器资源只有一份,但不同进程都需要使用这些寄存器。当进程 A 暂时被切走时,内核会把进程 A 当前的寄存器现场保存到它自己的上下文结构中;当进程 A 下次再次被调度回来时,内核再把这些数据恢复到 CPU 寄存器里。

所以,上下文切换不是简单的"让 A 停下,让 B 运行",而是一个完整的保存和恢复过程:

  1. 进程 A 正在 CPU 上运行。
  2. 时间片到了,或者进程 A 阻塞,或者出现更高优先级的进程。
  3. CPU 进入内核态。
  4. 内核保存进程 A 的上下文。
  5. 调度器选择下一个要运行的进程 B。
  6. 内核恢复进程 B 的上下文。
  7. CPU 返回用户态,进程 B 继续执行。

可以把 CPU 寄存器想象成一张公共书桌。进程 A 使用 CPU 时,桌上摆的是 A 的资料;A 被切走时,内核把 A 的资料装回 A 的档案袋;进程 B 上 CPU 时,内核再把 B 的档案袋打开,把 B 的资料摆到桌上。

这里的"档案袋",就是进程上下文。

1. 上下文里保存了什么?

上下文并不是一个抽象到摸不着的概念,它本质上就是进程下次继续运行时必须恢复的信息。

上下文内容 说明
程序计数器 PC/EIP/RIP 保存下一条要执行的指令地址
栈指针 SP/ESP/RSP 保存当前函数调用栈的位置
通用寄存器 保存计算过程中的临时数据
状态寄存器 保存 CPU 标志位和运行状态
内核栈信息 保存进程进入内核态后的调用现场
地址空间信息 保存页表、虚拟地址空间、内存映射等信息
调度相关信息 保存优先级、时间片、运行队列位置等信息

一句话概括:

只要是进程下次继续运行时不能丢的信息,都属于上下文的一部分。

2. 上下文保存在哪里?

这就要回到前面讲过的 task_struct

在 Linux 中,操作系统会为每个进程维护一个 task_struct。它不仅保存 PID、状态、优先级等基本信息,还会关联进程切换时需要保存的上下文数据。

从这张图可以看到,早期 Linux 内核中,task_struct 会关联 thread_struct、TSS 等和进程切换密切相关的数据结构。这些结构中会保存寄存器现场、栈信息以及其他 CPU 恢复进程运行所需要的数据。

可以这样理解:

task_struct 负责描述一个进程,而上下文数据负责保证这个进程被切走之后还能恢复回来。

也就是说,进程被切走时并不是"消失"了,而是它的运行现场被保存到了内核维护的数据结构里。等它再次被调度时,内核再根据这些数据把它恢复出来。

3. 为什么切换后还能接着运行?

假设进程 A 执行到下面这段代码附近时被切走:

c 复制代码
printf("step 1\n");
printf("step 2\n");
printf("step 3\n");

进程 A 被切走前,CPU 中可能保存着当前执行到哪里、栈顶在哪里、临时计算结果是什么等信息。内核把这些内容保存下来。等 A 再次被调度时,再把这些内容恢复回 CPU。

于是进程 A 就可以像"什么都没发生过"一样继续执行。

这也是为什么我们平时写程序时,很少感受到自己的进程被切走过。实际上,进程可能已经被切换了很多次,只是每次切回来时上下文都被恢复好了。

4. 上下文切换什么时候发生?

常见触发条件有:

触发情况 说明
时间片用完 当前进程运行时间到了,调度器选择其他进程
进程阻塞 比如等待键盘输入、磁盘 I/O、网络数据
主动让出 CPU 比如调用 sleep 或等待某个事件
更高优先级进程就绪 调度器可能切换到更重要的进程
中断或系统调用返回 内核有机会重新判断是否需要调度

例如下面这段程序:

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    while(1)
    {
        printf("pid=%d is running\n", getpid());
        sleep(1);
    }
    return 0;
}

每次执行到 sleep(1) 时,进程都会主动进入睡眠状态,不再占用 CPU。此时调度器就可以选择其他进程运行。

5. 上下文切换有成本

上下文切换虽然能让多个进程并发执行,但它不是免费的。

成本 原因
保存/恢复寄存器 CPU 现场需要写入和重新加载
切换内核栈 不同进程有不同的内核栈
切换地址空间 页表可能变化,影响内存访问
TLB 失效 虚拟地址到物理地址的缓存可能失效
Cache 命中率下降 新进程的数据可能不在 CPU 缓存里
调度器开销 内核需要选择下一个运行进程

所以进程并不是越多越好。进程太多时,CPU 可能会花大量时间在保存现场、恢复现场和调度选择上,而不是执行真正的业务代码。

可以使用下面命令观察系统上下文切换情况:

bash 复制代码
vmstat 1

输出中的 cs 字段表示 context switch,也就是上下文切换次数:

bash 复制代码
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 1  0      0 520000  32000 680000    0    0     1     2   60  120  1  1 98  0  0

这里的 cs 越高,说明单位时间内发生的上下文切换越多。适量切换是正常的,但如果切换次数异常高,就可能说明系统中进程或线程过多,或者存在频繁阻塞、唤醒的问题。

6. 小结

上下文切换可以总结成一句话:

CPU 寄存器只有一份,但进程有很多个。为了让多个进程轮流使用 CPU,内核必须在切换时保存当前进程的运行现场,并恢复下一个进程的运行现场。

这也是为什么进程能够"暂停后继续",为什么单核 CPU 能制造出并发效果,以及为什么过多进程会带来额外开销。


十七、Linux 2.6 O(1) 调度思想

早期 Linux 2.6 内核里有一个经典的 O(1) 调度器。现代 Linux 主要使用 CFS 完全公平调度器,但 O(1) 调度器很适合帮助我们理解调度器如何组织可运行进程。

这一节不要一上来就背"时间复杂度"。先抓住它要解决的问题:

text 复制代码
系统里可能有很多可运行进程。
调度器每次选下一个进程时,能不能不要从头到尾遍历一遍?

O(1) 调度器的答案是:用优先级数组组织进程,用 bitmap 快速定位非空队列

看图时建议按这个顺序看:

text 复制代码
runqueue
  -> active / expired
  -> prio_array_t
  -> bitmap + queue[140]
  -> task_struct 链表

1. runqueue:每个 CPU 的运行队列

在 SMP 多核系统中,每个 CPU 通常都有自己的运行队列 runqueue

它里面会记录当前 CPU 上和调度相关的信息,例如:

字段 作用
nr_running 当前运行队列中可运行进程数量
curr 当前正在 CPU 上运行的进程
idle CPU 空闲时运行的 idle 进程
active 指向当前正在参与调度的优先级数组
expired 指向时间片用完后暂存进程的优先级数组

注意:activeexpired 本质上是两个指针,它们会指向 array[0]array[1] 中的某一个。

2. prio_array_t:优先级数组

一个 prio_array_t 里大致有三类核心信息:

结构 作用
nr_active 这个数组里还有多少可运行进程
bitmap[5] 标记哪些优先级队列不为空
queue[140] 140 个优先级队列,每个队列挂同优先级进程

可以把 queue[140] 想象成 140 条队伍:

text 复制代码
queue[0]   -> 最高优先级队列
queue[1]   -> 次高优先级队列
...
queue[139] -> 较低优先级队列

同一个优先级的多个进程,会挂在同一个队列后面,形成 task_struct 链表。

3. bitmap:为什么能接近 O(1)

如果没有 bitmap,调度器想找最高优先级的可运行进程,可能要从 queue[0] 一直检查到 queue[139]

有了 bitmap 后,某个优先级队列是否为空,可以先由对应 bit 标记出来:

text 复制代码
bit = 1 -> 这个优先级队列非空
bit = 0 -> 这个优先级队列为空

于是调度器可以快速找到第一个置 1 的 bit,再定位到对应的队列,取出队首进程运行。

这就是 O(1) 调度器名字里的重点:

text 复制代码
选择下一个进程时,不需要随着系统进程总数增加而线性遍历。

4. active 与 expired:一轮调度如何循环

activeexpired 的分工很像两个篮子:

队列 可以怎么理解
active 当前这一轮还能竞争 CPU 的进程
expired 本轮时间片用完,等待下一轮的进程

执行过程可以理解成:

text 复制代码
1. 调度器从 active 中选择一个进程运行
2. 进程时间片用完后,被放入 expired
3. active 中的进程越来越少
4. active 为空后,交换 active 和 expired
5. 下一轮调度开始

对应到源码思想就是:

c 复制代码
struct prio_array *active = rq->active;
struct prio_array *expired = rq->expired;

if(active->nr_active == 0)
{
    swap(active, expired);
}

当然真实内核代码会更复杂,但学习阶段先抓住这个模型就够了。

5. 为什么这套设计很巧妙

它巧妙的地方在于:既按优先级区分了进程,又避免了每次调度都扫描所有进程。

设计 好处
queue[140] 不同优先级分开排队
bitmap 快速找到非空优先级队列
active/expired 避免某些进程一直霸占本轮调度
swap(active, expired) 一轮结束后快速进入下一轮

理解重点:

text 复制代码
O(1) 不是说进程执行时间是 O(1),
而是调度器选择下一个运行进程的过程尽量做到常数级。

十八、常用命令速查表

命令 作用
ps axj 查看进程父子关系、状态
ps -l 查看 PRI、NI 等优先级信息
top 动态观察进程和 CPU 占用
pidof name 查找指定程序 PID
kill -STOP pid 暂停进程
kill -CONT pid 继续进程
kill -9 pid 强制杀死进程
cat /proc/pid/status 查看进程状态信息
cat /proc/pid/maps 查看地址空间映射
nice -n 10 command 以指定 nice 值启动进程
renice 5 -p pid 修改已有进程 nice 值

十九、常见问题排查表

现象 可能原因 处理方式
ps 看到 Z 子进程退出,父进程未回收 父进程调用 wait/waitpid
子进程 PPID 变成 1 父进程先退出 孤儿进程被 init/systemd 领养
进程状态是 T 被暂停 kill -CONT pid
程序地址一样但数据不同 打印的是虚拟地址 理解写时拷贝和页表映射
CPU 占用很高 可能是死循环或计算密集任务 toppskill
优先级不符合预期 nice 值影响 PRI ps -lrenice

二十、全文总结

进程这部分内容看起来很多,但主线并不乱:

text 复制代码
程序运行起来成为进程
进程由 task_struct + 代码和数据组成
操作系统通过 PCB 描述进程
通过链表/队列组织进程
通过调度器分配 CPU
通过上下文切换保存和恢复运行现场
通过 wait/waitpid 回收子进程
通过优先级和 nice 影响调度倾向

最终要记住一句话:

进程不是程序本身,而是程序运行后,被操作系统描述、组织、调度和管理起来的动态执行实体。

当你能把 ps/procforkwaitpidkillnicetask_struct 串起来时,Linux 进程就不再是一组概念,而是一套能被观察、能被实验、能被解释的系统机制。


相关推荐
求知若渴,虚心若愚。1 小时前
Jenkins 自动化流水线(CICD)
运维·自动化·gitlab
凡人叶枫1 小时前
Effective C++ 条款26:尽可能延后变量定义式的出现时间
linux·开发语言·c++·effective c++
kebidaixu10 小时前
BCU 平台 RS485 驱动适配:从 THVD1406 到 ISO3082
linux
杨浦老苏10 小时前
家庭实验室监控仪表盘HomeLab-Monitor
运维·docker·监控·群晖
回忆2012初秋10 小时前
【Nginx】原理、配置与运维实战(2)
运维·nginx·策略模式
Urbano11 小时前
工装外套全制作流程、工序痛点及自动化设备升级方案
运维·自动化
映翰通朱工12 小时前
工业4G网关无公网IP远程运维实战(内网终端异地访问方案)
运维·服务器·网络·安全·智能路由器
洪晓露12 小时前
将 rke2 集群证书延长至 10 年
运维·服务器·数据库
谢平康12 小时前
解决用 rm 报bash: /usr/bin/rm: Argument list too long错
linux·运维·运维开发