调度器全景: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。
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 本章小结
调度器的全景图,说白了就是三件事:
- 调度类决定了进程的优先级和调度策略——CFS管普通进程,RT管实时进程,Deadline管硬实时任务。
- 就绪队列是每个CPU上的进程排队区——CFS用红黑树,RT用优先级位图,Deadline用最早截止时间队列。
- 调度域和调度组是多核时代的拓扑地图——内核靠它做负载均衡,避免跨NUMA节点迁移带来的性能损失。
嗯,这一章的内容就到这里。调度器是个大话题,后面我们会深入每个调度类的实现细节,还会讲怎么用perf、ftrace这些工具去抓调度延迟。但今天这张全景图,我希望你能先印在脑子里——有了全局视角,后面再抠细节才不会迷路。