nohz-idle-balance-bug
一次 Linux 内核调度器 Bug 的排查过程:nohz idle balance 为何总是失效
背景
在一台大型多 NUMA 机器上,我们将两个计算密集型任务绑定到了同一个 CPU 上(设为 cpu1),期望 Linux 的 nohz idle balance 机制能检测到这一不均衡,将其中一个任务迁移到空闲的 cpu0 上。cpu0 开启了 nohz,且处于 idle 状态,理论上应该频繁出现 nohz idle balance 事件。
然而,监控数据显示 cpu0 上的 nohz balance 几乎从未发生。
第一步:用 bpftrace 定位卡在哪
nohz idle balance 的入口是 nohz_csd_func,这是通过 smp_call_function_single_async 发送到 idle CPU 上执行的 CSD(call single data)回调。我们先验证它是否在被调用,以及执行时的状态:
bpftrace -e '
kprobe:nohz_csd_func {if (cpu == 0) {$nr = (int64)curtask->thread_info.flags & 8;printf("[nohz_csd] cpu0 need_resched=%d ts=%llu\n", $nr != 0, nsecs);}
}
tracepoint:sched:sched_switch {if (cpu == 0 && args->prev_pid == 0) {printf("[sched_sw] cpu0 idle->%s(%d) ts=%llu\n",args->next_comm, args->next_pid, nsecs);}
}'
结果令人意外:nohz_csd_func 每 4ms 调用一次(与 nohz 的 balance 周期吻合),但每一次 need_resched 都是 1。
看一眼 nohz_csd_func 的源码(kernel 6.6,kernel/sched/core.c):
static void nohz_csd_func(void *info)
{struct rq *rq = info;int cpu = cpu_of(rq);unsigned int flags;flags = atomic_fetch_andnot(NOHZ_KICK_MASK | NOHZ_NEWILB_KICK, nohz_flags(cpu));rq->idle_balance = idle_cpu(cpu);if (rq->idle_balance && !need_resched()) { // ← 永远过不了这里rq->nohz_idle_balance = flags;raise_softirq_irqoff(SCHED_SOFTIRQ);}
}
函数在检查 need_resched() 时总是返回 true,导致 SCHED_SOFTIRQ 从不触发,nohz idle balance 永远不会执行。
问题转化为:cpu0 处于 idle 状态,为什么 need_resched 总是为 true?
第二步:找出是谁在设置 need_resched
need_resched() 本质上是检查 idle task 上的 TIF_NEED_RESCHED 标志位,这个标志由 resched_curr() 设置。我们用 bpftrace 直接追踪所有针对 cpu0 的 resched_curr 调用:
bpftrace -e '
kprobe:resched_curr {if (((struct rq *)arg0)->cpu == 0) {printf("resched_curr → cpu0 caller: %s stack:\n%s\n",comm, kstack(8));}
}'
输出揭示了多条调用路径,其中最高频的是:
resched_curr+5
ttwu_do_activate+106
try_to_wake_up+1215
wake_up_process+25
handle_softirqs+696
irq_exit_rcu+118
sysvec_call_function_single+132 ← CSD IPI 处理函数
asm_sysvec_call_function_single+31
其他路径包括:
hrtimer_interrupt → hrtimer_wakeup → wake_up_process(hrtimer 到期唤醒进程)delayed_work_timer_fn → kick_pool → wake_up_process(kworker 被调度到 cpu0)watchdog_timer_fn → stop_one_cpu_nowait → wake_up_q(watchdog 唤醒迁移线程)
第三步:分析 __flush_smp_call_function_queue 的处理顺序
注意到上面最频繁的调用栈来自 sysvec_call_function_single——这正是 CSD IPI 的处理函数,也是 nohz_csd_func 的调用者。这里存在一个关键的处理顺序问题。
看 kernel/smp.c 中 __flush_smp_call_function_queue 的实现:
static void __flush_smp_call_function_queue(bool warn_cpu_offline)
{// Pass 1:处理 CSD_TYPE_SYNC(最高优先级)llist_for_each_entry_safe(csd, csd_next, entry, node.llist) {if (CSD_TYPE(csd) == CSD_TYPE_SYNC) { ... }}// Pass 2:处理 CSD_TYPE_ASYNC 和 CSD_TYPE_IRQ_WORKllist_for_each_entry_safe(csd, csd_next, entry, node.llist) {if (type != CSD_TYPE_TTWU) { ... } // nohz_csd_func 在这里}// Pass 3:最后处理 CSD_TYPE_TTWU(task wakeup)if (entry) {csd_do_func(sched_ttwu_pending, entry, csd); // sched_ttwu_pending 在这里}
}
nohz_csd_func 属于 CSD_TYPE_ASYNC,在 Pass 2 处理;sched_ttwu_pending(跨 CPU 的 task wakeup)在 Pass 3 处理。nohz_csd_func 一定先于 sched_ttwu_pending 执行,所以 ttwu 本身不可能在同一次 flush 中提前设置 need_resched。
同时注意到 do_idle 的结构(kernel/sched/idle.c):
static void do_idle(void)
{__current_set_polling(); // 设置 TIF_POLLING_NRFLAGtick_nohz_idle_enter();while (!need_resched()) { // idle 循环cpuidle_idle_call();}// 能走到这里,need_resched 已经为 true__current_clr_polling();flush_smp_call_function_queue(); // ← nohz_csd_func 在此处运行schedule_idle();
}
这个结构看起来像是 nohz_csd_func "注定"在 need_resched=true 时被调用。但这个推断是错误的——正确的问题应该是:在 idle 循环内部、通过 IPI 直接唤醒的路径下(Path A),nohz_csd_func 本应能看到 need_resched=false,为什么也看不到?
第四步:发现真正的根因
关键在 __current_set_polling() 设置的 TIF_POLLING_NRFLAG,以及 send_call_function_single_ipi 中的一个优化。
看 kernel/smp.c:
static void __smp_call_single_queue(int cpu, struct llist_node *node)
{if (llist_add(node, &per_cpu(call_single_queue, cpu)))send_call_function_single_ipi(cpu); // ← 队列原本为空,需要唤醒目标 CPU
}static void send_call_function_single_ipi(int cpu)
{if (call_function_single_prep_ipi(cpu)) { // ← 先检查目标 CPU 状态arch_send_call_function_single_ipi(cpu);}
}
再看 call_function_single_prep_ipi(kernel/sched/core.c):
bool call_function_single_prep_ipi(int cpu)
{if (set_nr_if_polling(cpu_rq(cpu)->idle)) {trace_sched_wake_idle_without_ipi(cpu);return false; // ← 不发送实际 IPI}return true;
}
set_nr_if_polling 的逻辑:
static bool set_nr_if_polling(struct task_struct *p)
{struct thread_info *ti = task_thread_info(p);typeof(ti->flags) val = READ_ONCE(ti->flags);for (;;) {if (!(val & _TIF_POLLING_NRFLAG))return false;if (val & _TIF_NEED_RESCHED)return true;if (try_cmpxchg(&ti->flags, &val, val | _TIF_NEED_RESCHED))break;}return true;
}
完整的因果链如下:
当 cpu0 处于 polling idle 状态(do_idle 循环内,TIF_POLLING_NRFLAG 已设置),某个忙碌 CPU 调用 nohz_balancer_kick() → smp_call_function_single_async(cpu0, &nohz_csd) → __smp_call_single_queue → send_call_function_single_ipi(cpu0) 时:
nohz_balancer_kick()→ smp_call_function_single_async(cpu0, &cpu0_rq->nohz_csd)→ __smp_call_single_queue(cpu0, node)→ llist_add() → 返回 true(队列为空)→ send_call_function_single_ipi(cpu0)→ call_function_single_prep_ipi(cpu0)→ set_nr_if_polling(cpu0_rq->idle)// cpu0 处于 polling idle,TIF_POLLING_NRFLAG = 1原子设置 TIF_NEED_RESCHED = 1 ← need_resched 在这里被设置!return false ← 不发送实际 IPI
由于返回 false,实际上不会发送 IPI,而是直接在 cpu0 的 idle task 上设置了 TIF_NEED_RESCHED。cpu0 的 polling 循环检测到这个标志,退出 idle 循环,进入 flush_smp_call_function_queue() 处理 CSD 队列。
这时 nohz_csd_func 运行,检查 need_resched(),当然是 true——因为是 nohz kick 机制本身设置的。
这是一个完美的自我击败:
nohz kick 想唤醒 cpu0 做 balance↓
"优化":不发 IPI,设置 TIF_NEED_RESCHED 来唤醒 polling CPU↓
cpu0 退出 idle 循环↓
nohz_csd_func 检查 need_resched() → true → 跳过 balance↓
SCHED_SOFTIRQ 从不触发
为什么这是一个 bug
这个问题的根源是两个独立提交在语义上的冲突:
Commit A(引入问题): b2a02fc43a1f "smp: Optimize send_call_function_single_ipi()"
这个优化的本意是:对于处于 polling idle 的 CPU,无需发送昂贵的 IPI,只需设置 TIF_NEED_RESCHED 标志,该 CPU 的 polling 循环会自己发现并处理。这对于 task wakeup(sched_ttwu_pending)的场景是合理的——因为确实需要 CPU 去调度那个 task。
但这个优化"重载"了 TIF_NEED_RESCHED 的语义:它不再仅意味着"有任务需要调度",还可能意味着"有 CSD 项需要处理"。
Commit B(nohz_csd_func 的历史): 自 2011 年起,nohz_csd_func 中的 !need_resched() 检查就存在,其原始目的是在 sched_ttwu_pending 之后进行安全检查,确保刚被唤醒的任务不会被 balance 打扰。当时的代码形如:
sched_ttwu_do_pending(list); // 先处理 task wakeupif (unlikely((rq->idle == current) &&rq->nohz_balance_kick &&!need_resched())) // 如果 ttwu 导致了 resched,就跳过raise_softirq_irqoff(SCHED_SOFTIRQ);
在这个结构中,need_resched() 检查是有意义的——sched_ttwu_pending 在前,如果它设置了 need_resched,后面就应该跳过 balance。
但随着内核演进,sched_ttwu_pending 被移入 __flush_smp_call_function_queue 的 Pass 3,晚于 nohz_csd_func 的 Pass 2 执行。旧的 need_resched() 检查逻辑上已经不再必要(task wakeup 设置的 need_resched 此时还没发生)。
而 Commit A 引入的 polling 优化让这个多余的检查变成了反效果。
验证:看 upstream 是否已修复
在 Linux 7.0 的源码中,nohz_csd_func 已经变成:
// linux 7.0: kernel/sched/core.c
static void nohz_csd_func(void *info)
{struct rq *rq = info;int cpu = cpu_of(rq);unsigned int flags;flags = atomic_fetch_andnot(NOHZ_KICK_MASK | NOHZ_NEWILB_KICK, nohz_flags(cpu));WARN_ON(!(flags & NOHZ_KICK_MASK));rq->idle_balance = idle_cpu(cpu);if (rq->idle_balance) { // ← need_resched() 检查已删除rq->nohz_idle_balance = flags;__raise_softirq_irqoff(SCHED_SOFTIRQ);}
}
对应的修复 commit 是 ea9cffc0a154,作者 K Prateek Nayak(AMD),时间 2024-11-19,Peter Zijlstra 提议并 review:
sched/core: Remove the unnecessary need_resched() check in nohz_csd_func()
Since commit b2a02fc43a1f ("smp: Optimize send_call_function_single_ipi()") overloads the interpretation of TIF_NEED_RESCHED for TIF_POLLING_NRFLAG idling, remove the need_resched() check in nohz_csd_func() to raise SCHED_SOFTIRQ based on Peter's suggestion.
Fixes: b2a02fc43a1f ("smp: Optimize send_call_function_single_ipi()")
修复后,idle_cpu() 才是判断是否应跳过 balance 的正确依据:
int idle_cpu(int cpu)
{struct rq *rq = cpu_rq(cpu);if (rq->curr != rq->idle) return 0; // 有任务在运行if (rq->nr_running) return 0; // runqueue 非空if (rq->ttwu_pending) return 0; // 有 task wakeup 正在路上return 1;
}
这三个条件都反映"CPU 有真实的任务要处理",而不是被 IPI 优化机制误设置的 TIF_NEED_RESCHED。
总结
| 层次 | 内容 |
|---|---|
| 现象 | nohz idle balance 不触发,cpu0 上两个任务无法被均衡 |
| 直接原因 | nohz_csd_func 中 need_resched() 永远为 true |
| 间接原因 | nohz kick 经由 set_nr_if_polling 设置了 TIF_NEED_RESCHED,而这个标志恰好又是 nohz_csd_func 跳过 balance 的条件 |
| 根本原因 | b2a02fc43a1f 重载了 TIF_NEED_RESCHED 的语义(既表示"有任务要调度",也表示"有 CSD 要处理"),使得 nohz_csd_func 中遗留的 !need_resched() 检查产生了错误的判断 |
| 影响范围 | Linux 6.x,大型多 NUMA 机器,CPU 处于 polling idle 时 nohz idle balance 完全失效 |
| 修复 | commit ea9cffc0a154,已合入 Linux 7.0;6.6 可 backport(单行改动) |
Backport 方法(6.6 内核):
--- a/kernel/sched/core.c
+++ b/kernel/sched/core.c
@@ -1202,7 +1202,7 @@ static void nohz_csd_func(void *info)rq->idle_balance = idle_cpu(cpu);
- if (rq->idle_balance && !need_resched()) {
+ if (rq->idle_balance) {rq->nohz_idle_balance = flags;raise_softirq_irqoff(SCHED_SOFTIRQ);}
调试工具箱
本次排查用到的几个关键 bpftrace 命令,供参考:
# 1. 验证 nohz_csd_func 的 need_resched 状态
bpftrace -e 'kprobe:nohz_csd_func /cpu == 0/ {printf("need_resched=%d\n", (curtask->thread_info.flags & 8) != 0); }'# 2. 找出是谁在设置 cpu0 的 need_resched(带调用栈)
bpftrace -e 'kprobe:resched_curr {if (((struct rq *)arg0)->cpu == 0)printf("%s\n", kstack(8)); }'# 3. 统计 cpu0 上 idle task 被哪些进程打断
bpftrace -e 'tracepoint:sched:sched_switch/cpu == 0 && args->prev_pid == 0/ {@[args->next_comm] = count(); }
interval:s:10 { print(@); exit(); }'# 4. 追踪 nohz_balancer_kick 来自哪些 CPU
bpftrace -e 'kprobe:nohz_balancer_kick {printf("kick from cpu%d\n", cpu); }'
