ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入解析 sched_ext init_task 回调:Linux 6.15.7 下的工作原理与调试实践

深入解析 sched_ext init_task 回调:Linux 6.15.7 下的工作原理与调试实践

最近在 6.15.7 上把 sched_ext 重新过了一遍,几个回调里最容易被低估的就是init_task。很多人写 BPF 调度器时,先去看enqueue、dispatch、run,因为那些直接决定任务怎么被选出来、怎么跑;但init_task决定了你的调度器能不能正确认识每个任务,甚至决定了调度器 attach 后会不会直接崩掉。这篇不聊泛泛的框架介绍,直接聚焦 Linux 6.15.7 下 sched_ext 的init_task回调,讲清楚它的调用时机、内核侧契约、可运行示例,以及我实际调试时踩过的几个坑。

你可能已经在 scx 示例里见过ops.init_task = ...这种写法,但对它背后的调用条件、返回值的含义、和exit_task怎么配合还是含糊。这篇文章就是补这一块的。适合三类人看:正在写自定义 BPF 调度器的人、想在调度器里为每个任务维护私有状态的人、以及纯粹想把 sched_ext 生命周期读懂的内核爱好者。

1. sched_ext 回调全景里,init_task 处于什么位置

1.1 不要把 init_task 当成普通“钩子”

sched_ext 不是简单的 tracepoint 或者 kprobe,它通过 BPF struct_ops 机制把一组调度回调注册进内核的调度子系统。换句话说,你写的不是观测代码,而是真正的调度路径的一部分。init_task正是这条调度路径上最早出现的回调之一。

很多从 eBPF 监控程序转过来的人会有一个错觉:回调嘛,能有多关键,最多就是打印一下。实际不是。在 sched_ext 里,init_task的返回值会影响这个任务能不能顺利进入调度器视野;它里面做的初始化逻辑,直接决定后续enqueue、dispatch能不能拿到正确的任务上下文。所以它不是“额外加的钩子”,而更像是每个任务的构造函数。

另外有个容易混淆的点:内核里有一个全局变量叫init_task,它是系统初始化时第一个任务描述符,也就是常说的 0 号进程或者 idle 线程;而 sched_ext 的ops.init_task是一个回调函数指针,针对的是任意任务。两者只是同名,别搞混了。我见过有人查init_task时跑去看内核启动流程,查了半天才发现方向错了。

1.2 init_task 在任务生命周期里的位置

一个任务从诞生到离开,在 sched_ext 里大致会经历这样一条路径:

  • fork 创建新任务,普通调度器先接手;
  • 在合适的时机,sched_ext 调用init_task,让 BPF 调度器为任务做初始化;
  • 任务变成可运行,进入enqueue;
  • 被选中执行,进入dispatch和run;
  • 任务退出或停止运行,可能走exit_task做资源回收。

init_task的作用就是这条路径上的前置条件:它先跑,后面的enqueue、dispatch才有一个已经初始化好的任务上下文。如果你在enqueue里想读取某个 per-task 的状态字段,而这个字段是在init_task里设置的,那顺序就非常重要。

我个人的理解是:把init_task当成一个构造函数,把exit_task当成析构函数。所有需要跟任务一一对应的资源,都应该在这两个回调里成对处理。只分配不释放,时间一长必然出问题。

1.3 为什么调度器需要这样一个初始化回调

有 CFS、RT 这些内核调度器在前,为什么 sched_ext 还要单独搞一个init_task?原因是 BPF 调度器有自己独立的数据结构。

内核调度器可以用task_struct自带的那几个调度字段,比如prio、se、rt,因为它们就在任务结构体里。但 BPF 调度器的状态没法随便塞进task_struct,一般要放在 BPF 哈希表或数组里。如果不在init_task阶段把这个状态创建好,后续每个回调里都要做“如果没创建就创建”的懒初始化,代码会变得又啰嗦又容易在竞争时出错。

init_task还承担了把任务和调度器策略绑定起来的职责。比如你要做一个按 cgroup 分组的调度器,你需要在任务进入调度路径之前就知道它属于哪个 cgroup;如果你要按任务类型区分策略,也需要在早期把这个信息算好。这不是enqueue阶段临时能做的,因为enqueue的调用频率太高,不适合每次做重活。

2. 6.15.7 里 init_task 的调用时机与实现机制

2.1 它到底是什么时候被调用的

最核心的问题:BPF 调度器 attach 之后,系统中已经存在的几千个任务,难道要一个一个立刻调用init_task吗?还是说只有新 fork 的任务才调用?

以 6.15.7 的实际行为看,两者都有,但方式不是“attach 时一次性全量回调”,而是采用了更务实的懒初始化思路。调度器注册成功后,已经存在的可运行任务不会在 attach 瞬间全部涌入你的 BPF 回调,而是会在它们第一次真正进入 BPF 调度路径之前补上init_task的调用。新 fork 出来的任务也会在进入调度器管辖前触发这个回调。

这个设计其实很合理。如果 attach 时系统里有几千个任务,一个个同步调用 BPF 回调会拉长 attach 过程,而且很多任务可能根本不会被新调度器处理到。懒初始化让开销分散到后续调度事件中,也是 BPF 程序可以接受的。

不过要注意,不同版本的内核在具体实现细节上可能略有差异。比如某些版本可能走了一个批量预初始化路径,某些版本则完全是按需触发。所以写代码时不要依赖“attach 结束前所有任务一定都调过 init_task”这种假设,应该把init_task写成幂等且可重入的。

2.2 回调签名、上下文和返回值约定

6.15.7 里sched_ext_ops结构体中的init_task回调,语义大致是这样的:

struct sched_ext_ops { // 其他回调... s32 (*init_task)(struct task_struct *p, struct scx_init_task_args *args); void (*exit_task)(struct task_struct *p, struct scx_exit_task_args *args); };

签名里面第一个参数是目标任务本身,第二个参数是附加的上下文信息。这里有两个细节很关键:

第一,回调的第一个参数是task_struct *,但你拿到的只是一个指针。在 BPF 程序里不能上来就随便解引用它,需要按 CO-RE 方式读取字段。这不是init_task特有的限制,而是写所有 sched_ext 回调的基本功。

第二,返回值s32不是让你随便返回的。简单来说,返回 0 表示初始化成功,任务可以进入后续调度路径;返回负数表示初始化失败。失败之后,内核侧会进入错误处理流程,通常最终会导致调度器进入故障状态并退出。所以你不仅要保证逻辑正确,还要保证这个回调尽可能不失败。

args里携带什么,不同版本会有差别。如果你的调度器需要在init_task阶段获取 cgroup 或其它上下文信息,一定要看当前头文件里的定义,不要照抄老版本的字段名。我个人建议:在init_task里只做那些必须做的初始化,复杂的信息查询尽量放到低频路径里去做。

2.3 和 exit_task 的配对契约

init_task和exit_task是天然的一对。init_task被调用时,你可以给任务分配 BPF map entry、设置初始状态、记录调度元数据;当任务退出、不再需要被这个调度器管理时,exit_task会被调用,让你回收这些资源。

这里有个很常见的错误:只写了init_task,不写exit_task。如果每个任务都往一个哈希表里插入一条记录,任务退出时不删除,这张表会无限膨胀。调试时你可能还会看到系统内存被 BPF map 吃掉一大块,重启调度器才恢复。

还有一个细节:任务可能不会在同一个调度器实例下完成整个生命周期。你在init_task里分配的资源,最终不一定在本轮调度器中释放,中间可能涉及任务迁移、调度器重启等环节。最稳妥的写法是:每次初始化前检查一下是否已经初始化,保证重复调用时不会状态错乱;exit_task里也做同样的幂等处理。

3. 写一个带 init_task 的最小可运行调度器

3.1 环境准备与版本确认

写代码前先确认你的内核确实支持 sched_ext,并且 headers 里包含sched_ext_ops相关定义。6.15.7 肯定没问题,但我还是建议你先检查一下:

  • 内核版本是否高于或等于 6.12,因为 sched_ext 是 6.12 合入主线的;
  • 内核配置里CONFIG_SCHED_CLASS_EXT是否打开;
  • 系统是否具备 BPF struct_ops 支持,bpftool feature可以确认。

示例代码我按 6.15.7 的接口习惯写,但不同发行版带的头文件可能有细微差别。最保险的做法是直接使用当前内核源码树里的tools/sched_ext示例作为基底,把我的init_task片段贴进去。

3.2 一个最简单的 init_task 回调

下面这段代码不是完整调度器,只展示init_task怎么写。核心逻辑是:为每个进入调度器的任务记录一次 pid 和 comm,并在一个 BPF map 里累计初始化次数。

#include <scx/common.bpf.h> #include <vmlinux.h> char LICENSE[] SEC("license") = "GPL"; /* 用一个数组 map 记录 init_task 触发的次数 */ struct { __uint(type, BPF_MAP_TYPE_ARRAY); __uint(max_entries, 1); __type(key, u32); __type(value, u64); } init_count_map SEC(".maps"); /* 用一个哈希 map 记录每个 task 的初始状态 */ struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 100000); __type(key, pid_t); __type(value, u64); } task_init_state SEC(".maps"); static s32 init_task(struct task_struct *p, struct scx_init_task_args *args) { pid_t pid; u32 key = 0; u64 *count; u64 state = 0; /* 通过 CO-RE 读取目标任务的 pid,不要用 bpf_get_current_pid_tgid() */ bpf_core_read(&pid, sizeof(pid), &p->pid); /* 如果之前已经初始化过,直接返回成功,保证幂等 */ if (bpf_map_lookup_elem(&task_init_state, &pid)) return 0; bpf_map_update_elem(&task_init_state, &pid, &state, BPF_ANY); count = bpf_map_lookup_elem(&init_count_map, &key); if (count) { __sync_fetch_and_add(count, 1); } bpf_printk("init_task called for pid=%d comm=%s", pid, p->comm); return 0; } SEC("struct_ops") struct sched_ext_ops ops = { .init_task = (void *)init_task, /* 这里需要补上 enqueue、dispatch 等回调,完整调度器才能运行 */ };

这只截取了核心片段。有几个地方特别注意:

  • p->comm是字符数组,在上面代码里直接传给bpf_printk是可行的,因为bpf_printk会处理字符串。如果你想把它拷贝到自定义缓冲区,一定要用bpf_probe_read_kernel_str,别用普通字符串拷贝。
  • 我用bpf_core_read读p->pid,不用bpf_get_current_pid_tgid()。原因在下一节讲,但这里先记住:init_task回调的运行上下文不一定是目标任务本身,current可能是内核线程或者另一个任务。
  • init_count_map的 lookup 和 atomic add 之间并不是严格原子计数器操作,示例只是为了看图方便,别直接拿去做精确统计。

3.3 把回调挂到调度器上并观察触发

上面代码的SEC("struct_ops")段声明了sched_ext_ops实例,实际编译时需要有一个完整的用户态加载程序。最简单的做法是拿 sched_ext 仓库里的示例调度器(例如scx_simple)作为骨架,把你的回调函数指针替换进去。

编译时建议开启-g,这样可以保留 BTF 信息和调试符号。加载后,通过 tracefs 观察bpf_printk输出:

cat /sys/kernel/debug/tracing/trace_pipe

如果你只想确认 map 计数在涨,可以用bpftool map dump查看init_count_map的值。注意:刚 attach 时,因为懒初始化机制,可能看不到大量输出;你可以手动执行几个进程,或者让系统稍微跑一会儿,计数就会明显增加。

如果你的调度器同时实现了enqueue或dispatch,还可以做一个辅助实验:在init_task里故意不设置任何状态,然后去enqueue里读取task_init_state,你会发现自己可能拿到全 0 或直接找不到记录。这能帮你直观理解“初始化必须先于调度路径”的顺序约束。

3.4 从示例到完整调度器要补什么

如果只定义init_task而没有其它调度回调,内核不会让这个调度器正常跑起来。你至少需要补上一个调度策略实现。最简单的策略可以是一个 FIFO:enqueue里把任务放到一个全局队列,dispatch里把队首任务弹出来。

如果你不想自己从头写队列逻辑,就基于 scx 官方示例改。官方示例里有很多已经实现好的链路,你只需要把init_task加进去,再看一下enqueue、dispatch是否用到了你初始化的字段。这种改法比自己从零搭一个 BPF 调度器稳得多。

4. 调试 init_task 时我踩过的几个坑

4.1 不要在 init_task 里用 bpf_get_current_pid_tgid()

这个坑几乎每个写 sched_ext 的人都会踩一次。直觉上,任务初始化回调嘛,当前任务不就是目标任务嘛,直接bpf_get_current_pid_tgid()取 pid 不就行了?实际上不行。

bpf_get_current_pid_tgid()返回的是“当前执行 BPF 程序的上下文”的 pid 和 tgid,而init_task回调触发时,很可能是在内核的某个调度路径或者辅助线程上下文中,current并不一定是你要初始化的那个任务。换句话说,你以为自己在记 A 任务的 pid,实际记的是 B 任务的 pid。

所以正确姿势只有一个:从回调参数里拿任务信息。代码里尽量用 CO-RE 方式读取p->pid、p->tgid、p->comm。这不仅是“能读到”的问题,更是语义正确性的问题。

4.2 打印风暴会拖垮系统

bpf_printk在调试阶段确实方便,但它走 tracefs,输出量大的时候会严重影响系统性能,甚至让 attach 过程看起来像是卡死了。

我遇到过一种情况:调度器刚 attach,系统里有大量内核线程和用户进程,每个任务触发一次init_task,每个回调里打印两行。结果就是 trace_pipe 瞬间被刷爆,ssh 都开始卡。后来我改成只在 map 里累计计数,用bpftool map dump间隔观察,问题立刻消失。

如果你想保留一些观察能力,可以不直接打日志,而是把 pid 记录到一段 ring buffer 里,只在必要时用用户态程序读取。这样既能看到哪些任务被初始化过,又不会过度影响调度路径。

4.3 忘了写 exit_task,状态表越涨越大

我写过一个小实验调度器,init_task里往哈希表写了任务状态,但exit_task是空的。刚开始没感觉,跑了一晚上加一上午,那个 BPF map 的条目数涨到了几万。任务早就退出了,但状态永远留在表里。

这种情况下,调度器本身的性能也会下降:如果一个新任务恰好复用了旧 pid,你会在init_task里查到旧状态,如果逻辑没做好幂等,就可能用到过期的调度参数。所以我的经验是:从写第一版init_task开始,就同时把exit_task的空实现放上,后续有需要再填内容。这样不会忘。

4.4 认为每个任务只会被初始化一次

严格意义上,一个任务在一个调度器实例内,init_task通常不会反复触发;但你的调度器可能被卸载再重挂,或者任务经历了特殊的迁移路径。这些情况下,初始化逻辑可能被执行多次。

如果你的初始化逻辑不是幂等的,比如每次都给状态加一,那多执行几次之后状态就完全不可信了。所以init_task里的逻辑要设计成“不管跑几次结果一致”。最稳妥的方法就是先查询 map 里是否已有该任务的记录,有就直接返回 0。

4.5 在初始化里做大量计算或复杂查询

init_task的调用频率虽然比enqueue低,但它仍然处于内核关键路径上。如果你在里面做大量遍历、查询、甚至 sleep,会直接影响任务进入可运行状态的效率。

我见过一个实现,在init_task里对每个任务做一次 cgroup 路径字符串解析,结果 attach 之后整个系统的调度延迟明显变大。后来把 cgroup 相关的字符串解析挪到用户态准备阶段,内核侧只保存一个枚举值,性能开销降了一个量级。

记一条原则:init_task里只做 O(1) 的轻量初始化,把重计算留给用户态或者低频回调。它相当于一个构造函数,构造函数越轻,系统启动越快。

5. 从 init_task 往后看整个调度器生命周期

5.1 enqueue 之前到底发生了什么

很多调度器把大量逻辑塞在enqueue里,但enqueue的上下文很忙,不适合做初始判断。正确拆分方式是:

  • init_task:决定任务的基础元数据,设置初始状态;
  • enqueue:根据当前状态决定任务进入哪个队列;
  • dispatch:从队列里挑出下一个要执行的任务;
  • run:任务被选中之后、真正执行之前做最后处理。

这样分下来,每个回调的职责就很清楚。如果你发现自己在enqueue里频繁判断“这个任务有没有初始化过”,说明init_task的使用方式有问题,应该把初始化结果前置。

5.2 构造和析构要一起设计

前面提过,init_task和exit_task是构造/析构对。这里再补充一个设计上的细节:如果你为每个任务维护了不止一个数据结构,建议用一个统一的 per-task 上下文结构体,在init_task里只分配一次,后续所有回调都从这个结构体里取字段。

示例结构体可以长这样:

struct task_ctx { u64 enqueue_count; u64 dispatch_count; u64 run_count; bool initialized; };

init_task里把它初始化并放到 BPF map,enqueue里让enqueue_count加一,exit_task里从 map 删除。这种模式非常清晰,调试时也容易打印。如果你发现某个任务的enqueue_count还在涨但任务已经退出,那就是 map 没有在exit_task里清掉。

5.3 对特殊任务的初始化要格外小心

不是所有任务都应该被同一个调度器策略对待。内核里有很多特殊任务:idle 线程、迁移线程、以及调度器自身可能依赖的内核线程。

在init_task里,你可以通过p->flags检查任务类型,也可以直接看 pid 范围做简单过滤。但千万不要在init_task里假设所有任务都是普通用户进程。比如某些内核线程的mm为空,如果你在初始化时读取task_struct->mm相关字段,要小心空指针。

我的习惯是:init_task里对一些明显特殊的标志位做 early return,避免后续逻辑出错。比如:

if (p->flags & PF_KTHREAD) return 0;

这行代码的作用不是歧视内核线程,而是告诉你的初始化逻辑:内核线程可能走另一套简化路径。很多通用调度器会直接不管这类线程,让它们在缺省路径上运行。

5.4 后续场景扩展

init_task只是入口,它后面可以接很多东西。比如你想做一个按优先级分类的调度器,可以在init_task里解析任务的优先级字段,写成 map 中的一个数值;想在任务进入调度器时就绑定某个固定的 CPU 集合,也可以在这个阶段记录。再往后,如果要支持 task migration 和 hotplug 场景,还需要补充对应的回调逻辑。

这些扩展的共同点是:所有信息都最好在init_task阶段算好,后续回调只做增量处理。我后来写新调度器时,都会先画一张表:每个任务有哪些字段,这些字段在哪个回调里被初始化、读取、更新、销毁。表格理清了,整个调度器逻辑就不会乱。

另外一个很实用的技巧是:把init_task触发次数和系统真实任务数做对比。如果你发现 map 里的任务数和系统ps看到的任务数差距很大,那说明有些任务没走你的初始化路径,要么是特殊任务被过滤了,要么是你的调度器没有管理这些任务,要么是懒初始化还没轮到它们。这种对比可以作为调度器健康度的一个快速检查手段。

最后分享一个自己的习惯:写 sched_ext 调度器时,我会在init_task里放一个极简的计数 map,这个 map 不清理,专门用来统计“这个调度器实例总共见过多少任务”。它不影响逻辑,但每次调试时都能用bpftool map dump快速确认调度器是否真的在工作,也比看日志直观得多。你可以试试这个做法。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进