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_latencymin_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上遇到的真实案例——一个游戏卡顿问题,最后发现是调度域配置的锅。

好,今天就到这儿。有问题随时找我。