4. CPU亲和性与中断绑定:让核心只干一件事

你有没有遇到过这种情况?

明明CPU负载不高,交易延迟却突然飙升。我早期做高频交易系统时就被这个问题折磨过。后来才发现,是操作系统把我们的交易线程和网卡中断扔到了同一个核心上。它们互相抢资源,延迟自然就炸了。

今天我们就来聊聊怎么解决这个问题。说白了就两个手段:CPU亲和性中断绑定。让每个核心只干一件事,别瞎掺和。

4.1 什么是CPU亲和性?

CPU亲和性,就是告诉操作系统:这个线程/进程,只允许在指定的CPU核心上运行。别给我乱调度。

默认情况下,操作系统会自作聪明地把线程在各个核心间搬来搬去。它觉得这是负载均衡,但在我们做低延迟交易的人眼里,这就是灾难。

为什么?

  • 缓存失效:线程换到另一个核心,L1/L2缓存全凉了。重新加载数据,几十纳秒就没了。
  • 上下文切换:核心切换线程时,要保存恢复状态。这个开销在微秒级别,但累积起来很要命。
  • 不可预测性:你不知道下一秒线程会被扔到哪个核心上。延迟曲线就像过山车。

我个人习惯,在交易系统里,每个关键线程都绑定到固定核心。这样延迟稳定,性能也可预测。

4.2 怎么设置CPU亲和性?

Linux下有两种方式:命令行和代码里设置。

4.2.1 命令行方式

taskset 命令。比如把进程PID为1234的进程绑定到核心0和1:

taskset -cp 0,1 1234

启动时直接绑定:

taskset -c 0,1 ./my_trading_app

4.2.2 代码里设置

sched_setaffinity 系统调用。我一般这样写:

#include <sched.h>
#include <pthread.h>

void pin_thread_to_core(pthread_t thread, int core_id) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(core_id, &cpuset);
    
    int ret = pthread_setaffinity_np(thread, sizeof(cpu_set_t), &cpuset);
    if (ret != 0) {
        // 处理错误,我一般直接打日志然后退出
        perror("pthread_setaffinity_np failed");
    }
}

嗯,这里要注意:pthread_setaffinity_np 是Linux特有的,不是POSIX标准。但做低延迟系统,我们基本只考虑Linux,所以没问题。

我的经验: 在交易系统初始化阶段,尽早绑定核心。越晚绑定,线程被迁移到其他核心的概率越大,缓存污染就越严重。

4.3 中断绑定:别让网卡打扰你的交易线程

CPU亲和性解决了线程调度问题。但还有一个隐藏的敌人:硬件中断

网卡收到一个数据包,会触发中断。CPU核心收到中断后,会暂停当前工作,去处理中断。如果你的交易线程和网卡中断在同一个核心上,那每次收数据包,你的交易逻辑就被打断一次。

我曾经在一个项目里,发现延迟偶尔会飙到几十微秒。排查了半天,最后发现是网卡中断和行情处理线程绑在了同一个核心上。把中断挪走后,延迟立刻稳定下来。

4.3.1 查看当前中断绑定

Linux下,中断信息在 /proc/interrupts 里。你可以看到每个中断号被分配到了哪个核心:

cat /proc/interrupts | grep eth0

输出大概长这样:

  CPU0    CPU1    CPU2    CPU3
 78:   12345       0       0       0   IR-PCI-MSI  eth0-TxRx-0
 79:       0   23456       0       0   IR-PCI-MSI  eth0-TxRx-1
 80:       0       0   34567       0   IR-PCI-MSI  eth0-TxRx-2
 81:       0       0       0   45678   IR-PCI-MSI  eth0-TxRx-3

你看,每个队列的中断都分配到了不同的核心。如果你的网卡支持多队列(RSS),那就可以把不同队列的中断绑定到不同核心。

4.3.2 修改中断绑定

echo 命令写 /proc/irq/{irq_number}/smp_affinity 文件。这个文件接受一个十六进制掩码。

比如把中断号78绑定到核心0(掩码为1):

echo 1 > /proc/irq/78/smp_affinity

绑定到核心1(掩码为2):

echo 2 > /proc/irq/78/smp_affinity

绑定到核心0和1(掩码为3):

echo 3 > /proc/irq/78/smp_affinity
注意: 修改中断绑定需要root权限。而且,有些网卡驱动不支持动态修改,你可能需要重启网络服务。我建议在系统启动脚本里就把中断绑定好,一劳永逸。

4.4 实战策略:如何规划核心分配

好了,现在我们知道怎么绑定了。但具体怎么分配核心?我一般遵循这几个原则:

  1. 隔离核心:把一部分核心专门留给交易线程,不让操作系统用它跑其他进程。Linux内核启动参数里加 isolcpus=2,3 就可以把核心2和3隔离出来。
  2. 中断专用核心:把网卡中断绑定到某个核心上,这个核心只处理中断,不跑交易逻辑。比如用核心0处理中断,核心1-3跑交易。
  3. 交易线程独占核心:每个交易线程绑定到独立的核心。不要多个线程共享一个核心,除非你很清楚自己在做什么。
  4. 管理线程靠边站:日志、监控、管理接口这些非关键线程,扔到其他核心上,别和交易线程抢资源。

下面这张图展示了我常用的核心分配方案:

CPU核心分配策略 物理CPU(4核心) 核心 0 网卡中断处理 数据包分发 不跑交易逻辑 核心 1 行情解码线程 订单簿维护 独占核心 核心 2 策略计算线程 信号生成 独占核心 核心 3 订单发送线程 风控检查 独占核心 isolcpus=1,2,3(隔离核心,操作系统不调度其他进程到这些核心) 只有交易线程可以运行在核心1、2、3上 管理线程(日志、监控、REST API)→ 运行在核心0或其他未隔离核心 不要和交易线程抢核心!

4.5 避坑指南

我踩过不少坑,分享几个典型的:

坑1:超线程带来的假核心

我曾经把两个交易线程绑到了同一个物理核心的两个超线程上。结果发现延迟反而更高了。为什么?因为超线程共享执行单元,两个线程互相抢资源。

解决方案:绑定前先搞清楚哪些是物理核心,哪些是逻辑核心。用 lscpu -e 查看拓扑结构。

坑2:中断绑定后没生效

有一次我改了 smp_affinity,但中断还是跑到原来的核心上。查了半天,发现是irqbalance服务在作怪。它会自动平衡中断,覆盖了我的设置。

解决方案:关掉irqbalance。 systemctl stop irqbalance,然后禁用掉。

坑3:NUMA架构下的跨节点访问

在多路服务器上,CPU分不同的NUMA节点。如果你把线程绑在一个节点,但内存分配在另一个节点,访问延迟会高很多。

解决方案:用 numactl 命令,把线程和内存绑定到同一个NUMA节点。比如 numactl --cpunodebind=0 --membind=0 ./my_app

4.6 验证你的绑定是否生效

设置完了,怎么确认生效了?我一般用这几个方法:

  • 查看线程亲和性taskset -p <pid> 显示线程当前绑定的核心掩码。
  • 查看中断分布watch -n 1 cat /proc/interrupts 观察中断是否只落在你指定的核心上。
  • 性能测试:用 perf stat -e context-switches,cpu-migrations 监控上下文切换和CPU迁移次数。理想情况下,绑定后迁移次数应该为0。

我个人习惯,在系统启动脚本里就把这些绑定逻辑写好。这样每次重启后,配置自动生效,不用手动操作。省心,也避免出错。

好了,CPU亲和性和中断绑定就聊到这里。记住核心思想:让每个核心只干一件事。交易线程绑到隔离的核心上,中断绑到专用的核心上。这样延迟稳定,性能也可预测。

一句话总结: 线程绑定核心,中断绑定核心,两者不要打架。这是低延迟交易系统的基础优化,也是最容易见效的优化之一。

公众号:蓝海资料掘金营,微信deep3321