3、多核调度模型:SMP与AMP模式对比、CPU亲和性设置、负载均衡策略

好,咱们进入第三章。多核调度模型,这名字听着挺唬人,对吧?其实说白了,就是怎么让多个CPU核心一起干活,还不打架。我这些年做QNX项目,发现很多问题都出在对这个模型的理解上。你选错了模型,后面再怎么优化,也是事倍功半。

3.1 SMP与AMP模式:两种截然不同的“分工”哲学

先聊聊SMP和AMP。这两个词,你肯定不陌生。但你真的理解它们的本质区别吗?

SMP(对称多处理),就像是一个全能团队。每个核心都是平等的,都能访问所有内存和外设。操作系统负责把任务分配给空闲的核心。你想想看,这种模式下,开发起来最省心。你写一个多线程程序,QNX的调度器会自动帮你把线程扔到不同的核心上跑。

我个人习惯,在项目早期或者对实时性要求不那么极端的时候,首选SMP。为什么?因为简单。你不用操心任务到底跑在哪个核上,系统自己会搞定。

AMP(非对称多处理),则像是特种兵分队。每个核心有明确的分工。比如,Core 0专门跑实时控制任务,Core 1专门跑UI和网络协议栈。每个核心运行独立的操作系统实例,或者一个跑QNX,另一个跑裸机程序。

我在项目中遇到过这样一个场景:一个工业机器人控制器,要求关节控制周期必须稳定在50微秒以内。用SMP模式,偶尔会因为缓存命中率或者中断分配问题,导致控制周期抖动。后来我们改成AMP模式,把控制任务锁定在Core 0上,中断也全部路由到Core 0,问题就解决了。

核心区别一句话总结:

  • SMP:系统管分配,你只管写代码。适合通用计算、负载变化大的场景。
  • AMP:你管分配,系统只管执行。适合硬实时、功能隔离要求高的场景。

这里有个表格,方便你对比着看:

特性 SMP AMP
操作系统实例 单一OS实例管理所有核心 每个核心可运行独立OS或裸机
内存访问 所有核心共享统一内存空间 内存通常被划分,核心间通过IPC通信
任务调度 由OS调度器统一负责 开发者手动分配任务到指定核心
实时性 受调度延迟和缓存冲突影响 可预测性高,适合硬实时
开发难度 低,与单核编程类似 高,需要手动管理核心间通信和同步
典型应用 服务器、移动设备、通用嵌入式 工业控制、航空航天、汽车域控制器

注意:AMP模式下,核心间通信是个大坑。我曾经见过一个团队,用共享内存做IPC,但没处理好缓存一致性,导致数据错乱。排查了整整一周。嗯,这里要提醒你,AMP下用共享内存,务必加上内存屏障(memory barrier)或者使用QNX提供的MsgPass机制。

3.2 CPU亲和性设置:把任务“绑”在它该待的地方

说完了宏观模型,咱们聊聊微观操作。CPU亲和性,英文叫CPU affinity。说白了,就是告诉操作系统:“这个线程,你就让它在这几个核上跑,别乱动。”

为什么要这么做?原因有几个:

  • 减少缓存抖动:线程频繁在不同核心间迁移,会导致L1/L2缓存反复失效。性能损失可能高达30%。
  • 保证实时性:把关键任务绑定到专用核心上,避免被其他任务干扰。
  • 利用硬件特性:有些外设只连接到特定核心,或者某些核心有专属的硬件加速器。

在QNX里,设置CPU亲和性主要通过ThreadCtl()函数。我给你看个实际例子:

#include <sys/neutrino.h>
#include <sys/sched.h>

// 将当前线程绑定到 CPU 0 和 CPU 1
int set_affinity_example() {
    // 创建一个 CPU 集合,这里用位图表示
    // 位0 表示 CPU 0,位1 表示 CPU 1,以此类推
    uint64_t cpu_set = (1ULL << 0) | (1ULL << 1); // 绑定到 Core 0 和 Core 1

    // 调用 ThreadCtl 设置亲和性
    // _NTO_TCTL_RUNMASK 是设置运行掩码的命令
    int ret = ThreadCtl( _NTO_TCTL_RUNMASK, (void *)&cpu_set );
    if (ret == -1) {
        // 处理错误,比如权限不足或无效的 CPU 编号
        perror("ThreadCtl failed");
        return -1;
    }
    return 0;
}

这段代码,把当前线程绑定到了Core 0和Core 1上。注意,我用了uint64_t,这意味着最多支持64个核心。如果你的系统核心更多,需要用_NTO_TCTL_RUNMASK_64或者更复杂的接口。

我的一个小技巧:在调试阶段,我会在关键线程的入口处打印它的亲和性掩码。这样一旦发现性能问题,我就能快速确认线程是不是跑在了我期望的核心上。命令是 pidin -p <进程名> -t <线程名> -v,输出里会有一列 RUNMASK

3.3 负载均衡策略:别让一个核累死,其他核闲死

好,现在你知道了怎么把任务绑到特定核心上。但如果你用的是SMP模式,或者你不想把所有任务都手动绑定,那系统就得自己想办法让各个核心的负载尽量均衡。

QNX的调度器,默认是支持负载均衡的。但它不是万能的。我见过不少案例,默认的负载均衡策略反而导致了性能下降。

为什么会这样?因为负载均衡本身有开销。它需要监控每个核心的负载情况,然后决定是否把某个线程迁移到另一个核心上。这个迁移过程,涉及到上下文切换、缓存刷新、调度器锁竞争等。如果迁移太频繁,性能反而会变差。

QNX提供了几种策略来控制负载均衡的行为:

  • 默认策略:系统自动平衡,适合大多数场景。
  • 禁止迁移:通过设置_NTO_TCTL_RUNMASK将线程绑定到特定核心,相当于手动关闭了该线程的负载均衡。
  • 调度域(Scheduling Domain):这是QNX 7.0以后引入的更强功能。你可以把核心分组,比如把Core 0和Core 1划为一个域,Core 2和Core 3划为另一个域。负载均衡只在域内进行,域间不迁移。

调度域的配置,通常是在系统启动时通过buildfile或者io-pkt的配置文件来设置的。举个例子:

# 在 buildfile 中配置调度域
[module=procnto]
sched_domain=0,1:2,3

这行配置的意思是:创建两个调度域。第一个域包含Core 0和Core 1,第二个域包含Core 2和Core 3。线程默认在它启动时所在的域内进行负载均衡,不会跨域迁移。

避坑指南:我曾经在一个8核的ARM平台上做视频编解码。默认负载均衡下,4个编码线程在8个核心上疯狂迁移,性能还不如只用4个核心。后来我通过调度域,把编码线程限制在Core 0-3上,把UI和网络线程限制在Core 4-7上。性能直接提升了40%。

最后,给你一个决策思路:

  • 如果你的任务是CPU密集型的,且线程数等于核心数,建议手动绑定,避免迁移开销。
  • 如果你的任务是IO密集型的,或者线程数远多于核心数,可以信任默认负载均衡
  • 如果你的系统有混合负载(既有硬实时任务,又有普通任务),务必使用调度域或手动绑定,把实时任务隔离出来。

嗯,这一章的内容就到这里。记住,没有银弹。SMP和AMP各有优劣,CPU亲和性和负载均衡策略需要根据你的实际场景来权衡。多动手试试,用pidintop观察核心利用率,你会找到最适合你的方案的。