调度器全景:CFS调度类、实时调度类、调度域与调度组

调度器,说白了就是内核里那个决定「下一个该谁跑」的模块。你想想看,一台机器上跑着几十上百个进程,CPU就那么几颗,谁先上、谁后上、谁该被赶下来——这些全是调度器说了算。

我个人习惯把调度器比作一个机场的登机口调度员。不同航班(进程)有不同优先级,有的要准点起飞(实时进程),有的晚点一会儿也没事(普通进程)。调度员手里有张表,按规则排着队,还得兼顾公平——嗯,这就是我们今天要聊的全景图。

1.1 调度类:谁来决定进程的「待遇」

Linux内核把调度逻辑拆成了多个调度类(scheduling class)。每个调度类负责一类进程的排队和选择。它们按优先级高低排成一条链,内核选进程时从高到低挨个问:「你那儿有活要跑吗?」

调度类 优先级 适用场景 典型策略
Stop调度类 最高 内核自身停止/迁移操作 不可抢占
Deadline调度类 高 硬实时任务,有截止时间要求 SCHED_DEADLINE
RT调度类 中 软实时任务,如音频、工业控制 SCHED_FIFO / SCHED_RR
CFS调度类 低 普通用户进程,占绝大多数 SCHED_NORMAL / SCHED_BATCH / SCHED_IDLE
Idle调度类 最低 CPU空闲时运行 pid=0的idle线程

我在项目中遇到过一个问题:一个实时音频进程用了SCHED_FIFO,结果它写了个死循环,直接把整个系统的普通进程饿死了。从那以后我养成了一个习惯——实时进程的优先级和运行时间一定要做上限约束,不然就是给自己挖坑。

1.2 CFS调度类:公平不是平均

CFS(Completely Fair Scheduler)是Linux默认的调度器,负责管理绝大多数普通进程。它的核心思想不是「每人分一样多的时间片」,而是「每人获得等比例的CPU时间」。

怎么做到的?CFS维护了一个红黑树,每个进程有一个vruntime(虚拟运行时间)。每次调度时,CFS从树里挑出vruntime最小的那个进程去跑。说白了,谁欠的CPU时间多,谁就先上。

关键公式:

vruntime += 实际运行时间 * (NICE_0_LOAD / 进程权重)

权重由nice值决定。nice值越低,权重越大,vruntime增长越慢,进程就能多占CPU。

举个例子:两个进程A和B,A的nice=0(权重1024),B的nice=10(权重110)。如果它们同时跑10ms,A的vruntime增长约10ms,B的vruntime增长约93ms。所以B很快就会被选上——因为它「欠」得更多。这就是CFS的公平:按权重分配,不是按人头分配。

我的调试技巧:排查CPU饥饿问题时,先看/proc/<pid>/sched里的se.vruntime字段。如果某个进程的vruntime远大于其他同优先级进程,说明它被调度器冷落了。这时候要检查是不是有更高优先级的实时进程在抢CPU。

1.3 实时调度类:FIFO和RR的区别

实时调度类(RT调度类)服务于那些对延迟敏感的任务。它有两种策略:

  • SCHED_FIFO:先入先出,不主动让出CPU。除非被更高优先级的实时进程抢占,或者自己睡眠/退出,否则一直跑。
  • SCHED_RR:时间片轮转。同优先级的RR进程轮流跑,每人一个时间片,跑完就换人。

我曾经帮一个客户排查工业控制系统的抖动问题。他们的采集进程用了SCHED_FIFO,优先级设了99(最高)。结果这个进程和内核的迁移线程抢CPU,导致系统响应忽快忽慢。解决方案很简单:把优先级降到50以下,给内核留点空间。实时不是越高越好,留有余地才是正道。

避坑指南:我曾经见过有人把所有的业务进程都设成SCHED_FIFO,觉得「实时嘛,越快越好」。结果系统负载一高,内核的软中断和workqueue被饿死,网络直接断连。记住:实时进程是特权阶级,特权阶级多了,系统就乱了。

1.4 调度域与调度组:多核时代的调度地图

单核时代很简单,一个就绪队列搞定所有。但到了多核、多NUMA节点的系统,事情就复杂了——CPU之间有不同的距离(延迟),共享不同的缓存层级。内核需要一张「地图」来知道:哪些CPU是近亲,哪些是远邻。

这张地图就是调度域(Scheduling Domain)。每个调度域包含一组CPU,域内又按拓扑层级分成多个调度组(Scheduling Group)。

典型的层级结构是这样的:

NUMA节点域(最顶层)
  └── LLC域(共享最后一级缓存)
       └── MC域(多核,共享L2/L3)
            └── SMT域(超线程,共享L1)

调度器在做负载均衡时,会从最内层(SMT)开始,逐层向外扩散。为什么?因为迁移进程是有代价的——跨NUMA节点迁移,内存访问延迟可能翻倍。所以内核的策略是:尽量在同一个缓存域内做均衡,实在不行再往外扩散。

查看调度域拓扑的命令:

# cat /proc/sys/kernel/sched_domain/cpu0/domain*/name
# 或者用更直观的工具:
# lstopo --no-io --no-legend

我在优化一个数据库服务的CPU亲和性时,发现它的多个线程被调度到了不同NUMA节点上,跨节点内存访问导致延迟飙升。解决方案是:用numactl或taskset把线程绑定到同一个NUMA节点内的CPU上。调度域的信息帮我们快速定位了问题。

1.5 调度器全景图:一张图看懂所有

下面这张SVG图把调度器的主要组件和关系画了出来。你可以看到:调度类如何分层、就绪队列如何组织、调度域如何连接CPU。

Linux调度器全景结构图 Stop调度类 Deadline调度类 RT调度类 CFS调度类 Idle调度类 每个CPU的 就绪队列(rq) 就绪队列内部 • cfs_rq(红黑树) • rt_rq(优先级位图) • dl_rq(最早截止时间) • nr_running(运行数) • load(负载权重) • curr(当前运行进程) NUMA节点域 LLC域(共享L3) MC域(多核) SMT域(超线程) 调度组(Sched Group) • 每个域包含多个调度组 • 组内CPU共享同一级缓存 • 负载均衡在组间进行 核心流程: 1. 调度器从最高优先级调度类开始,依次查询就绪队列 2. 选中进程后,通过调度域拓扑决定是否做负载均衡 3. 负载均衡从最内层SMT域开始,逐层向外扩散

1.6 负载均衡:调度器如何让所有CPU「雨露均沾」

调度域和调度组的存在,最终目的是为了做负载均衡。内核定期检查每个调度组内的CPU负载是否均衡,如果不均衡,就把进程从忙的CPU迁移到闲的CPU上。

负载均衡的触发时机有几种:

  • 周期性均衡:每个tick(通常4ms)检查一次当前CPU所在调度组
  • 空闲时均衡:CPU空闲时,主动去其他CPU「偷」进程过来跑
  • 唤醒时均衡:进程被唤醒时,选择负载最轻的CPU去跑
  • 新创建时均衡:fork/clone时,选一个负载最轻的CPU

我的调优经验:如果你发现某些CPU负载特别高,而另一些很闲,先检查/proc/sched_debug里的avg_load和group_imbalance字段。如果group_imbalance为1,说明调度域认为组间负载不均衡,但均衡动作被限制了。常见原因是进程的CPU亲和性(cpus_allowed)太窄,导致无法迁移。

1.7 本章小结

调度器的全景图,说白了就是三件事:

  1. 调度类决定了进程的优先级和调度策略——CFS管普通进程,RT管实时进程,Deadline管硬实时任务。
  2. 就绪队列是每个CPU上的进程排队区——CFS用红黑树,RT用优先级位图,Deadline用最早截止时间队列。
  3. 调度域和调度组是多核时代的拓扑地图——内核靠它做负载均衡,避免跨NUMA节点迁移带来的性能损失。

嗯,这一章的内容就到这里。调度器是个大话题,后面我们会深入每个调度类的实现细节,还会讲怎么用perf、ftrace这些工具去抓调度延迟。但今天这张全景图,我希望你能先印在脑子里——有了全局视角,后面再抠细节才不会迷路。