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 日志
}
缓存行与伪共享:看不见的“隐形锁”
这个知识点,很多新手完全没概念。但它是多核编程里最隐蔽的性能杀手。
现代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>)];
};
std::hardware_destructive_interference_size 和 std::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) {
// 处理错误
}
}
hwloc 库来查询拓扑结构,才彻底解决问题。如果你在云服务器上跑,记得先确认一下CPU拓扑。
知识体系总览
下面这张图,把本章的核心知识点串起来了。你可以把它当作一个检查清单,调优时对照着看。
总结一下
性能调优不是玄学,是有章可循的。你只要记住这四点:
- 锁竞争:粒度要细,能不锁就不锁,实在不行用读写锁。
- 伪共享:变量要缓存行对齐,别让两个线程“意外”共享同一个缓存行。
- NUMA:数据和线程要待在同一个NUMA节点上,别让数据“跑远路”。
- CPU亲和性:把线程“钉”在核心上,减少缓存丢失和迁移开销。
嗯,这些技巧我几乎在每个高性能项目里都用过。你下次遇到性能瓶颈,不妨先从这几个方向排查。相信我,会有惊喜的。