25、性能调优实战:锁竞争优化、缓存行与伪共享、NUMA感知编程、CPU亲和性设置

各位同学,今天我们来聊聊性能调优。说实话,多线程编程里最让人头疼的,不是死锁,不是活锁,而是——性能上不去

你明明加了锁,数据也保护好了,可程序就是跑不快。甚至,线程越多,性能反而越差。我当年第一次遇到这种情况,差点怀疑人生。后来才发现,问题出在几个非常隐蔽的地方。

这一章,我就带你把这些坑一个个填上。

锁竞争优化:别让线程排队等

锁竞争,说白了就是多个线程抢同一把锁。谁抢到谁干活,没抢到的只能干等着。你想想看,如果锁的粒度太大,那大部分时间线程都在排队,并行就变成了串行。

我在项目中遇到过这样一个案例:一个日志系统,所有线程写日志都抢同一把锁。结果16核的机器,跑起来跟单核差不多。后来怎么解决的?细粒度锁

把一把大锁拆成多把小锁。比如,每个日志级别(INFO、WARNING、ERROR)各有一把锁。或者,用读写锁代替互斥锁——读多写少的场景,读写锁能大幅提升并发度。

// 粗粒度锁:所有操作共用一把锁
std::mutex g_mutex;
void log(const std::string& msg) {
    std::lock_guard<std::mutex> lock(g_mutex);
    // 写入日志文件
}

// 细粒度锁:按日志级别拆分
std::mutex g_mutex_info;
std::mutex g_mutex_warn;
std::mutex g_mutex_error;

void log_info(const std::string& msg) {
    std::lock_guard<std::mutex> lock(g_mutex_info);
    // 写入 info 日志
}
小技巧: 锁的粒度不是越细越好。太细了,管理成本高,反而可能降低性能。我个人的习惯是:先粗后细,用性能分析工具(比如 perf)找出热点,再针对性优化。

缓存行与伪共享:看不见的“隐形锁”

这个知识点,很多新手完全没概念。但它是多核编程里最隐蔽的性能杀手。

现代CPU的缓存是以“缓存行”为单位的,通常是64字节。如果两个线程各自操作不同的变量,但这两个变量恰好在同一个缓存行里——那就会发生伪共享

为什么会这样?因为CPU缓存一致性协议(比如MESI)会保证:当一个核心修改了缓存行,其他核心的对应缓存行必须失效。于是,两个线程明明没抢同一把锁,却因为共享了同一个缓存行,被迫互相等待。

我曾经在一个金融交易系统中排查性能问题,发现两个线程各自更新一个计数器,互不干扰,但性能就是上不去。最后用 perf 一看,缓存未命中率高得吓人。嗯,这就是典型的伪共享。

解决办法很简单:缓存行对齐。让每个变量独占一个缓存行。

// 伪共享的典型例子
struct alignas(64) Counter {
    std::atomic<uint64_t> value;
};

// 或者用 padding 填充
struct Counter {
    std::atomic<uint64_t> value;
    char padding[64 - sizeof(std::atomic<uint64_t>)];
};
注意: C++17 之后,标准库提供了 std::hardware_destructive_interference_sizestd::hardware_constructive_interference_size,可以帮你获取缓存行大小。但不同架构可能不同,x86 通常是 64 字节,ARM 可能是 32 或 64。

NUMA感知编程:别让数据“跑远路”

NUMA(非统一内存访问)架构,在多路服务器上很常见。简单说,每个CPU有自己的本地内存,访问本地内存快,访问远程内存慢。

如果你不关心NUMA,线程可能被调度到CPU 0上,但它访问的数据却在CPU 1的本地内存里。每次内存访问都要跨CPU,性能自然就下来了。

我记得有一次优化一个数据库引擎,发现内存访问延迟高得离谱。后来用 numactl 一看,好家伙,线程和内存完全不在同一个NUMA节点上。

怎么做NUMA感知编程?

  • 内存分配亲和性:在哪个CPU上运行的线程,就在哪个NUMA节点上分配内存。Linux下可以用 mbind()libnuma 库。
  • 线程绑定:把线程绑定到特定的CPU核心上,避免线程在不同NUMA节点间迁移。
  • 数据本地化:尽量让每个线程只访问自己NUMA节点上的数据。
// 使用 libnuma 分配本地内存
#include <numa.h>

void* allocate_local_memory(size_t size) {
    void* ptr = numa_alloc_local(size);
    if (ptr == nullptr) {
        throw std::bad_alloc();
    }
    return ptr;
}
核心原则: 让数据和计算尽量靠近。线程在哪里跑,数据就在哪里分配。

CPU亲和性设置:把线程“钉”在核心上

CPU亲和性,就是把线程绑定到特定的CPU核心上。好处很明显:

  • 减少缓存未命中:线程一直跑在同一个核心上,L1/L2缓存是热的。
  • 避免上下文切换开销:操作系统不会把线程从一个核心迁移到另一个核心。
  • 配合NUMA:确保线程和它的数据在同一个NUMA节点上。

我个人的习惯是:对于计算密集型的线程,一定要设置CPU亲和性。对于I/O密集型的线程,可以交给操作系统调度。

// Linux 下设置 CPU 亲和性
#include <pthread.h>
#include <sched.h>

void set_cpu_affinity(int core_id) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(core_id, &cpuset);

    pthread_t current_thread = pthread_self();
    int result = pthread_setaffinity_np(current_thread, sizeof(cpu_set_t), &cpuset);
    if (result != 0) {
        // 处理错误
    }
}
避坑指南: 我曾经在虚拟化环境中设置CPU亲和性,结果发现物理核心和虚拟核心的映射关系是乱的。后来我改用 hwloc 库来查询拓扑结构,才彻底解决问题。如果你在云服务器上跑,记得先确认一下CPU拓扑。

知识体系总览

下面这张图,把本章的核心知识点串起来了。你可以把它当作一个检查清单,调优时对照着看。

多线程性能调优核心知识体系 锁竞争优化 细粒度锁 / 读写锁 缓存行与伪共享 缓存行对齐 / padding NUMA感知编程 本地内存分配 CPU亲和性 线程绑定核心 性能瓶颈定位 调优工具:perf / hwloc / numactl 减少锁争用 拆分锁 / 无锁结构 数据局部性优化 缓存行对齐 / NUMA 线程绑定核心 CPU亲和性设置

总结一下

性能调优不是玄学,是有章可循的。你只要记住这四点:

  • 锁竞争:粒度要细,能不锁就不锁,实在不行用读写锁。
  • 伪共享:变量要缓存行对齐,别让两个线程“意外”共享同一个缓存行。
  • NUMA:数据和线程要待在同一个NUMA节点上,别让数据“跑远路”。
  • CPU亲和性:把线程“钉”在核心上,减少缓存丢失和迁移开销。

嗯,这些技巧我几乎在每个高性能项目里都用过。你下次遇到性能瓶颈,不妨先从这几个方向排查。相信我,会有惊喜的。

一句话记住: 锁要细,行要对齐,数据要本地,线程要绑定。

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