基于 Linux 6.18.26,结合内核源码逐行分析
init_task 是什么
sched_ext(简称 scx)是 Linux 内核的 eBPF 调度器框架。你可以把它理解为一个"调度器插件系统"------内核提供了标准的钩子(hook),你用 eBPF 程序实现这些钩子,就能写出一个完整的调度器。
init_task 就是其中一个钩子,定义在 struct sched_ext_ops 里。它的职责很简单:在一个任务(task)被 scx 框架"认识"之前,给它做一次初始化。
一个关键点:不管任务最终是否被 scx 接管,只要 scx 调度器加载了,init_task 都会被调用。 这意味着即使任务运行在 CFS(普通进程)或 RT(实时进程)下,init_task 依然会执行。这一点在写 eBPF 调度器时容易被忽略,后面会详细解释。
init_task 的两种触发场景
init_task 的两种触发场景
注册前已存在的任务
注册后新 fork 的任务
🔍 scx 调度器注册
任务何时存在?
📋 场景一:注册时批量初始化
遍历 scx_tasks 全局链表
init_task(p, fork=false)
scx_set_task_state(READY)
🆕 场景二:fork 时逐个初始化
fork 流程中触发
scx_fork() → init_task(p, fork=true)
scx_post_fork() → READY → ENABLE
| 场景 | 时机 | fork 参数 |
|---|---|---|
| 场景一:scx 调度器注册时,系统中已存在的任务 | 遍历全局任务链表 scx_tasks |
false |
| 场景二:scx 调度器注册后,新 fork 出来的任务 | 在 fork 流程中调用 | true |
下面分别深入分析。
场景一:注册时批量初始化
1.1 全局任务链表 scx_tasks
内核维护了一个全局链表 scx_tasks,定义在 kernel/sched/ext.c 中:
c
static DEFINE_RAW_SPINLOCK(scx_tasks_lock);
static LIST_HEAD(scx_tasks); // 内核启动时就存在,不依赖 scx
这个链表在内核启动时就存在 ,跟有没有加载 scx 调度器无关。每当有新任务 fork 出来,scx_post_fork() 就会把它挂到这个链表上:
c
// kernel/sched/ext.c
void scx_post_fork(struct task_struct *p)
{
// ...
raw_spin_lock_irq(&scx_tasks_lock);
list_add_tail(&p->scx.tasks_node, &scx_tasks); // 新任务入链
raw_spin_unlock_irq(&scx_tasks_lock);
}
系统所有的任务都在这个链表中。
1.2 注册流程:从用户态到内核态
eBPF 用户态程序通过 SCX_OPS_ATTACH 宏触发调度器注册,完整调用链如下:
scx 框架 BPF 子系统 系统调用层 libbpf 用户态 eBPF 程序 scx 框架 BPF 子系统 系统调用层 libbpf 用户态 eBPF 程序 ──── 进入内核态 ──── kthread_queue_work() ① scx_bypass(true) ② scx_init_task_enabled = true ③ 第一轮遍历: init_task(fork=false) ④ 中间: 开启 __scx_enabled ⑤ 第二轮遍历: switching_to_scx → enable() ⑥ scx_bypass(false) SCX_OPS_ATTACH(numa_aware_ops, ...) bpf_map__attach_struct_ops() bpf_link_create() [系统调用] __sys_bpf(BPF_LINK_CREATE) link_create() bpf_struct_ops_link_create() bpf_scx_reg() [scx 注册入口] scx_enable() [包装函数] scx_enable_workfn() [核心逻辑]
注意 6.18.26 中 bpf_scx_reg 并不直接调用核心逻辑,而是通过 scx_enable() 将工作挂到一个内核线程(scx_enable_helper)上,由 scx_enable_workfn() 完成实际的注册工作。这样做是为了避免在 BPF 系统调用上下文中执行耗时的全任务遍历。
1.3 scx_enable_workfn:init_task 的执行现场
scx_enable_workfn 是注册的核心函数(kernel/sched/ext.c:4644),这里重点看 init_task 相关的部分:
c
// kernel/sched/ext.c,简化后
static void scx_enable_workfn(struct kthread_work *work)
{
// ...
// ① 先开启 bypass 模式------暂停所有 scx 调度操作
scx_bypass(true);
scx_bypassed_for_enable = true;
// ② 获取 scx_fork_rwsem 写锁,阻止 fork 与注册并发
percpu_down_write(&scx_fork_rwsem);
// ③ 设置标志位,后续 fork 的新任务也会触发 init_task
scx_init_task_enabled = true;
// ④ 第一轮遍历:给每个已存在的任务执行 init_task
scx_task_iter_start(&sti);
while ((p = scx_task_iter_next_locked(&sti))) {
// ...
scx_init_task(p, task_group(p), false); // fork=false
scx_set_task_state(p, SCX_TASK_READY);
// ...
}
scx_task_iter_stop(&sti);
scx_cgroup_unlock();
// ⑤ 释放 scx_fork_rwsem ------ 第一轮遍历结束
percpu_up_write(&scx_fork_rwsem);
// ⑥ 开启 scx_enabled,标记调度器生效
WRITE_ONCE(scx_switching_all, !(ops->flags & SCX_OPS_SWITCH_PARTIAL));
static_branch_enable(&__scx_enabled);
// ⑦ 重新获取 scx_fork_rwsem 写锁
percpu_down_write(&scx_fork_rwsem);
// ⑧ 第二轮遍历:把需要接管的任务切换到 ext_sched_class
scx_task_iter_start(&sti);
while ((p = scx_task_iter_next_locked(&sti))) {
// ...
p->sched_class = scx_setscheduler_class(p); // 全量模式下,fair → ext
check_class_changing(task_rq(p), p, old_class);
// → switching_to_scx → scx_enable_task → ops.enable()
// ...
}
scx_task_iter_stop(&sti);
// ⑨ 释放 scx_fork_rwsem ------ 第二轮遍历结束
percpu_up_write(&scx_fork_rwsem);
// ⑩ 关闭 bypass 模式------scx 调度正式生效
scx_bypassed_for_enable = false;
scx_bypass(false);
}
下面用一张流程图展示这个过程中的关键步骤和 bypass 保护区间:
scx_enable_workfn 执行流程
第二轮遍历:enable(持有 rwsem)
scx_task_iter_start
循环: scx_task_iter_next_locked
scx_setscheduler_class(p)
(fair → ext)
check_class_changing
→ switching_to_scx
scx_enable_task
→ ops.enable()
scx_task_iter_stop
第一轮遍历:init_task(持有 rwsem)
scx_task_iter_start
循环: scx_task_iter_next_locked
scx_init_task(p, tg, false)
scx_set_task_state(READY)
scx_task_iter_stop
① scx_bypass(true)
🔴 bypass 开启,暂停所有 scx 调度
② percpu_down_write
获取 scx_fork_rwsem 写锁
③ scx_init_task_enabled = true
此后 fork 的新任务也会触发 init_task
⑤ percpu_up_write
释放 scx_fork_rwsem
⑥ 开启 __scx_enabled
scx_switching_all = true
⑦ percpu_down_write
重新获取 scx_fork_rwsem
⑨ percpu_up_write
释放 scx_fork_rwsem
⑩ scx_bypass(false)
🟢 bypass 关闭,scx 调度正式生效
bypass 保护区间
从 ① 到 ⑩ 全程开启
此期间 enqueue 直接走全局 DSQ
不会调用 ops.enqueue()
此后 enqueue 正常调用
ops.enqueue()
这里有三个关键细节值得注意:
细节 1:bypass 全程保护一致性。 从 scx_bypass(true) 到 scx_bypass(false) 的整个过程中,bypass 始终开启。bypass 打开时,即使有任务触发了 enqueue,也会直接被放到全局 DSQ,不会调用 ops.enqueue() 钩子。这保证了 init_task 和 enable 的执行不会被 enqueue 打断。
看 bypass 是如何在 enqueue 路径生效的:
c
// kernel/sched/ext.c
static void do_enqueue_task(struct rq *rq, struct task_struct *p, ...)
{
// ...
if (scx_rq_bypassing(rq)) { // ← 检查 bypass 状态
__scx_add_event(sch, SCX_EV_BYPASS_DISPATCH, 1);
goto global; // ← 直接放到全局 DSQ,不调 ops.enqueue()
}
// ...
SCX_CALL_OP_TASK(sch, SCX_KF_ENQUEUE, enqueue, rq, p, enq_flags); // 正常路径才调 enqueue
}
细节 2:init_task 先于 enable。 有两轮遍历:第一轮执行 init_task,第二轮执行 enable(通过 switching_to_scx → scx_enable_task)。所以对每个任务来说,时序一定是 init_task → enable。
细节 3:两轮遍历之间有 rwsem 间隙,但 bypass 保护不中断。 第一轮遍历结束后,scx_fork_rwsem 写锁会短暂释放(用于在中间开启 __scx_enabled),然后重新获取进行第二轮遍历。虽然 rwsem 有间隙,但 bypass 在整个过程中始终开启 ,所以不会有 enqueue 打断的问题。间隙期间新 fork 的任务如果已经看到 scx_init_task_enabled == true 和 scx_enabled() == true,会在 scx_post_fork() 中自行完成 init 和 enable,不会漏掉。
c
// scx_pre_fork 在 fork 路径获取读锁
void scx_pre_fork(struct task_struct *p)
{
percpu_down_read(&scx_fork_rwsem); // ← 和 scx_enable_workfn 中的写锁互斥
}
1.4 init_task 的 fork 参数有什么用?
scx_init_task 的第三个参数 fork 区分了两种场景:
c
static int scx_init_task(struct task_struct *p, struct task_group *tg, bool fork)
{
// ...
if (SCX_HAS_OP(sch, init_task)) {
struct scx_init_task_args args = {
SCX_INIT_TASK_ARGS_CGROUP(tg)
.fork = fork, // eBPF 程序可以通过这个参数判断场景
};
ret = SCX_CALL_OP_RET(sch, SCX_KF_UNLOCKED, init_task, NULL, p, &args);
// ...
}
// ...
}
在eBPF 程序里,可以这样使用:
c
s32 BPF_STRUCT_OPS(my_init_task, struct task_struct *p, struct scx_init_task_args *args)
{
if (args->fork) {
// 这是 fork 出来的新任务,可以继承父任务的调度上下文
} else {
// 这是调度器注册时对已有任务的初始化,需要"冷启动"分配资源
}
return 0; // 返回 0 表示成功,非 0 会导致调度器加载失败
}
场景二:fork 时逐个初始化
scx 调度器注册完成后(scx_init_task_enabled = true),此后所有新 fork 的任务都会在 fork 流程中触发 init_task。
2.1 fork 调用链中的 init_task
scx 框架 copy_process kernel_clone fork 系统调用 scx 框架 copy_process kernel_clone fork 系统调用 dup_task_struct *dst = *src → 继承父进程 comm 内核线程: strscpy_pad(comm, name) sched_fork → scx_pre_fork() 获取 scx_fork_rwsem 读锁 sched_cgroup_fork ✅ init_task 执行完毕 sched_post_fork scx_set_task_state(READY) list_add_tail(scx_tasks) enqueue_task_scx() → ops.enqueue() SYSCALL_DEFINE0(fork) copy_process() scx_fork() scx_init_task(p, tg, true) ops.init_task(p, args) [fork=true] scx_post_fork() scx_enable_task(p) → ops.enable() 返回新任务 p wake_up_new_task(p)
scx_fork() 的实现非常简洁:
c
// kernel/sched/ext.c
int scx_fork(struct task_struct *p)
{
percpu_rwsem_assert_held(&scx_fork_rwsem);
if (scx_init_task_enabled)
return scx_init_task(p, task_group(p), true);
else
return 0;
}
如果 init_task 返回非零值,fork 会失败。 这意味着 eBPF 程序在 init_task 中分配资源失败时,可以通过返回错误码来阻止任务创建。不过要注意,fork 路径上的 init_task 返回错误会导致 scx_error(),直接触发调度器卸载,所以请谨慎使用这个机制。
2.2 为什么 init_task 一定先于 enqueue?
答案藏在 fork 流程的时序里:
scx_fork()在sched_cgroup_fork()中被调用 → 触发init_taskwake_up_new_task()在copy_process()返回后才被调用 → 触发enqueue
结论:在 fork 场景下,时序一定是 init_task → enable → enqueue。 三者不会乱序。
init_task 中能拿到正确的任务名吗?
这是一个很容易踩坑的地方。在 init_task 钩子里,如果你用 bpf_get_current_comm() 或通过 p->comm 获取任务名称,结果取决于任务的类型和场景:
任务名 (comm) 的生命周期
用户态进程
dup_task_struct
*dst = *src → 继承父进程 comm
例如: comm='bash'
init_task 中
⚠️ comm 可能还是父进程名
execve 系统调用
begin_new_exec()
→ __set_task_comm()
例如: comm='ls'
内核线程
fork 前
strscpy_pad(comm, name)
设置真实名称
init_task 中
✅ comm 是准确的
内核线程:名称是准确的
内核线程在 fork 之前就已经通过 strscpy_pad(p->comm, args->name, ...) 设置了真实名称,所以 init_task 中拿到的名称是正确的。
用户态进程:名称可能是父进程的
用户态进程在 fork 时,通过 arch_dup_task_struct 整体复制了父进程的 task_struct(包括 comm 字段):
c
// kernel/fork.c
int __weak arch_dup_task_struct(struct task_struct *dst, struct task_struct *src)
{
*dst = *src; // 整体赋值,comm 也被继承
return 0;
}
只有在后续执行 execve 时,comm 才会被替换成自己的真实名称:
execve 系统调用
└─ do_execve
└─ do_execveat_common
└─ bprm_execve
└─ exec_binprm
└─ search_binary_handler
└─ load_elf_binary
└─ begin_new_exec
└─ __set_task_comm() ← 这里才改名字
所以在 init_task 中,用户态进程的 comm 很可能还是父进程的名字。 比如你在 bash 里启动一个 ls 命令,在 init_task 时拿到的名字可能是 bash,而不是 ls。
实践建议: 如果调度器需要根据任务名来做决策,不要在 init_task 里做最终判断。可以结合 ops.enable() 或 ops.enqueue() 钩子,因为此时任务可能已经 exec 了。
init_task 与其他钩子的完整时序图
把上面所有分析汇总起来,整个时序如下:
注册调度器时(对已有任务)
enqueue 路径 已有任务 bypass 机制 scx_enable_workfn enqueue 路径 已有任务 bypass 机制 scx_enable_workfn 🔴 bypass 开启 percpu_down_write(scx_fork_rwsem) SCX_TASK_NONE → INIT → READY percpu_up_write(scx_fork_rwsem) 开启 __scx_enabled percpu_down_write(scx_fork_rwsem) READY → ENABLED percpu_up_write(scx_fork_rwsem) 🟢 bypass 关闭 bypass 关闭后,enqueue 正常生效 时序: init_task → enable → enqueue ✅ scx_bypass(true) 第一轮遍历: init_task(fork=false) 第二轮遍历: switching_to_scx → enable() scx_bypass(false) ops.enqueue()
时序:init_task → enable → (bypass 关闭后)enqueue
新任务 fork 时
wake_up_new_task scx 框架 copy_process kernel_clone wake_up_new_task scx 框架 copy_process kernel_clone ① sched_cgroup_fork() SCX_TASK_NONE → INIT INIT → READY READY → ENABLED 时序: init_task → enable → enqueue ✅ copy_process() scx_fork() → init_task(fork=true) scx_post_fork() scx_enable_task() → ops.enable() 返回 p wake_up_new_task(p) enqueue_task_scx() → ops.enqueue()
时序:init_task → enable → enqueue
任务的四种状态
init_task 执行后,任务会经历一系列状态转换,理解这些状态有助于判断任务的生命周期:
c
// include/linux/sched/ext.h
enum scx_task_state {
SCX_TASK_NONE, // ops.init_task() 还没被调用
SCX_TASK_INIT, // init_task 执行成功,但任务还没就绪
SCX_TASK_READY, // 完全初始化,可以被 scx 调度
SCX_TASK_ENABLED, // 已激活,正在被 scx 调度
SCX_TASK_NR_STATES,
};
状态流转图:
任务创建
ops.init_task() 成功返回
注册时: scx_enable_workfn 第一轮遍历
fork时: scx_post_fork()
任务调度类判断
sched_class == ext
switching_to_scx() 或
scx_post_fork() 中直接 enable
sched_class != ext
保持 READY,等后续切换
调度器卸载 / 策略切换
SCX_TASK_NONE
SCX_TASK_INIT
SCX_TASK_READY
SCX_TASK_ENABLED
初始状态,还未被 scx 认识
短暂中间态
init_task 返回 0 后立即进入
完全初始化
可以被 scx 调度
激活态
正在被 scx 调度
ops.enable() 已调用
SCX_TASK_INIT 是一个中间态,只在 scx_init_task() 执行成功后短暂存在 ,紧接着就会被 scx_set_task_state(p, SCX_TASK_READY) 推进到 READY。对于 fork 场景,这个转换在 scx_post_fork() 中完成;对于注册时的已有任务,这个转换在 scx_enable_workfn 的第一轮遍历中完成。
disallow 机制:init_task 中的"拒绝入场"
init_task 钩子里有一个特殊能力:可以通过设置 p->scx.disallow = true 来阻止任务进入 scx 调度。
c
// include/linux/sched/ext.h
struct sched_ext_entity {
// ...
bool disallow; // 如果设置为 true,任务不能切换到 SCHED_EXT 策略
};
源码中的处理逻辑:
c
// kernel/sched/ext.c,scx_init_task() 中
if (p->scx.disallow) {
if (!fork) {
// 注册时的已有任务:如果策略是 SCHED_EXT,强制回退到 SCHED_NORMAL
if (p->policy == SCHED_EXT) {
p->policy = SCHED_NORMAL;
atomic_long_inc(&scx_nr_rejected);
}
} else if (p->policy == SCHED_EXT) {
// fork 时不允许设置 disallow,否则触发调度器错误卸载
scx_error(sch, "ops.init_task() set task->scx.disallow for %s[%d] during fork",
p->comm, p->pid);
}
}
disallow 机制决策流程
fork=false
(注册时已有任务)
是
否
fork=true
(新 fork 任务)
是
否
init_task 中设置
p->scx.disallow = true
fork 参数
p->policy == SCHED_EXT?
强制回退为 SCHED_NORMAL
scx_nr_rejected++
无影响
任务保持原策略
✅ 允许,任务不会被 scx 接管
p->policy == SCHED_EXT?
🚨 scx_error()
触发调度器卸载!
无影响
规则总结:
- 注册时(fork=false):可以设置 disallow,阻止已有任务使用 SCHED_EXT 策略
- fork 时(fork=true):不能设置 disallow,否则会触发调度器错误并卸载
总结:写给 eBPF 调度器开发者的 check list
init_task 要点
触发时机
所有任务都会触发
不区分调度策略
CFS/RT 任务也一样
fork 参数
true = 新 fork 任务
false = 注册时已有任务
用于区分初始化策略
返回值
0 = 成功
非0 = 注册时加载失败
非0 = fork 时调度器卸载
任务名陷阱
内核线程 comm 准确
用户态进程 comm 可能是父进程名
不要依赖 comm 做最终决策
时序保证
init_task 一定先于 enable
enable 一定先于 enqueue
bypass 机制保证注册安全
disallow 机制
注册时可设置
fork 时禁止设置
用于拒绝特定任务
任务状态
NONE → INIT → READY → ENABLED
init_task 推动第一步
| 关注点 | 要点 |
|---|---|
| 触发时机 | 所有任务都会触发 init_task,不区分调度策略 |
| fork 参数 | fork=true 是新任务,fork=false 是已有任务 |
| 返回值 | 返回 0 成功;非 0 在注册时导致加载失败,在 fork 时导致调度器卸载 |
| 任务名 | 用户态进程的 comm 可能还是父进程名,不能依赖它做最终决策 |
| 时序保证 | init_task 一定先于 enable 和 enqueue 执行 |
| bypass 保护 | 注册过程中 bypass 打开,不会触发 enqueue |
| disallow | 可以在注册时阻止特定任务进入 scx,但不能在 fork 时使用 |
| 任务状态 | NONE → INIT → READY → ENABLED,init_task 推动第一步 |