3、调度器入门:Linux CFS调度器原理、调度实体与调度类、调度域与调度组
好,咱们今天聊聊Linux调度器。说实话,调度器这东西,很多做嵌入式开发的朋友容易忽略。总觉得「不就是分时间片嘛,有啥好研究的?」
我当年在调Exynos 5422的big.LITTLE架构时,就吃过这个亏。跑一个视频解码任务,大核小核来回跳,功耗飙得飞起。后来才发现,是调度域配置出了问题。嗯,从那以后,我再也不敢小看调度器了。
3.1 CFS调度器:公平不是平均
CFS,全称Completely Fair Scheduler,完全公平调度器。名字听着挺唬人,但说白了,它的核心思想就一句话:让每个进程都能公平地获得CPU时间。
但「公平」不等于「平均」。你想想看,一个后台刷新的小进程,和一个正在渲染UI的前台进程,能分一样多的时间吗?显然不能。
CFS的核心数据结构:红黑树
CFS用红黑树来管理所有可运行的进程。树的每个节点就是一个调度实体。键值是vruntime——虚拟运行时间。
每次调度时,CFS从树的最左边取一个节点。因为最左边的节点vruntime最小,说明它「挨饿」最久,理应优先运行。
我在项目中遇到过一个问题:两个同等优先级的进程,一个做大量I/O,一个做纯计算。I/O进程经常睡眠,醒来后vruntime很小,结果计算进程老抢不到CPU。后来怎么解决的?嗯,调整了sched_latency和min_granularity参数,让调度更平滑。
3.2 调度实体与调度类
调度实体,英文叫sched_entity。它是调度器眼中的「最小调度单位」。一个进程、一个线程组,甚至一个cgroup,都可以是一个调度实体。
// 调度实体的核心结构(简化版)
struct sched_entity {
struct load_weight load; // 权重
struct rb_node run_node; // 红黑树节点
u64 vruntime; // 虚拟运行时间
u64 exec_start; // 本次调度开始时间
u64 sum_exec_runtime; // 总运行时间
struct sched_entity *parent; // 父实体(用于组调度)
};
调度类呢?它是调度策略的具体实现。Linux内核里有这么几种调度类:
| 调度类 | 调度策略 | 优先级范围 | 适用场景 |
|---|---|---|---|
| stop_sched_class | 停止调度 | 最高 | CPU热插拔、migration |
| dl_sched_class | SCHED_DEADLINE | 硬实时 | 实时任务,有截止时间要求 |
| rt_sched_class | SCHED_FIFO / SCHED_RR | 0-99 | 实时任务,如音频处理 |
| fair_sched_class | SCHED_NORMAL / SCHED_BATCH | 100-139 | 普通进程,绝大多数场景 |
| idle_sched_class | SCHED_IDLE | 最低 | 空闲任务 |
每个CPU都有一个运行队列struct rq,里面包含了各个调度类的队列。调度时,优先级从高到低依次检查:先看stop类,再看dl类,再看rt类,最后才是fair类。
我的经验:在Exynos平台上,如果你把某个关键线程设成SCHED_FIFO,一定要小心。我曾经把一个显示刷新线程设成实时优先级99,结果它霸占了CPU,系统响应都卡死了。实时优先级越高,责任越大。
3.3 调度域与调度组
调度域,英文sched_domain。它是为了支持多核、多层级拓扑而设计的。说白了,就是告诉调度器:「这些CPU是共享缓存的,那些CPU是跨NUMA的,你调度时要考虑这些。」
在Exynos芯片上,调度域的层级通常是这样的:
// Exynos 5422 的调度域拓扑(简化)
// 层级0: MC(多核域)
// - 组0: CPU0-3(Cortex-A15,大核簇)
// - 组1: CPU4-7(Cortex-A7,小核簇)
// 层级1: DIE(芯片域)
// - 组0: CPU0-7(所有核心)
调度组呢?它是调度域里的子单元。每个调度组包含一个或多个CPU。调度器在做负载均衡时,是在调度组之间迁移任务的。
我曾经在调试Exynos 5422时,发现一个奇怪现象:大核簇的CPU0-3负载很高,小核簇的CPU4-7却很空闲。但调度器就是不把任务从小核移到大核。为什么?
查了半天,发现是调度域的flags配置有问题。SD_ASYM_PACKING这个标志没设对,导致调度器认为大核和小核是「对称」的,不需要做非对称负载均衡。
避坑指南:我曾经在Exynos 9810上遇到过一个问题——调度域配置了SD_SHARE_PKG_RESOURCES,但实际L2缓存并不共享。结果调度器以为两个核心共享缓存,频繁做任务迁移,导致缓存颠簸,性能反而下降。
所以,调度域的配置一定要和硬件拓扑严格对应。别偷懒,去查芯片手册。
3.4 负载均衡:调度器的「交通指挥」
负载均衡,就是让各个CPU的负载尽量均衡。CFS的负载均衡分两种:
- 周期性负载均衡:每个调度周期(通常是几毫秒)触发一次,检查当前CPU的负载是否过重。
- 空闲时负载均衡:当某个CPU空闲时,主动去其他CPU「偷」任务过来运行。
负载均衡的粒度是调度组。调度器会计算每个调度组的平均负载,如果某个组的负载明显高于其他组,就会从高负载组迁移任务到低负载组。
但这里有个坑:迁移成本。任务迁移会导致缓存失效,如果迁移太频繁,性能反而会下降。所以内核里有个imb_numa参数,控制迁移的阈值。
我的建议:在Exynos这类大小核架构上,我一般会关闭自动负载均衡,改用手动绑核。比如把前台交互任务绑到大核,后台任务绑到小核。这样虽然少了些灵活性,但性能和功耗都更可控。
3.5 实战:查看和调试调度器
说了这么多理论,咱们来点实际的。怎么查看当前系统的调度域和调度组?
# 查看CPU0的调度域信息
cat /proc/sys/kernel/sched_domain/cpu0/domain*/name
# 查看调度域的flags
cat /proc/sys/kernel/sched_domain/cpu0/domain*/flags
# 查看调度组信息
cat /proc/sys/kernel/sched_domain/cpu0/domain*/groups
输出大概长这样:
# 示例输出(Exynos 5422)
MC
DIE
# flags值(十进制)
1073741825 # 对应 SD_SHARE_PKG_RESOURCES | SD_BALANCE_NEWIDLE
1073741825 # 同上
如果你想调试调度行为,可以打开调度跟踪:
# 开启调度事件跟踪
echo 1 > /sys/kernel/debug/tracing/events/sched/enable
# 只跟踪特定进程
echo 'pid == 1234' > /sys/kernel/debug/tracing/events/sched/filter
# 查看跟踪结果
cat /sys/kernel/debug/tracing/trace
嗯,这里要注意:打开调度跟踪会影响性能,生产环境慎用。
3.6 小结
CFS调度器,说白了就是一棵红黑树加一堆调度域。理解它的关键是:
- vruntime:谁饿得久,谁先吃。
- 调度类:不同优先级,不同待遇。
- 调度域:告诉调度器CPU长啥样。
- 负载均衡:别让一个CPU累死,其他CPU闲死。
下一章,咱们会深入Exynos的big.LITTLE调度策略,讲讲怎么让大核和小核「和睦相处」。到时候我会分享一个我在Exynos 9820上遇到的真实案例——一个游戏卡顿问题,最后发现是调度域配置的锅。
好,今天就到这儿。有问题随时找我。