0/18 已展开

LLM 分析

sched_ext:为搁浅任务提供带宽受限的救援执行

系列概况

  • 标题:[PATCHSET sched_ext/for-7.3] sched_ext: Bandwidth-limited rescue execution for stranded tasks
  • 作者:Tejun Heo
  • 版本:v1(scx-rescue-v1,基于 sched_ext/for-7.3,基线 ee7aece60817
  • 规模:12 个实际 patch
  • 修改文件:13 个
  • 代码统计:+1186/-283
  • Message-ID:20260801085150.2697653-1-tj@kernel.org
  • 完整性:b4 获取到 18 封消息(cover、12 个 patch、5 个回复),实际 patch 为 12/12,均通过 DKIM;未发现 tip-bot2 污染或缺失警告。

一句话总结:为 sched_ext 子调度器中因 CPU affinity 与其 cid 不相交而无法落地的任务增加内核侧救援路径,并以带宽预算和过载逐出控制代价。

补丁目的

子调度器只拥有父调度器授予的 cid,系统却不保证这些 cid 覆盖其任务的 affinity。任务被插入目标 CPU 的 local DSQ 后,如果缺少所需 cap,旧代码把它放入 reject DSQ,再通过 ops.enqueue() 重新决策;若没有任何合法目标,任务会反复 reenqueue,最终触发 repeat limit 逐出调度器或 watchdog stall。

本系列让 BPF 调度器在可能被 cap 拒绝的 local insert 上设置 SCX_ENQ_RESCUE。内核随后接管该任务,在目标 CPU 的 rescue DSQ 中按小比例运行;正常情况下不打破持有 cap 的调度器控制,等待过久才升级为 protected execution。救援队列持续过载时,逐出近期消耗救援 CPU 时间最多的子调度器。

这里的“接管”不是把任务永久交给另一个 BPF scheduler,而是内核暂时接管“如何让它取得 CPU”这一件事。任务最终仍要进入某个 CPU 的 local DSQ 才能执行;rescue DSQ 主要是内核内部的等待和预算控制队列。

旧流程的问题

task enqueue -> local DSQ insert -> cap 检查失败
                                      |
                              reject DSQ / REENQ
                                      |
                scheduler 能否找到合法 CPU?--否-->
                         反复返回 enqueue -> repeat limit / watchdog

这不仅会饿死 affinity 与子调度器 cid 不相交的任务;退出中的任务跳过 ops.enqueue(),还可能在内核侧形成自重入式拒绝循环,消耗 CPU 后才被 watchdog 处理。

一次 REJECT 并不等于故障:它只是把任务退回给 BPF scheduler。若 scheduler 在收到 SCX_ENQ_REENQ 后改投 global DSQ 或选择合法 CPU,循环就结束。真正的问题是 scheduler 每次都把任务投回同一个无 cap 的 CPU;这时 REJECT 路径和旧行为本质相同,只能依靠 reenqueue 上限或 watchdog 结束异常状态。

新流程

local insert
    |
    +-- cap 足够 ----------------------> local DSQ -> 正常调度
    |
    +-- cap 不足且无 RESCUE ------------> reject DSQ -> reenqueue
    |
    `-- cap 不足且有 RESCUE、已启用 ------> per-CPU rescue DSQ
                                             |
                              token bucket 有完整 quantum?
                                  | 否                 | 是
                                  v                    v
                               等待 timer        admitted rescuee
                                                       |
                                      正常控制下运行,按 CPU 时间计费
                                                       |
                                  等待过久?--否--> slice 完成后结束救援
                                      |
                                      是
                                      v
                         SCX_TASK_PROTECTED + PREEMPT + IGNORE_CAPS

旧:拒绝意味着让所属调度器再次决策。新:调度器显式请求后,内核提供最后的 forward-progress backstop;默认每 CPU 仅分配 20 ppt(2%)带宽,每次 quantum 默认 5000 μs。

BYPASS、REJECT、RESCUE 的实际生效路径

队列谁把任务放入谁决定下一步典型场景是否受 rescue 2% 限制
BYPASSsched_ext disable/exit/abort 路径内核或最近的非-bypass ancestorscheduler 已失效,先保证系统继续运行
REJECTlocal insert 的 cap 检查失败BPF scheduler 的 ops.enqueue()这次目标不合法,但 scheduler 可能找到其他目标
RESCUEcap 失败且 insert 带 SCX_ENQ_RESCUE内核 token bucket 和 rescue timerscheduler 没有任何合法 cid,任务会 stranded

三者的控制权变化是:

BYPASS:  scheduler 退出 -> 内核/ancestor 接管整个 scheduler 的任务
REJECT:  一次 insert 失败 -> 任务退回原 BPF scheduler 重选
RESCUE:  重选很可能永远失败 -> 内核接管该任务的 forward progress

BYPASS 是 scheduler 级别的故障转移,不是 cap 拒绝。每个 scheduler、每个 CPU 都有 bypass DSQ;进入 bypass 后,原 scheduler 的 ops.dispatch() 不再负责这些任务,子 scheduler 的任务会放到最近仍工作的 ancestor 的 bypass DSQ 中。SCX_DSQ_BYPASS 因此是“调度器失效期间的安全通道”。

REJECT 是一次性的退回路径。patch 的 rq_owned_post_enq() 对 reject DSQ 安排 deferred reenqueue,使任务再次进入 ops.enqueue();它不是最终执行队列,也没有“内核强制运行”的含义。

RESCUE 是 per-CPU 的内核内部等待队列。任务不会直接从 rescue DSQ 运行,而是经过预算检查后移动到 local DSQ。若普通调度器不断抢占 rescuee,内核可以恢复剩余 slice;等待过久后设置 SCX_TASK_PROTECTED,使 BPF scheduler 不能再随意缩短它的 slice 或把别的 HEAD 任务插到它前面。

Patch 概览

Patch核心改动
1/12scx_local_or_reject_dsq() 改名为能容纳第三种目的地的 scx_resolve_local_dsq()
2/12暴露 ext.c helper 和 scx_sched_all,供 sub.c 救援逻辑使用。
3/12抽取可针对指定 rq 取时钟的 __scx_bpf_now()
4/12统一校验 dsq-move kfunc 的内部 enq_flags,阻止 BPF 夹带内核标志。
5/12SCX_ENQ_IGNORE_CAPS 同时豁免 preemption cap。
6/12将 slice/vtime 随 insert verdict 携带,在 insertion commit 点统一写入。
7/12增加 SCX_TASK_PROTECTED,保护 slice 和 rq-owned DSQ 头部位置。
8/12增加 rescue DSQ、token bucket、timer、计费及延迟升级机制。
9/12以衰减的 rescue 使用量选出 top consumer,过载时逐出它。
10/12同步 tools 侧自动生成的枚举头文件。
11/12scx_qmap 对 pinned task 也先做 idle 检查,避免错误的 direct dispatch。
12/12scx_qmap 在 enqueue/SHARED_DSQ 扫描中标记 rescue,并增加 -B-q 参数。

关键实现

  1. 三个 DSQ 的层次。 CPU 真正执行任务的入口仍然是 local DSQ。global DSQ 和 custom DSQ 中的任务需要被 move 到 local DSQ;reject DSQ 等待重新调用 scheduler;rescue DSQ 等待内核准入。因此新增 rescue 并没有把 CPU 的执行入口从一个变成两个。

  2. 救援准入与预算。 scx_resolve_local_dsq() 只对 local DSQ 处理 SCX_ENQ_RESCUE。cap 不足时,scx_rescue_try_admit() 检查 per-rq token bucket;无当前 rescuee、无排队者且预算达到完整 quantum 才立即准入,否则按到达顺序进入 rescue DSQ。拥挤时 quantum 按等待深度分摊,最小 slice 为 1 ms。

  3. quantum 不是 period。 默认 quantum=5000 μs 是一次救援允许消耗的 CPU 时间;bandwidth=20 ppt 是预算补充速率。近似计算为:5000 μs / 0.02 = 250 ms,即 CPU 大约经过 250 ms 才积攒出一次 5 ms rescue 预算。它是 token bucket,不是每 250 ms 严格打开的固定窗口;允许有限 burst,但长期 rescue CPU 占比趋近 2%。

  4. 拥挤时的 quantum。 若 5 个 stranded task 等待,单次 slice 大约是 5000/5=1000 μs,而不是每个任务都获得完整 5 ms。内核按实际获得的 CPU 时间扣预算;被普通调度逻辑抢占不会把未执行的时间错误地算作已服务。

  5. 救援不是取消 cap。 admitted rescuee 仍受所属调度器通常的抢占/重切片控制,实际执行时间由 update_curr_scx() 调用 scx_rescue_charge() 计费。被调度器抢占后,scx_rescue_keep() 恢复未服务的剩余 slice;scx_can_stop_tick() 保持 tick,以便计费和超时推进。

  6. 保护升级。 rescue timer 发现 rescuee 等待过久且预算超过两个 quantum 时,先设置剩余 slice,再置 SCX_TASK_PROTECTED,通过 SCX_ENQ_HEAD | SCX_ENQ_PREEMPT | SCX_ENQ_IGNORE_CAPS 放回 local DSQ。protected 任务拒绝 BPF 的 slice 修改,HEAD 插入也不能越过它;slice 消耗、dequeue、yield 或 bypass 会清除保护。

  7. 过载归责。 patch 9 在 rescue DSQ 队首等待超过派生阈值后,遍历各子调度器在该 CPU 的衰减 rescue_avg,选择最大者以 SCX_EXIT_ERROR_RESCUE 退出。默认配置下 funding period 约为 250 ms,过载阈值约为 16*250ms=4s,最终被限制在 1–15 s 范围。

  8. qmap 覆盖排队期间变化。 patch 12 不只在 qmap_enqueue() 标记没有 self-cid 的任务,也在每次 SHARED_DSQ 扫描时救援 affinity 已变化而搁浅的任务;错误 cid 注入同样走 rescue,便于确定性测试。

无 RESCUE 时是否回到旧流程

是的,核心行为没有改变:

SCX_ENQ_RESCUE 未设置,或 rescue 被 -B 0 关闭
        |
        v
cap 检查失败 -> SCX_DSQ_REJECT
        |
        v
SCX_ENQ_REENQ -> ops.enqueue() 再次决策
        |
        +-- 改投合法 DSQ:循环结束
        |
        `-- 每次重复错误目标:计数增加,最终逐出 scheduler

因此 REJECT -> reenqueue 不是 rescue 的替代实现,而是旧的恢复机制。patch 12 在 -B 0 时仍会设置 rescue 请求,但内核因为 rescue disabled 而走 reject;Tejun 的回复确认,每次 bounce 会累计 p->scx.reenq_cnt,超过 SCX_REENQ_MAX_REPEAT 后逐出 qmap。若任务长期 runnable 却始终得不到有效执行,也可能由 watchdog stall 路径逐出;正常情况下应先命中 reenqueue repeat limit。

类比

把 CPU 想成办公楼、子调度器想成只租了部分楼层的公司、cid 想成门禁卡。

  • BYPASS:公司倒闭,物业接管员工,保证大楼不停摆。
  • REJECT:员工走错门,被保安退回公司重新选楼层。
  • RESCUE:员工根本没有任何可用门禁卡,物业提供一个限时临时工位。

rescue 的 5000 μs 是一次临时工位的最长工作额度,2% 是临时工位重新获得额度的长期速率;不是“每 5000 μs 只能用 2%”。

Highlight:风险与注意点

  • -B 0 会关闭 rescue;scx_qmap 的 rescue insert 会照常被 cap 拒绝并重新入队。Tejun 回复称每次 bounce 都计入 p->scx.reenq_cnt,超过 SCX_REENQ_MAX_REPEAT 后逐出 qmap,因此循环有界,但预期失败模式是“击落 qmap”而不是继续救援。
  • REJECT 不是新的执行场所,也不是“无限等待队列”:它只负责触发一次 deferred reenqueue。scheduler 如果重复把任务投到同一个无 cap CPU,结果仍然是旧问题,只是最终会被 repeat limit/watchdog 处理。
  • 当前本地 linux-mainline checkout 尚未包含 SCX_DSQ_RESCUE;本报告解释的是该 v1 patch series 引入的目标实现。BYPASS 在当前代码中已经存在,REJECT/RESCUE 则属于该子调度器 rescue 版本的内核内部路径。
  • 自动审查指出 patch 9 在 rq lock 下用 list_for_each_entry_rcu() 遍历 scx_sched_all,但未显式出现 rcu_read_lock();这是需要复核的并发/RCU 风险,不应视为已解决。
  • 自动审查还指出 patch 11 删除 pinned task 快速路径后,always_enq_immed 可能成为未使用的 BPF rodata/用户态赋值;属于低风险清理项。
  • patch 6 明确接受某些并发写入的“后提交者生效”语义:rq-lock 下的 scx_bpf_task_set_slice() 与 DSQ insertion commit 可能竞争,BPF 调度器需自行同步 user-DSQ 场景。
  • watchdog timeout 若短于 rescue funding/过载阈值,watchdog 可能先于过载归责逐出调度器;代码会警告,但没有自动调整参数。
  • 线程是 v1;5 个回复主要来自自动审查,未见正式人工 Reviewed-by/Acked-by 或后续修订版本,不能把该系列视为已获维护者认可。

一句话总结

该系列把 sched_ext 的“cap 不相交导致永远无法运行”从无界拒绝循环改造成有预算、有计费、可升级且可归责的内核救援路径,同时以 scx_qmap 提供了覆盖 enqueue 与排队期间 affinity 变化的示例实现。