标签: 调度器

  • 深入 Linux 内核调度陷阱排查:滥用 SCHED_FIFO 实时调度引发的 RCU 饥饿与 Watchdog Soft Lockup 实战

    滥用 SCHED_FIFO 实时调度策略会导致 CPU 核心被 100% 霸占,进而饿死内核 RCU 线程,触发 rcu_sched stall 和 Watchdog 软死锁。本文还原一次宿主机内核 Panic 现场,深度解析 RT 调度器与 CFS 抢占机制,并给出通过 isolcpus 与 RT 节流阀实现安全绑核的终极方案。

    故障现场:K8s 节点神秘失联与内核崩溃

    近期在处理某低延迟网关集群的稳定性问题时,发现部分宿主机(OS: CentOS 8, Kernel 4.18.0-305.el8.x86_64)频繁触发 NotReady,随后整机无响应,只能通过 IPMI 硬重启。

    遇到物理节点无响应,第一反应是接上串口控制台看系统底层的最后遗言。提取 vmcore 并在控制台日志中发现海量如下报错:

    [ 3105.123456] NMI watchdog: BUG: soft lockup - CPU#4 stuck for 22s! [gw_worker:14032]
    [ 3105.123488] Modules linked in: veth ipt_MASQUERADE ...
    [ 3105.123501] CPU: 4 PID: 14032 Comm: gw_worker Kdump: loaded Tainted: G  W  4.18.0-305.el8.x86_64 #1
    [ 3105.123512] RIP: 0033:0x00007f8a9b1c2d40
    [ 3105.123520] Code: 89 d0 f3 90 48 8b 02 48 39 c0 75 f6 ... (用户态自旋指令)
    ...
    [ 3110.134567] INFO: rcu_sched self-detected stall on CPU
    [ 3110.134575]  4-....: (21000 ticks this jiffies) idle=002/1/0x4000000000000002 softirq=12345/12345 fqs=5250
    

    日志暴露出两个致命特征:

    1. Soft Lockup (软死锁):CPU 4 被进程 gw_worker (PID 14032) 连续霸占超过 22 秒,期间没有任何调度切换。

    2. RCU Stall (RCU 停滞):内核的 RCU (Read-Copy Update) 机制检测到 CPU 4 迟迟没有经过宽限期(Quiescent State),导致全系统 RCU 回收阻塞。

    查看发生 Lockup 的指令流(RIP),确认其卡在用户态地址 0x0033:...,而非陷入内核死锁。一个用户态进程,凭什么能让内核级 Watchdog 报警甚至宕机?

    为什么用户态的 SCHED_FIFO 会引发内核级瘫痪?

    排查过程中,直接抓取了该业务容器的启动脚本,发现开发团队为了压榨延迟,执行了以下操作:

    # 业务启动脚本片段
    taskset -c 4 chrt -f 99 ./gw_worker
    

    不仅用 taskset 绑定了 CPU 4,还用 chrt -f 99 将调度策略设置为了最高优先级的实时调度 SCHED_FIFO。配合代码中无休止的 while(true) { poll_nic(); }(完全没有 sleep 或系统调用),直接引爆了调度器。

    在 Linux 调度子系统中,调度类优先级严格遵循: StopTask > Deadline (SCHED_DEADLINE) > RealTime (SCHED_FIFO/SCHED_RR) > Fair (SCHED_NORMAL/CFS) > Idle

    底层机制解析:

    1. 抢占机制失效:SCHED_FIFO 属于实时调度类。对于 FIFO 策略,除非它主动 yield(),或者被更高优先级的实时任务抢占,否则它将永远独占所在的 CPU 的 Runqueue。

    2. 内核线程饿死:内核中有许多关键的后台线程(如 ksoftirqd、RCU 宽限期处理线程等),它们的默认调度策略是 CFS (SCHED_NORMAL)。当 CPU 4 上跑满了一个死循环的 SCHED_FIFO 任务时,CFS 队列中的任务连 1 纳秒的时间片都分不到。

    3. 软死锁触发:NMI Watchdog 是一个基于高精度定时器(hrtimer)的机制,它依赖内核能够定期响应中断并更新时间戳。当 CPU 完全被用户态 Spin-loop 锁死,且内核软中断(Softirq)被饿死时,Watchdog 计时器超时,判定 CPU 陷入软死锁,触发 Panic(若配置了 kernel.softlockup_panic = 1)。

    致命的配置漂移:RT 节流阀被关

    有经验的内核玩家会问:Linux 默认启用了 RT Throttling(实时任务节流机制),允许 RT 任务最多运行 95% 的时间,剩下 5% 强制留给 CFS,为什么没起作用?

    检查宿主机内核参数:

    $ sysctl kernel.sched_rt_runtime_us
    kernel.sched_rt_runtime_us = -1
    

    真相大白。某次系统级优化中,运维人员为了消除所谓的“调度抖动”,将 kernel.sched_rt_runtime_us 设为了 -1(彻底禁用 RT 限制)。这把最后一道安全阀拔掉了,导致 gw_worker 实现了一刀切的完全独占。

    终极解决方案:防御性配置与规范化绑核

    在生产环境中,要既满足低延迟诉求,又保证系统不发生雪崩,必须实施防御性隔离。

    方案一:恢复并优化 RT 节流机制 (快速止血)

    绝对禁止全局禁用 RT Throttling。将其恢复为默认值,或者适当放宽但不允许等于 -1:

    # 默认总周期 1000000 us (1s)
    sysctl -w kernel.sched_rt_period_us=1000000
    # 允许 RT 任务最多运行 980000 us (98%),留 20ms 给内核及 CFS 兜底
    sysctl -w kernel.sched_rt_runtime_us=980000
    

    这 2% 的时间足以让 RCU 完成宽限期推进,彻底消除 rcu_sched stall 和 Soft Lockup。

    方案二:内核级极致隔离 (isolcpus + nohz_full)

    如果业务(如高频交易 DPDK 场景)确实连 2% 的调度抖动都无法忍受,必须进行系统级的彻底隔离。通过内核启动参数,将特定 CPU 从内核的常规调度器中剥离。

    修改 /etc/default/grub 中的 GRUB_CMDLINE_LINUX:

    # 假设隔离 CPU 4,5
    isolcpus=4,5 nohz_full=4,5 rcu_nocbs=4,5 irqaffinity=0-3
    

    原理解析:

    • isolcpus=4,5:将 CPU 4 和 5 从 CFS 负载均衡域中剔除,常规进程绝不会被调度过去。

    • nohz_full=4,5:禁用该 CPU 上的时钟 Tick 中断(Adaptive-Ticks),避免每秒 1000 次(HZ=1000)的定时器中断打断用户态 Spin-loop。

    • rcu_nocbs=4,5:将 RCU 的回调(Callbacks)转移到其他 CPU 上执行,防止该 CPU 不响应调度引发 RCU Stall。

    • irqaffinity=0-3:将所有外部硬件中断限制在非隔离的 CPU 上。

    应用后重启:

    grub2-mkconfig -o /boot/grub2/grub.cfg
    reboot
    

    之后业务再通过 taskset -c 4 运行。由于该 CPU 上没有任何其他竞争者(内核中断也被驱逐),业务甚至不需要使用危险的 SCHED_FIFO,直接用普通 SCHED_NORMAL 跑满即可实现 0 调度上下文切换。

    常见问题 (Q&A)

    Q1:如果是普通的 CFS 调度任务死循环导致 CPU 100%,会触发 Soft Lockup 吗? 不会。CFS(完全公平调度器)保证了时间片的相对公平。即使进程写了死循环跑满 100%,当时钟中断(Tick)到来或时间片耗尽时,内核仍能强制剥夺其执行权,调度 ksoftirqd 和 Watchdog 线程,不会引发内核饿死。

    Q2:如何快速监控系统中是否存在滥用实时调度策略的危险进程? 可以通过 ps 命令直接排查,提取调度策略 (policy) 为 FF (FIFO) 或 RR (Round Robin) 的进程:

    # rtprio 为实时优先级, psr 为当前运行的 CPU
    ps -eo pid,ni,pri,rtprio,psr,comm,policy | grep -E 'FF|RR'
    

    Q3:在 Kubernetes 中,应该如何规范地分配独立 CPU 给低延迟业务? 不要在容器内使用 taskset 乱绑。应启用 Kubelet 的 CPU Manager(设置 --cpu-manager-policy=static),并将 Pod 的资源请求配置为 Guaranteed QoS(requests.cpu 等于 limits.cpu,且为整数)。Kubelet 底层会通过 cgroup cpuset 子系统将独占 CPU 分配给容器,避免与其他 Pod 产生资源争抢,此时配合宿主机的 isolcpus 即可达到最佳实践。

  • 深入 RT 调度陷阱排查:SCHED_FIFO 引发的 RCU Stall 与 CPU 亲和性假死实战

    盲目将业务进程设置为 SCHED_FIFO(实时调度)并绑核,若未保留内核线程调度余量,会直接饿死同核的 RCU 回调与 kworker,触发 rcu_sched stall 导致节点假死。解法:恢复 sched_rt_runtime_us 节流,高并发组件应优先使用 CFS 结合 cpuset 绑核,严禁滥用 RT 调度。

    故障现场:P99 飙升与节点失联

    某次排查高频交易网关的性能问题时,业务侧反馈部分节点的 P99 延迟会突然从 2ms 飙升至 3000ms 以上,甚至出现短暂的连接超时。 登录问题节点(Kernel 5.10.112-11)发现,Load Average 异常飙升至所在 CPU 核数以上,SSH 交互出现明显卡顿,但整体内存和磁盘 IO 并无压力。

    查看系统内核日志,直接抓到核心报错:

    $ dmesg -T | tail -n 20
    [Tue Oct 24 14:12:33] INFO: rcu_sched self-detected stall on CPU
    [Tue Oct 24 14:12:33]   12-....c.: (59999 ticks this GP) idle=31a/1/4611686018427387906 softirq=1028321/1028321 fqs=14995
    [Tue Oct 24 14:12:33]    (t=60000 jiffies g=2431213 q=101345)
    [Tue Oct 24 14:12:33] NMI backtrace for cpu 12
    ...
    [Tue Oct 24 14:12:33] RIP: 0010:gateway_poll_loop+0x45/0x120 [gateway_bin]
    ...
    

    日志非常明确:CPU 12 上发生了 rcu_sched stall(RCU 宽限期超时)。内核检测到 CPU 12 上的 RCU grace period 已经停滞了 60000 个 jiffies(通常是 60 秒),并且抓到的现场 RIP 寄存器停留在业务进程 gateway_bin 的轮询函数 gateway_poll_loop 中。

    剥丝抽茧:谁霸占了 CPU?

    既然业务进程在 CPU 12 上死循环或高负载,第一步是检查该进程的调度策略和 CPU 亲和性。

    抓取目标进程的 PID,查看其调度属性:

    $ chrt -p 48291
    pid 48291's current scheduling policy: SCHED_FIFO
    pid 48291's current scheduling priority: 99
    
    $ taskset -cp 48291
    pid 48291's current affinity list: 12
    

    结果一目了然:开发为了追求极致延迟,通过 sched_setscheduler 将该网关的轮询线程设置成了 SCHED_FIFO(实时调度),优先级拉到了最高的 99,并且通过 sched_setaffinity 强行绑定在了 CPU 12 上。

    顺手检查一下系统的 RT 调度全局限制:

    $ sysctl kernel.sched_rt_runtime_us
    kernel.sched_rt_runtime_us = -1
    

    发现 kernel.sched_rt_runtime_us 被改为了 -1(无限制)。这就是彻头彻尾的“自杀式”调优。

    为什么 SCHED_FIFO 结合绑核会引发系统假死?

    在 Linux 内核的调度子系统中,调度类(Scheduling Class)是有严格层级关系的:Stop > Deadline > Real-Time (RT) > Fair (CFS) > Idle。

    SCHED_FIFO 属于 RT 调度类,其优先级碾压普通的 CFS 调度任务。当一个 SCHED_FIFO 线程进入 Runnable 状态时,它会无条件抢占当前 CPU 上的 CFS 线程,并且只有在它主动让出 CPU(如 sleep、IO 阻塞)或被更高优先级的 RT 任务抢占时,才会交出执行权。

    在本案中,网关线程是一个 while(1) 的密集轮询(Poll)循环,不包含任何阻塞系统调用。这就导致了以下连锁反应:

    1. 绝对霸占:该线程在 CPU 12 上以 SCHED_FIFO 优先级 99 运行,永远不会主动 yield。

    2. 内核线程饿死:Linux 内核依赖每 CPU 的 ksoftirqd(处理软中断,如网络包处理)、rcuc(RCU 回调处理)等内核线程来维持系统运转。这些内核线程大部分默认是 CFS 调度。

    3. RCU 宽限期停滞:RCU(Read-Copy-Update)机制依赖各 CPU 周期性地报告 quiescent state(静止状态)来结束 Grace Period,进而回收内存。CPU 12 上的 rcuc 线程被彻底饿死,无法上报静止状态。

    4. 全局雪崩:其他 CPU 上的写操作在等待 RCU Grace Period 结束,由于 CPU 12 一直不上报,整个系统的 RCU 回收被阻塞。随着时间推移,系统内存无法回收,其他依赖 RCU 同步的内核路径全部卡死,最终触发 rcu_sched stall,甚至引发 Watchdog 触发 NMI 宕机(如果开启了 kernel.unknown_nmi_panic)。

    内核原本有一个保护机制:kernel.sched_rt_period_us(默认 1000000,即 1s)和 kernel.sched_rt_runtime_us(默认 950000,即 0.95s)。这意味着在每 1 秒内,RT 任务最多只能运行 0.95 秒,强制留出 50ms 给普通的 CFS 任务和内核线程运行。 但排查过程中发现,有人为了“避免这 50ms 的毛刺”,将 sched_rt_runtime_us 设置成了 -1,彻底关闭了 RT 节流保护,直接把系统推向了深渊。

    破局与架构优化:防御性调度

    针对高频网关/DPDK/Redis等对延迟极度敏感的场景,正确的调优姿势绝不是盲目开 SCHED_FIFO,而是通过“隔离与独占”在 CFS 下实现类似 RT 的效果。

    1. 紧急止血:恢复 RT 节流

    立即将 sched_rt_runtime_us 恢复为默认值,给内核线程留出活路。

    sysctl -w kernel.sched_rt_runtime_us=950000
    

    注:即使业务出现少量 P99 毛刺,也比整个 Node 假死要好。

    2. 长期重构:isolcpus + NOHZ_FULL + CFS

    废弃代码中的 SCHED_FIFO 设置,改回默认的 SCHED_OTHER (CFS)。 利用内核参数将特定 CPU 从调度器和中断中隔离出来,让业务线程在干净的 CPU 上运行。

    在 Grub 内核启动参数中添加:

    isolcpus=12 nohz_full=12 rcu_nocbs=12
    
    • isolcpus=12:将 CPU 12 从内核调度器的普通负载均衡域中剥离,CFS 不会主动将其他进程调度到该核。

    • nohz_full=12:当 CPU 12 上只有一个 Runnable 进程时,关闭 Tick 时钟中断,消除 1000Hz/250Hz 的调度时钟抖动。

    • rcu_nocbs=12:将 CPU 12 的 RCU 回调处理卸载到其他 CPU(如 CPU 0)上执行,防止 RCU 干扰业务线程。

    随后,通过 taskset 或 cgroup cpuset 将业务进程绑定到 CPU 12。此时,业务进程虽然是 CFS 调度,但在 CPU 12 上没有竞争者,既能实现接近 100% 的独占运行,又不会引发 RCU Stall。

    常见问题

    Q: 如何快速排查系统中是否存在滥用 SCHED_FIFO 的进程? 使用 ps 命令可以快速拉取调度策略和优先级。FF 代表 FIFO,RR 代表 Round Robin。

    ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,stat,comm --sort=-rtprio | grep -v TS
    

    Q: SCHED_RR (Round Robin) 能否解决这个饿死问题? 不能。SCHED_RR 同样属于 RT 调度类,优先级依然高于 CFS。它只是在同等优先级的多个 RT 任务之间采用时间片轮转。如果同核上只有一个跑满 CPU 的 SCHED_RR 任务,它依然会饿死普通的 CFS 内核线程。

    Q: 容器环境 (K8s) 中如何实现类似的安全绑核? 在 K8s 中,通过配置 kubelet 的 --cpu-manager-policy=static,并为 Pod 申请整数型的 CPU 资源(如 requests.cpu: 2, limits.cpu: 2),K8s 会自动为其分配独占的物理 CPU(借助 cpuset cgroup)。不要在容器内自行调用 sched_setscheduler 修改为 RT 调度,容易被 cgroup 限制策略拦截或引发宿主机雪崩。

    Q: 为什么有时候设置了 SCHED_FIFO 并没有引发机器死机? 因为业务逻辑中存在 epoll_wait、sleep、网络 IO 等阻塞调用。当 RT 进程阻塞等待时,它会主动让出 CPU,此时 ksoftirqd 等内核线程就能趁机运行。只有在纯 CPU 密集型的 while(1) 死循环或高频极密轮询下,RT 任务才会真正引发系统级灾难。