标签: Linux内核调度

  • 深入 SCHED_FIFO 调度死锁排查:RT 优先级滥用引发的 RCU Stall 与 Node 假死实战

    某次线上核心交易集群出现诡异的 Node 批量假死现象。故障发生时,API 响应瞬间超时(p99 从 2ms 飙升至 10s 以上),K8S 节点状态接连翻转为 NotReady,SSH 彻底失去响应。监控面板在断点前最后上报的数据显示:系统 Load Average 飙升至 800+,某几个单核 CPU 突然被打至 100%。

    最终结论:研发为了追求极致的低延迟,在业务代码中强行将关键线程的调度策略修改为 SCHED_FIFO(实时调度,优先级 99),并且在这个线程里写了一个没有退让机制的死循环 Spinlock。这导致对应的 CPU 核被完全霸占,内核态的 RCU (Read-Copy Update) 宽限期线程、ksoftirqd 等关键内核线程被彻底饿死,最终触发 rcu_sched stall,引发系统 Soft Lockup 进而 Panic。

    现场还原与死亡日志

    排查这种彻底死机的节点,常规的 topstrace 根本来不及敲,只能依赖保留的 kdump 或者重启后的 dmesg 遗言。在 messages 日志中,我提取到了最核心的崩溃栈:

    [ 1345.678901] watchdog: BUG: soft lockup - CPU#4 stuck for 22s! [bidding_worker:45092]
    [ 1345.678915] INFO: rcu_sched self-detected stall on CPU
    [ 1345.678920]  4-....: (59999 ticks this GP) idle=001/1/0x4000000000000000 softirq=45123/45123 fqs=14998
    [ 1345.678925]  (t=60000 jiffies g=123456 q=8901)
    [ 1345.678931] NMI backtrace for cpu 4
    [ 1345.678950] RIP: 0033:0x00007f8a9b2c3d4e (用户态死循环地址)
    

    日志非常清晰:CPU 4 卡死了 22 秒,RCU 机制检测到宽限期(Grace Period)已经 60000 个 jiffies 没有推进了。卡死 CPU 的进程是 bidding_worker,且 RIP 寄存器停留在用户态 (0033)。

    这意味着,不是内核出了 Bug,而是用户态进程死死咬住 CPU 不放,连内核的中断下半部和调度器都抢不回控制权。

    为什么用户态能干掉内核态?

    我拉取了该进程的调度配置,破案了:

    $ chrt -p 45092
    pid 45092's current scheduling policy: SCHED_FIFO
    pid 45092's current scheduling priority: 99
    

    这正是让人血压升高的操作。在 Linux 调度器(CFS,Completely Fair Scheduler)面前,所有普通进程(SCHED_OTHER)都要乖乖按时间片和 vruntime 来排队。但 SCHED_FIFOSCHED_RR 属于 Real-Time(实时)调度类,它的权限是凌驾于 CFS 之上的。

    SCHED_FIFO 的语义是:只要该线程不主动睡眠(sleep/wait)、不主动让出 CPU(sched_yield),或者不被更高优先级的 RT 线程抢占,它就会永远霸占当前的 CPU 核。

    业务代码里是怎么写的?

    // 致命的自旋锁逻辑
    while (!atomic_load(&task_ready)) {
        // 没有任何 sleep,没有 cpu_relax(),没有 sched_yield()
        // 纯纯的死循环烧 CPU
    }
    

    当优先级设定为 99 的 SCHED_FIFO 线程进入上述死循环时,灾难开始了:

    1. 抢占屏蔽:内核的普通调度器完全无法剥夺它的执行权。

    2. RCU 饿死:Linux 内核广泛使用 RCU 机制来管理无锁数据结构。RCU 需要等待所有 CPU 经过一次上下文切换(Quiescent State)来清理旧数据。CPU 4 被死循环霸占,永远不发生上下文切换,RCU 宽限期无限延长,内存无法释放,最终触发 RCU Stall Panic。

    3. 软中断阻塞:网卡收发包依赖的 ksoftirqd 默认也是普通优先级。CPU 4 上的网络包无法被处理,导致网络层面的“假死”。

    消失的最后一道防线

    按理说,Linux 内核对这种 RT 调度器的滥用是有防御机制的。kernel.sched_rt_runtime_uskernel.sched_rt_period_us 这两个 sysctl 参数,默认配置为每 1000000 微秒(1秒)内,RT 进程最多只能运行 950000 微秒(0.95秒)。剩下的 0.05 秒会强制留给普通进程(比如内核线程)续命。

    为什么没生效? 排查发现,某位前任 SRE 为了追求极限的内核调优,在初始化的 Ansible 剧本里加了一行: sysctl -w kernel.sched_rt_runtime_us=-1

    将该值设为 -1,意味着彻底关闭了 RT 进程的运行时间限制,扒光了内核的最后底裤。两者结合,精准击穿了系统的可用性防线。

    极低延迟的正确实现姿势

    如果业务真的需要旁路内核、避免调度开销来实现极低延迟(如 DPDK/SPDK 架构),绝不是简单粗暴地改个 SCHED_FIFO 就能搞定的。正确的系统级架构应该走CPU 隔离与绑核的路线:

    1. 内核启动参数隔离:通过 Grub 传入 isolcpus=4-7 nohz_full=4-7 rcu_nocbs=4-7。这会让操作系统把 CPU 4 到 7 隔离开,不往上面扔普通的定时器中断,不调度普通进程,并将 RCU 回调转移到其他核。

    2. 中断亲和性调整:配置 irqbalance 或手动修改 /proc/irq/xxx/smp_affinity,确保网卡和磁盘的硬件中断不要打到被隔离的核上。

    3. 应用侧精准绑核:业务进程启动后,使用 tasksetpthread_setaffinity_np,将死循环 Polling 的线程严格绑定在上述隔离核上。

    这时候,你用死循环轮询没有任何问题,因为这几个核本来就是买来专门给你“烧”的,不会影响系统大盘。

    排查清单与同类问题速查

    1. RCU Stall 定位确认 通过 dmesg -T | grep -i rcu_sched 查看是否有 self-detected stall。如果有,必须顺着堆栈找到是哪个 PID 卡死了特定的 CPU,并且留意 ticks this GP 是否异常庞大。

    2. 全网进程调度策略巡检 排查系统中是否有越权的调度策略:ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,comm | awk '$3 != "TS" && $3 != "-" {print $0}' (TS 代表 SCHED_OTHER,重点关注 FF/RR 类型的进程)。

    3. 内核防御机制校验 检查 /proc/sys/kernel/sched_rt_runtime_us 是否被手贱修改为 -1。在非专用的隔离物理机上,强烈建议保持默认值 950000

    4. 自旋锁的防御性编程 在任何 C/C++/Rust 的用户态 Spinlock 实现中,循环体内必须加入 cpu_relax()(在 x86 下编译为 PAUSE 指令),这不仅能降低 CPU 流水线功耗,也能缓解总线争用。