并发与锁优化:锁竞争分析,使用原子操作,无锁数据结构

并发编程,说白了就是让多个线程一起干活。但线程一多,抢资源的问题就来了。我早年做网络中间件的时候,遇到过一台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,等性能瓶颈出现后再降级。别一开始就追求极致优化,正确性永远是第一位的。

锁优化的实用策略

如果无锁数据结构太复杂,或者不适合你的场景,那就优化锁本身。以下是我常用的几招:

  1. 减小临界区:只锁真正需要保护的数据。把文件IO、网络请求等耗时操作移出临界区。
  2. 读写锁:读多写少的场景,用pthread_rwlock。多个读线程可以同时持有锁。
  3. 锁粒度细化:把一把大锁拆成多把小锁。比如哈希表,每个桶一把锁。
  4. 自旋锁:临界区极短(几十条指令)时,自旋比阻塞更高效。
// 自旋锁简单实现
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度。

知识体系总览

下面这张图总结了并发与锁优化的核心脉络。你可以把它当作一个决策树:先判断能不能用原子操作,不行再看无锁结构,最后才考虑优化锁。

并发与锁优化决策树 并发访问共享数据 操作简单?(整数/标志) 操作复杂?(结构体/链表) 原子操作 (atomic) 自旋锁 (spinlock) 无锁数据结构 优化锁 (粒度/读写锁) 注意:ABA问题、内存回收 减小临界区、读写锁 目标:减少等待,提高吞吐量

性能对比:锁 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