第15章:原子操作与无锁编程

多线程编程里,锁是个好东西,但也是个坏东西。好东西在于它能保护共享数据,坏东西在于它会让线程排队等待,性能直线下降。我早年做交易系统时,一个高频模块用了大量互斥锁,结果延迟从微秒级飙到了毫秒级——这谁受得了?

后来我深入研究了原子操作和无锁编程。说白了,就是用CPU提供的原子指令,替代传统的锁机制。今天咱们就聊聊这个话题。

15.1 std::atomic 详解

C++11 引入了 std::atomic,它封装了底层硬件支持的原子操作。你不需要再写内联汇编了,直接用标准库就行。

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

15.1.1 基本用法

#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;
}

你看,没有锁,没有互斥量,结果却是正确的。这就是原子操作的魅力。

15.1.2 常用操作

操作 说明 示例
load 原子读取 counter.load()
store 原子写入 counter.store(42)
exchange 原子交换 old = counter.exchange(10)
compare_exchange_weak 比较并交换(弱版本) counter.compare_exchange_weak(expected, desired)
compare_exchange_strong 比较并交换(强版本) counter.compare_exchange_strong(expected, desired)
fetch_add 原子加法 counter.fetch_add(1)
fetch_sub 原子减法 counter.fetch_sub(1)

我的经验: compare_exchange_weak 在某些平台上可能因为伪共享而失败,但性能更好。我一般在高频场景用 weak 版本,在需要确定性时用 strong 版本。

15.2 内存序模型

内存序是原子操作中最容易踩坑的地方。说白了,它控制着不同线程看到内存操作的顺序。

C++ 提供了六种内存序,从松到严:

  1. memory_order_relaxed:最宽松,只保证原子性,不保证顺序
  2. memory_order_consume:依赖关系排序(基本不用)
  3. memory_order_acquire:后续读操作不能重排到前面
  4. memory_order_release:前面的写操作不能重排到后面
  5. memory_order_acq_rel:acquire + release 的组合
  6. memory_order_seq_cst:最严格,全局顺序一致

15.2.1 默认是顺序一致性

如果你不指定内存序,默认就是 memory_order_seq_cst。这最安全,但性能也最差。我见过不少项目,明明可以用 relaxed,却用了 seq_cst,白白浪费了性能。

// 默认:顺序一致性
std::atomic<int> flag(0);
flag.store(1);  // 等价于 flag.store(1, std::memory_order_seq_cst)

// 宽松模式
flag.store(1, std::memory_order_relaxed);

15.2.2 Release-Acquire 模式

这是最常用的模式。一个线程 release,另一个线程 acquire,形成 happens-before 关系。

std::atomic<bool> ready(false);
int data = 0;

// 线程1:生产者
void producer() {
    data = 42;                          // 普通写
    ready.store(true, std::memory_order_release);  // 释放屏障
}

// 线程2:消费者
void consumer() {
    while (!ready.load(std::memory_order_acquire)) {
        // 等待
    }
    // 此时 data 一定是 42
    std::cout << data << std::endl;
}

注意: release 和 acquire 必须配对使用。如果生产者用 release,消费者用 relaxed,那 data 的值就不保证了。我曾经在代码审查时抓到过这种 bug,排查了整整两天。

15.3 无锁数据结构设计

无锁数据结构,核心思想是用原子操作代替锁。常见的无锁结构有:无锁栈、无锁队列、无锁哈希表等。

15.3.1 无锁栈(Treiber Stack)

这是最简单的无锁数据结构。基于 CAS(Compare-And-Swap)实现。

template<typename T>
class LockFreeStack {
private:
    struct Node {
        T data;
        Node* next;
    };
    std::atomic<Node*> head{nullptr};

public:
    void push(const T& value) {
        Node* new_node = new Node{value, nullptr};
        new_node->next = head.load(std::memory_order_relaxed);
        while (!head.compare_exchange_weak(
            new_node->next,
            new_node,
            std::memory_order_release,
            std::memory_order_relaxed
        )) {
            // 重试直到成功
        }
    }

    bool pop(T& value) {
        Node* old_head = head.load(std::memory_order_relaxed);
        while (old_head != nullptr) {
            if (head.compare_exchange_weak(
                old_head,
                old_head->next,
                std::memory_order_acquire,
                std::memory_order_relaxed
            )) {
                value = old_head->data;
                delete old_head;
                return true;
            }
        }
        return false;
    }
};

这段代码看起来简单,但有个大坑——内存管理。如果两个线程同时 pop,一个线程删除了节点,另一个线程还在用这个节点,那就出事了。

15.4 ABA 问题

ABA 问题是无锁编程中最经典的陷阱。我来解释一下:

假设有一个栈,栈顶是节点 A。线程1准备 pop,它读取了 A 的地址。但这时线程2插进来,把 A pop 掉了,又 push 了一个新节点 B,然后又 push 了一个新节点 A(注意,这个 A 是新的内存地址,但值相同)。

线程1的 CAS 比较时发现栈顶还是 A(地址不同但值相同?不对,CAS 比较的是地址),嗯,这里要纠正一下——ABA 问题中,CAS 比较的是指针值,而不是内容。如果内存被回收后重新分配,新节点的地址可能和旧节点相同。

ABA 问题的本质: 指针的值从 A 变成 B,又变回 A。CAS 误以为没有变化,但实际上中间状态已经变了。

15.4.1 解决方案:标记指针

最常用的方法是使用标记指针(Tagged Pointer)。把指针和计数器打包在一起,每次修改都递增计数器。

// 使用双字 CAS(DCAS)或 128位 CAS
struct TaggedPointer {
    void* ptr;
    uint64_t tag;
};

std::atomic<TaggedPointer> head;

void push(Node* node) {
    TaggedPointer old_head = head.load();
    TaggedPointer new_head{node, old_head.tag + 1};
    node->next = old_head.ptr;
    while (!head.compare_exchange_weak(old_head, new_head)) {
        new_head.tag = old_head.tag + 1;
        node->next = old_head.ptr;
    }
}

我的建议: 如果平台支持 128 位 CAS(比如 x86 的 CMPXCHG16B),直接用标记指针。否则可以考虑 hazard pointer 或 RCU(Read-Copy-Update)。我在一个金融项目中用过 hazard pointer,效果不错,就是实现起来有点复杂。

知识体系总览

下面这张图总结了本章的核心内容:

原子操作与无锁编程知识体系 std::atomic 原子操作 内存序模型 无锁数据结构 ABA 问题 六种内存序 • relaxed:最宽松 • consume:依赖排序 • acquire:读屏障 • release:写屏障 • acq_rel:读写屏障 • seq_cst:全局顺序 常用:release-acquire 配对 常见无锁结构 • 无锁栈(Treiber Stack) • 无锁队列(Michael-Scott) • 无锁哈希表 • 无锁链表 核心:CAS 循环 + 重试 难点:内存回收 ABA 问题与解决 • 问题:A→B→A 误判 • 方案1:标记指针 指针 + 计数器打包 • 方案2:Hazard Pointer • 方案3:RCU 推荐:128位 CAS + 标记 无锁编程 = 原子操作 + 内存序 + 正确性保证

无锁编程不是银弹。它比锁更难调试,更难保证正确性。我的原则是:能用锁就用锁,性能瓶颈到了再考虑无锁。毕竟,一个正确的慢程序,比一个快但错误的程序好得多。

嗯,这一章的内容就到这里。原子操作是基础,内存序是灵魂,无锁结构是应用,ABA 问题是陷阱。把这四点吃透了,你就能在多线程编程中游刃有余。

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