第十二章:无锁编程——原子操作、内存序与无锁队列

无锁编程,听起来很酷对吧?

我第一次接触这个概念是在一个高并发交易系统里。那时候锁竞争导致CPU利用率上不去,系统吞吐量卡在瓶颈上。领导说:「你去研究研究无锁方案。」好家伙,这一头扎进去就是好几年。

说白了,无锁编程就是用原子操作代替互斥锁,让多个线程在不阻塞的情况下安全地共享数据。它不是为了炫技,而是为了性能——当你的锁竞争超过一定阈值,无锁方案能带来数量级的提升。

12.1 原子操作:std::atomic 的基础

原子操作,就是不可分割的操作。要么全部执行完,要么一点都没执行。中间不会被其他线程打断。

C++11 引入了 std::atomic,让我们可以轻松定义原子变量。我习惯用 std::atomic<int> 替代普通的 int,尤其是在多线程计数器场景下。

#include <atomic>
#include <thread>
#include <iostream>

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

void worker() {
    for (int i = 0; i < 10000; ++i) {
        counter.fetch_add(1);  // 原子加1
    }
}

int main() {
    std::thread t1(worker);
    std::thread t2(worker);
    t1.join();
    t2.join();
    std::cout << counter.load() << std::endl;  // 输出 20000
    return 0;
}

你看,没有锁,没有互斥量,两个线程安全地累加。这就是原子操作的力量。

小提示: 能用 std::atomic 就别用 volatilevolatile 只告诉编译器不要优化,但不保证原子性。我在项目中见过有人用 volatile int 做计数器,结果线上数据对不上,排查了一整天。

12.2 内存序:控制可见性与顺序

原子操作只是基础,真正让无锁编程变得复杂的是内存序(memory_order)。

你想想看,CPU 和编译器为了性能,会重排指令。一个线程写入的值,另一个线程什么时候能看到?这就是内存序要解决的问题。

C++ 提供了六种内存序,但常用的就三种:

内存序 含义 性能
memory_order_relaxed 只保证原子性,不保证顺序 最快
memory_order_acquire/release 保证配对线程间的顺序一致性 中等
memory_order_seq_cst 全局顺序一致,最严格 最慢

我个人习惯:默认用 seq_cst 写原型,性能调优时再降级。别一开始就追求极致优化,容易出 bug。

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

// 线程1
void producer() {
    data = 42;                          // 普通写
    ready.store(true, std::memory_order_release);  // release 写
}

// 线程2
void consumer() {
    while (!ready.load(std::memory_order_acquire)) {}  // acquire 读
    std::cout << data << std::endl;  // 保证看到 42
}

这里 releaseacquire 配对使用。线程2看到 ready 为 true 时,也一定能看到线程1对 data 的写入。

注意: 我曾经在项目中误用了 memory_order_relaxed 做标志位同步,结果消费者线程读到了 ready=truedata 还是旧值。嗯,这个坑我踩过,你们别踩。

12.3 CAS 操作:无锁编程的核心武器

CAS(Compare-And-Swap)是啥?就是「比较并交换」。它做三件事:

  1. 读取内存中的值
  2. 和期望值比较
  3. 如果相等,写入新值;否则什么都不做

整个过程是原子的。C++ 里对应 compare_exchange_weakcompare_exchange_strong

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

// CAS 实现的自旋锁
void lock_free_increment() {
    int expected = value.load();
    int desired;
    do {
        desired = expected + 1;
    } while (!value.compare_exchange_weak(expected, desired));
    // 如果 CAS 失败,expected 会被更新为当前值,然后重试
}

这里有个细节:compare_exchange_weak 可能因为「伪失败」(spurious failure)而返回 false,所以要用循环。而 compare_exchange_strong 保证要么成功,要么失败,没有伪失败。

经验之谈: 在循环中优先用 compare_exchange_weak,性能更好。如果循环体很复杂,或者失败代价高,用 strong 版本更省心。

12.4 无锁队列:从理论到实践

无锁队列是无锁编程的经典应用。我参与过一个百万级 QPS 的消息中间件,核心就是无锁队列。

一个简单的无锁队列(单生产者单消费者)可以这样实现:

template<typename T, size_t Size>
class LockFreeQueue {
    std::atomic<size_t> head{0};
    std::atomic<size_t> tail{0};
    T buffer[Size];

public:
    bool push(const T& item) {
        size_t t = tail.load(std::memory_order_relaxed);
        size_t next = (t + 1) % Size;
        if (next == head.load(std::memory_order_acquire)) {
            return false;  // 队列满
        }
        buffer[t] = item;
        tail.store(next, std::memory_order_release);
        return true;
    }

    bool pop(T& item) {
        size_t h = head.load(std::memory_order_relaxed);
        if (h == tail.load(std::memory_order_acquire)) {
            return false;  // 队列空
        }
        item = buffer[h];
        head.store((h + 1) % Size, std::memory_order_release);
        return true;
    }
};

这个队列只用到了 releaseacquire 语义,没有锁,没有 CAS。为什么?因为单生产者单消费者场景下,headtail 各自只有一个线程修改,不需要 CAS。

如果是多生产者多消费者场景,就需要 CAS 来竞争槽位了。那复杂度会上一个台阶。

避坑指南: 我曾经在无锁队列里用了 memory_order_seq_cst,结果性能还不如加锁版本。后来改成 acquire/release,吞吐量直接翻倍。内存序的选择,真的会影响性能。

12.5 无锁编程的核心知识体系

下面这张图,是我自己总结的无锁编程知识脉络。你看一遍,心里就有谱了。

无锁编程 std::atomic 内存序 CAS 操作 load/store fetch_add/sub exchange relaxed acquire/release seq_cst compare_exchange_weak compare_exchange_strong 无锁队列 / 无锁栈 / 无锁哈希表

从这张图你能看到,无锁编程的根基是原子操作,内存序控制可见性,CAS 提供条件更新能力。这三者组合起来,就能构建出各种无锁数据结构。

12.6 实战中的避坑指南

说了这么多,最后分享几个我踩过的坑:

  • ABA 问题: CAS 操作中,值从 A 变成 B 又变回 A,CAS 会认为没变过。解决方案是加版本号,或者用 std::atomic<std::uint64_t> 把指针和版本号打包在一起。
  • 内存回收: 无锁数据结构中,一个线程还在读节点,另一个线程不能直接 delete。我一般用 Epoch-Based Reclamation 或 Hazard Pointer 来安全回收。
  • 性能测试: 无锁不一定比加锁快。在低竞争场景下,锁的开销很小,无锁的复杂逻辑反而可能更慢。一定要用实际场景压测。
重要提醒: 无锁编程的 bug 极难复现和调试。我有个同事,线上无锁队列偶尔丢数据,查了两个月才发现是内存序用错了。所以,能用锁解决的问题,先用锁。只有当你确认锁是瓶颈时,再考虑无锁方案。

嗯,无锁编程就讲到这里。原子操作、内存序、CAS、无锁队列,这四个概念你掌握了,就能看懂大部分无锁代码了。剩下的,就是在实战中慢慢积累经验了。


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