ARTICLE DETAIL

资讯详情

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

内核CFS调度器dequeue_task_fair深度剖析:从睡眠到迁移的复杂出队路径

内核CFS调度器dequeue_task_fair深度剖析:从睡眠到迁移的复杂出队路径 研究 Linux 内核调度器时enqueue_task_fair和dequeue_task_fair经常被当成一对镜像函数来读。但如果你真正把dequeue_task_fair的每一条分支抠一遍会发现它的复杂程度比入队高出一截。这篇文章是 CFS 调度器系列里关于出队路径的第二次分析重点放在dequeue_task_fair方法本身它如何在多个cfs_rq层级之间逐级摘除调度实体如何与 PELT 负载追踪交互以及睡眠、迁移、带宽抑制这些场景下为什么需要不同的处理。我会从源码脉络、边界条件、trace 实测三个角度把这个函数讲透适合已经了解 CFS 基本数据结构、想深入阅读fair.c的读者。1. 为什么 dequeue_task_fair 比 enqueue_task_fair 难伺候1.1 一次调度切换背后至少有三类“离队”场景CFS 里的任务离开运行队列不是一个单一动作。至少有三条完全不同的路径会走进dequeue_task_fair第一类是主动睡眠。进程调用schedule()让出 CPUdeactivate_task把prev从运行队列摘掉此时flags里设置了DEQUEUE_SLEEP。这是最常见的路径也是大多数入门教程里讲的那个“出队”。第二类是任务迁移。负载均衡器认定某个 CPU 过载要把任务拉到另一个 CPU或者用户通过sched_setaffinity改变任务绑核内核需要把它从旧 CPU 挪到新 CPU。这时flags里带有DEQUEUE_MOVE有时还会带DEQUEUE_SAVE。迁移路径的特殊之处在于任务并没有“不想跑”它只是换了个地方跑所以 vruntime 和负载贡献不能简单清零必须被完整地带过去。第三类是 CPU hotplug 和带宽控制。CPU 要被拔掉时运行队列上的任务必须全部疏散CFS 带宽控制cfs_bandwidth也可能会把整个cfs_rq节流把其中调度实体从父队列批量摘走。这些场景下dequeue_task_fair面对的不再是一个孤立任务而是一整个组在一瞬间消失。这三种场景对dequeue_entity的要求完全不同。睡眠任务需要修正 vruntime迁移任务需要保存 vruntime 现场hotplug 则需要彻底切断负载贡献。所以dequeue_task_fair一眼看上去只是个循环但循环体里塞满了flags分支。1.2 对称的入口不对称的处理把enqueue_task_fair和dequeue_task_fair摆在一起对比会看到十分明显的不对称性。入队时任务从“不在队列”变到“在队列”只需要把节点插到红黑树里再往cfs_rq的统计值上加一份权重而 dequeue 要承担“拆除现场”的职责——必须确认节点确实在树上、当前是否正在执行、父实体是否还需要保留、负载统计是否在正确的层级上衰减。一个更隐蔽的不对称在于“错误处理”。enqueue_entity会检查se-on_rq如果任务已经在运行队列上可以直接跳过因为重复入队只是浪费一些计算。但dequeue_entity没法那么洒脱它不能假设任务一定在红黑树里因为当前正在运行的任务cfs_rq-curr根本不在树上它也不能假设 dequeue 后自己所在的cfs_rq会变空因为可能有其他兄弟实体还在。我用一个表格归纳最核心的区别考察点enqueue_task_fairdequeue_task_fairvruntime入队前通常不需要调整睡眠要抬升到 min_vruntime迁移要保存相对值树操作往红黑树插入节点从红黑树摘节点但 curr 要绕开负载统计给 cfs_rq 加权减权重同时决定是否逐层向上减唤醒/抢占指针可能设置 last/next/skip必须把对应指针清掉循环终止条件se-on_rq为真就 breakcfs_rq-nr_running大于 0 就 break这个表格基本就是dequeue_task_fair的源码骨架。下面把它拆开讲。2. dequeue_task_fair 源码脉络先摘节点再往上减负载2.1 主循环for_each_sched_entity 与 nr_running 的联动dequeue_task_fair的核心是一个自底向上的循环。我剔掉锁、schedstat 和 NUMA 细节后主线代码如下static void dequeue_task_fair(struct rq *rq, struct task_struct *p, int flags) { struct cfs_rq *cfs_rq; struct sched_entity *se p-se; int task_sleep flags DEQUEUE_SLEEP; for_each_sched_entity(se) { cfs_rq cfs_rq_of(se); dequeue_entity(cfs_rq, se, flags); /* * 如果这个 cfs_rq 里还有别的实体在跑 * 就不能继续向上删除父实体。 */ if (cfs_rq-nr_running) break; update_load_avg(cfs_rq, se, UPDATE_TG); se parent_entity(se); } if (!se) return; if (task_sleep) dequeue_sleep_cfs_rq(cfs_rq); }for_each_sched_entity这个宏很有意思。在没有开启组调度、没有task_group的默认情况下se-parent为 NULL所以循环体只执行一次处理的是根cfs_rq上的任务。一旦开启组调度一个任务往往隶属于某个task_group该组在父cfs_rq上还有一个sched_entity代表整个组。于是dequeue_task_fair必须逐层向上走直到根cfs_rq或者在某层发现“这里还有别的实体不能动父实体”。这里的终止条件if (cfs_rq-nr_running) break;是整段代码里最容易理解错的地方。很多人以为这个判断是在问“当前 CPU 上还有没有任务”其实它问的是“当前这一层 cfs_rq 上还有没有调度实体”。如果还有那么父实体必须继续留在父 cfs_rq 的红黑树上所以循环在这里停下。如果当前层已经空了这个实体就要从父级队列里摘掉于是循环继续向上。2.2 dequeue_entity 内部的几个关键动作dequeue_entity是dequeue_task_fair真正干活的函数。它做的事情可以压缩成六步static void dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags) { update_curr(cfs_rq); // 1. 结算当前执行实体的运行时间 if (flags DEQUEUE_SLEEP) // 2. 睡眠任务做 vruntime 抬升 se-vruntime max_vruntime(se-vruntime, cfs_rq-min_vruntime); clear_buddies(cfs_rq, se); // 3. 清掉 last/next/skip 指向 if (se ! cfs_rq-curr) // 4. 只有非 curr 才做红黑树删除 __dequeue_entity(cfs_rq, se); account_entity_dequeue(cfs_rq, se); // 5. 减少 nr_running 和权重 update_min_vruntime(cfs_rq); // 6. 重新计算最小虚拟运行时间 }第一步update_curr经常被误认为是“更新当前任务自己的累计运行时间”但它的完整职责是用rq_clock_task减去se-exec_start算出当前实体从上次入队到现在已经运行了多久然后把这段时间累加到sum_exec_runtime再把虚拟时间增量加到se-vruntime上。这一步保证了每次进入红黑树比较之前任务的 vruntime 都包含了最新一段真实运行时间。否则一个刚刚运行了半个时间片的进程可能还带着旧 vruntime 躺在树里下一轮调度就会错误地高估它的等待时间。第二步的max_vruntime是睡眠路径的精髓后面单独说。第三步clear_buddies是很多人会忽略的。CFS 在cfs_rq上维护了last、next、skip三个指针分别指向最近被唤醒的实体、即将被调度器选择为 next 的实体、以及需要跳过的实体。dequeue_entity必须检查待出队实体是不是这三个指针之一如果是就清零。如果不做这个清理等下一次pick_next_entity时调度器可能会持有一个悬空的 sched_entity 指针轻则多一次无效比较重则在并发修改下产生难以复现的问题。第四步if (se ! cfs_rq-curr)是 CFS 的经典细节红黑树中只存放“当前不在执行”的实体。cfs_rq-curr代表当前正在 CPU 上运行的实体它占据着 CPU并没有躺在红黑树里。如果dequeue_task_fair想要摘掉的任务恰好就是cfs_rq-curr那就不存在“树上摘节点”这回事只需要修改状态位和统计值。真要遇到这个分支往往是任务在运行的中途被强制 dequeue例如 CPU hotplug 或者内核主动把当前运行任务迁走。这种时刻红黑树删除操作必须被绕开否则rb_erase_cached会操作一个根本不在树上的节点内核直接崩溃。第六步update_min_vruntime是在删除节点后重新计算这一层cfs_rq的最小 vruntime。红黑树的最小节点可能已经变了如果放任min_vruntime不更新后续新任务入队时就会拿错误的基准去比较整个公平性账目都会错位。2.3 account_entity_dequeue记账工作不能漏account_entity_dequeue做的是“减法记账”。它至少会处理三件事cfs_rq-nr_running--把se-load.weight从cfs_rq-load.weight里减掉以及把层级化的h_nr_running和共享权重关系更新掉。nr_running反映的是这一层队列上有多少个调度实体负载均衡、时钟 tick、甚至select_task_rq都会读它。如果这里少减一次后续调度决策可能把空队列当成忙队列。load.weight是 CFS 做优先级加权的基础。普通进程的权重由nice值决定组实体的权重则由整个task_group在当前 CPU 上的所有任务权重汇总而来。account_entity_dequeue在减少权重的同时还会触发update_cfs_load和update_cfs_shares。前者负责更新cfs_rq的负载历史后者负责重新计算当前cfs_rq上各个组应该分到的权重份额。这一步对组调度是必须的组内少了一个任务组的权重就要重新分摊一次。2.4 睡眠任务的仲裁vruntime 保护dequeue_entity里最反直觉的操作是让睡眠任务的 vruntime 不低于当前的min_vruntimeif (flags DEQUEUE_SLEEP) se-vruntime max_vruntime(se-vruntime, cfs_rq-min_vruntime);很多初学者不理解为什么要这么写进程睡眠前明明运行了一段时间vruntime 应该已经涨上去了为什么还要人为拉高问题是min_vruntime是动态推进的。一个任务睡眠期间其他任务还在不断运行它们的 vruntime 也在增长所以整个红黑树的最小值是不断变大的。如果睡眠任务仍保留出队时的旧 vruntime等它醒来时这个值可能已经明显小于当前的最小值它会插到红黑树最左边几乎立刻获得 CPU。这等于奖励了睡眠惩罚了那些一直让出 CPU 的进程。max_vruntime把睡眠任务的 vruntime 至少抬升到当前的min_vruntime相当于一次性补齐它“缺席期间”的虚拟时间让它重新排队时不会因为历史优势插队。这里的细节是内核只抬升不额外惩罚它不会把 vruntime 抬到min_vruntime以上很多所以这个任务重新入队后仍然处于相对公平的位置。3. 组调度与 PELTdequeue 不是“删一个节点”就完事3.1 为什么组调度下要一层层向上走如果你只管理普通进程dequeue_task_fair的循环体跑一次就结束了。但现代系统上大量存在cgroup的 cpu 控制器每个控制组对应一个task_group。task_group在父cfs_rq上有一个sched_entity这个实体代表整个组组内部再维护一个独立的cfs_rq存放组内的任务。假设一个task_group里有 100 个任务这 100 个任务在组内cfs_rq上排队同时整个组作为一个sched_entity挂在根cfs_rq的红黑树上。现在其中一个任务被 dequeue如果它不是组内最后一个任务那么整个组仍然存在父cfs_rq上的组实体必须继续留在树里。只有当最后一个任务也出队了组内cfs_rq变空组实体才有资格从父cfs_rq摘除。这正是if (cfs_rq-nr_running) break;的语义。它保证了层级信息不会被破坏。没有这行判断我们可能在一个子 cfs_rq 还有 99 个任务时就错误地把父级组实体也从红黑树上摘下来导致整组任务瞬间失去调度资格。3.2 update_load_avgPELT 衰减和层级聚合dequeue_task_fair中每次成功摘除一个实体后会调用update_load_avg(cfs_rq, se, UPDATE_TG)。这个调用是 PELTPer-Entity Load Tracking机制的关键一环。PELT 的基本思想是每个实体的负载贡献不是一个 0/1 开关而是按时间衰减的累计值。一个任务睡眠后它的负载不会像断电一样立刻归零而是以半衰期约 32ms 的节奏逐渐衰减。这样设计是为了让调度器看到的系统负载平滑变化不会被进程睡一秒醒一秒的抖动打乱。update_load_avg具体做两件事先根据se-last_update_time到当前时间之间的缺口计算衰减更新se-avg然后把se-avg对cfs_rq-avg的贡献同步过去。如果带有UPDATE_TG它还会进一步把这一层cfs_rq的利用率和负载变化向上传播到父sched_entity的avg形成一条完整的层级聚合链。在 dequeue 路径上这个步骤必须谨慎。任务被摘除后它的负载不应被立即清零而是应该继续按 PELT 时间轴走衰减但如果任务是被迁移走的它的负载需要被完整带往新 CPU那就要走detach_entity_cfs_rq路径把se-avg从旧cfs_rq-avg中分离。睡眠和迁移在这个点上的分歧会直接影响 schedutil 调频和负载均衡选择 busiest 队列的准确性。3.3 leaf_cfs_rq_list什么时候摘掉自己负载均衡器在挑选最忙 CPU 时不会遍历所有 CPU 的所有cfs_rq而是会维护一张leaf_cfs_rq_list只把那些“可能还有任务”的cfs_rq放在链表上。dequeue 路径因此还要承担一项维护职责当某个cfs_rq彻底空了并且load.weight也变成 0它应该从leaf_cfs_rq_list上摘除。这个删除动作是“懒”的。内核不会在nr_running变成 0 的瞬间立刻把cfs_rq从链表上摘掉而是会先尝试把负载权重减到 0只有同时满足“空队列”和“零权重”时才在update_load_avg之后做list_del_leaf_cfs_rq。这么做的原因很简单负载均衡器宁可多看到一个空队列也不能漏掉一个有任务的队列。漏掉意味着任务可能长时间不被均衡系统出现局部热点。3.4 throttled 队列对出队的干扰CFS 带宽控制引入了一个让dequeue_task_fair更不省心的状态cfs_rq-throttled。当一个task_group的带宽配额耗尽内核会调用throttle_cfs_rq把整个组的实体从父cfs_rq上批量摘走冻结这一层的调度。此时如果组内有一个任务因为其他原因再次进入 dequeue 路径dequeue_task_fair必须足够谨慎不能重复执行“从父队列摘除”的动作。事实上节流状态的cfs_rq往往已经不在父红黑树上了再去__dequeue_entity会造成同样的红黑树操作错误。所以dequeue_entity内部需要检查cfs_rq-throttled在发现队列已经被节流时跳过多余的负载和树操作。这也是为什么看这个函数时不能只看主循环还要注意那些被我在示例代码里省略掉的保护条件。4. 边界条件空队列、任务迁移与 tick 抢占4.1 最后一个任务 dequeue 后父实体的命运用两层调度举例最清楚。假设组 A 里有任务 a1 和 a2根cfs_rq上挂着组实体 A组内cfs_rq里排着 a1 和 a2。dequeue a1 时for_each_sched_entity首先处理的是 a1 对应的sched_entity。dequeue_entity把 a1 从组内红黑树上摘掉此时组内cfs_rq-nr_running仍然是 1因为 a2 还在所以if (cfs_rq-nr_running) break;生效。父级组实体 A 完全不受影响。dequeue a2 时dequeue_entity摘掉 a2 后组内cfs_rq-nr_running变成 0。这时循环继续向上se被更新为组实体 A调用dequeue_entity处理 A。A 从根cfs_rq的红黑树上摘下来组负载也从根上减去。这个“最后一根稻草”的逻辑清楚解释了为什么cd系列文章反复强调父实体的删除是滞后的必须等子队列真正空了才发生。4.2 DEQUEUE_MOVE / DEQUEUE_SAVE 的迁移细节任务迁移是 dequeue 路径里技术要求最高的场景。负载均衡器选中一个任务后会在源 CPU 的rq上调用deactivate_task此时flags带上DEQUEUE_MOVE如果还需要保留 vruntime 的上下文会再带上DEQUEUE_SAVE。DEQUEUE_SAVE的核心作用是让dequeue_entity把任务的 vruntime 做一次“去基准化”处理从 vruntime 中减去当前cfs_rq-min_vruntime让任务带着一个相对值离开旧 CPU。等它在新 CPU 上被enqueue_task_fair接纳时再把这个相对值加上目标cfs_rq的min_vruntime形成新队列里的绝对 vruntime。为什么要这样绕一圈因为不同 CPU 的cfs_rq有各自独立的min_vruntime坐标系。如果直接搬运绝对 vruntime任务可能到了新 CPU 后变成最左边的节点无脑抢占也可能变成最右边的节点长时间得不到调度。通过SAVE转成相对值再在入队时重建坐标系才能保证任务无论迁移到哪里都保持在它原本的“相对公平位置”附近。要注意DEQUEUE_MOVE和DEQUEUE_SAVE并不总是同时出现。在某些场景下内核只想移动任务而不想调整 vruntime比如负载均衡中的 idle 迁移可能会选择是否携带原来的 vruntime。追踪的时候看到flags的值不同含义差异很大。4.3 为什么 tick 抢占不直接走 dequeue_task_fair有一个误区需要澄清时间片耗尽导致的抢占并不是通过dequeue_task_fair完成的。时钟 tick 进入task_tick_fair最终调用check_preempt_tick比较当前任务的 vruntime 和min_vruntime的差值。如果任务运行时间已经超过理想时间片调度器只做一件事调用resched_curr给当前 CPU 设置TIF_NEED_RESCHED标志。任务仍然留在运行队列里直到某个时刻schedule()被触发。真正切换到下一个任务时旧任务的去留取决于它是不是真的睡眠。如果只是被抢占它仍然处于“可运行”状态put_prev_task_fair会把它重新插回红黑树dequeue_task_fair根本不会被调用。只有任务切换到睡眠状态deactivate_task才会把DEQUEUE_SLEEP传给dequeue_task_fair。这种“懒”设计的价值在于tick 上下文里不应该做复杂的红黑树删除和队列记账。Tick 处理函数的首要原则是尽量短小、快速把真正的队列操作推迟到schedule()进程上下文中完成。所以看调度器代码时不能简单地把“时间片用完”和“出队”划等号。4.4 CPU 亲和性和 hotplug 的特殊路径修改任务的 CPU 亲和性不会立刻触发dequeue_task_fair但会通过set_cpus_allowed唤醒专门的迁移线程最终由migration_cpu_stop在目标 CPU 上执行迁移动作。这个路径里任务会在旧 CPU 的rq上被 dequeue然后在新 CPU 的rq上被 enqueue。如果旧 CPU 正好是任务当前运行的那个 CPU还会遇到se cfs_rq-curr的分支此时红黑树删除被跳过但统计更新照做。CPU hotplug 更极端CPU 要下线时运行队列上的所有任务必须全部疏散到其他 CPU。内核会遍历当前rq上的所有任务一个一个迁移走。这时的 dequeue 调用会连续发生而且对被疏散的任务来说它们的运行队列上下文正在被关闭update_curr需要正确结算最后一段运行时间否则正在执行的任务会丢掉一部分统计。这类边缘路径在生产环境不常见却最能检验一个调度器实现是否严谨。5. 实测 dequeue_task_fair用 trace 抓现场5.1 挂 kprobe 的完整操作阅读源码只能建立静态认知真正理解dequeue_task_fair的调用频率和 flags 变化还是要在运行系统上抓现场。最轻量的方式是用 bpftrace 写一个 kprobe观测函数入参bpftrace -e kprobe:dequeue_task_fair { printf(cpu%d pid%d comm%s flags%d\n, cpu, arg1-pid, arg1-comm, arg2); }这里arg0是第一个参数struct rq *arg1是第二个参数struct task_struct *arg2是第三个参数int flags。所以arg1-pid取任务 PIDarg2取 flags 位掩码。如果系统里没有 bpftrace也可以退回到 ftrace kprobe event。x86_64 下函数参数分别放在 rdi、rsi、rdx 中可以这样挂载cd /sys/kernel/tracing echo p:deqf dequeue_task_fair p%si flags%dx kprobe_events echo 1 events/kprobes/deqf/enable cat trace挂上之后让系统跑一段业务或者手动让某个进程睡眠再唤醒就能看到dequeue_task_fair的调用记录。5.2 解读 flags睡眠还是迁移dequeue_task_fair的flags是一个位掩码常见值大致如下位掩码含义0x1DEQUEUE_SLEEP任务睡眠0x2DEQUEUE_SAVE保存 vruntime 相对值0x4DEQUEUE_MOVE任务正在迁移0x8DEQUEUE_IDLE与 idle 相关实际看到的值往往是组合。比如一个负载均衡迁移任务flags 可能是 0x2|0x4 6一个因睡眠出队的任务flags 是 0x1。如果 flags 是 1 但sched_switch事件里显示旧任务的prev_state并不是可中断睡眠那就要注意了可能是有其他内核路径用它来表示“让出 CPU 但保持可运行”。这也是我反复强调不能只凭 flags 下结论的原因。为了减少日志量可以过滤特定 PIDbpftrace -e kprobe:dequeue_task_fair /arg1-pid 1234/ { printf(pid%d flags%d\n, arg1-pid, arg2); }当看到大量 flags6 的调用说明任务频繁被负载均衡搬来搬去。这时候应该去查 CPU 利用率分布是不是出现了热点而不是怀疑任务自己睡眠异常。5.3 与负载均衡联动的观察技巧把dequeue_task_fair的观测和sched_switch、sched_migrate_task事件合在一起看能拼出更完整的调度决策链。一个常见的排查场景是“某个任务明明有 CPU 时间却响应很慢”。这时候统计一下该任务在短时间内的 dequeue flags 分布如果 flags 里DEQUEUE_SLEEP占比极高说明任务大部分时间在睡眠等待事件问题可能出在唤醒源而不是调度器。如果DEQUEUE_MOVE频繁出现说明任务在 CPU 之间反复横跳每次迁移都有 cache 冷启动成本会造成响应延迟。此时应该检查负载均衡的max_load阈值或者通过sched_setaffinity把任务固定到特定 CPU。如果DEQUEUE_SLEEP和DEQUEUE_MOVE都很少但sched_switch显示任务频繁让出 CPU那很可能是它自己在循环里主动调用sched_yield这属于业务层的调度行为而不是内核调度器的问题。我常用的一个技巧是在 bpftrace 里同时挂kprobe:dequeue_task_fair和kprobe:enqueue_task_fair统计某个 PID 在一段时间内的出入队次数差值。差值持续大于 1 且没有伴随sched_switch睡眠通常意味着任务被挂到了某个等待队列配合block事件的观测即可快速定位。最后再分享一个小技巧。看dequeue_task_fair的源码时不要只盯着红黑树删除要特别关注cfs_rq-throttled和flags DEQUEUE_SAVE这两个不起眼的分支。它们分别代表了带宽控制和任务迁移两条隐线也是实际排障中区分“任务为什么不在 CPU 上”的关键线索。我自己在这上面踩过一次坑看到一个任务长时间没有dequeue_task_fair的DEQUEUE_SLEEP日志以为它一直占用 CPU后来才发现是负载均衡反复把它搬走每次迁移都清空了 cache导致运行效率极低。明白 dequeue 的完整语义再回去看fair.c整个调度器就顺了。
返回列表