☰
Linux调度器抢占延迟调优:从xrun到PREEMPT_RT全解析
2026/10/14 7:14:44 网站建设 项目流程

1. 被低估的调度延迟:一次 xrun 引发的思考

做音频采集的老朋友某天半夜给我发来一条消息,说他在跑一个 8 通道实时采集程序时,声卡缓冲区偶尔会报 underrun,也就是俗称的 xrun。现象很典型:日志里没有错误,CPU 占用率也不高,系统负载甚至不到 1,但音频流每隔几十秒就会出现一次缺口。他看了一眼cyclictest的结果,最大延迟到了 3 毫秒左右,当时就觉得问题出在调度上。

很多人第一次接触到“延迟抢占”这个概念,都是从类似场景里被逼出来的。调度器的抢占延迟,指的是一个更高优先级任务从“变得可运行”到“真正在某个 CPU 上运行”所花费的时间。这个数字平时没人关注,因为它对普通的网页浏览、编译程序几乎没有影响,但在音频处理、工业控制、高速交易、机器人运动控制这些场景里,一次 500 微秒的延迟抖动,可能就意味着一次数据丢帧、一次控制周期超时,甚至是一套系统的硬性故障。

这篇文章我想把 Linux 调度器抢占延迟这条线完整捋一遍:它为什么存在,内核为了缩短它经历了哪些取舍,实测中哪些参数组合真正有效,以及遇到延迟异常时怎么一步步定位。适合正在做实时音频、嵌入式控制、虚拟化场景服务质量保障,或者单纯想把自己的 Linux 环境调得更丝滑的人参考。

1.1 抢占延迟到底卡在哪个环节

先说清楚一个容易混淆的地方:抢占延迟不是 CPU 执行任务本身的时间,而是“卡在队列里”的时间。一个任务从请求被调度,到最终被放上 CPU 运行,中间要经过好几道闸门。

第一道闸门是中断处理。硬件中断来了之后,CPU 要先执行中断处理程序,如果中断被其他更紧急的代码关闭了,那么中断响应本身就会延迟。第二道闸门是当前正在运行的临界区。如果某个任务正在持锁运行,而抢占已经被显式禁止,那么高优先级任务必须等它走完。第三道闸门是调度器本身的决策过程。即使设置了need_resched标志,也要等到内核主动检查这个标志,或者被其他机制打断,才能真正触发一次线程切换。

这三段加起来,才是完整的延迟抢占时间。很多人只盯着“调度算法”,其实前三段里的中断关闭时间和临界区时间,往往比调度算法本身的影响更大。后面我会逐段拆解。

1.2 为什么平时感受不到延迟

普通桌面和服务器场景感受不到抢占延迟,是因为大家对“延迟”的容忍度完全不同。比如执行一个数据库查询,几十毫秒的抖动根本不会引起注意;但一个 48kHz 采样率的音频流,每个采样点只有约 20.8 微秒的周期,只要某个路径卡了超过这个数量级的时间,缓冲区的余量就会被瞬间耗尽。用个不那么严谨的类比:普通任务关心的是“平均多久能办完”,实时任务关心的是“最长要等多久才轮到”。

所以内核这几十年做的事,本质上就是把“最长等待时间”这个上界不断往下压。从最初的内核不可抢占,到逐渐提供主动调度点,再到全面抢占,最后把整个内核路径整理成可被高优先级任务抢占的状态,每一步都是在“吞吐量”和“最坏情况延迟”之间做取舍。理解了这条主线,后面所有配置项就都好懂了。

2. 抢占模型的前世今生:先搞懂内核在防御什么

Linux 调度器的抢占能力不是一天长成的。早期内核的模型非常简单:用户态进程可以被随时打断,但一旦进入内核态,除非这个进程自己主动调用schedule(),否则没有其他人能把它赶下 CPU。这种模型的理由在当时很充分:内核代码运行在共享数据结构的上下文里,如果任意时刻都能被抢占,就可能出现一个任务正在修改链表,另一个任务插进来也要访问这个链表的情况,那必须有锁来保护,而频繁加锁对性能是很大的消耗。

所以早期内核更像是“合作式调度”:内核代码本身负责把自己切出去,把自己不忙的时候显式让位。这个模型在单核时代问题不大,因为中断返回时总会检查一下是否存在待调度的任务。但在多核、多任务、硬件加速场景出现之后,长尾延迟问题开始变得刺眼。

2.1 主动出让时代:慢得有理

老版本的内核,以及后来命名为CONFIG_PREEMPT_NONE的模型,基本思路是只在可以安全调度的位置检查TIF_NEED_RESCHED。这些安全位置包括系统调用入口、中断返回到用户空间、以及驱动代码里主动调用的schedule()。文件系统里某些耗时较长的循环,后来也加入了cond_resched()这样的主动让位点。

这种方式的优点是可预测性好,临界区天然很短,因为内核代码很少被中途打断,锁的设计也不用考虑“自己持有的锁被别人抢走”这种复杂局面。缺点是如果一段代码路径很长且没有主动出让点,高优先级任务的等待时间就无法给出上界。你无法证明它会在一毫秒内跑完,因为它可能在你之前那段不可抢占的内核代码里困了几毫秒甚至更久。

我曾经在一个旧发行版内核上实测过,带 IO 压力的网络程序,调度延迟偶尔能飙到几十毫秒。平时没问题,一旦磁盘控制器驱动进入一个比较长的处理循环,整个系统都好像在“屏住呼吸”。

2.2 全面抢占:让内核也能被“抢椅子”

随后出现的CONFIG_PREEMPT(也就是常说的 Full Preemption)彻底改变了规则:只要当前上下文不在原子区,即没有持有自旋锁、没有关闭本地中断、没有处于中断处理程序里,调度器就可以在任何内核代码路径上把当前任务赶下去,换高优先级任务上来。

实现上,内核靠preempt_count这个计数器来跟踪当前是否处于不可抢占状态。进入临界区前加一,退出时减一,只有计数器为零时才允许原地换入另一个任务。中断处理程序通常会被视为不可抢占,因为它们运行在中断栈上,上下文环境很特殊,这也是后来强制线程化中断出现的原因之一。

从延迟角度看,这一步是革命性的:它把“最坏情况等待时间”从“一段不确定长度的内核代码”压缩到“一个临界区加一次中断处理”。但代价也很明显,临界区之间的抢占意味着锁保护的数据结构访问必须非常短,自旋锁的使用要极度克制,否则抢占本身就变成了另一个延迟源。所以早期全面抢占内核也经历了不少“用着用着就死锁”的黑暗时期。

2.3 抢占模型怎么选:一张对照表

现代内核允许你在编译时选择抢占模型。常见的几档可以这样理解:

抢占模型内核代码是否可抢占适用场景典型特点
CONFIG_PREEMPT_NONE基本不可抢占,仅主动出让服务器、吞吐优先吞吐量高,最坏延迟没有上界
CONFIG_PREEMPT_VOLUNTARY在部分自愿让位点可抢占桌面、交互式负载加入cond_resched()点,改善明显但覆盖不全
CONFIG_PREEMPT除原子区外全部可抢占桌面、嵌入式、多媒体最坏延迟明显变好,吞吐略有损失
CONFIG_PREEMPT_RT全路径可抢占,锁转换为可睡眠互斥锁工业控制、音频、实时系统延迟上界最硬,但需要框架配合

注意,编译模型只决定“内核能否被抢占”,不能决定“延迟一定有多好”。把服务器内核换成 Full Preemption,并不等于变成实时系统,它只是把上限压低,后面的路径优化仍然要靠中断线程化、RCU 配置、CPU 隔离这些手段来补。

3. 延迟从哪来:一次调度发生的完整旅行

与其背配置参数,不如先搞清楚一次 wakeup 到 run 的完整旅程。我习惯把整个过程划分为四段:硬件中断触发、中断现场处理、调度标记、实际切换。每一段都可能是延迟黑洞。

3.1 中断路径:抢占的起点

硬件事件到了之后,CPU 会把当前指令流打断,跳到中断处理程序。这个过程本身很快,但真正的问题在进入中断之后。早期内核里中断处理程序要做大量工作,比如处理网络包、更新块设备状态,这些工作都在中断上下文里完成,而中断上下文通常不能被普通任务抢占。更糟的是,如果中断处理程序里用local_irq_disable()把自己的本地中断关掉,那么即使是另一个核发来的核间中断,也要等待。

所以我看到cyclictest出现大尖刺时,第一步就是查/proc/interrupts和/proc/softirqs,看哪个中断源的频率或处理时间异常。网卡收包风暴、NVMe 队列的密集中断、USB 设备反复请求,都是经典的尖刺来源。

解决办法之一是把中断变成线程。现代内核里可以用启动参数threadirqs让所有中断处理程序运行在内核线程里,这样中断本身也可以被更高优先级的实时任务抢占。代价是每次中断多了一点线程唤醒开销,但换来的是“最坏延迟”可预测性大幅提升。对于音频、工业控制这类场景,这笔账非常划算。

3.2 锁与临界区:看不见的隐形高墙

即使中断路径已经优化,下一个坑就在临界区。想一想自旋锁:锁的持有者正在一个循环里反复等待某个条件,而高优先级任务想运行,但调度器说“现在不可抢占”,于是只好等。更麻烦的是优先级反转:低优先级任务持锁,高优先级任务在等锁,中间层任务不断抢占低优先级任务,导致高优先级任务被夹在中间,迟迟拿不到锁。

传统的解决办法相当朴素:让持锁任务在临界区里先把抢占关掉,保证它快速完成。但这样一来,锁本身就成了延迟上界的一部分。如果驱动代码里有个临界区需要运行几百微秒,即便开满抢占也无济于事。

CONFIG_PREEMPT_RT的核心思路之一,就是把大部分自旋锁改造成可睡眠的优先级继承互斥锁。锁等不到时就主动睡眠,让高优先级任务能插进来,同时通过优先级继承机制避免低优先级任务被中层任务卡死。这不是一条容易走的路,它几乎要求内核里所有锁的使用场景都重新论证一遍,这也是为什么 PREEMPT_RT 合并进主线用了那么多年。

3.3 从 wakeup 到真正运行:一次核间旅行

任务被唤醒之后,调度器在唤醒者的 CPU 上调用try_to_wake_up(),然后把任务放入运行队列,并检查是否需要抢占当前运行的任务。如果目标 CPU 是另一个核,就要发一个核间中断过去,让那个核重新审视自己的运行队列。这里面有几个关键细节:

  • 如果目标 CPU 正运行在不可抢占区域,核间中断只能设置一个need_resched标志,真正切换要等它退出临界区;
  • 如果唤醒者和被唤醒任务各自在不同核上,缓存局域性会受影响,任务换过来后第一脚可能踩的是冷缓存;
  • 调度器会尽量优先考虑同一个 CPU core 组(SMT 兄弟核),但这又会带来“兄弟核共享执行单元”的相互干扰问题。

所以你会看到,很多实时调优方案里都有 CPU 隔离项。把实时任务固定到某个专用核上,禁止其他普通任务和中断迁入,就是为了缩短这段“核间旅行”,让 wakeup 到 run 的路径尽量保留在本地。

4. 实操:把延迟抢下来的一套可复现方案

理论讲得再多,不如在真实环境里压测一轮。下面这套流程我在多个发行版、多个内核版本上都用过,可以作为你优化抢占延迟的参考起点。前提是你有办法替换内核或者至少修改启动参数,如果没有,也仍然可以验证其中一部分。

4.1 编译内核时选对抢占模型

如果你要的是硬实时级别的可预测性,第一步是编译内核,进入Kernel Features -> Preemption Model菜单。需要确认自己的内核是否支持全抢占:

# 查看当前内核的抢占模型 zcat /proc/config.gz | grep PREEMPT uname -r ls /boot/config-$(uname -r)

如果你的编译配置里没有/proc/config.gz,一般可以看/boot/config-$(uname -r)文件。关键选项如下:

  • CONFIG_PREEMPT=y:开启全抢占;
  • CONFIG_PREEMPT_VOLUNTARY=y:主动出让式抢占;
  • CONFIG_PREEMPT_NONE=y:传统服务器模式;
  • CONFIG_PREEMPT_RT=y:实时抢占增强模式,通常对应独立的PREEMPT_RT内核。

如果只是做实验,不想从头交叉编译,也可以在启动菜单里看发行版是否提供了PREEMPT_RT变体内核。注意内核版本跨度大时,选项名会有变化,比如某些版本用CONFIG_PREEMPT_DYNAMIC支持运行时切换抢占模式,你需要先确认自己的内核支持哪一种。

编译时的建议是:别盲目堆选项。开启CONFIG_PREEMPT时,把CONFIG_RCU_BOOST一并打开,可以缓解 RCU 读端临界区对实时任务的压制。同时在Kernel hacking -> Tracers里把CONFIG_FUNCTION_TRACER、CONFIG_IRQSOFF_TRACER、CONFIG_PREEMPT_TRACER、CONFIG_WAKEUP_LATENCY_TRACER这类跟踪器编译进去,后面排查延迟尖刺会非常有用。

4.2 启动参数和 sysctl 的调优清单

内核编译好之后,启动参数和运行时参数同样重要。我实测时通常会按这套顺序做:

  1. CPU 隔离:用isolcpus=3-7把 3 到 7 号核从普通负载均衡中摘出来,避免调度器把普通进程塞到实时核上;
  2. 无时钟 tick:用nohz_full=3-7关闭这些核上的周期性时钟中断,减少恒定打扰;
  3. RCU 回调隔离:用rcu_nocbs=3-7让这些核上的 RCU 回调可以推迟到专门的 kthread 上统一处理;
  4. 中断亲和性:用irqaffinity=3-7逻辑上不是这样用,更常见的是手动设置/proc/irq/*/smp_affinity,把非必要中断从实时核上挪走;
  5. 强制线程化中断:加threadirqs,让中断处理程序线程化。

一个典型的隔离核启动参数长这样:

GRUB_CMDLINE_LINUX="isolcpus=3-7 nohz_full=3-7 rcu_nocbs=3-7 threadirqs"

这里有个容易踩的坑:isolcpus并不能把任务完全挡在核外,它只是“建议”调度器不要自动把普通任务放上去。如果你希望实时核真正干净,还要在用户态把实时任务绑定核,并且把其他任务绑定到非实时核上。常用做法是配合cpuset或者taskset -c手动安置。

运行时参数主要看sysctl里的调度器旋钮。例如:

  • kernel.sched_rt_runtime_us控制实时任务的公平性限制,实时任务默认最多占 95% CPU 时间,如果坚持长时间运行会踩到节流;
  • kernel.sched_autogroup_enabled在桌面发行版上常开着,但对实时任务可能带来分组内公平调度的额外延迟;
  • kernel.sched_cfs_bandwidth_slice_us控制 CFS 带宽分片粒度,在某些场景也会影响调度延迟。

需要说明的是,不同内核版本的sysctl项名和默认值有差异,具体以sysctl -a | grep sched输出为准。调优前先记录基线,改一个参数测一轮,不要一次全改。

4.3 用 cyclictest 量化你的延迟

延迟优化如果没有量化就是玄学。cyclictest是rt-tests套件里的标准测试工具,它的原理是创建一组周期任务,每次醒来后测量“实际醒来时间”和“预计醒来时间”的差值,把所有差值统计出最小、平均、最大延迟。

我常用的测试方法是:

# 单线程、绑定到 CPU 4、优先级 95、周期 1ms、循环 10000 次 sudo cyclictest -t 1 -a 4 -p 95 -i 1000 -l 10000 -n

-n表示使用 clock_nanosleep,-m可以锁定内存防止页面换出。输出大致长这样:

T: 0 ( 12345) P:95 I:1000 C: 10000 Min: 2 act: 4 Avg: 3 Max: 18

其中Min、Avg、Max是微秒级别的延迟统计。在隔离核上做完上述调优后,Max 一般能稳定在几十微秒以内。如果没有做任何隔离,裸跑全抢占内核,Max 飙到几百微秒甚至毫秒级都很正常。

跑测试的时候还有个技巧:同时跑多个不同优先级的任务看相互影响。比如:

# 两个实时线程分别绑到 CPU 4 和 CPU 5 sudo cyclictest -t 2 -a 4,5 -p 95 -i 1000 -l 100000

这样更能暴露核间干扰和优先级处理路径上的问题。如果隔离核的 Max 明显低于非隔离核,说明中断和调度器迁移是主要延迟源;如果两个核都高,就要回到锁和中断路径继续查。

5. 真实排查实录:三个让我失眠的延迟问题

理论参数是一回事,实际问题往往比教科书复杂。下面三件事是我在调延迟时真实遇到过的典型情境,经验都写在里面。

5.1 音频程序间歇性 xrun:罪魁是邻居中断

前面提到的那位做音频采集的朋友,调优后发现一个诡异现象:xrun 总在特定时间点出现,但每段时间规律不同。我们用cat /proc/interrupts对比了一段时间内各中断源的增长量,发现某张独立显卡驱动产生的中断频率高得离谱,而且它的处理程序里有一大段遍历操作,长时间处在不可抢占状态。

解决方式不是去改驱动,而是把显卡的中断亲和性重新设置。因为它的中断被 CPU 0 处理,实时音频任务绑定在 CPU 2,正常情况下不会直接碰撞,但系统偶尔会把其他任务迁移到 CPU 2,触发调度器处理复杂路径。我们把所有非必要设备的中断全部迁移到 CPU 0 和 CPU 1 上,同时用irqbalance的配置文件控制权重,问题基本消失。

这件事给我的教训是:延迟排查不能只看目标 CPU,要看整个中断拓扑。一个中断在 A 核上处理,也可能通过共享调度域影响到 B 核上的实时任务。

5.2 隔离核上的神秘抖动:RCU 回调惹的祸

另一个项目里,我们已经用了isolcpus=nohz_full=rcu_nocbs全套参数,隔离核上的 cyclictest 仍然偶发尖刺,而且尖刺间隔很不规律。后来通过开启trace_rcu_dyntick事件发现,隔离核上出现了 RCU 内核线程被唤醒,然后执行回调的痕迹。

原因是rcu_nocbs只是把回调从本核剥离,但配套的rcuog、rcuo线程仍然经常被唤醒去接管回调,唤醒本身带来了一次核间调度和锁访问。进一步排查后,我们把 RCU 相关 kthread 的 CPU 亲和性显式绑定到非隔离核上,同时确认启动参数里没有遗漏rcu_nocb_poll(轮询模式下可进一步降低核唤醒频率),尖刺才彻底消失。

这类问题最大的难点在于它不会稳定复现。我的建议是:一旦发现偶发尖刺,立刻打开调度事件跟踪,抓一两个周期的数据,别等到崩溃了再查。

5.3 内核模块把延迟拉高了上百微秒

某次给一台工控设备换内核,换完后 ping 延迟和 cyclictest 都正常,但一跑生产任务就有周期性延迟上限升高。我们先用perf统计了 production 线程上的时钟事件,发现大量时间花在一个特定驱动模块的锁等待上。

进一步看/proc/lock_stat,那个模块里的一个自旋锁平均持有时间只有几微秒,但最大持有时间却到了 200 微秒,而且最大值只出现在特定硬件 DMA 路径上,频率恰好和生产任务的周期重合。因为没有源码级修改能力,我们的处理是给生产任务开启优先级继承,并把该模块的中断线程优先级降级,让实时任务在锁等待上可以绕开它。实测最大延迟从 300 微秒降到了 50 微秒以下。

这里想强调的是:你在系统里引入的每一个内核模块,都可能成为延迟链条上的一环。不要只盯着官方内核参数,还要审视所有加载中的模块和它们的锁行为。

6. 常见延迟问题速查与进一步建议

下面是我实际排障时反复用到的一张速查表,建议先收藏,遇到问题按行自查:

现象常见原因优先排查方向
cyclictest 最大延迟持续走高中断集中、驱动临界区过长/proc/interrupts、threadirqs、中断亲和性
延迟尖刺周期性出现定时器 tick、RCU 回调、后台任务唤醒nohz_full、rcu_nocbs、ps/htop看唤醒源
某个核延迟明显高于其他核中断或 kthread 分布不均irqbalance、smp_affinity、taskset
高优先级任务仍被卡住优先级反转、锁不可抢占CONFIG_PREEMPT_RT、优先级继承、模块锁检查
开启抢占后系统吞吐下降明显过度的抢占导致上下文频繁切换检查负载类型,考虑 Voluntary 模型平衡
隔离核仍有任务乱入isolcpus没有配合用户态绑定cpuset或taskset -p显式固定

另有一个容易忽略的参数叫kernel.sched_migration_cost_ns,它控制调度器认为任务迁移成本过高的阈值。数值太小时,调度器会频繁迁移任务以追求负载均衡,反而给实时任务带来核间扰动;适当调大可以减少迁移,但对全局吞吐有抑制,需要根据实际负载权衡。

在做进一步调优之前,强烈建议先把CONFIG_SCHEDSTATS或CONFIG_CFS_BANDWIDTH等配置打开,至少保证能拿到调度统计。没有数据支撑的优化,最后多半是在瞎猜。我用perf sched latency和trace-cmd record -e sched_switch -e sched_wakeup -T两种方式配合,基本能定位到绝大多数调度延迟来源。

关于要不要直接上CONFIG_PREEMPT_RT,我的看法是:不要因为“实时”两个字就无脑切换。PREEMPT_RT 会改变很多驱动的行为假设,某些闭源模块在 RT 内核下可能表现异常,甚至触发运行时报错。如果只是音频桌面、交互式应用,Full Preemption 加隔离核已经足够;只有当你的系统有硬性截止时间要求,比如工业运动控制、飞行控制器这类场景,才值得承担 RT 内核带来的兼容性成本。

我个人在几次调优里最深的体会是:调度延迟优化更像一场“木桶工程”,编译选项、启动参数、中断配置、锁行为、RCU、CPU 亲和性,任何一块短板都会毁掉前面所有努力。普通发行版默认内核其实已经很能打了,真正决定延迟上限的往往不是调度算法本身,而是你有没有把中断、锁、隔离这几件事做干净。

如果这篇文章对你有用,建议你下次遇到 xrun 或者周期性抖动时,先别急着换内核,拿起cyclictest测一轮基线,再按上面这几层依次收紧。等到最大延迟降下来的那一刻,你大概会明白为什么说这是一条漫长但值得走的路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询