做嵌入式这行的人,几乎没有谁没被调度延迟折磨过。你写了一个驱动、跑了一个实时任务,一切看起来都正常,可一旦系统负载上来,某个中断响应慢了、某个高优先级线程迟迟没有拿到 CPU,整个项目就可能直接翻车。我在 Linux 内核这条线上折腾了好几年,光是“抢占延迟”这一个问题,就足够写一篇长文。这个标题叫“通往延迟抢占的漫漫长路”,其实说的就是 Linux 调度器为了把“任务从就绪到真正运行”的时间压到最低,走了差不多三十年的弯路。这篇文章就是把我这几年的理解、踩坑记录和实操经验一次性摊开讲清楚。
先说结论:Linux 内核从早期的不可抢占,到后来的 CONFIG_PREEMPT、再到 PREEMPT_RT 补丁集主线化,本质上都是在跟“临界区”和“中断关闭”两个问题死磕。搞清楚这条演进路径,你才能在自己的嵌入式或服务器场景里,正确选择内核配置、测量延迟、定位卡顿源。适合谁来读?做嵌入式 Linux、写内核驱动、跑实时业务的工程师,以及那些想知道“为什么我的应用延迟忽高忽低”的开发者。
1. 抢占是什么,为什么内核迟迟不愿意做
抢占(preemption)这个词,翻译成人话就是:当一个高优先级任务已经就绪,正在运行的普通任务必须立刻让位,把 CPU 交给高优先级任务。听起来很简单,但内核在这个问题上犹豫了很多年。
1.1 三种延迟别搞混:调度延迟、抢占延迟、唤醒延迟
很多人一上来就测“延迟”,但延迟其实分成好几段,优化手段完全不一样。我在实际分析时通常把它们拆开看:
| 延迟类型 | 定义 | 关注点 |
|---|---|---|
| 调度延迟 | 从任务变成可运行、到调度器选中它的时间 | 调度器的公平性与优先级设计 |
| 抢占延迟 | 从高优先级任务就绪、到低优先级任务被强制切走的时间 | 内核临界区长度、禁抢占区间 |
| 唤醒延迟 | 从硬件中断触发、到等待该事件的任务被唤醒的时间 | 中断处理、线程化中断、等待队列 |
很多人测出来的“抖动”其实是三者的叠加,源头却各不相同。如果你只盯着某一个数字去调,很容易把方向搞错。比如一个系统延迟很大,你不看是中断关得久还是调度器选人慢,一味去调调度器参数,结果基本是白忙活。
1.2 早期内核为什么倾向“不抢占”
Linux 最早的调度器是 O(n) 那种,每次调度都要遍历整个 runqueue,任务一多,开销直线上升。那时候内核里的锁是“大内核锁”(BKL),整个内核几乎是一把锁串起来的,抢占在这种结构下根本无从谈起。
更关键的是,内核态代码到处是自旋锁保护的临界区,这些临界区假设自己在执行期间不会被其他任务打断。如果允许抢占,一个任务在临界区里被切走,另一个任务又跑来争同一把锁,情况会变得非常复杂:锁的持有者不在 CPU 上,释放锁遥遥无期,其他 CPU 只能空转等待。这就是自旋锁在 SMP 下的经典死等场景。
所以早期内核的选择是:进程在用户态随便抢,一旦进入内核态就“不回头”,直到执行完系统调用、返回用户态之前都不允许切换。这个策略保证内核临界区的完整性,但代价是任何系统调用都可能成为延迟的黑洞。一个任务在内核态里跑得再久,高优先级任务也得在外面等着。
1.3 从 NONE 到 VOLUNTARY 再到 PREEMPT
Linux 后来把抢占做成了三个可配置档位,我在内核目录里经常看到这类 config:
- CONFIG_PREEMPT_NONE:默认无抢占,只在返回用户态或进入内核时检查是否需要调度,适合吞吐量优先的服务器。
- CONFIG_PREEMPT_VOLUNTARY:在长代码路径上自愿加 cond_resched(),让任务主动让出。适合桌面环境。
- CONFIG_PREEMPT:内核态也允许抢占,但需要满足“不在禁用抢占的临界区里”这个条件。这是大多数嵌入式发行版和实时场景的起点。
这三档的真实区别不是“能不能抢”,而是“在哪里检查 TIF_NEED_RESCHED”。从 NONE 到 PREEMPT,本质上是把调度检查点从“内核入口/出口”逐步扩展到“每一次临界区退出之后”。临界区哪怕只有几十微秒,也比几毫秒的系统调用更精细。可正因如此,才引出了新问题:如何知道当前是否处于临界区?
2. 抢占的核心机制:从 flag 到临界区围栏
可抢占内核不是一句“允许切换”就行,它需要一套机制精细控制“当前代码能不能被抢”。这套机制的核心是 TIF_NEED_RESCHED 标志位和 preempt_count 计数器。
2.1 TIF_NEED_RESCHED:一切抢占的起点
TIF_NEED_RESCHED 是每个线程的一个标志位,表示“你已经需要被重新调度了”。但我做过一次比喻:这就好比有人按了你家的门铃,不等于门立刻打开——你得走到门口、确认门外是谁、再开门。CPU 也一样,收到“需要调度”的信号后,不能立刻切,必须等到一个安全的调度点。
谁去按这个门铃?常见来源是:
- 高优先级任务被唤醒并加入 runqueue
- 定时器中断发现当前任务的时间片耗尽
- 任务主动调用 schedule()
真正干活的函数是 schedule(),而 set_tsk_need_resched() 只是把标志位立起来。一个任务可能带着这个标志跑很久,只要没到调度点就没人拦得住它。所以减少延迟的关键,不是让门铃响得更多,而是让“从门铃响到开门”的距离尽量短。
2.2 preempt_count:内核态的“可抢占围栏”
这是内核抢占从设计到落地最关键的机制。preempt_count 是 thread_info 里的一个计数器,它被拆成几段使用:
- PREEMPT_ACTIVE 段:标识是否正在执行抢占逻辑
- 硬中断计数段:记录当前处于几层硬中断处理中
- 软中断计数段:记录当前处于几层软中断处理中
- preempt 计数段:记录显式禁用抢占的次数
每次执行 preempt_disable() 就把 preempt 计数加一,preempt_enable() 就减一。只有整个 preempt_count 为零的时候,内核才认为当前代码处于“可抢占”状态。
我见过不少新手以为 preempt_count 只是简单地“禁止/允许”开关,其实它是多层嵌套的计数器。中断处理程序里如果又禁了一次抢占,外层的 preempt_enable 不会愚蠢地直接放行,必须等所有嵌套都退出。这个设计避免了一个非常恶心的 bug:如果只靠布尔开关,两层临界区嵌套时,内层退出就可能提前把抢占打开,导致外层临界区被非法抢占。
2.3 调度点在哪触发:显式、协作与强制
从纯工程视角看,调度点分三种:
- 强制抢占点:每次中断返回内核态时,检查 TIF_NEED_RESCHED 和 preempt_count,如果安全就直接调度。这是 CONFIG_PREEMPT 内核的核心。
- 显式调度点:任务主动调用 schedule(),比如等待 IO、睡眠、加锁失败阻塞。
- 协作调度点:cond_resched(),在长循环里主动检查是否需要让出 CPU。
你在驱动里写的 msleep、wait_event、mutex_lock 几乎都是显式调度点。而 cond_resched() 则是“我能抢,但我请你先看看别人要不要用 CPU”的绅士之举。这种多层次的调度点设计让延迟可控,又不需要把所有代码全部推翻重写。但问题也来了:如果内核里某段代码既不关抢占、也不开中断,而是跑了一个超长的无锁循环,调度依然会卡死。延迟优化的下一步,就必须收拾这类“隐藏临界区”。
3. 优先级翻转与实时抢占的推进
就算内核允许抢占,另一个“延迟刺客”依然存在:优先级翻转。这是实时系统最著名的坑之一,也是推动 PREEMPT_RT 发展的重要动力。
3.1 一个经典例子:低优先级线程卡住高优先级线程
设想三个线程:T1 优先级最高,T2 中等,T3 最低。T3 先获得了一把 mutex,T1 随后也想拿这把锁,只能阻塞等待。此时 T2 活跃,它不需要这把锁,但它优先级比 T3 高,于是 CPU 被 T2 抢走,T3 一直没机会运行,锁就永远放不掉,T1 被无限期卡住。
这就是优先级反转的典型场景。最坏情况下,T1 的延迟不再是“微秒”级,而是“看 T2 的心情”级,这在工业控制、汽车电子里是不可接受的。
3.2 rt_mutex:用优先级继承解决反转
传统 mutex 不解决这个问题,但 rt_mutex(real-time mutex)引入了优先级继承机制:如果 T3 持有锁,而 T1 在等这把锁,T3 会临时继承 T1 的优先级。这样 T2 就无法抢占 T3,T3 得以尽快运行并释放锁。
优先级继承不是万能的——它只能解决“单层持有”场景,多层嵌套继承需要链式传递,复杂度会上升。但它的意义重大:把“等待锁的延迟”从不可预测变成可上界预测。PREEMPT_RT 的底层锁就是 rt_mutex,它保证了任何临界区的等待时间都有上限,而不是像自旋锁那样彻底放飞。
我实际写驱动时养成了一个习惯:实时任务里能用 rt_mutex 的锁,绝不退化成普通 spinlock。可能你在单核平台上看不出差别,但拿到 SMP 多核平台一压测,差距立刻原形毕露。
3.3 中断线程化:把“不可控的中断”变成可调度实体
中断处理是抢占延迟的另一个大头。传统 Linux 里,硬中断处理程序是不可调度的,它运行时优先级比所有进程都高,而且在很多体系结构上会屏蔽同级或更低级中断。如果中断处理程序里有大段耗时逻辑(比如网卡收包、块设备处理),所有实时任务都得在后面排队。
PREEMPT_RT 的思路是把中断处理拆成两部分:上半部仍然在硬中断上下文里,只做最必要的硬件通知;下半部(原 softirq)不再以软中断上下文运行,而是交给一个实时内核线程。这就是“中断线程化”。线程化之后,中断下半部变成了一个个可被优先级调度的实体,你可以给网卡中断线程设实时优先级,也可以让它被更高优先级的任务抢占。
这个设计在工程师视角看简直是“作弊”:它把内核里最不可控的一段代码(中断处理)拉回到调度器的管辖范围内。代价是中断响应吞吐量会略降,因为线程上下文切换有开销。但在“延迟敏感”场景,这笔交易非常划算。
4. 调度器本体:从 O(1) 到 CFS,再到 EEVDF
抢占机制负责“能不能早切”,调度器算法负责“切给谁”。两者共同决定延迟表现。调度器的演进也很有趣,每一步优化本质上都在重新定义“什么时候该抢”。
4.1 O(1) 调度器的功与过
O(1) 调度器在 2.6 早期引入,用多个优先级队列实现调度器本身的开销恒定,不再随着任务数线性增长。它把任务按优先级硬分到 140 个队列里,每个队列内部用轮转。
O(1) 的问题在于:相同优先级的任务之间没有公平性可言,靠时间片轮转,切片定死了,交互式任务响应容易变差。它的“调度延迟”表现主要看优先级队列是否为空,高优先级任务通常能很快被选中,但中低优先级任务之间的公平性和延迟细粒度都不理想。
4.2 CFS 的 vruntime:公平如何影响抢占
CFS(完全公平调度器)用虚拟时间 vruntime 取代了固定优先级队列。每个任务的 vruntime 随着实际运行时间增长,调度器总是选择 vruntime 最小的任务运行。用生活化类比:一群人在排队打饭,CFS 不看你是 VIP 还是普通用户,只看你已经打了多少饭,打得少的优先。
这个设计对延迟的影响很微妙。CFS 有一个调度周期 sched_latency,在一个周期内,所有可运行任务至少要各跑一次,所以每个任务获得的实际时间片大约是 sched_latency / 任务数。任务越多,单个时间片越短,切换越频繁,延迟越小但吞吐下降;任务越少,时间片越长,延迟和吞吐之间靠这个参数平衡。
我调优时经常动的是 /sys/kernel/debug/sched/ 下的参数,比如 sched_latency_ns。如果你的系统任务数量稳定且不希望延迟抖动,把这个值调小有帮助。但千万别无脑调到最小,否则上下文切换开销会吃掉 CPU,整体吞吐崩掉。
4.3 EEVDF:虚拟截止时间带来的新抢占逻辑
CFS 运行了很多年,但它在面对“突发任务”时,抢占时机仍然不够精准。CFS 只根据 vruntime 最小来选择任务,但一个任务已经等得够久,另一个任务新来但 vruntime 更小,就可能出现“新任务插队”的异常行为。
EEVDF(Earliest Eligible Virtual Deadline First)在 6.6 版本合入主线,它引入了一个“虚拟截止时间”。每个任务不仅有一个虚拟开始时间,还有一个虚拟截止时间,调度器优先选择截止时间最早的任务。
这意味着什么?抢占不再只看一个单调增长的 vruntime 差值,而是看任务距离自己“应该完成”的还有多远。直观结果是:新唤醒的任务不能随便插队,必须证明它能赶上截止时间,否则就老老实实排队。这让调度延迟变得更可预测,同时减少了对交互式任务的“不公平抢占”。我在 6.6 内核上跑过负载测试,明显感觉到最坏延迟的尾巴缩短了,不再像 CFS 旧版那样偶尔出现莫名其妙的长时间卡顿。
4.4 调度类优先级:RT 任务为什么抢占 CFS
除了 CFS,内核还有 RT 调度类和 deadline 调度类。RT 调度器使用优先级调度,只要 RT 任务就绪,CFS 任务立刻被踢出 CPU。而 deadline 调度器使用基于截止时间的调度,适用于周期性硬实时任务。
理解这层关系对排查延迟特别关键:如果你看到一个高优先级进程被延迟了,先看它是不是 CFS 里的普通进程。普通进程之间是公平竞争,延迟必然随负载变化;只有把关键任务放进 RT 或 deadline 调度类,它才有“见谁抢谁”的资格。
不过 RT 调度类也有风险:如果一个 RT 任务变成死循环,整个系统都会被它锁死,因为 CFS 任务永远没有机会运行。所以生产系统里,RT 任务必须经过严格的周期和耗尽可能分析,否则等于埋雷。
5. PREEMPT_RT:把抢占进行到底的主线化之路
如果说上面讲的都是“补丁式优化”,那 PREEMPT_RT 就是一场系统性的“内功改造”。它的目标是让内核本身在所有地方都能被抢占,而不是只靠 TIF_NEED_RESCHED 和 preempt_count 解决。
5.1 从外部补丁集到主线合入
PREEMPT_RT 是一套庞大的补丁集,维护了很多年。它和主线的关系是“长期并行、逐步合入”。很多机制现在已经主线化:
- 高精度定时器 hrtimer 已经替代传统 jiffies 定时器,提供了纳秒级的定时精度,不再受内核 tick 频率限制。
- 内核锁的改造:大量原本用 spinlock 保护的临界区,在 PREEMPT_RT 里被替换成 rt_mutex 或 local_lock。spinlock 本身在 RT 内核里也变成可睡眠的锁。
- RCU 的 preemptible 版本,解决 RCU 读端临界区不允许抢占的问题。
- 中断线程化,上面提过,是其中标志性的一步。
- 相关机制陆续合入主线,最终在 6.12 版本,主线已经实现了 PREEMPT_RT 最主要的语义。
我自己的经验是:如果你要评估 PREEMPT_RT 对项目的影响,直接看内核配置项。当 CONFIG_PREEMPT_RT 为 y 时,内核的很多锁行为会发生变化,不再是无条件关闭抢占或关闭中断,而是把临界区允许的范围压缩到极致。
5.2 配置 PREEMPT_RT 内核实践
配置一个 real-time 内核,关键不是把编译选项全部打开,而是理解它带来的连锁反应:
- CONFIG_PREEMPT_RT:启用全内核抢占能力。启用后,普通 spinlock 会变成可睡眠锁(rt_mutex 变体),原来持有锁期间关抢占的假设全部失效。
- CONFIG_HZ:实时系统里我通常倾向高频率 tick,比如 1000Hz,但更极端的做法是启用 NO_HZ_FULL,让任务所在 CPU 没有周期性 tick,对延迟有好处。
- CONFIG_IRQ_FORCED_THREADING:强制所有中断线程化。这个对实时很有用,但有些驱动依赖硬中断里的上下文,开启后需要仔细测试。
我踩过最深的坑是:某个老驱动,在中断处理里直接调用了 spin_lock_irqsave,PREEMPT_RT 内核把这些锁语义改了,结果驱动在临界区里睡眠,直接触发内核警告。这类问题不是配置错误,而是驱动代码本身没有按 RT 规则写。
5.3 启动参数与 CPU 隔离
除了内核编译配置,启动参数对延迟的影响同样巨大。我常用的几个参数:
- isolcpus:把指定 CPU 从调度器普通负载里隔离出来,专门跑实时任务。但注意,中断也会被分配到这些 CPU,你需要同步设置 irqaffinity。
- nohz_full:让隔离 CPU 关闭周期性 tick,减少定时器中断对实时任务的干扰。
- rcu_nocbs:把 RCU 回调从隔离 CPU 上挪走,防止 RCU 的软中断打断实时任务。
用这些参数时,我最常见的问题是:只隔离了 CPU,忘了设置中断亲和性,结果网卡中断还是跑到了实时 CPU 上,延迟照样抖动。所以每次调优,我第一件事就是看 /proc/interrupts,确认中断到底落在哪个核上。
5.4 实测数据:配置前后差距
拿我自己常做的 cyclictest 来说。在同一个硬件平台上,分别跑默认发行版内核和 PREEMPT_RT 内核:
- 默认内核:平均延迟 80 微秒,最大延迟 5 毫秒甚至更高。最大延迟主要来自网络中断处理和不可抢占的内核代码段。
- PREEMPT_RT 内核:平均延迟 20 微秒,最大延迟通常在几百微秒以内,如果配合 CPU 隔离,最坏情况能稳定在一百微秒上下。
这些数字不必死记,因为不同硬件差异很大。但趋势是一致的:PREEMPT_RT 减少的不是平均延迟,而是最坏延迟的上界。这才是实时系统的命门——性能再快,只要最坏延迟不可预测,就无法保证任务在截止时间内完成。
6. 排查延迟问题的工具和方法
配置做完,接下来就是把问题定位到具体代码路径。延迟排查靠眼睛看是看不出来的,必须用工具。
6.1 ftrace 追踪抢占关闭时间
ftrace 提供了几个好用的追踪器。我最常开的是 preemptirqsoff,它记录 CPU 上抢占被关闭或中断被关闭的时间。
用法大致如下:
cd /sys/kernel/debug/tracing echo 0 > tracing_on echo preemptirqsoff > current_tracer echo 1000 > buffer_size_kb echo 1 > tracing_on # 在这里复现延迟场景 cat tracetrace 输出里会显示哪个函数关掉了抢占、持续了多久、在哪里恢复。如果看到某个驱动函数关了抢占几千微秒,基本可以直接定位到罪魁祸首。我遇到过一个 USB 控制器驱动,它的中断处理里关中断长达数百微秒,导致所有实时任务集体翻车,就是靠 preemptirqsoff 找出来的。
6.2 wakeup tracer 看唤醒路径
当高优先级任务被唤醒到真正运行之间的延迟过高,用 wakeup tracer 分析唤醒链路上的瓶颈。它能显示出唤醒任务的路径、以及关联的中断和锁等待。
6.3 延迟问题的常见原因速查
| 症状 | 可能原因 | 排查手段 |
|---|---|---|
| 最大延迟周期性出现 | 周期性 tick 中断 | 开启 nohz_full,确认 CPU 隔离 |
| 网络流量大时延迟暴增 | 网卡中断和软中断占用 CPU | 调整 irqaffinity,开启中断线程化 |
| 任务高优先级但仍被卡 | 内核不可抢占临界区 | preemptirqsoff 定位关闭抢占函数 |
| 锁等待导致延迟 | 优先级反转/锁竞争 | 检查锁类型,RT 任务用 rt_mutex |
| 功耗管理导致延迟毛刺 | CPU 进入了低功耗状态 | 启用 performance governor,禁用 deep idle |
| RCU 软中断拖慢任务 | RCU 回调堆积在隔离 CPU | 使用 rcu_nocbs 并设置 rcuo 线程优先级 |
这个表我一直贴在工位上。拿到一个延迟问题,先按表格里的方向去查,大概率能快速定位,而不是无头苍蝇一样到处试参数。
6.4 终极手段:让驱动也遵守实时规则
最后想强调一件事:内核调度器做到极致,如果驱动写得不好,延迟依然会爆。PREEMPT_RT 可以把内核自身的大部分不可抢占代码变短,但驱动里如果还有关中断几百微秒、或者持锁期间做耗时操作,谁也没办法救。
所以我在开发驱动时给自己定下几条硬规矩:
- 中断处理程序只做必要硬件操作,想都不要想数据解析和协议处理。
- 持锁期间禁止调用任何可能睡眠的函数,包括 printk。
- 可能睡眠的长路径必须使用 rt_mutex,而不是 spinlock。
- 实时任务用 sched_setscheduler 设置为 SCHED_FIFO 或 SCHED_DEADLINE,但优先级不能乱设,必须和任务周期匹配。
7. 写在最后:一点个人实操体会
这些年在 Linux 延迟优化上踩了无数坑,最深的体会是:延迟问题永远不会只有一个“银弹”。调度器算法、抢占配置、中断处理、驱动临界区、CPU 隔离、锁类型,任何一环掉链子,都会成为延迟链条上最粗的那截木棍。与其迷信某个新内核特性的宣传,不如老老实实拿 cyclictest 压一轮、拿 preemptirqsoff 扫一遍,把问题定位到具体的代码路径上。
如果你正准备在自己的系统里上 PREEMPT_RT,我给的建议只有一个:不要直接在生产环境启用它。先在一模一样的硬件上,把默认内核和 RT 内核的延迟分布测出来,确认 RT 内核确实带来了可预测的上界;再逐项开启 CPU 隔离、中断线程化和锁优化,每开一项就做一次 A/B 对比。真正上线之后,再多留几个 tracepoint,方便哪天问题出现时能按图索骥。
Linux 抢占这条漫漫长路走了这么多年,主线内核已经给了我们足够多的武器。剩下的功夫,基本上就是看你愿不愿意把每个临界区、每一条中断路径都抠干净了。