4、负载均衡:负载追踪、PELT算法、迁移决策、NUMA调度

负载均衡,说白了就是让 CPU 们别闲着,也别累死。一个核忙到冒烟,另一个核却在喝茶,这显然不是我们想看到的。Linux 内核的调度器在这方面做了大量工作,核心就是四个环节:负载追踪、PELT 算法、迁移决策和 NUMA 调度。

我个人习惯把负载均衡看作一个闭环:先得知道每个 CPU 有多忙(负载追踪),然后用一种公平的方式算出这个“忙”的程度(PELT),接着决定要不要把任务挪一挪(迁移决策),最后还得考虑内存访问的代价(NUMA 调度)。咱们一个一个来拆。

4.1 负载追踪:不只是看 CPU 使用率

很多人以为负载就是 CPU 使用率,其实没那么简单。你想想看,一个进程在睡眠,它占用的 CPU 是 0%,但它醒来后可能瞬间吃掉整个核。所以内核追踪的是 “可运行实体”的负载,也就是调度实体(sched_entity)对 CPU 时间的需求。

在 Linux 中,每个 runqueue(运行队列)都维护着一个 avg_load 值。这个值不是瞬时采样,而是通过 PELT 算法计算出来的历史加权平均。我遇到过不少性能问题,最后发现是负载追踪不准导致的——比如某个进程刚醒来,负载还没追上,调度器就误判它很轻,结果把它迁到了已经很忙的核上。

关键点: 负载追踪的对象是调度实体,不是整个系统。每个进程、每个 CPU、甚至每个调度组都有自己的负载贡献值。

4.2 PELT 算法:让历史说话,但别太啰嗦

PELT(Per-Entity Load Tracking)是内核 3.8 引入的算法,用来解决旧版调度器负载抖动太大的问题。它的核心思想很简单:用指数衰减的方式累加历史负载。

公式大概是这样:

L = L * y + delta
// y 是衰减因子,通常取 0.978...(对应 32ms 半衰期)
// delta 是当前时间片的负载贡献

嗯,这里要注意,PELT 不是简单地把过去一段时间的负载平均,而是越近的负载权重越大,越远的负载权重越小。这样既保留了历史趋势,又不会让很久以前的高负载影响现在的判断。

我曾经调试过一个数据库服务器,发现它的负载曲线像锯齿一样剧烈波动。查了半天,原来是 PELT 的半衰期参数被改过,导致负载追踪过于敏感。恢复默认值后,调度器的迁移决策就稳定多了。

PELT 参数 默认值 说明
半衰期 32ms 负载贡献每 32ms 衰减一半
衰减因子 y 0.978... (2^(-10/1024)) 对应 1024us 周期的衰减系数
周期长度 1024us (约 1ms) 每次更新负载的最小时间粒度
小技巧: 查看 /proc/sched_debug 可以观察每个 CPU 和每个进程的 PELT 负载值。重点关注 load_avg 和 util_avg 两个字段。

4.3 迁移决策:什么时候该搬家?

有了负载数据,接下来就是决定要不要迁移。Linux 的负载均衡分为两种:周期性均衡和 空闲时均衡。

  • 周期性均衡:每 1ms 或 4ms(取决于 HZ 值)触发一次,检查各 CPU 的负载是否失衡。如果某个 CPU 的负载超过平均值的 25%,就会尝试把任务迁走。
  • 空闲时均衡:当 CPU 空闲时,它会主动去其他 CPU 的 runqueue 里“偷”任务过来执行。这叫 nohz idle balance。

迁移决策不是随便搬的。内核会考虑 缓存热度——如果一个进程刚在 CPU0 上跑完,它的 L1/L2 缓存还是热的,这时候把它迁到 CPU1,缓存就全废了。所以调度器有个 “迁移代价” 的概念,迁移收益必须大于缓存损失才会执行。

避坑指南: 我曾经在虚拟化场景下遇到过一个问题:宿主机开启了超线程,但虚拟机内部的负载均衡没感知到物理核和逻辑核的区别,导致同一个物理核的两个逻辑核上跑了两个重负载进程,性能反而下降。后来通过调整 sched_domain 的层级解决了。

4.4 NUMA 调度:内存访问是有代价的

NUMA(Non-Uniform Memory Access)架构下,CPU 访问本地内存和远端内存的速度差很多。如果进程在 CPU0 上跑,但它的内存分配在远端节点,每次内存访问都要跨 QPI/Infinity Fabric 总线,延迟能差 2-3 倍。

Linux 的 NUMA 调度策略是:尽量让进程在分配内存的节点上运行。内核通过 numa_group 来追踪进程的内存访问模式,如果发现某个进程频繁访问远端内存,就会触发 NUMA 平衡——要么把进程迁过去,要么把内存页迁过来。

我记得有一次优化一个内存密集型的 HPC 应用,发现它的性能比理论值低了 30%。用 numastat 一看,好家伙,80% 的内存访问都是远端。后来通过设置 numa_balancing 和调整进程的 CPU 亲和性,把远端访问降到了 10% 以下,性能直接翻倍。

# 查看当前 NUMA 状态
numastat -p <pid>

# 设置进程的 NUMA 策略
numactl --membind=0 --cpunodebind=0 ./myapp

# 内核参数调整
echo 1 > /proc/sys/kernel/numa_balancing
核心原则: NUMA 调度的本质是“数据靠近计算”。要么让计算去找数据(进程迁移),要么让数据来找计算(内存迁移)。前者代价小,后者代价大,但有时候不得不做。

下面这张图展示了负载均衡的完整流程,从负载追踪到最终的迁移执行:

负载均衡核心流程 负载均衡核心流程 负载追踪 PELT 负载计算 是否失衡?(阈值 25%) 迁移决策(考虑缓存代价) NUMA 亲和性检查 执行迁移 说明: 每个 CPU 的 runqueue 独立维护负载值 周期性触发均衡检查

最后说一句,负载均衡不是越频繁越好。频繁迁移会导致 thrashing(抖动)——进程刚迁过去,缓存还没热起来,又被迁走了。内核通过 sched_migration_cost 参数来控制迁移的最小间隔,默认是 500us。我一般建议在数据库或延迟敏感型应用上把这个值调大一点,比如 2ms,减少不必要的迁移。