当前位置: 首页 > news >正文

nohz-idle-balance-bug

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_ipikernel/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_queuesend_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 Bnohz_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_funcneed_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); }'