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 */
};
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的负载均衡算法,我设计成三个步骤:
- 触发条件:当某个核的运行队列为空,或者某个核的任务数超过阈值时,触发均衡
- 寻找目标:在当前调度域内,找到负载最重的核
- 任务迁移:从重负载核上挑选一个合适的任务,迁移到当前核
核心代码大概长这样:
/* 负载均衡主函数 */
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; /* 迁移成功,退出 */
}
}
12.4 负载均衡的触发时机
负载均衡不能太频繁,否则调度开销会吃掉性能收益。我一般设置两种触发方式:
- 周期性触发:每个tick检查一次,但只在系统负载较高时做均衡
- 事件触发:当任务创建、任务退出、任务优先级变化时,触发局部均衡
这里有个技巧:周期性均衡的间隔,我建议根据CPU数量动态调整。核越多,间隔可以越长。比如4核系统每10ms均衡一次,16核系统每20ms均衡一次。
12.5 实际项目中的避坑指南
最后,分享几个我在实际项目中遇到的坑:
- 缓存污染问题:跨L2域迁移任务后,任务的缓存数据全废了,第一次运行会慢很多。我建议对延迟敏感的任务,尽量绑定在同一个L2域内
- 中断负载均衡:中断处理也要考虑拓扑。比如网卡中断,最好绑定在离PCIe控制器最近的核上
- 调试技巧:在/proc或RT-Thread的shell里,加一个命令显示每个核的负载情况和拓扑关系。我习惯用
cpu_topology命令,一眼就能看出哪个核是瓶颈
好了,关于CPU拓扑和调度域,今天就聊这么多。下一章我们会讲中断负载均衡,到时候会用到今天讲的拓扑知识。记住一句话:不了解芯片的“地理”关系,就别谈高效调度。