Linux 性能调优系列(十五): CPU 到底该先给谁?搞懂 Linux 实时调度
1 | 作者:李晓辉 |
上一篇我们学习了 CPU 调度类别、调度策略、EEVDF 公平调度器,以及 nice、renice、chrt 的基本用法。普通业务进程使用 SCHED_OTHER 通常就足够了。但是有些场景对延迟非常敏感,比如:
1 | 工业控制 |
这些任务关心的并不是:
CPU 分得够不够公平?
而是:
关键任务能不能及时获得 CPU?
这时候,就需要:
Linux 实时调度。
什么是实时调度?
实时调度的目标,不是简单地追求”运行得最快”,而是:
让任务的响应时间更加可预测。
普通公平调度主要关心:
1 | 任务之间是否公平 |
实时调度更加关注:
1 | 高优先级任务能不能及时运行? |
比如一个每 10ms 处理一次数据的采集任务:
1 | 第 1 次:8ms 完成 |
平均值并不差,但第 4 次的 35ms 就可能已经造成事故。所以实时系统看的不是平均值,而是最坏情况。
Linux 中常见的实时相关调度策略包括:
1 | SCHED_FIFO |
其中:
SCHED_FIFO:固定优先级 + 先进先出;SCHED_RR:固定优先级 + 时间片轮转;SCHED_DEADLINE:根据 runtime、deadline、period 进行截止时间调度。
flowchart TD
A[实时任务] --> B[SCHED_FIFO]
A --> C[SCHED_RR]
A --> D[SCHED_DEADLINE]
B --> E[固定优先级<br/>FIFO]
C --> F[固定优先级<br/>时间片轮转]
D --> G[runtime<br/>deadline<br/>period]先看全局:调度类之间谁能压谁?
在讲具体策略之前,先建立一个重要概念:Linux 内核里有”调度类”,类与类之间是绝对的上下级关系。
flowchart TB
A["stop 类<br/>内核内部任务,如 CPU 迁移"] --> B["deadline 类<br/>SCHED_DEADLINE"]
B --> C["rt 类<br/>SCHED_FIFO / SCHED_RR"]
C --> D["fair 类<br/>SCHED_OTHER / BATCH"]
D --> E["idle 类<br/>SCHED_IDLE"]记住一句话:
deadline 类 > rt 类 > fair 类。
这不是 nice 那种”多分一点、少分一点”,而是:只要高一级的类里有任务可运行,低一级的类就得让路(除非触发后面要讲的限流或兜底机制)。
SCHED_FIFO:先进先出实时调度
SCHED_FIFO 是最简单的实时调度策略。它没有普通任务那种周期性的时间片轮转。
一个 SCHED_FIFO 线程获得 CPU 后,只要它:
1 | 没有阻塞 |
就可以继续运行。但是,如果有更高实时优先级的线程变成可运行状态,它会立即抢占当前线程。同一实时优先级下,则按照 FIFO 顺序运行。
例如:
1 | 任务 A:priority 80 |
如果 A 正在运行,此时 C 变成 Runnable,C 会立即抢占 A。而 B 只能继续等待。
flowchart TD
S["有 RT 任务变成可运行"] --> Q{"优先级比当前任务高?"}
Q -- 是 --> P["立刻抢占"]
Q -- 同优先级 --> T{"当前任务的策略?"}
T -- SCHED_FIFO --> W["排队,直到对方主动让出"]
T -- SCHED_RR --> R["对方时间片用完后轮到我"]
Q -- 更低 --> L["继续等待"]一个容易被忽略的前提:上面”B 只能继续等待”,指的是 A 和 B 在同一个 CPU 的运行队列上。在多核机器上,内核有 RT 任务的 push/pull 机制,B 往往会被迁移到别的空闲 CPU 上去跑。所以真正出问题的,经常是绑核或CPU 隔离的场景:B 没处可去,只能干等。
SCHED_FIFO 的风险
如果一个 SCHED_FIFO 任务长期不阻塞、不让出 CPU,同一 CPU 上其他低优先级任务就可能长时间得不到运行机会。
因此:
SCHED_FIFO 很强,但也很容易被滥用。
SCHED_RR:时间片轮转
SCHED_RR 和 SCHED_FIFO 非常相似。最大的区别就是:
同一实时优先级下,SCHED_RR 会进行时间片轮转。
例如三个 priority 50 的任务,会按 A → B → C → A → B → C 不断轮转。
| 特性 | SCHED_FIFO | SCHED_RR |
|---|---|---|
| 高优先级抢占 | 支持 | 支持 |
| 同优先级轮转 | 不支持 | 支持 |
| 同优先级多任务 | 需要谨慎 | 更适合 |
| 时间片 | 无普通意义上的时间片 | 有 |
注意:SCHED_RR 的时间片是实时调度类内部的机制,和 Fair 调度的时间片不是一回事。可以这样查看:
1 | cat /proc/sys/kernel/sched_rr_timeslice_ms |
默认通常是 100ms。
经验:拿不准的时候,同优先级多任务用 SCHED_RR,更不容易被某一个任务长期霸占。
实时优先级
Linux 实时调度策略使用 1 ~ 99 的实时优先级,99 最高,1 最低。
1 | # 查看优先级范围 |
输出里 FF 表示 SCHED_FIFO,RR 表示 SCHED_RR,TS 表示普通调度,此时 rtprio 显示 -。
1 | PID TID COMMAND RTPRIO POL |
请注意上面这两行。内核自己的关键线程,比如 migration/N、watchdog/N,本身就跑在 99。
这就是为什么不建议业务任务设成 99:
你的任务如果也是 99,就会和负责 CPU 迁移、软锁死检测的内核线程平起平坐,甚至把它们挡在门外。系统一旦出问题,连”自救”的能力都会受影响。
**实时优先级不是越高越好。**建议:
- 从较低优先级开始,确认存在延迟问题后再逐步提高;
- 业务任务一般放在 10 ~ 80 之间,留出层次;
- 把优先级设计写进文档,比如”数据采集 60 > 数据处理 50 > 辅助线程 40”,不要靠口头约定。
使用 chrt 管理实时任务
查看某个线程:
1 | chrt -p 12345 |
设置已有线程为 SCHED_FIFO,优先级 50:
1 | chrt -f -p 50 12345 |
设置已有线程为 SCHED_RR,优先级 40:
1 | chrt -r -p 40 12345 |
恢复普通调度:
1 | chrt -o -p 0 12345 |
如果是启动一个新程序,则不需要 -p:
1 | chrt -f 50 ./my_realtime_task |
**两种语法不能混用:**改已有进程是 -p <优先级> <PID>,启动新进程是 <优先级> <命令>。
子进程会继承实时策略
默认情况下,子进程会继承父进程的实时策略。如果不希望 fork 出一堆实时子进程,可以加 -R(reset-on-fork):
1 | chrt -R -f 50 ./my_realtime_task |
权限
设置实时策略不是只有 root 才行,有三种方式:
1 | 1. root |
例如:
1 | appuser - rtprio 50 |
生产环境:交给 systemd
手动 chrt 的设置在进程重启后就丢了。生产环境应该写进 unit 文件:
1 | [Service] |
实时限流:防止实时任务把普通任务饿死
实时任务优先级很高。如果实时任务一直运行,就可能出现:
1 | 实时任务 → 持续占用 CPU → 普通任务长时间得不到 CPU |
Linux 因此提供了实时任务 CPU 带宽限制:
1 | kernel.sched_rt_period_us |
1 | sysctl kernel.sched_rt_period_us |
典型默认值:
1 | sched_rt_period_us = 1000000 |
可以理解为:每 1 秒的周期里,整个 RT 调度类(FIFO 和 RR)累计最多使用约 0.95 秒 CPU 时间,剩下约 0.05 秒留给普通任务。
pie title 默认配置下一个周期的时间分配
"RT 调度类上限" : 95
"留给普通任务" : 5这里有两个容易理解错的点:
- 它不是单个任务的限额,而是 RT 调度类整体的配额;
- 它是周期内的累计用量,不是”连续运行”的时间。
触发限流时,内核日志里会出现类似:
1 | sched: RT throttling activated |
看到这行日志,说明实时任务已经把配额用光了。不要把它当噪音,要去查谁在狂吃 CPU。
修改配置
1 | # 临时修改 |
设成 -1 会怎样?
1 | kernel.sched_rt_runtime_us = -1 |
表示关闭实时任务的 CPU 带宽限制,等于撤掉了安全网。生产环境不要为了”让实时任务跑得更快”就随便关闭它,除非是在充分隔离的 CPU 上运行经过压测的任务。
兜底机制的变化:Deadline Server
较新的内核(RHEL 10 基于 6.12)引入了 fair server(Deadline Server):内核通过 deadline 机制,给普通 Fair 任务保留一小段兜底运行时间,减少被 RT/DL 任务长期挤压的情况。这也影响了后面 stalld 的定位。
SCHED_DEADLINE:按照截止时间调度
SCHED_FIFO 和 SCHED_RR 主要回答:
谁优先运行?
而 SCHED_DEADLINE 更进一步关注:
任务应该在什么时候之前完成?
它使用三个核心参数:
| 参数 | 含义 |
|---|---|
runtime | 每个周期可以获得的 CPU 执行时间 |
deadline | 相对截止时间 |
period | 任务周期 |
例如 runtime=3ms, deadline=5ms, period=10ms:
flowchart LR
A["t=0ms<br/>任务释放"] --> B["最多执行 3ms"]
B --> C["t=5ms<br/>截止时间,必须完成"]
C --> D["t=10ms<br/>下一个周期开始"]这个任务的 CPU 利用率是 3/10 = 30%。
内核怎么调度:EDF + CBS
- EDF(最早截止时间优先):谁的截止时间最近,谁先运行;
- CBS(恒定带宽服务器):每个任务有预算,用完 runtime 就被节流到下个周期,防止单个任务拖垮系统。
所以 DEADLINE 比 FIFO 更”安全”:死循环的 FIFO 任务会占满 CPU,死循环的 DEADLINE 任务只会被按预算掐断。
使用 chrt 设置
1 | chrt -d \ |
单位是 ns。注意最后的 0:SCHED_DEADLINE 不使用优先级,但 chrt 的语法要求这个位置必须有一个优先级参数,只能填 0。漏掉它,./my_task 会被当成优先级解析,报 invalid priority argument。
周期任务要主动交还 CPU
每个周期的工作做完后,任务应该调用 sched_yield(),告诉内核”这一轮结束了”,把剩余预算让出去,等下个周期再运行。只会死循环的程序,会在 runtime 用完后被节流。
准入控制:为什么有时候起不来
内核在设置 SCHED_DEADLINE 时会做两层检查:
1 | 1. 单任务合法性:runtime <= deadline <= period,否则返回 EINVAL |
这里有个关键细节:全局上限复用的就是上一节的 sched_rt_runtime_us / sched_rt_period_us(再乘以 CPU 数)。
也就是说:
任务启动报
EBUSY时,不一定是你的参数有问题,也可能是系统的实时带宽上限不够。调整sched_rt_runtime_us会同时影响 RT 任务和 DEADLINE 任务。
不等于硬实时
SCHED_DEADLINE并不等于”配置了它就是硬实时系统”。
真正的实时保证还取决于:
1 | 调度器 |
更准确的说法是:
SCHED_DEADLINE 为具有明确时间约束的任务提供了截止时间调度机制。
stalld:专治”被实时任务饿死的线程”
先纠正一个很容易理解反的地方:stalld 不是帮实时任务的,它救的是被实时任务压住的线程。
典型场景:你隔离了一个 CPU,上面跑一个 SCHED_FIFO 的忙等轮询线程(网络转发、DPDK 里很常见),它从不睡眠。结果同一个 CPU 上的 kworker、ksoftirqd 等线程一直拿不到 CPU,内核内部的工作越积越多,最终出现各种诡异问题。
stalld(stall daemon)会周期性扫描各 CPU 的运行队列,寻找长时间处于 Runnable、却没有被调度的线程,然后:
flowchart LR
A["周期性扫描运行队列"] --> B{"有线程超过阈值<br/>仍没有运行?"}
B -- 否 --> A
B -- 是 --> C["临时提权<br/>(短时间的 DEADLINE 等)"]
C --> D["线程跑一小会儿<br/>处理积压的工作"]
D --> E["恢复原调度属性"]
E --> A1 | # 仓库和订阅随版本不同,以你的环境为准 |
阈值等参数可以调整,参见 man stalld。
需要注意两点:
**1. stalld 是兜底手段,不是正解。**如果它频繁介入,说明实时任务设计有问题,比如该睡眠的地方在忙等,或者隔离核上混进了不该有的线程。
**2. 它是否必装,要看版本和负载。**RHEL 10 内核已经有 Deadline Server 为普通任务提供兜底,是否还需要 stalld,要结合你使用普通内核还是 RT 内核,以及具体的实时负载来判断。
PREEMPT_DYNAMIC:内核抢占
前面讲的策略,主要讨论的是用户任务怎么调度。但是任务进入内核态以后,还有一个问题:
内核代码什么时候允许被抢占?
实时任务就绪后,如果 CPU 正在执行一段不可抢占的内核代码,调度器就必须等它执行完才能切换,这段等待就是延迟。
现代内核可以通过 PREEMPT_DYNAMIC 在不同抢占模型之间选择:
| 模式 | 特点 | 取舍 |
|---|---|---|
none | 内核代码基本不被强制抢占 | 吞吐最高,延迟抖动最大,服务器的经典选择 |
voluntary | 只在显式的抢占点让出 | 折中 |
full | 内核大部分位置可被抢占 | 延迟更低,吞吐略降 |
查看与切换
先确认内核是否支持:
1 | grep PREEMPT /boot/config-$(uname -r) |
查看当前模式(需要 root,且 debugfs 已挂载;括号标出的是当前模式):
1 | cat /sys/kernel/debug/sched/preempt |
提示:在开启 Secure Boot 锁定模式的环境中,debugfs 可能不可读写,这时只能通过启动参数设置。
永久生效,用 grubby 修改启动参数:
1 | grubby --update-kernel=ALL --args="preempt=full" |
两个常见误区
**误区一:full 一定更好。**它是在吞吐量、调度延迟和抢占开销之间取舍,是否调整要用实际延迟指标验证。
**误区二:
preempt=full就是实时内核。**不是。full只是让更多内核路径可被抢占,但自旋锁临界区和硬中断仍然不可打断。这正是下一节 RT 内核要解决的问题。
RHEL Real-Time 实时内核
如果 full 抢占仍然满足不了最坏延迟指标,就轮到 RT 内核了。它的本质是 PREEMPT_RT,主要改动有三点:
flowchart TB
RT["PREEMPT_RT 内核"] --> A["自旋锁变成可睡眠的锁<br/>临界区内也能被抢占"]
RT --> B["中断处理线程化<br/>中断变成可调度的线程"]
RT --> C["优先级继承<br/>避免优先级反转"]
A --> R["最坏延迟显著降低"]
B --> R
C --> R为什么这三点重要:
- 自旋锁可睡眠:标准内核里,持有自旋锁时不能被抢占,实时任务只能等;
- 中断线程化:标准内核里,硬中断不管你优先级多高都会打断你;线程化后,中断也要按优先级排队;
- 优先级继承:低优先级任务持锁、高优先级任务等锁时,低优先级任务会临时”借”到高优先级,避免被中间优先级任务插队(即优先级反转)。
安装与验证
1 | # 仓库名随版本和订阅变化,以你的环境为准 |
一个常被忽略的坑
RT 内核里,中断线程默认就是 SCHED_FIFO 优先级 50。如果你的业务线程优先级设成 40,就会被网卡中断线程压着打。设计优先级之前,先看清内核线程的分布:
1 | ps -eo pid,comm,cls,rtprio | grep -E "irq|FF" |
不要盲目使用
1 | 收益:最坏延迟显著降低,行为更可预测 |
RT 内核不是普通业务的”性能加速开关”。对 Nginx、MySQL、Redis 这类业务,装了 RT 内核不一定更快。判断标准只有一个:你的业务有没有明确的、用数字写出来的延迟指标。没有,就别用。
用数据说话:cyclictest
实时调优不能凭感觉。cyclictest(来自 rt-tests)专门测量调度延迟:
1 | cyclictest -m -p 95 -a 2-7 -t -D 10m -q |
重点看 Max(最坏延迟),平均值在实时系统里几乎没有意义。每次改完配置,都要对比 Max 值。
实时任务出现延迟怎么排查?
flowchart TD
A[实时任务延迟] --> B{"调度策略和优先级<br/>真的生效了吗?<br/>chrt -p PID"}
B -- 没有 --> B1["检查 systemd 配置<br/>权限 / rtprio 限额"]
B -- 有 --> C{"dmesg 有<br/>RT throttling?"}
C -- 有 --> C1["查谁用光了配额<br/>sched_rt_runtime_us 是否被改"]
C -- 没有 --> D{"被更高优先级<br/>RT 任务抢占?"}
D -- 是 --> D1["重新规划优先级<br/>留意内核线程"]
D -- 否 --> E{"中断落在同一个 CPU?<br/>/proc/interrupts"}
E -- 是 --> E1["IRQ 亲和性绑到其他核"]
E -- 否 --> F["检查 CPU 隔离 / 内存锁定<br/>抢占模式 / RT 内核"]第一步:确认调度策略
1 | ps -eo pid,tid,comm,cls,rtprio,psr |
确认任务到底是不是 SCHED_FIFO、SCHED_RR 或 SCHED_DEADLINE。psr 列可以看到线程当前跑在哪个 CPU 上。
第二步:检查实时 CPU 带宽限制
1 | sysctl kernel.sched_rt_period_us kernel.sched_rt_runtime_us |
确认有没有被改成 -1,或者已经触发了限流。
第三步:检查高优先级任务
1 | 谁的优先级更高? |
第四步:检查 CPU 和中断
1 | top |
某个 CPU 负载很高或中断非常频繁,都会影响实时任务。
第五步:检查 CPU 亲和性和隔离
1 | taskset -cp PID |
对于高实时性要求的任务,还需要结合:
1 | CPU affinity |
这已经从单纯的”调度策略”进入完整的低延迟系统优化。
生产环境几个重要注意事项
**不要把所有任务都设置成实时。**滥用会导致普通业务饿死、系统管理任务得不到 CPU、问题更难排查。
**不要随便把优先级设置成 99。**内核的 migration、watchdog 线程就在 99,业务任务与它们抢占,会影响系统自我保护。
**不要随便关闭实时限流。**尤其不要为了”提高实时性能”直接设 kernel.sched_rt_runtime_us = -1。
**不要把 SCHED_DEADLINE 等同于硬实时。**真正的硬实时,需要考虑整个系统的最坏情况延迟。
**不要靠手工 chrt。**用 systemd 固化配置。
**不要凭感觉优化。**用 cyclictest 的 Max 值对比前后效果。
把三种实时调度策略串起来
flowchart TD
A[实时任务] --> B{采用什么调度方式}
B --> C[SCHED_FIFO]
B --> D[SCHED_RR]
B --> E[SCHED_DEADLINE]
C --> C1[固定优先级]
C1 --> C2[同优先级 FIFO]
D --> D1[固定优先级]
D1 --> D2[同优先级时间片轮转]
E --> E1[runtime]
E --> E2[deadline]
E --> E3[period]1 | SCHED_FIFO |
系列小结
| 调度策略 | 类型 | 核心特点 | 常见场景 |
|---|---|---|---|
SCHED_OTHER | 普通 | 默认公平调度 | 普通业务 |
SCHED_BATCH | 普通 | 假定任务是 CPU 密集型,减少唤醒抢占 | 离线计算、批处理 |
SCHED_IDLE | 普通 | 极低优先级 | 低优先级后台任务 |
SCHED_FIFO | 实时 | 固定优先级,同优先级 FIFO | 对响应延迟敏感的短任务 |
SCHED_RR | 实时 | 固定优先级,同优先级时间片轮转 | 多个同优先级实时任务 |
SCHED_DEADLINE | 实时 | EDF + CBS,按 runtime/deadline/period 调度 | 有明确时间约束的任务 |
如果这一篇只记住一句话:
普通调度解决的是”CPU 怎么公平地分”;实时调度解决的是”关键任务怎么更及时、更可预测地获得 CPU”。
而真正的实时优化,也绝不仅仅是执行一条 chrt -f 99 ... 就结束了:
1 | 调度策略 → 优先级 → CPU 亲和性 → CPU 隔离 → 中断 → 内存 → 抢占模型 → 实时内核 → 实测验证 |
下一步继续往下深入,可以研究:
为什么实时任务明明优先级很高,却还是可能出现延迟?
这时候,就要进入 CPU affinity、CPU isolation、IRQ affinity、内存锁定、优先级反转这些真正影响 Linux 实时性能的内容。
课后思考
- 一台 8 核机器,
sched_rt_runtime_us=950000。如果每个 DEADLINE 任务是runtime=4ms, period=10ms,最多能放几个?(提示:计算利用率总和与全局上限) - 为什么
preempt=full仍然不等于 PREEMPT_RT? - 隔离核上跑着忙等的 FIFO 线程,为什么还需要 stalld?这反映了设计上的什么问题?
参考手册
1 | man chrt |
