第十二章:无锁编程——原子操作、内存序与无锁队列
无锁编程,听起来很酷对吧?
我第一次接触这个概念是在一个高并发交易系统里。那时候锁竞争导致CPU利用率上不去,系统吞吐量卡在瓶颈上。领导说:「你去研究研究无锁方案。」好家伙,这一头扎进去就是好几年。
说白了,无锁编程就是用原子操作代替互斥锁,让多个线程在不阻塞的情况下安全地共享数据。它不是为了炫技,而是为了性能——当你的锁竞争超过一定阈值,无锁方案能带来数量级的提升。
12.1 原子操作:std::atomic 的基础
原子操作,就是不可分割的操作。要么全部执行完,要么一点都没执行。中间不会被其他线程打断。
C++11 引入了 std::atomic,让我们可以轻松定义原子变量。我习惯用 std::atomic<int> 替代普通的 int,尤其是在多线程计数器场景下。
#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;
}
你看,没有锁,没有互斥量,两个线程安全地累加。这就是原子操作的力量。
std::atomic 就别用 volatile。volatile 只告诉编译器不要优化,但不保证原子性。我在项目中见过有人用 volatile int 做计数器,结果线上数据对不上,排查了一整天。
12.2 内存序:控制可见性与顺序
原子操作只是基础,真正让无锁编程变得复杂的是内存序(memory_order)。
你想想看,CPU 和编译器为了性能,会重排指令。一个线程写入的值,另一个线程什么时候能看到?这就是内存序要解决的问题。
C++ 提供了六种内存序,但常用的就三种:
| 内存序 | 含义 | 性能 |
|---|---|---|
memory_order_relaxed |
只保证原子性,不保证顺序 | 最快 |
memory_order_acquire/release |
保证配对线程间的顺序一致性 | 中等 |
memory_order_seq_cst |
全局顺序一致,最严格 | 最慢 |
我个人习惯:默认用 seq_cst 写原型,性能调优时再降级。别一开始就追求极致优化,容易出 bug。
std::atomic<bool> ready{false};
int data = 0;
// 线程1
void producer() {
data = 42; // 普通写
ready.store(true, std::memory_order_release); // release 写
}
// 线程2
void consumer() {
while (!ready.load(std::memory_order_acquire)) {} // acquire 读
std::cout << data << std::endl; // 保证看到 42
}
这里 release 和 acquire 配对使用。线程2看到 ready 为 true 时,也一定能看到线程1对 data 的写入。
memory_order_relaxed 做标志位同步,结果消费者线程读到了 ready=true 但 data 还是旧值。嗯,这个坑我踩过,你们别踩。
12.3 CAS 操作:无锁编程的核心武器
CAS(Compare-And-Swap)是啥?就是「比较并交换」。它做三件事:
- 读取内存中的值
- 和期望值比较
- 如果相等,写入新值;否则什么都不做
整个过程是原子的。C++ 里对应 compare_exchange_weak 和 compare_exchange_strong。
std::atomic<int> value{0};
// CAS 实现的自旋锁
void lock_free_increment() {
int expected = value.load();
int desired;
do {
desired = expected + 1;
} while (!value.compare_exchange_weak(expected, desired));
// 如果 CAS 失败,expected 会被更新为当前值,然后重试
}
这里有个细节:compare_exchange_weak 可能因为「伪失败」(spurious failure)而返回 false,所以要用循环。而 compare_exchange_strong 保证要么成功,要么失败,没有伪失败。
compare_exchange_weak,性能更好。如果循环体很复杂,或者失败代价高,用 strong 版本更省心。
12.4 无锁队列:从理论到实践
无锁队列是无锁编程的经典应用。我参与过一个百万级 QPS 的消息中间件,核心就是无锁队列。
一个简单的无锁队列(单生产者单消费者)可以这样实现:
template<typename T, size_t Size>
class LockFreeQueue {
std::atomic<size_t> head{0};
std::atomic<size_t> tail{0};
T buffer[Size];
public:
bool push(const T& item) {
size_t t = tail.load(std::memory_order_relaxed);
size_t next = (t + 1) % Size;
if (next == head.load(std::memory_order_acquire)) {
return false; // 队列满
}
buffer[t] = item;
tail.store(next, std::memory_order_release);
return true;
}
bool pop(T& item) {
size_t h = head.load(std::memory_order_relaxed);
if (h == tail.load(std::memory_order_acquire)) {
return false; // 队列空
}
item = buffer[h];
head.store((h + 1) % Size, std::memory_order_release);
return true;
}
};
这个队列只用到了 release 和 acquire 语义,没有锁,没有 CAS。为什么?因为单生产者单消费者场景下,head 和 tail 各自只有一个线程修改,不需要 CAS。
如果是多生产者多消费者场景,就需要 CAS 来竞争槽位了。那复杂度会上一个台阶。
memory_order_seq_cst,结果性能还不如加锁版本。后来改成 acquire/release,吞吐量直接翻倍。内存序的选择,真的会影响性能。
12.5 无锁编程的核心知识体系
下面这张图,是我自己总结的无锁编程知识脉络。你看一遍,心里就有谱了。
从这张图你能看到,无锁编程的根基是原子操作,内存序控制可见性,CAS 提供条件更新能力。这三者组合起来,就能构建出各种无锁数据结构。
12.6 实战中的避坑指南
说了这么多,最后分享几个我踩过的坑:
- ABA 问题: CAS 操作中,值从 A 变成 B 又变回 A,CAS 会认为没变过。解决方案是加版本号,或者用
std::atomic<std::uint64_t>把指针和版本号打包在一起。 - 内存回收: 无锁数据结构中,一个线程还在读节点,另一个线程不能直接 delete。我一般用 Epoch-Based Reclamation 或 Hazard Pointer 来安全回收。
- 性能测试: 无锁不一定比加锁快。在低竞争场景下,锁的开销很小,无锁的复杂逻辑反而可能更慢。一定要用实际场景压测。
嗯,无锁编程就讲到这里。原子操作、内存序、CAS、无锁队列,这四个概念你掌握了,就能看懂大部分无锁代码了。剩下的,就是在实战中慢慢积累经验了。