标签: CPU亲和性

  • 深入 CPU 亲和性陷阱排查:HT 物理核争抢引发的 L1 Cache 颠簸与 CFS 性能退化实战

    盲目使用 taskset 绑定 CPU 亲和性,若无视底层的 NUMA 与超线程(HT)拓扑,极易引发性能灾难。核心结论:将高负载线程绑定到同一物理核的两个逻辑核上,会导致 L1/L2 Cache 严重颠簸与 ALU 运算器争抢,使进程 IPC 大幅下降。由于强绑定限制,CFS 调度器无法进行跨核负载均衡。解决方案:解析 /sys/devices/system/cpu/ 拓扑树,将延迟敏感任务绑定到独立物理核,并配合 isolcpus 剔除内核干扰。

    现场还原:P99 飙升与离奇的 CPU 闲置

    排查过程中遇到一个经典场景:某个自研的高性能 C++ 网关应用(基于 epoll 跑在 Kernel 5.4.x 环境),为追求极低延迟,研发人员使用了严格的 taskset -cp 将 4 个 Worker 线程绑定到 CPU 8, 9, 10, 11 上。

    测试环境表现尚可,但一到生产环境面临 10万+ QPS 的真实流量时,监控面板的 P99 延迟直接从 2ms 飙升到了 35ms,甚至偶发超时熔断。

    奇怪的是,查看系统级指标:

    • Load Average 并不高。

    • mpstat -P ALL 1 显示这几个绑定的 CPU 使用率只有 60% 左右。

    • 没有明显的 I/O Wait。

    如果 CPU 没跑满,为什么会出现严重的延迟毛刺?

    抽丝剥茧:揪出 Cache 颠簸元凶

    遇到这种“CPU 没跑满但延迟极高”的 CPU-Bound 任务,直觉告诉我,要么是自旋锁争抢,要么是 CPU 缓存或者流水线出了问题。祭出 perf 查看微架构指标。

    对其中一个 Worker 线程(PID 10245)抓取指令周期与 Cache 命中率:

    # 采样 10 秒钟的核心硬件指标
    perf stat -p 10245 -e cycles,instructions,cache-misses,L1-dcache-load-misses,L1-icache-load-misses sleep 10
    

    输出结果非常扎眼:

     Performance counter stats for process id '10245':
    
         3,456,123,908      cycles                    #    3.2 GHz
         1,210,034,211      instructions              #    0.35  insn per cycle (IPC)
           215,892,103      cache-misses              #    ...
         1,890,210,345      L1-dcache-load-misses     #    38.4% of all L1-dcache hits
    
          10.001234123 seconds time elapsed
    

    IPC (Instructions Per Cycle) 仅为 0.35。正常优化良好的 C++ 网络服务,IPC 至少应该在 1.0 以上。L1 数据缓存未命中率高达 38.4%,说明流水线大部分时间都在 stall(停顿),等待主存数据。

    为什么 Cache 会颠簸得如此厉害?查看绑定的 CPU 拓扑。

    lscpu -p=CPU,CORE,SOCKET,NODE | grep -v '#'
    # 节选输出:
    ...
    8,4,0,0
    9,5,0,0
    10,4,0,0
    11,5,0,0
    ...
    

    破案了。研发绑定的 CPU 8, 9, 10, 11,对应的物理核(CORE)其实是 45。 具体来说,逻辑核 8 和 10 是同一个物理核 4 的超线程(HT)对;逻辑核 9 和 11 是物理核 5 的超线程对。

    为什么 HT 伪共享会击穿 CFS 调度器预期?

    在 x86 架构中,SMT(超线程)技术让一个物理核虚拟出两个逻辑核,但这两个逻辑核是共享 L1/L2 Cache 以及 ALU(算术逻辑单元)的

    当两个 CPU 密集型的高并发线程被硬绑定在 CPU 8 和 CPU 10 上时:

    1. 硬件层面的踩踏:线程 A 刚把热点数据加载进 L1 Cache,线程 B 运转时瞬间将其驱逐(Eviction)。两个线程在同一个物理核内发生了剧烈的 Cache 争抢(Cache Thrashing)。ALU 流水线也被塞满,导致计算能力退化,IPC 断崖式下跌。

    2. CFS 调度器的无力:Linux CFS(完全公平调度器)在构建调度域(sched_domain)时,知道 8 和 10 属于 SD_SHARE_CPUCAPACITY(共享 CPU 容量)。正常情况下,CFS 会尽量把任务打散到不同的物理核上。但是,研发使用了 taskset(底层是 sched_setaffinity 系统调用)进行了强制 cpumask 绑定。 CFS 的负载均衡器(Load Balancer)被严格的亲和性规则限制,无法将任务迁移到系统其他空闲的物理核上。

    这两个线程被死死按在同一个物理核上互相绞杀,单核的真实算力从 1.0 骤降到 0.6,这就是为什么 mpstat 看起来只跑了 60%,但延迟已经完全崩坏的原因——不是算力没用满,而是算力上限暴跌了

    防御性隔离方案:从内核到 K8s 的落地

    要彻底解决由于 CPU 亲和性设置不当带来的延迟问题,必须做到“拓扑感知”。

    1. 脚本化解析独立物理核

    在宿主机部署服务时,禁止硬编码绑核。可以使用如下 Bash 脚本获取处于同一 NUMA 节点且各自独占物理核的逻辑 CPU 列表:

    #!/bin/bash
    # 找出 NUMA Node 0 上的独立物理核对应的第一个逻辑核
    numa_node=0
    cpus=$(lscpu -p=CPU,CORE,NODE | grep -v '#' | awk -v node="$numa_node" -F, '$3==node {print $0}')
    
    declare -A seen_cores
    result=()
    
    for line in $cpus; do
        cpu=$(echo $line | cut -d, -f1)
        core=$(echo $line | cut -d, -f2)
        if [[ -z "${seen_cores[$core]}" ]]; then
            seen_cores[$core]=1
            result+=($cpu)
        fi
    done
    
    echo "可绑定的独立逻辑核: ${result[*]}"
    # 输出示例: 可绑定的独立逻辑核: 0 1 2 3 4 5 6 7
    

    将 Worker 线程绑定到这批 CPU 上,杜绝 HT 资源内耗。

    2. K8s 环境下的 CPU Manager 策略

    如果业务运行在 Kubernetes(例如 v1.22+)中,千万不要在容器的 entrypoint 脚本里自己玩 taskset。正确的做法是利用 Kubelet 的 CPUManager

    /var/lib/kubelet/config.yaml 中配置:

    cpuManagerPolicy: static
    topologyManagerPolicy: single-numa-node
    

    并在 Pod 的 YAML 中设置 Guaranteed QoS(requests 和 limits 相等且为整数):

    resources:
      requests:
        cpu: "4"
        memory: "8Gi"
      limits:
        cpu: "4"
        memory: "8Gi"
    

    Kubelet 会自动分配独占的物理核,底层利用 cgroup 的 cpuset 子系统进行严格隔离,并且优先分配属于同一个 NUMA Node 且完整的物理核,避免跨 NUMA 内存访问延迟和 HT 争抢。

    3. 终极隔离:Kernel 启动参数调优

    对于极高要求的 DPDK 或核心网关进程,仅靠用户态的 cpuset 还不够。内核的软中断(ksoftirqd)、RCU 回调、定时器滴答(tick)依然会打断当前 CPU。 需要在 Grub 启动参数中加入:

    isolcpus=4-7 nohz_full=4-7 rcu_nocbs=4-7
    
    • isolcpus:将 CPU 4-7 从 CFS 的普通调度队列中剔除。

    • nohz_full:在 CPU 只有一个可运行任务时,关闭时钟滴答,消除 Context Switch 开销。

    • rcu_nocbs:将 RCU 回调转移到其他 CPU 执行。

    配置后,通过 taskset -c 4-7 ./gateway_app 启动程序,即可获得近乎物理机裸金属级别的极致低延迟。

    常见问题 (Q&A)

    Q1:我是不是直接进 BIOS 把 Hyper-Threading(超线程)关掉就一劳永逸了? 视业务场景而定。对于普通的 Web 服务、微服务集群,HT 能够提升整个系统 20%~30% 的并发吞吐量;但对于 Redis、DPDK 这种重度依赖 L1/L2 Cache 和极低延迟的 CPU-Bound 任务,HT 是毒药。如果没有混合部署的需求,纯跑高频核心服务,关闭 HT 是最简单的防误操作方案。

    Q2:使用了 isolcpus 之后,发现绑定的 CPU 依然偶尔会处理网络中断,为什么? isolcpus 只是隔离了进程调度,并没有隔离硬件中断(IRQ)。你还需要配置 SMP IRQ Affinity。修改 /proc/irq/default_smp_affinity,或者使用 irqbalance 黑名单机制,把网卡的软硬中断绑定到非 isolcpus 的核上(比如 CPU 0-3),否则高频网卡中断依然会击穿你的隔离。

    Q3:taskset 和 cgroup 的 cpuset 有什么本质区别? taskset 是基于线程级别的亲和性掩码(Bitmask)设置,CFS 调度时会取任务掩码与系统可用 CPU 的交集,比较轻量,但容易被后续的系统调用覆盖。而 cpuset 属于 Cgroup 树形资源控制,不仅能限制进程组使用的 CPU,还能限制内存 NUMA 节点分配(cpuset.mems),隔离性更强。在容器化场景下,绝对推荐使用 cpuset,废弃 taskset

  • 深入 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 干扰业务线程。

    随后,通过 tasksetcgroup 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_waitsleep、网络 IO 等阻塞调用。当 RT 进程阻塞等待时,它会主动让出 CPU,此时 ksoftirqd 等内核线程就能趁机运行。只有在纯 CPU 密集型的 while(1) 死循环或高频极密轮询下,RT 任务才会真正引发系统级灾难。