3. 多核调度策略:SMP与AMP、负载均衡、任务亲和性
多核MCU的调度策略,说白了就是决定「哪个核,在什么时候,执行哪个任务」。这不像单核那么简单——你只有一个CPU,任务排队就行。多核时代,你得考虑核之间怎么配合。
我个人习惯把调度策略分成两大类:SMP(对称多处理)和AMP(非对称多处理)。这两者的区别,决定了你的系统架构怎么搭。
3.1 SMP vs AMP:核心差异
先看一张对比表,心里有个底:
| 特性 | SMP(对称多处理) | AMP(非对称多处理) |
|---|---|---|
| 操作系统 | 单个OS实例管理所有核 | 每个核可运行独立OS或裸机 |
| 任务调度 | 全局调度器,任务可在任意核运行 | 每个核独立调度,任务绑定固定核 |
| 内存共享 | 统一内存空间,共享数据简单 | 需显式定义共享内存区域 |
| 负载均衡 | 自动或半自动迁移任务 | 手动分配,静态负载规划 |
| 典型场景 | 通用计算、高吞吐应用 | 实时控制、功能安全隔离 |
我在一个工业控制器项目里用过AMP方案。当时要求一个核跑实时控制环路(1kHz周期),另一个核跑人机界面和网络协议栈。两个任务对实时性的要求差太多,混在一起调度反而容易出问题。AMP正好解决——控制核跑裸机或RTOS,通信核跑Linux或高功能RTOS。
关键判断标准:如果任务之间耦合度高、需要频繁交换数据,选SMP;如果任务隔离性要求高、或者有硬实时约束,AMP更合适。
3.2 对称多处理(SMP)调度
SMP下,所有核平等,一个全局调度器负责分配任务。你想想看,这就像一个大食堂,只有一个打饭窗口,所有核都来排队取任务。但这里有个问题——调度器本身是共享资源,多个核同时访问调度数据结构,就需要锁保护。
常见的SMP调度算法有几种:
- 全局轮转(Global Round-Robin):任务按优先级排成一个全局队列,每个核空闲时取队首任务。实现简单,但缓存亲和性差——任务可能在不同核之间跳来跳去。
- 按需推送(Push Migration):某个核空闲时,从其他核的队列里「偷」任务过来执行。负载均衡效果好,但增加了调度开销。
- 工作窃取(Work Stealing):每个核有自己的就绪队列,空闲时随机偷其他核的任务。FreeRTOS SMP版本就用了这种思路。
我记得有一次调试一个SMP系统,发现两个核的负载严重不均——一个核忙到100%,另一个核闲到30%。查了半天,原来是某个中断服务程序绑定了特定核,导致那个核被频繁打断,任务执行时间被拉长。嗯,这里要注意:中断亲和性也会影响负载均衡。
3.3 非对称多处理(AMP)调度
AMP就简单粗暴了——每个核独立运行自己的调度器,任务从一开始就绑定死了。你不需要考虑任务迁移,也不需要全局锁。但代价是:灵活性差,一旦某个核过载,其他核再闲也帮不上忙。
AMP的典型部署方式:
- 主从模式:一个核做「主核」,负责系统初始化和通信协调;其他核做「从核」,执行固定任务。我在一个电池管理系统里用过这种模式——主核跑BMS状态机,从核专门做电池均衡控制。
- 独立域模式:每个核完全独立,运行不同的应用。比如一个核跑实时控制,另一个核跑安全监控,两者通过共享内存交换心跳包。
AMP的坑:我曾经在一个项目里,两个核同时访问同一个外设寄存器,没有做互斥保护,结果导致外设状态错乱。AMP下,外设资源也要明确归属——要么归某个核独占,要么通过核间通信协调访问。
3.4 负载均衡算法
负载均衡的目标很简单:让所有核的利用率尽量接近。但实现起来,学问不少。
常见的负载均衡策略:
- 静态负载均衡:编译时或启动时分配任务,运行期间不调整。适合任务行为可预测的系统。
- 动态负载均衡:运行时监控每个核的负载,定期迁移任务。适合负载波动大的场景。
- 混合策略:大部分任务静态分配,只有少数「弹性任务」在核之间迁移。
我比较推荐混合策略。为什么呢?因为动态迁移有代价——任务上下文、缓存数据都要搬过去,如果迁移太频繁,性能反而下降。我在一个视频处理项目里试过全动态均衡,结果任务迁移开销占了CPU时间的5%,得不偿失。
负载均衡的触发条件:一般设置一个阈值,比如某个核的利用率超过80%,而另一个核低于40%,才触发迁移。阈值设得太小,迁移太频繁;设得太大,均衡效果差。我个人习惯设成30%的差值。
3.5 任务亲和性设置
任务亲和性(Affinity),就是告诉调度器:这个任务只能在指定的核上运行。为什么要这么做?
- 缓存优化:任务一直跑在同一个核上,L1/L2缓存命中率高。
- 外设绑定:某些外设只连接到特定核,任务必须跑在那个核上才能访问。
- 实时性保障:把高优先级任务固定在某个核上,避免被其他核的任务干扰。
在FreeRTOS SMP中,设置亲和性的API很简单:
// 将任务绑定到核0和核1
UBaseType_t uxAffinityMask = (1 << 0) | (1 << 1);
vTaskCoreAffinitySet( taskHandle, uxAffinityMask );
在Zephyr RTOS中,通过线程属性设置:
k_thread_cpu_pin(thread_id, cpu_id); // 固定到指定核
k_thread_cpu_enable(thread_id, cpu_mask); // 允许在多个核上运行
我曾经犯过一个错误:把一个高频中断处理任务固定到了核0,但核0同时还在跑通信协议栈。结果中断延迟飙升,通信也丢包。后来我把中断处理任务挪到核1,核0专心跑协议栈,问题就解决了。所以,亲和性设置不是越固定越好,要综合考虑任务之间的依赖关系。
3.6 知识结构图
下面这张图总结了本章的核心逻辑:
3.7 实战建议
最后,给几个我踩过坑之后总结的建议:
- 先评估任务特性:哪些任务需要高实时性?哪些任务计算量大?哪些任务之间有频繁通信?这些决定了你选SMP还是AMP。
- 不要迷信负载均衡:负载均衡不是万能的。任务迁移有开销,缓存亲和性被破坏后性能可能下降。我见过有人把负载均衡做得太激进,结果系统吞吐量反而降低了。
- 亲和性设置要留余地:不要把任务死死绑在一个核上。可以设置一个「首选核」,允许调度器在必要时迁移。这样既保证了大部分时间的缓存亲和性,又保留了灵活性。
- 监控核间通信开销:AMP下,核间通信(IPC)是性能瓶颈。我曾经用共享内存加信号量做IPC,结果信号量竞争导致实时任务抖动。后来改用无锁环形缓冲区,问题才解决。
调试小技巧:在开发阶段,可以临时关闭负载均衡,把任务固定到不同核上,观察每个核的负载分布。这样能快速发现任务分配是否合理。等调优好了再开启负载均衡。