3.6 多核、NUMA 与负载均衡
开机时第二个 CPU 是谁唤醒的?一个线程在 CPU 0 上跑,为什么过一会儿跑到 CPU 3 上去了?「负载均衡」到底在搬什么、多久搬一次?NUMA 机器上「内存比 CPU 远」意味着什么,numactl 的每种策略各自解决什么问题?isolcpus 和 cgroup 的 cpuset 有什么本质区别?
画出从 SMT 到 NUMA 节点的调度域层级;说清 load_balance() 的四步与触发时机;用 taskset/sched_setaffinity 把任务钉在核上并验证;用 numastat 判断一个进程是不是在跨节点访存;用 perf 与 bpftrace 数出迁移次数。
3.5 讲的是「一个 CPU 上怎么选下一个任务」。真实机器有几十上百个 CPU,每个都有自己的运行队列。于是多出一层问题:任务该放在哪个队列里?放错了,一个核忙死、七个核闲着;放对了,但内存在另一个 NUMA 节点,访存延迟翻倍。
多核调度有两个层次:放置(placement) 决定任务初始落在哪个 CPU,负载均衡(load balancing) 在不断把任务从忙的队列搬到闲的队列。这两件事都不是「最优解」问题——精确求解需要全局信息与频繁同步,代价太高。Linux 的解法是分层的调度域 + 周期性的近似:先在 SMT 兄弟间找,再在同一 LLC 的核间找,再跨 NUMA 节点找,每一层有自己的开销预算。
本章按「拓扑 → 域 → 均衡 → 亲和性 → NUMA」展开,最后落到观测。奔跑吧第 3 章的 3.3–3.6(SMP 负载均衡、HMP、NUMA、EAS)是主要母本;OSTEP 第 10 章给理论对照。
3.6.1 从 boot CPU 到 AP:多核怎么起来
开机时只有一个 CPU 在跑(BSP,bootstrap processor)。其他核(AP,application processor)要由 BSP 主动唤醒:
start_kernel() → rest_init() → kernel_init() → kernel_init_freeable()
→ smp_init() /* kernel/smp.c */
→ bringup_nonboot_cpus() /* 遍历 cpu_possible_mask,跳过 boot CPU */
→ cpu_up(cpu) → _cpu_up()
→ cpuhp_up_callbacks() /* CPUHP_BP_PREPARE 到 CPUHP_ONLINE 逐级回调 */
→ smpboot_thread_fn() 在 AP 上跑起来
→ do_basic_setup() → do_initcalls()
AP 的入口是 arch/x86/kernel/smpboot.c: start_secondary():它初始化自己的 GDT/IDT/APIC,设置 per-CPU 变量,然后进入 cpu_startup_entry() 的 idle 循环——和 BSP 最后走的是同一个函数。从这一刻起,两个核在调度器眼里是对等的。
内核用三个位图描述 CPU 的状态,区别很重要:
| 位图 | 含义 | 什么时候变 |
|---|---|---|
cpu_possible_mask | 可能存在的 CPU(受 CONFIG_NR_CPUS 与 ACPI/MADT 限制) | 启动时固定 |
cpu_present_mask | 物理上存在的 CPU(含热插拔槽位) | 热插拔时变 |
cpu_online_mask | 正在参与调度的 CPU | cpu_up()/cpu_down() 时变 |
cpu_active_mask | 可以接收新任务的 CPU | 热插拔的过渡阶段 |
热插拔时,cpu_down() 会把目标任务迁走、停掉该 CPU 的 tick、把它的运行队列并到别的核,最后置 !cpu_online。容器/虚拟化里「vCPU 热插拔」就是这套机制。
拓扑信息从哪来?x86 上来自 ACPI 的 MADT(APIC 拓扑)与 CPUID 的 0x1f/0xb 叶(x2APIC ID 的层级编码);ARM64 上来自设备树的 cpu-map 节点。内核把它们整理成 struct cpu_topology:
/* include/linux/arch_topology.h —— 6.8 节选 */
struct cpu_topology {
int thread_id; /* SMT 线程号 */
int core_id; /* 核号 */
int cluster_id;
int package_id; /* 插槽号 */
cpumask_t thread_sibling; /* 同一核的 SMT 兄弟 */
cpumask_t core_sibling; /* 同一 cluster 的核 */
cpumask_t cluster_sibling;
cpumask_t package_sibling;
};
用户态能看到同一份信息的投影:
lscpu -e=CPU,NODE,SOCKET,CORE,CLUSTER,ONLINE,MAXMHZ
cat /sys/devices/system/cpu/cpu0/topology/thread_siblings_list
cat /sys/devices/system/cpu/cpu0/topology/core_id
一个典型的双路 8 核(开了 SMT)输出:
CPU NODE SOCKET CORE ONLINE MAXMHZ
0 0 0 0 yes 3800.0
1 1 1 0 yes 3800.0
2 0 0 1 yes 3800.0
3 1 1 1 yes 3800.0
...
8 0 0 0 yes 3800.0 ← 与 cpu0 同核,SMT 兄弟
注意 NODE 与 SOCKET 不必一致:单路机器也可以有多个 NUMA 节点(EPYC 的 chiplet 就是这样)。判拓扑要看 thread_siblings_list 与 node* 目录,不要只看 nproc。
thread_siblings_list 是判断「超线程兄弟」的快捷方式:如果 cpu0 与 cpu1 互为兄弟,把两个重负载分别绑到 0 和 1 不会得到两倍算力,因为它们共享执行单元。
3.6.2 调度域:把拓扑变成一棵树
负载均衡不能盲目跨核搬任务:跨 NUMA 节点搬一次,缓存全失效;跨 SMT 搬,几乎没收益。内核用 调度域(sched_domain) 表达这种「越近越便宜」的层级:
┌──────────────────────────────┐
NUMA 层(跨节点) │ sd: NUMA, flags=SD_NUMA │ 代价最高,最后才考虑
└──────────────┬───────────────┘
┌────────────────────┴────────────────────┐
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ node 0 │ │ node 1 │
│ sd: DIE/MC │ │ sd: DIE/MC │
│ flags=SD_SHARE_PKG │ │ flags=SD_SHARE_PKG │
└──────────┬───────────┘ └──────────┬───────────┘
┌─────┴─────┐ ┌─────┴─────┐
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ core 0 │ │ core 1 │ │ core 2 │ │ core 3 │
│ sd: SMT │ │ sd: SMT │ │ sd: SMT │ │ sd: SMT │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
┌──┴──┐ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
cpu0 cpu1 cpu2 cpu3 cpu4 cpu5 cpu6 cpu7
(SMT 兄弟共享执行单元;同节点共享 LLC;跨节点走互连)
读图:每个 CPU 属于一串嵌套的域,从最内层的 SMT 到最外层的 NUMA。load_balance() 从最内层开始尝试,搬不动(或搬了不划算)才往上层走。域的标志位决定「这一层值不值得搬」:
| 标志 | 含义 | 典型层级 |
|---|---|---|
SD_SHARE_CPUCAPACITY | 共享执行能力(SMT 兄弟) | SMT |
SD_SHARE_PKG_RESOURCES | 共享 L2/L3(同一核簇/LLC) | MC/DIE |
SD_NUMA | 跨 NUMA 节点,搬任务可能丢本地内存 | NUMA |
SD_ASYM_CPUCAPACITY | 核的算力不同(big.LITTLE) | DIE |
SD_SERIALIZE | 该层均衡串行化,避免多核同时搬 | NUMA |
域是在启动时由 kernel/sched/topology.c: build_sched_domains() 依据拓扑与 NUMA 信息生成的,不是静态表。CPU 热插拔、cgroup cpuset 变化都会触发 partition_sched_domains() 重建。
每个调度域下挂着若干调度组(struct sched_group),组内 CPU 被视为一个整体参与比较。组的 sgc->capacity 是该组的总算力(按 CPU 的最初算力 capacity_of() 累加,受 cpu_capacity 与热降频影响),group_weight 是组内 CPU 数。load_balance() 比较的是「组的负载 / 组的容量」,而不是绝对负载——这就是为什么 8 核小核簇和 2 核大核簇可以公平比较。
8 核机器(单 NUMA 节点,无 SMT),CPU 0 上有 6 个可运行任务,其余 7 个核各有 1 个。load_balance() 在 MC 层被 CPU 7 的空闲触发:
- CPU 7 的
rq->nr_running = 1(它自己),CPU 0 的rq->nr_running = 6; - 按「负载/容量」算,CPU 0 的利用率约 600%,CPU 7 约 100%,
busiest是 CPU 0; detach_tasks()尝试搬min(6/2, sched_nr_migrate=32),即 3 个;但can_migrate_task()会拒绝task_hot()为真的任务(最近在本核跑过、缓存还热);- 实际搬走 2 个,CPU 0 剩 4 个,CPU 7 变 3 个。
下一轮 tick 会发现 CPU 0 仍然最忙,再搬 1 个,直到各核大致均衡。整个过程分散在若干个 tick 里完成,不是一次到位——这就是「周期性近似」的含义。
3.6.3 负载均衡:谁在搬、搬什么、多久搬一次
均衡有三个触发点:
- 周期性:
scheduler_tick()里若到达sd->balance_interval,置SCHED_SOFTIRQ,由run_rebalance_domains()→rebalance_domains()处理。 - 新空闲(newidle):CPU 的运行队列空了,
newidle_balance()立刻去别的队列偷任务,避免进入 idle。 - 唤醒时:
select_task_rq_fair()在wake_up_new_task()/try_to_wake_up()时选一个「最合适」的 CPU(wake_affine、select_idle_sibling)。
核心函数是 kernel/sched/fair.c: load_balance(),四步:
load_balance(rq, this_cpu, sd, idle, ...)
├─ 1. find_busiest_group() 在 sd 范围内找「负载/容量」比最高的组
├─ 2. find_busiest_queue() 在该组里找最忙的 CPU 队列
├─ 3. detach_tasks() 从忙队列摘出若干任务(最多 sched_nr_migrate 个)
└─ 4. attach_tasks() 挂到本地队列;若本地正忙,走 active balance
摘任务时有几条硬约束:
- 亲和性:任务的
cpus_ptr不含本地 CPU,不能搬。 - 缓存热度:
task_hot()判断任务最近是否在本 CPU 上跑过;热任务搬迁的收益可能抵不上缓存失效,sysctl_sched_migration_cost_ns(默认 0.5 ms)是阈值。 - 迁移代价:
can_migrate_task()综合判断,正在运行的任务不能被直接搬(要等它让出),只能通过stop_one_cpu()在 stop 类里强制迁移(active balance,由migration/N内核线程执行)。
sched_nr_migrate(默认 32)限制一次最多搬多少任务,避免长时间持锁。sched_migration_cost_ns 与 sched_nr_migrate 都能在 /proc/sys/kernel/ 下调整,但通常不该调:默认值是多年调优的结果,改动往往在修一个不存在的问题。
最公平的做法是所有 CPU 共享一个就绪队列,谁空谁取。但那样每次入队/出队都要抢同一把锁,核越多争用越重(一份全局锁的扩展性上限约 8 核)。Linux 选择 per-CPU 队列 + 周期性均衡:入队无争用(本地锁),代价是「短时不均」。这是典型的「用一点公平换扩展性」。
OSTEP 第 10 章把多处理器调度分为单队列(SQMS)与多队列(MQMS):SQMS 简单,但全局锁不可扩展、缓存亲和差;MQMS 扩展性好,却会负载不均,靠 work stealing 迁移。Linux 是 MQMS 的工程化极端版本:per-CPU rq + 调度域树 + 周期性 load_balance(),newidle_balance() 就是“空闲时偷任务”的内核版。区别是 Linux 的“偷”受 sched_domain 层级与 can_migrate_task() 约束,不是任意跨队列;这也解释了为什么 OSTEP 里一句“迁移即可”在真实内核里会变成 3.6.8 的一串拒绝条件。
3.6.4 亲和性:把任务钉在核上
sched_setaffinity(2) 设置的是掩码,不是单个 CPU。内核把它存进 task_struct->cpus_ptr(指向 cpus_mask),迁移时检查:
/* kernel/sched/core.c: __sched_setaffinity() —— 6.8 节选,已简化 */
retval = security_task_setscheduler(p);
...
cpumask_and(new_mask, in_mask, cpus_allowed);
...
retval = dl_task_check_affinity(p, new_mask);
...
retval = set_cpus_allowed_ptr(p, new_mask);
set_cpus_allowed_ptr() 如果发现当前 CPU 不在新掩码里,会触发一次迁移:给任务置 migration_pending,通过 stop 类在安全点上把它挪走。所以 sched_setaffinity 返回时,任务可能还在旧 CPU 上跑一小段,但下一个调度点必然迁走。
三种常用手段的差别:
| 手段 | 作用范围 | 是否强制 | 典型用途 |
|---|---|---|---|
taskset -c 2,3 ./prog | 单个进程 | 是(掩码) | 临时实验 |
sched_setaffinity() | 线程/进程 | 是 | 程序内绑核 |
isolcpus=2,3 | 整机启动参数 | 是(隔离核不参与均衡) | 实时任务、DPDK |
cgroup cpuset.cpus | 一组进程 | 是(组内约束) | 容器、systemd slice |
numactl --physcpubind | 进程 + 内存策略 | 是 | NUMA 绑定 |
isolcpus= 与 cpuset 的区别是关键:isolcpus 让内核根本不把普通任务放到这些核上(也不做负载均衡),需要显式 sched_setaffinity 才能用;cpuset 是「允许集合」,组内任务只能在这些核上跑,但别的组仍可能用(除非配合 cpuset.cpus.partition=isolated)。实时场景通常两者叠加:isolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3,再把实时线程绑上去。
cgroup v2 的 cpuset 用法:
# 建一个只能用 CPU 2-3、内存只能用 node 0 的组
mkdir -p /sys/fs/cgroup/realtime
echo "2-3" > /sys/fs/cgroup/realtime/cpuset.cpus
echo "0" > /sys/fs/cgroup/realtime/cpuset.mems
echo max > /sys/fs/cgroup/realtime/cpuset.cpus.partition # 或 isolated
# 把 shell 放进去,之后它 fork 的进程都受约束
echo $$ > /sys/fs/cgroup/realtime/cgroup.procs
taskset -pc $$ # 应显示 2,3
注意顺序:先设 cpuset.cpus/cpuset.mems,再往里放进程;空掩码时写 cgroup.procs 会返回 ENOSPC。cpuset.cpus.partition=isolated 要求该组是 cgroup 根的直接子组、且没有兄弟组竞争,否则返回 EBUSY——这是 6.x 引入的「真正隔离」语义,比只设掩码更强。
- 「绑核就一定更快」:绑核减少迁移与缓存失效,但也放弃了其他核的空闲算力。单线程绑一个核,其余核闲着,总吞吐不会提高。
- 「
taskset能绑内核线程」:多数内核线程带PF_NO_SETAFFINITY,sched_setaffinity返回EINVAL。kthreadd创建时可指定kthread_bind()。 - 「亲和性掩码是建议」:它是硬约束。掩码里没有当前 CPU 时任务会被强制迁移;掩码为空返回
EINVAL。 - 「绑了核就不会迁移」:掩码含多个 CPU 时仍会在其中迁移;
isolcpus的核也可能被kthread或中断占用(除非同时设irqaffinity)。 - 「容器里
nproc就是限额」:nproc看的是亲和性掩码;cpu.max是带宽限制,不改掩码。两者可以矛盾:掩码 8 个核但配额只有 1 个核的算力。
3.6.5 NUMA:距离、放置与自动迁移
UMA 机器上,任何 CPU 访问任何内存的延迟相同。NUMA 机器上,内存被分成节点,CPU 访问本地节点快、远程节点慢(典型 1:1.5 到 1:2.5)。内核用 node_distance(a, b) 表达这个代价,数据来自 ACPI 的 SLIT 表:
$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3
node 0 size: 32768 MB
node 0 free: 1024 MB
node 1 cpus: 4 5 6 7
node 1 size: 32768 MB
node distances:
node 0 1
0: 10 21
1: 21 10
10 是本地,21 是远程——访问远程内存的代价约为本地的 2.1 倍。
默认策略是 first-touch:进程第一次写某页时,内存在「当时运行的那个 CPU」的本地节点分配。这对单线程友好,对多线程是灾难:线程 A 在 node 0 分配了大数组,线程 B 在 node 1 读它,全是远程访问。
四种内存策略:
| 策略 | 行为 | 适用 |
|---|---|---|
MPOL_DEFAULT | 跟随当前 CPU(first-touch) | 默认 |
MPOL_BIND | 只从指定节点分配 | 确定性延迟、隔离 |
MPOL_PREFERRED | 优先指定节点,不够时回退 | 轻度过载 |
MPOL_INTERLEAVE | 按页轮转跨节点 | 大数组、带宽优先 |
# 绑定 CPU 与内存都在 node 0
numactl --cpunodebind=0 --membind=0 ./server
# 只绑内存,CPU 随意
numactl --membind=1 ./server
# 交错分配(适合大数组、内存带宽敏感)
numactl --interleave=all ./stream
在程序里设置策略要用 set_mempolicy() 或 mbind(),前者影响「之后的分配」,后者作用于「已映射区间」:
/* numa_alloc.c — 在指定节点上分配并报告实际落点
* 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -O2 -o numa_alloc numa_alloc.c -lnuma
* 运行: ./numa_alloc 1 64
*/
#define _GNU_SOURCE
#include <numa.h>
#include <numaif.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[])
{
int node = argc > 1 ? atoi(argv[1]) : 0;
size_t mb = argc > 2 ? (size_t)atoi(argv[2]) : 64;
if (numa_available() < 0) {
fprintf(stderr, "NUMA not available\n");
return 1;
}
if (node >= numa_num_configured_nodes()) {
fprintf(stderr, "node %d out of range\n", node);
return 1;
}
/* 在指定节点上分配,并立即触碰每一页(否则只是虚拟映射) */
void *p = numa_alloc_onnode(mb << 20, node);
if (!p) { perror("numa_alloc_onnode"); return 1; }
memset(p, 0x5a, mb << 20);
/* 查询第一页实际落在哪个节点 */
void *page = p;
int status = -1;
if (move_pages(0, 1, &page, NULL, &status, 0) == -1) {
perror("move_pages");
numa_free(p, mb << 20);
return 1;
}
printf("allocated %zu MiB on node %d, first page -> node %d\n",
mb, node, status);
numa_free(p, mb << 20);
return 0;
}
move_pages(pid, count, pages, nodes, status, flags) 在 nodes == NULL 时是查询:status[0] 返回该页当前所在节点。这是验证「策略有没有生效」的最直接手段,比看 numastat 更精确——numastat 是统计,move_pages 是逐页事实。
numastat 是判断「有没有跨节点」的直接工具:
$ numastat -p 1234
Per-node process memory usage (in MBs) for PID 1234 (server)
Node 0 Node 1 Total
--------------- --------------- ---------------
Huge 0.00 0.00 0.00
Heap 0.00 1024.00 1024.00
Stack 0.00 0.02 0.02
Private 512.00 0.00 512.00
---------------- --------------- --------------- ---------------
Total 512.00 1024.02 1536.02
如果上面的进程被绑在 node 0 的 CPU 上,却有 1 GiB 在 node 1,说明有线程在 node 1 上分配过内存(first-touch 落在了远程),或者有人显式 mbind 过。
AutoNUMA(kernel.numa_balancing=1)试图自动修这个问题:它周期性扫描每个任务的地址空间,把 PTE 改成「NUMA 提示缺页」(change_prot_numa()),下次访问触发 do_numa_page(),统计「本地/远程」访问次数;当远程访问占优时,migrate_misplaced_page() 把页迁到本地节点。相关计数器在 /proc/vmstat:
numa_hit 123456
numa_miss 789
numa_foreign 789
numa_interleave 0
numa_local 120000
numa_other 3456
numa_pte_updates 45678
numa_hint_faults 2345
numa_hint_faults_local 1200
numa_pages_migrated 567
numa_pte_updates 很高但 numa_pages_migrated 很低,说明扫描在跑但没有值得迁的页——通常是「访问模式本来就分散」,调 AutoNUMA 的参数没用,该改数据布局。
- 「
numactl --membind能限制所有分配」:只影响之后的分配,已有的页不会自动迁;要迁得用migrate_pages()或重启。 - 「
numa_hit高就说明没问题」:numa_hit是「在期望节点上分配成功」,期望节点本身可能就是远程的(MPOL_BIND指定的)。看numa_other更有意义。 - 「AutoNUMA 能解决一切」:它按页迁移,对共享只读的大页、频繁跨节点访问的页效果有限;而且迁移本身有开销。
- 「NUMA 距离是固定的」:SLIT 可能不准(虚拟机尤其),
numa=fake=2可以伪造节点做实验;距离矩阵可以用numactl --hardware核对。
3.6.6 HMP 与 EAS:算力不一样的核
手机 SoC 的 big.LITTLE、Intel 的 P/E 核,都是「同一台机器上核的算力不同」。这打破了一个隐含假设:迁到一个空闲核不一定更好,如果那个核是小核。
内核用 capacity_of(cpu) 表示算力(相对值,最大核为 1024),并在调度域上打 SD_ASYM_CPUCAPACITY。EAS(Energy Aware Scheduling) 在此基础上引入能耗模型(energy_model),在唤醒时选「满足性能前提下最省电」的核,而不是「最闲的核」。x86 的 hybrid 支持在 6.x 逐步完善,但主流仍是 Intel 的 intel_pstate + HWP 硬件调频。
这块在服务器上基本用不到,知道「有这回事、它改变了放置策略」即可。本章其余内容都以「同构多核」为前提。
3.6.7 调度域的字段与初始化:拓扑怎么变成一棵树
3.6.2 画了 SMT→MC→NUMA 的层级,但没说这些层级从哪来、什么时候重建、每个字段谁维护。这一节拆 sched_domain、sched_group 与 sched_domain_topology_level,并走一遍 build_sched_domains()。
三个结构体
| 结构体 | 作用 | 关键字段 |
|---|---|---|
sched_domain_topology_level | 描述“拓扑的一层”(SMT/MC/DIE/NUMA) | mask(该层的 cpumask 生成函数)、sd_flags(该层默认标志)、data(每 CPU 的 sd/group 指针数组) |
sched_domain | 一个 CPU 在某一层的“视野” | parent/child、groups、span、flags、min_interval/max_interval、balance_interval、nr_balance_failed、统计计数 |
sched_group | 域内的一组 CPU(比较单位) | next、group_weight、sgc(sched_group_capacity,含 capacity/cpumask)、asym_prefer_cpu |
拓扑层级表在 kernel/sched/topology.c:
/* kernel/sched/topology.c: default_topology[] —— 6.8 节选 */
static struct sched_domain_topology_level default_topology[] = {
#ifdef CONFIG_SCHED_SMT
{ cpu_smt_mask, cpu_smt_flags, SD_INIT_NAME(SMT) },
#endif
#ifdef CONFIG_SCHED_MC
{ cpu_coregroup_mask, cpu_core_flags, SD_INIT_NAME(MC) },
#endif
{ cpu_cpu_mask, SD_INIT_NAME(DIE) },
{ NULL, },
};
三层不是固定的:没有 CONFIG_SCHED_SMT 就没有 SMT 层;ARM 服务器常见 MC+DIE;NUMA 机器会在 DIE 之上再插一层 SD_NUMA 的域(由 sched_domains_numa_levels 与距离矩阵决定)。default_topology[] 只是“最小集”,实际域树由 build_sched_domains() 按 cpu_possible_mask 与 NUMA 距离扩展。
从位图到域树
sched_init_smp()
└─ sched_init_domains(cpu_online_mask)
└─ build_sched_domains(doms_cur, dattr_cur)
├─ 1. 为每个 SDTL 层级分配 per-CPU 的 sched_domain
│ sd = kzalloc_node(sizeof(*sd) + cpumask_size(), ...)
├─ 2. 自底向上连接 parent/child
│ child = build_sched_domain(tl, ..., child, cpu)
│ sd->parent = parent; parent->child = sd;
├─ 3. 建调度组
│ build_sched_groups(sd, cpu) → sched_group + sgc
├─ 4. 计算组的 capacity(含 freq/thermal 修正)
│ update_group_capacity(sd, cpu)
└─ 5. 挂到 rq->sd,并设置 root_domain
rq_attach_root(rq, rd); rq->sd = sd;
第 1 步的 kzalloc_node(..., cpu_to_node(cpu)) 值得注意:调度域对象本身也按 NUMA 节点分配,否则启动时会在远程节点上分配几千个域对象。
标志位决定“这一层值不值得搬”
| 标志 | 含义 | 谁设置 | 影响 |
|---|---|---|---|
SD_LOAD_BALANCE | 该层参与负载均衡 | sd_init() 默认置位 | 清除后 rebalance_domains() 跳过 |
SD_BALANCE_NEWIDLE | 新 idle 时向上层要任务 | cpu_smt_flags 等 | newidle_balance() |
SD_BALANCE_WAKE | 唤醒时用该层选核 | SD_WAKE_AFFINE 常伴 | select_task_rq_fair() |
SD_SHARE_CPUCAPACITY | 共享执行单元(SMT) | SMT 层 | smt 特殊处理,避免重复算容量 |
SD_SHARE_PKG_RESOURCES | 共享 LLC | MC 层 | select_idle_sibling() 的搜索范围 |
SD_NUMA | 跨节点 | NUMA 层 | 迁移可能丢本地内存,代价最高 |
SD_ASYM_CPUCAPACITY | 核算力不同 | HMP/EAS | misfit 任务迁移 |
SD_SERIALIZE | 该层均衡串行化 | NUMA 层 | 避免多 CPU 同时搬 |
SD_PREFER_SIBLING | 优先在同级组内摊平 | MC/DIE | update_sd_lb_stats() 的 prefer_sibling |
SD_SHARE_CPUCAPACITY 与 SD_SHARE_PKG_RESOURCES 的区别常被混淆:前者是“共享执行单元”(SMT 兄弟,算力不叠加),后者是“共享缓存”(同 LLC,搬任务会丢缓存但算力叠加)。capacity_of() 对 SMT 兄弟要打折,对 LLC 兄弟不打折。
什么时候重建
域树不是启动后一成不变。以下事件会触发 partition_sched_domains():
| 事件 | 入口 | 结果 |
|---|---|---|
| CPU 热插拔 | sched_cpu_activate() / sched_cpu_deactivate() | 重建 online/active 掩码对应的域 |
| cpuset 变更 | cpuset_attach() → rebuild_sched_domains() | 按 cpuset.cpus 生成新的 domain 集合 |
isolcpus 启动参数 | sched_init_smp() | 隔离 CPU 不参与默认放置与均衡(各版本对 cpu_active_mask 的处理不同,见 3.6.9) |
| NUMA 热插拔 | sched_update_numa()(6.x) | 更新距离矩阵与 NUMA 层 |
重建的代价不小:要停机器、分配/释放域对象、重新计算组容量,期间调度器处于“域不完整”状态。所以高频修改 cpuset.cpus 会让整机抖动——这是容器平台要避免“每个 Pod 一个 cpuset 子树并频繁改动”的原因之一。
一个 sched_domain 描述“一个 CPU 在某一层能看到哪些 CPU”,它的 span 是这些 CPU 的并集;sched_group 是 span 的一个划分,负载均衡以组为单位比较 group_load / group_capacity。域是树(parent/child),组是环(sg->next 链),两者不是同一维度:域决定“往哪一层找”,组决定“跟谁比”。
- “
/proc/sched_debug里的域层级就是硬件拓扑”:它是内核配置 + 启动参数 + cpuset 共同作用的结果。关了CONFIG_SCHED_MC,MC 层就消失;isolcpus会把 CPU 从 span 里摘掉。 - “域越多越好”:每一层都要周期扫描、比较组负载;层级过多会增加 tick 开销,尤其是在数百核机器上。
- “
partition_sched_domains()是免费的”:它要 stop_machine,频繁触发会让整机出现毫秒级停顿。 - “NUMA 层一定存在”:单节点机器没有
SD_NUMA;numa=fake=2可以伪造,但距离矩阵与真实硬件不同,实验结果不能直接外推。
3.6.8 负载均衡的判据与失败路径:搬谁、搬多少、为什么没搬成
load_balance() 不是“把多出来的任务平均分”,而是一个带预算、带拒绝条件的搜索过程。它的失败路径比成功路径更值得读:线上“明明有空核却没搬过去”多半落在某条拒绝条件上。
六个触发点
| 触发 | 入口 | 特点 |
|---|---|---|
| 周期 tick | rebalance_domains() | 按 sd->balance_interval 节流,自底向上 |
| 新 idle | newidle_balance() | CPU 变空时立刻偷任务,避免进 idle |
| 唤醒 | select_task_rq_fair() | 只选核,不搬已在队列里的任务 |
| fork/exec | select_task_rq(p, ..., WF_FORK/WF_EXEC) | 把新任务放到合适核 |
| active balance | active_load_balance_cpu_stop() | 用 stop 类强制迁移正在跑的任务 |
| nohz idle | nohz_balancer_kick() | 空闲 CPU 代劳,减少 wakeup IPI |
load_balance 的七步
load_balance(rq, this_cpu, sd, idle, continue_balancing)
├─ 1. should_we_balance() 这一轮该不该由本 CPU 做?
├─ 2. find_busiest_group() 域内哪个组的 load/capacity 最不平衡?
├─ 3. find_busiest_queue() 该组里哪个 rq 最忙?
├─ 4. detach_tasks() 从忙队列摘任务(受 sched_nr_migrate 限制)
├─ 5. attach_tasks() 挂到本地队列
├─ 6. active_balance 搬不动且失败次数超阈值 → stop_one_cpu 强制搬
└─ 7. 更新 sd 统计与 balance_interval
should_we_balance() 的规则:如果本组有空闲 CPU,只有第一个空闲 CPU 可以做均衡;如果没有空闲 CPU,只有组的 group_balance_cpu(通常是第一个 CPU)可以做。这避免了 8 个核同时扑向同一个忙核。
判据:group_type 决定搬什么
update_sd_lb_stats() 给每个组算出一个 group_type,find_busiest_group() 按类型决定迁移策略:
group_type | 判定 | 迁移策略 |
|---|---|---|
group_has_spare | 组内还有空闲容量 | migrate_util:搬“利用率”而不是具体任务 |
group_fully_busy | 没有空闲容量,但不过载 | migrate_task:搬一个任务 |
group_misfit_task | 任务算力需求超过所在核 | migrate_misfit:搬到更强核 |
group_asym_packing | 大小核打包 | 按 asym_prefer_cpu 搬 |
group_imbalanced | 组间亲和性不均衡 | 有限迁移 |
group_overloaded | 负载超过容量 | migrate_task,优先搬权重大的 |
这张表解释了一个常见疑问:为什么空闲核很多,任务却没搬?如果忙组被判成 group_has_spare,均衡器会尝试“搬利用率”,而 migrate_util 只在不破坏缓存亲和时生效;如果任务的 task_hot() 为真,它会被留下。
detach_tasks:带拒绝条件的循环
/* kernel/sched/fair.c: detach_tasks() —— 6.8 节选,已简化 */
static int detach_tasks(struct lb_env *env)
{
struct list_head *tasks = &env->tasks;
unsigned long load;
int detached = 0;
if (env->imbalance <= 0)
return 0;
while (!list_empty(tasks)) {
struct task_struct *p = list_first_entry(tasks, struct task_struct, se.group_node);
/* 预算用完或已经搬够,提前退出 */
if (env->loop > env->loop_max ||
env->loop >= env->loop_break)
break;
if (!can_migrate_task(p, env))
goto next; /* 亲和性/缓存热/正在跑:拒绝 */
load = task_h_load(p);
if (load < env->imbalance / 2) {
/* 搬它不解决问题,跳过 */
goto next;
}
env->imbalance -= load;
list_move(&p->se.group_node, &env->tasks);
detached++;
next:
env->loop++;
}
return detached;
}
can_migrate_task() 的拒绝条件:
| 条件 | 检查 | 为什么拒绝 |
|---|---|---|
| 亲和性 | !cpumask_test_cpu(env->dst_cpu, p->cpus_ptr) | 目标核不在允许集合 |
| 正在运行 | task_running(env->src_rq, p) | 不能直接搬走正在用的栈/寄存器 |
| 缓存热 | task_hot(p, env) | 最近在本核跑过,搬走会丢缓存 |
| 迁移禁用 | p->migration_disabled | migrate_disable() 区段 |
| 刚迁移过 | p->se.nr_migrations + sysctl_sched_migration_cost | 迁移太频繁不划算 |
task_hot() 的时间阈值是 sysctl_sched_migration_cost(默认 500 µs)。注意它是“上次运行时间”与“现在”的差,不是“缓存命中率”;SSD 上的短任务经常被误判为热。
失败路径:为什么“没搬”
| 返回/计数 | 含义 | 下一步 |
|---|---|---|
LBF_ALL_PINNED | 忙队列里所有任务都不能搬到目标核 | 放弃本层,向上层域重试 |
LBF_NEED_BREAK | 一次搬太多,需要放锁让出 | 重新进入 load_balance() |
LBF_DST_PINNED | 目标任务被钉在别的核 | 换一个任务 |
LBF_SOME_PINNED | 部分任务被钉住 | 继续,但记 lb_imbalance |
sd->nr_balance_failed++ | 本轮没搬成 | 超过 cache_nice_tries 触发 active balance |
nr_balance_failed 是判断“均衡是否有效”的关键计数。它连续增长说明:忙队列的任务都搬不动(亲和性/热),或者目标核没有容量。/proc/sched_debug 的 lb_failed/lb_balanced 能直接看到。
active balance:最后的手段
当 nr_balance_failed > sd->cache_nice_tries + 2 且本地是 idle 时,均衡器会:
/* kernel/sched/fair.c: load_balance() 的 active balance 分支 —— 6.8 节选 */
if (active_balance) {
stop_one_cpu_nowait(cpu_of(busiest), active_load_balance_cpu_stop,
busiest, &busiest->active_balance_work);
}
active_load_balance_cpu_stop() 在 stop 类里运行——它是唯一能“暂停一个正在跑的 CPU,把它的当前任务换走”的机制。代价是目标 CPU 会停一小段,所以只在“不搬就真的不均衡”时使用。
一台 8 核机器,CPU 0 有 6 个可运行任务,其他 7 核各 1 个。按 load/capacity,CPU 0 是 600%,其余 100%,看起来应该搬走 5 个。实际第一轮摘 3 个、搬走 2 个,原因有三:
sched_nr_migrate=32是上限,不是目标;detach_tasks()以env->imbalance为预算,而每个候选还要过can_migrate_task()。task_hot()把最近在 CPU 0 跑过的任务判为热,can_migrate_task()拒绝。should_we_balance()只允许组内第一个空闲 CPU 发起均衡,其余 CPU 不会同时来抢。
所以“均衡”是分多个 tick 收敛的过程,不是一次到位。看到 mpstat 在几秒内从 600%/100% 收敛到 200%/200%,是正常行为。
- 看
sched_debug的lb_failed与lb_balanced:失败多、成功少 → 拒绝条件在起作用。 - 看
nr_balance_failed:持续增长 → active balance 阈值;不增长 → 根本没找到不均衡组。 - 看任务的
Cpus_allowed_list:全被钉住 →LBF_ALL_PINNED。 - 看
se.nr_migrations:很高但负载仍不均 → 迁移在抖,不是没搬。 - 看
task_hot的时间窗:短任务在sched_migration_cost内反复唤醒,会被持续判热。
3.6.9 亲和、隔离与 cgroup:三种“把任务关起来”的方式
taskset、cpuset、isolcpus 经常被混用,但它们的作用点不同:一个改任务的掩码,一个改组的允许集合,一个改整机的默认放置。这一节把三者的语义、失败条件与组合方式对齐。
三种手段的作用点
| 手段 | 作用对象 | 生效方式 | 能否被绕过 | 典型场景 |
|---|---|---|---|---|
sched_setaffinity() / taskset | 单个任务/线程 | 写 task_struct->cpus_mask | 子进程可继承,也可被再次修改 | 临时实验、线程绑核 |
cgroup v2 cpuset | 一组任务 | 组的 cpuset.cpus/cpuset.mems | 组内任务只能在该集合内 | 容器、systemd slice |
isolcpus= | 整机启动 | 从默认放置/均衡中排除 | 显式亲和仍可使用 | 实时任务、DPDK |
关键区别:taskset 是任务级约束,cpuset 是组级约束,isolcpus 是机器级默认值。三者叠加时取交集:任务的最终可运行集合 = cpus_mask ∩ cpuset.cpus.effective ∩ cpu_online_mask。
cgroup v2 cpuset 的文件
| 文件 | 读写 | 含义 | 常见错误 |
|---|---|---|---|
cpuset.cpus | rw | 本组允许的 CPU 列表 | 格式错 EINVAL;写超集会被裁成与父组的交集 |
cpuset.mems | rw | 本组允许的 NUMA 节点 | 节点不存在 EINVAL;为空时任务无法分配内存;空掩码放任务可能 ENOSPC |
cpuset.cpus.effective | ro | 与父组取交集后的实际值 | 只读,写入 EPERM |
cpuset.mems.effective | ro | 同上 | 同上 |
cpuset.cpus.exclusive | rw | 为本组保留的 CPU(6.5+) | 与兄弟重叠时被拒绝 |
cpuset.cpus.partition | rw | member/root/isolated | isolated 条件不满足时 EBUSY |
cpuset.cpus.partition=isolated 是“真隔离”:它不仅限制本组,还要求兄弟组没有任务,从而保证本组 CPU 不会被其他组的负载均衡拉走。写入失败返回 EBUSY 的典型原因:
- 兄弟 cgroup 里还有任务;
cpuset.cpus与兄弟的cpuset.cpus.exclusive重叠;- 本组的
cpuset.cpus.exclusive为空,或父组的cpuset.cpus.effective没有覆盖它。
# 典型顺序:先建父子关系,再设 exclusive,最后切 partition
mkdir -p /sys/fs/cgroup/rt
echo "+cpuset" > /sys/fs/cgroup/cgroup.subtree_control
echo "2-3" > /sys/fs/cgroup/rt/cpuset.cpus.exclusive
echo "2-3" > /sys/fs/cgroup/rt/cpuset.cpus
echo "0" > /sys/fs/cgroup/rt/cpuset.mems
echo isolated > /sys/fs/cgroup/rt/cpuset.cpus.partition
isolcpus 与它的搭档
单独 isolcpus=2,3 只做到“调度器默认不往这放任务”,还有三件事要一起做:
| 参数 | 解决什么 | 不加会怎样 |
|---|---|---|
isolcpus=2,3 | 默认放置与负载均衡 | 任务会被均衡到隔离核 |
nohz_full=2,3 | 停掉周期 tick(需 CONFIG_NO_HZ_FULL) | 隔离核仍有 100–1000 Hz 的 tick 干扰 |
rcu_nocbs=2,3 | 把 RCU 回调卸载到 rcuoc/N | 隔离核上仍有 RCU 软中断 |
irqaffinity=0-1 | 把中断移出隔离核 | 网卡/定时器中断仍打在隔离核上 |
skew_tick=1 | 错开各核 tick 相位 | 多核 tick 同时到期,产生周期性抖动 |
完整写法:
GRUB_CMDLINE_LINUX="isolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3 irqaffinity=0-1 skew_tick=1"
启动后用这些命令核对:
cat /sys/devices/system/cpu/isolated # 2-3
cat /sys/devices/system/cpu/nohz_full # 2-3
cat /proc/cmdline
cat /proc/interrupts | awk '{print $NF}' | sort | uniq -c # 中断是否还在隔离核
isolcpus 在 6.x 已被标记为 deprecated,官方推荐用 cpuset partition 替代。但在实时圈子里它仍是标配,因为它在启动时就生效,不依赖 cgroup 挂载与用户态配置。
systemd 与容器
systemd 把 cpuset 包装成两个属性:
[Service]
CPUAffinity=2 3 # 等价于 sched_setaffinity
AllowedCPUs=2 3 # 等价于 cpuset.cpus(需要 cgroup v2)
slice 的写法同理,AllowedCPUs 会落到 cpuset.cpus:
systemctl set-property myservice.service AllowedCPUs=2-3
systemctl show myservice.service -p AllowedCPUs
cat /sys/fs/cgroup/system.slice/myservice.service/cpuset.cpus.effective
注意 systemctl set-property 写入的是 drop-in,重启后仍生效;临时实验用 systemd-run --scope -p AllowedCPUs=2-3 ./workload 更干净。
容器运行时通常同时用两个维度:
# 允许 2-3 号 CPU(亲和),且最多用 1.5 个核的算力(带宽)
docker run --cpuset-cpus=2,3 --cpus=1.5 ...
--cpuset-cpus 改的是 cpuset.cpus,--cpus 改的是 cpu.max。前者决定“能在哪些核上跑”,后者决定“总共能跑多久”。两者可以矛盾:允许 8 个核但配额只有 0.5 个核,nproc 显示 8,实际算力只有半个核。
- “
isolcpus之后普通进程绝对上不去”:显式sched_setaffinity()仍可把任务放上去;isolcpus只影响默认放置与负载均衡。 - “cpuset 只限制 CPU”:
cpuset.mems同样重要。只设 CPU 不设内存,任务可能在远程节点分配,NUMA 延迟翻倍。 - “写了
cpuset.cpus就立刻生效”:组内任务要等下一次调度/迁移;正在跑的任务可能还在旧核上,直到被抢占或主动让出。 - “
cpuset.cpus.partition=isolated一定能成功”:兄弟组有任务、CPU 重叠、父组不是 root,都会EBUSY;它不是“写一次就永久有效”的开关。 - “容器里
nproc就是可用核数”:nproc读的是亲和掩码;cpu.max是带宽。判断真实算力要看cpu.stat的usage_usec与nr_throttled。
3.6.10 NUMA 深入:距离、策略与自动迁移
NUMA 的核心不是“内存分节点”,而是访问代价不对称。这一节从 SLIT/SRAT 讲到 mbind()、AutoNUMA 与 numastat,最后给一个能验证“页到底落在哪”的实验。
距离矩阵从哪来
x86 上,ACPI 的 SRAT 描述“CPU/内存属于哪个节点”,SLIT 描述“节点间的距离”。内核解析后存成 node_distance(a, b),并在 sched_domains_numa_distance[] 里保留一份用于建域。
$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3
node 0 size: 32768 MB
node 0 free: 1024 MB
node 1 cpus: 4 5 6 7
node 1 size: 32768 MB
node 1 free: 512 MB
node distances:
node 0 1
0: 10 21
1: 21 10
距离 10 是“本地”的基准值,21 表示远程访问约 2.1 倍代价。这个值不是带宽比,也不是固定常数:虚拟机、CXL 内存分层、numa=fake= 都会改变它。
节点与 zone 的关系
每个节点一个 struct pglist_data(NODE_DATA(nid)):
| 字段 | 含义 |
|---|---|
node_id | 节点号 |
node_zones[] | 该节点的 zone(DMA/DMA32/NORMAL/MOVABLE/DEVICE) |
node_zonelists[] | 分配时的 fallback 顺序(本地优先,再按距离) |
node_start_pfn / node_present_pages / node_spanned_pages | 物理页范围 |
kswapd | 该节点的回收线程 |
node_size_lock / totalreserve_pages | 热插拔与保留页 |
cpu_to_node(cpu) 给 CPU 所属节点,numa_node_id() 给当前 CPU 的节点。ZONE_NORMAL 的 zone_pgdat 指回节点,zone_to_nid() 是两者的桥。
mempolicy:一次分配的“意图”
/* include/linux/mempolicy.h —— 6.8 节选 */
struct mempolicy {
atomic_t refcnt;
unsigned short mode; /* MPOL_* */
unsigned short flags;
union {
nodemask_t nodes; /* BIND/INTERLEAVE 的节点集 */
int preferred_node; /* PREFERRED 的单节点 */
} v;
nodemask_t cpuset_mems_allowed; /* 创建时的 cpuset 快照 */
};
策略是继承的:fork() 时 mpol_dup() 复制父进程的策略;exec() 保留;子进程可以用 set_mempolicy() 改自己的。MPOL_DEFAULT 表示“跟随当前 CPU 的 first-touch”,不是“无策略”。
| 策略 | 含义 | 失败/边界 |
|---|---|---|
MPOL_DEFAULT | 本地节点优先,first-touch | 无 |
MPOL_PREFERRED | 优先指定节点,不足时回退 | 节点为空时回退到本地 |
MPOL_BIND | 只从指定节点分配 | 节点集为空 EINVAL;内存不足 ENOMEM,不回退 |
MPOL_INTERLEAVE | 按页轮转 | 节点集为空 EINVAL |
MPOL_LOCAL | 强制本地(5.15+) | 需要 CONFIG_NUMA |
MPOL_BIND 最容易被误用:它不回退。绑定到 node 1 后,如果 node 1 内存耗尽,分配直接失败(或触发回收),不会偷偷用 node 0。这是“确定性延迟”的代价。
mbind 的 flags
mbind(addr, len, mode, nodemask, maxnode, flags) 只对尚未分配的页设置策略;对已分配的页,要 MPOL_MF_MOVE 才迁移:
| flag | 作用 | 代价 |
|---|---|---|
MPOL_MF_STRICT | 无法满足策略时返回 EIO | 只检查,不迁移 |
MPOL_MF_MOVE | 迁移本进程的页到目标节点 | 需要 CAP_SYS_NICE;有拷贝开销 |
MPOL_MF_MOVE_ALL | 迁移所有进程映射的页 | 更强权限,影响他人 |
MPOL_MF_LAZY | 延迟迁移(配合 MPOL_F_MOF) | 仅特定内核配置 |
set_mempolicy() 影响“之后的分配”,mbind() 影响“指定区间”,migrate_pages() 影响“整个进程的页”,move_pages() 既可查询也可逐页迁移。四者的关系:
set_mempolicy → 改 mempolicy(未来分配)
mbind → 改 VMA 的 mempolicy(区间未来分配 + 可选迁移)
move_pages → 查询/迁移指定页(一次性)
migrate_pages → 迁移进程的全部页(按节点过滤)
看单个进程的 VMA 分布,numa_maps 比 numastat 更细:
$ cat /proc/1234/numa_maps | head -5
55d0c0c00000 default anon=4096 dirty=4096 active=4094 N0=2048 N1=2048
55d0c0e00000 bind:1 anon=8192 dirty=8192 active=8192 N1=8192
7f8a2c000000 interleave:0-1 anon=16384 N0=8192 N1=8192
第一列是 VMA 起始地址;default/bind:1/interleave:0-1 是该 VMA 的策略;anon= 是匿名页数;N0=/N1= 是各节点页数。这个文件是验证 mbind() 是否真的改了 VMA 策略的最快方式,比 numastat 的汇总更直接。
AutoNUMA:用缺页统计驱动迁移
kernel.numa_balancing=1 时,内核周期性扫描任务地址空间:
task_numa_work()
├─ change_prot_numa() 把 PTE 改成 PROT_NONE + NUMA 提示
├─ 下次访问触发 do_numa_page()
│ └─ task_numa_fault() 记录本地/远程访问次数
└─ 远程占优时
└─ migrate_misplaced_page() → migrate_pages()
相关计数器在 /proc/vmstat:
| 计数器 | 含义 | 异常解读 |
|---|---|---|
numa_hit | 在期望节点分配成功 | 期望节点可能是远程(BIND),不能单独看好坏 |
numa_miss | 期望节点失败,回退到别处 | 高 = BIND 节点内存不足 |
numa_foreign | 本节点被别的节点“借走” | 与 numa_miss 配对看 |
numa_pte_updates | 被改成 NUMA 提示缺页的 PTE 数 | 高但迁移低 = 扫描无效 |
numa_hint_faults | 提示缺页次数 | 高 = 访问模式分散 |
numa_pages_migrated | 实际迁移页数 | 与 numa_hint_faults 比值看收益 |
numa_pte_updates 很高、numa_pages_migrated 很低,通常说明“页在两地被频繁访问,迁到哪边都会再迁回来”。这时调 numa_balancing_scan_period_* 没用,应该改数据布局(每线程本地分配、复制而不是共享)。
一个能验证落点的实验
numactl/libnuma 在容器里不一定装得上。下面这个程序只用原始系统调用:mbind 设策略、move_pages 查询与迁移。
/* numa_touch.c — 用原始系统调用验证 first-touch、mbind 与 move_pages
* 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -o numa_touch numa_touch.c
* 运行: ./numa_touch 1 64 # 绑定 node 1,分配 64 MiB
*/
#define _GNU_SOURCE
#include <errno.h>
#include <linux/mempolicy.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <unistd.h>
static int max_node(void)
{
char path[128];
int n = -1;
for (int i = 0; i < 256; i++) {
snprintf(path, sizeof path, "/sys/devices/system/node/node%d", i);
if (access(path, F_OK) == 0)
n = i;
}
return n;
}
static int page_node(void *addr)
{
void *pages[1] = { addr };
int status = -1;
long rc = syscall(SYS_move_pages, 0, 1, pages, NULL, &status, 0);
if (rc < 0)
return -errno;
return status;
}
static void touch(void *p, size_t len)
{
volatile char *c = p;
for (size_t i = 0; i < len; i += 4096)
c[i] = (char)i;
}
int main(int argc, char *argv[])
{
int node = argc > 1 ? atoi(argv[1]) : 0;
size_t mb = argc > 2 ? (size_t)atoi(argv[2]) : 64;
size_t len = mb << 20;
int max = max_node();
unsigned long nodemask;
void *p;
long rc;
if (max < 0) {
fprintf(stderr, "no /sys/devices/system/node/node* — NUMA disabled?\n");
return 1;
}
if (node > max) {
fprintf(stderr, "node %d out of range (max %d)\n", node, max);
return 1;
}
p = mmap(NULL, len, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) {
perror("mmap");
return 1;
}
/* 1. 设置内存策略:只从指定节点分配(mbind 作用于尚未分配的页) */
nodemask = 1UL << node;
rc = syscall(SYS_mbind, p, len, MPOL_BIND, &nodemask,
8 * sizeof(nodemask), MPOL_MF_STRICT);
if (rc == -1) {
fprintf(stderr, "mbind: %s\n", strerror(errno));
munmap(p, len);
return 1;
}
/* 2. 触碰每一页,触发缺页分配;first-touch 发生在这一步 */
touch(p, len);
printf("mbind node=%d first_page=%d last_page=%d\n",
node, page_node(p), page_node((char *)p + len - 4096));
/* 3. 把第一页迁到 node 0,验证 move_pages 的迁移与状态回写 */
if (node != 0) {
void *pages[1] = { p };
int dest = 0, status = -1;
rc = syscall(SYS_move_pages, 0, 1, pages, &dest, &status,
MPOL_MF_MOVE);
if (rc == -1)
fprintf(stderr, "move_pages: %s\n", strerror(errno));
else
printf("after migrate: rc=%ld status=%d first_page=%d\n",
rc, status, page_node(p));
}
/* 4. 解除绑定后新分配才可能回到默认策略 */
munmap(p, len);
return 0;
}
预期输出(双节点):
$ ./numa_touch 1 64
mbind node=1 first_page=1 last_page=1
after migrate: rc=0 status=0 first_page=0
rc=0 表示 move_pages 没有失败页;status=0 是该页迁移后的节点。如果 mbind 返回 EPERM,检查是否在容器里被 seccomp 拦了 mbind/move_pages;如果 first_page=-14(EFAULT),检查地址是否真的已映射。
- 先确认是不是 NUMA 问题:
numastat -p PID看内存分布;/proc/PID/numa_maps看每个 VMA 的节点分布。 - 再看 CPU 与内存是否同节点:
numactl --show、/proc/PID/status的Mems_allowed_list与Cpus_allowed_list。 - 看策略是否被继承/覆盖:
get_mempolicy()读当前策略;子进程可能继承了父进程的BIND。 - 看 AutoNUMA 是否在工作:
kernel.numa_balancing、/proc/vmstat的numa_*。 - 最后改布局:每线程本地分配、
mbind到本地节点、或复制数据避免跨节点共享。
3.6.11 HMP 与 EAS:当核的算力不一样
前面所有内容都隐含一个假设:核与核等价,迁移到哪个核都一样。big.LITTLE、Intel P/E 核打破了这个假设。这一节讲内核怎么表达“算力不对称”,以及 EAS 在什么条件下才接管放置。
算力与容量
capacity_of(cpu) 给出 0–1024 的算力估计,来源是 arch_scale_cpu_capacity()(DT 的 capacity-dmips-mhz 或 x86 的默认值)叠加频率与热压修正。调度域上的 SD_ASYM_CPUCAPACITY 标记“这个域里算力不同”。
一旦有这个标志:
- 唤醒选核不能只看“空闲”,还要看“够不够强”;
- 负载均衡会把
group_misfit_task作为一类单独处理; misfit任务(util 超过当前核容量)会被迁移到更强的核。
EAS 的接管条件
EAS(Energy Aware Scheduling)不是无条件生效。sched_energy_enabled() 为真需要:
| 条件 | 说明 |
|---|---|
CONFIG_ENERGY_MODEL | 编译了能耗模型 |
CONFIG_SCHED_DEBUG 下的 sched_energy_present | 根域已建立 perf domain |
| 非 overutilized | rd->overutilized == 0;一旦有核过载,EAS 退出,回到常规均衡 |
| 有 perf domain | 每个 root_domain 至少一个 em_perf_domain |
| 算力不对称 | 同构服务器上通常没有 EAS |
满足条件时,select_task_rq_fair() 先调 find_energy_efficient_cpu():在“满足性能”的候选核里选能耗最低的。它返回 −1 表示“没有合适候选”,回落到 select_idle_sibling()。
EAS 的“性能约束”由 uclamp 表达:
| 接口 | 作用 | 取值 |
|---|---|---|
sched_setattr() 的 sched_util_min | 任务的最小性能需求 | 0–1024 |
sched_setattr() 的 sched_util_max | 任务的最大性能上限 | 0–1024 |
cgroup v2 cpu.uclamp.min / cpu.uclamp.max | 组的 clamp | 百分比/max |
uclamp_min 是“至少给我这么多算力”,uclamp_max 是“最多给我这么多”(用于限频/省电)。两者都参与选核与频率选择,但不改变权重与份额。
x86 的 hybrid 与 ITMT
Intel 的 P/E 核(Alder Lake 及之后)在 Linux 上通过 ITMT(Intel Turbo Boost Max Technology 3.0)与 SD_ASYM_PACKING 暴露优先级:
asym_prefer_cpu标记组内“首选 CPU”;SD_ASYM_PACKING让均衡器把任务往高优先级核上“打包”,而不是平均摊开;intel_pstate的 HWP 负责频率,EAS 在 x86 上通常不启用(没有 energy model)。
所以“x86 hybrid 上 EAS 生效”是误解:x86 用的是 ITMT + SD_ASYM_PACKING + intel_pstate,与 ARM 的 EAS 是两套机制。
- “
capacity_of()是常数”:它随频率、热压、capacity-dmips-mhz变;同一台机器降频后容量会掉。 - “EAS 在所有 ARM 上生效”:需要 energy model、非 overutilized、算力不对称;服务器 ARM 上常常不满足。
- “
uclamp会改变 nice 份额”:它只影响选核与调频的“性能需求”,不改变 CFS 权重;份额仍由load.weight决定。 - “misfit 任务一定会被搬走”:还要看亲和性、迁移代价与目标核容量;
can_migrate_task()仍会拒绝。
3.6.12 两个新实验:验证 NUMA 落点与 cpuset 隔离
3.6.12/3.6.15 已有拓扑模块与绑核实验。这里补两个更贴近线上问题的实验:一个验证“页到底落在哪个节点”,一个验证“cpuset 的 isolated 到底隔离了什么”。
实验一:NUMA 落点(承接 3.6.10)
代码见 3.6.10 的 numa_touch.c。按下面的矩阵跑,能区分“策略没生效”与“策略生效但被 first-touch 覆盖”:
# 1. 不设策略,直接触碰:页落在当前 CPU 的本地节点
numactl --cpunodebind=1 ./numa_touch 1 64 # 先看 CPU 与内存都在 node 1
# 2. 设 BIND 到 node 0,但 CPU 在 node 1:应该全部落在 node 0
numactl --cpunodebind=1 ./numa_touch 0 64
# 3. 用 move_pages 迁移后再查
./numa_touch 1 64
判读:
mbind node=1 first_page=1 last_page=1:策略与落点一致。mbind node=1 first_page=0:mbind没生效(被 cpuset 的mems限制,或页已分配)。move_pages: Cannot allocate memory:目标节点没有足够内存,或RLIMIT_MEMLOCK限制。
实验二:cpuset 的 isolated 到底做了什么
#!/bin/bash
# cpuset_probe.sh — 建一个 isolated 分区,验证掩码、迁移与失败条件
set -euo pipefail
CG=/sys/fs/cgroup/cpuset-probe
CPU=3
if [ ! -f /sys/fs/cgroup/cgroup.controllers ]; then
echo "需要 cgroup v2" >&2
exit 1
fi
if ! grep -qw cpuset /sys/fs/cgroup/cgroup.controllers; then
echo "cpuset 控制器不可用" >&2
exit 1
fi
mkdir -p "$CG"
echo "+cpuset" > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true
echo "$CPU" > "$CG/cpuset.cpus"
echo "0" > "$CG/cpuset.mems"
# 先尝试 isolated;失败时打印内核给的原因(通常是 EBUSY)
if ! echo isolated > "$CG/cpuset.cpus.partition" 2>/tmp/cpuset.err; then
echo "partition=isolated 失败:$(cat /tmp/cpuset.err)"
echo "当前值:$(cat "$CG/cpuset.cpus.partition")"
else
echo "partition=$(cat "$CG/cpuset.cpus.partition")"
fi
# 把当前 shell 放进组,读回掩码
echo $$ > "$CG/cgroup.procs"
taskset -pc $$
grep -E 'Cpus_allowed_list|Mems_allowed_list' /proc/self/status
# 统计 1 秒内的迁移次数(需要 perf)
perf stat -e cpu-migrations -a sleep 1 2>&1 | tail -3 || true
# 清理:先把自己移回根组,再删子组
echo $$ > /sys/fs/cgroup/cgroup.procs
rmdir "$CG"
预期:
partition=isolated
pid 12345's current affinity list: 3
Cpus_allowed_list: 3
Mems_allowed_list: 0
如果 partition=isolated 失败,先看兄弟组:cat /sys/fs/cgroup/cpuset-probe/../cgroup.procs 是否为空;再看 cpuset.cpus.exclusive 是否与兄弟重叠。isolated 不是“写一个文件”就完成的,它要求整棵子树的 CPU 分配是自洽的。
观测:迁移与均衡
# 1. 谁在迁移、从哪到哪
sudo bpftrace -e '
tracepoint:sched:sched_migrate_task
{
@[comm, args->orig_cpu, args->dest_cpu] = count();
}
interval:s:10 { exit(); }'
# 2. 每个 CPU 的均衡失败/成功计数
grep -E "lb_(failed|balanced|gained)|nr_balance_failed" /proc/sched_debug
# 3. perf 侧:迁移次数与上下文切换
perf stat -e cpu-migrations,context-switches -a sleep 5
perf stat 的 cpu-migrations 是全系统计数,不能定位到任务;要定位用 perf stat -p PID 或 bpftrace。/proc/sched_debug 的 lb_failed 高而 lb_gained 低,说明均衡在“尝试但搬不动”,回到 3.6.8 的拒绝条件表。
perf sched 也能从时间轴看迁移:
$ sudo perf sched record -a -- sleep 3
$ sudo perf sched map
* . . . . . . . 1234.567890 secs bash
* . . . . . . . 1234.567901 secs bash
. . * . . . . . 1234.568013 secs kworker/u8:2
map 每行是一个时刻,* 表示该 CPU 上有任务,行末是当时运行的任务名;同一任务在不同 CPU 列之间“跳”,就是一次迁移。它不告诉你迁移原因(唤醒选核、负载均衡、主动亲和),要把时间戳对齐 sched_migrate_task 的 bpftrace 输出才能定位。
3.6.13 用 /proc/sched_debug 读调度域与均衡
/proc/sched_debug 是唯一能在用户态看到“域树 + 组 + 每 CPU 队列”的接口(需要 CONFIG_SCHED_DEBUG)。它输出很长,但真正要读的只有四段:CPU 头、域、组、队列。
域段
cpu#0, 2899.998 MHz
.nr_running : 3
.nr_switches : 123456
.clock : 987654321.123
.clock_task : 987000000.000
...
domain0: span=0-7 level=SMT
.min_interval : 2
.max_interval : 4
.balance_interval : 2
.nr_balance_failed : 0
.lb_count[0] : 123
.lb_failed[0] : 4
.lb_balanced[0] : 100
.lb_gained[0] : 19
groups: 0:{0,8} 1:{1,9} ...
domain1: span=0-15 level=MC
...
domain2: span=0-15 level=NUMA
...
读法:
| 字段 | 含义 | 异常信号 |
|---|---|---|
span | 该域覆盖的 CPU | 与 lscpu 拓扑不一致 → cpuset/isolcpus 改过 |
level | SMT/MC/DIE/NUMA | 缺层 → 内核配置或硬件不支持 |
min_interval/max_interval | 均衡间隔范围(ms) | 过大 → 收敛慢;过小 → 抖动 |
balance_interval | 当前间隔 | 随 nr_balance_failed 退避增长 |
nr_balance_failed | 连续失败次数 | 持续增长 → 拒绝条件或 active balance |
lb_count/lb_failed/lb_gained | 尝试/失败/成功迁移次数 | failed/count 高 → 搬不动 |
groups | 组划分与 CPU 位图 | 组数与 span 不符 → 拓扑重建中 |
队列段
cfs_rq[0]:/
.nr_running : 3
.load : 3072
.min_vruntime : 12345.678901
.exec_clock : 9876.543210
se.sum_exec_runtime : 4567.890123
se.vruntime : 12345.678901
se.nr_migrations : 7
cfs_rq[0]:/ 里的 / 是 cgroup 路径:根组。组调度打开时,会看到 cfs_rq[0]:/a、cfs_rq[0]:/b,每个组一段。不同版本打印的字段不完全相同:6.6+ 的 se 有 deadline/slice,但 sched_debug 是否打印它们取决于版本,不要把它当成稳定的 ABI。
标志位对照
/proc/sched_debug 不直接打印标志位名字,读源码或 drgn 时用下面这张表(include/linux/sched/sd_flags.h,6.8):
| 位 | 名称 | 含义 |
|---|---|---|
0x0001 | SD_LOAD_BALANCE | 参与均衡 |
0x0002 | SD_BALANCE_NEWIDLE | 新 idle 时均衡 |
0x0004 | SD_BALANCE_EXEC | exec 时选核 |
0x0008 | SD_BALANCE_FORK | fork 时选核 |
0x0010 | SD_BALANCE_WAKE | 唤醒时选核 |
0x0020 | SD_WAKE_AFFINE | 唤醒亲和 |
0x0040 | SD_ASYM_CPUCAPACITY | 算力不对称 |
0x0080 | SD_SHARE_CPUCAPACITY | 共享执行单元(SMT) |
0x0100 | SD_CLUSTER | 核簇(若内核支持) |
0x0200 | SD_SHARE_PKG_RESOURCES | 共享 LLC |
0x0400 | SD_SERIALIZE | 串行化均衡 |
0x0800 | SD_ASYM_PACKING | 大小核打包 |
0x1000 | SD_PREFER_SIBLING | 优先同级摊平 |
0x2000 | SD_OVERLAP | 域可重叠 |
0x4000 | SD_NUMA | 跨节点 |
想知道某个域实际带了哪些标志,最可靠的方式是读内核源码里的 cpu_smt_flags()/cpu_core_flags(),或在 drgn 里读 sd->flags 再按上表解码——sched_debug 只给名字与跨度,不给十六进制值。
和 schedstat 的分工
/proc/schedstat 给的是每 CPU 的累计计数(版本号在第一行),适合做趋势;sched_debug 给的是当前快照,适合看“现在卡在哪”。两者都不如 perf sched 精确到事件,但都不需要额外工具。
head -1 /proc/schedstat # version 15
grep -E "^cpu0" /proc/schedstat # 该 CPU 的 9 个字段
schedstat 的字段含义随版本变(6.8 的 version 15 有 9 列),不要硬编码列号;用 Documentation/scheduler/sched-stats.rst 对照。
- 先看 CPU 头的
nr_running与clock/clock_task差:判断是否过载、是否被中断占满。 - 再看每个域的
lb_failed/lb_count:失败率高说明“搬不动”,回到 3.6.8 的拒绝条件。 - 看
nr_balance_failed:持续增长会触发 active balance,代价是 stop_machine。 - 最后看组的 CPU 位图:组划分与预期不符,说明 cpuset/热插拔改过拓扑,先重建再调参。
- “文件不存在就是内核坏了”:多数发行版默认关
CONFIG_SCHED_DEBUG;/proc/sched_debug不存在是正常配置。 - “
nr_running包含组内所有任务”:根队列的nr_running只算本层;组内任务数看h_nr_running或子组。 - “
lb_gained高就是均衡好”:它只统计“成功搬走的任务数”,不统计搬得是否值得;频繁搬可能带来缓存失效,要看nr_migrations与业务延迟一起判断。
3.6.14 陷阱清单:多核与 NUMA 篇
| # | 现象 | 原因 | 判断/修复 |
|---|---|---|---|
| 1 | lscpu 显示 8 核,nproc 只有 4 | 容器/任务的亲和掩码被限制 | 看 Cpus_allowed_list;taskset -pc $$ |
| 2 | 两个线程绑 0/1,算力只有 1.2 倍 | 0/1 是 SMT 兄弟,共享执行单元 | cat /sys/devices/system/cpu/cpu0/topology/thread_siblings_list |
| 3 | NUMA 机器上 numastat 显示远程内存占一半 | first-touch 落在远程,或继承了父进程策略 | numactl --membind 重启;mbind/migrate_pages 迁移 |
| 4 | mbind 返回 EPERM | 容器 seccomp 拦截,或 MPOL_MF_MOVE 缺 CAP_SYS_NICE | 用 MPOL_MF_STRICT 先验证;检查 capsh --print |
| 5 | MPOL_BIND 后分配失败 | BIND 不回退,目标节点内存耗尽 | 改 MPOL_PREFERRED,或留出 fallback 节点 |
| 6 | cpuset.cpus.partition=isolated 返回 EBUSY | 兄弟组有任务、CPU 重叠、父组不是 root | 清空兄弟、设 cpuset.cpus.exclusive、自顶向下建 |
| 7 | isolcpus 后延迟仍有抖动 | 中断/RCU tick 还在隔离核 | 加 irqaffinity、rcu_nocbs、nohz_full |
| 8 | 负载均衡“不搬” | 任务被 task_hot() 判热、亲和掩码限制、migration_disabled | 看 lb_failed 与 nr_balance_failed;临时放宽 sched_migration_cost 验证 |
| 9 | 迁移次数很高但负载仍不均 | 迁移在“抖”,不是“搬” | 看 sched_migrate_task 的 orig/dest 是否成对;缩小 cpuset 范围 |
| 10 | numa_hit 高但性能差 | 期望节点本身是远程(BIND 指定) | 看 numastat -p 的实际分布,不只看 numa_hit |
| 11 | EAS 没生效 | 没有 energy model、overutilized、算力同构 | dmesg | grep -i eas;ls /sys/kernel/debug/energy_model |
| 12 | 热插拔 vCPU 后任务卡住 | 域重建期间 stop_machine,或 CPU 被离线但任务掩码仍含它 | 看 dmesg 的 CPU hotplug;避免在热路径频繁改 cpuset |
| 13 | 虚拟机里 NUMA 距离全是 10 | hypervisor 没暴露 SLIT,或 numa=fake= 未配 | numactl --hardware;virtio/vNUMA 配置 |
| 14 | perf stat -e cpu-migrations 为 0 | 事件不支持或权限不足 | perf list | grep migration;perf_event_paranoid |
3.6.15 观测与实验
先看一个内核模块:打印每个 CPU 的拓扑与调度域信息,演示 for_each_online_cpu()、topology_*() 与 cpu_smt_mask()。
// topo_demo.c — 打印 CPU 拓扑与算力(Linux 6.8)
#include <linux/init.h>
#include <linux/module.h>
#include <linux/sched/topology.h>
#include <linux/cpumask.h>
#include <linux/arch_topology.h>
static int __init topo_demo_init(void)
{
int cpu;
for_each_online_cpu(cpu) {
pr_info("cpu%-3d pkg=%-2d core=%-3d smt=%*pbl cap=%lu\n",
cpu,
topology_physical_package_id(cpu),
topology_core_id(cpu),
cpumask_pr_args(cpu_smt_mask(cpu)),
(unsigned long)capacity_of(cpu));
}
pr_info("topo_demo: nr_cpu_ids=%u online=%u\n",
nr_cpu_ids, num_online_cpus());
return 0;
}
static void __exit topo_demo_exit(void) { }
module_init(topo_demo_init);
module_exit(topo_demo_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("dump CPU topology and capacity");
capacity_of() 返回的是 cpu_capacity(由 arch_topology 从 DT/ACPI 的 capacity-dmips-mhz 或 x86 的默认值算出)。x86 上通常全是 1024,除非开了 hybrid 支持。
用户态程序:设亲和性 → 跑一段 → 读 sched_getcpu(),验证「确实只在指定核上」。
/* affinity_demo.c — 绑核并验证
* 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -O2 -o affinity_demo affinity_demo.c
* 运行: ./affinity_demo 0 2 3
*/
#define _GNU_SOURCE
#include <errno.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
cpu_set_t set;
CPU_ZERO(&set);
for (int i = 1; i < argc; i++)
CPU_SET(atoi(argv[i]), &set);
if (sched_setaffinity(0, sizeof set, &set) == -1) {
fprintf(stderr, "sched_setaffinity: %s\n", strerror(errno));
return 1;
}
/* 读回掩码,确认内核接受的就是我们想要的 */
cpu_set_t got;
if (sched_getaffinity(0, sizeof got, &got) == -1) {
perror("sched_getaffinity");
return 1;
}
printf("allowed cpus:");
for (int c = 0; c < CPU_SETSIZE; c++)
if (CPU_ISSET(c, &got))
printf(" %d", c);
printf("\n");
/* 跑一会儿,采样当前 CPU,统计分布 */
int count[CPU_SETSIZE] = {0};
for (int i = 0; i < 100000; i++) {
int cpu = sched_getcpu();
if (cpu >= 0 && cpu < CPU_SETSIZE)
count[cpu]++;
}
for (int c = 0; c < CPU_SETSIZE; c++)
if (count[c])
printf("cpu %d: %d samples\n", c, count[c]);
return 0;
}
在双核 QEMU 里跑 ./affinity_demo 0,应只看到 cpu 0 的采样;跑 ./affinity_demo 0 1,两个核都可能出现(具体取决于调度器,单线程通常固定在一个核上,除非被均衡搬走)。
bpftrace 数迁移:
# 每个进程被迁移的次数(10 秒)
bpftrace -e 'tracepoint:sched:sched_migrate_task { @[comm] = count(); } interval:s:10 { exit(); }'
perf 侧的对应指标是软件事件 cpu-migrations:
perf stat -e task-clock,context-switches,cpu-migrations ./affinity_demo 0 1
再看一次「谁在用 CPU」的分布。两个 CPU 密集进程绑在同一个核上,mpstat 会显示该核 100%、其他核空闲;不绑定时它们会分到两个核,各 100%:
# 终端 1:两个死循环,绑在 CPU 0
taskset -c 0 sh -c 'while :; do :; done' &
taskset -c 0 sh -c 'while :; do :; done' &
# 终端 2:按核看利用率
mpstat -P ALL 1 3
# 预期:CPU 0 的 %usr 接近 100,其余接近 0;两个进程各拿约 50%
把两个 taskset -c 0 改成 taskset -c 0,1,mpstat 会看到两个核各约 100%——这就是「亲和性决定放置」的最小实验。注意 %usr 是「该核处于用户态的时间比例」,两个进程争一个核时该核仍然是 100%,不会变成 200%。
- 确认是不是 NUMA 问题:
numastat -p PID看内存分布;perf stat -e node-load-misses,node-loads(若支持)看远程访存比例。 - 确认 CPU 与内存是否同节点:
numactl --show看策略;/proc/PID/status的Mems_allowed_list与Cpus_allowed_list。 - 看 AutoNUMA 是否在工作:
cat /proc/vmstat | grep numa_;kernel.numa_balancing是否为 1。 - 看是否绑错:
numactl --cpunodebind=0 --membind=0与不绑对比;如果绑了反而慢,可能是「绑到了没有本地内存的节点」。 - 最后才改数据布局:把每线程的数据放在本地节点(
numa_alloc_onnode/mbind),这通常比调内核参数有效得多。
3.6.16 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出 AP 的唤醒链 | smp_init → cpu_up → start_secondary | 3.6.1 |
| 画出 SMT→MC→NUMA 域层级 | 每层的标志位与代价 | 3.6.2 |
说出 load_balance 七步 | group → queue → detach → attach → active | 3.6.3、3.6.8 |
区分 isolcpus、cpuset 与亲和 | 三种作用点与交集 | 3.6.4、3.6.9 |
| 解释 first-touch 与四种策略 | 至少说出 BIND/INTERLEAVE 的差别 | 3.6.5、3.6.10 |
用 numastat/numa_maps 判断远程内存 | 能读 Total 行与 VMA 的 N0/N1 | 3.6.5、3.6.10 |
说出 cpuset.cpus.partition 的 EBUSY 条件 | 兄弟有任务、CPU 重叠、exclusive 为空 | 3.6.9 |
| 解释 EAS 的生效条件 | energy model、非 overutilized、算力不对称 | 3.6.11 |
读 sched_debug 的 lb_failed/nr_balance_failed | 判断“没搬”卡在哪 | 3.6.13 |
| 数出迁移次数 | perf stat -e cpu-migrations 或 bpftrace | 3.6.12、3.6.15 |
Q1 4 核 8 线程的机器上,把两个 CPU 密集线程分别绑到 cpu0 与 cpu1,为什么可能只得到约 1.2 倍的算力而不是 2 倍?
Q2 load_balance() 为什么不直接把正在运行的任务从忙核搬到闲核?
Q3 isolcpus=2,3 之后,普通进程还能不能在 CPU 2 上跑?怎样才能让某个线程跑在上面?
Q4 一个进程的 numastat 显示 90% 内存在 node 1,但它被 taskset 绑在 node 0 的 CPU 上。可能的成因与修法各是什么?
Q5 numa_hit 很高、numa_miss 很低,是否说明没有跨节点访问?
Q6 cpuset.cpus.partition=isolated 返回 EBUSY,列出三种可能原因与各自的核对命令。
Q7 一台机器 lscpu 显示 64 核,但业务线程的 se.avg.util_avg 总和只有 8 个核的量,perf sched 也看不到排队。可能是什么配置导致的?
答案
A1 cpu0 与 cpu1 很可能是同一物理核的两个 SMT 兄弟,共享执行单元与 L1/L2。两个线程各占一个逻辑核,但算力不会翻倍,通常只有 1.2–1.4 倍。用 thread_siblings_list 可以确认。
A2 正在运行的任务不能从别的 CPU 上「摘下来」——它的寄存器与栈正在被使用。load_balance() 只能搬「可运行但没在跑」或「正在睡眠」的任务;对正在跑的,要么等它主动让出,要么用 stop 类的 stop_one_cpu() 在安全点强制迁移(active balance)。
A3 普通进程默认不会被放到 CPU 2/3 上(isolcpus 把它们从调度域里摘掉),但如果显式 sched_setaffinity 把掩码设成 CPU 2,仍然可以跑——isolcpus 隔离的是「自动放置」,不是「禁止使用」。这正是实时程序的标准做法:isolcpus + 自己绑核。
A4 成因:进程在 node 1 上启动过(first-touch 分配在 node 1),之后才被 taskset 绑到 node 0;或内存是继承来的(fork 自一个在 node 1 上跑的父进程)。修法:用 numactl --membind=0 重启,或 migrate_pages() 把页迁到 node 0,或让程序在绑核后重新分配关键缓冲区。
A5 不一定。numa_hit 统计的是「在期望节点上分配成功」,而期望节点由策略决定;如果策略是 MPOL_BIND=1,所有分配都算 numa_hit,但都在 node 1。要看 numa_other(在非本地节点分配的次数)与 numastat -p 的实际分布。
A6 ① 兄弟组有任务:cat /sys/fs/cgroup/<parent>/<sibling>/cgroup.procs;② cpuset.cpus 与兄弟的 cpuset.cpus.exclusive 重叠:cat .../cpuset.cpus.exclusive.effective;③ 本组 cpuset.cpus.exclusive 为空或父组 cpuset.cpus.effective 未覆盖:先读 effective 再写。三者都满足仍失败时,看 dmesg 的 cpuset 提示。
A7 最常见的是 cgroup cpu.max 配额只有 8 个核的算力,而亲和掩码是 64 核:nproc 与 util_avg 看起来“核很多”,实际被带宽限流。查 cat /sys/fs/cgroup/.../cpu.max 与 cpu.stat 的 nr_throttled;另一种是 taskset 只给了 8 个核,lscpu 看的是整机。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| 奔跑吧 Linux 内核 | 第 3 章 3.3 SMP 负载均衡 | 3.6.2–3.6.3、3.6.7–3.6.8 | 完整(调度域、load_balance、lb_env) |
| 奔跑吧 Linux 内核 | 第 3 章 3.4 HMP 调度器 | 3.6.6、3.6.11 | 完整(算力不对称、misfit) |
| 奔跑吧 Linux 内核 | 第 3 章 3.5 NUMA 调度器 | 3.6.5、3.6.10 | 完整(NUMA 策略、AutoNUMA、迁移) |
| 奔跑吧 Linux 内核 | 第 3 章 3.6 EAS 绿色节能调度器 | 3.6.11 | 精选(能效模型与 EAS 条件) |
| OSTEP | 第 10 章 多处理器调度(高级) | 3.6.3、3.6.8、3.6.9 | 对照(缓存亲和、工作窃取、迁移代价) |
| 李治军 | 第 4 章 线程切换与调度 | 3.6.1 | 背景(单核到多核的切换视角) |
下一站:3.7 物理内存管理 从 CPU 转到内存:伙伴系统怎么把物理页分出去、
struct page里到底存了什么、水位线与 per-CPU 缓存怎么决定「现在要不要回收」。