
1. 从一次线上抖动说起为什么要啃 CFS 调度器前阵子帮朋友排查一个服务响应毛刺的问题现象很典型一台 8 核的机器跑着一个多线程的数据处理服务平时 P99 延迟稳定在 20ms 左右但每隔几分钟就会冒出一两个 200ms 以上的尖刺。CPU 使用率看着不高内存也够磁盘 IO 也没打满监控上一切正常但业务侧就是能感知到卡顿。最后定位下来问题出在调度上——几个计算密集型的后台线程和主业务线程抢 CPUCFS 的调度粒度、唤醒抢占、负载均衡几个机制叠在一起导致了这种周期性的抖动。这件事让我意识到很多人对 Linux 调度的理解停留在“时间片轮转”“优先级”这种模糊概念上真到排查性能问题的时候根本不知道从哪下手。CFSCompletely Fair Scheduler完全公平调度器从 2007 年进入内核主线到现在已经是最核心的进程调度器它管着普通进程的调度时机、选任务逻辑、以及多核之间的负载均衡。你写的每一行用户态代码最终都要经过它才能拿到 CPU。这篇内容我打算把 CFS 拆开讲透重点放在三块调度时机什么时候触发调度、选任务从就绪队列里挑哪个跑、负载均衡多核之间怎么分活。会涉及sched_class这套调度类框架、vruntime的计算、sched_latency和min_granularity的关系、CFS的唤醒抢占逻辑以及load_balance的触发路径。适合已经会写代码、会用 Linux但想往底层再走一步的开发和运维同学。不需要你读过内核源码但至少要能看懂 C 语言和基本的数据结构。我尽量不堆源码而是把“为什么这么设计”讲清楚再配上能直接上手验证的命令和参数。看完之后你至少能做到两件事一是遇到 CPU 调度相关的性能问题时知道往哪个方向查二是能根据业务特点去调/proc/sys/kernel/下面那几个调度参数而不是只会nice和renice。2. 调度类框架CFS 到底站在哪一层2.1 sched_class 这套“插槽式”设计解决了什么问题Linux 内核里同时存在多种调度策略普通进程用 CFS实时进程用 RT 调度器还有 deadline 调度器、idle 调度器。如果把这些逻辑全塞进一个函数里用一堆 if-else 判断代码会烂到没法维护。所以内核抽象出了sched_class这个结构体本质上是一组函数指针的集合每种调度器实现自己的一套方法内核在需要调度的时候通过sched_class去调用对应实现。这套设计的好处是可扩展。新增一种调度策略只要实现sched_class里定义的接口注册进去就行不用动核心调度路径。目前主线内核里调度类的优先级从高到低大致是stop_sched_classdl_sched_classdeadlinert_sched_class实时fair_sched_classCFSidle_sched_class。调度器选任务的时候会从最高优先级的调度类开始问“你有任务要跑吗”有就选它没有就往下问。这里有个关键点很多人会忽略CFS 只负责普通进程。你nice值在 -20 到 19 之间的进程默认策略SCHED_NORMAL走的就是 CFS。而SCHED_FIFO和SCHED_RR的实时进程走 RT 调度器优先级永远压过 CFS。所以如果你发现某个实时进程把 CPU 吃满了普通进程再nice也没用因为根本轮不到 CFS 出场。2.2 fair_sched_class 的核心方法一览fair_sched_class里定义了一堆回调实际排查问题时最常打交道的是这几个方法作用触发场景enqueue_task_fair把任务加入 CFS 就绪队列任务被唤醒、新建dequeue_task_fair把任务从就绪队列移除任务阻塞、退出pick_next_task_fair从红黑树里选下一个任务调度器选任务时task_tick_fair周期性地更新运行统计每个 tick 中断check_preempt_tick判断当前任务是否该被抢占tick 中断里task_fork_fair处理新 fork 出来的任务fork 系统调用select_task_rq_fair为新任务选一个 CPU唤醒、fork这几个方法串起来就是 CFS 的完整生命周期。比如一个任务被唤醒先走select_task_rq_fair选 CPU再走enqueue_task_fair入队然后可能触发check_preempt_curr判断要不要抢占当前正在跑的任务。理解这条链路比死记源码有用得多。2.3 红黑树CFS 的“公平”靠什么数据结构撑起来CFS 的核心数据结构是一棵红黑树按vruntime虚拟运行时间排序。vruntime越小说明这个任务“欠”的 CPU 时间越多越应该被优先调度。每次选任务就是取红黑树最左边那个节点也就是vruntime最小的任务。为什么用红黑树而不是堆或者链表因为 CFS 需要频繁地插入、删除、查找最小值。红黑树的插入删除是 O(log n)取最小值是 O(log n)最左节点而且能保证最坏情况下的平衡。如果用普通二叉搜索树遇到有序插入会退化成链表O(n) 的复杂度在任务多的时候会拖垮调度器。堆虽然取最小值是 O(1)但删除任意节点不方便而任务阻塞时需要按vruntime精确删除。红黑树的键是vruntime但实际实现里用的是min_vruntime作为基准来维护相对值避免vruntime无限增长导致溢出。这个细节后面讲vruntime计算的时候再展开。3. 调度时机内核到底在哪些瞬间决定“换人”3.1 主动调度与被动调度两条完全不同的路径调度时机分两大类主动调度和被动调度。主动调度是任务自己调用schedule()让出 CPU。典型场景是任务要等 IO、等锁、等信号知道自己跑不下去了主动说“我不占了你找别人吧”。这种调度是任务自愿的内核处理起来简单直接走__schedule()选下一个任务。被动调度是任务没想放弃 CPU但内核觉得“你该让位了”强制把它换下来。触发点主要有两个tick 中断和唤醒抢占。tick 中断是周期性的默认 250Hz 或 1000Hz取决于内核配置每次中断都会检查当前任务跑了多久是否超过了它应得的时间片。唤醒抢占是当一个更高优先级或者vruntime更小的任务被唤醒时内核判断它是否应该立刻抢占当前任务。这两条路径的区别很重要。主动调度是“礼貌让座”被动调度是“被请下台”。排查性能问题时如果发现大量被动调度说明 CPU 竞争激烈如果大量主动调度可能是 IO 等待或者锁竞争导致的。3.2 tick 中断里的 check_preempt_tick 做了什么check_preempt_tick是 CFS 被动调度的核心判断函数逻辑大致是这样计算当前任务的理想运行时间ideal_runtime也就是它这一轮应该跑多久。如果当前任务实际运行时间超过了ideal_runtime标记需要调度。如果没超过但红黑树里最左节点的vruntime比当前任务的vruntime小很多差值超过ideal_runtime也标记需要调度。ideal_runtime的计算跟sched_latency和min_granularity有关。sched_latency是调度周期默认 6ms内核 5.15 之后有调整具体看配置意思是“所有可运行任务在这么长时间内都应该至少跑一次”。min_granularity是最小调度粒度默认 0.75ms防止任务太多时每个任务分到的时间太短上下文切换开销盖过实际工作。公式是ideal_runtime max(sched_latency / nr_running, min_granularity)。假设有 4 个任务在跑sched_latency是 6ms那每个任务理想运行时间是 1.5ms。如果有 20 个任务6/20 0.3ms小于min_granularity0.75ms那就取 0.75ms这时候实际调度周期会被拉长到 20 * 0.75 15ms。这个设计有个隐含的取舍任务越多每个任务分到的时间越少但不会少于min_granularity保证上下文切换不至于太频繁。代价是任务多的时候调度延迟会变大交互式任务的响应可能变差。3.3 唤醒抢占新任务什么时候能立刻抢到 CPU当一个任务从阻塞状态被唤醒比如 IO 完成、锁释放它会走enqueue_task_fair入队然后内核会调用check_preempt_curr判断是否要抢占当前任务。CFS 的判断逻辑是如果唤醒的任务vruntime比当前任务小且差值超过一个阈值通常是ideal_runtime或者sysctl_sched_wakeup_granularity就触发抢占。sysctl_sched_wakeup_granularity默认是 1ms内核版本不同可能有差异意思是“新任务的vruntime要比当前任务小至少 1ms 才有资格抢占”。这个参数的存在是为了避免频繁的唤醒抢占导致上下文切换过多。如果设得太小交互式任务响应会更快但系统整体吞吐可能下降设得太大交互式任务会感觉卡顿。这里有个实际经验数据库、消息队列这类对延迟敏感的服务可以适当调小sched_wakeup_granularity让唤醒的任务更快拿到 CPU。但别调得太激进否则上下文切换的开销会吃掉收益。我一般建议先测基线再小步调整观察 P99 延迟和 CPU 上下文切换次数的变化。3.4 调度时机的验证方法光看理论不够得能验证。最直接的工具是perf sched# 记录 5 秒的调度事件 perf sched record -- sleep 5 # 查看调度延迟统计 perf sched latency # 查看调度事件的时间线 perf sched scriptperf sched latency会输出每个任务的调度延迟包括平均延迟、最大延迟、以及被调度了多少次。如果某个任务的最大延迟特别大说明它等 CPU 等得太久可能是负载太高或者优先级设置有问题。另一个工具是trace-cmd配合sched_switch和sched_wakeup事件能看到完整的调度切换过程trace-cmd record -e sched_switch -e sched_wakeup trace-cmd report输出里能看到每个 CPU 上任务切换的时间点、切换前后的任务、以及唤醒事件。排查毛刺问题时把sched_switch的时间戳和业务日志对齐往往能直接定位到是哪个任务抢了 CPU。4. 选任务vruntime 怎么算红黑树怎么选4.1 vruntime 的本质把优先级折算成时间权重vruntime是 CFS 的灵魂。它的设计思想是不同优先级的任务实际运行时间的“价值”不一样。高优先级任务跑 1ms折算成vruntime可能只有 0.5ms低优先级任务跑 1ms折算成vruntime可能是 2ms。这样红黑树按vruntime排序高优先级任务的vruntime增长慢自然就更频繁地被选中。具体公式是vruntime delta_exec * (NICE_0_LOAD / weight)。delta_exec是实际运行时间weight是任务权重由nice值决定。nice0 的权重是 1024nice每降低 1权重增加约 1.25 倍每升高 1权重减少约 1.25 倍。这个 1.25 倍不是随便定的是内核为了让nice值对应的 CPU 时间比例接近 10% 的差距而选的。举个例子nice0 的任务权重 1024nice1 的任务权重约 820。两个任务同时跑nice0 的vruntime增长速度是nice1 的 820/1024 ≈ 0.8 倍。也就是说nice0 的任务跑 1msvruntime增加 1msnice1 的任务跑 1msvruntime增加 1.25ms。最终红黑树会倾向于让nice0 的任务多跑比例大约是 1024:820。4.2 min_vruntime防止 vruntime 溢出和“老任务”饿死如果每个任务的vruntime都从 0 开始独立增长会有两个问题一是长时间运行后vruntime可能溢出虽然 64 位下基本不可能二是新创建的任务vruntime是 0会立刻抢占所有老任务导致老任务饿死。内核的解法是维护一个 per-CPU 的min_vruntime记录这个 CPU 上所有任务vruntime的最小值。新任务入队时vruntime不是设成 0而是设成min_vruntime或者min_vruntime加上一个小的偏移。这样新任务不会因为vruntime太小而无限抢占老任务也不会因为vruntime太大而永远排不上。min_vruntime会随着任务的出队和入队动态更新始终跟踪红黑树里最小的vruntime。这个机制保证了 CFS 的“公平”是相对公平而不是绝对公平。新任务和老任务在同一个基准上竞争但新任务不会因为“资历浅”而被歧视。4.3 pick_next_task_fair 的选任务流程pick_next_task_fair的逻辑可以简化成这几步如果当前 CPU 的就绪队列cfs_rq为空返回 NULL让上层调度类去选。如果cfs_rq-nr_running等于 1直接返回唯一那个任务不用查红黑树。否则取红黑树最左节点也就是vruntime最小的任务。如果这个任务是一个组调度实体group scheduling entity需要递归进入它的子队列继续选。选中任务后更新min_vruntime并把任务从红黑树移除因为要开始跑了。这里有个优化nr_running 1时直接返回避免红黑树操作。这个优化在单任务场景下很有效因为红黑树的插入删除虽然是对数复杂度但常数不小。组调度是另一个重要概念。如果开启了CONFIG_FAIR_GROUP_SCHEDCFS 会按 cgroup 分组每个组作为一个调度实体参与红黑树排序。组内的任务再在组内竞争。这样做的目的是让 CPU 时间在组之间公平分配而不是在任务之间。比如两个 cgroup一个跑 1 个任务一个跑 10 个任务没有组调度的话10 个任务的那个组会拿到 10/11 的 CPU有组调度的话两个组各拿 50%组内再分。4.4 选任务的实操观察想看 CFS 实际选任务的过程可以用bpftrace挂pick_next_task_fairbpftrace -e kprobe:pick_next_task_fair { printf(CPU %d picking task\n, cpu); }更实用的是看/proc/sched_debug里面会输出每个 CPU 的 CFS 队列状态cat /proc/sched_debug输出里能看到cfs_rq的nr_running、min_vruntime、以及红黑树里每个任务的vruntime、se.exec_start、se.sum_exec_runtime等。如果发现某个任务的vruntime远小于其他任务说明它被调度得太少可能是被高优先级任务压制了。注意/proc/sched_debug在任务很多的时候输出会非常大建议先grep过滤或者只看特定 CPU 的部分。5. 负载均衡多核之间怎么“分活”5.1 什么时候触发负载均衡单核上的 CFS 只管自己那一亩三分地多核之间的任务分配靠负载均衡。触发时机主要有四个周期性均衡每个 CPU 的调度 tick 里如果距离上次均衡超过sched_balance_interval默认跟 CPU 数量有关就尝试做一次均衡。idle 均衡CPU 要进入 idle 状态前会主动去其他 CPU 拉任务避免自己闲着而别人忙死。新任务 forkfork出新任务时select_task_rq_fair会选一个相对空闲的 CPU。唤醒均衡任务被唤醒时如果原 CPU 太忙可能会被迁移到其他 CPU。这四种均衡的粒度和开销不一样。周期性均衡最重会扫描多个 CPU 的队列idle 均衡最轻只在自己要闲下来时做fork 和唤醒均衡是单次决策开销最小。5.2 等开销负载均衡不是比任务数是比“权重”很多人以为负载均衡就是“任务多的 CPU 分给任务少的”其实 CFS 比的是加权负载。每个任务的负载不是 1而是它的权重由nice值决定。一个nice0 的任务负载是 1024一个nice10 的任务负载可能只有 110 左右。所以一个跑着 10 个nice10 任务的 CPU负载可能比一个跑着 2 个nice0 任务的 CPU 还低。cfs_rq里维护了load.weight表示这个队列的总权重。负载均衡的目标是让各个 CPU 的load.weight尽量接近。如果两个 CPU 的负载差超过一定阈值sched_balance_gap或者imbalance计算出来的值就会触发迁移。迁移的时候不是随便搬一个任务而是要找“搬走之后能让两边更平衡”的任务。内核会计算imbalance然后从源 CPU 的红黑树里找合适的任务。如果任务绑定了 CPUsched_setaffinity就不能迁移如果任务正在跑也不能迁移只能等它让出 CPU。5.3 NUMA 架构下的负载均衡多路服务器上CPU 访问本地内存和远程内存的延迟差好几倍。NUMA 感知的负载均衡会尽量让任务在本地节点跑避免跨节点迁移导致内存访问变慢。numactl --hardware能看到节点的内存和 CPU 分布。如果发现跨节点迁移频繁可以调/proc/sys/kernel/numa_balancing或者用numactl把关键任务绑定到特定节点。但 NUMA 均衡是个复杂话题盲目关闭可能导致负载不均反而更慢。我的经验是先看numastat的numa_hit和numa_miss比例如果numa_miss很高说明跨节点访问多再考虑调整。5.4 负载均衡的验证与调参验证负载均衡效果最直接的是看每个 CPU 的负载# 查看每个 CPU 的负载 mpstat -P ALL 1 # 查看 CFS 队列的负载权重 cat /proc/sched_debug | grep -E cpu#|load如果发现某个 CPU 长期 100% 而其他 CPU 闲着可能是负载均衡没生效或者任务绑定了 CPU。检查taskset -p pid看亲和性设置。调参方面/proc/sys/kernel/sched_balance_interval控制周期性均衡的间隔默认值跟 CPU 数量相关。调小会让均衡更频繁但开销更大调大则相反。一般不建议动这个参数除非有明确的负载不均问题。另一个参数是sched_migration_cost_ns默认 5000000.5ms表示任务迁移后的一段时间内调度器认为它“缓存是热的”不愿意再迁移它。这个参数影响迁移的积极性调大可以减少迁移次数但可能导致负载不均持续更久。6. 常见问题与排查技巧实录6.1 常见问题速查表现象可能原因排查方法解决方向服务 P99 延迟毛刺计算密集任务抢占perf sched latency调sched_wakeup_granularity或绑核某 CPU 100% 其他空闲亲和性绑定或均衡失效mpstat -P ALL、taskset检查亲和性调均衡参数交互式任务卡顿调度延迟大perf sched看延迟调小sched_latency或提高优先级上下文切换过高任务太多或粒度太小vmstat看cs列调大min_granularity新任务启动慢vruntime基准问题/proc/sched_debug检查min_vruntime和组调度跨 NUMA 访问多负载均衡跨节点numastat绑核或调 NUMA 策略6.2 几个踩过的坑坑一以为nice能解决一切。nice只影响 CFS 内部的时间分配如果实时进程在跑nice再低也没用。有次帮人调一个服务把nice调到 -20 还是卡最后发现是个SCHED_FIFO的实时线程在跑直接把实时优先级降下来就好了。坑二忽略组调度的影响。容器环境下cgroup 的 CPU 配额和 CFS 组调度会叠加。如果容器设了cpu.shares但宿主机上任务分布不均容器内的任务可能还是抢不到 CPU。排查时要同时看宿主机和容器的/proc/sched_debug。坑三盲目调sched_latency。sched_latency调小能让交互式任务响应更快但任务多的时候会导致上下文切换暴增。我试过把它从 6ms 调到 2ms结果cs从 5000 涨到 20000吞吐反而下降了。后来改成只对特定 cgroup 调效果才好。坑四NUMA 均衡不是越积极越好。有次在双路机器上把numa_balancing调得很激进结果任务频繁跨节点迁移内存访问延迟反而变大。后来用numactl把关键任务绑到本地节点性能才稳定下来。6.3 一个完整的排查案例回到开头那个毛刺问题。排查步骤是这样的perf sched latency发现主业务线程的最大调度延迟达到 180ms远超正常值。perf sched script看时间线发现每次毛刺都对应几个后台线程的集中唤醒。cat /proc/sched_debug看 CFS 队列发现后台线程的vruntime增长很慢说明它们权重高nice值低。查代码发现后台线程被设了nice -10而主业务线程是默认nice 0。解决方案把后台线程的nice调回 0同时用cgroup限制它们的 CPU 配额避免集中唤醒时抢占主业务线程。调整后 P99 延迟回到 25ms 以内毛刺基本消失。这个案例说明调度问题往往不是单一参数导致的而是优先级、唤醒模式、负载分布共同作用的结果。排查时要顺着调度事件的时间线把各个环节串起来看。6.4 几个实用的调试命令# 查看当前任务的调度策略和优先级 chrt -p pid # 查看任务的 CPU 亲和性 taskset -p pid # 实时监控上下文切换 vmstat 1 # 查看调度统计 cat /proc/pid/sched # 查看 CPU 频率和调度域 cat /proc/schedstat/proc/pid/sched里的se.sum_exec_runtime和se.vruntime能直接看到任务的运行时间和虚拟时间对比不同任务的这两个值能判断调度是否公平。7. 参数调优的边界与个人经验CFS 的可调参数集中在/proc/sys/kernel/下面常用的有这几个参数默认值作用调整建议sched_latency_ns6ms视配置调度周期交互式场景可调小但别低于 2mssched_min_granularity_ns0.75ms最小调度粒度任务多时调大减少切换sched_wakeup_granularity_ns1ms唤醒抢占阈值延迟敏感场景调小sched_migration_cost_ns0.5ms迁移成本估计一般不动sched_balance_interval动态均衡间隔一般不动调参的原则是一次只动一个小步调整有基线对比。我见过有人一口气把sched_latency和sched_min_granularity都调了结果性能变差还不知道是哪个参数的问题。另外这些参数是全局的影响所有任务。如果只想对特定服务调用 cgroup 的cpu子系统更合适。cpu.cfs_period_us和cpu.cfs_quota_us能控制组内的 CPU 带宽cpu.shares控制组间的权重分配。容器环境下这些参数更常用也更安全。最后说个个人体会CFS 的默认参数是经过大量场景验证的绝大多数情况下不需要调。真正需要调的场景往往是业务本身有特殊需求比如极低延迟、或者任务分布极度不均。调之前先想清楚“我要解决什么问题”而不是“听说这个参数调了会更快”。我踩过的坑里有一半是因为盲目调参而不是因为默认值不好。