1
2
3
4
5
6
7
作者:李晓辉

联系方式:

1. 微信:Lxh_Chat

2. 邮箱:939958092@qq.com

上一篇我们学习了 CPU 调度类别、调度策略、EEVDF 公平调度器,以及 nice、renice、chrt 的基本用法。普通业务进程使用 SCHED_OTHER 通常就足够了。但是有些场景对延迟非常敏感,比如:

1
2
3
4
5
工业控制
数据采集
机器人
网络处理
多媒体处理

这些任务关心的并不是:

CPU 分得够不够公平?

而是:

关键任务能不能及时获得 CPU?

这时候,就需要:

Linux 实时调度。


什么是实时调度?

实时调度的目标,不是简单地追求”运行得最快”,而是:

让任务的响应时间更加可预测。

普通公平调度主要关心:

1
2
3
任务之间是否公平
系统吞吐量
交互响应

实时调度更加关注:

1
2
3
高优先级任务能不能及时运行?
任务的调度延迟是否可控?
任务能不能在规定时间内完成?

比如一个每 10ms 处理一次数据的采集任务:

1
2
3
4
第 1 次:8ms 完成
第 2 次:11ms 完成
第 3 次:7ms 完成
第 4 次:35ms 完成

平均值并不差,但第 4 次的 35ms 就可能已经造成事故。所以实时系统看的不是平均值,而是最坏情况。

Linux 中常见的实时相关调度策略包括:

1
2
3
SCHED_FIFO
SCHED_RR
SCHED_DEADLINE

其中:

  • 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
2
3
没有阻塞
没有退出
没有主动让出 CPU(如 sched_yield)

就可以继续运行。但是,如果有更高实时优先级的线程变成可运行状态,它会立即抢占当前线程。同一实时优先级下,则按照 FIFO 顺序运行。

例如:

1
2
3
任务 A:priority 80
任务 B:priority 80
任务 C:priority 90

如果 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_FIFOSCHED_RR
高优先级抢占支持支持
同优先级轮转不支持支持
同优先级多任务需要谨慎更适合
时间片无普通意义上的时间片有

注意:SCHED_RR 的时间片是实时调度类内部的机制,和 Fair 调度的时间片不是一回事。可以这样查看:

1
cat /proc/sys/kernel/sched_rr_timeslice_ms

默认通常是 100ms。

经验:拿不准的时候,同优先级多任务用 SCHED_RR,更不容易被某一个任务长期霸占。


实时优先级

Linux 实时调度策略使用 1 ~ 99 的实时优先级,99 最高,1 最低。

1
2
3
4
5
# 查看优先级范围
chrt -m

# 查看线程调度策略
ps -eo pid,tid,comm,rtprio,policy

输出里 FF 表示 SCHED_FIFO,RR 表示 SCHED_RR,TS 表示普通调度,此时 rtprio 显示 -。

1
2
3
PID     TID COMMAND         RTPRIO POL
19 19 migration/0 99 FF
20 20 idle_inject/0 50 FF

请注意上面这两行。内核自己的关键线程,比如 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
2
chrt -f 50 ./my_realtime_task
chrt -r 40 ./my_realtime_task

**两种语法不能混用:**改已有进程是 -p <优先级> <PID>,启动新进程是 <优先级> <命令>。

子进程会继承实时策略

默认情况下,子进程会继承父进程的实时策略。如果不希望 fork 出一堆实时子进程,可以加 -R(reset-on-fork):

1
chrt -R -f 50 ./my_realtime_task

权限

设置实时策略不是只有 root 才行,有三种方式:

1
2
3
1. root
2. 具备 CAP_SYS_NICE 能力
3. 在 /etc/security/limits.conf 中给用户配置 rtprio 限额

例如:

1
appuser  -  rtprio  50

生产环境:交给 systemd

手动 chrt 的设置在进程重启后就丢了。生产环境应该写进 unit 文件:

1
2
3
4
[Service]
CPUSchedulingPolicy=fifo
CPUSchedulingPriority=50
CPUSchedulingResetOnFork=yes

实时限流:防止实时任务把普通任务饿死

实时任务优先级很高。如果实时任务一直运行,就可能出现:

1
实时任务 → 持续占用 CPU → 普通任务长时间得不到 CPU

Linux 因此提供了实时任务 CPU 带宽限制:

1
2
kernel.sched_rt_period_us
kernel.sched_rt_runtime_us
1
2
sysctl kernel.sched_rt_period_us
sysctl kernel.sched_rt_runtime_us

典型默认值:

1
2
sched_rt_period_us  = 1000000
sched_rt_runtime_us = 950000

可以理解为:每 1 秒的周期里,整个 RT 调度类(FIFO 和 RR)累计最多使用约 0.95 秒 CPU 时间,剩下约 0.05 秒留给普通任务。

pie title 默认配置下一个周期的时间分配
    "RT 调度类上限" : 95
    "留给普通任务" : 5

这里有两个容易理解错的点:

  • 它不是单个任务的限额,而是 RT 调度类整体的配额;
  • 它是周期内的累计用量,不是”连续运行”的时间。

触发限流时,内核日志里会出现类似:

1
sched: RT throttling activated

看到这行日志,说明实时任务已经把配额用光了。不要把它当噪音,要去查谁在狂吃 CPU。

修改配置

1
2
3
4
5
6
7
8
9
10
# 临时修改
sysctl -w kernel.sched_rt_runtime_us=900000

# 永久修改(RHEL 系推荐放到 sysctl.d)
cat > /etc/sysctl.d/90-rt.conf <<'EOF'
kernel.sched_rt_period_us = 1000000
kernel.sched_rt_runtime_us = 900000
EOF

sysctl --system

设成 -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
2
3
4
5
chrt -d \
--sched-runtime 3000000 \
--sched-deadline 5000000 \
--sched-period 10000000 \
0 ./my_task

单位是 ns。注意最后的 0:SCHED_DEADLINE 不使用优先级,但 chrt 的语法要求这个位置必须有一个优先级参数,只能填 0。漏掉它,./my_task 会被当成优先级解析,报 invalid priority argument。

周期任务要主动交还 CPU

每个周期的工作做完后,任务应该调用 sched_yield(),告诉内核”这一轮结束了”,把剩余预算让出去,等下个周期再运行。只会死循环的程序,会在 runtime 用完后被节流。

准入控制:为什么有时候起不来

内核在设置 SCHED_DEADLINE 时会做两层检查:

1
2
1. 单任务合法性:runtime <= deadline <= period,否则返回 EINVAL
2. 全局带宽:所有 DEADLINE 任务的 runtime/period 之和不能超过上限,否则返回 EBUSY

这里有个关键细节:全局上限复用的就是上一节的 sched_rt_runtime_us / sched_rt_period_us(再乘以 CPU 数)。

也就是说:

任务启动报 EBUSY 时,不一定是你的参数有问题,也可能是系统的实时带宽上限不够。调整 sched_rt_runtime_us 会同时影响 RT 任务和 DEADLINE 任务。

不等于硬实时

SCHED_DEADLINE 并不等于”配置了它就是硬实时系统”。

真正的实时保证还取决于:

1
2
3
4
5
6
7
8
调度器
CPU 抢占
中断
CPU 亲和性
内存
I/O
硬件
最坏情况延迟

更准确的说法是:

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 --> A
1
2
3
4
# 仓库和订阅随版本不同,以你的环境为准
dnf install stalld -y
systemctl enable --now stalld
systemctl status stalld

阈值等参数可以调整,参见 man stalld。

需要注意两点:

**1. stalld 是兜底手段,不是正解。**如果它频繁介入,说明实时任务设计有问题,比如该睡眠的地方在忙等,或者隔离核上混进了不该有的线程。

**2. 它是否必装,要看版本和负载。**RHEL 10 内核已经有 Deadline Server 为普通任务提供兜底,是否还需要 stalld,要结合你使用普通内核还是 RT 内核,以及具体的实时负载来判断。


PREEMPT_DYNAMIC:内核抢占

前面讲的策略,主要讨论的是用户任务怎么调度。但是任务进入内核态以后,还有一个问题:

内核代码什么时候允许被抢占?

实时任务就绪后,如果 CPU 正在执行一段不可抢占的内核代码,调度器就必须等它执行完才能切换,这段等待就是延迟。

现代内核可以通过 PREEMPT_DYNAMIC 在不同抢占模型之间选择:

模式特点取舍
none内核代码基本不被强制抢占吞吐最高,延迟抖动最大,服务器的经典选择
voluntary只在显式的抢占点让出折中
full内核大部分位置可被抢占延迟更低,吞吐略降

查看与切换

先确认内核是否支持:

1
2
grep PREEMPT /boot/config-$(uname -r)
# 看到 CONFIG_PREEMPT_DYNAMIC=y 才支持动态切换

查看当前模式(需要 root,且 debugfs 已挂载;括号标出的是当前模式):

1
cat /sys/kernel/debug/sched/preempt

提示:在开启 Secure Boot 锁定模式的环境中,debugfs 可能不可读写,这时只能通过启动参数设置。

永久生效,用 grubby 修改启动参数:

1
2
3
grubby --update-kernel=ALL --args="preempt=full"
reboot
cat /proc/cmdline # 验证

两个常见误区

**误区一: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
2
3
4
5
6
7
8
9
10
# 仓库名随版本和订阅变化,以你的环境为准
dnf install kernel-rt tuned-profiles-realtime rt-tests

# 把隔离给实时任务的 CPU 写进配置
echo "isolated_cores=2-7" >> /etc/tuned/realtime-variables.conf
tuned-adm profile realtime
reboot

# 验证
cat /sys/kernel/realtime # 输出 1 表示 RT 内核

一个常被忽略的坑

RT 内核里,中断线程默认就是 SCHED_FIFO 优先级 50。如果你的业务线程优先级设成 40,就会被网卡中断线程压着打。设计优先级之前,先看清内核线程的分布:

1
ps -eo pid,comm,cls,rtprio | grep -E "irq|FF"

不要盲目使用

1
2
收益:最坏延迟显著降低,行为更可预测
代价:吞吐量可能下降,第三方驱动和软件需要验证兼容性,调试和维护成本更高

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
2
ps -eo pid,tid,comm,cls,rtprio,psr
chrt -p PID

确认任务到底是不是 SCHED_FIFO、SCHED_RR 或 SCHED_DEADLINE。psr 列可以看到线程当前跑在哪个 CPU 上。

第二步:检查实时 CPU 带宽限制

1
2
sysctl kernel.sched_rt_period_us kernel.sched_rt_runtime_us
dmesg | grep -i throttl

确认有没有被改成 -1,或者已经触发了限流。

第三步:检查高优先级任务

1
2
3
4
谁的优先级更高?
是否存在任务长期占用 CPU?
是否存在低优先级任务长期饥饿?
是否被内核线程(包括 RT 内核里的中断线程)压住?

第四步:检查 CPU 和中断

1
2
3
top
mpstat -P ALL 1
cat /proc/interrupts

某个 CPU 负载很高或中断非常频繁,都会影响实时任务。

第五步:检查 CPU 亲和性和隔离

1
taskset -cp PID

对于高实时性要求的任务,还需要结合:

1
2
3
4
CPU affinity
CPU isolation
IRQ affinity
内存锁定(mlockall,避免缺页造成延迟)

这已经从单纯的”调度策略”进入完整的低延迟系统优化。


生产环境几个重要注意事项

**不要把所有任务都设置成实时。**滥用会导致普通业务饿死、系统管理任务得不到 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
2
3
4
5
6
7
8
SCHED_FIFO
谁优先级高,谁先运行;同优先级,先来先运行

SCHED_RR
谁优先级高,谁先运行;同优先级,轮流运行

SCHED_DEADLINE
关注任务的时间约束:runtime + deadline + period

系列小结

调度策略类型核心特点常见场景
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 实时性能的内容。


课后思考

  1. 一台 8 核机器,sched_rt_runtime_us=950000。如果每个 DEADLINE 任务是 runtime=4ms, period=10ms,最多能放几个?(提示:计算利用率总和与全局上限)
  2. 为什么 preempt=full 仍然不等于 PREEMPT_RT?
  3. 隔离核上跑着忙等的 FIFO 线程,为什么还需要 stalld?这反映了设计上的什么问题?

参考手册

1
2
3
4
5
6
man chrt
man 7 sched
man sched_setattr
man stalld
man systemd.exec
man cyclictest