4、SMP调度器:RT-Thread SMP调度器架构、负载均衡策略、线程迁移、优先级继承

好,咱们进入SMP调度器这个核心话题。说实话,这部分内容我当年啃了很久才真正吃透。单核调度你只要管好一个CPU就行,但多核环境下,调度器要同时管好几个CPU,还要保证它们别闲着、别打架。嗯,这里面的门道不少。

4.1 RT-Thread SMP调度器整体架构

RT-Thread的SMP调度器,说白了就是给每个CPU核心都配了一个独立的就绪队列。每个核心都有自己的优先级位图,自己管自己的线程。但调度器本身是全局统一的——所有核心共享同一套调度算法逻辑。

我画个简化的架构图给你看:

+------------------+     +------------------+     +------------------+
|     CPU0         |     |     CPU1         |     |     CPU2         |
|  +------------+  |     |  +------------+  |     |  +------------+  |
|  | 就绪队列0  |  |     |  | 就绪队列1  |  |     |  | 就绪队列2  |  |
|  | 优先级位图0 |  |     |  | 优先级位图1 |  |     |  | 优先级位图2 |  |
|  +------------+  |     |  +------------+  |     |  +------------+  |
|  | 当前线程0  |  |     |  | 当前线程1  |  |     |  | 当前线程2  |  |
|  +------------+  |     |  +------------+  |     |  +------------+  |
+------------------+     +------------------+     +------------------+
         |                       |                       |
         +-----------------------+-----------------------+
                                 |
                         +-----------------+
                         | 全局调度锁      |
                         | 线程迁移决策    |
                         | 负载均衡触发    |
                         +-----------------+

每个核心独立运行调度器,但通过全局调度锁来同步关键操作。我在项目中遇到过一个问题:两个核心同时触发线程迁移,结果把同一个线程抢来抢去。后来加了迁移状态标志才解决。

4.2 负载均衡策略

负载均衡,说白了就是让每个核心干的活差不多。别一个核心忙死,另一个核心闲死。RT-Thread的负载均衡策略主要有两种:

4.2.1 周期性负载均衡

系统每隔一段时间(比如每个系统Tick)检查一次各核心的负载情况。怎么算负载?看就绪队列里线程的数量和优先级权重。

我习惯用一个简单的公式:

负载值 = 就绪线程数 × 平均优先级权重

当某个核心的负载值超过平均值一定阈值(比如20%),就触发迁移。

4.2.2 事件触发负载均衡

有些场景下周期性检查不够及时。比如一个高优先级线程突然就绪,而某个核心正闲着。这时候应该立即触发迁移。

RT-Thread在以下事件中会检查负载均衡:

  • 线程创建时
  • 线程唤醒时
  • 线程优先级改变时
  • 线程退出时
我的经验: 周期性均衡适合稳态负载,事件触发适合突发负载。两者结合效果最好。我曾经在一个视频处理项目里只用了周期性均衡,结果画面卡顿严重。加上事件触发后,延迟降了40%。

4.3 线程迁移

线程迁移就是把一个线程从一个核心的就绪队列挪到另一个核心。听起来简单,但实现起来坑不少。

4.3.1 迁移条件

不是所有线程都能随便迁移。RT-Thread里线程有个cpu_affinity属性,表示它允许在哪些核心上运行。如果线程绑定了CPU0,那就不能把它迁到CPU1。

迁移的基本条件:

  • 目标核心的负载低于源核心
  • 线程允许在目标核心上运行(亲和性检查)
  • 线程当前不在临界区或中断上下文
  • 线程不是正在运行的(不能把正在执行的线程抢走)

4.3.2 迁移过程

迁移过程我拆成三步:

  1. 摘除:从源核心的就绪队列中移除线程
  2. 插入:将线程插入目标核心的就绪队列
  3. 触发:如果目标核心空闲,触发调度

代码层面大概是这样:

static void rt_scheduler_migrate_thread(struct rt_thread *thread, 
                                        rt_uint8_t target_cpu)
{
    rt_base_t level;
    
    // 关中断,防止竞争
    level = rt_hw_interrupt_disable();
    
    // 1. 从源核心队列移除
    rt_scheduler_remove_thread(thread);
    
    // 2. 更新线程的绑核信息
    thread->bind_cpu = target_cpu;
    
    // 3. 插入目标核心队列
    rt_scheduler_insert_thread(thread);
    
    // 4. 如果目标核心空闲,触发调度
    if (rt_cpu_self() != target_cpu) {
        rt_hw_ipi_send(RT_SCHEDULE_IPI, target_cpu);
    }
    
    rt_hw_interrupt_enable(level);
}
注意: 迁移过程中必须关中断!我曾经因为没关中断,导致迁移过程中被中断打断,线程在两个队列里同时存在,最后系统崩溃。这个bug查了我整整两天。

4.4 优先级继承

优先级继承是解决优先级反转问题的经典方案。在SMP环境下,这个问题变得更复杂了。

4.4.1 单核优先级继承

先回顾下单核的情况。假设有三个线程:T1(高优先级)、T2(中优先级)、T3(低优先级)。T3持有锁,T1等待锁,T2抢占T3。这时候T1被T2间接阻塞了——这就是优先级反转。

解决方案:T1等待锁时,把T3的优先级临时提升到T1的水平。这样T3就能尽快执行完释放锁,T1就能继续跑了。

4.4.2 SMP下的优先级继承

多核环境下,情况更复杂。比如T3在CPU0上持有锁,T1在CPU1上等待这个锁。这时候要把T3的优先级提升,但T3可能正在CPU0上跑,而T1在CPU1上等着。

RT-Thread的处理方式:

  • 每个锁维护一个等待队列
  • 当高优先级线程等待锁时,检查持有锁的线程优先级
  • 如果持有者优先级更低,就提升它
  • 释放锁时恢复原始优先级

我遇到过的一个坑:在SMP环境下,两个核心上的线程同时等待同一个锁,结果优先级继承链变得很长。后来我限制了继承深度,最多继承3层。

核心要点: SMP下的优先级继承必须考虑跨核心的情况。锁的持有者和等待者可能在不同核心上,调度器需要跨核心通信来调整优先级。

4.5 调度器性能优化建议

最后分享几个我在实际项目中用到的优化技巧:

优化点 方法 效果
调度锁粒度 使用每个核心独立的调度锁,减少竞争 调度延迟降低30%
就绪队列查找 使用位图加速最高优先级查找 O(1)时间复杂度
迁移阈值 根据核心数量动态调整 避免频繁迁移
亲和性缓存 缓存线程上次运行的核心 减少缓存缺失

嗯,调度器这部分内容确实比较烧脑。但只要你理解了「每个核心独立调度、全局协调迁移」这个核心思想,剩下的就是细节问题了。我当年做第一个SMP项目时,光调度器就调了两个月。但一旦跑通,那种成就感是单核系统给不了的。

下一节我们会讲中断负载均衡,那是另一个有意思的话题。