12、CPU拓扑与调度域:CPU拓扑结构、调度域划分、负载均衡算法

好,咱们今天聊一个在SMP系统里特别关键,但很多人容易忽略的话题——CPU拓扑与调度域。

说实话,我刚开始做多核移植那会儿,觉得调度嘛,不就是把任务扔给空闲的核跑吗?后来发现,事情远没那么简单。你想想看,一个四核的芯片,如果两个核共享L2缓存,另外两个核各自独享L1,那任务放哪个核上跑,性能差距可能很大。这就是CPU拓扑要解决的问题。

12.1 CPU拓扑结构:芯片里的“地理”关系

CPU拓扑,说白了就是描述处理器核心之间“亲疏远近”的关系。在RT-Thread SMP里,我们通过一个树形结构来管理这些关系。

常见的拓扑层级包括:

  • 线程(Thread):单个硬件线程,比如Intel的Hyper-Threading
  • 核心(Core):物理CPU核心
  • 簇(Cluster):一组共享L2缓存的核,比如ARM的big.LITTLE架构
  • 芯片(Die/Chip):整个物理封装
  • 节点(NUMA Node):在NUMA架构中,一组共享内存的核

我在项目中遇到过一个问题:某款ARM Cortex-A53四核芯片,两个核共享一个L2缓存,另外两个核共享另一个L2缓存。如果调度器不考虑这个拓扑,把两个计算密集型的任务分别放在不同L2域的核上,那还好;但如果放在同一个L2域,缓存争抢就会导致性能下降30%以上。

RT-Thread里,CPU拓扑信息通常通过设备树或启动时的CPU信息来构建。我习惯在系统初始化时,用一个结构体数组来描述每个核的拓扑关系:

/* CPU拓扑描述结构 */
struct rt_cpu_topology {
    int cpu_id;           /* CPU编号 */
    int cluster_id;       /* 所属簇 */
    int core_id;          /* 物理核心编号 */
    int thread_id;        /* 硬件线程编号 */
    int numa_node;        /* NUMA节点 */
    int llc_id;           /* 最后一级缓存ID */
};

/* 示例:4核CPU的拓扑 */
static const struct rt_cpu_topology cpu_topology[] = {
    {0, 0, 0, 0, 0, 0},  /* CPU0: 簇0, 核心0 */
    {1, 0, 1, 0, 0, 0},  /* CPU1: 簇0, 核心1 */
    {2, 1, 0, 0, 0, 1},  /* CPU2: 簇1, 核心0 */
    {3, 1, 1, 0, 0, 1},  /* CPU3: 簇1, 核心1 */
};
我的经验:在构建拓扑时,别忘了考虑big.LITTLE架构。大核和小核的性能差异可能达到2-3倍,如果调度器把实时任务放到小核上,那延迟就不好看了。

12.2 调度域划分:把核分组管理

有了拓扑信息,下一步就是划分调度域。调度域是一组CPU的集合,调度器在域内进行负载均衡。

为什么要划分域?原因很简单:

  • 减少开销:如果每次负载均衡都要扫描所有核,那在128核的系统上开销太大了
  • 考虑缓存亲和性:同一个L2域内的核,迁移任务的开销小;跨域迁移,缓存就废了
  • 支持异构:大核和小核不能混在一起做负载均衡

RT-Thread的调度域设计,我参考了Linux的sched_domain思路,但做了简化。每个调度域包含:

/* 调度域结构 */
struct rt_sched_domain {
    rt_uint32_t span;           /* CPU位图,表示包含哪些核 */
    int level;                  /* 域层级:0=核心内, 1=簇内, 2=芯片内 */
    int flags;                  /* 标志位 */
    struct rt_sched_domain *parent;  /* 父域,用于跨域均衡 */
    struct rt_sched_domain *child;   /* 子域 */
    int busy_factor;            /* 繁忙因子,用于判断是否均衡 */
    int imbalance_pct;          /* 不均衡百分比阈值 */
};

举个例子,一个4核CPU,两个L2域,调度域划分如下:

域层级 包含CPU 说明
域0(核心级) CPU0, CPU1 共享L2缓存,迁移开销小
域1(核心级) CPU2, CPU3 另一个L2域
域2(芯片级) CPU0-3 跨L2域,迁移开销大

嗯,这里要注意:域层级越高,迁移开销越大。所以负载均衡应该优先在低层级域内进行。

12.3 负载均衡算法:让每个核都忙起来

负载均衡,说白了就是让各个核的任务量尽量均匀。但“均匀”怎么定义?是看运行队列长度?还是看CPU利用率?还是看任务优先级?

我个人习惯用运行队列长度加权的方式。每个任务根据优先级有一个权重,高优先级任务权重更大。这样,即使两个核的运行队列长度一样,但一个核上全是高优先级任务,另一个核上全是低优先级任务,那也不算均衡。

RT-Thread的负载均衡算法,我设计成三个步骤:

  1. 触发条件:当某个核的运行队列为空,或者某个核的任务数超过阈值时,触发均衡
  2. 寻找目标:在当前调度域内,找到负载最重的核
  3. 任务迁移:从重负载核上挑选一个合适的任务,迁移到当前核

核心代码大概长这样:

/* 负载均衡主函数 */
static void rt_sched_balance(int cpu)
{
    struct rt_sched_domain *sd;
    int target_cpu;
    
    /* 从最低层级域开始查找 */
    for (sd = current_domain(cpu); sd; sd = sd->parent) {
        /* 检查域内是否不均衡 */
        if (!domain_imbalanced(sd, cpu))
            continue;
        
        /* 找到域内负载最重的核 */
        target_cpu = find_busiest_cpu(sd, cpu);
        if (target_cpu < 0)
            continue;
        
        /* 从目标核迁移一个任务过来 */
        if (migrate_task(target_cpu, cpu) == RT_EOK)
            break;  /* 迁移成功,退出 */
    }
}
曾经踩过的坑:我曾经在迁移任务时,忘了考虑任务对当前核的亲和性。结果把一个绑定了CPU0的任务迁移到了CPU1,导致任务跑飞了。所以迁移前一定要检查任务的cpus_allowed位图。

12.4 负载均衡的触发时机

负载均衡不能太频繁,否则调度开销会吃掉性能收益。我一般设置两种触发方式:

  • 周期性触发:每个tick检查一次,但只在系统负载较高时做均衡
  • 事件触发:当任务创建、任务退出、任务优先级变化时,触发局部均衡

这里有个技巧:周期性均衡的间隔,我建议根据CPU数量动态调整。核越多,间隔可以越长。比如4核系统每10ms均衡一次,16核系统每20ms均衡一次。

12.5 实际项目中的避坑指南

最后,分享几个我在实际项目中遇到的坑:

  • 缓存污染问题:跨L2域迁移任务后,任务的缓存数据全废了,第一次运行会慢很多。我建议对延迟敏感的任务,尽量绑定在同一个L2域内
  • 中断负载均衡:中断处理也要考虑拓扑。比如网卡中断,最好绑定在离PCIe控制器最近的核上
  • 调试技巧:在/proc或RT-Thread的shell里,加一个命令显示每个核的负载情况和拓扑关系。我习惯用cpu_topology命令,一眼就能看出哪个核是瓶颈

好了,关于CPU拓扑和调度域,今天就聊这么多。下一章我们会讲中断负载均衡,到时候会用到今天讲的拓扑知识。记住一句话:不了解芯片的“地理”关系,就别谈高效调度