Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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正在参与调度的 CPUcpu_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() 重建。

定义 3.6.1 · 调度组与容量

每个调度域下挂着若干调度组(struct sched_group),组内 CPU 被视为一个整体参与比较。组的 sgc->capacity 是该组的总算力(按 CPU 的最初算力 capacity_of() 累加,受 cpu_capacity 与热降频影响),group_weight 是组内 CPU 数。load_balance() 比较的是「组的负载 / 组的容量」,而不是绝对负载——这就是为什么 8 核小核簇和 2 核大核簇可以公平比较。

例 3.6.1 · 一次均衡决策的算术

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 负载均衡:谁在搬、搬什么、多久搬一次

均衡有三个触发点:

  1. 周期性:scheduler_tick() 里若到达 sd->balance_interval,置 SCHED_SOFTIRQ,由 run_rebalance_domains() → rebalance_domains() 处理。
  2. 新空闲(newidle):CPU 的运行队列空了,newidle_balance() 立刻去别的队列偷任务,避免进入 idle。
  3. 唤醒时: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 的 SQMS/MQMS 与 Linux 调度域

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 引入的「真正隔离」语义,比只设掩码更强。

关于亲和性的五个误解
  1. 「绑核就一定更快」:绑核减少迁移与缓存失效,但也放弃了其他核的空闲算力。单线程绑一个核,其余核闲着,总吞吐不会提高。
  2. 「taskset 能绑内核线程」:多数内核线程带 PF_NO_SETAFFINITY,sched_setaffinity 返回 EINVAL。kthreadd 创建时可指定 kthread_bind()。
  3. 「亲和性掩码是建议」:它是硬约束。掩码里没有当前 CPU 时任务会被强制迁移;掩码为空返回 EINVAL。
  4. 「绑了核就不会迁移」:掩码含多个 CPU 时仍会在其中迁移;isolcpus 的核也可能被 kthread 或中断占用(除非同时设 irqaffinity)。
  5. 「容器里 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 的参数没用,该改数据布局。

NUMA 的四个误解
  1. 「numactl --membind 能限制所有分配」:只影响之后的分配,已有的页不会自动迁;要迁得用 migrate_pages() 或重启。
  2. 「numa_hit 高就说明没问题」:numa_hit 是「在期望节点上分配成功」,期望节点本身可能就是远程的(MPOL_BIND 指定的)。看 numa_other 更有意义。
  3. 「AutoNUMA 能解决一切」:它按页迁移,对共享只读的大页、频繁跨节点访问的页效果有限;而且迁移本身有开销。
  4. 「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共享 LLCMC 层select_idle_sibling() 的搜索范围
SD_NUMA跨节点NUMA 层迁移可能丢本地内存,代价最高
SD_ASYM_CPUCAPACITY核算力不同HMP/EASmisfit 任务迁移
SD_SERIALIZE该层均衡串行化NUMA 层避免多 CPU 同时搬
SD_PREFER_SIBLING优先在同级组内摊平MC/DIEupdate_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 子树并频繁改动”的原因之一。

定义 3.6.2 · 调度域与调度组的包含关系

一个 sched_domain 描述“一个 CPU 在某一层能看到哪些 CPU”,它的 span 是这些 CPU 的并集;sched_group 是 span 的一个划分,负载均衡以组为单位比较 group_load / group_capacity。域是树(parent/child),组是环(sg->next 链),两者不是同一维度:域决定“往哪一层找”,组决定“跟谁比”。

调度域的四个坑
  1. “/proc/sched_debug 里的域层级就是硬件拓扑”:它是内核配置 + 启动参数 + cpuset 共同作用的结果。关了 CONFIG_SCHED_MC,MC 层就消失;isolcpus 会把 CPU 从 span 里摘掉。
  2. “域越多越好”:每一层都要周期扫描、比较组负载;层级过多会增加 tick 开销,尤其是在数百核机器上。
  3. “partition_sched_domains() 是免费的”:它要 stop_machine,频繁触发会让整机出现毫秒级停顿。
  4. “NUMA 层一定存在”:单节点机器没有 SD_NUMA;numa=fake=2 可以伪造,但距离矩阵与真实硬件不同,实验结果不能直接外推。

3.6.8 负载均衡的判据与失败路径:搬谁、搬多少、为什么没搬成

load_balance() 不是“把多出来的任务平均分”,而是一个带预算、带拒绝条件的搜索过程。它的失败路径比成功路径更值得读:线上“明明有空核却没搬过去”多半落在某条拒绝条件上。

六个触发点

触发入口特点
周期 tickrebalance_domains()按 sd->balance_interval 节流,自底向上
新 idlenewidle_balance()CPU 变空时立刻偷任务,避免进 idle
唤醒select_task_rq_fair()只选核,不搬已在队列里的任务
fork/execselect_task_rq(p, ..., WF_FORK/WF_EXEC)把新任务放到合适核
active balanceactive_load_balance_cpu_stop()用 stop 类强制迁移正在跑的任务
nohz idlenohz_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_disabledmigrate_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 会停一小段,所以只在“不搬就真的不均衡”时使用。

例 3.6.1 · 为什么 8 个空闲核没有接走 6 个任务

一台 8 核机器,CPU 0 有 6 个可运行任务,其他 7 核各 1 个。按 load/capacity,CPU 0 是 600%,其余 100%,看起来应该搬走 5 个。实际第一轮摘 3 个、搬走 2 个,原因有三:

  1. sched_nr_migrate=32 是上限,不是目标;detach_tasks() 以 env->imbalance 为预算,而每个候选还要过 can_migrate_task()。
  2. task_hot() 把最近在 CPU 0 跑过的任务判为热,can_migrate_task() 拒绝。
  3. should_we_balance() 只允许组内第一个空闲 CPU 发起均衡,其余 CPU 不会同时来抢。

所以“均衡”是分多个 tick 收敛的过程,不是一次到位。看到 mpstat 在几秒内从 600%/100% 收敛到 200%/200%,是正常行为。

判断“没搬”卡在哪
  1. 看 sched_debug 的 lb_failed 与 lb_balanced:失败多、成功少 → 拒绝条件在起作用。
  2. 看 nr_balance_failed:持续增长 → active balance 阈值;不增长 → 根本没找到不均衡组。
  3. 看任务的 Cpus_allowed_list:全被钉住 → LBF_ALL_PINNED。
  4. 看 se.nr_migrations:很高但负载仍不均 → 迁移在抖,不是没搬。
  5. 看 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.cpusrw本组允许的 CPU 列表格式错 EINVAL;写超集会被裁成与父组的交集
cpuset.memsrw本组允许的 NUMA 节点节点不存在 EINVAL;为空时任务无法分配内存;空掩码放任务可能 ENOSPC
cpuset.cpus.effectivero与父组取交集后的实际值只读,写入 EPERM
cpuset.mems.effectivero同上同上
cpuset.cpus.exclusiverw为本组保留的 CPU(6.5+)与兄弟重叠时被拒绝
cpuset.cpus.partitionrwmember/root/isolatedisolated 条件不满足时 EBUSY

cpuset.cpus.partition=isolated 是“真隔离”:它不仅限制本组,还要求兄弟组没有任务,从而保证本组 CPU 不会被其他组的负载均衡拉走。写入失败返回 EBUSY 的典型原因:

  1. 兄弟 cgroup 里还有任务;
  2. cpuset.cpus 与兄弟的 cpuset.cpus.exclusive 重叠;
  3. 本组的 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,实际算力只有半个核。

亲和与隔离的五个坑
  1. “isolcpus 之后普通进程绝对上不去”:显式 sched_setaffinity() 仍可把任务放上去;isolcpus 只影响默认放置与负载均衡。
  2. “cpuset 只限制 CPU”:cpuset.mems 同样重要。只设 CPU 不设内存,任务可能在远程节点分配,NUMA 延迟翻倍。
  3. “写了 cpuset.cpus 就立刻生效”:组内任务要等下一次调度/迁移;正在跑的任务可能还在旧核上,直到被抢占或主动让出。
  4. “cpuset.cpus.partition=isolated 一定能成功”:兄弟组有任务、CPU 重叠、父组不是 root,都会 EBUSY;它不是“写一次就永久有效”的开关。
  5. “容器里 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 问题的排查顺序
  1. 先确认是不是 NUMA 问题:numastat -p PID 看内存分布;/proc/PID/numa_maps 看每个 VMA 的节点分布。
  2. 再看 CPU 与内存是否同节点:numactl --show、/proc/PID/status 的 Mems_allowed_list 与 Cpus_allowed_list。
  3. 看策略是否被继承/覆盖:get_mempolicy() 读当前策略;子进程可能继承了父进程的 BIND。
  4. 看 AutoNUMA 是否在工作:kernel.numa_balancing、/proc/vmstat 的 numa_*。
  5. 最后改布局:每线程本地分配、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
非 overutilizedrd->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 是两套机制。

HMP/EAS 的四个坑
  1. “capacity_of() 是常数”:它随频率、热压、capacity-dmips-mhz 变;同一台机器降频后容量会掉。
  2. “EAS 在所有 ARM 上生效”:需要 energy model、非 overutilized、算力不对称;服务器 ARM 上常常不满足。
  3. “uclamp 会改变 nice 份额”:它只影响选核与调频的“性能需求”,不改变 CFS 权重;份额仍由 load.weight 决定。
  4. “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 改过
levelSMT/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):

位名称含义
0x0001SD_LOAD_BALANCE参与均衡
0x0002SD_BALANCE_NEWIDLE新 idle 时均衡
0x0004SD_BALANCE_EXECexec 时选核
0x0008SD_BALANCE_FORKfork 时选核
0x0010SD_BALANCE_WAKE唤醒时选核
0x0020SD_WAKE_AFFINE唤醒亲和
0x0040SD_ASYM_CPUCAPACITY算力不对称
0x0080SD_SHARE_CPUCAPACITY共享执行单元(SMT)
0x0100SD_CLUSTER核簇(若内核支持)
0x0200SD_SHARE_PKG_RESOURCES共享 LLC
0x0400SD_SERIALIZE串行化均衡
0x0800SD_ASYM_PACKING大小核打包
0x1000SD_PREFER_SIBLING优先同级摊平
0x2000SD_OVERLAP域可重叠
0x4000SD_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 对照。

读 sched_debug 的四步
  1. 先看 CPU 头的 nr_running 与 clock/clock_task 差:判断是否过载、是否被中断占满。
  2. 再看每个域的 lb_failed/lb_count:失败率高说明“搬不动”,回到 3.6.8 的拒绝条件。
  3. 看 nr_balance_failed:持续增长会触发 active balance,代价是 stop_machine。
  4. 最后看组的 CPU 位图:组划分与预期不符,说明 cpuset/热插拔改过拓扑,先重建再调参。
sched_debug 的三个坑
  1. “文件不存在就是内核坏了”:多数发行版默认关 CONFIG_SCHED_DEBUG;/proc/sched_debug 不存在是正常配置。
  2. “nr_running 包含组内所有任务”:根队列的 nr_running 只算本层;组内任务数看 h_nr_running 或子组。
  3. “lb_gained 高就是均衡好”:它只统计“成功搬走的任务数”,不统计搬得是否值得;频繁搬可能带来缓存失效,要看 nr_migrations 与业务延迟一起判断。

3.6.14 陷阱清单:多核与 NUMA 篇

#现象原因判断/修复
1lscpu 显示 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
3NUMA 机器上 numastat 显示远程内存占一半first-touch 落在远程,或继承了父进程策略numactl --membind 重启;mbind/migrate_pages 迁移
4mbind 返回 EPERM容器 seccomp 拦截,或 MPOL_MF_MOVE 缺 CAP_SYS_NICE用 MPOL_MF_STRICT 先验证;检查 capsh --print
5MPOL_BIND 后分配失败BIND 不回退,目标节点内存耗尽改 MPOL_PREFERRED,或留出 fallback 节点
6cpuset.cpus.partition=isolated 返回 EBUSY兄弟组有任务、CPU 重叠、父组不是 root清空兄弟、设 cpuset.cpus.exclusive、自顶向下建
7isolcpus 后延迟仍有抖动中断/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 范围
10numa_hit 高但性能差期望节点本身是远程(BIND 指定)看 numastat -p 的实际分布,不只看 numa_hit
11EAS 没生效没有 energy model、overutilized、算力同构dmesg | grep -i eas;ls /sys/kernel/debug/energy_model
12热插拔 vCPU 后任务卡住域重建期间 stop_machine,或 CPU 被离线但任务掩码仍含它看 dmesg 的 CPU hotplug;避免在热路径频繁改 cpuset
13虚拟机里 NUMA 距离全是 10hypervisor 没暴露 SLIT,或 numa=fake= 未配numactl --hardware;virtio/vNUMA 配置
14perf 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 性能问题的排查顺序
  1. 确认是不是 NUMA 问题:numastat -p PID 看内存分布;perf stat -e node-load-misses,node-loads(若支持)看远程访存比例。
  2. 确认 CPU 与内存是否同节点:numactl --show 看策略;/proc/PID/status 的 Mems_allowed_list 与 Cpus_allowed_list。
  3. 看 AutoNUMA 是否在工作:cat /proc/vmstat | grep numa_;kernel.numa_balancing 是否为 1。
  4. 看是否绑错:numactl --cpunodebind=0 --membind=0 与不绑对比;如果绑了反而慢,可能是「绑到了没有本地内存的节点」。
  5. 最后才改数据布局:把每线程的数据放在本地节点(numa_alloc_onnode/mbind),这通常比调内核参数有效得多。

3.6.16 本章出口检查

检查项达标线没达标去哪
说出 AP 的唤醒链smp_init → cpu_up → start_secondary3.6.1
画出 SMT→MC→NUMA 域层级每层的标志位与代价3.6.2
说出 load_balance 七步group → queue → detach → attach → active3.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/N13.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 或 bpftrace3.6.12、3.6.15
即时自测 · 3.6

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 缓存怎么决定「现在要不要回收」。