硬件与操作系统基础:CPU缓存架构、NUMA、中断与轮询、CPU亲和性

做低延迟网络编程,说白了就是在跟硬件打交道。你写的代码再漂亮,如果不懂CPU怎么干活,那都是白搭。今天咱们就聊聊底层这些硬核东西——CPU缓存、NUMA、中断和亲和性。这些概念我当年刚接触时也觉得抽象,后来在实盘交易系统里踩过坑,才真正明白它们的分量。

CPU缓存架构:L1/L2/L3

先问个问题:为什么CPU要搞三级缓存?直接访问内存不行吗?

答案很简单——内存太慢了。CPU主频动辄3GHz,而内存访问延迟在100纳秒级别。你想想看,CPU等内存就像你等快递,一等就是几百个时钟周期。所以缓存就是CPU的「快递柜」,把常用数据就近放着。

三级缓存的层次是这样的:

  • L1缓存:每个核心独享,32KB-64KB,延迟约1纳秒(3-4个时钟周期)。分指令缓存和数据缓存。
  • L2缓存:每个核心独享,256KB-512KB,延迟约3-5纳秒。
  • L3缓存:所有核心共享,几MB到几十MB,延迟约10-20纳秒。

这里有个关键点——缓存行。CPU不是按字节加载数据的,而是按64字节的缓存行。我有个血泪教训:曾经写网络报文解析,两个线程分别修改同一个结构体的不同字段,结果这两个字段恰好在同一缓存行上。性能直接崩了,因为每次修改都要触发缓存一致性协议,两个核心互相通知「你那个脏了」。这就是经典的伪共享(False Sharing)问题。

避坑指南:我曾经在行情网关里,把两个高频访问的计数器放在同一个结构体里。压测时发现吞吐量上不去,用perf一看,L1缓存miss率高达30%。后来用__attribute__((aligned(64)))强制对齐,问题就解决了。

代码示例:避免伪共享

// 错误写法:两个变量可能在同一缓存行
struct Counter {
    uint64_t reads;
    uint64_t writes;
};

// 正确写法:用填充对齐
struct alignas(64) Counter {
    uint64_t reads;
    char padding[56]; // 填充到64字节
    uint64_t writes;
};

NUMA架构:非一致内存访问

多路服务器时代,NUMA是个绕不开的话题。简单说,每个CPU有自己的本地内存,访问本地内存快,访问远端内存慢。延迟差距可能达到1.5-2倍。

我刚开始做多路服务器优化时,完全没考虑NUMA。结果发现一个奇怪现象:同样的代码,在单路机器上跑得飞快,换到双路机器上反而慢了。后来用numactl --hardware一看,好家伙,内存分配全在远端节点上。

访问类型 延迟(纳秒) 带宽(GB/s)
本地内存访问 ~80 ~40
远端内存访问 ~140 ~25

NUMA优化的核心原则:谁分配,谁使用。也就是说,线程应该绑定到特定CPU核心,并且内存分配也要在那个核心的本地节点上。

个人习惯:我一般用libnuma库来管理内存绑定。分配内存时指定节点,比如numa_alloc_local(size)。这样能保证内存和线程在同一NUMA节点上。

代码示例:NUMA感知的内存分配

#include <numa.h>

void* alloc_numa_local(size_t size) {
    if (numa_available() < 0) {
        return malloc(size); // 回退
    }
    return numa_alloc_local(size);
}

void free_numa(void* ptr, size_t size) {
    if (numa_available() >= 0) {
        numa_free(ptr, size);
    } else {
        free(ptr);
    }
}

中断与轮询:两种数据接收模式

网卡收到数据后,怎么通知CPU?两种方式:中断和轮询。

中断模式:数据来了,网卡发个信号,CPU停下当前工作去处理。好处是CPU利用率低,但延迟不稳定。因为中断处理有上下文切换开销,而且高并发时中断风暴能把CPU打满。

轮询模式:CPU不断检查网卡是否有新数据。延迟极低且稳定,但CPU占用100%。

我做过一个对比测试:在10Gbps网卡上,中断模式平均延迟10微秒,但P99延迟能到50微秒。轮询模式平均延迟只有2微秒,P99不超过5微秒。对于高频交易来说,这个差距就是生死线。

注意:轮询不是万能的。如果系统负载不高,轮询就是浪费CPU。我建议的做法是:核心线程用轮询,非核心线程用中断。或者用NAPI这种混合模式——平时中断,高负载时自动切到轮询。

代码示例:简单的轮询接收

// 轮询模式接收数据包
while (true) {
    while (rx_ring->head != rx_ring->tail) {
        struct packet* pkt = &rx_ring->buf[rx_ring->head];
        process_packet(pkt);
        rx_ring->head = (rx_ring->head + 1) % RING_SIZE;
    }
    // 没有数据时,短暂休眠或继续轮询
    _mm_pause(); // 让CPU休息一下
}

CPU亲和性:绑定核心

CPU亲和性,说白了就是把线程固定到某个核心上跑。为什么需要这个?

原因有三:

  • 缓存命中率:线程固定后,L1/L2缓存是热的,不用反复加载。
  • 避免上下文切换:操作系统不会把线程从一个核心搬到另一个核心。
  • NUMA优化:绑定核心后,内存分配可以跟着走。

我见过最离谱的案例:一个同事没设亲和性,结果操作系统把网络线程在两个NUMA节点之间来回调度。每次调度,L1缓存全废了,延迟直接翻倍。

我的做法:每个核心只跑一个繁忙线程。比如4核机器,留一个核心给操作系统,剩下3个核心各绑一个工作线程。用pthread_setaffinity_np或者sched_setaffinity来设置。

代码示例:设置CPU亲和性

#include <sched.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");
    }
}

知识体系总览

下面这张图把今天讲的内容串起来了。你可以看到,从CPU缓存到NUMA,再到中断和亲和性,其实都在解决同一个问题——让数据离CPU更近,让CPU干活更专注

低延迟网络编程硬件基础 CPU缓存架构 • L1缓存:32KB-64KB,1ns延迟 • L2缓存:256KB-512KB,3-5ns延迟 • L3缓存:几MB-几十MB,10-20ns延迟 • 伪共享:缓存行对齐(64字节) • 缓存行:64字节批量加载 NUMA架构 • 本地内存:~80ns延迟 • 远端内存:~140ns延迟 • 核心原则:谁分配谁使用 • libnuma:numa_alloc_local() • 线程与内存绑定同一节点 中断 vs 轮询 • 中断:CPU利用率低,延迟不稳定 • 轮询:延迟低且稳定,CPU 100% • 轮询延迟:2μs vs 中断:10μs • NAPI:混合模式,动态切换 • _mm_pause():轮询时让CPU休息 CPU亲和性 • 线程绑定到固定核心 • 提高L1/L2缓存命中率 • 避免跨NUMA节点调度 • pthread_setaffinity_np() • 每个核心只跑一个繁忙线程

嗯,今天的内容就这些。记住一句话:低延迟系统的本质,就是让CPU少等、少跑、少切换。缓存对齐、NUMA绑定、轮询替代中断、亲和性固定——这些技巧都是围绕这个核心展开的。

最后一个小建议:别一次性把所有优化都加上。先跑基准测试,然后逐个优化,每次只改一个变量。这样你才能知道哪个优化真正起了作用。我见过太多人一股脑全加上,结果性能反而下降了。


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