操作系统内功 · 第 11 篇:Linux 调度类全景——从 RT 到 SCHED_OTHER 的完整策略

一台机器上其实同时跑着五套调度器

Linux 调度类全景:从 RT 到 SCHED_OTHER

很多应用工程师把 Linux 调度器理解成一个算法:「CFS(或现在的 EEVDF)决定谁上 CPU」。这个理解只对了四分之一。真实的内核里,调度器是一个框架加多套策略的组合:框架负责「下一个该谁上 CPU」的统一入口,策略则分成五个先后有序的「调度类」(sched_class),每一条线程在任意时刻只属于其中一个类。schedule() 被触发的瞬间,内核做的事情不是「算一遍 CFS」,而是按固定顺序逐个询问五个类:你手里有没有就绪任务?有就选它上场,没有才轮到下一个类。这个链条是硬编码的优先级:stop 类 > deadline 类 > RT 类 > fair 类 > idle 类。也就是说,只要有一个优先级为 1 的 SCHED_FIFO 任务在就绪队列里,哪怕机器上有一千个 nice 值为 -20 的普通任务,CPU 也只会给那个 FIFO 任务。

这个架构对应用工程师的第一个启示是:你平时看到的 nice 值、容器里的 cpu.shares,全部只在 fair 类这一个层级里起作用。一个普通业务进程把 nice 调到 -20,也抢不过任何一个实时任务;反过来,一台机器上哪怕只有一个失控的实时线程,也能把整台机器的业务进程全部饿死。理解调度类,本质上是在理解「你的进程在内核视角下处于哪一层,哪一层的事件能压过你」。

flowchart TD
    A["schedule(): 挑选下一个上 CPU 的任务"] --> B{"stop 调度类<br/>有无就绪任务?"}
    B -- "有" --> S["运行 stop 类任务<br/>(CPU 热插拔/迁移等内核机制专用)"]
    B -- "无" --> C{"deadline 调度类<br/>有无就绪任务?"}
    C -- "有" --> DL["运行 SCHED_DEADLINE 任务<br/>(EDF:最早截止期优先)"]
    C -- "无" --> D{"RT 调度类<br/>有无就绪任务?"}
    D -- "有" --> RT["运行 SCHED_FIFO / SCHED_RR<br/>实时优先级 1~99"]
    D -- "无" --> E{"fair 调度类<br/>有无就绪任务?"}
    E -- "有" --> F["运行 SCHED_OTHER<br/>按 nice 权重分配 CPU"]
    E -- "无" --> I["运行 idle 线程<br/>CPU 进入休眠"]

调度类:内核里的优先级金字塔

五个调度类各自的管理范围非常清晰,值得逐个认识,因为它们每一个都对应着你在生产环境里可能遇到的真实实体:

stop 类不是给应用用的,它是内核自己的「最高优先级工具」。CPU 热插拔、任务迁移(migration/N 线程)、stop_machine 这类需要独占 CPU 的内核机制,都借道 stop 类执行。你在 ps 里看到的 migration/0、migration/3 这些线程就属于这一层的边缘——严格说 migration 线程本身是 RT 类,但迁移动作的执行走 stop 类。

deadline 类实现 SCHED_DEADLINE 策略,是内核里唯一的 EDF(最早截止期优先)调度器,专为有明确时延需求的任务设计,后面专门开一节讲。

RT 类承载 SCHED_FIFO 和 SCHED_RR 两个策略,对应实时优先级 1 到 99。内核自己的很多基础设施就运行在这一层:线程化中断(irq/44-nvme0q1 这类名字)默认是 FIFO 优先级 50,高精度定时器软中断线程 ktimers 也运行在 RT 上下文。

fair 类就是绝大多数进程的世界,SCHED_OTHER(也叫 SCHED_NORMAL)策略,由 CFS 演化而来的 EEVDF 算法驱动。你的 nginx、Java、MySQL 全部在这里。

idle 类只有一个成员:每个 CPU 上的 idle 线程(swapper/N)。前四个类都空手时 CPU 才轮到它,它的存在就是让 CPU 停下来省电。

用 ps 可以直接把一台机器的调度类分布列出来:

$ ps -eo pid,cls,rtprio,ni,comm --sort=-rtprio | head -10
  PID CLS RTPRI  NI COMMAND
   11  FF     99   - migration/0
   14  FF     99   - migration/1
  121  FF     50   - irq/124-nvme0q2
  134  FF     50   - irq/30-iwlwifi
 3231  TS      -   0 nginx: worker process
 3232  TS      -   1 node /srv/app/server.js
 9981  TS      -   0 java -Xmx4g -jar app.jar

CLS 列里 FF 是 SCHED_FIFO,TS(time sharing)是 SCHED_OTHER。注意一个细节:内核线程占据了大半的实时槽位,而你的业务进程全挤在 fair 类里。这张表是排查「机器莫名卡死」的第一张图——如果某个业务进程出现在 FF 行,要么是有人手工 chrt 过,要么是某个库悄悄把自己提升成了实时优先级,这两种情况都值得警惕。

实时调度类:SCHED_FIFO 与 SCHED_RR

RT 类的两个策略共享同一个优先级空间(1~99,数字越大优先级越高),但行为规则完全不同。SCHED_FIFO 没有时间片概念:一个 FIFO 任务一旦上 CPU,会一直跑到它主动让出(阻塞、yield)或者被更高优先级的实时任务抢占。SCHED_RR 带时间片轮转:同优先级的多个 RR 任务轮流使用 CPU,默认时间片可以从 /proc/sys/kernel/sched_rr_timeslice_ms 读到:

$ cat /proc/sys/kernel/sched_rr_timeslice_ms
100

100 毫秒是默认值,这个值可以运行期修改(写回这个文件即可),但只对 RR 有效,FIFO 永远不受时间片约束。把一个线程设置成实时优先级只需要一次系统调用:

#include <sched.h>

struct sched_param sp = { .sched_priority = 50 };
if (sched_setscheduler(0, SCHED_FIFO, &sp) == -1) {
    perror("sched_setscheduler");   // 无 CAP_SYS_NICE 时必然 EPERM
}

这里的 EPERM 是新人最常撞的墙:设置实时策略(以及把 nice 调负)都需要 CAP_SYS_NICE 能力。在容器里默认没有这个能力,所以 chrt -f 50 some_app 在容器里会直接报错 "Operation not permitted",这不是 bug,是安全边界。在宿主机上非 root 用户同样失败,除非用 setcap cap_sys_nice+ep 给二进制授权或改 PAM 限制(/etc/security/limits.conf 的 rtprio 项)。

命令行上用 chrt 观察和设置是最直接的方式:

$ chrt -p 3231
pid 3231's current scheduling policy: SCHED_OTHER
pid 3231's current scheduling priority: 0

$ sudo chrt -f 50 taskset -c 2,3 ./rt_worker &
[1] 9981

$ chrt -p 9981
pid 9981's current scheduling policy: SCHED_FIFO
pid 9981's current scheduling priority: 50

上面这条组合命令还顺带把 worker 绑到了 2、3 号核(taskset -c),这是实时场景的标准搭配:先把核从普通调度里隔离出来,再把实时任务放上去。谁在生产环境用实时策略?三个典型:线程化中断(内核把硬中断处理搬进内核线程时默认给 FIFO 50,方便 RT 补丁用户统一调优先级)、音频/工业控制(几十微秒级的唤醒延迟需求)、DPDK/SEV 类用户态网络栈(独占核 + FIFO,保证收包线程不被任何东西打断)。对绝大多数 Web 服务来说,你不需要碰实时策略——你需要的是知道它存在,并且在它失控时能认出来。

nice 值的真实含义:权重表与时间配比

nice 值可能是被误解最多的调度概念。常见的错误理解有两种:「nice 10 就是只能用 10% 的 CPU」「nice -20 优先级最高所以会饿死别人」。两个都不对。nice 值的真实身份是 fair 类里的权重(weight),内核维护着一张从 nice 到权重的映射表(sched_prio_to_weight),规则是:nice 0 对应权重 1024,每差一个 nice 档位,权重比值为 1.25:1(往下)或 1:1.25(往上)。表的一角长这样:

nice -5 -1 0 1 5 10 19
权重 3121 1277 1024 820 335 110 15

fair 类分配 CPU 的原则是「按权重比例分时间」:同一颗 CPU 上,nice 0 和 nice 5 两个任务竞争时,1024 对 335,总权重 1359,前者大约拿到 75.35% 的 CPU 时间,后者拿 24.65%——大约是 3:1。如果换成 nice 0 对 nice 10,那就是 1024:110 ≈ 9.3:1。这就解释了第一个误解:nice 10 的任务不是被限流到某个配额,而是在竞争激烈时按比例少拿;机器空闲时它照样能用满整颗 CPU,因为权重只在「有人抢」的时候才起作用。

第二个误解的澄清更重要:nice -20 并不会饿死别人。fair 类内部的调度是按 vruntime 排序的公平算法,权重只影响各自 vruntime 的推进速度,任何就绪的 fair 任务都会在合理的时间粒度内被调度到,不存在「低权重任务永远轮不上」。真正会饿死别人的从来不是 fair 类内部,而是跨调度类的压制——实时类压 fair 类,这才是上一节 RT throttling 存在的原因。

还有一层容易被忽略的语义:nice 是逐任务的属性,而现代桌面和服务器的调度单元经常是「分组」。这就要引出 autogroup 机制。

RT throttling:防止实时任务锁死系统的保险丝

RT 类最危险的特性是 FIFO 无时间片——一个死循环的 FIFO 任务可以永久占住一颗 CPU,让该核上的所有 fair 任务(包括你的 shell、SSH 会话)再也得不到调度。如果每个核都有一个这样的任务,整台机器就锁死了,而且 SSH 都连不上去,只能硬重启。内核为此装了一道保险丝:RT 带宽控制(RT throttling)。两个 sysctl 参数控制它:

$ cat /proc/sys/kernel/sched_rt_period_us
1000000
$ cat /proc/sys/kernel/sched_rt_runtime_us
950000

语义是:以 1 秒(1000000 微秒)为一个周期,所有实时任务合计最多使用 950 毫秒 CPU 时间,剩下的 50 毫秒强制留给其他类。一旦实时任务在一个周期内耗尽 950ms 配额,内核会把它们全部暂停到周期结束。这意味着哪怕一个失控的 FIFO 死循环,机器上普通进程每秒仍能拿到 5% 的时间——足够 SSH 上去把它杀掉。这也是为什么有些做音频低延迟的人会把 sched_rt_runtime_us 改成 -1(关闭限流)来消除 50ms 的周期性毛刺——这样做等于拆掉保险丝,生产环境上强烈不建议。

怎么确认系统发生过 RT 限流?看 /proc/sched_debug:

$ sudo grep rt_throttled /proc/sched_debug | head -4
cpu#0
  .rt_throttled                  : 1
cpu#1
  .rt_throttled                  : 0

.rt_throttled 为 1 说明当前正处于限流状态(或统计上发生过)。值得注意的一点是:cgroup v2 不提供按组的 RT 带宽控制——RT throttling 在 v2 里是全局的(v1 时代有 cpu.rt_runtime_us 可以给每个 cgroup 分配实时预算,v2 刻意去掉了,因为按组切分的实时预算分配在数学上极难做对)。所以在 v2 的世界里,实时任务的管控只能靠「全局限流 + 隔离核」这一对组合。

SCHED_DEADLINE:内建准入控制的 EDF 调度器

fair 类和 RT 类之间其实还夹着一个大多数工程师从没用过的策略:SCHED_DEADLINE。它不是「优先级更高」,而是换了一套完全不同的调度模型:任务自己声明三个参数——runtime(每个周期内我需要多少 CPU 时间)、deadline(这个时间之前必须用完)、period(多长时间一个周期),内核用 EDF 算法保证截止期。设置方式用的是新接口 sched_setattr:

struct sched_attr attr = {
    .size = sizeof(attr),
    .sched_policy = SCHED_DEADLINE,
    .sched_runtime  = 2 * 1000 * 1000,    // 2 ms
    .sched_deadline = 10 * 1000 * 1000,   // 10 ms
    .sched_period   = 10 * 1000 * 1000,   // 10 ms
};
syscall(SYS_sched_setattr, 0, &attr, 0);

这段代码的含义是「每 10 毫秒给我 2 毫秒 CPU,必须在 10 毫秒内用完」。SCHED_DEADLINE 最与众不同的地方是准入控制(admission control):内核会检查每个 CPU 运行队列上所有 deadline 任务的 runtime/period 之和不能超过 1(利用率守恒),超过就拒绝新的设置请求,返回 EBUSY。这是五个策略里唯一一个「设置失败可能因为资源不够」而非「权限不够」的。比如上面例子中每任务利用率是 2ms/10ms = 20%,同一个核上最多塞进 5 个这样的任务,第 6 个就会被拒绝。配套的 CBS(Constant Bandwidth Server)机制还保证:就算任务超时运行(实际用了比 runtime 更多的 CPU),内核也会把它单独限流,不会拖累同队列的其他 deadline 任务。这些特性让它成为音视频、机器人控制这类硬实时场景的选型,但代价是配置心智负担重、与容器生态整合弱——对 Web 后端工程师,知道它的位置(fair 之上、stop 之下)和「带准入检查」这个特性就够了。

autogroup:nice 语义的隐藏变数

从 2.6.38 内核起,fair 类多了一层大多数人没意识到的分组:autogroup。打开时(/proc/sys/kernel/sched_autogroup_enabled,桌面发行版默认为 1),每个会话(登录 shell 及其子孙)会被自动划成一个调度组,fair 类先在组与组之间按权重分时间,组内再按各任务的权重细分。这个机制的动机是桌面体验:你开一个终端跑 make -j32 编译,另一个终端里的 GUI 和 shell 不应该被 32 个编译进程挤死——有了 autogroup,编译组整体只占「一个组」的份额。

对服务器端它有一个微妙的影响:nice 值的语义变了。两个 autogroup 之间竞争时,先比的是组的权重(组权重 = 组内任务权重之和,但有上限机制),组内才轮到单个任务的 nice。这意味着你给某个进程 renice -10,如果它所在会话组整体权重远低于目标组,单独提权可能毫无效果;反过来,跨会话想精确控制进程优先级,最可靠的方式不是 nice 而是cgroup(systemd 的 CPUWeight)。Docker/Kubernetes 环境下每个容器是独立的 cgroup,autogroup 的跨容器影响基本可以忽略,但同容器内的多进程 nice 语义依然受 autogroup 影响(容器内默认也开启),排查「renice 了但没用」时记得把这一层考虑进去。

为什么你的应用会受这个影响

把这些机制拉回到应用工程场景,有几条可以直接落地的结论:

第一,排查「整机卡死」先看调度类分布。 机器 SSH 变慢、命令全部超时,第一件事 ps -eo pid,cls,rtprio,ni,comm --sort=-rtprio | grep FF——如果列表里出现了不该存在的业务进程(比如某个带音频库的服务把自己设成了 FIFO),RT throttling 的 5% 救命时间就是你的排查窗口。同理,grep rt_throttled /proc/sched_debug 能确认限流是否正在发生。

第二,容器里 nice 和 cpu-shares 是两个世界的东西。 docker run --cpu-shares 512 设置的是 cgroup 的 cpu.weight(fair 类组级权重),不是任何进程的 nice;容器内进程 renice 只影响容器内部的相对份额,容器之间的竞争由 cgroup 层裁决。两层权重独立存在,排查 CPU 竞争时要先确定「竞争发生在哪一层」。

第三,警惕库悄悄提升实时优先级。 一些音频库、某些 DPDK 封装、个别 GPU 驱动用户态组件会在初始化时调用 sched_setscheduler。在共享宿主机上,一个失控的 FIFO 线程就是全局事故。给关键服务的启动脚本加上启动后校验(chrt -p $pid 输出必须是 SCHED_OTHER)是廉价的防御。

第四,真需要实时,优先级从低往高试,并绑核。 FIFO 优先级 50 的意义在于压过默认 FIFO 50 的线程化中断吗?不,50 对 50 是平级。把业务线程设在 50 以下会反而被中断线程压住。通用建议:业务实时线程用较低值(如 10~40)避免与内核线程(50)竞争,同时用 taskset/cpuset 把它和普通负载隔到不同核上,再用 isolcpus 类手段清场。优先级数字本身不承担「多重要」的语义,它只是硬性的压制关系。

小结

Linux 调度器的全貌是一个五层的优先级金字塔:stop 与 deadline 类服务内核机制和硬实时场景,RT 类用 FIFO/RR 提供确定性抢占,fair 类用权重而非优先级的方式服务 99% 的业务进程,idle 类兜底省电。对应用工程师来说,最值得带走的三个事实是:nice 只在 fair 类内部按权重比例起作用,跨类压制与它无关;RT throttling 以每周期 50ms 的代价换取系统永不锁死,是实时失控时的最后防线;实时设置需要 CAP_SYS_NICE,cgroup v2 已不提供按组实时带宽控制。下一层认知——每个调度类内部的队列组织与唤醒路径——是这篇的续篇,但先记住「我的进程在哪一层、谁能压过我」,足够你解决生产环境里绝大多数调度相关的事故。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?