/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
一台机器上同时有几百个进程,但 CPU 核心数远少于进程数。每个进程都认为自己应该继续执行,而操作系统必须在毫秒级做出选择:现在把 CPU 给谁?这个选择由优先级、调度器和上下文切换共同完成。
一、优先级解决的是“谁先运行”
1. 竞争、并发与并行
进程之间最直接的关系是竞争:CPU 资源有限,而可运行的进程很多。为了让竞争有秩序,内核给每个进程一个可比较的优先级,调度时优先照顾更重要的任务。
下面几个概念经常出现,先做个辨析:
| 竞争性 | 多个进程争抢有限的 CPU | 一边编译代码,一边开视频会议 |
| 独立性 | 每个进程有自己的地址空间和运行上下文 | 一个进程崩溃,不影响另一个进程 |
| 并发 | 一个 CPU 在多个进程之间快速切换 | 单核机器上同时下载、播放音乐、编辑文档 |
| 并行 | 多个 CPU 真正同时执行多个任务 | 四核机器上四个线程同时计算 |
并发是“在一段时间内都向前走”,并行是“在同一时刻真的都在走”。单核机器上的多任务靠的是切换,切换得足够快,用户就会觉得它们在同时运行。
优先级只在竞争时才有意义。CPU 空闲时,优先级再高也不会让程序跑得更快,因为根本没有别的任务在抢。
2. 优先级不是权限
这两个概念很容易混淆,一定要辨析清楚:
| 回答的问题 | 谁先获得 CPU | 能不能访问某个资源 |
| 主要依据 | nice 值、PRI、调度策略 | UID、GID、rwx、capabilities |
| 调整方式 | nice、renice、setpriority | chmod、chown、能力位 |
| 边界 | 高优先级不能绕过权限检查 | 权限高不自动获得更多 CPU |
权限低的进程,仍然可以读取它本来就有权读取的文件;权限高的普通进程,也打不开自己没有权限的文件。Linux 在判断资源访问时,看的是进程携带的用户身份(UID),而不是它的调度优先级。
把这两件事分开,后面看 PRI、NI 和调度队列时就不容易串线。
二、PRI、NI 与 nice
1. ps 里的 PRI 和 NI
先看一个最常用的命令:
ps -l -p $$
输出大致如下,PID 会随环境变化:
F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
0 S 1000 4217 4216 0 80 0 – 2780 wait pts/0 00:00:00 bash
这一行里,UID、PID、PPID、PRI、NI 最值得关注。和优先级直接相关的是两个字段:
- NI:nice 值,表示进程优先级的修正量。
- PRI:调度器使用的优先级显示值。数值越小,越早被调度。
PRI 不是权限,也不是进程的“重要程度评分”,它只是调度顺序的一个数字表示。
2. nice 是修正值,不是优先级本身
nice 的取值范围是 -20 到 19,一共 40 个级别。默认值是 0。
它的名字很形象:nice 值越高,进程对别的进程越“客气”,越愿意把 CPU 让出来,所以优先级越低;nice 值越低,进程越“不客气”,优先级越高。
在常见的 ps 显示口径下,普通进程的基准优先级可以写成:
PRI = 80 + nice
如果把 80 看作基准值,也可以写成 PRI(new) = PRI(old) + nice。这里的 PRI(old) 是基准优先级,不是上一次 renice 之后的结果。重复执行 renice 是覆盖 nice 值,不是把差值一次次累加。
不同的工具会用不同的刻度显示同一个调度倾向:
| ps -l 的 PRI | 80 | 60 | 99 | 常见的 procps 显示方式,80 + nice |
| 内核普通优先级 | 120 | 100 | 139 | O(1) 调度队列的下标,120 + nice |
| top 的 PR | 20 | 0 | 39 | 另一套显示口径;实时任务可能显示为负值 |
表里这三套数字不一样,方向是一致的:数值越小,优先级越高。写监控脚本时,不要把 top 的 PR 和 ps 的 PRI 直接放在一起比较,它们不是同一把尺子。调度器最终比较的,是 task_struct 里的整数字段;ps 和 top 只是把这些字段翻译成不同的显示口径。
ps 自己的不同输出格式也可能换刻度:ps -l 常见的是 80 基准,ps -o pri 可能给出另一组值。看到 PRI 列时先确认命令用的是哪种格式,不要跨格式套 80 + nice。
内核内部还可能根据任务的睡眠和运行行为做动态修正,但用户设置的 nice 始终是基准修正量。这种修正通常有上限,否则进程可以靠刻意睡眠、唤醒来操纵自己的优先级。不同内核版本的动态规则不同,看调度行为时要以对应版本的实现为准。
3. 优先级范围与饥饿
在 ps 的普通进程口径下,PRI 落在 60 到 99 之间,对应 nice 的 -20 到 19。内核队列里则是 100 到 139。两套刻度各有 40 个级别,数值上相差 40。
优先级差距过大时,低优先级进程可能长时间抢不到 CPU,这就是进程饥饿。
nice 只是一种“偏向”,不是硬实时保证。把普通进程的 nice 调到 -20,也不会把它变成实时任务。实时策略(如 SCHED_FIFO、SCHED_RR)使用另一段优先级区间,实时任务会排在所有普通任务之前。除非确实需要低延迟保障,否则不要用极端 nice 值去模拟实时行为,容易把系统里其他任务饿死。
三、查看与调整优先级
1. 用 ps 和 top 观察
查看某个进程的 nice 和 PRI:
ps -l -p 4301
持续观察:
top -p 4301
top 里的 PR 是优先级显示值,NI 是 nice 值。默认普通进程通常显示 PR = 20、NI = 0,这和 ps 的 80 是两套刻度。
在 top 里按 r,输入进程 PID,再输入新的 nice 值,就可以调整已有进程,效果等同于执行一次 renice。
2. 用 nice 和 renice 调整
启动一个新进程时直接指定 nice:
nice -n 10 ./cpu_task
nice -n 10 表示以 nice 10 启动,优先级比默认值低。适合编译、备份、批处理这类“可以慢一点,但别挡住交互”的任务。
调整已经运行的进程:
renice -n 5 -p 4301
正数表示降低优先级。要调高优先级,需要负的 nice 值:
sudo renice -n -5 -p 4301
普通用户通常只能把 nice 调大,也就是主动降低优先级;把 nice 调小需要 root 或 CAP_SYS_NICE 能力,某些环境还会受到 RLIMIT_NICE(资源限制)的约束。把 nice 从 0 调到 10 后,普通用户一般不能自己再调回 0。
renice 也可以按进程组或用户批量操作,分别使用 -g 和 -u。批量调整前先确认影响范围,尤其是不要把系统服务一起降权。
3. 用系统调用读取和设置
需要在程序里读取或设置 nice,可以使用 getpriority 和 setpriority:
#include <sys/resource.h>
#include <errno.h>
#include <stdio.h>
int main(void)
{
errno = 0;
int prio = getpriority(PRIO_PROCESS, 0);
if (prio == –1 && errno != 0) {
perror("getpriority");
return 1;
}
printf("nice = %d\\n", prio);
if (setpriority(PRIO_PROCESS, 0, 10) == –1) {
perror("setpriority");
return 1;
}
return 0;
}
编译并运行:
gcc -Wall -Wextra -o prio_demo prio_demo.c
./prio_demo
几个参数的含义:
- which 可以是 PRIO_PROCESS、PRIO_PGRP 或 PRIO_USER,分别表示按进程、进程组、用户操作。
- who 为 0 时表示当前进程、当前进程组或当前用户。
- getpriority 返回的是 nice 值,不是 ps 里的 PRI。不要拿返回值和 80 直接比较。
- getpriority 失败时返回 -1,但合法的 nice 值也可能是 -1,所以调用前要把 errno 清零,返回后再检查 errno。
把 nice 调大通常不需要特权;把 nice 调小、提高优先级,仍然需要相应的权限。
官方手册对这两个接口的返回值和错误码写得很清楚,可以查阅 getpriority(2)。
4. 调整优先级的边界
nice 只影响 CPU 时间的竞争,不解决 I/O 瓶颈。磁盘读写慢、网络等待长,调 nice 没有用,这时要看 I/O 优先级或存储本身的吞吐。I/O 优先级是另一套机制,效果还取决于所使用的 I/O 调度器。
在容器里,nice 还要和 cgroup(控制组)的 CPU 权重、配额一起看。容器内把进程调成高优先级,不会突破上层设置的 CPU 限额;它只是改变同一控制组内的竞争顺序。
另外,负 nice 值不是“性能开关”。一个 CPU 密集型任务即使拿到最高普通优先级,也只是在竞争时更早、更频繁地拿到 CPU,不会提高单核本身的计算速度。真正需要低延迟时,应该考虑实时调度策略,并同时评估它对系统其他任务的影响。
四、进程切换:保存一份,恢复另一份

1. 时间片让 CPU 能被收回
普通的现代分时系统中,一个进程即使写了死循环,也不会永远占着 CPU 不放:
int main(void)
{
for (;;) {
}
}
这段程序会一直处于可运行状态,但内核会在时钟中断到来时检查它是否已经用完时间片。时间片到了,就把 CPU 收走,换另一个任务运行。死循环进程会让一个核心跑满,但不会让整台机器失去响应。
触发切换的常见情况有三类:
- 当前任务的时间片用完。
- 当前任务主动阻塞,例如等待 I/O、睡眠或获取锁。
- 一个更高优先级的任务被唤醒,内核决定立即抢占。
2. 上下文里有什么
CPU 的寄存器只有一份,但每个进程都有自己的“寄存器快照”。切换时保存的是 CPU 当前正在使用的硬件状态,不是整个进程的内存:
- 程序计数器 PC,在 x86-64 上是 RIP,指向下一条要执行的指令。
- 栈指针 SP,指向当前用户栈或内核栈。
- 通用寄存器,例如 RAX、RBX、RCX、RDX。
- 标志寄存器,保存条件码和控制位。
- 浮点、SIMD 等扩展状态,按需保存。
- 地址空间相关状态,例如页表基址寄存器 CR3。
可以把 CPU 想成一间只能坐一个人的办公室,寄存器是一张张桌子,寄存器中的内容就是桌面上正在摊开的纸。进程 A 被换下时,要把这些纸收进 A 自己的文件袋;进程 B 上来时,再把 B 的文件袋摊开。桌子只有一张,文件袋可以有很多个。
3. 保存到哪里
进程的上下文,即各类寄存器的现场,主要保存在被换出任务自身的内核栈中。任务被换出时,内核将返回地址和部分寄存器压入该任务的内核栈,待该任务再次被调度时再从栈中恢复。
task_struct 中用于保存上下文的 thread_struct 并不存放这些通用寄存器,它只记录若干用于定位现场的字段,其中最重要的是内核栈指针 sp(指向上述内核栈),此外还有 FPU 浮点寄存器状态等。
简言之:通用寄存器的现场保存在内核栈上,内核栈的位置与浮点状态则由task_struct 中的 thread_struct 记录。
早期 x86 提供硬件任务切换机制,任务状态段 TSS 中保存任务的部分上下文,Linux 0.11 的 task_struct 中即可看到 tss_struct。现代 Linux 改用软件方式完成上下文切换,TSS 仍然保留,但主要用于在用户态切换到内核态时提供内核栈指针,已不再用于保存通用寄存器现场。
内核通过 current 宏获取当前正在运行的 task_struct。调度器选出下一个任务后,将 current 切换到新任务,再恢复其现场。
4. 切换的成本
直接成本包括保存和恢复寄存器、更新调度器数据结构、加锁,以及切换地址空间。间接成本往往更大:新任务的工作集可能不在 CPU 缓存里,页表和 TLB(地址转换缓存)可能需要更新,分支预测器也要重新适应。切换越频繁,这些“重新热身”的开销越明显。
可以用 GNU time 观察一个程序被切换了多少次:
/usr/bin/time -v ./cpu_task
如果提示找不到 /usr/bin/time,需要安装 GNU time 包;shell 内置的 time 不提供 -v 选项。输出里有两行值得看:
- Voluntary context switches:任务主动让出 CPU,通常是因为睡眠、等待 I/O 或阻塞。
- Involuntary context switches:任务并不想走,但被时间片或更高优先级任务抢占了。
这两个数字能帮助判断切换主要来自阻塞还是抢占,具体数值会随负载变化。上下文切换不是越少越好:切换太少,交互延迟变差;切换太多,吞吐量被开销吃掉。调度器做的就是在这两者之间找平衡。
五、O(1) 调度器:常数时间挑出下一个进程
下面讨论的是 Linux 2.6 早期使用的 O(1) 调度器。它在 2.6.23 之后被 CFS(完全公平调度器)取代,更新的内核又转向 EEVDF(Earliest Eligible Virtual Deadline First)。现代生产内核里不会直接看到这套数组,但它把“优先级队列 + 位图 + 活动/过期双数组”组织得很清楚,仍然是理解调度器设计的好样本。
1. 每个 CPU 一个 runqueue
O(1) 调度器为每个 CPU 维护一个运行队列,结构体叫 rq。几个主要字段是:
struct rq {
unsigned long nr_running;
struct task_struct *curr, *idle;
struct mm_struct *prev_mm;
struct prio_array *active, *expired, arrays[2];
int best_expired_prio;
/* … */
};
nr_running 记录可运行任务的数量,curr 是当前任务,idle 是空闲任务。active 和 expired 分别指向活动数组和过期数组,arrays[2] 是把这两个数组放在一起的存储。
每个 CPU 自己维护队列,可以减少多核之间的锁竞争。多个 CPU 之间还要做 SMP(对称多处理)负载均衡,否则会出现一个 CPU 忙得排队、另一个 CPU 空闲的情况。负载均衡、任务迁移这些细节不在本文展开,但它们是“每个 CPU 一个 runqueue”带来的直接问题。
2. 140 个优先级队列
优先级数组 prio_array 的核心是一个长度为 140 的队列数组:
struct prio_array {
unsigned int nr_active;
DECLARE_BITMAP(bitmap, MAX_PRIO + 1);
struct list_head queue[MAX_PRIO];
};
queue[140] 的每个元素是一条链表的头。数组下标就是优先级,相同优先级的任务按 FIFO(先进先出)挂在这条链表上。调度时取链表头的任务,入队时挂到链表尾。
这 140 个下标分成两段:
- 0 到 99:实时优先级,普通进程不参与。
- 100 到 139:普通优先级,普通进程在这里竞争。
普通任务的队列下标由 nice 值决定:
static_prio = 120 + nice
nice 为 -20 时下标是 100,nice 为 19 时下标是 139。nice 越小,下标越小,越早被调度器看到。
3. bitmap:先找队列,再找任务
如果每次都从 queue[0] 开始逐个检查 140 个队列,虽然上限固定,但常数仍然偏大。O(1) 调度器用一张位图来加速:
- 每一位对应一个优先级队列。
- 位为 1 表示队列非空,为 0 表示空。
- 找到第一个为 1 的位,就找到了当前最高优先级的非空队列。
在 32 位内核上,bitmap[5] 是 5 个 32 位字,一共 160 位,足够覆盖 140 个优先级。查找第一个置位可以用 sched_find_first_bit 之类的原语完成,不需要逐条遍历任务链表。
这里要区分两个“常数”:
- 扫描 140 个队列的上限是固定的,本来就是 O(1)。
- 位图把查找成本进一步压低,而且查找成本不随可运行进程数量增长。
O(1) 描述的是挑选成本与任务数量无关:100 个任务和 10000 个任务,调度器都只需要在位图里找第一个非空队列,然后取链表头。
4. active 和 expired
prio_array 有两份,一份是活动数组,一份是过期数组:
- 活动数组里的任务还有剩余时间片,可以被调度。
- 过期数组里的任务时间片已经用完,等待下一轮。
当活动数组中的任务逐渐被处理完,过期数组会越来越满。等活动数组空了,内核只要交换 active 和 expired 两个指针,原来的过期数组就变成了新的活动数组:
swap(active, expired);
这不是把 140 条链表整体搬过去,只是交换两个指针。换入的任务会重新计算时间片,获得下一轮运行机会。

这种双数组设计让“用完时间片的任务”和“还有时间片的任务”自然分开。调度器不需要在每个任务上反复计算“是否还有配额”,只要看两个数组的状态即可。
5. 为什么叫 O(1)
把一次调度拆开看:
入队和出队都是链表操作,查找队列是位图操作,整个过程不随可运行任务数量线性增长,所以被称为 O(1) 调度算法。
但 O(1) 不等于零成本。调度器仍然要加锁、维护 nr_running、处理优先级变化、做 SMP 负载均衡,还要在时间片用完时把任务从活动数组移到过期数组。O(1) 描述的是“选择下一个任务的算法复杂度”,不是整个系统的开销。
6. 历史定位与局限
O(1) 调度器在 Linux 2.6 早期解决了当时调度器在进程数量增大时变慢的问题。它需要根据任务的睡眠和运行行为判断“交互性”,并给交互型任务一定的优先级补偿。这些启发式规则在负载变化时容易判断失误,公平性和延迟之间的平衡也不够理想。
Linux 2.6.23 引入了 CFS,用虚拟运行时间和红黑树来维护可运行任务,选择下一个任务的复杂度变成 O(log n),但公平性更好。更新的内核使用 EEVDF。今天在 ps 或 top 里仍然能看到 PRI 和 NI,但背后的调度器已经不再是这套 140 个队列的数组。在 CFS 里,nice 主要折算成权重,影响的是 CPU 份额,而不是 O(1) 那种固定队列的先后顺序。
可以把 O(1) 调度器当作一个经典样本:它把“优先级如何映射到队列”“如何用位图快速找最高优先级”“如何用双数组处理时间片”讲得很直观。理解这些结构,再去看 CFS 的红黑树,会容易很多。CFS 的设计和调度模型可以参考 内核调度文档。
六、把三件事串起来
现在把优先级、调度和切换放回同一条时间线上:
优先级决定 A 和 B 在队列里的位置,调度器决定这次选谁,上下文切换负责把 CPU 真正交出去。三者配合,才有了“多个进程同时推进”的效果。
七、几个容易混淆的点
- nice 不是优先级本身。 nice 是修正值,PRI 是显示值,内核队列下标又是另一套刻度,三者方向一致但数值不同。
- 优先级不是权限。 高优先级进程不能绕过文件权限,低优先级 root 进程也不会因为 nice 高就失去权限。
- 进入内核不等于发生切换。 系统调用和中断可能只是内核处理完再返回原任务;只有选中另一个任务才需要保存和恢复上下文。
- O(1) 只描述选择任务的复杂度。 真实调度仍有锁、缓存和负载均衡的开销;现代内核也已经改用 CFS 和 EEVDF。
- 高优先级不等于实时。 普通进程的 nice 范围只有 -20 到 19,实时任务是另一套调度策略和优先级区间。
END
网硕互联帮助中心






评论前必须登录!
注册