第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++ 提供了六种内存序,从松到严:
- memory_order_relaxed:最宽松,只保证原子性,不保证顺序
- memory_order_consume:依赖关系排序(基本不用)
- memory_order_acquire:后续读操作不能重排到前面
- memory_order_release:前面的写操作不能重排到后面
- memory_order_acq_rel:acquire + release 的组合
- 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,效果不错,就是实现起来有点复杂。
知识体系总览
下面这张图总结了本章的核心内容:
无锁编程不是银弹。它比锁更难调试,更难保证正确性。我的原则是:能用锁就用锁,性能瓶颈到了再考虑无锁。毕竟,一个正确的慢程序,比一个快但错误的程序好得多。
嗯,这一章的内容就到这里。原子操作是基础,内存序是灵魂,无锁结构是应用,ABA 问题是陷阱。把这四点吃透了,你就能在多线程编程中游刃有余。