Skip to content

第五十四章:多核之惑

结丹圆满

涉及内核源码:

林小源在调度竞技场中,看到了一座巨大的棋盘。

棋盘上有八个格子,每个格子都是一颗 CPU 核心。它们排列成两排——上面四颗属于 NUMA 节点 0,下面四颗属于 NUMA 节点 1。每颗核心都在独立运行着进程,但它们共享同一片内存。

"这就是多核的世界。"一个声音从棋盘中央传来。

一个身穿黑白棋袍的男子站在棋盘中央,他的左手拿着白色的棋子——代表负载均衡,右手拿着黑色的棋子——代表缓存亲和性。他的眼神深邃而矛盾,仿佛永远在做着选择。

"我是 select_task_rq(),"他说,"多核调度的核心决策者。当一个进程需要被调度时,由我来决定它应该在哪颗 CPU 上运行。"

林小源看着棋盘,问道:"选择 CPU 有什么难的?随便选一颗空闲的不就行了?"

男子摇摇头,把白色的棋子放在 CPU 0 上:"如果 CPU 0 很忙,CPU 7 很闲,你当然会选 CPU 7。但如果这个进程之前一直在 CPU 0 上运行,它的数据还缓存在 CPU 0 的缓存中。迁移到 CPU 7 意味着缓存失效,需要重新从内存加载——代价高昂。"

他又把黑色的棋子放在 CPU 7 上:"所以,选择 CPU 0 是'缓存友好'的,选择 CPU 7 是'负载均衡'的。哪个更好?没有标准答案。"

林小源跟着棋袍男子在棋盘上行走。每一步都踩在一颗 CPU 核心上,核心的温度和负载通过脚底传来。

"除了缓存亲和性和负载均衡,还有什么需要考虑?"林小源问。

男子指着棋盘的分界线:"NUMA 拓扑。这颗 CPU 访问本地内存只需几十纳秒,但访问远程内存——跨过这条分界线——需要上百纳秒。进程应该尽量在本地 NUMA 节点上运行,避免远程内存访问。"

他又指向棋盘的角落,那里有两颗大小不同的核心:"还有能效。在大小核架构中,大核性能高但功耗大,小核性能低但功耗小。一个小任务放在大核上是浪费,一个大任务放在小核上是瓶颈。调度器需要根据任务的负载选择合适的核心。"

林小源看着棋盘上错综复杂的关系,感到一阵眩晕。单核调度只需要考虑'选哪个进程',多核调度还要考虑'选哪颗 CPU'。维度增加了,权衡也增加了。

"select_task_rq() 的逻辑是什么?"林小源问。

男子在空中画了一幅流程图:"第一步,检查进程的 CPU 亲和性——有些进程被绑定到特定的 CPU 集合。第二步,在亲和的 CPU 中,找到'最合适的'——考虑缓存亲和性、NUMA 距离、负载、能效。第三步,返回目标 CPU 的运行队列。"

"这些因素有优先级吗?"

"有,但不是固定的,"男子说,"缓存亲和性在大多数情况下优先于负载均衡——因为缓存失效的代价比等待的代价更大。但在极端情况下——比如某颗 CPU 的负载是其他 CPU 的几倍——负载均衡会优先。调度器不断在这些因素之间寻找平衡。"

林小源坐在棋盘的边缘,看着男子在棋盘上反复摆放棋子。

"有没有完美的方案?"他问。

男子停下手,苦笑一声:"没有。每种选择都有代价。选择缓存亲和性,可能牺牲负载均衡——某颗 CPU 忙死,其他 CPU 闲死。选择负载均衡,可能牺牲缓存亲和性——进程频繁迁移,缓存不断失效。选择能效,可能牺牲性能——小任务用小核,但小核处理慢。"

"那怎么办?"

"权衡,"男子说,"多核调度没有'完美'的方案,只有'最合适'的方案。调度器的目标不是让每个因素都最优,而是让整体效果最好。有时候,牺牲一点缓存亲和性来换取负载均衡是值得的;有时候,牺牲一点负载均衡来换取缓存亲和性是值得的。"

林小源看着棋盘上的八颗 CPU,心中感慨。单核调度是一个一维问题——选哪个进程。多核调度是一个多维问题——选哪个进程、在哪颗 CPU 上、考虑哪些因素。维度的增加带来了复杂性的指数增长。

"权衡是永恒的主题,"林小源说。

男子点头:"在内核的世界里,没有免费的午餐。每一个优化都有代价,每一个选择都有权衡。理解这些权衡,才是真正的理解。"


c
/*
 * 多核调度的挑战:
 *
 * 1. 缓存亲和性
 *    进程在 CPU 0 上运行时,数据缓存在 CPU 0 的缓存中
 *    如果进程被迁移到 CPU 1,缓存失效
 *    需要重新从内存加载数据
 *
 * 2. NUMA 拓扑
 *    访问本地内存快,访问远程内存慢
 *    进程应该在本地 NUMA 节点上运行
 *
 * 3. 负载均衡
 *    某些 CPU 很忙,某些 CPU 很闲
 *    需要迁移进程来平衡负载
 *
 * 4. 核心间通信
 *    进程间通信需要通过缓存一致性协议
 *    频繁通信的进程应该在相邻的 CPU 上运行
 */

struct cpu_topology {
    int cpu;
    int core_id;
    int node_id;        /* NUMA 节点 */
    int cache_level;
};

printf("=== 多核调度的挑战 ===\n\n");

/* 8 个 CPU,2 个 NUMA 节点 */
struct cpu_topology topo[] = {
    { 0, 0, 0, 2 },  /* Node 0, Core 0 */
    { 1, 1, 0, 2 },  /* Node 0, Core 1 */
    { 2, 2, 0, 2 },  /* Node 0, Core 2 */
    { 3, 3, 0, 2 },  /* Node 0, Core 3 */
    { 4, 4, 1, 2 },  /* Node 1, Core 4 */
    { 5, 5, 1, 2 },  /* Node 1, Core 5 */
    { 6, 6, 1, 2 },  /* Node 1, Core 6 */
    { 7, 7, 1, 2 },  /* Node 1, Core 7 */
};
int nr = sizeof(topo) / sizeof(topo[0]);

printf("CPU 拓扑:\n");
printf("%-6s %-10s %-10s %-10s\n", "CPU", "Core", "NUMA Node", "Cache");
printf("%-6s %-10s %-10s %-10s\n", "---", "---", "---", "---");
for (int i = 0; i < nr; i++) {
    printf("%-6d %-10d %-10d %-10d\n",
           topo[i].cpu, topo[i].core_id,
           topo[i].node_id, topo[i].cache_level);
}

printf("\n--- 调度决策的权衡 ---\n");
printf("选择 CPU 时需要考虑:\n");
printf("1. 缓存亲和性:\n");
printf("   进程之前在 CPU 0 上运行 → 优先选择 CPU 0\n\n");
printf("2. NUMA 距离:\n");
printf("   进程的内存分配在 Node 0 → 优先选择 Node 0 的 CPU\n\n");
printf("3. 负载均衡:\n");
printf("   CPU 0 很忙,CPU 7 很闲 → 考虑迁移到 CPU 7\n\n");
printf("4. 能效:\n");
printf("   大小核架构 → 小任务用小核,大任务用大核\n\n");

printf("--- select_task_rq() 的逻辑 ---\n");
printf("1. 检查进程的 CPU 亲和性\n");
printf("2. 在亲和的 CPU 中,找到\"最合适的\"\n");
printf("3. 考虑缓存亲和性、NUMA 距离、负载\n");
printf("4. 返回目标 CPU 的运行队列\n");

道藏笔记

内核启示

多核调度引入了全新的挑战。

多核调度的挑战:

  1. 缓存亲和性 — 进程应该在"熟悉"的 CPU 上运行
  2. NUMA 拓扑 — 进程应该在本地 NUMA 节点上运行
  3. 负载均衡 — 需要迁移进程来平衡负载
  4. 能效 — 大小核架构需要选择合适的核心

的逻辑:

  1. 检查进程的 CPU 亲和性
  2. 在亲和的 CPU 中找到"最合适的"
  3. 考虑缓存亲和性、NUMA 距离、负载
  4. 返回目标 CPU 的运行队列

多核调度的权衡:

  • 缓存亲和性 vs 负载均衡
  • 性能 vs 能效
  • 延迟 vs 吞吐量

多核调度没有"完美"方案——只有"权衡"。


破关试炼

多核之惑之试

多核机器上新任务要先选择运行 CPU,本章提到的选队列入口函数是什么?

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

以修仙之名,悟内核之道