3. 无锁数据结构(上):std::atomic基础与内存序

各位同学,今天我们来聊聊无锁编程。说实话,这是整个高频交易引擎里最硬核的部分之一。我当年刚接触这块时,也被内存序搞得晕头转向。但别怕,咱们一步步来。

3.1 为什么需要无锁?

先问个问题:锁有什么不好?

锁的本质是让线程排队。排队意味着什么?意味着阻塞、上下文切换、cache miss。在高频交易场景下,一笔订单可能就几微秒的窗口期,你让线程睡一觉再醒来?黄花菜都凉了。

我曾在某家做市商公司优化过一套订单系统。原来的实现用了std::mutex,延迟大概在500纳秒左右。换成无锁队列后,直接压到了80纳秒。嗯,这就是无锁的魅力。

核心思想:无锁编程不是真的不用锁,而是用CPU提供的原子指令来替代操作系统级别的锁。

3.2 std::atomic基础

C++11开始,标准库提供了std::atomic。这东西说白了就是一个模板类,能让你对基本类型做原子操作。

#include <atomic>

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

// 原子自增
counter.fetch_add(1, std::memory_order_relaxed);

// 原子比较交换
int expected = 0;
int desired = 1;
bool success = counter.compare_exchange_weak(expected, desired, 
                                             std::memory_order_acq_rel);

注意看compare_exchange_weak。这个函数是CAS(Compare-And-Swap)的核心实现。我习惯用weak版本而不是strong版本,因为weak在某些平台上性能更好,而且配合循环使用完全没问题。

个人经验:在x86平台上,compare_exchange_weakcompare_exchange_strong生成的指令是一样的。但在ARM上,weak版本可能更高效。

3.3 内存序(Memory Order)—— 这才是重点

内存序是什么?说白了就是告诉CPU和编译器:我这个原子操作前后,内存访问的顺序该怎么保证。

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

内存序 含义 性能开销
memory_order_relaxed 只保证原子性,不保证顺序 几乎为零
memory_order_acquire 之后的读写不能重排到之前 较小
memory_order_release 之前的读写不能重排到之后 较小
memory_order_acq_rel acquire + release 中等
memory_order_seq_cst 全局顺序一致性(默认) 最大

你想想看,默认的seq_cst是最安全的,但也是性能最差的。我见过太多人直接用默认值,结果性能上不去还找不到原因。

避坑指南:我曾经在一个项目中,所有原子操作都用了seq_cst,结果延迟比用锁还高。后来改成acquire-release语义,性能直接翻倍。

3.4 无锁SPSC队列实现

SPSC(Single Producer Single Consumer)队列是无锁编程的入门级实现。为什么?因为只有一个生产者和一个消费者,不需要处理多线程同时写入的问题。

来看一个经典实现:

template<typename T, size_t Capacity>
class SPSCQueue {
    static_assert(Capacity > 0, "Capacity must be positive");
    static_assert((Capacity & (Capacity - 1)) == 0, "Capacity must be power of 2");
    
    alignas(64) std::atomic<size_t> head_{0};
    alignas(64) std::atomic<size_t> tail_{0};
    alignas(64) T buffer_[Capacity];
    
public:
    bool push(const T& item) {
        size_t tail = tail_.load(std::memory_order_relaxed);
        size_t next_tail = (tail + 1) & (Capacity - 1);
        
        if (next_tail == head_.load(std::memory_order_acquire)) {
            return false; // 队列满了
        }
        
        buffer_[tail] = item;
        tail_.store(next_tail, std::memory_order_release);
        return true;
    }
    
    bool pop(T& item) {
        size_t head = head_.load(std::memory_order_relaxed);
        
        if (head == tail_.load(std::memory_order_acquire)) {
            return false; // 队列空了
        }
        
        item = buffer_[head];
        head_.store((head + 1) & (Capacity - 1), std::memory_order_release);
        return true;
    }
};

注意几个关键点:

  • 内存对齐:alignas(64)让head和tail在不同缓存行,避免伪共享
  • 容量是2的幂:这样可以用位运算代替取模,性能更好
  • 内存序选择:生产者用release,消费者用acquire,形成happens-before关系
为什么这样设计?生产者写入数据后,用release语义告诉消费者:数据已经准备好了。消费者读取数据前,用acquire语义确认:数据确实准备好了。

3.5 内存回收与ABA问题

无锁队列本身不涉及动态内存分配,所以没有内存回收问题。但如果你用无锁链表或栈,那就麻烦了。

ABA问题是什么?举个例子:

  1. 线程A读取节点X,准备CAS操作
  2. 线程B把X删除了,又分配了一个新节点,地址恰好也是X
  3. 线程A的CAS成功了,但它操作的是已经被删除又重建的节点

嗯,这就是ABA。我当年第一次遇到这个问题时,排查了整整两天。

解决方案主要有三种:

方案 原理 适用场景
标记指针(Tagged Pointer) 在指针中嵌入版本号 64位系统,地址空间足够
RCU(Read-Copy-Update) 延迟回收,等所有读者完成 读多写少的场景
Hazard Pointer 每个线程标记正在使用的指针 通用场景
我的建议:在高频交易场景中,尽量用数组实现的无锁结构(比如上面的SPSC队列),避免动态内存分配。这样ABA问题自然就不存在了。

3.6 本章小结

无锁编程的核心就三点:原子操作、内存序、ABA问题处理。SPSC队列是入门,但已经能覆盖很多实际场景了。

记住:无锁不是银弹。如果你的场景是读多写少,或者并发冲突不激烈,用锁可能更简单、更稳定。但如果你追求极致性能,那无锁就是必经之路。

无锁数据结构知识体系 无锁数据结构 std::atomic基础 原子操作 CAS (compare_exchange) fetch_add / load / store 内存序 (Memory Order) relaxed / acquire / release acq_rel / seq_cst happens-before 关系 SPSC队列实现 环形缓冲区 内存对齐 (避免伪共享) 位运算取模 ABA问题与内存回收 标记指针 (Tagged Pointer) RCU (Read-Copy-Update) Hazard Pointer 核心:原子操作 + 内存序 + ABA防护 = 无锁安全