4、负载均衡:负载追踪、PELT算法、迁移决策、NUMA调度
负载均衡,说白了就是让 CPU 们别闲着,也别累死。一个核忙到冒烟,另一个核却在喝茶,这显然不是我们想看到的。Linux 内核的调度器在这方面做了大量工作,核心就是四个环节:负载追踪、PELT 算法、迁移决策和 NUMA 调度。
我个人习惯把负载均衡看作一个闭环:先得知道每个 CPU 有多忙(负载追踪),然后用一种公平的方式算出这个“忙”的程度(PELT),接着决定要不要把任务挪一挪(迁移决策),最后还得考虑内存访问的代价(NUMA 调度)。咱们一个一个来拆。
4.1 负载追踪:不只是看 CPU 使用率
很多人以为负载就是 CPU 使用率,其实没那么简单。你想想看,一个进程在睡眠,它占用的 CPU 是 0%,但它醒来后可能瞬间吃掉整个核。所以内核追踪的是 “可运行实体”的负载,也就是调度实体(sched_entity)对 CPU 时间的需求。
在 Linux 中,每个 runqueue(运行队列)都维护着一个 avg_load 值。这个值不是瞬时采样,而是通过 PELT 算法计算出来的历史加权平均。我遇到过不少性能问题,最后发现是负载追踪不准导致的——比如某个进程刚醒来,负载还没追上,调度器就误判它很轻,结果把它迁到了已经很忙的核上。
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
下面这张图展示了负载均衡的完整流程,从负载追踪到最终的迁移执行:
最后说一句,负载均衡不是越频繁越好。频繁迁移会导致 thrashing(抖动)——进程刚迁过去,缓存还没热起来,又被迁走了。内核通过 sched_migration_cost 参数来控制迁移的最小间隔,默认是 500us。我一般建议在数据库或延迟敏感型应用上把这个值调大一点,比如 2ms,减少不必要的迁移。