ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux CFS调度器深度拆解:调度时机、选任务与负载均衡

Linux CFS调度器深度拆解:调度时机、选任务与负载均衡 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_classDeadline 调度用于有硬实时截止时间的任务。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从红黑树选最左节点上 CPUtask_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.weightsched_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_HZ100的系统上交互式任务延迟明显比CONFIG_HZ1000差——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的任务权重 1024vruntime增长等于实际运行时间nice -5的任务权重约 3355vruntime增长只有实际时间的 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这是一个 PELTPer-Entity Load Tracking衰减平均值。PELT 把负载按时间衰减近期运行多的实体负载高长期睡眠的实体负载低。为什么不用任务数因为一个nice -10的任务和一个nice 19的任务对 CPU 的需求完全不同。用加权负载nice -10的权重约 9548nice 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-cmdtrace-cmd record -e sched:sched_switch -e sched:sched_wakeup -e sched:sched_migrate_task trace-cmd report | head -100sched_migrate_task事件会显示任务从哪个 CPU 迁到哪个 CPU以及迁移原因。如果看到大量迁移但负载没改善可能是sched_migration_cost_ns太小任务刚迁过去又被迁回来形成乒乓。这时候调大迁移成本让调度器更谨慎。6.3 一个真实的负载不均排查案例回到开头那个 32 核机器的问题。排查步骤是这样的top -H确认线程分布发现 8 个线程集中在 CPU 0-7。cat /proc/sched_debug | grep -A5 cpu#0看 CPU 0 的cfs_rqnr_running是 8其他核是 0。trace-cmd抓sched_migrate_task发现几乎没有迁移事件。检查/proc/schedstat发现 CPU 0-7 所在 NUMA 节点的lb_failed很高。查sched_domain拓扑发现这台机器 BIOS 里 NUMA 被设成了NPS4每个节点只有 8 核跨节点迁移成本高调度器不愿意迁。解决方案把 BIOS 的 NUMA 模式改成NPS1让所有核在一个节点内负载均衡立刻生效CPU 占用率摊平到 32 核。这个案例说明CFS 负载均衡不是软件单方面的事硬件拓扑和 BIOS 设置会直接影响调度域划分。排查调度问题时先看拓扑再看参数最后看代码逻辑顺序反了会浪费很多时间。7. 几个容易混淆的边界问题7.1 nice 值和权重不是线性关系很多人以为nice每差 1权重差固定值。实际是查表映射nice 0权重 1024nice 1是 820nice -1是 1277比例约 1.25。但nice 19权重只有 15nice -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 等待跟调度器没关系。工具用对了方向才不会错。
返回列表