飞腾平台实时Linux多核心调度优化方案
这两年一直在飞腾平台上折腾实时Linux,前几篇写了实时方案的补丁搭建、中断线程化改造和时钟源选型,这次把多核心调度这块单独拎出来聊聊。做实时系统的朋友应该都有体会:在单核上把延迟压到几十微秒并不难,真正让人头疼的是上了多核之后,任务莫名其妙被调度到别的核心上,cache一冷,响应时间直接翻倍,找半天还不知道问题出在哪儿。这篇就把我这段时间在飞腾多核平台上做实时调度优化的思路、参数调整过程、实测数据和一些排查经验完整梳理一遍,给正在搞同类方案的朋友做个参考。
先说一个基本事实:飞腾平台本身是ARM架构的多核SMP系统,跑Linux内核时默认的调度策略对实时任务并不友好——CFS调度器追求的是整体吞吐和公平,实时任务的优先级虽然存在,但内核线程、中断处理、软中断这些系统负载会把实时任务的执行环境搅得一塌糊涂。再加上ARM架构里中断控制器(GIC)的路由规则和x86不完全一样,核间中断(IPI)的延迟、CPU亲和性的默认行为,都直接影响实时任务的最坏响应时间。所以,多核心调度优化的本质,不是简单地把实时任务优先级调高,而是围绕“确定性”这三个字,把可能干扰实时任务的每一个因素逐个隔离掉。
1. 多核心调度为什么是实时性的关键瓶颈
1.1 实时系统关注的是最坏情况,不是平均表现
做实时系统的工程师都知道,衡量实时性能的核心指标叫“最坏执行时间”和“中断/调度响应延迟”,不是你跑一千次任务平均耗时多少,而是那最差的一次响应到底多少微秒。工业生产里一个典型的现场设备控制场景——飞腾平台同时跑着数据采集、人机界面、网络通信和控制闭环,如果控制闭环的周期是1毫秒,那么从外部信号触发到控制输出翻转,这个链路里任何一个环节出现几十微秒的抖动,都可能让执行机构的动作产生肉眼可见的偏差,严重的会直接导致报警停机。
多核心调度最麻烦的地方在于:任务被调度到哪个CPU核上运行不是固定的。今天跑在核0上,明天可能跑在核2上,核间切换这个过程里,任务要么被全局调度器重新排队,要么因为CPU亲和性设置不到位而反复迁移,每一次迁移都意味着:
- 执行上下文要从旧的CPU核上保存,再在新核上恢复;
- 新核的L1/L2 cache里没有这个任务的热数据,全部缓存缺失,访存延迟瞬间上去了;
- 新核的运行队列可能还有别的任务在排队,实时任务需要等待;
- 如果迁移发生在实时任务的执行中场,时间片分布就完全失去了确定性。
所以在多核平台上谈实时,第一原则就是:让该固定的东西全部固定下来。CPU核固定、中断固定、内核线程固定、实时任务归属固定,把“动态”的部分从这个系统里尽可能剔除。
1.2 多核场景下实时任务面临的四类干扰源
我把这段时间在飞腾平台上观察到的实时任务干扰因素归纳成四类,这四类因素也是下面所有优化操作要解决的目标。
第一类是调度器本身造成的调度延迟。即使任务已经设置了SCHED_FIFO策略和最高实时优先级,内核的调度器在做出“应该切换到这个任务”的决定时,仍需要处理运行队列的锁竞争、核间负载均衡的考量。在完全没有做隔离的系统里,每个CPU核都可能被CFS任务占满,实时任务在唤醒后不一定能立即抢占到CPU,因为优先级虽然够高,但调度器的抢占点必须等到内核完成当前临界区的处理后才生效。
第二类是中断和软中断带来的抢占干扰。ARM架构里的中断控制器把外部中断路由到某个CPU核上,如果不做配置,GIC默认分发策略可能导致中断频繁落在同一个核上。更关键的是,即使我们把实时任务绑到了核3,如果网卡中断、串口中断、定时器中断全部往核3上灌,那么实时任务执行到一半,一个串口中断进来就能抢占它几十微秒到上百微秒。这个影响在实测里非常明显——中断风暴一来,最大响应延迟直线上升。
第三类是内核线程和内核活动的迁移干扰。Linux内核有大量周期性的辅助线程,比如rcuos、migration、ksoftirqd这些,它们在多核之间跳动,如果恰好跳到实时任务所在的核心,并且该核心没有做隔离,这些内核线程的调度活动就会污染实时任务的执行环境。
第四类是缓存和内存带宽的竞争。这一点在ARM多核平台上格外明显,因为ARM处理器普遍是用共享L2或集群架构设计的,一个核上的DMA操作或者大量内存拷贝,可能通过总线争用拖慢另一个核的访存速度。某个核上的数据采集线程频繁读写内存,实时任务即使绑在另一个核上,内存带宽的争用一样会放大它的执行时间抖动,这不是调度器能通过优先级调整解决的,必须从内存分配和总线隔离层面想办法。
1.3 飞腾平台做实时调度的硬件特殊性
飞腾平台和x86平台在实时优化上有个显著差异:x86上有现成的ACPUI和调度辅助机制,而飞腾平台的ARM架构更依赖内核参数和中断路由配置的精细控制。ARM的GIC中断控制器对中断有独立的亲和性配置接口,比如/proc/irq/xxx/smp_affinity_list,你可以把指定中断固定到某一个核,也可以通过GIC的distributor配置实现更底层的路由策略,在实时场景里这两条路都要走通。
再有就是飞腾平台的处理器核数通常从4核到几十核不等,核心频率、L2划分方式在不同型号上有差异。高核数平台反而更适合做CPU隔离——把实时任务隔离到专门的核上,其他核跑业务;但核数少的板卡就需要精打细算,哪个核跑实时、哪个核跑非实时、哪个核专门处理中断,分配方案要因地制宜。
2. 实时调度基础与策略选型
2.1 PREEMPT_RT内核是必需的底座
飞腾平台跑实时Linux,首先要保证内核打上了PREEMPT_RT补丁。PREEMPT_RT把Linux从“抢占点受限”的内核改造成“几乎处处可抢占”的内核,它做了三件关键的事:
- 将大部分
spinlock替换为raw_spinlock,允许在临界区里被更高优先级的实时任务抢占; - 把中断处理程序线程化,中断到来后先唤醒一个内核线程,而不是直接抢占当前任务,这样实时任务不会被中断硬生生打断太久;
- 引入了
irqsoff、preemptoff、scheduling等追踪点,方便测量延迟来源。
如果你还在用标准内核做实时,就谈不上多核调度优化,因为标准内核里一次锁竞争占用中断现场的时间,可能就顶掉整个实时任务的一个周期。飞腾平台上编译PREEMPT_RT内核和x86流程差不多,但要注意交叉编译工具链与内核版本匹配,以及把CONFIG_PREEMPT_RT、CONFIG_HZ_1000这样的配置项开到位。
2.2 三种实时调度策略的选择
Linux对实时任务提供了三种调度策略,实际使用中要区分清楚:
SCHED_FIFO是最常用的实时调度策略,带固定优先级(范围1到99),高优先级的任务总是先运行,同级任务按先来先服务顺序,除非任务主动让出CPU或被更高优先级任务抢占,否则一致运行下去。适合控制闭环、运动控制这类需要连续占核的周期性任务。
SCHED_RR也是固定优先级,但同级任务之间按时间片轮转,适合多个同优先级实时任务需要共享CPU的场景,比如同时跑两路控制算法,它们优先级一样,希望各自分到CPU时间。
SCHED_DEADLINE是Linux 3.14之后引入的基于EDF(最早截止时间优先)机制的调度策略,你给任务设定运行时间/周期/截止时间三个参数,内核按截止时间动态安排执行,性能上限理论上更高,但实际使用中参数设置错误会导致任务直接被调度器判为“不可调度”,对系统设计能力要求较高。
我给工业设备做实时方案时,固定就选SCHED_FIFO,优先级按控制闭环最高、数据采集次之、日志和通信最低这样的顺序排列,原因很简单:确定性优先,而SCHED_FIFO的行为模型最容易被工程师理解和测量。
2.3 优先级设定与优先级反转问题
实时系统的优先级不是越高越好,还要考虑优先级反转。典型的三任务场景:低优先级任务持有一把锁,高优先级任务在等这把锁,中等优先级任务刚好在运行,结果高优先级任务被锁住,中等优先级任务反而抢占了低优先级任务的执行,最后高优先级任务被困死。解决这个问题Linux有优先级继承机制——低优先级任务持有锁的时候,临时继承等待者的优先级,从而避免中等优先级任务的插队。
实际操作中,我见过很多工程师把实时任务优先级设成99,然后发现一个问题:网卡的中断线程也是高优先级,某次大流量数据包进来,中断线程把实时任务挤到后面去,控制周期全乱了。优先级分配是要全盘考虑的,线程、中断、内核线程、定时器各自占多少优先级,必须在设计阶段就有一张清晰的表,而不是用到哪个设哪个。
3. 多核心调度优化完整实操过程
3.1 第一步:用内核参数把CPU核分出亲区和隔离区
多核调度优化的第一步,不是改代码,而是先改内核启动参数。以一块飞腾8核板卡为例,我需要把它分成两类核:
- 核0-核3:跑Linux常规业务、网络、界面、文件系统;
- 核4-核7:跑实时任务,其中核4专用于主控制闭环,核5用于数据采集,核6/核7作为备用和中断处理核。
实现这个分区,在bootargs里加isolcpus=4-7 nohz_full=4-7 rcu_nocbs=4-7就足够了。解释一下这三个参数的职责:
isolcpus=4-7告诉内核调度器,默认情况下列表里的CPU核不参与普通任务的负载均衡,普通任务不会主动落到这些核上。注意,isolcpus屏蔽的是默认调度,不表示这些核完全不可用,实时任务如果想绑定这些核,仍然可以通过亲和性设置让它运行。
nohz_full=4-7把这些核从内核的周期性tick中解放出来,当核上只有一个任务时,内核时钟中断可以被关闭或大幅减少频率,减少了周期性中断对实时任务的扰动。
rcu_nocbs=4-7把RCU回调处理从这些核上转移出去,避免RCU相关的后台线程在这几个核上周期性调度。
这三个参数配合起来,就是给实时任务划出一块“清静之地”。启动之后,你可以用cat /sys/devices/system/cpu/isolated确认隔离是否生效。
另外建议把skew_tick=1和CLOCK_EVT相关的动态tick选项打开,skew_tick会让不同CPU核的tick在时间上错开,避免多核tick在同一个时刻一起触发造成短暂的并发中断风暴。
3.2 第二步:中断亲和性调整,把硬件中断从实时核上赶走
实时任务所在的核,原则上不接收任何非必要的硬件中断。实际操作中,我先把网卡、串口、USB、定时器等所有外围设备的IRQ统一查看一遍,命令是cat /proc/interrupts,这个文件会列出每个中断向量在各个CPU核上的累计触发次数。如果发现某个中断大量落在核4上,就要通过echo 8 > /proc/irq/xxx/smp_affinity这样的方式把它指向核0到核3中的某个核。
更精确的做法是使用/proc/irq/xxx/smp_affinity_list,直接写CPU编号列表,比如echo 2 > /proc/irq/88/smp_affinity_list表示把88号中断固定到核2。有些中断由驱动注册时设置了IRQF_NOBALANCING标志,不会自动参与亲和性迁移,需要在内核代码里用irq_set_affinity显式设置,飞腾平台上GIC相关的中断尤其要注意这一点。
可能有人会问:中断全都赶走了,那实时任务所在核的定时器中断怎么办?答案是通过nohz_full和动态tick机制,当一个核上只有实时任务在跑的时候,内核会自动降低tick频率,在纯任务执行阶段甚至可以做到几乎不产生tick中断,这个实测效果非常明显,调度延迟的基线整个往下走了一个量级。
3.3 第三步:内核线程与实时任务的CPU亲和性落地
隔离参数只是把核“划分”出来了,真正要把实时任务、内核线程、中断处理各就各位,还需要逐项设置亲和性。这个过程我分三步做:
第一步是把内核对应用任务的亲和性设置好。修改应用的启动脚本,在start_rt_app之前调用sched_setaffinity系统调用,把控制闭环线程的CPU亲和性设置为核4。如果应用用的是pthread,就用pthread_setaffinity_np。这里我强烈建议不要用taskset命令在启动时临时垫一下,因为应用内部的线程若不显式设置亲和性,很可能被调度到隔离核之外的核上跑,这个问题我踩过,后来干脆在应用代码里统一封装了一个set_realtime_thread函数,里面一次性完成策略、优先级、亲和性三个设置。
第二步是处理内核线程。隔离核上的migration/4、ksoftirqd/4这些内核线程虽然没有实时任务在跑,但它们依然存在,会在特定情况下被唤醒。把它们的CPU亲和性全部保留在各自核上就行,不需要特别处理。关键是rcu_preempt、kworker这些全局线程,可能会跳到隔离核上执行,通过taskset -pc 4-7 <pid>把它们全部限定到核0-核3,防止污染。
第三步是配置实时任务的调度优先级。控制闭环主线程设为SCHED_FIFO优先级80,数据采集线程设SCHED_FIFO优先级70,备用调度处理线程设60,非实时业务线程全部用CFS默认策略。这样在任何时刻,只要控制闭环线程处于就绪态,它一定是系统里第一个被调度执行的,没有任何其他线程能打断它。
3.4 第四步:调度器全局参数细调
完成绑核之后,还有一个很容易被忽略的层面,就是调度器自身的几个全局参数。/proc/sys/kernel/sched_rt_period_us和/proc/sys/kernel/sched_rt_runtime_us构成了实时任务CPU带宽限制:系统默认实时任务最多占用CPU的95%带宽,剩余5%留给非实时任务。如果你的系统里实时任务确实需要长期独占某个核,可以把runtime设为-1,表示不限制,命令是echo -1 > /proc/sys/kernel/sched_rt_runtime_us。不过一般不建议这么干,除非你已经把实时任务限定到了隔离核,并且非实时任务不会饿死。
/proc/sys/kernel/sched_cfs_bandwidth_slice_us这个参数是CFS带宽控制的调度粒度,默认值在5毫秒到10毫秒之间,可以适当调小,增强CFS任务之间的响应性,但这跟实时任务关系不大,我不太建议在这个层面增加复杂度。
另外需要关注的是/sys/kernel/debug/sched_features里的NO_HZ_FULL和NO_HZ标记,确保内核确实启用了动态tick。调试的时候可以通过watch -n 1 -d cat /proc/interrupts观察实时核上的中断计数是否明显减少,之前没做隔离时每秒几百次的定时器中断,做完隔离后每秒只剩个位数甚至零,直观且有说服力。
4. 实测数据:调优前后的延迟对比
4.1 测试环境与压测工具
我在一块飞腾8核板卡上跑了一套完整的对比测试。实时任务设计成典型的控制闭环模型:1毫秒周期,任务体内做20微秒的计算,然后挂起等待下一轮唤醒;另一个数据采集任务以500微秒周期运行,通过DMA搬运传感器数据。外围同时跑着网络流量注入、日志写入和一块压力测试程序(故意占用核0-核3的CPU,制造系统负载)。
测量方式用的是cyclictest的扩展版本,同时用内核的trace-cmd记录了调度器和中断事件。测三项指标:
- 实时任务从唤醒到实际运行的调度延迟;
- 从外部GPIO信号触发到任务响应并翻转GPIO的端到端延迟;
- 任务执行时间本身的抖动范围。
4.2 调优前后的数据汇总
默认配置(无任何隔离和亲和性设置)下,调度延迟的表现是最差的。这里把实测数据统计出来,我挑几个有代表性的:
| 测试场景 | 平均调度延迟 | 最大调度延迟 | 执行时间P99抖动 | 端到端最长响应 |
|---|---|---|---|---|
| 默认配置,全核负载 | 约120微秒 | 约840微秒 | 波动超过200微秒 | 约1.3毫秒 |
| 仅设置SCHED_FIFO高优先级 | 约65微秒 | 约320微秒 | 波动约120微秒 | 约560微秒 |
| 完成隔离+绑核+中断亲和性 | 约8微秒 | 约85微秒 | 波动约40微秒 | 约150微秒 |
看到这个数字变化,大家就能理解为什么多核心调度优化这么值得做——840微秒到85微秒,最大延迟直接缩到了十分之一,端到端响应从超过1毫秒降到150微秒以内。对一个周期1毫秒的控制闭环来说,这个差距意味着系统是否有能力跑起来。
4.3 数据解读与稳定性分析
调优后的最大调度延迟仍然有85微秒,这个数值肯定不能让人满意,问题出在哪里呢?用trace-cmd深挖后发现,这部分延迟主要来自两个地方:
一个是实时任务本身被其他实时优先级线程抢占。备用数据采集线程的优先级设定过高,在极端情况下它执行DMA搬运时,中间还有内核的irq线程参与,优先级链路的嵌套产生了额外的延迟。解决办法是把备用线程优先级从70降到50,并且给它单独绑核,不让它和主控制任务在同一核上竞争。
另一个是缓存刷新带来的执行时间抖动。即便绑核成功了,控制闭环任务运行所需的代码和数据结构还是分散在不同的内存页上,每次唤醒时指令cache和data cache重新填充都需要时间,这个体现了内存访问延迟对实时任务的影响。后来我把实时任务的关键数据结构和控制代码做了mlock内存锁页,同时在编译时通过-falign-functions -falign-loops优化代码对齐,并让控制算法使用的数据放在预分配的热页里。这样处理之后,执行时间的P99抖动从40微秒降到了12微秒左右,整体稳定了很多。
5. 常见问题与排查技巧实录
5.1 最容易踩的四个坑
第一个坑是isolcpus参数不生效或者部分生效。很多时候bootargs写了隔离核,但启动后/sys/devices/system/cpu/isolated里确实是空的,原因是内核配置里CONFIG_CPUSET没有打开,或者initramfs阶段的systemd把cpuset重新初始化了。解决方法是检查内核配置,同时确认isolcpus的写法和grub/uboot的引导参数传递是否正确。
第二个坑是任务的亲和性被继承机制改掉。应用主进程设置了亲和性之后,通过fork()或pthread_create()创建的子线程默认继承父进程的CPU亲和性,听起来没问题,但如果父进程设置亲和性的代码在创建线程之后执行,那么已经创建的线程就错过了亲和性设置,继续乱跑。我踩过几次之后,现在一律在主线程创建任何子线程之前完成亲和性初始化,或者在每个实时线程的首行代码里显式pthread_setaffinity_np。
第三个坑是DMA内存的缓存一致性问题。实时任务绑核后,如果相邻核上有DMA操作频繁改写同一块内存,ARM架构下DMA虽然走的是总线,但缓存一致性问题会导致实时任务读到的数据不稳定。我在做飞腾平台的实时采集时,把DMA缓冲区的内存配置为device属性或使用无缓存映射,避免缓存一致性问题干扰实时数据链路。
第四个坑是盲信“隔离核完全免打扰”。隔离核在启动后仍然可能收到系统管理中断、性能计数器溢出中断、NMIC等不可屏蔽中断。其中性能计数器溢出中断最隐蔽,如果你开启了perf事件剖析,板上某个核的PMU溢出中断可能会路由到隔离核上。排查办法是cat /proc/interrupts看是否存在PMU中断计数,如果有,把它也设置亲和性指向非隔离核。
5.2 基于追踪的排查思路
如果做完优化后延迟指标依然不理想,建议用追踪的方式定位,而不是猜。Linux实时内核自带ftrace,配合trace-cmd就能把每一个延迟事件的现场完整记录出来。我常用的排查流程是:
先全量记录调度和中断事件,跑10分钟后在trace-cmd report里筛选实时任务唤醒后到sched_switch到它执行之间的时间窗口,看这段时间里是什么东西占据了CPU,是中断还是高优先级线程。再看核间迁移事件,确认实时任务是否被迁移过,如果被迁移了,检查亲和性设置是不是没生效。
排查软中断问题时可以观察softirq进入和离开的耗时,特别是网络接收路径的NEtwork softirq,很多不必要的网络包处理可以通过napi参数调整或RPS(Receive Packet Steering)的配置来减少对实时核的影响。RPS在嵌入式平台上其实不建议对实时核开,它会引入额外的IPI,增加噪声。
对于时间抖动持续偏大的场景,重点看是不是cgroup的CPU带宽限制在起作用。某些系统基础镜像里会默认给cgroup加cpu.max限制,实时任务被放进有带宽限制的cgroup里,即使优先级再高,执行超过规定额度后会被强制挂起。排查命令是cat /sys/fs/cgroup/cpu/<应用目录>/cpu.max,如果仓位限制太紧,给实时任务单独建一个无限制的cgroup。
5.3 一套可直接复用的配置清单
最后整理一份我在这几个项目里反复使用、实测有效的核心配置清单。内核启动参数部分:
isolcpus=4-7 nohz_full=4-7 rcu_nocbs=4-7 skew_tick=1应用内部实时线程初始化代码的要点:
// 伪代码,展示核心设置流程 struct sched_param param = {.sched_priority = 80}; pthread_attr_t attr; cpu_set_t set; CPU_ZERO(&set); CPU_SET(4, &set); // 绑定到核4 pthread_attr_init(&attr); pthread_attr_setaffinity_np(&attr, sizeof(set), &set); pthread_attr_setschedpolicy(&attr, SCHED_FIFO); pthread_attr_setschedparam(&attr, ¶m); pthread_attr_setinheritsched(&attr, PTHREAD_EXPLICIT_SCHED); pthread_create(&thread, &attr, rt_loop, NULL);注意第4步必须用PTHREAD_EXPLICIT_SCHED,显式指定线程继承调度属性,否则线程会沿用调用者的调度策略,设置整个就白做了。
中断亲和性部分(以某中断为例):
# 查看IRQ号和当前分布 cat /proc/interrupts # 将88号中断固定到核2 echo 2 > /proc/irq/88/smp_affinity_list # 对网络队列的IRQ同样处理 for i in $(ls /proc/irq/ | grep -E '^[0-9]+$'); do echo 0-3 > /proc/irq/$i/smp_affinity_list 2>/dev/null done上面的循环把系统里未显式设置的大部分中断都驱逐到核0-核3,执行后重新检查/proc/interrupts,确保隔离核上的中断计数基本不增长。
在飞腾平台做完这一套多核心调度优化后,我最大的体会是:实时性优化不是“调一个参数、测一个数据”就完事,而是一个持续逼近的过程,目标是系统性的——从内核配置、中断路由、线程亲和性、优先级设计、内存分配,到最终的追踪验证,每一步都要扣得足够细。每次调试时把trace-cmd的数据留下来做对比,时间长了就会形成一套自己的优化经验库。
最后再啰嗦一句:配置技巧虽然可以复用,但每个项目的任务模型、核数资源、外设中断模型都不同,最稳妥的方式是搭建一个隔离良好的实验环境,随时能复现问题,把每一个改动的前后数据都记录到位,这样整套方案才真正可控、可持续优化。这套配置清单我跑过多次,只要内核版本一致、外设拓扑相近,基本可以直接抄作业,但务必盯紧最后那一项验证环节。