第四节:无锁编程基础
无锁编程,说白了就是让多个线程同时读写共享数据,但不用互斥锁。
听起来很美好对吧?
我刚开始接触高频交易系统时,也觉得锁是万恶之源。那时候系统里到处都是锁,性能瓶颈一眼就能看出来。后来我花了整整三个月,把核心路径上的锁全部替换成了无锁结构——效果确实惊人,延迟从微秒级降到了纳秒级。
但代价呢?
调试无锁代码的痛苦,我至今记忆犹新。一个ABA问题,能让你排查三天三夜。
4.1 CAS操作:无锁编程的基石
CAS,全称Compare-And-Swap,比较并交换。
它的核心逻辑很简单:
bool compare_and_swap(int* addr, int expected, int new_value) {
if (*addr == expected) {
*addr = new_value;
return true;
}
return false;
}
但注意,上面这段代码只是示意。真正的CAS是一条CPU指令,是原子的。
在C++11里,我们可以这样用:
#include <atomic>
std::atomic<int> counter{0};
void worker() {
int expected = counter.load();
int new_val = expected + 1;
// 如果counter还是expected,就更新成new_val
while (!counter.compare_exchange_weak(expected, new_val)) {
// 失败说明别的线程改了,重新读取
new_val = expected + 1;
}
}
我在项目中遇到过一个问题:用CAS实现一个简单的计数器,结果在高并发下性能反而比互斥锁还差。后来发现是CAS的循环次数太多了——说白了就是竞争太激烈,大家都在自旋。
核心要点:
- CAS是乐观锁思想——先尝试,失败了再重试
- compare_exchange_weak和strong的区别:weak可能伪失败,但性能更好
- CAS适合低冲突场景,高冲突时反而比锁差
4.2 内存屏障:看不见的秩序
你想想看,CPU为了性能,会乱序执行指令。编译器也会重排代码。
单线程下没问题,多线程就出事了。
举个例子:
std::atomic<bool> ready{false};
int data = 0;
// 线程1
data = 42;
ready.store(true, std::memory_order_release);
// 线程2
while (!ready.load(std::memory_order_acquire));
assert(data == 42); // 这个断言会成功吗?
如果没有内存屏障,线程2可能看到ready为true,但data还是0。
为什么?因为CPU可能把data=42这条指令重排到了ready.store之后。
内存屏障就是用来阻止这种重排的。C++11提供了六种内存序:
| 内存序 | 含义 | 性能开销 |
|---|---|---|
| memory_order_relaxed | 无任何顺序保证 | 几乎为零 |
| memory_order_consume | 依赖关系保证 | 很低 |
| memory_order_acquire | 之后的读不能重排到之前 | 中等 |
| memory_order_release | 之前的写不能重排到之后 | 中等 |
| memory_order_acq_rel | acquire + release | 较高 |
| memory_order_seq_cst | 全局顺序一致 | 最高 |
我的建议:
刚开始接触无锁编程时,先用memory_order_seq_cst。等系统跑稳了,再逐步优化成更弱的内存序。我曾经一上来就用relaxed,结果bug修了一个月。
4.3 ABA问题:无锁编程的头号陷阱
什么是ABA问题?
假设你有一个栈,栈顶指针是T。线程1想pop一个节点:
- 它读取T指向节点A
- 它读取A的next指针,指向B
- 它准备用CAS把T从A改成B
但在第2步和第3步之间,发生了:
- 线程2把A和B都pop了
- 线程2又把A push回来了
- 现在栈顶还是A,但A的next已经变了
线程1的CAS发现T还是A,就成功把T改成了B。但B早就被删除了!
这就是ABA问题——值没变,但语义变了。
我曾经踩过的坑:
有一次线上系统出现诡异的内存访问错误,排查了整整三天。最后发现是ABA问题导致一个已经被释放的节点被再次访问。从那以后,我所有无锁数据结构都加上了ABA防护。
4.4 ABA问题的解决方案
解决ABA问题,主流方案有两种:
方案一:标记指针(Tagged Pointer)
在指针的高位加一个版本号,每次修改都递增:
struct Node {
int value;
Node* next;
};
struct TaggedPtr {
Node* ptr;
uintptr_t tag; // 版本号
};
std::atomic<TaggedPtr> head;
void push(Node* node) {
TaggedPtr old = head.load();
TaggedPtr new_val;
do {
node->next = old.ptr;
new_val.ptr = node;
new_val.tag = old.tag + 1; // 版本号递增
} while (!head.compare_exchange_weak(old, new_val));
}
这样即使指针值相同,版本号不同,CAS也会失败。
方案二:垃圾回收(Hazard Pointer / RCU)
延迟回收被删除的节点,确保没有线程还在访问它。
我个人更推荐标记指针方案,因为它实现简单,性能开销小。在高频交易场景下,每纳秒都很宝贵。
实战建议:
- 如果指针有足够的空闲位(比如64位系统只用48位地址),用标记指针
- 如果节点生命周期复杂,用Hazard Pointer
- 读多写少的场景,RCU是更好的选择
4.5 知识体系总览
下面这张图,是我自己总结的无锁编程知识体系:
嗯,这张图基本概括了无锁编程的核心。CAS是工具,内存屏障是规则,ABA问题是陷阱。三者缺一不可。
最后说一句:无锁编程不是银弹。如果你的场景并发冲突很高,或者代码维护者水平参差不齐,老老实实用锁反而更稳妥。我在团队里定过一个规矩:除非性能测试证明锁是瓶颈,否则不许用无锁。