Skip to content

第四十二章:负载均衡

结丹中期

涉及内核源码:

林小源在四座石台之间走动时,发现了一个问题。

CPU 0 的石台上堆满了进程的光团——八个光团挤在一起,争先恐后地向石台顶端的晶石涌去。而 CPU 3 的石台旁只站着一个光团,它百无聊赖地绕着石台转圈,头顶上标着 "idle" 的字样。

"这不公平。"林小源说。

"确实不公平。"一个声音从他身后传来。

林小源回头,看到一个穿着灰色斗篷的老者。老者的手中握着一根天平——天平的两端各放着一座微型石台,一端高高翘起,另一端沉沉下坠。

"我是 ,"老者说,"负载均衡器。我的工作就是在各 CPU 的运行队列之间迁移进程,让负载尽可能均衡。"

他指了指 CPU 0 的石台:"那边有 8 个进程,负载 800。"又指了指 CPU 3 的石台:"那边只有 1 个进程,负载 100。差距太大了。"

"那你怎么做?"

"从 CPU 0 迁移 3 个进程到 CPU 3。"老者说,"但迁移不是随便做的——我要考虑很多因素。"

老者带着林小源走到一座石台旁,指着石台表面的符文说:"负载均衡有三个触发条件。"

"第一,定时器触发。scheduler_tick() 每隔一段时间检查各 CPU 的负载。如果负载差异超过阈值,就触发负载均衡。"

"第二,进程入队触发。当进程被加入'重'的运行队列时,负载均衡器会被唤醒,检查是否需要迁移。"

"第三,CPU 空闲触发。当 CPU 变为空闲时——也就是没有可运行的进程了——它会从其他 CPU '偷'进程过来。这叫 。"

林小源点了点头:"主动和被动结合。"

"对。"老者说,"但迁移是有代价的。"他从怀中取出一块水晶,水晶中映出一个进程在两个 CPU 之间跳跃的画面。

"看到没?进程从 CPU 0 迁移到 CPU 3 后,它在 CPU 0 上的缓存全部失效了。CPU 3 需要重新从内存中加载数据——这需要几十到几百个时钟周期。"

"所以你不会轻易迁移。"

"对。"老者说,"只有当负载差异足够大时,迁移的收益才大于代价。这就是负载均衡的权衡——公平 vs 效率。"

林小源想起了自己在内核中看到的 NUMA 架构:"如果迁移跨越 NUMA 节点呢?"

老者的脸色变得严肃:"跨 NUMA 节点迁移代价更高——远程内存访问比本地慢 2-3 倍。所以负载均衡器会尽量在同一个 NUMA 节点内迁移,只有在必要时才跨节点。"

林小源在石台旁坐了很久,看着老者操作天平。

"你有没有想过,"林小源问,"如果把所有 CPU 的负载都拉平,系统性能会最好吗?"

老者停下手中的动作,看了林小源一眼:"你觉得呢?"

林小源想了想:"如果频繁迁移进程,缓存会不断失效。每个进程刚在一个 CPU 上'暖'起来,就被迁走了。这样反而会降低性能。"

"你理解得很对。"老者说,"负载均衡不是'把所有 CPU 的负载拉平'——那会导致频繁的进程迁移,缓存失效反而降低性能。负载均衡的目标是'在可接受的范围内保持公平'。"

他重新拿起天平,轻轻晃动:"看到没?天平不是要完全水平——只要两端的差距在可接受的范围内,就是好的。"

林小源看着那架天平,突然意识到负载均衡器不是在追求"绝对公平",而是在寻找"动态平衡"。它需要不断地权衡:迁移的收益 vs 缓存失效的代价,公平 vs 效率,本地 vs 远程。

"这是一个没有完美答案的问题。"林小源喃喃道。

"对。"老者说,"但内核开发者已经找到了一个足够好的答案——大多数时候,它是对的。"


c
/*
 * 负载均衡的触发条件:
 * 1. 定时器中断(scheduler_tick())
 *    每隔一段时间检查各 CPU 的负载
 * 2. 进程入队(enqueue_task())
 *    当进程被加入"重"的运行队列时
 * 3. CPU 空闲(idle_balance())
 *    当 CPU 变为空闲时,从其他 CPU "偷"进程
 *
 * 负载均衡的算法:
 * 1. 计算每个 CPU 的负载
 * 2. 找到最重的 CPU 和最轻的 CPU
 * 3. 从最重的 CPU 迁移进程到最轻的 CPU
 * 4. 考虑缓存亲和性、NUMA 距离等因素
 */

struct rq {
    int cpu;
    int nr_running;
    int load;           /* 负载值 */
    int cached;         /* 缓存亲和性得分 */
};

printf("=== 负载均衡 ===\n\n");

struct rq rqs[] = {
    { .cpu = 0, .nr_running = 8, .load = 800, .cached = 50 },
    { .cpu = 1, .nr_running = 2, .load = 200, .cached = 30 },
    { .cpu = 2, .nr_running = 6, .load = 600, .cached = 40 },
    { .cpu = 3, .nr_running = 1, .load = 100, .cached = 20 },
};
int nr = sizeof(rqs) / sizeof(rqs[0]);

printf("负载均衡前:\n");
printf("%-6s %-12s %-8s %-10s\n", "CPU", "nr_running", "load", "cached");
for (int i = 0; i < nr; i++) {
    printf("%-6d %-12d %-8d %-10d\n",
           rqs[i].cpu, rqs[i].nr_running,
           rqs[i].load, rqs[i].cached);
}

/* 找到最重和最轻的 CPU */
int busiest = 0, lightest = 0;
for (int i = 1; i < nr; i++) {
    if (rqs[i].load > rqs[busiest].load)
        busiest = i;
    if (rqs[i].load < rqs[lightest].load)
        lightest = i;
}

printf("\n最重的 CPU: %d (load=%d)\n", busiest, rqs[busiest].load);
printf("最轻的 CPU: %d (load=%d)\n", lightest, rqs[lightest].load);

/* 模拟迁移 */
int migrate = 3;
printf("\n迁移 %d 个进程: CPU %d → CPU %d\n",
       migrate, busiest, lightest);

rqs[busiest].nr_running -= migrate;
rqs[busiest].load -= migrate * 100;
rqs[lightest].nr_running += migrate;
rqs[lightest].load += migrate * 100;

printf("\n负载均衡后:\n");
printf("%-6s %-12s %-8s\n", "CPU", "nr_running", "load");
for (int i = 0; i < nr; i++) {
    printf("%-6d %-12d %-8d\n",
           rqs[i].cpu, rqs[i].nr_running, rqs[i].load);
}

printf("\n--- 负载均衡的权衡 ---\n");
printf("迁移进程的好处:\n");
printf("  - 提高 CPU 利用率\n");
printf("  - 减少进程等待时间\n\n");
printf("迁移进程的代价:\n");
printf("  - 缓存失效(进程迁移到新 CPU 后,缓存是冷的)\n");
printf("  - NUMA 跨节点访问(如果迁移到远端 NUMA 节点)\n");
printf("  - 锁开销(负载均衡需要获取运行队列锁)\n\n");
printf("负载均衡器需要在\"公平\"\"效率\"之间权衡\n");

道藏笔记

内核启示

负载均衡是多核调度的核心机制。

触发条件:

  1. 定时器触发 — scheduler_tick() 定期检查
  2. 进程入队触发 — 进程加入"重"的运行队列
  3. CPU 空闲触发 — 从其他 CPU "偷"进程

负载均衡的算法:

  1. 计算每个 CPU 的负载
  2. 找到最重和最轻的 CPU
  3. 从最重的 CPU 迁移进程到最轻的 CPU
  4. 考虑缓存亲和性、NUMA 距离等因素

负载均衡的权衡:

  • 好处:提高 CPU 利用率,减少等待时间
  • 代价:缓存失效,NUMA 跨节点访问,锁开销
  • 目标:在"公平"和"效率"之间找到平衡

负载均衡不是"绝对公平"——是"可接受范围内的公平"。


破关试炼

负载均衡之试

本章讲周期性检查负载、推动调度和均衡的时钟入口函数是什么?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道