最近帮一个客户排查多任务服务器上的 CPU 延迟抖动问题,那台机器同时跑着流媒体转码和 Web API 网关,CPU 明明没有超卖,资源看起来也够,可网关的 p99 延迟偶尔还是会冲到几百毫秒。折腾到最后,问题既不在网络,也不在应用层 GC,而在 Linux 内核的 CPU 调度器这一层——CFS 默认的公平时间分配机制,在后台批处理任务没有合理控制调度粒度的场景下,照样能把延迟敏感进程的时间挤没。
CFS(Completely Fair Scheduler,完全公平调度器)自 2.6.23 起就作为 Linux 内核默认的 CPU 调度机制,一直稳定服役到今天。这篇文章我想从一个内核开发者的视角,把 CFS 的来龙去脉、关键数据结构、数学原理和实战调优讲透。无论你是正在读内核源码、排查性能抖动,还是面试前想系统地理解调度器,这篇都能给你一条完整且清晰的路径。
1. 从 O(1) 调度器到 CFS:一次调度思路的彻底转向
1.1 O(1) 调度器的时间片分层逻辑
要说清楚 CFS 解决了什么,就得先看它在 2.6.23 之前替代的 O(1) 调度器干了什么。
O(1) 这个名字是相对于 2.4 时代的 O(n) 调度器而言的,因为它在调度决策时无论系统里有多少进程,耗时都是常数级。它的设计核心是active / expired 双优先级数组:每个 CPU 维护两个大数组,每个优先级一个队列,调度时直接从 active 数组里找到最高优先级的非空队列,取第一个任务运行;时间片耗尽后就把它挪到 expired 数组里;active 数组空了就把两个数组交换。
这个机制的问题不少。最明显的是时间片的分配方式:普通进程按 nice 值(-20 到 19)映射出一套时间片,优先级高的进程分得多,优先级低的分得少,而且是固定的“长度”概念。交互式进程优先级往往不高,但它的特点是“短时间醒来、快速消费、然后继续睡”,O(1) 给它的时间片哪怕不小,它也只有醒来那一下能跑,等它再想跑的时候可能已经排在长队列后面了。所以 O(1) 时代,桌面系统里视频解码和后台编译抢 CPU 会非常明显,鼠标拖影、窗口卡顿是家常便饭。
另一个问题是用“优先级修正”来处理交互性:调度器会统计进程睡眠时长,如果经常睡眠就认为它是交互进程,偷偷抬高它的优先级。这在复杂负载下很容易误判,后台数据库的 I/O 等待线程也会被当成交互进程,最后把前端交互任务的 CPU 时间抢走大半。
1.2 CFS 用“虚拟时钟”解决公平性问题的思路
CFS 的思路和上述完全不沾边,它不画二维优先级表,也不区分“交互进程”和“批处理进程”。它只回答一个问题:每个进程应该获得 CPU 时间中的多大比例。这个比例由权重决定,交互性的好坏通过“睡眠补补偿 + 唤醒抢占”这套机制去体现,而不是靠猜进程类型。
CFS 最核心的抽象是虚拟运行时间,也就是 vruntime。每个调度实体(sched_entity)都维护一个 vruntime,调度器用红黑树把所有可运行实体按 vruntime 排序,每次总是挑 vruntime 最小的那个进程去运行。为什么这样是“公平”?可以类比分蛋糕——假设两个人胃口不同,一个食量大一个食量小,食量大的人每次吃得多、vruntime 涨得快,食量小的人每单位物理时间只涨一点 vruntime,那么系统经过几轮分配后,两人的 vruntime 会逐渐对齐到同一水平线。谁落后了,谁就有资格多获得一些 CPU 时间。这个“公平”的单位不是物理时间,而是按权重归一化后的虚拟时间,权重越大,单位时间内 vruntime 涨得越慢,实际获得的 CPU 比例就越高。
这套思路可以说把调度器从“管理者分配固定配额”变成了“按需收敛的闭环反馈系统”,不需要任何外部干预,系统天生就向公平点收敛。O(1) 那种靠预分时间片然后强制剥夺的方式,在交互场景下天然吃亏,而 CFS 在大多数轻载和中载场景下,交互延迟都控制得更好。
2. vruntime 的数学底细:调度周期与权重的拆解
2.1 最核心的公式:vruntime 增量如何计算
CFS 的 vruntime 不是凭空加的,它的更新逻辑是:
vruntime += delta_exec * NICE_0_LOAD / weight其中delta_exec是本次实际运行的物理时间,NICE_0_LOAD是 nice 0 对应的权重(默认 1024),weight是当前进程自己的权重。所以:
- 权重为 1024 的 nice 0 进程,跑多少物理时间,vruntime 就加多少;
- 权重小于 1024 的 nice 正值进程,跑同样的物理时间,vruntime 加得快;
- 权重高于 1024 的 nice 负值进程,vruntime 加得慢。
举个实际例子。假设系统里只有两个 CPU 密集型进程 A 和 B,A 的 nice 值是 0,权重 1024;B 的 nice 值是 5,权重约 335。两个进程都很饥饿,那么 CFS 给它们分配 CPU 时间的比例大约是:
A 获得的比例 = 1024 / (1024 + 335) ≈ 75.3% B 获得的比例 = 335 / (1024 + 335) ≈ 24.7%A 跑 100ms 物理时间,vruntime 增加约 100ms;B 跑 100ms 物理时间,vruntime 增加约 305ms。所以调度器观测到的是两者比较虚拟时间,而不是物理时间。跑一段时间后,A 的 vruntime 和 B 的 vruntime 会在某个动态平衡点上不断交错,这个平衡点对应的物理时间比例,恰好就是权重比例。
这个公式是整个 CFS 的基石,理解它之后,再去看内核源码里那些update_curr、calc_delta_fair函数就不会迷路。update_curr做的事就是拿当前时间和上次调度点相减得到delta_exec,然后代入上面的公式,更新当前任务的 vruntime,同时更新 cfs_rq 的min_vruntime。
2.2 nice 值与权重映射的关系
权重从哪来?内核维护了一张长度为 40 的表,把 nice 值 -20 到 19 映射成一组权重值。这张表的关键部分是:
| nice 值 | 权重 |
|---|---|
| -20 | 88761 |
| -10 | 9549 |
| -5 | 3355 |
| 0 | 1024 |
| 5 | 335 |
| 10 | 110 |
| 15 | 36 |
| 19 | 15 |
有规律吗?有的。nice 值每降低 1,权重大约乘以 1.25;nice 值每增加 1,权重大约除以 1.25。比如 nice 0 的 1024 除以 1.25 等于 819.2,内核取整成了 820,也就是 nice 1 的权重。为什么要取 1.25 这个基数?历史原因是 nice 语义设计时希望每差 1 级,CPU 时间占用差约 10%(实际是 1.25 倍对应 20% 左右的 CPU 比例差),“每降 1 级多 10% CPU” 的旧界被进一步精确化。注意这张表是为了在数学上用整数运算模拟浮点指数关系,所以相邻项并不严格是 1.25 倍,但整体趋势一致。
从这张表还能看出一个实用规律:nice 值差分得太细,实际效果增长越来越不明显。nice 0 到 nice 5,权重差了大约 3 倍,对 CPU 密集任务来说效果立竿见影;但从 nice 15 到 nice 19,权重差只有 2.4 倍,绝对值都很小,调度器对这些低权重进程的照顾程度差别已经不大。所以在实际调优时,没必要把某个进程拉到 nice -20 那样的极端值,多数情况下 nice 差 5 级以内就能解决问题,尤其不要轻易给核心进程设负优先级——它会挤占所有普通任务,包括系统管理用的进程。
2.3 调度周期与“延迟”边界
CFS 里还有个和 vruntime 强相关的参数:调度周期(sched_period)。它刻画的是“CPS 保证所有可运行任务至少在它对应的周期内各运行一次”的时间窗口。
内核里维护了sysctl_sched_latency(默认 6ms)和sysctl_sched_min_granularity(默认 0.75ms)。调度周期计算逻辑大致是:
period = sched_latency_ns if (nr_running * min_granularity > period) period = nr_running * min_granularity也就是说,当运行队列里的任务数不多时,调度周期就是固定的 6ms;任务非常多时,周期自动拉长,保证每个任务至少能运行一个 min_granularity。比如 100 个任务,period 就被拉长到 75ms,每个任务转一圈的时间变长,但至少都有 0.75ms 的 CPU 时间。
系统中立地看,CFS 的目标并不是“每个任务每毫秒都被执行”,而是“每个任务在每个调度周期内获得它应得的 CPU 比例”。响应延迟的改善靠的是“让没有用完自己 vruntime 配额的进程尽快获得 CPU”,低频等待任务在唤醒时会带着很小的 vruntime 进入红黑树,自然会插到最左边,从而快速被选中。这也是为什么 CFS 不需要专门猜谁是交互进程——等待时间长、需求量小的进程天然 vruntime 落后,天然优先。
3. 红黑树与调度栈:CFS 一次调度决策的完整路径
3.1 调度实体与调度类:CFS 在调度器中的位置
CFS 不是孤立的一套逻辑,它由sched_class这个抽象接口接入 Linux 的调度框架。Linux 内核按优先级维护了五个调度类:
| 调度类 | 实现文件 | 说明 |
|---|---|---|
| stop_sched_class | sched_stop.c | 最高优先级,CPU 停机和自旋等待迁移任务用 |
| dl_sched_class | sched_deadline.c | 实时截止时间调度 |
| rt_sched_class | sched_rt.c | 实时进程调度 |
| fair_sched_class | sched_fair.c | CFS,普通时间片任务 |
| idle_sched_class | sched_idle.c | 空闲任务 |
调度器在挑选下一个任务时,从 stop 到 idle 逐级询问每个调度类“你有没有可运行的任务”,第一个答复有的类负责产出任务。普通进程默认进入 fair 类,即 CFS。CFS 管理的对象是调度实体sched_entity,它并不是 task_struct 本身,task_struct 里内嵌了一个 sched_entity(通常叫 se)。组调度模式下,每个 cgroup 也对应一个 se,可以嵌套——一个 se 可以是一个任务,也可以代表一个子调度实体队列。
sched_entity的关键字段很简单:
struct sched_entity { struct load_weight load; /* 权重 */ struct rb_node run_node; /* 红黑树节点 */ struct list_head group_node; unsigned int on_rq; /* 是否在运行队列 */ u64 vruntime; /* 虚拟运行时间 */ u64 prev_sum_exec_runtime; ... };3.2 红黑树的键值与操作开销
为什么选红黑树而不是简单的数组或者链表?因为 CFS 的核心操作有两个:频繁插入/删除新唤醒的任务,频繁取“vruntime 最小”的任务。链表取最小点是 O(n),数组插入要移动大量元素,都不适合。红黑树是自平衡二叉搜索树,插入和删除都是 O(log n),而且 CFS 缓存了红黑树的最左节点指针,leftmost直接指向最小 vruntime 的节点,这样取下一个任务的复杂度是 O(1)。
红黑树里节点的排序键是 se->vruntime 与 cfs_rq->min_vruntime 的差,或者说比较两个 vruntime 的先后。为了避免 vruntime 数值无限增长,内核这里有个非常容易忽略的细节:min_vruntime会单调递增,新入队的进程如果 vruntime 太老(比如刚从睡眠中醒来),系统会做钳制,把它拉到不小于 min_vruntime 减去某个阈值的水平。这个机制叫“vruntime 规范化/钳制”,防止一个长时间睡眠的进程醒来时 vruntime 远小于所有运行进程,从而独占 CPU 很久。
从读源码角度,红黑树操作集中在kernel/sched/fair.c的__enqueue_entity和__dequeue_entity。__enqueue_entity调用rb_add_augmented_cached,通过leftmost缓存保证插入后如果有更小的 vruntime 节点会更新最左指针。__dequeue_entity会检查被删的是不是当前最左节点,如果是就更新leftmost为后继节点。
3.3 一次时钟中断引发的调度决策链
把 CFS 放到调度主链路里看,一次典型调度决策是这样发生的:
- 硬件定时器触发时钟中断,进入内核,最后走到
scheduler_tick(); scheduler_tick找到当前 CPU 上的运行队列的 fair 类,调用task_tick_fair();task_tick_fair判断当前进程是否运行超过理想配额,如果超过,设置TIF_NEED_RESCHED标志;- 中断返回用户态或内核态时,检查
TIF_NEED_RESCHED,触发schedule(); schedule()调用__schedule(),其中核心两步是pick_next_task_fair()和put_prev_task()(严格说一个新的调度周期会先 dequeue 再 pick,但大体类似);pick_next_task_fair从当前 cfs_rq 的红黑树拿到最左节点,取它的 task_struct,完成 context switch。
除了时钟中断,还有一类是唤醒抢占(wakeup preemption)。当进程 A 唤醒进程 B 时,调度器会计算 B 的 vruntime,如果 B 的 vruntime 比当前进程小,并且差值超过sched_wakeup_granularity_ns,就会触发抢占,设置当前进程的TIF_NEED_RESCHED。这套机制让刚醒来的、有交互性质的进程能很快上 CPU,而不是排到队尾。内核 5.x 里这个开关由sched_feat WAKEUP_PREEMPTION控制。
如果你在 perf 里看到定时器中频繁发生调度切换,可以沿着这条链去抓sched_switch事件,结合sched_wakeup事件判断每次调度是被谁触发的。实际排查中我经常用 bpftrace 挂sched_switch的 tracepoint,直接打印 prev_comm/next_comm,先看哪些进程在互相切换,再结合 vruntime 分析谁抢谁。
4. 多核伸缩、组调度与负载均衡的真实挑战
4.1 从单核公平到多核均衡:调度域的作用
CFS 的单核逻辑只维护一个运行队列,但现代服务器都是几十核起步,不可能所有进程都放进一个队列里全局排序——那会有巨大的锁竞争和缓存失效。所以内核的做法是:每个 CPU 都有自己的运行队列(cfs_rq),CFS 在每个队列上独立选下一个进程。跨 CPU 之间的公平由负载均衡器负责。
负载均衡器按调度域(sched domain)分层组织 CPU,从 SMT 域、MC 域(一个物理 CPU 内的多个核心)、NUMA 域,逐层向上。不同域层级有着不同距离的“迁移代价”,越往上层迁移代价越高。内核会定期检查各 CPU 的运行队列负载,把负载高的一侧任务迁移到负载低的一侧。
“负载”在现代内核里用的是 PELT,即 Per-Entity Load Tracking,每个调度实体维护一个基于半衰期衰减的负载均值。它更新时按 1ms 粒度,历史负载以约 32ms 的半衰期衰减。注意 PELT 的“负载估算”和 vruntime 不是一回事,vruntime 管公平顺序,PELT 管的是迁移决策。
多核 CFS 有个经典问题,就是“全局怎么看都均衡、局部却延迟高”。一个进程被迁移到新 CPU 之后,它的vruntime会和目标 CPU 的min_vruntime做一次归一化校准,避免因为两 CPU 调度进度不同导致新 CPU 上的进程永远被“老居民”压制。这个细节在阅读kernel/sched/fair.c的migrate_task_rq_fair时能看到。
4.2 用 nice 调不够了,就要靠 cgroup 组调度
单机场景下 nice 值可以手工调,但真实服务器上会有几十上百个进程,而且分属不同业务方。比如一台机器同时跑 Nginx、Java 微服务、日志采集 Agent 和凌晨的批处理任务,给每个进程调 nice 是非常脆弱的方案——进程起来就没了,运维根本追不上。
这时候要用 CFS 的组调度能力,也就是 cgroup 的 cpu 子系统。你可以把一个业务组的所有进程放进同一个 cgroup,给这个 cgroup 设置cpu.shares,组内的进程再算各自权重。组调度不会管组内用不用得完配额,但它会保证“按权重比例公平分配组间 CPU”,组内谁饥饿谁先跑。
比较典型的是把 Web 组和批处理组各自建 cgroup,Web 共享 70% 的 CPU 份额,批处理共享 30%。如果 Web 组负载低,批处理仍然可以用闲置的 CPU,不会白费算力。这比按进程设 nice 粒度粗糙但极其省心。
从数据结构上看,组调度就是让 cfs_rq 挂到一个更高层次的队列里去参与竞争。task_group结构体里有自己的 cfs_rq 和 se,层层嵌套,公平性在每一层递归下去。初学的时候建议从扁平结构开始理解,组嵌套时最上层公平影响比组内优先级影响更大,先设好组间权重,再考虑组内 nice。
4.3 睡眠进程与唤醒抢占的“补偿”机制
CFS 对睡眠进程有一种隐含的“补偿效果”。进程睡眠时,它不再运行,vruntime 不增长;别的进程继续跑,vruntime 持续上涨。等它醒过来,它的 vruntime 自然就显得“远远落后”,红黑树排序后它插到很靠左的位置,于是获得了优先运行权。
这本身是一种对 I/O 密集/交互型任务的天然优待,但太激进也会有副作用。一个进程如果频繁短期睡眠(比如每次只睡 200us),它会反复获得先发优势,长时间霸占 CPU,导致其他 CPU 密集进程被饿着。内核历史上专门处理过“睡眠偷跑”问题,用sched_feat GENTLE_FAIR_SLEEPERS控制睡眠补偿强度。在新内核里,唤醒时钳制 vruntime 的机制会对补偿幅度做限制,避免过度补偿。你在调优时遇到“某个常睡进程反而占用高 CPU”时,不要先怀疑调度器坏了,先看看是不是睡眠时间极短、唤醒极其频繁的模式触发了补偿放大器,这种场景直接用 cgroup 的 shares 限制它的权重收益会更有效。
5. 观察与调优:用纪律代替玄学
5.1 用 proc 和 trace 看穿 CFS 的真实状态
很多人在排查调度延迟时,第一反应是改参数,但我建议顺序反过来——先观察,再调参。
最简单的观察手段是/proc/sched_debug,它输出每个 CPU 的运行队列情况,以及当前任务和可运行任务的详细信息。里面的关键字段包括:
runnable tasks: task vruntime ... nginx-1234 1112345.123 ...重点关注每个任务的 vruntime 差值,如果某些任务 vruntime 长期远远落后(数量级差异巨大),说明它们被持续抢占,可能是该调高优先级,也可能是负载已经远超 CPU 能力。
更精细的观察用 tracepoint。可以用 perf 记录调度事件:
sudo perf sched record -- sleep 5 sudo perf sched latency --sort max输出里会列出每个任务的调度延迟分布,包括平均延迟和最大延迟,这是找“谁拖累了谁”的最快路径。嫌 perf sched 太重的话,bpftrace 也可以:
sudo bpftrace -e 'tracepoint:sched:sched_switch { @[comm] = count(); }'先确认哪些进程参与切换,再对着/proc/sched_debug逐个查 vruntime。如果看到某个交互进程被某个后台任务反复抢占,而且后台任务的 vruntime 已经很落后,说明后台任务确实长期饥饿,抢占是有道理的,问题根源在 CPU 总资源不足;如果后台任务 vruntime 明明领先还频繁抢占,那就需要检查sched_wakeup_granularity_ns是不是设得太大(准确说是要检查唤醒抢占的参数设置)。
5.2 几个值得调整的调度参数与适用场景
CFS 的可调参数入口是多级,既有 sysctl 又有/sys/kernel/debug/sched/features,还有 cgroup 内的cpu.shares。下面这几个是我实际项目里用过的:
sysctl_sched_min_granularity_ns:单次运行最小量子,默认 750000ns。调小它可以让进程切换更频繁、响应更快,但会增加上下文切换开销;调大它适合批处理场景,减少切换。注意不建议设到 1ms 以下甚至几百 us,除非你充分评估过 syscall 和 cache miss 的代价。sysctl_sched_wakeup_granularity_ns:默认 1ms 左右。这个值越大,唤醒抢占越不积极;越小,刚醒来的进程越容易立刻抢 CPU。延迟敏感服务希望小,但小到 0.5ms 以下,可能造成频繁乒乓切换。sched_child_runs_first:默认关闭。开启后 fork 出来的子进程先运行,对某些 fork 频繁且子进程快速消费 CPU 的场景有收益,但可能让父进程的唤醒延迟变高。sysctl_sched_autogroup_enabled:很多发行版默认开。它把“同一会话(终端/SSH 登录)的进程”自动分成一组,防止一个 make 进程把整个系统的交互性拉垮。如果你发现明明只有一个用户跑构建任务,桌面却卡成 PPT,先看看 autogroup 有没有被关掉。
线上排查时还有一个非常重要的纪律:每次只调一个参数,记录前后数据对比。调度器参数之间存在相互作用,比如 min_granularity 调小后 wakeup_granularity 可能也需要相应调整,两个一起动,出了问题你根本不知道是谁的锅。
5.3 我个人在项目里用过的调优策略
最后分享一个实际案例。那个转码服务器最后没有调任何全局参数,而是给 Web 网关进程和转码进程各建了一个 cgroup,Web 组 shares 设为 2048,转码组 shares 设为 1024。同时把转码进程组里的线程 nice 值整体设为 10,而不是给进程设 0。效果是 API 网关的 p99 延迟从 300ms 量级降到 40ms 左右,转码吞吐只损失了约 8%。
这背后的逻辑很简单:cgroup shares 保证组间的比例,组内用 nice 拉低批处理优先级,两层叠加,比只调全局 sysctl 更可控。调参不要追求极限,要追求可预期。全局参数会影响所有任务,一旦误调,整个主机交互性都崩。用 cgroup 把调优范围圈在一个业务组内,出问题也只是影响那个组,回滚成本极低。
另外一个容易被忽视但很实用的细节是,在 NUMA 机器上,CFS 的负载均衡会尽量保持进程在同一个 NUMA 节点内迁移,跨节点迁移会带来内存访问延迟的大幅上升。所以调优时除了看调度参数,还要用numactl --hardware和perf stat -e remote_node_loads检查跨节点访问比例。如果发现远程内存访问占比高,优先修复内存绑定问题,调度器参数救不了内存距离。
这套 CFS 的机制看起来复杂,核心就三件事:权重决定比例、vruntime 决定顺序、红黑树保证效率。把这三根支柱搞透,再遇到 CPU 延迟抖动,你就知道该看哪一层了——先看是不是负载过载,再看是不是参数不合适,最后看是不是组间配置问题。照这个顺序排查,绝大多数调度层面的幺蛾子都能在半小时内定位。