剥离CFS的伪公平:高频低延迟场景下的RT调度切换与NUMA亲和性深度优化

凌晨三点,机房的报警面板终于恢复了一片幽绿色。我合上终端,冷掉的半杯咖啡还在手边。

过去的一周,一个核心交易网关的P99延迟一直在折磨整个基础架构组。平时稳定在 2ms 的响应,在早晚高峰时段会毫无规律地出现 50ms 甚至上百毫秒的毛刺。研发翻遍了所有业务日志,将怀疑的矛头指向了网络抖动和 GC,但通过在宿主机不同层级抓包打点,最终的证据链却指向了一个容易被忽略的盲区:内核调度器。

这不是什么玄学,而是当业务的 IO 密集度与延迟敏感度达到一定水位时,Linux 默认的 CFS(完全公平调度器)策略及其在 NUMA 架构下的行为,已经成了系统的最大瓶颈。

CFS 唤醒延迟的底层软肋

最初排查时,宿主机的 CPU 负载(Load)和整体使用率都非常健康,单核使用率最高不超过 60%。但系统看起来闲,并不代表任务跑得顺。

我直接挂上 perf 抓了一段调度延迟:

perf sched record -p <gateway_pid> -- sleep 10
perf sched latency

解析后的结果让我皱了眉头:网关的几个核心 Epoll Reactor 线程,最大调度延迟(Maximum delay)竟然飙到了 43ms。这意味着,一个网络包到达网卡,硬中断转软中断,协议栈处理完唤醒 Epoll 线程后,这个线程在 Runqueue 里干等了 43ms 才真正拿到 CPU。

问题出在 CFS 的“公平”二字上。

CFS 维护了一个红黑树,按照每个任务的虚拟运行时间(vruntime)来排序。当一个处于休眠状态的 IO 线程被网卡中断唤醒时,它需要抢占当前 CPU 上正在运行的任务。但内核并非无条件允许抢占,我们看内核 kernel/sched/fair.c 中的 check_preempt_wakeup 函数逻辑:

只有当被唤醒任务的 vruntime 与当前任务的 vruntime 的差值,大于一个特定的阈值时,才会触发抢占(resched_curr(rq))。这个阈值由内核参数 sched_wakeup_granularity_ns 决定,默认通常是数毫秒级别。如果抢占失败,这个极其重要的 IO 线程只能被乖乖塞进红黑树,等待当前任务消耗完它的最小时间片(sched_min_granularity_ns)。

对于注重吞吐量的 Web 服务,这种设计完美避免了频繁上下文切换带来的开销;但对于低延迟交易网关,毫秒级的等待就是灾难。

粗放的 CPU 亲和性与缓存失效

起初,我尝试调小 sched_wakeup_granularity_ns 来提升唤醒抢占的敏感度,毛刺确实有所缓解,但随之而来的是系统上下文切换(cs)飙升,整体吞吐下降。

更严重的问题隐藏在研发之前做的一个“优化”里。为了避免跨 NUMA 节点的内存访问,研发通过 taskset 将整个网关进程粗暴地绑在了 NUMA 0 节点的所有核上(0-19核)。

在 CFS 机制下,当一个线程被唤醒时,调度器会进入 select_task_rq_fair 来为它挑选一个合适的 CPU。内核不仅会看之前运行的 CPU,还会评估整个 NUMA 节点内各个核心的负载情况。由于绑核粒度太粗,这几十个核心网关线程在 NUMA 0 的 20 个物理核之间疯狂弹跳。

每一次跨核心的线程迁移,意味着该线程之前在 L1/L2 Cache 中建立的热点数据全部失效(Cache Miss),随之而来的 TLB Miss 更是让内存访问延迟雪上加霜。在 perf stat -d 的数据里,L1-dcache-load-misses 的比例高得吓人。

破局:SCHED_FIFO 与精准硬隔离

面对这种场景,修修补补已经没有意义。核心网关线程不需要 CFS 给的“公平”,它们需要的是“绝对特权”。

我决定将网关网络层的 Reactor 线程从传统的 SCHED_OTHER(CFS调度)剥离,切换到 SCHED_FIFO(实时调度 RT)。

SCHED_FIFO 的逻辑极为霸道:只要 RT 线程处于 Runnable 状态,它会无视任何 CFS 任务直接抢占 CPU;除非它主动让出(阻塞/休眠)或被更高优先级的 RT 任务抢占,否则它会一直霸占 CPU。

1. 业务层切调度策略

我们在网关初始化的代码中加入了这段逻辑,仅针对核心线程提权,普通的工作线程依然走 CFS:

#include <sched.h>

void set_thread_rt_priority() {
    struct sched_param param;
    param.sched_priority = 50; // 设置为较高的 RT 优先级 (1-99)

    // 获取当前线程 PID,将调度策略改为 SCHED_FIFO
    if (sched_setscheduler(0, SCHED_FIFO, &param) == -1) {
        perror("sched_setscheduler failed");
    }
}

2. 内核层兜底防护

将用户态进程设为 RT 是极其危险的操作。如果这个网络线程出现死循环,该 CPU 核心将彻底锁死,连 SSH 的 sshd 进程都得不到运行机会。

为了防止“一车面包人全被带进沟里”,必须配置 RT 组调度参数作为底层保险:

# 调度周期 1秒
sysctl -w kernel.sched_rt_period_us=1000000
# RT任务在一个周期内最多只能运行 0.95 秒,剩下 0.05 秒强制留给 CFS 任务
sysctl -w kernel.sched_rt_runtime_us=950000

3. 精细化绑核与中断隔离

解决了抢占延迟,下一步是消灭 Cache Miss。废弃之前大锅饭式的 NUMA 绑定,在 Grub 内核启动参数中,我直接划出 4 个物理核作为“禁区”:

# grub 启动参数追加
isolcpus=16,17,18,19 nohz_full=16,17,18,19 rcu_nocbs=16,17,18,19

isolcpus 让 CFS 调度器完全忽略这几个核心,nohz_fullrcu_nocbs 进一步剥离了这几个核心上的时钟滴答(Tick)和 RCU 回调。这就是纯粹的 Linux 零干扰环境(Zero-Interference)。

随后,通过网卡队列的 smp_affinity,将特定网卡队列的硬中断绑定在这 4 个核上;最后,在程序启动后,精确地将 4 个设为 SCHED_FIFO 的 Reactor 线程,通过 pthread_setaffinity_np 1对1地死死按在这 4 个物理核上。

不迁移,不排队,不被普通任务打断。

尾声

一套组合拳打完,重新上线切流。

再看监控面板,不仅那几十毫秒的毛刺彻底绝迹,连平均响应延迟都硬生生压低了 15%。内核调度器就像是一条八车道的高速公路,CFS 负责让所有车都能平稳地跑起来,但如果你开的是救护车,就别在车流里按喇叭了,直接去走应急车道。

今晚的活儿干完了,该下线了。