第3章:SMP调度器核心

好,咱们进入正题。SMP调度器,说白了就是让多个CPU核心一起干活,还得干得漂亮。我刚开始接触NuttX的SMP时,第一反应是——这不就是把单核调度器复制几份吗?后来发现,事情远没那么简单。

3.1 NuttX SMP调度器架构

NuttX的调度器,核心思想是「每个CPU都有自己的就绪队列」。嗯,这里要注意,不是全局一个队列,而是每个CPU一个。为什么?你想想看,如果所有任务都挤在一个队列里,多个CPU同时取任务,那锁竞争得多激烈。

我给你们画个简图:

CPU0 就绪队列          CPU1 就绪队列
┌─────────────┐       ┌─────────────┐
│ 任务A (prio 5)│       │ 任务C (prio 3)│
│ 任务B (prio 2)│       │ 任务D (prio 1)│
│ ...          │       │ ...          │
└─────────────┘       └─────────────┘

每个CPU核心独立运行调度器,选择自己队列里优先级最高的任务。这叫做「私有就绪队列」方案。我个人习惯用这种方案,因为它简单、高效,锁的粒度最小。

但有个问题——任务怎么分配到哪个CPU?NuttX提供了两种方式:

  • CPU亲和性(Affinity):你可以在创建任务时指定它只能在哪些CPU上运行。比如,中断处理任务绑在CPU0上,计算密集任务绑在CPU1上。
  • 自动负载均衡:如果没指定亲和性,调度器会尽量把任务均匀分配到各个CPU上。

核心数据结构:每个CPU有一个 struct cpu_sched_s 结构体,里面包含了该CPU的就绪队列、当前运行任务、空闲任务等信息。调度器通过 this_cpu() 宏获取当前CPU的ID,然后操作对应的数据结构。

3.2 CPU负载均衡策略

负载均衡,说白了就是别让一个CPU忙死,另一个CPU闲死。我在项目中遇到过这种情况:CPU0跑满了,CPU1却在睡大觉,系统整体性能上不去。

NuttX的负载均衡策略主要有三种:

策略 触发时机 做法
拉取(Pull) CPU空闲时 从其他CPU的就绪队列里「偷」任务过来
推送(Push) 新任务创建时 把任务放到负载最轻的CPU上
周期性均衡 定时器触发 每隔一段时间检查负载,做一次全局调整

我个人最常用的是「拉取」策略。为什么?因为它只在CPU空闲时才触发,不会干扰正在运行的任务。你想想看,如果CPU0正在跑一个实时任务,突然被中断去做负载均衡,那实时性就毁了。

具体实现上,NuttX在 sched/sched_balance.c 里实现了 sched_balance_pull() 函数。它会遍历所有CPU的就绪队列,找到优先级最高的可迁移任务,然后拉过来。

/* 伪代码示意 */
void sched_balance_pull(int cpu)
{
    for (每个其他CPU) {
        task = 找到最高优先级的可迁移任务();
        if (task != NULL) {
            从源CPU队列移除(task);
            加入当前CPU队列(task);
            break;
        }
    }
}

避坑指南:我曾经在负载均衡上踩过一个坑——把中断处理任务也迁移了。结果中断响应时间变得不可预测。后来我加了个判断:如果任务绑定了中断,就不允许迁移。记住,不是所有任务都能随便迁移的。

3.3 Spinlock与临界区保护

多核编程,最头疼的就是并发保护。NuttX里主要用spinlock(自旋锁)来保护临界区。为什么不用互斥锁?因为spinlock在等待时不会休眠,适合保护那些执行时间很短的临界区。

spinlock的原理很简单:

/* 获取spinlock */
void spin_lock(spinlock_t *lock)
{
    while (test_and_set(lock) != 0) {
        /* 自旋等待 */
        arch_spin_pause();  /* 让CPU休息一下 */
    }
}

/* 释放spinlock */
void spin_unlock(spinlock_t *lock)
{
    *lock = 0;
}

嗯,这里要注意,test_and_set 必须是原子操作。在ARM上,这通常用 LDREX/STREX 指令实现。在RISC-V上,用 AMOSWAP 指令。

NuttX里,spinlock的使用场景主要有:

  • 调度器锁:保护就绪队列的操作
  • 定时器链表:保护定时器操作
  • 信号量等待队列:保护信号量的等待队列
  • 内存分配器:保护堆内存的分配和释放

警告:spinlock不能在中断上下文里长时间持有!我曾经见过一个bug:某个驱动在中断处理里拿了spinlock,然后去等一个信号量,结果信号量被另一个CPU持有,那个CPU又在等这个spinlock——死锁了。记住,spinlock的临界区要短,最好在几十条指令以内。

还有一个重要的概念——中断屏蔽。在单核时代,我们通过关中断来保护临界区。但在多核时代,光关中断不够,因为其他CPU还在跑。所以NuttX的做法是:

/* 获取带中断屏蔽的spinlock */
irqstate_t flags = spin_lock_irqsave(&lock);
/* 临界区代码 */
spin_unlock_irqrestore(&lock, flags);

这个 spin_lock_irqsave 会先关本地中断,再拿spinlock。这样既防止了本地中断的干扰,又防止了其他CPU的并发访问。

我个人习惯,在写驱动时,只要临界区可能被中断访问,就用 spin_lock_irqsave。虽然性能稍微差一点,但安全第一。你想想看,一个偶发的死锁bug,排查起来可能花好几天,不值得。

3.4 实战经验总结

最后,分享几个我在SMP调度器调优中的心得:

  1. 减少锁的粒度:不要把整个调度器锁住,只锁需要保护的那一小块数据。NuttX里,每个CPU的就绪队列有自己的锁,这就是细粒度锁的好例子。
  2. 避免频繁的负载均衡:负载均衡本身有开销。如果任务执行时间很短,频繁迁移反而降低性能。我一般把均衡周期设在10ms以上。
  3. 利用CPU亲和性:对于关键任务,显式绑定到特定CPU上。比如,把网络协议栈绑在CPU0,把音频处理绑在CPU1。这样能避免任务迁移带来的缓存失效。
  4. 监控调度延迟:在调试阶段,我会在每个任务里记录调度延迟——从任务就绪到实际运行的时间。如果发现某个CPU的延迟特别高,说明负载不均衡。

一句话总结:SMP调度器的核心,就是让每个CPU高效地做自己的事,同时在必要时互相帮忙。spinlock是保护共享数据的基石,但用不好就是性能杀手。记住——短临界区、细粒度锁、合理负载均衡,这三板斧用好了,多核性能就能发挥出来。

好,这一章就到这里。下一章我们讲NuttX SMP的启动流程,看看多核是怎么从复位到跑起来的。到时候我会分享一个我在RISC-V平台上移植SMP时遇到的坑,保证让你少走弯路。