3. 无锁数据结构(上):std::atomic基础与内存序
各位同学,今天我们来聊聊无锁编程。说实话,这是整个高频交易引擎里最硬核的部分之一。我当年刚接触这块时,也被内存序搞得晕头转向。但别怕,咱们一步步来。
3.1 为什么需要无锁?
先问个问题:锁有什么不好?
锁的本质是让线程排队。排队意味着什么?意味着阻塞、上下文切换、cache miss。在高频交易场景下,一笔订单可能就几微秒的窗口期,你让线程睡一觉再醒来?黄花菜都凉了。
我曾在某家做市商公司优化过一套订单系统。原来的实现用了std::mutex,延迟大概在500纳秒左右。换成无锁队列后,直接压到了80纳秒。嗯,这就是无锁的魅力。
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在某些平台上性能更好,而且配合循环使用完全没问题。
compare_exchange_weak和compare_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关系
3.5 内存回收与ABA问题
无锁队列本身不涉及动态内存分配,所以没有内存回收问题。但如果你用无锁链表或栈,那就麻烦了。
ABA问题是什么?举个例子:
- 线程A读取节点X,准备CAS操作
- 线程B把X删除了,又分配了一个新节点,地址恰好也是X
- 线程A的CAS成功了,但它操作的是已经被删除又重建的节点
嗯,这就是ABA。我当年第一次遇到这个问题时,排查了整整两天。
解决方案主要有三种:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 标记指针(Tagged Pointer) | 在指针中嵌入版本号 | 64位系统,地址空间足够 |
| RCU(Read-Copy-Update) | 延迟回收,等所有读者完成 | 读多写少的场景 |
| Hazard Pointer | 每个线程标记正在使用的指针 | 通用场景 |
3.6 本章小结
无锁编程的核心就三点:原子操作、内存序、ABA问题处理。SPSC队列是入门,但已经能覆盖很多实际场景了。
记住:无锁不是银弹。如果你的场景是读多写少,或者并发冲突不激烈,用锁可能更简单、更稳定。但如果你追求极致性能,那无锁就是必经之路。