一、为什么需要优先级
CPU 资源分配的先后顺序,就是进程的优先权(priority)。
优先权高的进程有优先执行权。配置进程优先级对多任务环境的 Linux 很有用,可以改善系统性能;还可以把进程绑到指定 CPU 上,把不重要的进程安排到某个 CPU,进一步改善整体性能。
📌 现实里的例子:ssh 会话要秒响应(优先级高),批量压缩文件可以慢慢跑(优先级低)。两者抢 CPU 时,你会希望交互优先。
二、怎么查看优先级:ps -l
ps -l
你会看到类似这样的输出(示意):
F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
0 S 1000 12345 1230 0 80 0 - 4281 wait pts/0 00:00:00 bash
0 R 1000 12346 12345 0 80 0 - 1120 - pts/0 00:00:00 ps
重点字段:
| 字段 | 含义 |
|---|---|
UID |
执行者身份 |
PID |
进程代号 |
PPID |
父进程代号 |
PRI |
进程可被执行的优先级,值越小越早被执行 |
NI |
进程的 nice 值(优先级的修正数值) |
三、PRI 和 NI 到底是什么关系
3.1 PRI 是"最终优先级"
PRI 就是进程的优先级,通俗说就是程序被 CPU 执行的先后顺序,此值越小,进程优先级越高。
3.2 NI 是"修正值"
nice 值表示进程可被执行的优先级的修正数值。
关系式(课件原句):
PRI(new) = PRI(old) + nice
推导一下:
- nice 是负值 →
PRI(new)变小 → 优先级变高 → 更快被 CPU 执行; - nice 是正值 →
PRI(new)变大 → 优先级变低 → 更慢被 CPU 执行。
所以:
调整进程优先级,在 Linux 下就是调整 nice 值。
3.3 nice 的范围
nice ∈ [-20, 19],一共 40 个级别
-20:最"不善良"------抢 CPU 最凶(最高优先级);19:最"善良"------最愿意让出 CPU(最低优先级)。
📌 想想为什么叫 nice:「你越 nice(谦让),越不容易抢到 CPU」,命名很形象。
3.4 为什么单独需要一个 NI,不直接把 PRI 给你改?
进程的 nice 值不是进程的优先级,它们不是一个概念 ,但 nice 值会影响优先级变化。 可以把 nice 值理解为优先级的修正数据。
好处有三:
- 好算:PRI 是"算出来的最终结果",nice 只是"我额外让/抢了多少",语义清晰;
- 范围可控:nice 只有 40 档,用户随便调也不会把系统调崩(否则设成一个极端值就能饿死所有进程);
- 好判断"谁在捣乱" :
ps里看到谁 NI 是负数,就知道是谁在抢资源。
四、怎么改优先级
4.1 用 top 改(已运行进程)
top
# 进入 top 后按 r → 输入进程 PID → 输入 nice 值
4.2 用命令改
nice -n -5 ./a.out # 以 nice=-5 启动(普通用户通常不允许负值)
renice -n 10 -p 12345 # 把 PID 12345 的 nice 改成 10
📌 普通用户只能 把自己的进程 nice 值调大(降低优先级);调小(提高优先级,负值)需要 root 权限。这是保护机制,否则谁都能抢占 CPU。
4.3 用系统调用改
#include <sys/time.h>
#include <sys/resource.h>
int getpriority(int which, int who);
int setpriority(int which, int who, int prio);
which 常见取值 PRIO_PROCESS,who 是 pid(0 表示自己)。
五、四个必须分清的概念:竞争、独立、并行、并发
这一组是面试高频,定义要背准。
5.1 竞争性
系统进程数目众多,而 CPU 资源只有少量,甚至只有 1 个,所以进程之间是具有竞争属性 的。 为了高效完成任务、更合理地竞争相关资源,便有了优先级。
竞争 → 需要优先级 → 需要调度。这是因果链。
5.2 独立性
多进程运行,需要独享各种资源,多进程运行期间互不干扰。
这个"独立",技术上靠虚拟地址空间 + 写时拷贝保证(第 6 篇会讲透)。
5.3 并行(parallel)
多个进程在多个 CPU 下分别、同时运行,称之为并行。
关键词:多个 CPU + 同时。3 个进程在 3 个核心上真的同时在算,这就是并行。
5.4 并发(concurrent)
多个进程在一个 CPU 下采用进程切换的方式,在一段时间之内让多个进程都得以推进,称之为并发。
关键词:一个 CPU + 轮流。看起来"同时"在跑,其实是切换得快。
对照记:
| CPU 数量 | 机制 | 本质 | |
|---|---|---|---|
| 并行 | 多核 | 真的同时执行 | 物理同时 |
| 并发 | 单核(或多核也好使) | 进程切换(时间片) | 逻辑同时 |
📌 并行是硬件能力 ,并发是调度技巧。你现在打开的任务管理器里几十个进程都在"动",其实是并发。
六、进程切换:上下文切换
6.1 什么是上下文切换
CPU 上下文切换,实际含义是任务切换 ,或者 CPU 寄存器切换。
当多任务内核决定运行另外的任务时,它保存正在运行任务的当前状态,也就是 CPU 寄存器中的全部内容 。这些内容被保存在任务自己的堆栈 中。入栈完成后,就把下一个将要运行的任务的当前状况从它的栈中重新装入 CPU 寄存器,并开始下一个任务的运行。这一过程就是 context switch。
画成图:
进程 A 正在跑 内核决定切换 进程 B 开始跑
────────────── ───────────── ──────────────
CPU 寄存器 = A 的值 → 1. 把寄存器全部压入 A 的栈
2. 从 B 的栈里弹出 B 的寄存器值
3. 装入 CPU 寄存器 → CPU 寄存器 = B 的值
所以所谓"进程被切走了",本质就是:它的寄存器值被存进了它的栈,CPU 转去装别人的值。
6.2 时间片
当代计算机都是分时操作系统,每个进程都有它合适的时间片(其实就是一个计数器)。时间片到达,进程就被操作系统从 CPU 中剥离下来。
时间片(time slice)就是"这次让你跑多久"。跑完:
- 如果还没结束 → 状态从
R变成"就绪",重新排队; - 如果结束了 → 继续等下一轮。
📌 时间片太大 → 交互卡顿;太小 → 切换开销占比太高。这是一门平衡的艺术,调度器就是干这个的。
七、Linux 2.6 内核的 O(1) 调度队列
这一节是课件里最"硬"的部分,我们按"为什么要 → 怎么设计 → 为什么是 O(1)"的顺序拆。
7.1 为什么要专门设计一个调度器
老调度器有个致命问题:每次挑进程都要遍历一遍所有进程。进程一多,挑一次就要 O(n),系统越忙越慢。
目标很明确:不管有多少进程,挑出下一个该跑的进程,代价都是常数。这就是 O(1) 的由来。
7.2 一个 CPU 一个 runqueue
CPU0 → runqueue0
CPU1 → runqueue1
CPU2 → runqueue2
如果有多个 CPU,还要考虑进程个数的负载均衡------不能让一个核忙死、另一个核闲着。
7.3 优先级范围:0~139
| 类别 | 范围 | 说明 |
|---|---|---|
| 实时优先级 | 0 ~ 99 | 我们不关心(实时进程) |
| 普通优先级 | 100 ~ 139 | 我们写的程序都在这里 |
📌 呼应一下:普通优先级 100~139 正好 40 档 ,与 nice 的 -20~19 一一对应。也就是说 nice = -20 对应优先级 100(最高),nice = 19 对应 139(最低)。
7.4 活动队列(active):queue140
struct prio_array {
unsigned int nr_active; // 有几个运行态进程
DECLARE_BITMAP(bitmap, MAX_PRIO + 1); // 位图,标记哪些队列非空
struct list_head queue[MAX_PRIO]; // 140 个队列
};
关键点(课件原话):
- 时间片还没有结束的所有进程,都按照优先级放在活动队列里;
nr_active:总共有多少个运行状态的进程;queue[140]:一个元素就是一个进程队列,相同优先级的进程按照 FIFO 规则排队 ,数组下标就是优先级;- 从该结构中挑选最合适进程的流程:
- 从下标 0 开始遍历
queue[140]; - 找到第一个非空队列,该队列必定是优先级最高的;
- 拿到选中队列的第一个进程,开始运行,调度完成;
- 遍历
queue[140]时间复杂度是常数......但还是太低效了。
- 从下标 0 开始遍历
为什么"常数"还嫌慢?因为 140 次循环也要时间,尤其是高频调度时。于是有了位图。
7.5 位图 bitmap5:把 140 次变成几个字
bitmap[5]:一共 140 个优先级、140 个进程队列,为了提高查找非空队列的效率,可以用 5 × 32 个比特位表示队列是否为空,这样便可以大大提高查找效率。
bitmap(5 个 32 位整数 = 160 位,够用 140 位)
bit[0] bit[1] ... bit[139]
1 0 1 ← 1 表示该优先级队列非空
查找流程变成:
- 先找第一个不为 0 的 32 位整数(最多看 5 个整数);
- 再用
ffs(找最低位 1)定位到具体是哪一位; - O(1) 完成,最多 5 次判断。
📌 这就是"用位运算换时间"的经典案例,也是所有高性能调度器的必备技巧。
7.6 过期队列(expired)
过期队列和活动队列结构一模一样。
区别只有一条:
- 过期队列上放的是时间片耗尽的进程;
- 当活动队列上的进程都被处理完毕后,对过期队列的进程重新计算时间片。
为什么要两个队列?因为如果只有一个队列,时间片耗尽的进程重新插回队尾,会导致"刚跑完的又立刻跑到前面",饿死新进程。分成两队就干净了:跑完的进 expired,等 active 空了再整体换过来。
7.7 active 指针和 expired 指针:一次 swap 全场复活
struct rq {
...
struct prio_array *active, *expired, arrays[2];
...
};
active指针永远指向活动队列;expired指针永远指向过期队列。
问题来了:active 上的进程会越来越少,expired 会越来越多(因为时间片到期是常态)。
解法极其优雅:
在合适的时候,只要交换 active 指针和 expired 指针的内容,就相当于拥有了一批新的活动进程!
交换前:
active → [ 剩下的 3 个 ]
expired → [ 攒下的 100 个 ]
一次 swap(只是交换两个指针,O(1)):
active → [ 攒下的 100 个 ] ← 它们的时间片已被重新计算
expired → [ 剩下的 3 个 ]
📌 一次指针交换 = 整个队列"复活"。这是内核设计里最漂亮的 trick 之一。
7.8 总结:为什么叫 O(1)
在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加 ,我们称之为进程调度 O(1) 算法。
结构全景图:
struct rq(每个 CPU 一个)
┌──────────────────────────────┐
│ active ──┐ │
│ ├→ arrays[0] / [1]│
│ expired ──┘ │
└──────────────────────────────┘
│
prio_array(140 个队列 + bitmap)
┌───────────────────────────────────────────┐
│ queue[0] queue[1] ... queue[139] │
│ ↓ ↓ ↓ │
│ [进程链表] [进程链表] [进程链表] │
│ │
│ bitmap: 0 1 0 0 ... 1 ← 1 表示该队列非空 │
└───────────────────────────────────────────┘
📌 补充一句现实:现代 Linux 早已换成 CFS(完全公平调度) 和更高版本的其他调度器,2.6 的 O(1) 已经不是主力。但它是理解调度思想的最佳样本:分优先级队列 + 位图加速 + 双队列轮换,这套思路至今到处都在用。
八、易错点
| 误解 | 正确理解 |
|---|---|
| nice 值就是优先级 | nice 是修正值 ,PRI(new) = PRI(old) + nice |
| nice 越大越优先 | 越小越优先(-20 最高,19 最低) |
| 普通用户能随便调优先 | 只能把 nice 调大(降优先级),调小需要 root |
| 并行 = 并发 | 并行要多核 ,并发是单核切换出来的假象 |
| 进程切换很便宜 | 要保存/恢复全部寄存器、刷 TLB,是很贵的 |
本篇小结
- 优先级决定谁先上 CPU;
PRI是最终值,NI是修正值,PRI(new) = PRI(old) + nice,nice ∈ -20, 19。 - 竞争性(因为抢 CPU)→ 产生优先级;独立性(靠地址空间);并行(多核同时);并发(单核切换)。
- 上下文切换 = 保存当前寄存器到自己的栈 + 装入别人的寄存器;时间片到了就被剥下来。
- Linux 2.6 O(1) 调度器 = 每 CPU 一个 runqueue + 140 个优先级队列 + bitmap 位图加速 + active/expired 双队列 + 指针交换。
- 挑下一个进程的代价是常数,与进程数量无关------这就是"O(1)"。