一句话导读:这篇把进程状态之后最容易混在一起的几个问题串起来:父进程先退出会怎样、进程为什么需要优先级、nice 到底修正了什么、CPU 如何切走一个进程,以及经典 O(1) 调度模型如何用队列、位图和双队列解决"选谁运行"的问题。
写在前面
前面理解进程状态时,有一个很关键的结论:进程到底是运行、阻塞还是等待,本质上不是看它"心情如何",而是看它的 task_struct 被组织到了哪个数据结构里。
可运行进程在运行队列里;等待键盘、磁盘、网络等资源的进程,会被挂到对应资源的等待队列里。Linux 管理对象的思路可以概括为:先描述,再组织。进程由 task_struct 描述,再通过链表、队列、哈希表等结构组织起来。
这篇就在这个基础上继续往前走:既然 CPU 面前有很多可运行进程,那它先运行谁?运行到一半又怎么停下来?停下来以后,下一次如何接着原来的位置继续?

目录
- 孤儿进程:父进程先退出,子进程怎么办
- 为什么孤儿进程必须被领养
- 优先级 priority:资源稀缺时的先后顺序
- Linux 普通进程的
PRI、NI与 nice 值 - 调整优先级:
top、nice、renice与系统调用 - 多进程的四个基本属性:竞争、独立、并行、并发
- 时间片 time slice:死循环为什么不会天然卡死系统
- 进程切换 context switch:寄存器内容的保存与恢复
- 调度 scheduling 和切换 switching 的区别
- 经典 O(1) 调度模型:runqueue、140 个队列与 bitmap
- active / expired 双队列:为什么能缓解饥饿
- 新进程、抢占 preemption 与多 CPU 负载均衡
- 常见实习/面试问题小结
- 总结
1. 孤儿进程:父进程先退出,子进程怎么办
僵尸进程 (zombie process) 讨论的是:子进程先退出,父进程还活着,但父进程不回收子进程退出信息,于是子进程的 PCB 还要保留一部分退出状态。
孤儿进程 (orphan process) 正好从另一个方向理解父子关系:父进程先退出,子进程还在运行。这个时候子进程不会变成"没有父进程的进程",它会被 1 号进程领养。被领养后的子进程,就叫孤儿进程。

先写一个最小实验:父进程运行 5 秒后退出,子进程一直运行,并持续打印自己的 PID 和 PPID。
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
}
if (id == 0) {
while (1) {
printf("child: pid=%d, ppid=%d\n", getpid(), getppid());
sleep(1);
}
} else {
int cnt = 5;
while (cnt--) {
printf("parent: pid=%d, child=%d\n", getpid(), id);
sleep(1);
}
}
return 0;
}
这段代码里,fork() 返回两次:
- 在子进程中返回
0,所以进入if (id == 0)分支。 - 在父进程中返回子进程的
pid,所以进入else分支。 - 如果返回负数,表示创建失败,需要用
perror()输出错误原因。
子进程里用 getpid() 查看自己的进程 ID,用 getppid() 查看父进程 ID。父进程只循环 5 次,之后 main() 返回,进程退出;子进程是死循环,所以会继续活着。
对应的 Makefile 可以先写得很简单:
makefile
myprocess: myprocess.c
gcc -o myprocess myprocess.c
.PHONY: clean
clean:
rm -f myprocess
编译运行:
bash
make
./myprocess
再开一个终端持续观察:
bash
while :; do
ps -al | head -1
ps -al | grep myprocess | grep -v grep
sleep 1
done
刚开始会看到父子进程同时存在,子进程的 PPID 是父进程的 PID。大约 5 秒后,父进程退出,子进程仍然存在,此时子进程的 PPID 会变成 1。
⚠️ 易错点:Ctrl+C 通常只会作用在当前前台控制的进程组上。子进程被 1 号进程领养、逐渐转为后台状态后,可能不能再靠原来的终端中断干掉它,需要用 kill:
bash
kill -9 子进程PID
-9 对应 SIGKILL,是强制终止。平时优先用普通 kill PID,只有进程不响应时再用 -9。
2. 为什么孤儿进程必须被领养
1 号进程在较新的发行版里通常是 systemd,老系统里可能是 init。可以用下面命令查看:
bash
ps -p 1 -f
更早的启动阶段还会涉及 0 号进程、idle/swapper 等内核启动细节。复习进程父子关系时可以先抓住主线:普通用户态进程如果失去原父进程,会被 1 号进程或对应的子进程管理机制接管。

为什么要领养?反过来想最清楚:如果不领养,子进程将来退出时会进入僵尸状态,但原来的父进程已经没了,没有人调用 wait() 或 waitpid() 回收它的退出信息。这样 PCB 中保留的退出状态就可能长期留在系统里。
所以孤儿进程被领养,不是为了好看,而是为了让它未来退出时仍然有人负责回收。
💡 复习提示:现实里能处理一个孩子后续事务的通常是家人或公共管理系统;进程里也类似,能回收子进程退出状态的要么是父进程,要么是系统级的接管者。
3. 优先级 priority:资源稀缺时的先后顺序
优先级 (priority) 的本质是:衡量一个对象得到某种资源的先后顺序。
放到进程里,进程优先级就是:进程获得 CPU 资源的先后顺序。CPU 少,进程多,大家必然要排队;只要要排队,就要有规则决定谁先谁后。

这里要把优先级和权限 (permission) 区分开:
- 权限回答的是:能不能得到资源。
- 优先级回答的是:已经能得到资源时,谁先得到。
比如访问文件时,系统会根据进程的 UID、GID 等信息判断它对文件是拥有者、所属组还是 other。注意:系统不是直接和"人"打交道,而是通过进程承载用户的请求。也就是说,访问权限最终表现为"进程与文件之间的权限关系"。
💡 复习提示:权限像"有没有入场券",优先级像"入场后排第几"。不要把"先后问题"和"资格问题"混成一个概念。
4. Linux 普通进程的 PRI、NI 与 nice 值
用 ps -al 可以看到和优先级相关的列:
bash
ps -al
常见输出里会有 PRI 和 NI:
PRI:priority,进程最终展示出来的优先级。NI:nice value,优先级修正数据。
在这篇讨论的普通分时进程模型里,可以先用下面公式理解:
text
最终优先级 = 默认基准优先级 80 + nice 值

默认 nice 值是 0,所以普通进程默认看到的 PRI 常常是 80。nice 值越小,最终 PRI 越小,进程越优先;nice 值越大,最终 PRI 越大,进程越靠后。
nice 的范围不是无限的:
text
nice: -20 ~ 19
普通进程 PRI: 80 + [-20, 19] = 60 ~ 99

如果尝试把 nice 调成 -100,系统会把它限制到 -20;如果尝试调成 100,会被限制到 19。这样设计是为了避免用户随意把自己的进程调到极端高优先级,导致低优先级进程长期拿不到 CPU。
⚠️ 易错点:数值越低,优先级越高。PRI=60 比 PRI=99 更优先。
⚠️ 易错点:NI 严格说不是最终优先级,它是"修正数据"。最终调度看到的优先级需要结合基准优先级理解。
5. 调整优先级:top、nice、renice 与系统调用
先从命令行方法看。
启动一个进程时指定 nice 值:
bash
nice -n 10 ./myprocess
这表示以 nice=10 启动进程。普通进程默认基准按 80 理解时,最终优先级就是 90,优先级被调低。
进程已经启动后,用 renice 修改:
bash
renice -n 12 -p 25110
其中:
-n 12表示把 nice 值设置为12。-p 25110表示作用到 PID 为25110的进程。
也可以进入 top 后按 r,输入 PID,再输入新的 nice 值。
如果想在程序里查看或修改优先级,可以使用 getpriority() 和 setpriority():
c
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
errno = 0;
int nice_value = getpriority(PRIO_PROCESS, 0);
if (nice_value == -1 && errno != 0) {
perror("getpriority");
return 1;
}
printf("before: pid=%d, nice=%d\n", getpid(), nice_value);
if (setpriority(PRIO_PROCESS, 0, 10) == -1) {
perror("setpriority");
return 1;
}
errno = 0;
nice_value = getpriority(PRIO_PROCESS, 0);
if (nice_value == -1 && errno != 0) {
perror("getpriority");
return 1;
}
printf("after: pid=%d, nice=%d\n", getpid(), nice_value);
while (1) {
sleep(1);
}
return 0;
}
关键参数:
PRIO_PROCESS:表示按进程维度操作。- 第二个参数为
0:表示当前进程。 setpriority(..., 10):尝试把当前进程 nice 值设置为10。
返回值要注意:getpriority() 合法返回值可能是 -1,所以判断失败不能只看返回值,需要先把 errno 清 0,再结合 errno 判断是否真的出错。
编译运行:
bash
gcc -o prio_demo prio_demo.c
./prio_demo
⚠️ 易错点:普通用户通常可以把自己的进程优先级调低,也就是把 nice 调大;把 nice 调小、提高优先级往往需要更高权限。权限不足时,setpriority() 可能失败并设置 errno。
6. 多进程的四个基本属性:竞争、独立、并行、并发
理解优先级之后,要补上四个概念,它们是后面理解调度的地基。

竞争性:系统资源相对于进程数量永远是少的。CPU、内存、磁盘、网络带宽、显示器、键盘、鼠标等资源都要被多个进程共享。只要资源少,多个进程之间就必然存在竞争;有竞争,才需要权限、优先级和调度。
独立性:每个进程都有自己的 PCB、代码和数据。一个进程崩溃,通常不会直接把其他进程也带崩。更深入的独立性要结合进程地址空间、页表和虚拟内存继续理解。
并行 (parallelism):多个 CPU 或多个真正可同时执行的硬件执行单元上,同一时刻有多个进程同时运行。
并发 (concurrency):单 CPU 上任意一个瞬间只能运行一个进程,但通过高速切换,在一段时间内让多个进程的代码都向前推进。

💡 复习提示:如果没有特殊说明,分析多进程时优先用并发模型来想,因为它更能暴露调度、切换、上下文保存这些关键问题。
7. 时间片 time slice:死循环为什么不会天然卡死系统
分时操作系统 (time-sharing operating system) 的核心特点之一,是基于时间片进行调度。一个进程拿到 CPU 后,不是想跑多久就跑多久,而是临时拥有 CPU 一小段时间。
这段时间就叫时间片 (time slice)。

假设一个进程分到 1ms:
- 如果它在
1ms内执行完,就正常退出。 - 如果它没有执行完,时间片到了以后,会被操作系统从 CPU 上剥离下来。
- 它需要重新进入运行队列,等待下一次被调度。
这就解释了一个常见现象:死循环进程会让系统变卡,但不会天然把系统彻底卡死。因为它不能永久占有 CPU,时间片到了仍然会被切换下去,其他进程就还有机会响应。
当然,如果系统资源被大量消耗,或者内核态出现不可抢占的长时间执行,现象会更复杂。这里先抓住普通分时调度下的主线。
8. 进程切换 context switch:寄存器内容的保存与恢复
CPU 执行进程时,真正直接打交道的是进程的代码和数据。CPU 内部有大量寄存器 (register),用于保存当前执行过程中的临时信息。
常见寄存器包括:
PC/EIP:程序计数器或指令指针,保存下一条要执行指令的地址。ESP/EBP:和栈顶、栈帧相关。EAX/EBX/ECX/EDX:通用寄存器,可保存临时计算结果。CS/DS/SS/ES/FS/GS:段相关寄存器。EFLAGS:状态标志寄存器。CR0 ~ CR4:控制寄存器,和 CPU 控制状态有关。

这里最容易错的点是:寄存器是 CPU 内部的硬件空间,寄存器里的值才是某个进程当前运行到一半留下来的临时数据。
用 C 语言变量类比:
c
int a = 10;
a = 20;
int b = a;
a 这个变量空间没有变,但里面的内容可以从 10 变成 20。寄存器也是这样。CPU 寄存器空间通常只有一份,但不同进程运行时,会把自己的临时数据写进去。
进程切换要解决的问题是:进程 A 运行到一半被切走,下次不能从头开始,也不能丢掉临时计算结果。它必须从历史位置继续。

一次典型切换可以这样理解:
- 进程 A 正在 CPU 上运行,寄存器中保存着 A 的临时数据。
- A 的时间片到了,操作系统要把 A 切下去。
- 切走前,先把 A 对应的寄存器内容保存起来。
- 调度器选择进程 B。
- 如果 B 是老进程,就把 B 之前保存的上下文恢复到 CPU 寄存器中;如果 B 是第一次运行,就按它的入口地址等初始上下文开始。
- CPU 继续执行 B。
所谓进程上下文 (process context),在这里重点指硬件上下文 (hardware context):CPU 寄存器内容的快照。
那这些上下文保存到哪里?可以先理解为和 task_struct 绑定,通过 PCB 能找到。

早期 Linux 内核的 task_struct 里能看到 tss 这样的任务状态段 (Task State Segment) 相关结构,用来保存硬件上下文。现代内核实现已经演进,具体字段位置不必死背;复习时真正重要的是:上下文必须和具体进程绑定,切走时保存,切回时恢复。
💡 复习提示:保存不是把寄存器这个硬件搬走,而是把寄存器里的内容拷贝到进程对应的上下文保存区。
9. 调度 scheduling 和切换 switching 的区别
调度器 (scheduler) 做的事情可以拆成两类:
- 切换:把当前进程的上下文保存起来,把下一个进程的上下文恢复上来。
- 调度:从可运行进程集合中选择下一个应该运行的进程。
切换回答的是"如何换上去、还能接着跑";调度回答的是"下一个该选谁"。
前面讲时间片、寄存器和上下文,是为了解决切换;接下来讲运行队列、位图和双队列,是为了解决调度。
10. 经典 O(1) 调度模型:runqueue、140 个队列与 bitmap
为了方便理解优先级如何真正影响"选谁运行",这里使用经典 O(1) 调度模型来拆解。现代 Linux 默认调度器已经演进到 CFS 等模型,但这个经典模型非常适合把运行队列、优先级、位图和饥饿问题讲清楚。
首先,一个 CPU 对应一个运行队列 (runqueue)。如果有两个 CPU,就可以有两个运行队列;如果有四个 CPU,就可以有四个运行队列。

一个运行队列里,不是只有一个简单 FIFO 队列。为了体现优先级,可以把它理解成一组按优先级分桶的队列:
c
struct task_struct *queue[140];
这里的 queue[140] 可以理解为 140 个优先级队列的入口。Linux 优先级范围可分成两段理解:
0 ~ 99:实时优先级 (real-time priority),这篇不展开。- 普通分时进程可映射到剩余的普通优先级范围。
在这里讨论的普通进程里,PRI 范围是 60 ~ 99。可以把它映射到某个下标区间,再将同优先级的进程挂到同一个队列中。
text
宏观:先按优先级选队列
局部:同一个优先级队列内先进先出 FIFO
如果优先级为 60 的进程有 3 个,它们都进入同一个优先级队列;如果优先级为 61,进入另一个队列。这样调度器可以先看高优先级队列,再从对应队列头部取出一个 task_struct。
如果每次都从头遍历 140 个队列,虽然 140 是常数,但设计上仍然不够优雅。于是引入位图 (bitmap):

位图的含义:
- 位图中的一个 bit 对应一个优先级队列。
- bit 为
0:对应队列为空。 - bit 为
1:对应队列里有可运行进程。
如果用 unsigned int bitmap[5],一个 unsigned int 按 32 bit 理解,5 * 32 = 160 bit,足够覆盖 140 个队列,多出来的 bit 不使用。
调度时可以按下面思路走:
c
struct task_struct *pick_next_task(struct rq_elem *active)
{
if (active->nr_active == 0) {
return NULL;
}
int index = find_first_set_bit(active->bitmap);
if (index < 0) {
return NULL;
}
return pop_front(active->queue[index]);
}
这段伪代码表达的是:
- 先看
nr_active,如果活跃进程数为 0,就没有可选进程。 - 再查
bitmap,快速找到第一个有进程的优先级队列。 - 根据下标定位
queue[index]。 - 从队头取出一个 PCB。
这就是 O(1) 调度模型里"快速挑选"的直觉来源:不是在所有进程里线性找,而是通过优先级队列和位图快速定位。
11. active / expired 双队列:为什么能缓解饥饿
如果只有一组优先级队列,会出现一个问题:高优先级死循环进程时间片用完后,又回到高优先级队列尾部。只要前面的高优先级进程不断回来,低优先级进程可能长期等不到 CPU,这就是进程饥饿 (starvation)。
解决思路是引入两组队列:
active:当前轮次正在被调度的队列。expired:时间片用完但还没执行完的进程,被放入过期队列。

可以用一个结构体抽象这组数据:
c
struct rq_elem {
int nr_active;
unsigned int bitmap[5];
struct task_struct *queue[140];
};
struct runqueue {
struct rq_elem prio_array[2];
struct rq_elem *active;
struct rq_elem *expired;
};
初始化时:
c
struct runqueue rq;
rq.active = &rq.prio_array[0];
rq.expired = &rq.prio_array[1];
调度规则可以这样理解:
c
void scheduler_tick(struct runqueue *rq)
{
struct task_struct *next = pick_next_task(rq->active);
if (next == NULL) {
struct rq_elem *tmp = rq->active;
rq->active = rq->expired;
rq->expired = tmp;
next = pick_next_task(rq->active);
}
if (next != NULL) {
context_switch(next);
}
}
这里有一个很重要的点:交换的是两个指针的内容,不是把整组队列搬来搬去。指针也是变量,交换两个指针指向的对象,就可以让 CPU "看待队列的视角"发生变化。
当一个进程从 active 被调度走,时间片用完但没有执行结束,就不再放回 active,而是进入 expired。这样 active 会越来越少,expired 会越来越多。当 active->nr_active == 0 时,交换 active 和 expired,进入下一轮。
这个设计的好处是:已经在本轮运行过的进程不会立刻回到本轮继续插队;低优先级进程虽然仍然靠后,但至少有机会在当前 active 轮次中被轮到。
⚠️ 易错点:prio_array[2] 是两个"大结构",每个结构里都有计数器、位图和 140 个队列。active 和 expired 是指向这两个结构的指针。
⚠️ 易错点:这不是说低优先级一定和高优先级一样快,而是说调度器尽量避免"已经跑过的高优先级进程反复插到没跑过的低优先级进程前面"。
12. 新进程、抢占 preemption 与多 CPU 负载均衡
新进程来了应该放入 active 还是 expired?可以分两个角度看。
如果把新进程放入 expired,它处于"就绪但暂时不被本轮调度"的状态。这个理解和抽象操作系统原理里的就绪状态很接近。
但现代分时系统常常支持抢占 (preemption)。如果当前正在运行的是 PRI=80 的进程,此时来了一个 PRI=60 的高优先级进程,系统可能希望它尽快运行。要让它"插队",放到 expired 没有意义,因为 expired 要等 active 空了才会被切换过来;放入 active 才有可能排到更前,并触发后续抢占判断。

多 CPU 场景还要多考虑一步:每个 CPU 都有自己的运行队列。如果 CPU0 队列里挂了大约 100 个任务,CPU1 只有 20 个,把新任务继续塞给 CPU0 通常就不合理。系统需要根据调度策略选择负载较低的 CPU,让多个 CPU 尽量都忙起来,这就是负载均衡 (load balance)。
runqueue 里常能看到一些辅助信息,例如:
cpu_load:用于评估 CPU 负载。nr_switches:切换次数,切换越多通常说明越忙。nr_running:可运行进程数量。
真实内核会比这里复杂得多,但复习时先抓住这条主线:每个 CPU 一套运行队列,调度器既要考虑局部优先级,也要考虑整体负载。
13. 常见实习/面试问题小结
1. 什么是孤儿进程?为什么要被 1 号进程领养?
父进程先退出,子进程仍在运行,这个子进程会被 1 号进程或相应系统管理机制接管,称为孤儿进程。领养的核心意义是:子进程未来退出时仍然有人回收退出状态,避免僵尸进程长期存在。
2. 僵尸进程和孤儿进程有什么区别?
僵尸进程是子进程已经退出,但父进程还没有回收它的退出信息;孤儿进程是父进程先退出,子进程还在运行并被系统接管。
3. 优先级和权限有什么区别?
权限决定能不能访问某资源;优先级决定已经能访问资源时,谁先得到资源。进程调度里的优先级主要针对 CPU 资源的获取顺序。
4. PRI 和 NI 分别是什么?
PRI 是最终展示的优先级,NI 是 nice 值,是优先级修正数据。普通进程可先按 PRI = 80 + nice 理解,nice 范围为 -20 ~ 19,所以普通进程优先级常见范围为 60 ~ 99。
5. 为什么数值越小优先级越高?
这是系统设计约定。复习时记住结论:PRI=60 比 PRI=99 更容易先被调度。
6. 为什么不直接修改 PRI,而要引入 nice 值?
如果运行队列中的老进程直接改 PRI,它已经挂在某个优先级队列里,位置是否要立刻调整会很麻烦;不调整又会出现"显示优先级是 60,却仍挂在 80 对应队列"的不一致。引入 nice 后,可以把修正推迟到下一次重新入队时处理,更容易配合调度队列结构。
7. 进程切换保存的是什么?
保存的是 CPU 寄存器里的内容,也就是当前进程的硬件上下文,而不是把寄存器这个硬件本身保存起来。
8. 切换和调度有什么区别?
调度负责选择下一个进程;切换负责保存旧进程上下文、恢复新进程上下文,并让 CPU 从新进程的历史位置继续执行。
9. 什么是进程饥饿?
低优先级进程长时间得不到 CPU 资源,导致代码长期无法推进,就叫进程饥饿。优先级范围过大、调度策略不合理、已运行进程反复插队,都可能加重这个问题。
10. 为什么 O(1) 调度模型要用 bitmap?
如果逐个遍历优先级队列,查找成本不够理想。bitmap 用一个 bit 表示一个队列是否为空,调度器可以快速定位第一个非空队列,再从队头取出进程。
14. 总结
进程不是静态地"加载在那里",而是在队列、时间片、上下文和调度器之间动态流转。孤儿进程让父子关系的回收链条闭合;优先级解决 CPU 资源稀缺时的先后顺序;nice 让优先级调整能和调度结构配合;进程切换通过保存和恢复寄存器上下文,让进程能从历史位置继续;经典 O(1) 调度模型则用 runqueue、bitmap、active/expired 双队列,把"如何快速选中下一个进程"讲成了一套可以落地的数据结构。理解到这里,进程在脑子里就不再是一块静态 PCB,而是一组会被组织、选择、切走、恢复、再运行的动态对象。