CFS(完全公平调度器)从 Linux 2.6.23 起接管了进程调度,这十几年来一直是内核“进程篇”里绕不开的核心。很多人背过“红黑树选最左节点”“vruntime 小的先跑”,可真到了排查 CPU 占用不均、响应延迟抖动的时候,又总觉得缺一层纸没捅破。这篇文章不打算给你复述内核文档,而是把 CFS 从设计动机、关键数据结构、vruntime 计算,到 pick_next、tick、唤醒抢占、负载均衡这几条主路径完整拆一遍,最后落到排查和调优的实测经验上。无论是刚接触内核调度的新人,还是已经被调度器折磨过的老手,希望看完都能建立起自己的分析框架。
1. 先搞清楚 CFS 解决了什么问题——调度器进化与“公平”的起源
进程调度的本质,是把有限的 CPU 时间在多个可运行进程之间分配。听起来简单,做起来难。因为进程形态千差万别:有的是纯 CPU 密集型的批处理任务,一跑就是几分钟;有的是交互型进程,大部分时间在等事件,苏醒后只跑几百微秒;还有一些是后台 IO 密集型的,频繁睡眠频繁醒来。每种进程对“响应及时”和“吞吐稳定”的诉求完全不同,调度器必须在其中找平衡。
在 CFS 之前,Linux 用的是 O(1) 调度器。O(1) 的设计思路是“优先级队列 + 时间片”。系统维护 140 个优先级队列,每个进程根据静态优先级分到一个时间片,用完了就降级,奖励交互型进程、惩罚 CPU 占用型进程。这套机制在服务器吞吐场景下表现不错,但有几个问题很致命。
- 时间片是离散化的,进程的“公平感”取决于时间片长度和优先级映射,而不是真实需求。
- 交互性判断依赖启发式算法,比如睡眠时间统计。这类启发式在负载波动大的场景里经常误判,要么把批量任务当成交互任务给太多优先级,要么反过来。
- 多核时代来临后,O(1) 的负载均衡逻辑跟不上,会出现 CPU 之间忙闲不均。
CFS 的思路是:不要再用启发式去猜“谁更需要 CPU”,而是去模拟一个“理想多任务处理器”。想象一台机器能同时运行 N 个进程,每个进程获得 1/N 的 CPU 速度。CFS 要做的,就是把时间切碎,让每个进程尽可能接近“按权重均分 CPU”的理想状态。
为了实现这件事,CFS 引进了两个关键概念:虚拟运行时间(vruntime)和红黑树。所有可运行进程按 vruntime 排序,每次调度都选 vruntime 最小的进程。vruntime 增长速度和进程权重成反比:权重高的进程,vruntime 涨得慢,就能获得更多 CPU 时间。这样优先级不再通过固定时间片体现,而是通过“vruntime 增长速度”自然体现。
这里我想多说一句:2023 年合入内核 6.6 的 EEVDF(Earliest Eligible Virtual Deadline First)可以看作 CFS 的演进版本。它选进程不再单纯找 vruntime 最小的最左节点,而是引入了“eligible”和“deadline”的概念,以解决 CFS 在负载切换频繁时调度延迟仍然不够平滑的问题。不过 EEVDF 依然工作在 sched_fair.c 里,权重表、vruntime、调度实体、红黑树这些地基没有变。理解 CFS,是理解 EEVDF 的必经之路。
2. 核心数据结构——调度实体、CFS 运行队列与调度类
内核里没有一个叫“CFS”的独立模块。它是一系列数据结构 + 回调函数组成的“调度类”。理解这一点,你就抓住了阅读源码的钥匙。
2.1 调度类从高到低分四层
每类进程都隶属于一个调度类,优先级从高到低分别是:停驻类(Stop)、限期调度类(DL)、实时调度类(RT)、公平调度类(CFS)、空闲调度类(IDLE)。调度器选择下一个进程时,从最高优先级类开始找,如果该类的运行队列有可运行任务,就直接从该类选取,否则往下找。CFS 位于 RT 和 IDLE 之间,所以只要存在实时进程,CFS 进程就不可能被选中。
每个调度类都实现了一组统一回调,定义在 struct sched_class 里:
struct sched_class { const struct sched_class_ops *ops; void (*enqueue_task)(struct rq *rq, struct task_struct *p, int flags); void (*dequeue_task)(struct rq *rq, struct task_struct *p, int flags); void (*yield_task)(struct rq *rq); bool (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags); struct task_struct *(*pick_next_task)(struct rq *rq); void (*put_prev_task)(struct rq *rq, struct task_struct *p); void (*task_tick)(struct rq *rq, struct task_struct *p, int queued); void (*task_fork)(struct task_struct *p); void (*task_woken)(struct rq *rq, struct task_struct *p); };CFS 的回调实现都放在 kernel/sched/fair.c 里。比如 enqueue_task_fair、dequeue_task_fair、pick_next_task_fair、task_tick_fair,这就是你定位代码的入口。
2.2 task_struct 与 sched_entity:一个进程如何参与调度
每个进程的 task_struct 里嵌入了一个 sched_entity 结构,这就是 CFS 眼中的“调度实体”:
struct sched_entity { struct load_weight load; struct rb_node run_node; struct list_head group_node; unsigned int on_rq; u64 exec_start; u64 sum_exec_runtime; u64 vruntime; u64 prev_sum_exec_runtime; struct sched_statistics *stats; ... };关键字段就几个:load 是进程权重,run_node 是红黑树节点,vruntime 是虚拟运行时间,exec_start 记录当前这段 CPU 时间的起始时刻,sum_exec_runtime 是累计真实运行时间。注意,sched_entity 是嵌入在 task_struct 里的,不是指针。这意味着每个进程天生自带一个参与 CFS 调度的实体,不需要额外分配内存。
顺便提一下组调度。当开启 CONFIG_FAIR_GROUP_SCHED 后,CFS 的调度实体不仅能代表进程,还能代表一个“任务组”。一个任务组有自己的 cfs_rq,组内进程在组内红黑树里竞争,而组本身作为一个实体,挂在上一级 cfs_rq 的树里。这实现了“按组分配 CPU,组内再按进程分配”。
2.3 cfs_rq 与红黑树:就绪进程的容器
每个 CPU 都有一个 rq(运行队列),rq 内嵌了 cfs_rq。cfs_rq 是 CFS 运行队列,核心成员如下:
struct cfs_rq { struct load_weight load; unsigned int nr_running; unsigned int h_nr_running; u64 min_vruntime; struct rb_root_cached tasks_timeline; struct sched_entity *curr; struct sched_entity *next; struct sched_entity *last; ... };tasks_timeline 是红黑树的根,它缓存了最左节点,这样取最小 vruntime 就是 O(1) 操作。所有可运行进程通过 run_node 挂在这棵树上。min_vruntime 是整个运行队列里最小的虚拟运行时间,它有几个用途,后面讲新进程初始化和进程唤醒时会用到。
2.4 就绪队列的“按 CPU 隔离”设计
CFS 不是全局一个队列,而是每个 CPU 一个 cfs_rq。这带来一个重要推论:在没有负载均衡介入的情况下,进程的 vruntime 只在同一个 CPU 内比较才有意义。不同 CPU 的 min_vruntime 是各自维护的。这在多核调度里是个理解难点,很多人问“为什么我在 CPU0 上调了 nice,CPU1 上的进程表现不一样”,就是因为每颗核心的 cfs_rq 是独立的,并且负载均衡迁移进程时会对 vruntime 做差值补偿。理解了这个,后面很多问题都能解释通了。
3. vruntime 与权重——公平是怎么算出来的
如果说红黑树是 CFS 的骨架,那 vruntime 就是血液。这章我们把计算逻辑一次讲透。
3.1 vruntime 的增长公式
进程获得 CPU 后,调度器会周期性调用 update_curr 更新它的 vruntime:
static void update_curr(struct cfs_rq *cfs_rq) { struct sched_entity *curr = cfs_rq->curr; u64 now = rq_clock_task(rq_of(cfs_rq)); u64 delta_exec; if (unlikely(!curr)) return; delta_exec = now - curr->exec_start; if (unlikely((s64)delta_exec <= 0)) return; curr->exec_start = now; curr->sum_exec_runtime += delta_exec; curr->vruntime += calc_delta_fair(delta_exec, curr); update_min_vruntime(cfs_rq); }关键是 calc_delta_fair:
static inline u64 calc_delta_fair(u64 delta_exec, struct sched_entity *se) { if (unlikely(se->load.weight != NICE_0_LOAD)) delta_exec = __calc_delta(delta_exec, NICE_0_LOAD, &se->load); return delta_exec; }当进程权重等于 NICE_0_LOAD(1024)时,vruntime 增长量和真实运行时间一致。当权重不等于 1024 时,会按比例折算。展开后的关系是:
vruntime += delta_exec * NICE_0_LOAD / se->load.weight写成自然语言就是:真实运行了 delta_exec 这么长的 CPU 时间,虚拟时间增长多少,取决于权重。权重越大,分母越大,vruntime 增长越慢。于是高权重进程在红黑树里的位置移动慢,更容易保持在树的左边,被选中次数更多。
3.2 权重表:nice 值与权重的映射关系
进程的 nice 值范围是 -20 到 19,对应静态优先级 100 到 139。CFS 用一个表把优先级映射成权重:
| nice 值 | 优先级 | 权重 load | 相对 nice 0 的 CPU 份额 |
|---|---|---|---|
| -20 | 100 | 88761 | 约 86.7 倍 |
| -15 | 105 | 29154 | 约 28.5 倍 |
| -10 | 110 | 9548 | 约 9.3 倍 |
| -5 | 115 | 3121 | 约 3.0 倍 |
| 0 | 120 | 1024 | 1 倍 |
| 5 | 125 | 335 | 约 0.33 倍 |
| 10 | 130 | 110 | 约 0.11 倍 |
| 15 | 135 | 36 | 约 0.035 倍 |
| 19 | 139 | 15 | 约 0.015 倍 |
相邻两级权重相差约 25%,也就是每调低一个 nice 值(优先级变高),能多获得约 25% 的 CPU 份额。注意,这不是简单的线性关系:nice 0 的权重 1024,nice 1 是 820,nice 2 是 655,越到高 nice 值,权重倒数差距越小,但换算成 CPU 时间份额时体现得更明显。两个 nice 0 的进程共享 CPU 时各得 50%;如果一个是 nice 0,另一个是 nice 10,则 nice 0 进程分到大头,二者份额约是 1024 : 110,大约 90% 对 10%。
3.3 调度周期与最小粒度:CFS 的“切时间”规则
CFS 不是给每个进程一个固定时间片,而是根据当前可运行进程数量,动态计算“本轮调度周期”。调度周期里有三个关键参数:
- sysctl_sched_latency:目标调度周期,默认值在内核里是 6ms(可通过 /proc/sys/kernel/sched_latency_ns 查看和调整)。
- sysctl_sched_min_granularity:进程被抢前的最小运行时间,默认 0.75ms。
- sysctl_sched_wakeup_granularity:唤醒抢占时的最小虚拟时间差,默认 1ms 左右。
当可运行进程数量不超过一定阈值时,调度周期固定为 sched_latency,每个进程分到 slice = sched_latency / nr_running。如果有 4 个 nice 0 进程,每个进程的理想 slice 是 6ms / 4 = 1.5ms。如果可运行进程很多,比如 100 个,每个进程只分 0.06ms,这会带来巨大的切换开销。所以内核规定每个进程至少运行 min_granularity(0.75ms),此时调度周期自然延长为 max(sched_latency, nr_running * min_granularity)。100 个进程时的实际调度周期就是 75ms。
每个进程在入队时会计算一个“调度截止值”:
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se) { u64 slice = __sched_period(cfs_rq->nr_running + !se->on_rq); ... return slice; }__sched_period 取 nr_running * min_granularity 和 sched_latency 中的较大值作为周期,再除以进程数。这个 slice 用于判断一个进程是不是“已经获得足够运行时间、可以被抢占了”。
3.4 新进程与唤醒进程的 vruntime 魔法
新 fork 出来的进程,vruntime 不能直接设为 0,否则它会长期霸占最左端,饿死老进程。内核里用 place_entity 处理:
static void place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial) { u64 vruntime = cfs_rq->min_vruntime; if (initial && sched_feat(START_DEBIT)) vruntime += sched_vslice(cfs_rq, se); se->vruntime = max_vruntime(se->vruntime, vruntime); }新进程的 vruntime 被设置在 min_vruntime 附近,并加上一个虚拟时间片(sched_vslice)的惩罚,这是为了避免新进程一进场就抢占 CPU。被唤醒的睡眠进程也走类似逻辑:它睡眠期间 vruntime 不增长,天然落后于其他进程,所以唤醒后往往能优先运行。但内核又加了唤醒补偿限制(WAKEUP_GRAN),不让它一下子吃掉太多 CPU,防止“虚假唤醒”导致交互进程长期独占。
这实际上解释了 IO 密集型进程为什么会比 CPU 密集型进程响应快:它在树里的 vruntime 落后,每次唤醒都会往左移动,自然得到优先调度。这不是偏袒,而是 vruntime 计算的自然结果。
3.5 一段简单的计算实例
假设一个 CPU 上有两个进程 A 和 B,都是 nice 0,权重 1024。A 先运行了 2ms,vruntime 变成 2ms,B 的 vruntime 还是 0。此时 tick 触发检查,B 在树的最左端,A 被抢占,切到 B。B 运行 1ms 时 vruntime 变成 1ms,小于 A 的 2ms,所以 B 会继续运行。只有当 B 的 vruntime 追上并超过 A 后,调度器才可能把 CPU 还给 A。这样来回切换,最终两个进程的虚拟运行时间趋于一致,实际运行时间也就趋于一致。
如果 A 是 nice -5(权重 3121),B 是 nice 0(权重 1024),那么 A 的 vruntime 增长速度为 B 的 1024/3121 ≈ 0.328 倍。假设 B 先运行 1ms,vruntime 涨 1ms;A 再运行 1ms,vruntime 只涨 0.328ms。要等 A 的 0.328ms 落后追上 B 的 1ms,A 大约要再运行约 2ms。多次循环后,A 实际获得的 CPU 时间约是 B 的 3 倍,正好等于权重比 3121:1024。这就是 CFS 权重公平性的数学基础。
4. 核心路径源码级分析——从 tick 到抢占再到负载均衡
理论再清楚,不落到代码路径上总是虚的。这一节把 CFS 的几条关键路径串起来,按“选择下一个进程 → tick 更新 → 唤醒抢占 → 负载均衡”的顺序走一遍。
4.1 pick_next_task_fair:从红黑树上取最左节点
每次进程切换,最终都要调用调度类的 pick_next_task。CFS 的实现在 pick_next_task_fair 里。核心简化逻辑是:
static struct sched_entity * pick_next_entity(struct cfs_rq *cfs_rq) { struct sched_entity *se = __pick_first_entity(cfs_rq); if (se && cfs_rq->next) se = cfs_rq->next; // 唤醒路径可指定优先 entity return se; }__pick_first_entity 利用 rb_root_cached 缓存的 leftmost 节点直接拿到整棵红黑树的最小节点。这个节点就是当前 cfs_rq 中 vruntime 最小的进程。注意,这里还有个 cfs_rq->next 的优化:唤醒进程时,如果判断它应该抢占当前进程,调度器可以把目标进程暂存到 next 字段,让下次 pick_next_entity 优先选择它,减少一次入队出队操作。这是 CFS 的常见性能优化。
取到 se 后,还需要把它从红黑树里摘下来(如果它在树上),因为当前运行进程不应该留在可运行队列里:
static void put_prev_entity(struct cfs_rq *cfs_rq, struct sched_entity *prev) { if (prev->on_rq) { update_curr(cfs_rq); ... __dequeue_entity(cfs_rq, prev); } cfs_rq->curr = NULL; }4.2 scheduler_tick 与 entity_tick:周期性的公平检查
时钟中断驱动着 CFS 的“公平审计”。每次 tick 都会调用 scheduler_tick,最终 socket 进入 CFS 的 task_tick_fair,逐层调用 entity_tick。entity_tick 的逻辑是:
- 调用 update_curr 更新当前进程 vruntime。
- 如果当前进程的 on_rq 为真(还没被换出),检查它是否已经运行够 slice。如果运行时间超过其应得分额,调用 check_preempt_tick 决定是否抢占。
check_preempt_tick 的核心判断是:
static void check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr) { unsigned long ideal_runtime, delta_exec; struct sched_entity *se; ideal_runtime = sched_slice(cfs_rq, curr); delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime; if (delta_exec > ideal_runtime) { resched_curr(rq_of(cfs_rq)); return; } ... }如果当前进程的实际运行时间已经超过它本轮分到的 slice,就调用 resched_curr 设置 TIF_NEED_RESCHED。注意,这不是立即切换,而是标出“需要调度”,真正的切换发生在从内核态返回用户态、或者后续某个安全点上。这样做是为了保证进程切换不会打断临界区。
4.3 唤醒抢占:check_preempt_wakeup
当进程从睡眠中苏醒,会走到 try_to_wake_up → check_preempt_wakeup。CFS 通过 wakeup_preempt_entity 比较唤醒进程和当前进程的 vruntime 差距:
static int wakeup_preempt_entity(struct sched_entity *curr, struct sched_entity *se) { s64 gran, vdiff = curr->vruntime - se->vruntime; if (vdiff <= 0) return -1; gran = wakeup_gran(curr, se); if (vdiff > gran) return 1; return 0; }只有当唤醒进程的 vruntime 比当前进程小,且差距超过 wakeup_gran 时,才会抢占当前进程。这个门槛避免了“每次唤醒都抢”的抖动。如果进程只是睡眠了很短时间,vruntime 差距不够大,唤醒后就要排队。
这里我踩过很经典的坑:把 sched_wakeup_granularity 调得很小,结果高负载下上下文切换暴增。因为交互进程频繁唤醒,每次都要打断正在运行的批量任务,整个系统吞吐率掉得厉害。后来我才意识到,唤醒补偿不是越大越好,也不是越小越好,它是在“响应速度”和“切换开销”之间做权衡。
4.4 负载均衡:vruntime 差值补偿
CFS 是多队列设计,每 CPU 一个 cfs_rq,但是进程可以在 CPU 之间迁移。迁移发生在两类场景:唤醒迁移和定期负载均衡。
唤醒迁移的核心逻辑在 select_task_rq_fair。它会先尝试唤醒亲和(wake_affine),把进程放到上次运行的 CPU 附近,利用缓存温度;如果目标 CPU 繁忙,则找当前域里最空闲的 CPU(find_idlest_cpu)。这个选择影响很大,因为一旦选错,进程会在两个 CPU 之间来回迁移,造成缓存抖动。
定期负载均衡由 scheduler_tick 触发的 rebalance_domains 驱动。它按调度域(sched domain)层级逐层比较 CPU 负载,将任务从繁忙 CPU 迁移到空闲 CPU。迁移发生后,目标 CPU 的 cfs_rq 会执行 update_curr 和 attach_entity_load_avg,并调整进程的 vruntime,因为不同 CPU 的 min_vruntime 基准不同,直接移植会导致一段时间内公平性失真。
关于均衡,我想多说一句经验:负载均衡只看“平均负载”,它衡量的是运行队列的权重和,不是进程数。两个 CPU 上同样都是两个进程,但一个 CPU 上的进程 nice 都是 -10,另一个都是 10,那么平均负载差异会很大,均衡器会倾向于把 nice 10 的进程往第一个 CPU 迁移。这种迁移有时候反而会让响应变差,因为进程跨 NUMA 节点或跨缓存域了。内核引入了 forked/exec/wake 路径上的 NUMA 感知后改善很多,但实际部署时配合 taskset 手动绑核仍然是最可靠的兜底。
4.5 组调度:CFS 的二次公平
开启 CONFIG_FAIR_GROUP_SCHED 后,cfs_rq 变成层级结构。每个任务组在父级运行队列中作为一个 se 存在,它有自己的 cfs_rq,组内进程在组内树中竞争。组实体也有自己的 vruntime 和权重(由组内进程权重累加而来)。这带来一个效果:即使组内某进程跑得非常激进,也不会挤占其他组的 CPU 份额。
组调度的 vruntime 计算同样是分层递归的:进程运行时间先更新组内 cfs_rq 的 vruntime,再根据组实体权重折算成对父级 cfs_rq 的 vruntime 贡献。这也是很多人用 cgroup cpu 子系统时,明明看到组内进程权重相同,但整体份额仍然不均的原因——你没有看全整棵树的权重累加。
4.6 关于 EEVDF 的进化
前面提到内核 6.6 合入了 EEVDF。这里简单补充。EEVDF 彻底替代了“最左 vruntime 优先”的选取逻辑,改为每个调度实体维护一个“虚拟截止时间”(virtual deadline)。它在唤醒和抢占路径上能更精确地控制调度延迟,但仍然基于 vruntime 计算,权重表也和 CFS 完全一致。所以如果你看到较新内核的 sched_fair.c 里提供 place_entity、pick_eevdf 等函数,不要慌,前面这些 vruntime 核心概念依然适用。
5. 实操调优与观测——把 CFS 拉到台面上看
角度一变,调度器从来不是黑盒。只要方法对,你完全能观测到每个决策。
5.1 常用 sysctl 参数速查
| 参数路径 | 默认值 | 含义 |
|---|---|---|
| /proc/sys/kernel/sched_latency_ns | 6000000 | 目标调度周期 |
| /proc/sys/kernel/sched_min_granularity_ns | 750000 | 进程最小运行时间 |
| /proc/sys/kernel/sched_wakeup_granularity_ns | 1000000 | 唤醒抢占的最小 vruntime 差 |
| /proc/sys/kernel/sched_child_runs_first | 0 | 是否让子进程先跑 |
| /proc/sys/kernel/sched_autogroup_enabled | 1 | 是否启用自动分组 |
| /proc/sys/kernel/sched_cfs_bandwidth_slice_us | 5000 | cfs bandwidth 的默认 slice |
这里我提醒一句:调参前先记录基线。我曾经只调整了 sched_min_granularity_ns,从 0.75ms 调到 0.4ms,结果某个数据库进程的 sys 时间占比上升了 2%,因为上下文切换变多了。调度参数是全局的,影响面很大,别在生产环境随手改。
5.2 /proc/sched_debug 能告诉你的信息
系统开启 CONFIG_SCHED_DEBUG 后,可以输出 sched_debug 到内核日志:
echo 1 > /proc/sys/kernel/sched_debug之后 dmesg 会打出一大堆调度器状态,或者直接看 /proc/sched_debug。关键信息包括:
- 每个 CPU 的 cfs_rq 状态:nr_running、load、min_vruntime。
- 每个进程的 se 状态:vruntime、sum_exec_runtime、weight。
- 当前运行进程、上次运行进程。
排查问题时,我最常用的动作是看两个 CPU 的 min_vruntime 差。如果差距很大,说明负载均衡可能没做干净,或者有进程被长期钉在某个 CPU 上。此时可以用 taskset 手动绑核,再观察 min_vruntime 是否收敛。
5.3 ftrace 观测调度事件
调度器提供了多个 tracepoint:
- sched_switch:记录进程切换。
- sched_wakeup:记录进程被唤醒。
- sched_migrate_task:记录进程跨 CPU 迁移。
- sched_stat_runtime:记录进程实际运行时间。
用 ftrace 拉一段切换日志:
cd /sys/kernel/tracing echo 0 > tracing_on echo 'sched_switch sched_wakeup sched_stat_runtime' > set_event echo 1 > tracing_on sleep 2 echo 0 > tracing_on cat trace | head -200输出列非常直观:哪个进程切换到了哪个进程,切换时的 CPU 编号,进程的 vruntime delta 信息等。有一次我排查线上服务延迟抖动,就是通过 sched_wakeup 发现某个线程频繁被其他高优先级实时线程打断,而不是 CFS 内部问题。这类问题如果不看 trace,很容易被误判成网络或锁竞争。
5.4 perf sched 做调度延迟建模
perf sched 是 reconstructing 调度事件统计的利器:
perf sched record -- sleep 5 perf sched latency perf sched timehistperf sched latency 能输出每个任务的平均调度延迟、最大调度延迟。如果某个任务的 max delay 突然飙高,说明它在 run queue 排队时间过长。这时候你会需要回头检查是不是有大量实时进程穿插,或者负载均衡不畅。
5.5 现场实测:一个 nice 值引发的“假饥饿”
讲一个我实际排查过的案例。某服务在线程池里跑 CPU 密集任务,同时有少量 IO 线程负责转发请求。上线后 IO 线程的响应延迟暴涨。最初怀疑锁竞争,但排查后锁不是瓶颈。后来用 ftrace 抓 sched_stat_runtime,发现 Linux 把 CPU 密集线程的 nice 设成了 -5,IO 线程是默认 nice 0。权重比是 3121:1024,接近 3:1。当 CPU 密集线程数足够多时,IO 线程几乎抢不到 CPU。
把 CPU 密集线程 nice 调回 0,或者把 IO 线程 nice 提高到 -2,延迟立刻恢复。这个案例告诉我们:nice 值的真实作用远比“优先级数字”看起来大。改动一个 nice 值,等于给进程的 CPU 份额乘了一个系数。
6. 常见问题与排查技巧实录
6.1 为什么我的进程时间片忽长忽短?
CFS 没有固定时间片,slice 取决于当前运行队列里的进程数。两个进程时 slice 可能接近 3ms,十个进程时可能只有 0.75ms。看到运行时间变化是正常的,不是调度器出错。观察时记得同时看 nr_running。
6.2 为什么高 nice 进程(低优先级)看起来并没有“饿死”?
当系统中只有低优先级进程可运行时,即使它权重很低,也会获得全部 CPU。CFS 的公平是“在所有可运行进程之间按权重比例分配”,不是“给高优先级进程让路”。只有混合高权重和低权重进程时,高权重进程才会占据压倒性份额。所以看到低 nice 进程长时间占用 CPU 时,先检查系统里还有没有更高优先级的进程在排队。
6.3 为什么绑核之后性能反而下降?
taskset 固定进程到某个 CPU,会让负载均衡失效。如果该 CPU 上已经有很多进程,新绑进来的进程只能排长队。绑核建议只在“明确知道进程应该贴近哪颗 CPU”时使用,而不是随手固定。尤其是在 NUMA 架构上,跨节点内存访问代价远高于调度节省。
6.4 追踪不到真实运行时间?
很多人在 /proc/pid/stat 里看 utime 和 stime,或者用 top 观察。但要注意,CFS 的调度时间是按 sched_clock 计算的,可能包含或排除 IRQ 时间,取决于 CONFIG_IRQ_TIME_ACCOUNTING 等配置。如果只看进程自己的 utime,可能会漏掉被抢占期间的实际开销。建议配合 perf stat 或 bpftrace 统计。
6.5 实时进程和 CFS 进程混淆
RT 调度类的 SCHED_FIFO / SCHED_RR 进程根本不参与 CFS 红黑树。只要你创建了一个 RT 进程,并且它不主动睡眠,CFS 进程在它运行期间完全得不到 CPU。排查“CFS 进程饥饿”时,先排除实时类进程,用 ps 查看 rtprio 列,再决定是否往 CFS 方向查。
6.6 一个快速排查决策表
| 现象 | 优先排查项 | 观测工具 |
|---|---|---|
| 响应延迟暴涨 | 是否有 RT 进程、中断风暴 | ps、top、/proc/softirqs |
| CPU 长期满载但任务没推进 | 是否被抢占、锁等待 | perf sched timehist |
| 多核利用率不均 | 负载均衡失效、绑核、NUMA 感知 | /proc/sched_debug、taskset |
| 上下文切换异常高 | min_granularity 太小、唤醒抢占频繁 | vmstat、perf stat context-switches |
| vruntime 在 CPU 间差距大 | 负载均衡周期、迁移补偿 | /proc/sched_debug 对比 min_vruntime |
7. 写在最后的个人经验
有句话想分享给刚开始接触内核调度的朋友:调度器调优,本质上不是在调“算法”,而是在调“业务模型”。你的进程是批处理型、交互型还是 IO 密集型的,决定了你该关注 sched_latency 还是 sched_min_granularity 还是 wakeup_granularity。别把别人的内核参数模板直接抄到生产环境。
我个人的习惯是:先在测试机用 ftrace 抓一段真实业务的调度事件,统计 context switch 分布和 wakeup 抢占次数,看现有配置下切换开销是否成为瓶颈。如果 sys 时间占比高,才考虑调大 min_granularity;如果响应延迟不达标,才考虑调小 wakeup_granularity。改一个参数,测一轮,再改下一个。
另外一定要养成看统计信息的习惯。perf sched latency、/proc/sched_debug、bpftrace 脚本这三件套,能覆盖从宏观到微观的大部分调度问题。没有数据支撑的调度优化,最终都是玄学。希望这篇关于 CFS 的拆解能帮你少走一些弯路。