☰
Linux CFS调度器深度拆解:调度时机、选任务与负载均衡
2026/10/7 10:17:43 网站建设 项目流程

1. 从一次负载不均的线上告警说起

很多人对 CFS 调度器的理解停留在"完全公平调度"这五个字上,觉得它就是个按权重分 CPU 时间的黑盒。直到某次线上集群出现诡异现象:一台 32 核机器上,8 个计算密集型线程的 CPU 占用率在 60% 到 95% 之间来回抖动,而另外 24 个核几乎闲置。用top -H看线程状态,发现它们全挤在同一个 NUMA 节点的几个核上。这时候你才会意识到,CFS 的"公平"是有边界的,调度时机、选任务逻辑和负载均衡三者配合稍有偏差,公平就变成了局部公平。

这篇内容围绕 Linux 调度子系统的 CFS 调度器展开,重点拆解三个核心问题:调度时机(什么时候触发重新选任务)、选任务(从红黑树里挑哪个实体上 CPU)、负载均衡(多核之间怎么把任务摊开)。关键词里出现的sched_class、等开销负载均衡、linux底层原理这些词,正好对应了 CFS 的类注册机制、负载计算模型和内核源码路径。适合已经会写驱动、调过内核参数,但想真正搞懂调度器内部运转逻辑的运维和嵌入式开发者。如果你只是想知道nice值怎么改,那网上随便一搜就有;但如果你想搞清楚为什么改了nice之后延迟反而变大了,那得往下看。

我下面讲的内容,一部分来自内核源码kernel/sched/fair.c的阅读笔记,一部分来自实际压测中踩过的坑。所有参数和路径都以主流 5.x/6.x 内核为基准,不同版本细节有差异,但核心逻辑一致。

2. CFS 在 sched_class 体系里的位置与注册逻辑

2.1 调度类优先级链:为什么 CFS 排在中间

Linux 内核不是只有 CFS 一个调度器。它用sched_class结构体把不同调度策略串成一条优先级链,每个任务属于且仅属于一个调度类。链的顺序在kernel/sched/sched.h里定义,从高到低大致是:

  • stop_sched_class:最高优先级,用于 CPU 热插拔、停止机器等紧急场景,普通任务永远碰不到。
  • dl_sched_class:Deadline 调度,用于有硬实时截止时间的任务。
  • rt_sched_class:实时调度,对应SCHED_FIFO和SCHED_RR。
  • fair_sched_class:就是 CFS,对应SCHED_NORMAL和SCHED_BATCH。
  • idle_sched_class:最低优先级,CPU 没事干时才跑。

这个顺序不是随便排的。pick_next_task会从最高优先级类开始问"你有任务要跑吗",一旦某个类返回了任务,低优先级的类根本没机会被查询。所以 CFS 的"公平"只发生在SCHED_NORMAL任务之间,一旦有 RT 任务就绪,CFS 任务立刻被抢占。我见过有人把数据库进程设成SCHED_FIFO想"提速",结果把整个系统的网络中断处理线程饿死,机器直接失联——这就是没搞清调度类优先级链的代价。

2.2 fair_sched_class 的函数指针都指向谁

sched_class本质上是一组函数指针的集合,CFS 把自己的实现挂上去。核心几个指针和对应函数如下:

函数指针CFS 实现作用
enqueue_taskenqueue_task_fair任务变为可运行时插入红黑树
dequeue_taskdequeue_task_fair任务阻塞或退出时移出红黑树
pick_next_taskpick_next_task_fair从红黑树选最左节点上 CPU
task_ticktask_tick_fair周期时钟中断里更新运行时间
check_preempt_tickcheck_preempt_tick判断当前任务是否该被抢占
set_next_taskset_next_task_fair切换任务时更新调度实体状态

这些函数不是孤立的。task_tick_fair在每次时钟中断被调用,它更新当前任务的vruntime,然后调用check_preempt_tick决定要不要打抢占标记。如果打了标记,中断返回时会触发schedule(),进而走到pick_next_task_fair。整条链路是"时钟中断 → 更新统计 → 判断抢占 → 重新选任务",理解这条链路是搞懂调度时机的关键。

2.3 调度实体:任务不是最小单位

CFS 里被调度的对象叫调度实体(sched_entity),不是task_struct。一个任务有一个se,但一个任务组(cgroup 的 CPU 子系统)也有自己的se。这就引出了组调度的概念:当 cgroup 开启 CPU 限制时,整个组被当成一个实体参与红黑树排序,组内再递归排序。红黑树的节点可能是任务,也可能是组,取决于是否启用了CONFIG_FAIR_GROUP_SCHED。

这个设计直接影响了负载均衡的粒度。如果按任务均衡,一个组里 100 个线程会被摊到各个核;如果按组均衡,整个组可能被塞到一个核上,组内再分。实际生产里我建议对延迟敏感的服务不要开组调度,否则一个组内的线程争抢会放大尾延迟。判断方法很简单:cat /proc/<pid>/sched看se.sum_exec_runtime和se.vruntime,如果同一 cgroup 下多个进程的vruntime差异很大,说明组调度在起作用。

3. 调度时机:时钟中断、唤醒与主动让出

3.1 周期性 tick 里的抢占判断

调度时机分三类:周期抢占、唤醒抢占、主动让出。先说周期抢占,它由scheduler_tick()驱动,频率取决于CONFIG_HZ,常见 250Hz 或 1000Hz。每次 tick 调用task_tick_fair,里面做两件事:更新当前实体的运行时间,然后调check_preempt_tick。

check_preempt_tick的逻辑值得细看。它先算当前任务已经跑了多久(delta_exec),如果delta_exec超过理想运行时间sched_slice,直接标记抢占。理想运行时间怎么算?公式是:

sched_slice = (sched_period * se->load.weight) / cfs_rq->load.weight

sched_period是调度周期,任务数少于 8 个时默认 6ms,超过 8 个时按nr_running * 0.75ms算,但有上限。se->load.weight是任务权重,由nice值映射而来,nice 0对应 1024。所以一个nice 0的任务在 8 个同优先级任务里,理想运行时间是6ms * 1024 / (8*1024) = 0.75ms。如果它连续跑了超过 0.75ms 还没被抢占,说明 tick 粒度太粗或者抢占被延迟了。

注意:sched_slice是"理想"值,不是硬上限。实际运行时间可能远超它,因为抢占要等下一个 tick。这就是为什么CONFIG_HZ=100的系统上,交互式任务延迟明显比CONFIG_HZ=1000差——tick 间隔 10ms,任务可能多跑 10ms 才被换下。

3.2 唤醒抢占:新任务能不能立刻上 CPU

当一个任务从阻塞变为就绪(比如等到了 IO 或信号量),try_to_wake_up会调用check_preempt_curr,最终走到check_preempt_wakeup。这里有个关键判断:唤醒的任务是否应该抢占当前任务。

判断依据是vruntime比较。如果新任务的vruntime比当前任务小很多(小于当前任务的vruntime减去sysctl_sched_wakeup_granularity),就标记抢占。sched_wakeup_granularity默认 1ms,转成vruntime单位后作为阈值。这个机制叫唤醒抢占,目的是让刚醒来的交互式任务尽快得到响应。

但这里有个坑:如果新任务只是短暂唤醒(比如等一个很快的锁),频繁抢占会导致上下文切换开销飙升。我实测过一个场景,两个线程通过futex高频同步,每次唤醒都触发抢占,vmstat里cs(上下文切换)从 2 万涨到 15 万,吞吐反而降了 30%。后来把sched_wakeup_granularity从 1ms 调到 3ms,切换次数降了一半,吞吐恢复。所以这个参数不是越小越好,要看负载特征。

3.3 主动让出:yield 和阻塞的区别

主动让出分两种:sched_yield()和阻塞。sched_yield把当前任务放到红黑树最右(vruntime设为cfs_rq->min_vruntime + 1),然后立刻重新选任务。如果没别的任务,它又会被选回来,等于白让。所以sched_yield在 CFS 下语义很弱,不要指望它做精确的让出控制。

阻塞则是任务进入睡眠,dequeue_task_fair把它移出红黑树,pick_next_task_fair自然选下一个。阻塞是真正释放 CPU 的方式。我见过有人用sched_yield做自旋等待的退让,结果 CPU 占用率 100% 但吞吐没提升——因为让出后立刻又被选回来,等于空转。正确做法是用futex或条件变量真正睡眠。

4. 选任务:红黑树最左节点与 vruntime 的微妙之处

4.1 vruntime 怎么算,为什么它决定一切

CFS 的核心是vruntime(虚拟运行时间)。每个调度实体维护一个vruntime,它随实际运行时间增长,但增长速度受权重影响:

vruntime += delta_exec * (NICE_0_LOAD / se->load.weight)

NICE_0_LOAD是 1024。nice 0的任务权重 1024,vruntime增长等于实际运行时间;nice -5的任务权重约 3355,vruntime增长只有实际时间的 0.3 倍,所以它跑得越多,vruntime涨得越慢,越容易被再次选中。这就是"权重高优先"的实现方式。

红黑树按vruntime排序,pick_next_task_fair取最左节点,也就是vruntime最小的实体。这个操作是 O(log n),但最左节点有缓存(rb_leftmost),实际是 O(1)。选任务本身很快,开销主要在vruntime更新和红黑树插入删除。

提示:vruntime是 64 位无符号数,但实际不会溢出,因为每次更新都会减去cfs_rq->min_vruntime做归一化。如果你在/proc/<pid>/sched里看到vruntime是 0 或很小的值,说明它刚被归一化过,不代表它没跑。

4.2 min_vruntime 的作用与更新时机

cfs_rq->min_vruntime是队列里所有实体vruntime的最小值,但它不是实时精确的,而是单调递增的近似值。每次dequeue_task_fair或put_prev_task_fair时,会用当前实体的vruntime去更新min_vruntime,但只增不减。

这个设计是为了防止新任务或刚唤醒的任务因为vruntime太小而无限抢占。新任务的vruntime初始化为min_vruntime,而不是 0。如果初始化为 0,一个刚 fork 的任务会瞬间抢占所有老任务,因为它们跑了很久vruntime很大。用min_vruntime做基准,新任务和老任务站在同一起跑线附近,公平性才成立。

我踩过一个相关的坑:在容器里跑短生命周期任务,每个任务 fork 出来vruntime都是min_vruntime,如果min_vruntime因为某个长跑任务被推得很高,新任务的vruntime也高,导致它一上来就落后,响应变慢。解决办法是给容器设cpu.shares并限制长跑任务的影响,或者用SCHED_BATCH降低交互敏感度。

4.3 组调度下的选任务递归

开了CONFIG_FAIR_GROUP_SCHED后,pick_next_task_fair不是选一个任务就完事,而是可能递归。红黑树最左节点如果是一个组实体,就要进入这个组的cfs_rq再选最左节点,直到选到任务。递归深度等于 cgroup 层级深度。

这个递归有开销。我实测过 5 层 cgroup 嵌套,pick_next_task_fair的耗时比扁平结构高 3 到 5 倍。如果对调度延迟敏感,建议 cgroup 层级不要超过 3 层。另外,组实体的vruntime更新和任务不同,它用的是组内所有任务的加权平均,计算量更大。/proc/sched_debug里能看到每个cfs_rq的nr_running和min_vruntime,排查组调度问题时这个文件比top有用得多。

5. 负载均衡:从等开销模型到 NUMA 感知

5.1 负载是什么:weight 还是 runnable 时间

负载均衡的第一步是定义"负载"。CFS 用的是加权负载,不是简单的任务数。每个实体的负载是se->load.weight,但实际计算时用的是se->avg.load_avg,这是一个 PELT(Per-Entity Load Tracking)衰减平均值。PELT 把负载按时间衰减,近期运行多的实体负载高,长期睡眠的实体负载低。

为什么不用任务数?因为一个nice -10的任务和一个nice 19的任务对 CPU 的需求完全不同。用加权负载,nice -10的权重约 9548,nice 19约 15,差 600 多倍。如果按任务数均衡,一个核上放一个重任务和一个轻任务,另一个核放两个轻任务,看起来都是 2 个任务,实际负载差几十倍。所以 CFS 均衡的是load_avg,不是nr_running。

关键词里的"等开销负载均衡"指的就是这个模型:让每个 CPU 的load_avg尽量相等。但"等开销"是理想,实际因为缓存亲和性和 NUMA 距离,完全相等既不现实也不最优。

5.2 负载均衡的触发路径:idle、newidle 与周期均衡

负载均衡不是随时都在跑,它有几个触发点:

  • idle 均衡:CPU 要进入 idle 前,调newidle_balance去其他 CPU 拉任务。这是最积极的均衡,因为空闲核拉任务能提升利用率。
  • 周期均衡:scheduler_tick里每隔一定时间调trigger_load_balance,最终走rebalance_domains。间隔由sd->balance_interval决定,默认随 CPU 数变化,通常几十到几百毫秒。
  • fork 均衡:新任务创建时,select_task_rq_fair选一个负载轻的核。
  • wake 均衡:任务唤醒时,select_task_rq_fair可能把它放到唤醒 CPU 或负载轻的 CPU。

这几个路径里,newidle_balance最关键。如果它没拉到任务,CPU 就真 idle 了。我遇到过newidle_balance因为sd->nr_balance_failed太高而放弃拉任务的情况,原因是之前几次拉取都失败(比如任务刚被拉走又跑回来),调度器进入保守模式。调/proc/sys/kernel/sched_migration_cost_ns可以影响这个判断,默认 500000ns,调大能让调度器更愿意迁移。

5.3 NUMA 感知与调度域层级

多核系统里,CPU 不是平等的。同一物理核的两个超线程共享 L1/L2,同一 NUMA 节点的核共享 L3,跨 NUMA 节点访问内存延迟差几倍。CFS 用调度域(sched_domain)描述这种层级,每个域有flags标记能力,比如SD_SHARE_PKG_RESOURCES表示共享缓存,SD_NUMA表示跨 NUMA。

负载均衡从最低层域开始,逐层向上。低层域均衡频繁但迁移范围小,高层域均衡稀疏但迁移范围大。/proc/schedstat里能看到每个域的lb_count和lb_failed,如果某个域的lb_failed很高,说明均衡尝试多但成功少,可能是任务亲和性太强或迁移成本太高。

NUMA 系统上还有个特殊逻辑:task_numa_fault统计任务的内存访问分布,如果发现任务大部分内存访问在远端节点,会触发 NUMA 均衡,把任务迁到内存所在节点。这个机制叫NUMA balancing,由sched_numa_balancing控制,默认开启。我实测过数据库场景,关掉 NUMA balancing 后跨节点访问延迟降低,但 CPU 利用率下降,因为任务不再往内存节点迁。开还是关,取决于负载是延迟敏感还是吞吐敏感。

6. 实操中调优 CFS 的几个关键参数与验证方法

6.1 参数速查与调整建议

CFS 暴露的调优参数主要在/proc/sys/kernel/下,常用的几个:

参数默认值作用调整建议
sched_min_granularity_ns3ms任务最小运行时间交互式负载调小,吞吐负载调大
sched_wakeup_granularity_ns1ms唤醒抢占阈值高频同步场景调大,减少切换
sched_migration_cost_ns500us迁移成本估计缓存敏感负载调大,减少迁移
sched_nr_migrate32单次迁移任务数大核数机器可调大,加快均衡
sched_latency_ns6ms调度周期任务数多时自动放大,一般不动

调这些参数不要一次改多个,否则出问题不知道是哪个引起的。我的习惯是先用perf sched或trace-cmd抓一段调度轨迹,看sched_switch的频率和sched_wakeup的延迟,定位瓶颈再改对应参数。

6.2 用 sched_debug 和 trace 验证调度行为

/proc/sched_debug是排查调度问题的第一手资料。它按 CPU 列出每个cfs_rq的nr_running、min_vruntime、load_avg,还有每个任务的se.vruntime和se.sum_exec_runtime。如果发现某个 CPU 的nr_running长期比其他高,说明负载均衡没生效;如果min_vruntime差异大,说明vruntime归一化有问题。

更细的追踪用trace-cmd:

trace-cmd record -e sched:sched_switch -e sched:sched_wakeup -e sched:sched_migrate_task trace-cmd report | head -100

sched_migrate_task事件会显示任务从哪个 CPU 迁到哪个 CPU,以及迁移原因。如果看到大量迁移但负载没改善,可能是sched_migration_cost_ns太小,任务刚迁过去又被迁回来,形成"乒乓"。这时候调大迁移成本,让调度器更谨慎。

6.3 一个真实的负载不均排查案例

回到开头那个 32 核机器的问题。排查步骤是这样的:

  1. top -H确认线程分布,发现 8 个线程集中在 CPU 0-7。
  2. cat /proc/sched_debug | grep -A5 "cpu#0"看 CPU 0 的cfs_rq,nr_running是 8,其他核是 0。
  3. trace-cmd抓sched_migrate_task,发现几乎没有迁移事件。
  4. 检查/proc/schedstat,发现 CPU 0-7 所在 NUMA 节点的lb_failed很高。
  5. 查sched_domain拓扑,发现这台机器 BIOS 里 NUMA 被设成了NPS4,每个节点只有 8 核,跨节点迁移成本高,调度器不愿意迁。
  6. 解决方案:把 BIOS 的 NUMA 模式改成NPS1,让所有核在一个节点内,负载均衡立刻生效,CPU 占用率摊平到 32 核。

这个案例说明,CFS 负载均衡不是软件单方面的事,硬件拓扑和 BIOS 设置会直接影响调度域划分。排查调度问题时,先看拓扑,再看参数,最后看代码逻辑,顺序反了会浪费很多时间。

7. 几个容易混淆的边界问题

7.1 nice 值和权重不是线性关系

很多人以为nice每差 1,权重差固定值。实际是查表映射,nice 0权重 1024,nice 1是 820,nice -1是 1277,比例约 1.25。但nice 19权重只有 15,nice -20权重 88761,差距是几千倍。所以nice从 0 调到 1 影响不大,从 18 调到 19 可能让任务几乎饿死。调nice时不要线性思维,要看权重表。

7.2 SCHED_BATCH 和 SCHED_IDLE 的区别

SCHED_BATCH还是 CFS 类,只是标记为批处理,唤醒抢占时更保守,适合编译、渲染这类吞吐型任务。SCHED_IDLE权重极低(3),只有在 CPU 完全空闲时才跑,适合后台清理任务。两者都不改变调度类,但SCHED_IDLE的vruntime增长极快,几乎抢不到 CPU。别把SCHED_IDLE当成"低优先级 CFS"用,它的语义是"只有真没人跑才轮到我"。

7.3 实时任务对 CFS 的挤压

RT 任务的优先级高于 CFS,如果 RT 任务持续就绪,CFS 任务永远得不到 CPU。内核有sched_rt_runtime_us限制 RT 任务每周期最多跑 95% 时间,留 5% 给 CFS。但这个限制可以关(设为 -1),关掉后 RT 任务能 100% 占用 CPU。生产环境不要关这个限制,否则一个死循环的 RT 任务能让系统完全无响应。检查方法:cat /proc/sys/kernel/sched_rt_runtime_us,正常应该是 950000。

8. 写在最后的一点个人体会

CFS 的代码我读了不止一遍,每次都有新收获。最开始觉得vruntime和红黑树就是全部,后来发现负载均衡的复杂度远超选任务本身。再后来做 NUMA 调优,才意识到调度器是和硬件拓扑深度耦合的。如果你也在啃这块,我的建议是别一上来就看fair.c的几千行代码,先抓三条线:task_tick_fair看调度时机,pick_next_task_fair看选任务,rebalance_domains看均衡。三条线跑通,再回头看细节,会顺很多。

另外,调优参数之前一定先抓数据。/proc/sched_debug和trace-cmd能告诉你问题在哪,凭感觉改参数十有八九是白改。我见过太多人把sched_min_granularity_ns调来调去,最后发现瓶颈在 IO 等待,跟调度器没关系。工具用对了,方向才不会错。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询