第四十二章:负载均衡
结丹中期涉及内核源码:
一
林小源在四座石台之间走动时,发现了一个问题。
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 远程。
"这是一个没有完美答案的问题。"林小源喃喃道。
"对。"老者说,"但内核开发者已经找到了一个足够好的答案——大多数时候,它是对的。"
/*
* 负载均衡的触发条件:
* 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");#include <stdio.h>
/*
* 负载均衡的触发条件:
* 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; /* 缓存亲和性得分 */
};
int main() {
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");
return 0;
}道藏笔记
内核启示
负载均衡是多核调度的核心机制。
触发条件:
- 定时器触发 —
scheduler_tick()定期检查 - 进程入队触发 — 进程加入"重"的运行队列
- CPU 空闲触发 — 从其他 CPU "偷"进程
负载均衡的算法:
- 计算每个 CPU 的负载
- 找到最重和最轻的 CPU
- 从最重的 CPU 迁移进程到最轻的 CPU
- 考虑缓存亲和性、NUMA 距离等因素
负载均衡的权衡:
- 好处:提高 CPU 利用率,减少等待时间
- 代价:缓存失效,NUMA 跨节点访问,锁开销
- 目标:在"公平"和"效率"之间找到平衡
负载均衡不是"绝对公平"——是"可接受范围内的公平"。
负载均衡之试
本章讲周期性检查负载、推动调度和均衡的时钟入口函数是什么?