程序躺在磁盘上时,只是一份静态文件;
当它被加载到内存、被操作系统管理、开始参与 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;
}
运行后用 top 或 ps 观察,通常能看到它处于 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 运行",而是一个完整的保存和恢复过程:
- 进程 A 正在 CPU 上运行。
- 时间片到了,或者进程 A 阻塞,或者出现更高优先级的进程。
- CPU 进入内核态。
- 内核保存进程 A 的上下文。
- 调度器选择下一个要运行的进程 B。
- 内核恢复进程 B 的上下文。
- 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 |
指向时间片用完后暂存进程的优先级数组 |
注意:active 和 expired 本质上是两个指针,它们会指向 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:一轮调度如何循环
active 和 expired 的分工很像两个篮子:
| 队列 | 可以怎么理解 |
|---|---|
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 占用很高 | 可能是死循环或计算密集任务 | top、ps、kill |
| 优先级不符合预期 | nice 值影响 PRI | ps -l、renice |
二十、全文总结
进程这部分内容看起来很多,但主线并不乱:
text
程序运行起来成为进程
进程由 task_struct + 代码和数据组成
操作系统通过 PCB 描述进程
通过链表/队列组织进程
通过调度器分配 CPU
通过上下文切换保存和恢复运行现场
通过 wait/waitpid 回收子进程
通过优先级和 nice 影响调度倾向
最终要记住一句话:
进程不是程序本身,而是程序运行后,被操作系统描述、组织、调度和管理起来的动态执行实体。
当你能把 ps、/proc、fork、waitpid、kill、nice 和 task_struct 串起来时,Linux 进程就不再是一组概念,而是一套能被观察、能被实验、能被解释的系统机制。