高性能并发编程:缓存行与伪共享、内存对齐与性能优化、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::jthreadstd::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节点上,每个线程只处理自己节点上的数据。

四、知识体系总览

下面这张图,帮你把今天讲的内容串起来:

高性能并发编程核心知识体系 缓存行与伪共享 • 缓存行大小:64字节 • MESI协议 • 伪共享产生条件 • 解决方案:填充 • alignas关键字 • hardware_destructive _interference_size 内存对齐与性能优化 • 自然对齐 • 结构体成员排序 • 编译器填充规则 • alignas/alignof • std::atomic对齐要求 • 缓存行对齐 • 性能影响:2x~10x NUMA与线程亲和性 • NUMA节点概念 • 本地/远端内存延迟 • pthread_setaffinity • std::thread绑定 • numa_alloc_local • 线程迁移代价 • 负载均衡策略 关联 关联 三者共同决定并发程序的实际性能,缺一不可

五、实战建议

说了这么多,总结几条我自己的经验:

  1. 先测量,再优化。别一上来就搞填充、绑核。用perf、vtune看看是不是真的存在伪共享或NUMA问题。
  2. 结构体设计时,就把对齐和缓存行考虑进去。后期再改,牵一发动全身。
  3. 线程亲和性要结合数据分布。线程绑在哪个核心,数据就分配在哪个NUMA节点上。
  4. 不要过度优化。如果程序只有4个线程,伪共享的影响可能不大。但如果是64个线程,那就必须处理了。

最后说一句:并发编程的瓶颈,往往不在代码逻辑里,而在硬件细节里。理解缓存、内存、NUMA,你才能真正写出高性能的并发程序。


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