Linux 基础体系 · 第 29/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 调度器深入:CFS、实时策略、优先级、Affinity 和 NUMA

在 Linux 中,“调度器”不是一个简单地把进程轮流分配给 CPU 的循环。它同时处理以下问题:

  • 哪些任务当前可以运行;
  • 哪个任务应该优先运行;
  • 任务在多个 CPU 之间如何迁移;
  • 实时任务如何限制普通任务;
  • 线程的 CPU 亲和性如何约束调度;
  • NUMA 系统中,任务在哪个 CPU 上运行、数据位于哪个内存节点;
  • 阻塞、唤醒、抢占、上下文切换和软中断如何改变系统状态。

理解这些机制,需要先区分三个概念:

  1. 调度策略(scheduling policy):任务属于哪一个调度类,例如普通任务、实时任务或 deadline 任务。
  2. 优先级(priority):同一调度类中,哪个任务应当先获得 CPU;不同调度类之间也存在固定的优先级关系。
  3. 亲和性(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 上,可以抽象出一个基本选择问题:

next=argmaxtReffective_priority(t)\text{next} = \arg\max_{t \in R} \text{effective\_priority}(t)

其中:

  • RR 是当前 CPU 上允许运行的任务集合;
  • effective_priority 不是单一整数,而是由调度类、实时优先级、nice、deadline 等规则共同决定。

在多核系统中,任务还必须满足 CPU affinity 和 cpuset 约束:

eligible(t)=R(t)online_cpusaffinity(t)cpuset(t)\text{eligible}(t) = R(t) \cap \text{online\_cpus} \cap \text{affinity}(t) \cap \text{cpuset}(t)

如果一个任务没有可用 CPU,即使它是可运行状态,也只能等待。

运行队列与每 CPU 调度

Linux 通常以每 CPU 运行队列(runqueue,简称 rq)组织可运行任务。虽然现代内核中调度类、调度实体和负载跟踪有更复杂的层次,但“每个 CPU 有自己的可运行任务集合”仍然是理解调度的核心。

任务可能因为以下事件进入或离开运行队列:

任务被唤醒
    │
    ├── 检查 affinity、cpuset 和 CPU 可用性
    ├── 选择目标 CPU
    ├── 加入目标 CPU 的调度类运行队列
    └── 必要时触发抢占

任务运行
    │
    ├── 主动阻塞:从运行队列移除
    ├── 时间片或公平份额耗尽:重新排队
    ├── 更高优先级任务唤醒:被抢占
    ├── 系统调用或中断:进入内核路径
    └── 退出:从调度系统移除

“可运行任务数”不等于“CPU 使用率”。例如,一个线程等待 I/O 时通常不占用 CPU,但任务数可能增加;一个 CPU 密集型线程可能使 CPU 使用率接近 100%,但运行队列中只有一个任务。


二、调度类:普通、实时与 deadline 的关系

Linux 不是把所有任务放入同一种队列,而是通过调度类(scheduler class)组织不同调度策略。不同内核版本的具体实现细节会变化,但调度类之间的优先关系是理解行为的关键。

通常可以按以下逻辑理解:

  1. deadline 类任务优先于实时类;
  2. 实时类任务优先于普通 fair 类;
  3. 普通任务之间按照公平调度规则竞争;
  4. SCHED_IDLE 任务属于普通调度体系中的极低优先级场景。

因此,一个持续运行的 SCHED_FIFO 任务可能长期阻止普通 CFS/fair 任务运行。这个行为不是“CFS 失效”,而是调度类优先级导致的正常结果。

常见策略

可以用 chrt 查看或设置实时策略,用 nicerenice 调整普通任务优先级:

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_OTHERSCHED_BATCHSCHED_IDLE 仍属于普通任务范畴,受 fair 调度和 nice 影响。SCHED_FIFOSCHED_RR 使用静态实时优先级。SCHED_DEADLINE 使用另一套参数和调度保证。

调度类之间的抢占

假设一个 CPU 上已有普通任务 A 正在运行:

CPU 0: A (SCHED_OTHER)

此时实时任务 B 被唤醒:

B: SCHED_FIFO, priority 50

典型路径是:

  1. B 进入 CPU 0 的实时运行队列;
  2. 调度器发现实时类非空;
  3. B 的调度类优先级高于普通 fair 类;
  4. A 被标记为需要重新调度;
  5. 在合适的抢占点,A 保存寄存器和内核执行状态;
  6. B 开始运行。

如果 B 不阻塞、不调用 sched_yield(),也没有更高实时优先级任务出现,B 可以持续运行很长时间。

这解释了一个生产风险:实时策略不是“让某个线程更快”,而是改变了它与其他任务之间的调度关系。


三、CFS 与现代 fair 调度:从虚拟运行时间到 EEVDF

1. CFS 的目标

CFS(Completely Fair Scheduler,完全公平调度器)长期用于 Linux 普通任务调度。它试图让任务获得与权重成比例的 CPU 时间。

设任务 ii 的权重为 wiw_i,所有可运行普通任务权重总和为:

W=jwjW = \sum_j w_j

在理想的多路复用模型中,任务 ii 获得的 CPU 比例近似为:

sharei=wiW\text{share}_i = \frac{w_i}{W}

如果 CPU 在一段时间 TT 内一直忙,任务的理想 CPU 时间是:

ti=TwiWt_i = T \cdot \frac{w_i}{W}

nice 值越小,权重越大;nice 值越大,权重越小。Linux 内核使用离散的权重表,而不是在每次调度时直接计算一个理想实数。

2. 虚拟运行时间

CFS 使用虚拟运行时间(virtual runtime,vruntime)表达一个任务相对于其权重“已经消耗了多少公平份额”。

典型关系为:

Δvruntime=ΔexecNICE_0_LOADwi\Delta vruntime = \Delta exec \cdot \frac{NICE\_0\_LOAD}{w_i}

其中:

  • Δexec\Delta exec:任务实际占用 CPU 的时间;
  • wiw_i:任务权重;
  • NICE_0_LOAD:nice 0 对应的基准权重;
  • Δvruntime\Delta vruntime:任务公平账本增加的量。

因此:

  • 权重大的任务运行同样的实际时间,vruntime 增加较少;
  • 权重小的任务运行同样的实际时间,vruntime 增加较多;
  • 调度器倾向于选择 vruntime 较小的任务。

这并不是“每个任务得到相同的实际时间”,而是“每个任务按照权重得到相近的虚拟进度”。

完整算例

假设只有两个普通任务:

  • 任务 A:nice 0,权重 wA=1024w_A = 1024
  • 任务 B:nice 5,权重约为 wB=335w_B = 335

则总权重:

W=1024+335=1359W = 1024 + 335 = 1359

理想 CPU 比例:

shareA=1024135975.35%\text{share}_A = \frac{1024}{1359} \approx 75.35\%

shareB=335135924.65%\text{share}_B = \frac{335}{1359} \approx 24.65\%

如果 CPU 忙碌 100 ms:

tA75.35 mst_A \approx 75.35\text{ ms}

tB24.65 mst_B \approx 24.65\text{ ms}

假设调度器最初让 A 运行 10 ms,B 运行 10 ms,取 NICE_0_LOAD = 1024

ΔvruntimeA=1010241024=10\Delta vruntime_A = 10 \cdot \frac{1024}{1024} = 10

ΔvruntimeB=10102433530.57\Delta vruntime_B = 10 \cdot \frac{1024}{335} \approx 30.57

虽然两者实际都运行了 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_FIFOSCHED_RRSCHED_DEADLINE 任务,修改 nice 不是控制其核心调度顺序的正确手段。

2. 实时优先级

Linux 实时策略通常使用静态优先级范围 1 到 99,数值越大优先级越高。可用命令查看系统允许范围:

chrt -m

以 FIFO 策略和实时优先级 50 启动程序:

sudo chrt -f 50 ./rt-program

查看进程策略:

chrt -p 12345

不同实时任务的选择规则是:

  1. 选择实时优先级最高的非空队列;
  2. 如果策略是 SCHED_FIFO,运行队首任务;
  3. 如果策略是 SCHED_RR,同优先级任务按时间片轮转;
  4. 只有当实时运行队列为空时,普通 fair 任务才有机会运行。

3. SCHED_FIFOSCHED_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。

执行顺序:

  1. L 持有互斥锁;
  2. H 需要同一把锁,因此阻塞;
  3. M 不需要这把锁,但一直可运行;
  4. M 抢占 L;
  5. L 无法运行,也就无法释放锁;
  6. 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:任务周期。

一个周期性任务可以抽象为:

(C,D,T)(C, D, T)

其中:

  • CC:每个周期需要的最大 CPU 时间;
  • DD:从释放到截止期的相对时间;
  • TT:任务释放周期。

例如:

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. 上下文切换的代价

上下文切换通常包括:

  1. 保存当前任务的寄存器和调度上下文;
  2. 更新运行时间、统计量和调度实体状态;
  3. 切换到新任务的内核栈;
  4. 可能切换地址空间;
  5. 恢复新任务的寄存器;
  6. 重新进入用户态或内核态。

代价不只有保存寄存器。任务切换还可能影响:

  • TLB;
  • L1/L2 cache;
  • 分支预测器;
  • 调度器锁竞争;
  • NUMA 局部性;
  • 内存带宽。

可以使用以下命令观察系统整体调度指标:

vmstat 1

常见字段:

  • r:可运行任务数;
  • b:阻塞任务数;
  • cs:上下文切换次数;
  • in:中断次数;
  • ussy:用户态和内核态 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 通常是多个集合的交集:

effective CPUs=task affinitycpuset effective CPUsonline CPUs\text{effective CPUs} = \text{task affinity} \cap \text{cpuset effective CPUs} \cap \text{online CPUs}

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 所属内存节点。

示例:

  1. 线程 T 在节点 0 的 CPU 上分配一块大数组;
  2. malloc() 只建立虚拟地址范围,物理页可能尚未真正分配;
  3. T 首次写入数组;
  4. 缺页异常触发物理页分配;
  5. 页面通常落在节点 0;
  6. 随后线程迁移到节点 1;
  7. 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. 并行初始化

单线程初始化大数组会让页面集中到一个节点。更合理的方式是:

  1. 创建线程;
  2. 将每组线程绑定到对应 NUMA 节点;
  3. 每组线程初始化自己负责的数据分片;
  4. 后续计算继续处理本地分片。

这利用了 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 进入无限循环:

  1. B 进入实时运行队列;
  2. A 不能再通过 fair 调度获得 CPU;
  3. softirq 可能延迟到内核必须处理或其他 CPU 处理的时机;
  4. worker 延迟,可能造成 I/O、网络或存储处理堆积;
  5. 系统负载表现可能并不完全等同于 CPU 100%,但业务延迟不断增加;
  6. 若 B 还绑定了唯一 CPU,影响会集中在该 CPU;
  7. 若 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 使用率低,但任务仍然慢

按以下顺序排查:

  1. 任务是否在睡眠或等待 I/O;
  2. 是否受到 affinity、cpuset 或 quota 限制;
  3. 是否在等待锁;
  4. 是否有高优先级实时任务占用 CPU;
  5. 是否存在严重远端 NUMA 访问;
  6. 是否有 softirq 或中断集中;
  7. 是否受到频率限制或宿主机虚拟化调度影响。

查看 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 包含调度等待以及某些不可中断等待,必须结合 vmstatmpstat、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 调度的核心不是一个单独的优先级数字,而是一套分层约束:

调度类类内优先级或公平规则可运行状态CPU affinity/cpusetCPU 与 NUMA 拓扑\text{调度类} \rightarrow \text{类内优先级或公平规则} \rightarrow \text{可运行状态} \rightarrow \text{CPU affinity/cpuset} \rightarrow \text{CPU 与 NUMA 拓扑}

CFS 及其现代 fair 调度演进解决普通任务之间的公平分配;实时策略改变任务之间的抢占关系;nice 调整普通任务的相对权重;Affinity 限制任务可运行位置;NUMA 决定这些 CPU 与数据之间的内存访问代价。

真正的性能问题通常出在这些层次的交界处:一个高优先级线程可能被锁阻塞,一个绑核线程可能访问远端内存,一个 CPU 使用率不高的任务可能受 cgroup quota 限制,一个实时线程可能让 softirq 和普通任务饥饿。只有把调度策略、运行队列、任务状态、CPU 集合、cgroup 和 NUMA 页面分布放在同一条因果链上,才能从“看到现象”推导出“为什么会这样”。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。