Linux 基础体系 · 第 29/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。
Linux 调度器深入:CFS、实时策略、优先级、Affinity 和 NUMA
在 Linux 中,“调度器”不是一个简单地把进程轮流分配给 CPU 的循环。它同时处理以下问题:
- 哪些任务当前可以运行;
- 哪个任务应该优先运行;
- 任务在多个 CPU 之间如何迁移;
- 实时任务如何限制普通任务;
- 线程的 CPU 亲和性如何约束调度;
- NUMA 系统中,任务在哪个 CPU 上运行、数据位于哪个内存节点;
- 阻塞、唤醒、抢占、上下文切换和软中断如何改变系统状态。
理解这些机制,需要先区分三个概念:
- 调度策略(scheduling policy):任务属于哪一个调度类,例如普通任务、实时任务或 deadline 任务。
- 优先级(priority):同一调度类中,哪个任务应当先获得 CPU;不同调度类之间也存在固定的优先级关系。
- 亲和性(affinity):任务允许在哪些 CPU 上运行,以及其内存访问倾向于哪些 NUMA 节点。
这三个概念相互影响,但不能互相替代。提高 nice 优先级不能把任务固定到某个 CPU;设置 CPU affinity 也不会自动把内存迁移到目标 NUMA 节点;使用实时策略则可能绕过普通任务的公平调度。
一、从 CPU 调度问题开始:任务何时可以运行
调度器面对的是一组任务和一组 CPU。
对一个任务而言,至少存在以下状态:
- 运行中(running):当前正在某个 CPU 上执行;
- 可运行(runnable):没有阻塞,等待获得 CPU;
- 睡眠(sleeping):等待 I/O、锁、定时器、事件或其他条件;
- 不可中断睡眠(uninterruptible sleep,通常显示为
D):常见于等待某些内核 I/O 或资源; - 停止(stopped):被信号或调试器暂停;
- 僵尸(zombie):进程已退出,但父进程尚未回收其退出状态。
只有可运行任务会进入调度器的运行队列。正在睡眠的任务不需要 CPU,因此调度器不会为它保留时间片。
在单个 CPU 上,可以抽象出一个基本选择问题:
其中:
- 是当前 CPU 上允许运行的任务集合;
effective_priority不是单一整数,而是由调度类、实时优先级、nice、deadline 等规则共同决定。
在多核系统中,任务还必须满足 CPU affinity 和 cpuset 约束:
如果一个任务没有可用 CPU,即使它是可运行状态,也只能等待。
运行队列与每 CPU 调度
Linux 通常以每 CPU 运行队列(runqueue,简称 rq)组织可运行任务。虽然现代内核中调度类、调度实体和负载跟踪有更复杂的层次,但“每个 CPU 有自己的可运行任务集合”仍然是理解调度的核心。
任务可能因为以下事件进入或离开运行队列:
任务被唤醒
│
├── 检查 affinity、cpuset 和 CPU 可用性
├── 选择目标 CPU
├── 加入目标 CPU 的调度类运行队列
└── 必要时触发抢占
任务运行
│
├── 主动阻塞:从运行队列移除
├── 时间片或公平份额耗尽:重新排队
├── 更高优先级任务唤醒:被抢占
├── 系统调用或中断:进入内核路径
└── 退出:从调度系统移除
“可运行任务数”不等于“CPU 使用率”。例如,一个线程等待 I/O 时通常不占用 CPU,但任务数可能增加;一个 CPU 密集型线程可能使 CPU 使用率接近 100%,但运行队列中只有一个任务。
二、调度类:普通、实时与 deadline 的关系
Linux 不是把所有任务放入同一种队列,而是通过调度类(scheduler class)组织不同调度策略。不同内核版本的具体实现细节会变化,但调度类之间的优先关系是理解行为的关键。
通常可以按以下逻辑理解:
- deadline 类任务优先于实时类;
- 实时类任务优先于普通 fair 类;
- 普通任务之间按照公平调度规则竞争;
SCHED_IDLE任务属于普通调度体系中的极低优先级场景。
因此,一个持续运行的 SCHED_FIFO 任务可能长期阻止普通 CFS/fair 任务运行。这个行为不是“CFS 失效”,而是调度类优先级导致的正常结果。
常见策略
可以用 chrt 查看或设置实时策略,用 nice 或 renice 调整普通任务优先级:
ps -eLo pid,tid,cls,rtprio,pri,ni,psr,stat,comm
典型列含义:
PID:进程 ID;TID:线程 ID;CLS:调度类;RTPRIO:实时优先级;PRI:内核显示的动态优先级,不能简单等同于 nice;NI:nice 值;PSR:最近运行的逻辑 CPU;STAT:任务状态;COMM:线程名。
常见策略包括:
| 策略 | 适用对象 | 核心行为 |
|---|---|---|
SCHED_OTHER / SCHED_NORMAL |
普通任务 | 使用 fair 调度 |
SCHED_BATCH |
批处理任务 | 倾向于减少交互式抢占影响 |
SCHED_IDLE |
极低优先级后台任务 | 只有没有更重要普通任务时才运行 |
SCHED_FIFO |
实时任务 | 同优先级 FIFO,不主动让出时可持续运行 |
SCHED_RR |
实时任务 | 同优先级任务按时间片轮转 |
SCHED_DEADLINE |
周期性、截止期任务 | 使用 runtime、deadline、period 描述需求 |
SCHED_OTHER、SCHED_BATCH 和 SCHED_IDLE 仍属于普通任务范畴,受 fair 调度和 nice 影响。SCHED_FIFO 和 SCHED_RR 使用静态实时优先级。SCHED_DEADLINE 使用另一套参数和调度保证。
调度类之间的抢占
假设一个 CPU 上已有普通任务 A 正在运行:
CPU 0: A (SCHED_OTHER)
此时实时任务 B 被唤醒:
B: SCHED_FIFO, priority 50
典型路径是:
- B 进入 CPU 0 的实时运行队列;
- 调度器发现实时类非空;
- B 的调度类优先级高于普通 fair 类;
- A 被标记为需要重新调度;
- 在合适的抢占点,A 保存寄存器和内核执行状态;
- B 开始运行。
如果 B 不阻塞、不调用 sched_yield(),也没有更高实时优先级任务出现,B 可以持续运行很长时间。
这解释了一个生产风险:实时策略不是“让某个线程更快”,而是改变了它与其他任务之间的调度关系。
三、CFS 与现代 fair 调度:从虚拟运行时间到 EEVDF
1. CFS 的目标
CFS(Completely Fair Scheduler,完全公平调度器)长期用于 Linux 普通任务调度。它试图让任务获得与权重成比例的 CPU 时间。
设任务 的权重为 ,所有可运行普通任务权重总和为:
在理想的多路复用模型中,任务 获得的 CPU 比例近似为:
如果 CPU 在一段时间 内一直忙,任务的理想 CPU 时间是:
nice 值越小,权重越大;nice 值越大,权重越小。Linux 内核使用离散的权重表,而不是在每次调度时直接计算一个理想实数。
2. 虚拟运行时间
CFS 使用虚拟运行时间(virtual runtime,vruntime)表达一个任务相对于其权重“已经消耗了多少公平份额”。
典型关系为:
其中:
- :任务实际占用 CPU 的时间;
- :任务权重;
NICE_0_LOAD:nice 0 对应的基准权重;- :任务公平账本增加的量。
因此:
- 权重大的任务运行同样的实际时间,
vruntime增加较少; - 权重小的任务运行同样的实际时间,
vruntime增加较多; - 调度器倾向于选择
vruntime较小的任务。
这并不是“每个任务得到相同的实际时间”,而是“每个任务按照权重得到相近的虚拟进度”。
完整算例
假设只有两个普通任务:
- 任务 A:nice 0,权重 ;
- 任务 B:nice 5,权重约为 。
则总权重:
理想 CPU 比例:
如果 CPU 忙碌 100 ms:
假设调度器最初让 A 运行 10 ms,B 运行 10 ms,取 NICE_0_LOAD = 1024:
虽然两者实际都运行了 10 ms,但 B 的公平账本前进得更快,因此下一次更可能选择 B。经过足够长时间,A 和 B 的平均 CPU 份额会趋近于 75.35% 和 24.65%。
反例:nice 不是硬上限
如果只有一个 nice 19 的任务在运行,它仍可能使用接近 100% 的 CPU。nice 只决定它与其他普通任务竞争时的相对权重,并不表示“最多只能使用某个百分比”。
同样,如果一个 nice -20 的任务正在等待磁盘,它不会因为优先级高就凭空获得 CPU;任务必须先处于可运行状态。
3. min_vruntime 与睡眠唤醒
运行队列维护一个不断前进的公平时间基准,通常称为 min_vruntime。新唤醒的任务不能简单地带着任意小的 vruntime 插入,否则它可以通过频繁睡眠和唤醒反复插队。
因此,唤醒任务的虚拟时间会被放置在当前运行队列的合理范围附近。内核还会通过唤醒抢占、延迟参数和调度粒度控制交互响应与公平性的平衡。
这带来一个重要边界:
- 频繁睡眠的交互任务通常可以较快响应;
- 长时间运行的 CPU 密集型任务会累积公平进度;
- 频繁创建、销毁或唤醒任务不会无限制地获得免费 CPU 时间;
- 实际行为受内核版本、调度参数、cgroup 权重和系统负载影响。
4. CFS 与 EEVDF 的版本边界
在较新的 Linux 内核中,普通 fair 类逐步采用 EEVDF(Earliest Eligible Virtual Deadline First)相关机制。很多资料仍使用“CFS”泛指普通公平调度,但不能把旧的“总是选择最小 vruntime”模型原封不动地当作所有现代内核版本的完整实现。
可以这样区分:
- CFS 是长期存在的公平调度设计和概念体系;
- EEVDF 是较新内核中用于 fair 类任务选择的实现演进;
- nice 权重、fair 类、运行队列、抢占和 CPU 亲和性等概念仍然有效;
- 具体的可运行性、虚拟 deadline 和任务选择细节应以目标内核版本为准。
因此,分析生产系统时,应记录:
uname -a
cat /proc/version
不能仅凭发行版名称推断调度器实现细节。发行版可能回移植补丁,长期支持内核与上游同版本号的行为也可能不同。
四、优先级:nice、实时优先级与动态优先级不是一回事
1. nice 值
普通任务的 nice 范围通常为:
-20 到 19
数值越小,普通 fair 调度中的权重越大。
查看一个 shell 的 nice:
ps -o pid,tid,cls,pri,ni,comm -p "$$"
调整进程 nice:
nice -n 10 ./cpu-bound-program
调整已经运行的进程:
renice -n 10 -p 12345
降低优先级通常不需要特权;提高优先级,例如从 nice 0 改为 nice -5,通常需要相应权限。现代系统还可能受到 user namespace、systemd service 配置和 cgroup 限制。
nice 只影响普通调度类。对 SCHED_FIFO、SCHED_RR 或 SCHED_DEADLINE 任务,修改 nice 不是控制其核心调度顺序的正确手段。
2. 实时优先级
Linux 实时策略通常使用静态优先级范围 1 到 99,数值越大优先级越高。可用命令查看系统允许范围:
chrt -m
以 FIFO 策略和实时优先级 50 启动程序:
sudo chrt -f 50 ./rt-program
查看进程策略:
chrt -p 12345
不同实时任务的选择规则是:
- 选择实时优先级最高的非空队列;
- 如果策略是
SCHED_FIFO,运行队首任务; - 如果策略是
SCHED_RR,同优先级任务按时间片轮转; - 只有当实时运行队列为空时,普通 fair 任务才有机会运行。
3. SCHED_FIFO 与 SCHED_RR
SCHED_FIFO 的任务在以下情况下才会停止当前运行:
- 主动阻塞;
- 退出;
- 调用
sched_yield(); - 被更高实时优先级任务抢占;
- 被系统的实时带宽限制机制约束;
- 受到 CPU 下线、affinity 变化等调度事件影响。
同优先级的新 FIFO 任务通常排在现有同优先级任务之后,不会自动抢占正在运行的同优先级任务。
SCHED_RR 与 FIFO 类似,但同一实时优先级的任务会轮流运行。它的时间片长度由内核调度参数和实现决定,不能假设为某个固定的毫秒数。
反例:用 SCHED_FIFO 运行死循环
for (;;) {
compute();
}
如果 compute() 不阻塞、不主动让出 CPU,也没有更高优先级任务,SCHED_FIFO 任务可能长期占据一个 CPU。即使它只占一个 CPU,也可能造成:
- 该 CPU 上的普通任务饥饿;
- kworker、用户线程或关键服务无法及时运行;
- softirq 处理延迟;
- watchdog 报警;
- 系统管理命令响应变慢。
一些内核通过实时带宽控制限制实时任务使用 CPU 的总量。常见配置接口包括:
cat /proc/sys/kernel/sched_rt_period_us
cat /proc/sys/kernel/sched_rt_runtime_us
这些参数的默认值和是否启用可能因内核配置、版本和发行版而异。修改前必须确认 systemd、容器运行时或发行版管理工具是否也会管理它们。
4. 优先级反转
考虑三个任务:
- 高优先级任务 H;
- 中优先级任务 M;
- 低优先级任务 L。
执行顺序:
- L 持有互斥锁;
- H 需要同一把锁,因此阻塞;
- M 不需要这把锁,但一直可运行;
- M 抢占 L;
- L 无法运行,也就无法释放锁;
- H 间接被 M 阻塞。
这就是优先级反转。实时系统通常使用优先级继承(priority inheritance)互斥锁,使 L 临时继承 H 的优先级,从而尽快运行并释放锁。
Linux 用户态可使用支持优先级继承的 pthread mutex 属性:
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT);
pthread_mutex_t lock;
pthread_mutex_init(&lock, &attr);
这段代码成立的前提是:
- C 库和内核支持对应的优先级继承机制;
- 使用的锁类型和属性组合合法;
- 进程具有运行实时任务所需的权限;
- 应正确检查每个 pthread API 的返回值。
优先级继承只能缓解特定锁竞争路径,不能解决未受保护的共享资源、长时间持锁、错误的 I/O 设计或实时任务本身过载。
五、SCHED_DEADLINE:以运行预算和截止期描述任务
SCHED_DEADLINE 不使用普通 nice 或传统实时优先级,而是使用:
runtime:每个周期允许消耗的 CPU 时间;deadline:相对截止期;period:任务周期。
一个周期性任务可以抽象为:
其中:
- :每个周期需要的最大 CPU 时间;
- :从释放到截止期的相对时间;
- :任务释放周期。
例如:
runtime = 2 ms
deadline = 10 ms
period = 10 ms
表达的是:任务每 10 ms 释放一次,最多需要 2 ms CPU,并希望在释放后 10 ms 内完成。
使用命令设置:
sudo chrt --deadline 2000000:10000000:10000000 ./deadline-program
这里的单位通常是纳秒,具体命令语法应通过目标系统检查:
chrt --help
SCHED_DEADLINE 的理论分析依赖任务独立性、执行时间上界、CPU 数量、阻塞时间和调度模型。实际系统还存在:
- 中断和 softirq;
- 内核不可抢占区间;
- 锁竞争;
- I/O;
- page fault;
- NUMA 远端内存访问;
- cgroup 和 cpuset 限制。
因此,不能因为设置了 deadline 参数就宣称应用具有硬实时保证。权限和参数范围也受内核配置限制。
六、抢占、上下文切换与调度延迟
1. 抢占发生在哪里
抢占可以由以下事件触发:
- 更高优先级任务唤醒;
- 当前任务的 fair 调度份额变化;
- 当前任务从用户态返回;
- 中断处理结束;
- 系统调用结束;
- 定时器或调度 tick;
- CPU 进入重新调度路径。
内核并不是在任意机器指令之间都立即切换任务。抢占必须满足当前执行上下文允许切换,内核还可能处于:
- 关闭抢占的临界区;
- 持有自旋锁;
- 中断上下文;
- 不允许睡眠的原子上下文。
2. 上下文切换的代价
上下文切换通常包括:
- 保存当前任务的寄存器和调度上下文;
- 更新运行时间、统计量和调度实体状态;
- 切换到新任务的内核栈;
- 可能切换地址空间;
- 恢复新任务的寄存器;
- 重新进入用户态或内核态。
代价不只有保存寄存器。任务切换还可能影响:
- TLB;
- L1/L2 cache;
- 分支预测器;
- 调度器锁竞争;
- NUMA 局部性;
- 内存带宽。
可以使用以下命令观察系统整体调度指标:
vmstat 1
常见字段:
r:可运行任务数;b:阻塞任务数;cs:上下文切换次数;in:中断次数;us、sy:用户态和内核态 CPU 时间;wa:I/O wait 的统计口径。
cs 很高不一定代表问题。短小任务、网络服务、锁竞争都会产生上下文切换。需要结合 CPU 使用率、run queue、延迟、缓存未命中和业务吞吐判断。
更细粒度的分析可以使用:
sudo perf stat -e context-switches,cpu-migrations,cpu-clock,task-clock \
./program
若提示事件不可用,应先检查 perf 版本、内核配置和权限。某些系统通过 kernel.perf_event_paranoid 限制普通用户访问性能计数器。
七、Affinity:限制任务可以在哪些 CPU 上运行
CPU affinity 是任务可运行 CPU 集合的约束。它不是“当前 CPU”,也不是“优先选择 CPU”。
查看进程允许的 CPU:
taskset -pc 12345
输出可能类似:
pid 12345's current affinity list: 0-3
表示该线程允许在 CPU 0 到 3 上运行。
绑定进程:
taskset -cp 2 12345
启动时绑定:
taskset -c 2,3 ./program
使用 taskset 时,CPU 编号是逻辑 CPU 编号,不一定等于物理核心编号。先查看拓扑:
lscpu -e=CPU,CORE,SOCKET,NODE
线程级而不是进程级
Linux 调度的基本对象是线程。一个多线程进程的每个线程都可能有独立 affinity。
ps -eLo pid,tid,psr,comm
taskset -pc 12345
taskset -pc 12346
修改进程 affinity 在某些工具中会影响主线程或线程组的特定对象,不能假设所有线程都自动得到相同设置。生产诊断应针对 TID 检查:
for t in /proc/12345/task/*; do
tid=${t##*/}
printf '%s: ' "$tid"
taskset -pc "$tid"
done
C API 示例:设置并验证 affinity
下面是一个完整的最小示例。它将当前线程绑定到 CPU 0,并读取内核实际接受的 mask:
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
int main(void)
{
cpu_set_t set;
cpu_set_t actual;
CPU_ZERO(&set);
CPU_SET(0, &set);
if (sched_setaffinity(0, sizeof(set), &set) == -1) {
fprintf(stderr, "sched_setaffinity: %s\n", strerror(errno));
return EXIT_FAILURE;
}
CPU_ZERO(&actual);
if (sched_getaffinity(0, sizeof(actual), &actual) == -1) {
fprintf(stderr, "sched_getaffinity: %s\n", strerror(errno));
return EXIT_FAILURE;
}
printf("allowed CPUs:");
for (int cpu = 0; cpu < CPU_SETSIZE; cpu++) {
if (CPU_ISSET(cpu, &actual))
printf(" %d", cpu);
}
putchar('\n');
return EXIT_SUCCESS;
}
编译运行:
cc -O2 -Wall affinity.c -o affinity
./affinity
可能输出:
allowed CPUs: 0
pid 参数为 0 表示当前线程。sizeof(cpu_set_t) 适用于这个固定大小示例;如果需要处理非常大的 CPU 集合,应使用动态 CPU set API,例如 CPU_ALLOC()、CPU_ALLOC_SIZE()。
错误处理很重要:
EINVAL:mask 为空、CPU 编号无效或 mask 大小不正确;ESRCH:目标线程不存在;EPERM:权限不足;- 目标 CPU 即使在线,也可能被 cpuset 或容器约束排除。
成功设置 affinity 只表示请求被接受,不表示线程立即迁移到目标 CPU。线程下一次被调度时,才会在允许集合中选择 CPU。
Affinity 的收益与代价
固定 CPU 可能带来:
- 更稳定的 cache 局部性;
- 减少 CPU migration;
- 为低延迟线程预留 CPU;
- 与设备中断或网卡队列协调。
但过度固定也会造成:
- 一个 CPU 队列过载;
- 其他 CPU 空闲;
- 任务无法在临时空闲 CPU 上运行;
- NUMA 位置与 CPU 绑定不匹配;
- 热点锁集中在单个 CPU;
- cgroup 或容器的 CPU 集合进一步缩小后,任务无处运行。
典型反例
机器有 16 个逻辑 CPU,但把 32 个 CPU 密集型线程全部绑定到 CPU 0:
taskset -cp 0 $(pgrep program)
这不会提高确定性,结果通常是 CPU 0 的 run queue 变长,其他 CPU 空闲,吞吐下降,延迟增加。
八、CPU affinity、cpuset 与 cgroup 的区别
Affinity 是任务自身请求的 CPU mask,但它不是最终唯一约束。任务最终可运行的 CPU 通常是多个集合的交集:
cgroup v2 的 cpuset 控制器还可以限制内存节点:
cat /sys/fs/cgroup/cpuset.cpus.effective
cat /sys/fs/cgroup/cpuset.mems.effective
systemd 服务可以通过配置实现 CPU 和 NUMA 约束,例如:
[Service]
CPUAffinity=2 3
NUMAPolicy=preferred
NUMAMask=1
具体字段是否可用取决于 systemd 版本。修改服务前应使用:
systemctl cat example.service
systemctl show example.service
容器环境中还可能有:
- 容器运行时传入的
--cpuset-cpus; - Kubernetes CPU Manager 的 exclusive CPU;
- pod 的 cpuset cgroup;
- quota 限制;
- 虚拟机 vCPU 调度。
因此,宿主机上看到的 affinity 不一定就是容器内部可见的全部约束。
九、NUMA:CPU 位置与内存位置必须一起分析
NUMA(Non-Uniform Memory Access,非一致性内存访问)系统包含多个内存节点。每个节点通常与一组 CPU 和本地内存控制器关联。
NUMA 的“不一致”不是数据内容不一致,而是访问延迟和带宽不同:
- CPU 访问本地节点内存,通常延迟较低;
- CPU 访问远端节点内存,需要经过互连,延迟更高、带宽可能更低;
- 多 socket 系统中,远端访问还会与其他流量竞争互连带宽。
查看拓扑:
numactl --hardware
lscpu -e=CPU,CORE,SOCKET,NODE
numastat -p 12345
numactl --hardware 可能输出:
available: 2 nodes (0-1)
node 0 cpus: 0-7
node 1 cpus: 8-15
node 0 size: ...
node 1 size: ...
node distances:
node 0 1
0 10 20
1 20 10
距离矩阵中的数值是内核和平台提供的相对距离,不应直接当作纳秒。
First-touch:页通常在哪里分配
Linux 常见 NUMA 内存策略是 first-touch:匿名页在第一次实际写入时,倾向于分配到执行该写入的 CPU 所属内存节点。
示例:
- 线程 T 在节点 0 的 CPU 上分配一块大数组;
malloc()只建立虚拟地址范围,物理页可能尚未真正分配;- T 首次写入数组;
- 缺页异常触发物理页分配;
- 页面通常落在节点 0;
- 随后线程迁移到节点 1;
- T 访问这些页时产生远端内存访问。
所以:
CPU affinity ≠ 内存 affinity
把线程固定到 CPU 1,并不自动把它之前分配的页面迁移到 CPU 1 的内存节点。
完整算例:线程和内存位置错配
假设:
- 节点 0:CPU 0–7;
- 节点 1:CPU 8–15;
- 大数组由 CPU 0 上的线程首次写入;
- 计算阶段线程被绑定到 CPU 8。
则:
首次写入:CPU 0 → 页面位于 node 0
计算访问:CPU 8 → 读取 node 0 的远端页面
即使计算线程始终固定在 CPU 8,它仍可能长期进行远端访问。正确分析必须同时检查:
numastat -p <pid>
cat /proc/<pid>/numa_maps
/proc/<pid>/numa_maps 的格式受内核版本影响,但通常能看到页面分布在各 NUMA 节点的统计。
内存策略工具
使用 numactl 启动程序:
numactl --cpunodebind=1 --membind=1 ./program
含义是:
- CPU 绑定到节点 1 的 CPU;
- 内存严格从节点 1 分配。
如果节点 1 没有足够可用内存,严格 --membind=1 可能导致分配失败或触发更严重的回退路径。更宽松的策略是:
numactl --cpunodebind=1 --preferred=1 ./program
这表示优先节点 1,但允许在其他节点分配。
交错分配:
numactl --interleave=all ./program
它适合某些带宽型、访问模式均匀的任务,但不一定适合延迟敏感且具有明显数据局部性的任务。
内核自动 NUMA 平衡
部分系统启用自动 NUMA balancing。内核可能通过页表访问跟踪发现任务与页面位置不匹配,再迁移任务或页面。
查看状态:
cat /proc/sys/kernel/numa_balancing
自动平衡不是无成本的:
- 需要额外的页表和 fault 跟踪;
- 页面迁移会消耗 CPU 和内存带宽;
- 共享页迁移可能带来抖动;
- 工作集快速变化时可能追赶不及;
- 人工设置严格策略后,自动机制的作用会受限。
是否启用应通过压测和 NUMA 计数器验证,而不是凭经验决定。
十、NUMA 下的线程、数据和锁
NUMA 优化不仅是“绑定 CPU”。
1. 数据分片
假设有两个 NUMA 节点,可以让每个节点的线程处理本地数据:
node 0:
worker 0-3 → data shard 0
node 1:
worker 4-7 → data shard 1
若线程频繁访问另一个节点的数据,CPU affinity 反而可能固化远端访问。
2. 并行初始化
单线程初始化大数组会让页面集中到一个节点。更合理的方式是:
- 创建线程;
- 将每组线程绑定到对应 NUMA 节点;
- 每组线程初始化自己负责的数据分片;
- 后续计算继续处理本地分片。
这利用了 first-touch 规则。
3. 共享锁的跨节点代价
一个全局锁被多个 NUMA 节点上的线程争用时,锁变量的 cache line 会在节点之间来回转移。即使每次临界区很短,也可能产生高延迟。
常见替代方案包括:
- 每节点一个锁;
- 每节点一个队列;
- 分片计数器,最后归并;
- 降低跨节点共享写入;
- 使用读多写少的数据结构。
但这些是设计取舍,不是调度器自动完成的优化。
十一、调度器如何选择 CPU:负载均衡与迁移
任务唤醒时,内核需要选择目标 CPU;任务运行期间,还需要在 CPU 之间平衡负载。
调度器考虑的因素可能包括:
- CPU 当前可运行任务数和负载;
- 任务 affinity;
- cpuset;
- CPU capacity;
- SMT、核心、socket 和 NUMA 拓扑;
- cache 局部性;
- 节能策略;
- cgroup 权重和 quota;
- 任务是否 recently running。
在异构 CPU(例如大小核)系统中,还要考虑 CPU capacity:同样的运行时间,不同 CPU 可能完成不同数量的工作。现代内核会将 capacity 和频率等因素纳入调度决策,但实际行为依赖架构驱动和内核配置。
任务迁移的收益是平衡负载,代价是:
- cache 局部性损失;
- NUMA 内存位置可能失配;
- 迁移统计和锁操作;
- 任务在不同 CPU 上观察到不同的频率和中断竞争。
查看迁移统计:
sudo perf stat -e cpu-migrations,context-switches \
./program
如果迁移很多,不应立即得出“必须绑核”的结论。先确认迁移是否真的造成 cache miss、远端内存访问或尾延迟问题。
十二、实时任务与普通任务共享 CPU 的故障路径
考虑一个 CPU 上存在:
- 普通任务 A;
SCHED_FIFO任务 B;- softirq 处理;
- 内核 worker。
如果 B 进入无限循环:
- B 进入实时运行队列;
- A 不能再通过 fair 调度获得 CPU;
- softirq 可能延迟到内核必须处理或其他 CPU 处理的时机;
- worker 延迟,可能造成 I/O、网络或存储处理堆积;
- 系统负载表现可能并不完全等同于 CPU 100%,但业务延迟不断增加;
- 若 B 还绑定了唯一 CPU,影响会集中在该 CPU;
- 若 B 绑定了多个 CPU 并创建多个实时线程,影响范围扩大。
诊断实时任务:
ps -eLo pid,tid,cls,rtprio,pri,ni,psr,stat,comm --sort=-rtprio
sudo chrt -p <tid>
top -H -p <pid>
恢复时应保留可操作路径:
sudo chrt -o -p 0 <tid>
sudo taskset -cp 0-3 <tid>
sudo kill -STOP <pid>
其中:
chrt -o -p 0尝试将线程改回普通策略;taskset重新扩大可运行 CPU 集合;SIGSTOP暂停进程。
这些操作需要权限,且如果线程卡在内核不可中断路径、系统已严重失去响应,用户态恢复可能无效。生产环境应先确认目标 TID、服务重启策略和进程管理器是否会重新设置策略。
十三、Load、CPU 使用率与调度器状态
Load average 不是 CPU 使用率。
Linux 的 load average 传统上反映:
- 可运行任务;
- 以及处于特定不可中断睡眠状态的任务。
因此,一个 I/O 堵塞严重的系统可能 load 很高,但 CPU 并未满载;一个 CPU 密集型任务可能让 CPU 100%,但单核 load 只接近 1。
观察基本状态:
uptime
cat /proc/loadavg
vmstat 1
mpstat -P ALL 1
结合解释:
r高、CPU busy 高:可能是 CPU 不足;r高、CPU idle 高:可能存在 affinity、cpuset 或调度限制;b高、wa高:可能是 I/O 等待;- CPU 使用率不高但延迟高:可能是锁、远端 NUMA、软中断或调度延迟;
- 某个 CPU 100%、其他 CPU 空闲:可能是单线程瓶颈、中断集中或 affinity 过窄。
这也是为什么只看 top 中的 CPU 百分比无法解释调度问题。
十四、软中断与用户任务的相互影响
网络收包、定时器、块设备完成等事件可能通过 softirq 处理。softirq 通常在中断返回路径处理,也可能由 ksoftirqd/N 线程兜底处理。
检查软中断计数:
cat /proc/softirqs
ps -eLo pid,tid,psr,stat,comm | grep ksoftirqd
如果某个 CPU 上网络 softirq 很高,同时用户线程也绑定到该 CPU,二者会竞争执行资源。将用户线程固定到该 CPU 可能使尾延迟更差;但盲目把线程移走,也可能失去网卡队列和数据处理的局部性。
网络高性能场景常需要同时考虑:
- IRQ affinity;
- RSS/RPS/XPS;
- NAPI poll;
- 用户线程 affinity;
- NUMA 网卡所在节点;
- 内存分配位置。
调度器只负责在可运行实体之间选择 CPU,无法单独解决整个 I/O 数据路径的拓扑问题。
十五、cgroup 调度:任务之外的资源层次
即使任务拥有较高 nice,也可能受到 cgroup 的 CPU 配额限制。
cgroup v2 常见接口:
cat /sys/fs/cgroup/cpu.max
cat /sys/fs/cgroup/cpu.weight
cat /sys/fs/cgroup/cpuset.cpus.effective
其中:
cpu.max控制一个周期内最多使用多少 CPU 时间;cpu.weight表示可运行 cgroup 之间的相对权重;cpuset.cpus.effective表示最终允许使用的 CPU。
例如:
cpu.max = 20000 100000
通常表示每 100000 微秒周期,最多使用 20000 微秒 CPU 时间,即约 20% 的单 CPU 配额;在多 CPU 场景下,实际解释要结合 cgroup 的可用 CPU 集合。
这与 nice 不同:
- nice 调整任务在普通调度中的相对权重;
- cgroup quota 给出资源上限;
- cpuset 限制可用 CPU 集合;
- 实时策略还可能受到实时带宽控制。
诊断“为什么任务优先级很高却跑不满”时,必须检查这些层次。
十六、生产诊断流程:从现象到因果
场景一:某线程延迟突然升高
先确认线程实际状态:
ps -eLo pid,tid,cls,rtprio,pri,ni,psr,stat,wchan:32,comm
重点观察:
- 是否变成
D状态; - 是否处于实时策略;
- 是否被限制到单个 CPU;
wchan是否显示在等待锁、I/O 或 futex;- 是否频繁迁移;
- 是否长期处于 runnable。
查看线程 affinity:
taskset -pc <tid>
查看 CPU 拓扑和实时运行队列:
lscpu -e=CPU,CORE,SOCKET,NODE
cat /proc/schedstat
/proc/schedstat 的字段是内核版本相关的内部统计接口,不能跨版本直接套用字段含义。需要更明确的事件时使用 tracepoint:
sudo trace-cmd record \
-e sched:sched_switch \
-e sched:sched_wakeup \
-e sched:sched_wakeup_new \
-e sched:sched_migrate_task \
sleep 10
sudo trace-cmd report
如果系统没有 trace-cmd,可以使用 perf:
sudo perf sched record -- sleep 10
sudo perf sched latency
场景二:CPU 使用率低,但任务仍然慢
按以下顺序排查:
- 任务是否在睡眠或等待 I/O;
- 是否受到 affinity、cpuset 或 quota 限制;
- 是否在等待锁;
- 是否有高优先级实时任务占用 CPU;
- 是否存在严重远端 NUMA 访问;
- 是否有 softirq 或中断集中;
- 是否受到频率限制或宿主机虚拟化调度影响。
查看 NUMA:
numastat -p <pid>
cat /proc/<pid>/numa_maps
查看 cgroup:
cat /proc/<pid>/cgroup
查看调度和迁移:
sudo perf stat -p <pid> \
-e context-switches,cpu-migrations,page-faults
场景三:负载很高但只有一个 CPU 忙
检查:
mpstat -P ALL 1
taskset -pc <pid>
cat /sys/fs/cgroup/cpuset.cpus.effective
可能原因包括:
- 单线程程序;
- 线程 affinity 只允许一个 CPU;
- cgroup cpuset 只有一个 CPU;
- 全局锁导致其他线程等待;
- 中断或 softirq 集中在一个 CPU;
- 任务使用
SCHED_FIFO,但只绑定一个 CPU; - CPU 拓扑或虚拟机 vCPU 映射造成误判。
“把线程数量加到 CPU 数量”只有在任务可并行、锁竞争可接受、内存带宽足够且 affinity 合理时才可能有效。
十七、常见误解与边界
误解一:nice -20 可以抢过实时任务
不能。nice 只影响普通 fair 类任务。实时类任务通常优先于普通类。
误解二:绑定 CPU 后就不会被抢占
不会。Affinity 只限制可运行 CPU,不禁止:
- 更高优先级任务抢占;
- 同 CPU 的实时任务抢占;
- 中断和 softirq;
- 内核内部调度;
- cgroup 配额限制。
误解三:NUMA 绑定 CPU 就等于本地内存
不等于。页面可能在绑定前已经分配,或者由其他线程首次写入。必须同时验证 CPU 和页面分布。
误解四:上下文切换越少越好
不一定。减少切换可能提高吞吐,但也可能让交互任务、I/O 完成或其他线程等待更久。需要根据延迟、吞吐和公平性判断。
误解五:Load 等于 CPU 利用率
不等于。Load 包含调度等待以及某些不可中断等待,必须结合 vmstat、mpstat、I/O 和任务状态分析。
误解六:实时策略能自动提供实时性
不能。实时性还依赖:
- 最坏执行时间;
- 锁和阻塞时间;
- 中断延迟;
- 内存 fault;
- I/O;
- CPU 频率;
- NUMA 访问;
- 系统超载行为;
- 应用是否正确让出或阻塞。
十八、如何选择策略、优先级、Affinity 和 NUMA 方案
可以按问题类型选择机制,而不是先使用最强的手段:
普通 CPU 密集任务
优先使用:
- 默认 fair 调度;
- 合理的线程数;
- 数据分片;
- 必要时调整 nice;
- 通过性能分析确认瓶颈。
不要仅因任务重要就使用 SCHED_FIFO。
低优先级后台任务
可以考虑:
nice -n 19 ./background-job
或者:
chrt -i 0 ./background-job
具体效果需要结合 cgroup、I/O 优先级和系统负载验证。低 CPU 优先级不自动降低磁盘或网络资源竞争。
低延迟线程
应同时分析:
- 是否真的需要实时策略;
- 是否需要独占或保留 CPU;
- IRQ 和 softirq 是否集中;
- 锁是否支持优先级继承;
- 内存是否预 fault;
- NUMA 页面是否本地;
- 是否受到频率和电源管理影响。
NUMA 大内存任务
优先设计:
- 线程与数据分片的节点对应;
- 并行 first-touch;
- 减少跨节点共享写入;
- 根据访问模式选择 local、preferred 或 interleave;
- 用
numastat和性能计数器验证。
容器和服务
必须同时检查:
- 任务自身 affinity;
- cgroup cpuset;
- CPU quota 和 weight;
- systemd 或容器运行时设置;
- NUMA memory policy;
- 宿主机 CPU、IRQ 和虚拟化环境。
结语
Linux 调度的核心不是一个单独的优先级数字,而是一套分层约束:
CFS 及其现代 fair 调度演进解决普通任务之间的公平分配;实时策略改变任务之间的抢占关系;nice 调整普通任务的相对权重;Affinity 限制任务可运行位置;NUMA 决定这些 CPU 与数据之间的内存访问代价。
真正的性能问题通常出在这些层次的交界处:一个高优先级线程可能被锁阻塞,一个绑核线程可能访问远端内存,一个 CPU 使用率不高的任务可能受 cgroup quota 限制,一个实时线程可能让 softirq 和普通任务饥饿。只有把调度策略、运行队列、任务状态、CPU 集合、cgroup 和 NUMA 页面分布放在同一条因果链上,才能从“看到现象”推导出“为什么会这样”。
系列导航与关联阅读
- 系列入口:Linux 完整学习路线:从内核与文件系统到网络、性能和生产运维
- 上一篇:Linux 系统调用与 strace:调用边界、阻塞、错误和性能诊断
- 下一篇:Linux 中断与 Softirq:IRQ、Bottom Half、ksoftirqd 和延迟
- 延伸:Linux CPU 调度与负载:运行队列、上下文切换、Load 和软中断
- 延伸:Linux NUMA 与硬件拓扑:节点、内存亲和、跨节点访问和诊断
官方资料
本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论
0 条讨论