ARTICLE DETAIL

资讯详情

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

CFS调度器dequeue路径深度解析:从dequeue_task_fair到负载追踪

CFS调度器dequeue路径深度解析:从dequeue_task_fair到负载追踪 咱们接着上一期继续聊CFS调度器的dequeue路径。如果没看过第一篇问题也不大这次直接聚焦在dequeue_task_fair这个入口上我会从调用时机、内部逻辑、负载传递到调试方法一起串一遍看到最后你基本能自己在源码里画出这条路径了。1. 先把“出队”这件事摆到正确的位置上1.1 一个进程被“请出”运行队列的几种场景在CFS调度器里“出队”不是只有进程睡眠这么一件事。很多人刚开始读内核源码时以为sched_dequeue只发生在进程主动sleep实际上它覆盖的场景非常广我把最常见的四类列一下进程调用sleep、nanosleep、cond_wait等主动让出CPU进入阻塞态进程被信号打断或者被同步机制唤醒后再次睡眠即DEQUEUE_SLEEP语义进程被迁移到其他CPU时需要先把调度实体从当前rq摘除再挂到目标rq完全公平调度器面临带宽控制时CFS运行队列被throttle批量把实体摘除。这几种场景在dequeue_task里通过不同的flags区分最终都会汇集到dequeue_task_fair但它内部对同一函数的处理差异很大。如果你只是大概看函数名很容易误以为“dequeue就是rb_tree删除”真实情况要复杂得多因为它还承担了负载更新、兄弟调度器回调、带宽控制等多重职责。1.2 为什么必须单独研究dequeue_task_fair我经常看到有人只追dequeue_entity把树操作研究完之后就结束了。这个思路不能说错但会漏掉一半逻辑。dequeue_entity只能算“当前层级”的摘除dequeue_task_fair才是一个完整的“自顶向下”的处理过程——它要先处理本CPU的CFS运行队列如果当前调度实体属于某个task group还要递归地把对父级调度实体的贡献也摘除掉。也就是说dequeue_task_fair处理的是调度实体嵌套的问题。在cgroup和task group场景下一个进程属于某个groupgroup又挂在更高层的CFS rq上你只删叶子节点不更新中间节点的负载与权重调度器就会算出错误的数据最后导致CPU时间分配失衡。这套逻辑也是task group负载跟踪的关键路径所以单独拉出来看很有必要。1.3 入口参数与三个关键标志先看函数原型static void dequeue_task_fair(struct rq *rq, struct task_struct *p, int flags)一个rq表示当前CPU的就绪队列p是被摘除的进程flags则告诉调度器这次出队的性质。常用的有下面几个DEQUEUE_SLEEP进程主动睡眠这是最常见的路径DEQUEUE_MOVE进程要被迁移到其他CPU所有调度实体需要一并迁移DEQUEUE_IDLE当前是idle线程上下文要求为了照顾真实任务作特殊处理。还有一个容易忽略的细节在较新内核里dequeue_task_fair会检查flags DEQUEUE_SLEEP如果为真会在合适的时机把调度实体加入cfs_rq-curr的vruntime调整逻辑。这是因为睡眠进程不该被立即惩罚否则它本来睡眠的时间还没补偿回来醒来之后又要重新排队和CFS的虚拟时间模型冲突。这一点后面具体展开。2. dequeue_task_fair主流程拆解从入口到rb_tree删除2.1 调用链scheduler_tick到dequeue_task的汇合点先画一下调用链不要嫌简单链子是后面排查问题的基础进程睡眠路径do_nanosleep-schedule-__schedule-pick_next_task/deactivate_task-dequeue_task-dequeue_task_fair负载均衡路径load_balance-detach_tasks-detach_one_task-deactivate_task-dequeue_task_fair此时flags带DEQUEUE_MOVE。带宽控制路径start_cfs_bandwidth/throttle_cfs_rq直接操作CFS队列但是也会调用一些回调函数最终可能通过dequeue_task_fair的辅助逻辑。deactivate_task是个调度类无关的封装它拿到当前task所属的调度类再调用对应类的dequeue_task方法。CFS的dequeue_task_fair就是在这里被间接调到的。理解了这一层你就知道为什么调试ftrace时不能只挂dequeue_task_fair还要同时挂deactivate_task来看调用方。2.2 先更新CFS运行队列的时钟update_curr函数第一步不是直接删节点头而是先更新当前运行实体的运行信息。update_curr(cfs_rq)会做这些事取得当前cfs_rq-curr即正在占用CPU的调度实体计算从上次更新到现在的真实时间差delta_exec把delta_exec折算成虚拟运行时间加到当前实体的vruntime如果当前位置没变还会更新cfs_rq-min_vruntime的可能候选值。为什么要先做这一步因为CFS的公平性完全建立在vruntime上而vruntime本身是逐步累计的。如果你在dequeue之前不更新那么被删除实体在CPU上最后一段时间的运行成本就没算进去树上的键值就会偏小后续调度实体选择就会偏差。这里尤其值得注意的是delta_exec是经过scale_load和权重加权的测试代码或者性能分析时如果发现vruntime增长异常多半要回到这里检查时钟源和负载贡献计算。2.3 dequeue_entity从树中摘除只是开始dequeue_task_fair的主要工作其实是循环调用dequeue_entity。在4.19以后的内核里dequeue_task_fair的循环结构很清晰先从当前CPU的CFS队列开始取到调度实体se p-se然后不断把该实体从所属队列里摘除同时向上回溯父实体。来看dequeue_entity内部三个核心动作第一个动作是update_curr(se-cfs_rq)这里会重复一次vruntime更新。因为它可能在父层级改变了curr所以子层级也要清楚自己当前的运行时间。第二个动作是__dequeue_entity(cfs_rq, se)这才是真正的红黑树删除。CFS的__dequeue_entity会把se从rb_tree里拿走同时更新cfs_rq-nr_running。这里有个容易误解的点树节点被删除后min_vruntime并不会自动跟随新的左子树节点需要调用update_min_vruntime手动刷新。所以代码里几乎是立刻执行了这个函数。第三个动作是负载贡献更新。dequeue_entity会根据se的负载值从cfs_rq-avg.load_avg里扣掉对应贡献同时更新rq-cpu_load[]这部分在负载均衡时会被用到。从内核5.4开始这部分大量使用PELTPer Entity Load Tracking即周期性负载跟踪dequeue 时要把se-avg.last_update_time等跳跃点处理对否则后面的enqueue_entity会累加出飘移的负载。2.4 向上递归组调度实体的父链处理如果你只用单个进程的CFS队列那么一个dequeue_entity就结束了。但只要开了CONFIG_FAIR_GROUP_SCHED事情就变得复杂。dequeue_task_fair在删完se后会判断se-parent是否存在如果存在就把父级调度实体也作为se继续递归。举个例子。进程A放在cgroup A组A组作为调度实体挂在根CFS队列上。当进程A睡眠我们需要先把进程A从cgroup A的CFS rq中删掉如果cgroup A在父队列上的se因为子进程减少而不再有可运行任务也要从父队列上摘除每次摘除父实体时同样要更新父层的nr_running和负载贡献。这个递归有个边界条件如果某个父级实体还有别的子实体在运行那么不能把它从父队列删掉只更新负载不执行树删除。我在调试时经常用trace_printk或者bpf去打印se-parent和cfs_rq-nr_running就是为了确认递归是否正确退出。实际遇到过一个casecgroup的子任务早就退出但父实体一直挂在树上导致CPU带宽分配异常最后定位到就是某个dequeue路径在父实体处置时因为curr se的检查顺序写反了属于内核历史版本的bug。3. 带宽控制、睡眠补偿与idle特殊处理三个被低估的细节3.1 被throttle的CFS队列dequeue_task_fair如何配合带宽控制CFS并不是无限公平的它受cpu.maxcgroup带宽控制约束。当一个cgroup的CPU配额用完内核会调用throttle_cfs_rq把该队列的实体全部摘除挂到throttled_list上。此时dequeue_task_fair虽然不会直接负责throttle但它必须正确识别出当前CFS是否处于throttle状态避免错误地修改nr_running。代码里有一个非常经典的判断if (!se) { if (!cfs_rq-nr_running || cfs_rq-throttled) break; goto more; }这段逻辑的含义是当cfs_rq-throttled为真时即使队列里还挂着实体也不要继续执行父级删除了因为throttle状态下的队列本来就不参与调度。否则你会把父实体的nr_running改小导致整个CPU认为系统空转进入idle的提前唤醒流程最终产生调度延迟。另一个容易被坑的点是sub_nr_running与cfs_b-nr_running的同步。带宽控制场景中CFS带宽管理有一个独立的计数器throttle 时递增unthrottle 时递减。dequeue_task_fair要保证没有重复递减。如果你用strace或者感知到cpu quota耗尽后任务状态异常建议先检查cfs_rq-throttled_clock_task与throttled_count这两个字段能快速确认是不是带宽控制路径触发了额外dequeue。3.2 DEQUEUE_SLEEP的vruntime补偿睡眠进程不该被惩罚DEQUEUE_SLEEP是dequeue最核心的场景但对vruntime的处理却很微妙。在老的O(1)调度器里进程睡眠会把vruntime直接重置或者保持不变CFS则更进一步想尽量保证睡眠进程醒来后仍能根据它的虚拟时间公平竞争。dequeue_task_fair里对DEQUEUE_SLEEP的处理主要在dequeue_entity前。你会在代码中看到这样的逻辑if (flags DEQUEUE_SLEEP) { se-vruntime - cfs_rq-min_vruntime; se-vruntime cfs_rq-min_vruntime; }看着有点脱裤子放屁对吧实际上这是为了处理vruntime归一化。在CONFIG_FAIR_GROUP_SCHED下不同CPU的min_vruntime可能不一样而实体在睡眠期间其虚拟时间可能需要偏移到当前的min_vruntime基准上以保证唤醒后不会被立即抢占。但这里有个我们要特别注意的地方DEQUEUE_SLEEP不能随意移动vruntime否则就会破坏“组内虚拟时间单调性”。比如一个进程在睡眠期间同组任务一直运行min_vruntime推进了如果不对se的vruntime做偏移进程唤醒后会显得“很新”抢到大量CPU形成sleeping catch-up效应。这其实是有意设计的但也需要配合place_entity的唤醒前插入位置计算一起看。建议看完dequeue_task_fair后立刻去看enqueue_task_fair和place_entity三者联动才能理解睡眠补偿的完整范围。3.3 idle与DEQUEUE_IDLE防止调度器逻辑混乱还有一种特殊路径是idle线程触发的dequeue。系统在进入idle之前调度器会用pick_next_task选择idle线程但某些特殊场景下idle线程也会参与dequeue操作。此时dequeue_task_fair会检查DEQUEUE_IDLE标志调整cfs_rq-idle_h_nr_running这类计数。idle处理容易出问题的地方在于调度类选择逻辑。CFS的dequeue_task_fair里有一个变量is_idle_task它会通过task_css_is_root或者task_sched_class判断当前任务是否为idle。如果判断不准就可能把一个idle线程错误地推入CFS队列导致idle线程抢占普通任务。这个场景在CPU hotplug或者suspend/resume过程中比较常见表现为系统进入idle后唤醒延迟变高。从调试角度看这类问题很少直接表现在dequeue_task_fair的栈上更多是表现为调度的“幽灵唤醒”。建议用/proc/sched_debug查看每个CFS队列的nr_running是否为0如果nr_running不为0但CPU一直在idle说明dequeue路径没有完全摘除干净要重点查idle_h_nr_running。4. 源码实践用ftrace追踪dequeue_task_fair以及几个真实故障4.1 用ftrace看一条完整的dequeue调用链光看代码不如动手追踪。我在长期调试中习惯用ftrace的function_graph配合filter挂到dequeue_task_fair上这样能直观看到函数的嵌套调用。示例命令echo dequeue_task_fair /sys/kernel/tracing/set_graph_function echo function_graph /sys/kernel/tracing/current_tracer echo 1 /sys/kernel/tracing/tracing_on sleep 1 echo 0 /sys/kernel/tracing/tracing_on cat /sys/kernel/tracing/trace | grep -A 30 dequeue_task_fair输出里你能看到类似结构1) 0.500 us | dequeue_task_fair(); 1) 0.200 us | update_curr(); 1) 0.100 us | __dequeue_entity(); 1) 0.150 us | update_min_vruntime(); 1) 0.200 us | update_cfs_group();这里我要强调一个经验ftrace只是辅助真正要判断性能问题还得加kprobe或bpf trace直接读se-vruntime和min_vruntime的最终值。比如可以用bpftracekprobe:dequeue_task_fair { vruntime[tid]((struct task_struct *)arg1)-se.vruntime; }不过参数在不同架构下偏移不同不建议直接用更稳的方式是使用/proc/sched_debug以及perf sched记录整个调度事件。4.2 常见问题min_vruntime更新滞后导致新进程饥饿我在排查性能问题时碰到过一类经典现象新创建的进程总是长时间得不到调度而老进程几乎占满CPU。用sched_debug看会发现新进程的vruntime非常小但它没有进入树中老进程的min_vruntime却远远大于新进程。问题定位到dequeue路径的update_min_vruntime。CFS的update_min_vruntime只有在curr为NULL或curr-vruntime小于当前左子树节点时才把min_vruntime设为curr-vruntime。如果某个dequeue_entity后curr被提前清空或者se-vruntime更新顺序不对就会导致min_vruntime停滞在旧值。新进程在插入时基于不合理的min_vruntime计算vruntime自然会被推到很远的位置。实际修复要结合内核版本但排查顺序一定是先看dequeue路径有没有正确刷新min_vruntime。4.3 进程卡在D状态如何判断是不是dequeue路径的锅很多时候进程进入不可中断睡眠后看起来像dequeue卡住。我建议先查看/proc/pid/stack和wchan确认是不是在do_exit或者schedule处停留。如果两者都指向__schedule再用ftrace挂deactivate_task与dequeue_task_fair看函数是否正常返回。有一个真实案例在某个5.10版本内核上cgroup的cpu.max被限制为100ms/200ms而进程调用了futex_wait频繁睡眠唤醒。由于dequeue_task_fair里的update_curr对delta_exec做了多次max夹取导致在时钟节拍非常短的虚拟机里delta_exec被拉大CFS虚拟时间出现负值回退进程卡在D状态。最后通过给该cgroup提高cpu.max并升级内核解决。这类问题很难通过阅读单一函数发现但如果没有对dequeue路径的完整理解你连排查方向都找不到。4.4 内核“八股”复习点dequeue路径的必考细节很多Linux内核学习资料都喜欢把CFS调度器做成面试八股我在这里把dequeue相关的几个“送分点”整理一下方便面试前快速过一遍dequeue_task_fair由dequeue_task调度类函数指针调用CFS在调度类初始化时注册该函数dequeue_entity负责层级内的摘除、负载更新和min_vruntime维护DEQUEUE_SLEEP会影响vruntime补偿但不能直接修改min_vruntime带宽控制时throttled状态会阻止父实体继续摘除负载均衡时的DEQUEUE_MOVE不计算睡眠补偿只执行迁移所需的最小操作。这几个点如果自己能串起来讲清楚说明已经基本掌握CFS出队逻辑了。5. 我的内核源码阅读心得与后续扩展方向5.1 阅读顺序建议从dequeue_task_fair到enqueue_task_fair的对照我一直推荐把dequeue_task_fair和enqueue_task_fair放在一起读原因很简单它们的逻辑是对称的但细节完全不同步。比如enqueue_task_fair在插入树前有一个account_entity_enqueue而dequeue_task_fair则要额外处理nr_running的递减。只看一半容易产生“插入和删除应该互逆”的错误直觉实际上两者在带宽控制、负载跟踪、唤醒抢占等路径上差异极大。我当时把两个函数打印出来并排对比后发现dequeue侧明显更复杂的是group entity的“占用继承”处理。当父实体还在被其他进程使用时dequeue不能删除它只能更新shares和负载。而enqueue侧更多是考虑是否要抢占当前任务以及是否需要重新计算组权重。把两侧放在一起你就能自然理解为什么CONFIG_FAIR_GROUP_SCHED会显著增加调度器的复杂度。5.2 从CFS到EEVDFdequeue思想的演进如果你现在用的是6.6以上内核会发现CFS的vruntime模型已经从严格红黑树切换到EEVDF的延迟期望模型但dequeue_task_fair这个名字并没有变。在EEVDF中出队和入队仍然有dequeue_entity和enqueue_entity只是树上的键值从vruntime调整为virtual deadline相关量。因此本篇的所有宏观概念仍然适用但细节计算要重新理解。我建议学习者在读完CFS之后立刻去读EEVDF的补丁集重点看entity_key和place_entity的变化这会让你理解为什么新调度器不再需要CFS里那种花式的min_vruntime归一化。对于只做运维不写调度器的朋友知道dequeue路径还在、名字没变、主要流程仍由调度类函数指针触发就够了。5.3 调试工具链的推荐组合如果你要深入dequeue路径我比较推荐这组工具组合ftrace/proc/sched_debugcrash来分析在线与离线问题。平时可以用perf sched recordperf sched latency看整体调度延迟一旦出现异常再用bpftrace或内核动态插桩去抓dequeue_task_fair的运行时间和调用者。我踩过几次坑之后学到的教训是不要一上来就用复杂的追踪优先看/proc/sched_debug和/sys/kernel/debug/sched/目录。大部分dequeue问题都可以从nr_running与nr_uninterruptible的差值上看出端倪。把基本状态确认好再决定是否需要打动态补丁这才是高性价比的排查方式。最后给一点个人体会其实每个调度器函数的难度不在于那几十行代码而在于你必须同时理解rq、cfs_rq、task_group、sched_entity四个结构体之间的相互引用和计数关系。很多事故都是计数不同步造成的而这些计数恰恰在dequeue路径里被大量修改。把dequeue_task_fair的每一步都和数据结构的实际变化对上号比背十遍源码都管用。希望这篇拆解能帮你在读内核源码时少走点弯路。
返回列表