4、任务调度器改造:多核就绪队列设计,负载均衡策略,任务亲和性设置
各位同学,咱们今天聊点硬核的。多核调度器改造,说白了就是让RTOS学会「多线程干活」。单核时代,一个CPU核心忙得团团转,其他核心在旁边看戏——这显然不行。我当年第一次把FreeRTOS往双核Cortex-A9上移植时,就踩过不少坑。今天把这些经验掰开揉碎讲给你听。
4.1 多核就绪队列设计
单核RTOS里,就绪队列通常只有一个。所有任务排着队等CPU。多核环境下,这个设计就出问题了——多个核心同时访问同一个队列,锁竞争能把性能吃掉一半。
我个人习惯的做法是:每个核心维护一个独立的就绪队列。这样每个核心只操作自己的队列,不需要加锁。但问题来了——任务怎么分配到各个队列?
核心原则:就绪队列的访问必须是O(1)复杂度,不能因为多核引入额外的查找开销。
我建议采用这种结构:
/* 每个核心的就绪队列 */
struct rdq_node {
uint32_t bitmap; /* 优先级位图 */
list_t ready_list[CONFIG_MAX_PRIORITIES];
spinlock_t lock; /* 仅用于跨核操作 */
};
/* 全局就绪队列数组 */
struct rdq_node per_cpu_ready_q[CONFIG_NR_CPUS];
嗯,这里要注意:每个队列的锁只在跨核迁移任务时才需要。平时任务调度,核心自己玩自己的,完全无锁。我在项目中遇到过,有人给每个调度操作都加锁,结果四核跑起来比单核还慢——这就是典型的过度设计。
避坑指南:我曾经在Zephyr上做过一个实验,把就绪队列设计成全局共享+读写锁。结果呢?8核系统,调度器开销占了CPU时间的15%。改成per-CPU队列后,直接降到2%以下。你想想看,这差距有多大。
4.2 负载均衡策略
有了per-CPU队列,新问题来了:任务分配不均匀。核心0忙死,核心1闲死。这就好比四个工人干活,一个在拼命搬砖,另外三个在喝茶——效率能高吗?
负载均衡分两种:
- 推模式(Push):新任务创建时,主动放到最空闲的核心上
- 拉模式(Pull):空闲核心主动去忙的核心「偷」任务
我个人更推荐混合策略。说白了就是:平时用推模式,发现负载不均时启动拉模式。
来看看我常用的负载均衡算法:
/* 推模式:选择负载最轻的核心 */
static inline int select_least_loaded_cpu(void)
{
int cpu;
int min_load = INT_MAX;
int target = smp_processor_id();
for_each_online_cpu(cpu) {
int load = per_cpu_load[cpu];
if (load < min_load) {
min_load = load;
target = cpu;
}
}
return target;
}
/* 拉模式:从最忙的核心偷任务 */
static int pull_task_from_busy_cpu(void)
{
int busy_cpu = find_busiest_cpu();
if (busy_cpu < 0)
return -1;
/* 偷取一个高优先级任务 */
struct task *task = steal_highest_prio_task(busy_cpu);
if (task) {
enqueue_task_local(task);
return 0;
}
return -1;
}
注意:拉模式不能太频繁。我曾经设置成每10ms检查一次,结果系统大部分时间都在「偷来偷去」,任务切换开销暴涨。后来改成每100ms检查一次,效果就好多了。这个阈值需要根据实际业务调整。
负载均衡的触发条件,我建议这样设计:
| 条件 | 阈值 | 动作 |
|---|---|---|
| 核心空闲超过50ms | 负载差 > 20% | 启动拉模式 |
| 新任务创建 | 当前核心负载 > 80% | 推模式到其他核心 |
| 定时器触发 | 每100ms | 检查全局负载均衡 |
4.3 任务亲和性设置
有些任务天生就该待在某个核心上。比如中断处理任务,最好和产生中断的外设绑在同一个核心上。否则中断来了,任务却在另一个核心上,缓存命中率会惨不忍睹。
任务亲和性(Affinity)说白了就是:告诉调度器,这个任务只能在指定的核心上运行。
我建议的API设计:
/* 设置任务亲和性掩码 */
int task_set_affinity(struct task *task, cpumask_t mask);
/* 获取任务当前所在核心 */
int task_get_cpu(struct task *task);
/* 将任务迁移到指定核心 */
int task_migrate(struct task *task, int target_cpu);
亲和性设置有几个典型场景:
- 实时性要求高的任务:绑定到专用核心,避免被其他任务干扰
- 缓存敏感型任务:绑定到同一个核心,提高缓存命中率
- 外设相关任务:绑定到外设所在的核心,减少跨核通信
经验之谈:我在做工业控制器项目时,把运动控制任务绑定到核心0,通信任务绑定到核心1,显示任务绑定到核心2。结果呢?运动控制的抖动从原来的50us降到了5us。这就是亲和性的威力。
实现亲和性时,调度器需要做两件事:
- 任务调度时,只从亲和性掩码允许的核心中选择
- 负载均衡时,不能把任务迁移到不允许的核心上
代码实现大致这样:
/* 调度时检查亲和性 */
static inline int cpu_allowed(struct task *task, int cpu)
{
return cpumask_test_cpu(cpu, &task->affinity_mask);
}
/* 选择调度核心 */
static int select_target_cpu(struct task *task)
{
int cpu = smp_processor_id();
/* 如果当前核心允许,优先使用 */
if (cpu_allowed(task, cpu))
return cpu;
/* 否则从允许的核心中选择负载最轻的 */
for_each_cpu_in_mask(cpu, &task->affinity_mask) {
if (per_cpu_load[cpu] < per_cpu_load[best])
best = cpu;
}
return best;
}
小技巧:我曾经遇到一个bug——任务设置了亲和性,但负载均衡器还是把它迁移走了。查了两天才发现,是负载均衡代码没有检查亲和性掩码。从那以后,我在所有迁移路径上都加了cpumask_test_cpu检查。这个教训告诉我:亲和性检查必须覆盖所有调度路径,一个都不能漏。
最后说一句:多核调度器的改造,核心就三个字——隔离性。队列隔离、负载隔离、亲和性隔离。把这三点做好了,多核RTOS的性能就能真正发挥出来。你想想看,如果每个核心都能独立高效地工作,整个系统的吞吐量能翻几倍?