硬件与操作系统基础: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少等、少跑、少切换。缓存对齐、NUMA绑定、轮询替代中断、亲和性固定——这些技巧都是围绕这个核心展开的。
最后一个小建议:别一次性把所有优化都加上。先跑基准测试,然后逐个优化,每次只改一个变量。这样你才能知道哪个优化真正起了作用。我见过太多人一股脑全加上,结果性能反而下降了。
公众号:蓝海资料掘金营,微信deep3321