并发与锁优化:锁竞争分析,使用原子操作,无锁数据结构
并发编程,说白了就是让多个线程一起干活。但线程一多,抢资源的问题就来了。我早年做网络中间件的时候,遇到过一台48核的机器,跑起来CPU利用率死活上不去,一查,好家伙,所有线程都在抢一把锁。那场景,就像一群人挤一个独木桥,桥没断,但谁也别想快。
今天我们就来聊聊,怎么把这座「独木桥」拆了,或者至少让它变成八车道。
锁竞争:性能杀手的第一张脸
锁竞争的本质,是多个线程试图同时获取同一把锁。当一个线程持有锁时,其他线程只能干等——要么自旋(spin),要么阻塞(block)。
我在项目中遇到过最典型的场景:一个全局哈希表,用pthread_mutex保护。刚开始只有4个线程,跑得挺欢。加到16个线程,性能反而下降了。为什么?因为锁的争抢开销已经盖过了并行带来的收益。
锁竞争的核心代价:
- 上下文切换:阻塞锁会让线程进入内核态,一次切换约1-10微秒
- 缓存颠簸:锁变量在多个CPU核心间 bouncing,导致缓存行失效
- 优先级反转:低优先级线程持有锁,高优先级线程被阻塞
怎么判断你的程序是不是被锁拖垮了?我一般用perf top看热点,如果发现pthread_mutex_lock占CPU时间超过5%,那就该动手了。
原子操作:轻量级的武器
原子操作是CPU直接支持的、不可中断的读-改-写操作。说白了,就是硬件帮你保证「要么全做,要么没做」。C11标准引入了
#include <stdatomic.h>
atomic_int counter = 0;
void increment() {
atomic_fetch_add(&counter, 1);
// 等价于 counter++,但线程安全
}
嗯,这里要注意:原子操作不是万能的。它只适合简单的整数操作。如果你要保护一个复杂的数据结构,原子操作就力不从心了。
我的经验:原子操作最适合做计数器、标志位、引用计数。我曾经把一个热点路径上的mutex换成atomic_fetch_add,性能提升了3倍。但别指望它解决所有并发问题。
无锁数据结构:拆掉那座桥
无锁(lock-free)数据结构,不是真的不用任何同步机制,而是不用传统的互斥锁。它依赖原子操作和内存序(memory ordering)来保证正确性。
无锁的核心思想:至少有一个线程能在有限步数内完成操作。换句话说,不会出现所有线程都卡死的情况。
一个简单的无锁栈
#include <stdatomic.h>
typedef struct node {
int value;
struct node* next;
} node_t;
typedef struct {
atomic_uintptr_t head;
} lockfree_stack_t;
void push(lockfree_stack_t* stack, node_t* new_node) {
node_t* old_head;
do {
old_head = (node_t*)atomic_load(&stack->head);
new_node->next = old_head;
} while (!atomic_compare_exchange_weak(
&stack->head,
(uintptr_t*)&old_head,
(uintptr_t)new_node
));
}
node_t* pop(lockfree_stack_t* stack) {
node_t* old_head;
node_t* new_head;
do {
old_head = (node_t*)atomic_load(&stack->head);
if (old_head == NULL) return NULL;
new_head = old_head->next;
} while (!atomic_compare_exchange_weak(
&stack->head,
(uintptr_t*)&old_head,
(uintptr_t)new_head
));
return old_head;
}
这段代码用了CAS(Compare-And-Swap)循环。push操作先读head,然后尝试把新节点的next指向旧head,最后用CAS把head更新为新节点。如果CAS失败,说明有其他线程改了head,那就重试。
避坑指南:我曾经在无锁栈上栽过跟头——ABA问题。线程A读出head为节点X,然后被调度走。线程B弹出X并释放内存,再压入一个新节点Y,恰好Y的地址和X一样。线程A恢复后,CAS发现head地址没变,就成功了,但此时head指向的已经是Y,而Y的next和X完全不同。后果?程序崩溃。
解决方案:使用带标记的指针(tagged pointer),或者用hazard pointer、RCU等内存回收机制。
内存序:无锁编程的隐形规则
原子操作不只是保证「原子性」,还涉及内存序(memory order)。CPU和编译器可能会重排指令,导致你看到的结果和代码顺序不一致。
| 内存序 | 含义 | 性能开销 |
|---|---|---|
| memory_order_relaxed | 只保证原子性,不保证顺序 | 几乎为零 |
| memory_order_acquire | 之后的读操作不能重排到前面 | 较小 |
| memory_order_release | 之前的写操作不能重排到后面 | 较小 |
| memory_order_acq_rel | acquire + release | 中等 |
| memory_order_seq_cst | 全局顺序一致,最严格 | 最大 |
我个人习惯:默认用seq_cst,等性能瓶颈出现后再降级。别一开始就追求极致优化,正确性永远是第一位的。
锁优化的实用策略
如果无锁数据结构太复杂,或者不适合你的场景,那就优化锁本身。以下是我常用的几招:
- 减小临界区:只锁真正需要保护的数据。把文件IO、网络请求等耗时操作移出临界区。
- 读写锁:读多写少的场景,用pthread_rwlock。多个读线程可以同时持有锁。
- 锁粒度细化:把一把大锁拆成多把小锁。比如哈希表,每个桶一把锁。
- 自旋锁:临界区极短(几十条指令)时,自旋比阻塞更高效。
// 自旋锁简单实现
typedef struct {
atomic_flag flag;
} spinlock_t;
void spin_lock(spinlock_t* lock) {
while (atomic_flag_test_and_set(&lock->flag)) {
// 自旋等待
// 可以加CPU pause指令减少功耗
__builtin_ia32_pause();
}
}
void spin_unlock(spinlock_t* lock) {
atomic_flag_clear(&lock->flag);
}
我的建议:自旋锁适合临界区小于100条指令的场景。如果临界区太长,自旋就是浪费CPU。我曾经在数据库引擎里用自旋锁保护一个内存索引,效果很好,但换到磁盘IO场景就崩了——线程自旋等待磁盘,CPU烧到100度。
知识体系总览
下面这张图总结了并发与锁优化的核心脉络。你可以把它当作一个决策树:先判断能不能用原子操作,不行再看无锁结构,最后才考虑优化锁。
性能对比:锁 vs 原子操作 vs 无锁
我拿一个简单的计数器压测过,结果如下(单位:纳秒/操作,16线程并发):
| 方案 | 单线程 | 16线程 | 可扩展性 |
|---|---|---|---|
| pthread_mutex | 25 ns | 420 ns | 差 |
| 自旋锁 | 18 ns | 310 ns | 中等 |
| 原子操作 (fetch_add) | 12 ns | 45 ns | 好 |
| 无锁栈 (CAS循环) | 20 ns | 80 ns | 较好 |
你看,原子操作在16线程下只增加了不到4倍开销,而mutex增加了近17倍。这就是锁竞争的代价。
核心原则:能用原子操作就别用锁,能用无锁就别用有锁。但记住,无锁编程的正确性验证成本很高。我见过太多「优化到一半发现ABA问题」的案例。如果你不是100%确定,先用锁,再优化。
最后说一句:并发优化的本质是减少串行化。不管你用原子操作、无锁结构还是优化锁,目标都是让更多线程真正并行工作。别为了炫技而用无锁,简单可靠的锁往往更持久。
公众号:蓝海资料掘金营,微信deep3321