第四节:无锁编程基础

无锁编程,说白了就是让多个线程同时读写共享数据,但不用互斥锁。

听起来很美好对吧?

我刚开始接触高频交易系统时,也觉得锁是万恶之源。那时候系统里到处都是锁,性能瓶颈一眼就能看出来。后来我花了整整三个月,把核心路径上的锁全部替换成了无锁结构——效果确实惊人,延迟从微秒级降到了纳秒级。

但代价呢?

调试无锁代码的痛苦,我至今记忆犹新。一个ABA问题,能让你排查三天三夜。

4.1 CAS操作:无锁编程的基石

CAS,全称Compare-And-Swap,比较并交换。

它的核心逻辑很简单:

bool compare_and_swap(int* addr, int expected, int new_value) {
    if (*addr == expected) {
        *addr = new_value;
        return true;
    }
    return false;
}

但注意,上面这段代码只是示意。真正的CAS是一条CPU指令,是原子的。

在C++11里,我们可以这样用:

#include <atomic>

std::atomic<int> counter{0};

void worker() {
    int expected = counter.load();
    int new_val = expected + 1;
    // 如果counter还是expected,就更新成new_val
    while (!counter.compare_exchange_weak(expected, new_val)) {
        // 失败说明别的线程改了,重新读取
        new_val = expected + 1;
    }
}

我在项目中遇到过一个问题:用CAS实现一个简单的计数器,结果在高并发下性能反而比互斥锁还差。后来发现是CAS的循环次数太多了——说白了就是竞争太激烈,大家都在自旋。

核心要点:

  • CAS是乐观锁思想——先尝试,失败了再重试
  • compare_exchange_weak和strong的区别:weak可能伪失败,但性能更好
  • CAS适合低冲突场景,高冲突时反而比锁差

4.2 内存屏障:看不见的秩序

你想想看,CPU为了性能,会乱序执行指令。编译器也会重排代码。

单线程下没问题,多线程就出事了。

举个例子:

std::atomic<bool> ready{false};
int data = 0;

// 线程1
data = 42;
ready.store(true, std::memory_order_release);

// 线程2
while (!ready.load(std::memory_order_acquire));
assert(data == 42); // 这个断言会成功吗?

如果没有内存屏障,线程2可能看到ready为true,但data还是0。

为什么?因为CPU可能把data=42这条指令重排到了ready.store之后。

内存屏障就是用来阻止这种重排的。C++11提供了六种内存序:

内存序 含义 性能开销
memory_order_relaxed 无任何顺序保证 几乎为零
memory_order_consume 依赖关系保证 很低
memory_order_acquire 之后的读不能重排到之前 中等
memory_order_release 之前的写不能重排到之后 中等
memory_order_acq_rel acquire + release 较高
memory_order_seq_cst 全局顺序一致 最高

我的建议:

刚开始接触无锁编程时,先用memory_order_seq_cst。等系统跑稳了,再逐步优化成更弱的内存序。我曾经一上来就用relaxed,结果bug修了一个月。

4.3 ABA问题:无锁编程的头号陷阱

什么是ABA问题?

假设你有一个栈,栈顶指针是T。线程1想pop一个节点:

  1. 它读取T指向节点A
  2. 它读取A的next指针,指向B
  3. 它准备用CAS把T从A改成B

但在第2步和第3步之间,发生了:

  1. 线程2把A和B都pop了
  2. 线程2又把A push回来了
  3. 现在栈顶还是A,但A的next已经变了

线程1的CAS发现T还是A,就成功把T改成了B。但B早就被删除了!

这就是ABA问题——值没变,但语义变了。

我曾经踩过的坑:

有一次线上系统出现诡异的内存访问错误,排查了整整三天。最后发现是ABA问题导致一个已经被释放的节点被再次访问。从那以后,我所有无锁数据结构都加上了ABA防护。

4.4 ABA问题的解决方案

解决ABA问题,主流方案有两种:

方案一:标记指针(Tagged Pointer)

在指针的高位加一个版本号,每次修改都递增:

struct Node {
    int value;
    Node* next;
};

struct TaggedPtr {
    Node* ptr;
    uintptr_t tag; // 版本号
};

std::atomic<TaggedPtr> head;

void push(Node* node) {
    TaggedPtr old = head.load();
    TaggedPtr new_val;
    do {
        node->next = old.ptr;
        new_val.ptr = node;
        new_val.tag = old.tag + 1; // 版本号递增
    } while (!head.compare_exchange_weak(old, new_val));
}

这样即使指针值相同,版本号不同,CAS也会失败。

方案二:垃圾回收(Hazard Pointer / RCU)

延迟回收被删除的节点,确保没有线程还在访问它。

我个人更推荐标记指针方案,因为它实现简单,性能开销小。在高频交易场景下,每纳秒都很宝贵。

实战建议:

  • 如果指针有足够的空闲位(比如64位系统只用48位地址),用标记指针
  • 如果节点生命周期复杂,用Hazard Pointer
  • 读多写少的场景,RCU是更好的选择

4.5 知识体系总览

下面这张图,是我自己总结的无锁编程知识体系:

无锁编程知识体系 无锁编程 CAS操作 内存屏障 ABA问题 compare_exchange_weak/strong 乐观锁思想 自旋重试机制 六种内存序 禁止指令重排 acquire/release语义 标记指针(Tagged Ptr) Hazard Pointer RCU机制 三者缺一不可,共同构成无锁编程的基石

嗯,这张图基本概括了无锁编程的核心。CAS是工具,内存屏障是规则,ABA问题是陷阱。三者缺一不可。

最后说一句:无锁编程不是银弹。如果你的场景并发冲突很高,或者代码维护者水平参差不齐,老老实实用锁反而更稳妥。我在团队里定过一个规矩:除非性能测试证明锁是瓶颈,否则不许用无锁。


专注资料整理