多线程性能优化:锁竞争优化、锁的粒度细化、无锁编程技术、缓存行与伪共享问题、NUMA架构下的线程优化

多线程编程,说白了就是让多个工人一起干活。但工人多了,抢工具、抢材料的问题就来了。性能优化,就是解决这些「抢」的问题。我做了这么多年系统编程,踩过的坑比写过的代码还多。今天咱们就聊聊,怎么让多线程程序跑得又快又稳。

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

锁竞争,是性能杀手第一名。你想想看,如果十个线程抢一把锁,九个在等,那跟单线程有啥区别?

我在项目中遇到过这样一个案例:一个日志系统,所有线程写日志都要抢同一把锁。结果压测时,CPU 利用率不到 30%,吞吐量上不去。后来怎么解决的?

  • 减少锁持有时间:把不需要锁的操作移到外面。比如先拼好字符串,再一次性写入。
  • 读写锁分离:读多写少的场景,用 pthread_rwlock_t。读锁可以共享,写锁独占。
  • 自旋锁 vs 互斥锁:锁持有时间很短,用自旋锁;时间较长,用互斥锁。自旋锁会让 CPU 空转,但避免了上下文切换的开销。

核心原则:锁的临界区越小越好。能放一个变量,别放一个结构体;能放一条语句,别放一个函数。

锁的粒度细化:大锁拆小锁

锁的粒度,就是锁保护的数据范围。粒度太粗,并发度低;粒度太细,管理复杂,还可能死锁。

我习惯用「分桶」的思路。比如一个哈希表,如果只有一把全局锁,那所有操作都串行了。但如果每个桶一把锁,不同桶的操作就能并行。

// 粗粒度锁
pthread_mutex_t global_lock;
void insert(int key, int value) {
    pthread_mutex_lock(&global_lock);
    // 操作整个哈希表
    pthread_mutex_unlock(&global_lock);
}

// 细粒度锁:每个桶一把锁
pthread_mutex_t bucket_locks[BUCKET_COUNT];
void insert(int key, int value) {
    int bucket = hash(key) % BUCKET_COUNT;
    pthread_mutex_lock(&bucket_locks[bucket]);
    // 只操作这一个桶
    pthread_mutex_unlock(&bucket_locks[bucket]);
}

嗯,这里要注意:粒度细化后,要小心死锁。比如同时操作两个桶,必须按固定顺序加锁,否则就会死锁。我曾经在一个交易系统里,因为加锁顺序不一致,排查了整整两天。

无锁编程技术:原子操作与 CAS

无锁编程,不是真的不用锁,而是用 CPU 提供的原子指令来替代锁。最常用的是 CAS(Compare And Swap)。

说白了,CAS 就是一条指令完成「比较-交换」两个动作,中间不会被中断。Linux 下用 __sync_bool_compare_and_swap 或 C11 的 atomic_compare_exchange_strong

#include <stdatomic.h>

atomic_int counter = 0;

void increment() {
    int expected;
    int desired;
    do {
        expected = atomic_load(&counter);
        desired = expected + 1;
    } while (!atomic_compare_exchange_strong(&counter, &expected, desired));
}

这个循环就是 CAS 的典型用法。如果 counter 没被改过,就更新;否则重试。无锁队列、无锁栈都是基于这个原理。

避坑指南:我曾经在 ARM 平台上用 CAS 实现无锁队列,结果发现 ARM 的内存模型比 x86 弱,需要加内存屏障(memory barrier)。否则,一个线程看到的数据顺序可能跟实际写入顺序不一致。血的教训啊。

缓存行与伪共享问题:看不见的竞争

伪共享,是多线程编程里最隐蔽的性能问题。为什么?因为代码里没有锁,但性能就是上不去。

原因出在 CPU 缓存上。CPU 缓存以「缓存行」为单位加载数据,通常是 64 字节。如果两个线程操作的数据在同一个缓存行里,即使操作的是不同变量,CPU 也会强制同步缓存行。这就是伪共享。

// 伪共享的例子
struct data {
    int a;  // 线程1 只操作 a
    int b;  // 线程2 只操作 b
};  // a 和 b 很可能在同一个缓存行里

// 解决方法:填充到不同缓存行
struct data {
    int a;
    char padding[60];  // 填充到 64 字节
    int b;
};

我在一个高性能网络框架里遇到过这个问题。两个线程分别处理收包和发包,各自维护一个计数器。结果压测时,吞吐量死活上不去。用 perf 一看,缓存未命中率超高。加上 padding 后,性能直接翻倍。

小技巧:Linux 下可以用 getconf LEVEL1_DCACHE_LINESIZE 查看缓存行大小。大部分 x86 机器是 64 字节。对齐可以用 __attribute__((aligned(64)))

NUMA 架构下的线程优化:让数据离 CPU 近一点

NUMA(非统一内存访问)架构下,每个 CPU 有自己的本地内存,访问本地内存比访问远程内存快得多。如果线程跑在 CPU0 上,却访问 CPU1 的内存,性能会大打折扣。

我优化过一个数据库引擎,最初所有线程随机分配,内存也是随机分配。后来改成:

  • 线程绑定到指定 CPU:用 pthread_setaffinity_npsched_setaffinity
  • 内存分配在本地节点:用 numa_alloc_localmbind 系统调用。
  • 避免跨节点访问:线程只处理自己节点上的数据。
#include <numa.h>

void bind_to_node(int node) {
    struct bitmask *mask = numa_allocate_cpumask();
    numa_node_to_cpus(node, mask);
    sched_setaffinity(0, sizeof(mask), mask);
    numa_free_cpumask(mask);
}

void *worker(void *arg) {
    int node = (int)(long)arg;
    bind_to_node(node);
    
    // 在本地节点分配内存
    void *local_mem = numa_alloc_local(1024 * 1024);
    
    // 处理数据...
    
    numa_free(local_mem, 1024 * 1024);
    return NULL;
}

经验之谈:NUMA 优化在单机多路服务器上效果最明显。如果是虚拟机或者云服务器,NUMA 拓扑可能被虚拟化层隐藏,这时候绑定 CPU 反而可能适得其反。我建议先跑 numactl --hardware 看看实际拓扑。

知识体系总览

下面这张图,把本章的核心知识点串起来了。你可以看到,从锁竞争到 NUMA 优化,是一条从「软件锁」到「硬件架构」的优化路径。

多线程性能优化知识体系 多线程性能优化 锁竞争优化 减少锁持有时间 读写锁分离 自旋锁 vs 互斥锁 锁的粒度细化 分桶/分段锁 固定加锁顺序 避免死锁 无锁编程技术 CAS 原子操作 内存屏障 缓存行与伪共享 缓存行对齐 Padding 填充 NUMA 架构优化 线程绑定 CPU 本地内存分配 避免跨节点访问

这张图把五个优化方向串在了一起。从锁竞争开始,一步步深入到 CPU 缓存和内存架构。每个方向都有对应的技术手段,但实际项目中往往是组合使用。

比如我最近优化的一个消息中间件,先用锁粒度细化把全局锁拆成桶锁,再用 CAS 实现无锁队列,最后绑定线程到 NUMA 节点。每一步优化,性能都有明显提升。

多线程优化没有银弹。你得先 profiling,找到真正的瓶颈,再对症下药。别一上来就搞无锁编程,那玩意儿调试起来能让你怀疑人生。

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