14、C++11 并发编程:std::atomic与内存序,无锁编程的基础
说到并发编程,很多人第一反应就是加锁。互斥锁、读写锁、条件变量…… 锁用多了,性能就下来了,死锁风险也上来了。那有没有不用锁也能保证数据安全的方法?有,这就是我们今天要聊的 std::atomic 和无锁编程。
我个人习惯把原子操作看作是并发编程的「最小积木块」。它不像锁那样需要你去管理临界区,而是直接告诉编译器:这个变量的读写,必须是不可分割的。嗯,说白了,就是让 CPU 在硬件层面保证操作的原子性。
为什么需要原子操作?
先看一个经典问题。两个线程同时对一个 int 变量做 ++ 操作。你觉得结果会是什么?
int counter = 0;
// 线程1
counter++;
// 线程2
counter++;
你可能会说,不就是 2 吗?错。实际运行中,结果可能是 1,也可能是 2。为什么会这样?因为 counter++ 在 CPU 层面其实是三步:读取、修改、写回。两个线程的这三步可能交错执行,导致其中一个线程的修改被覆盖。
我在项目中遇到过类似的 bug,线上服务偶尔出现计数不准的问题。查了两天才发现是并发累加导致的。从那以后,凡是多线程共享的计数器,我全部换成了 std::atomic。
std::atomic 的基本用法
用起来其实很简单。把普通变量换成 std::atomic<T> 就行。
#include <atomic>
std::atomic<int> counter{0};
void increment() {
counter.fetch_add(1); // 原子加1
}
int main() {
// 两个线程同时执行 increment
// counter 最终一定是 2
return 0;
}
fetch_add 是原子操作,它保证整个「读-改-写」过程不会被其他线程打断。类似的还有 fetch_sub、exchange、compare_exchange_weak 等。
compare_exchange_weak 和 compare_exchange_strong 的区别在于,weak 版本在某些平台上可能出现「伪失败」(spurious failure),需要在循环中使用。我一般优先用 strong 版本,除非在性能敏感的循环中才考虑 weak。
内存序:原子操作的灵魂
很多人以为用了 std::atomic 就万事大吉了。其实不然。原子操作只是保证了操作的不可分割性,但不同线程之间看到的数据顺序,还需要内存序(memory order)来约束。
你想想看,现代 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); // 这个断言能通过吗?
如果用了 release 和 acquire 语义,就能保证线程2看到线程1对 data 的写入。这就是内存序的作用——它定义了原子操作之间的可见性约束。
六种内存序一览
| 内存序 | 含义 | 使用场景 |
|---|---|---|
| memory_order_relaxed | 只保证原子性,不保证顺序 | 计数器、统计量 |
| memory_order_consume | 数据依赖顺序(目前不建议使用) | 基本被 acquire 取代 |
| memory_order_acquire | 后续读写不能重排到该操作之前 | 读取标志位 |
| memory_order_release | 之前的读写不能重排到该操作之后 | 写入标志位 |
| memory_order_acq_rel | acquire + release | 读-改-写操作 |
| memory_order_seq_cst | 全局顺序一致(默认) | 默认选择,性能稍低 |
memory_order_relaxed。结果跑出来的数据时对时错。后来花了整整一天排查,才发现是内存序太宽松,导致不同线程看到的数据顺序不一致。所以我的建议是:除非你非常清楚自己在做什么,否则先用默认的 seq_cst,性能不够再优化。
无锁编程的核心思想
无锁编程,说白了就是利用原子操作和内存序,在不使用互斥锁的前提下实现线程安全。它的核心数据结构是「无锁队列」、「无锁栈」等。
最常见的无锁技术是 CAS(Compare-And-Swap),在 C++ 中对应 compare_exchange_weak/strong。
std::atomic<int> value{0};
void cas_example() {
int expected = 0;
int desired = 42;
// 如果 value 等于 expected,就换成 desired,返回 true
// 否则把 value 的值写入 expected,返回 false
if (value.compare_exchange_strong(expected, desired)) {
// 成功
} else {
// 失败,expected 被更新为当前值
}
}
无锁编程的难点在于处理 ABA 问题。简单说就是:一个值从 A 变成 B,又变回 A,CAS 检查时发现还是 A,就认为没被修改过。但实际上中间被改过。解决方法是使用带版本号的原子指针,比如 std::atomic<std::shared_ptr<T>> 或者用 tagged pointer。
知识体系图
下面这张图总结了本章的核心知识结构:
实际项目中的经验
我在做高性能消息队列时,用过无锁队列。当时对比了 std::mutex 和 std::atomic 实现的 SPSC(单生产者单消费者)队列。结果很有意思:在低竞争下,无锁版本比加锁版本快 3-5 倍;但在高竞争下,差距反而缩小了,因为 CAS 的失败重试也消耗了 CPU。
所以我的建议是:
- 简单场景:计数器、标志位,直接用
std::atomic加默认内存序 - 中等场景:需要传递数据,用
release/acquire语义 - 复杂场景:无锁数据结构,建议先评估是否真的需要。无锁编程的调试难度远高于加锁方案
嗯,关于原子操作和内存序,今天就聊到这里。记住一句话:并发编程没有银弹,理解底层原理,才能做出正确的选择。