高性能并发编程:缓存行与伪共享、内存对齐与性能优化、NUMA架构下的线程亲和性
各位同学,今天我们来聊一个真正决定并发程序性能的硬核话题。说实话,很多人在写多线程代码时,只关注锁和原子操作,却忽略了硬件层面的那些“隐形杀手”。我见过太多项目,明明逻辑没问题,一上多核就跑得比单线程还慢。问题出在哪?就在我们今天要讲的这几个点上。
一、缓存行与伪共享:你以为是并行,其实是串行
先问大家一个问题:两个线程各自修改不同的变量,它们能同时进行吗?
你可能会说:“当然能啊,变量又不冲突。” 嗯,理论上没错。但现代CPU的缓存机制,会让这个“同时”变成“串行”。
CPU从主存读数据时,不是按字节读的,而是按“缓存行”读的。缓存行通常是64字节。也就是说,如果两个变量在内存中挨着,且距离小于64字节,它们就会被加载到同一个缓存行里。
这时候,如果核心A修改了变量x,核心B修改了变量y,而x和y恰好在同一个缓存行里——那好戏就来了。缓存一致性协议(比如MESI)会让这两个核心互相通知:“嘿,你那个缓存行脏了,得重新加载。” 结果就是,两个核心来回抢这个缓存行的所有权,性能直接崩盘。
这就是伪共享(False Sharing):逻辑上不共享的数据,在物理缓存层面上被迫共享了。
我在项目中遇到过这样一个案例:一个高性能日志系统,每个线程有自己的日志缓冲区。缓冲区头部的几个字段(比如当前写入位置、刷新标志)被定义在一个结构体里。结果压测时发现,线程数从4增加到8,吞吐量反而下降了。排查了半天,最后发现就是伪共享在作祟。
如何解决?
最简单的办法:填充(padding)。让每个变量独占一个缓存行。
// 伪共享版本
struct alignas(64) ThreadData {
int32_t counter; // 每个线程写自己的counter
// 编译器可能会把多个ThreadData对象紧挨着放
};
// 修复版本:每个对象独占一个缓存行
struct alignas(64) ThreadDataFixed {
int32_t counter;
char padding[60]; // 填充到64字节
};
在C++17中,还可以用 std::hardware_destructive_interference_size 来获取当前平台的缓存行大小,更优雅一些。
小技巧:如果你用C++11/14,可以自己定义一个宏:#define CACHE_LINE_SIZE 64。虽然不够通用,但在x86_64上基本没问题。
二、内存对齐:别让CPU多干活
内存对齐这件事,很多人觉得是编译器的事,自己不用管。但说实话,在并发编程里,对齐问题会直接导致原子操作失败,或者性能骤降。
为什么?因为CPU访问对齐的数据是一次性完成的。如果数据没对齐,CPU可能需要两次内存访问,再拼接起来。这在单线程下可能只是慢一点,但在多线程下,非对齐访问可能破坏原子性。
举个例子:
struct Unaligned {
char a; // 1字节
int32_t b; // 4字节,但偏移是1,没对齐
};
struct alignas(4) Aligned {
char a;
char pad[3]; // 手动填充到4字节对齐
int32_t b; // 现在偏移是4,对齐了
};
我个人习惯在定义结构体时,总是按成员大小从大到小排列。这样编译器填充的字节最少,内存利用率最高。比如:
// 推荐:大成员在前
struct Good {
double x; // 8字节
int32_t y; // 4字节
int16_t z; // 2字节
char w; // 1字节
}; // 总共15字节,对齐后16字节
// 不推荐:小成员在前
struct Bad {
char a;
int32_t b;
double c;
}; // 编译器会填充很多空洞
注意:在并发场景下,如果你用 std::atomic<T>,T必须是普通可复制类型,并且对齐要求必须满足。否则,std::atomic可能会退化成使用锁,性能直接掉一个数量级。
三、NUMA架构下的线程亲和性:别让你的线程“串门”
NUMA(非统一内存访问)架构,说白了就是:每个CPU核心访问自己“附近”的内存快,访问别的核心的内存慢。在现代服务器上,动辄几十个核心,内存被分成多个节点(NUMA node)。
如果你不设置线程亲和性,操作系统可能会把线程调度到任意核心上。今天跑在核心0,明天跑在核心48。每次迁移,缓存都失效了,还得重新加载数据。更糟糕的是,线程可能访问了远端内存,延迟翻倍。
我曾经在一个数据库项目中遇到过:同样的查询,第一次跑只要10ms,第二次跑要50ms。查了半天,发现是线程被调度到了不同的NUMA节点上,缓存全凉了。
如何设置线程亲和性?
在Linux上,我们用 pthread_setaffinity_np。在C++11的 std::thread 里,可以这样封装:
#include <pthread.h>
#include <thread>
void set_thread_affinity(std::thread& t, int cpu_id) {
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(cpu_id, &cpuset);
pthread_setaffinity_np(t.native_handle(), sizeof(cpu_set_t), &cpuset);
}
// 使用
std::thread worker([]{
// 工作逻辑
});
set_thread_affinity(worker, 0); // 绑定到核心0
在C++20中,有了 std::jthread 和 std::stop_token,但亲和性设置仍然需要平台相关代码。我个人建议封装一个跨平台的亲和性工具类,把Linux的 pthread 和Windows的 SetThreadAffinityMask 统一起来。
NUMA感知的内存分配
光绑定线程还不够,内存分配也要“就近”。在Linux上,可以用 libnuma 库:
#include <numa.h>
void* alloc_on_node(size_t size, int node) {
void* ptr = numa_alloc_local(size); // 在当前节点分配
// 或者 numa_alloc_onnode(size, node);
return ptr;
}
嗯,这里要注意:numa_alloc_local 分配的内存,默认就在当前线程所在的NUMA节点上。如果你绑定了线程亲和性,那内存自然就在“附近”。
避坑指南:我曾经在压测时发现,所有线程都绑定到同一个NUMA节点上,导致内存带宽被占满,其他节点空闲。正确的做法是:把线程均匀分布到所有NUMA节点上,每个线程只处理自己节点上的数据。
四、知识体系总览
下面这张图,帮你把今天讲的内容串起来:
五、实战建议
说了这么多,总结几条我自己的经验:
- 先测量,再优化。别一上来就搞填充、绑核。用perf、vtune看看是不是真的存在伪共享或NUMA问题。
- 结构体设计时,就把对齐和缓存行考虑进去。后期再改,牵一发动全身。
- 线程亲和性要结合数据分布。线程绑在哪个核心,数据就分配在哪个NUMA节点上。
- 不要过度优化。如果程序只有4个线程,伪共享的影响可能不大。但如果是64个线程,那就必须处理了。
最后说一句:并发编程的瓶颈,往往不在代码逻辑里,而在硬件细节里。理解缓存、内存、NUMA,你才能真正写出高性能的并发程序。
公众号:蓝海资料掘金营,微信deep3321