8. 原子操作基础:std::atomic 的基本用法、原子类型与内存顺序、原子操作与互斥锁的性能对比
说到并发编程,锁是个绕不开的话题。但说实话,很多时候我们用锁,其实有点「杀鸡用牛刀」的感觉。我早年做游戏服务器的时候,有个高频调用的计数器,一开始用的互斥锁保护,结果压测时发现性能瓶颈就卡在这里。后来换成原子操作,吞吐量直接翻了一倍。
嗯,今天我们就来聊聊这个「轻量级」的并发利器——std::atomic。
8.1 为什么需要原子操作?
先想一个问题:两个线程同时对一个 int 变量做 ++,结果会怎样?
你可能会说「不就是加1嘛,有什么问题?」
问题大了。在 CPU 层面,i++ 其实分三步:读取、修改、写回。如果两个线程同时读到同一个值,各自加1再写回,那最终结果只加了1次,而不是2次。这就是经典的「读-改-写」竞态条件。
互斥锁能解决这个问题,但代价不小。每次加锁解锁,都有系统调用和上下文切换的开销。而原子操作,直接在 CPU 指令层面保证这三步不可分割——要么全做,要么没做,中间不会被其他线程打断。
8.2 std::atomic 的基本用法
C++11 开始,标准库提供了 std::atomic 模板类。用法其实很简单,我直接上代码:
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter(0);
void worker() {
for (int i = 0; i < 100000; ++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;
return 0;
}
你看,没有锁,没有 mutex,代码干净利落。运行结果永远是 200000,不会丢数据。
常用的原子操作接口有这些:
| 操作 | 说明 |
|---|---|
load() |
原子读取当前值 |
store(val) |
原子写入新值 |
exchange(val) |
原子交换,返回旧值 |
compare_exchange_weak/strong |
CAS 操作,比较并交换 |
fetch_add/sub |
原子加减,返回旧值 |
++ / -- |
前置/后置自增自减 |
fetch_add 而不是 ++,因为前者语义更明确,而且能拿到旧值,调试时方便打印。
8.3 原子类型与内存顺序
这里有个坑,我当年踩过。你以为原子操作就是「绝对安全」的?其实不是。原子操作只保证操作本身是原子的,但不同线程之间看到的操作顺序,可能和你想的不一样。
举个例子:
std::atomic<bool> ready(false);
int data = 0;
// 线程A
data = 42;
ready.store(true);
// 线程B
while (!ready.load());
assert(data == 42); // 可能失败!
为什么会这样?因为 CPU 和编译器可能会重排指令。线程A里 data = 42 和 ready.store(true) 的顺序,在另一个线程看来可能是颠倒的。
这就是内存顺序(memory order)要解决的问题。std::atomic 提供了几种内存顺序选项:
- memory_order_relaxed:最宽松,只保证原子性,不保证顺序。性能最好,但使用需谨慎。
- memory_order_consume:依赖关系排序,用得少,我几乎没用过。
- memory_order_acquire:读取操作使用,保证后续读写不会被重排到前面。
- memory_order_release:写入操作使用,保证前面的读写不会被重排到后面。
- memory_order_acq_rel:同时具备 acquire 和 release 语义。
- memory_order_seq_cst:最严格,全局顺序一致。也是默认选项。
回到刚才的例子,正确的写法应该是:
ready.store(true, std::memory_order_release);
// 线程B
while (!ready.load(std::memory_order_acquire));
assert(data == 42); // 现在一定成功
release-acquire 配对使用,就能保证「写入之前的所有操作,在读取之后都可见」。
memory_order_relaxed,结果线上出现诡异的数据不一致问题。排查了两天才发现是内存顺序搞的鬼。所以我的建议是:除非你非常清楚自己在做什么,否则就用默认的 seq_cst。性能差不了多少,但安全得多。
8.4 原子操作与互斥锁的性能对比
光说不练假把式。我们来做个简单的性能测试,看看原子操作到底快多少。
#include <atomic>
#include <mutex>
#include <chrono>
#include <iostream>
const int COUNT = 10000000;
std::atomic<int> atomic_counter(0);
int mutex_counter = 0;
std::mutex mtx;
void test_atomic() {
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < COUNT; ++i) {
atomic_counter.fetch_add(1);
}
auto end = std::chrono::steady_clock::now();
std::cout << "原子操作耗时: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
<< " ms" << std::endl;
}
void test_mutex() {
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < COUNT; ++i) {
std::lock_guard<std::mutex> lock(mtx);
++mutex_counter;
}
auto end = std::chrono::steady_clock::now();
std::cout << "互斥锁耗时: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
<< " ms" << std::endl;
}
int main() {
test_atomic();
test_mutex();
return 0;
}
在我机器上的测试结果:
| 操作方式 | 耗时(1000万次) | 相对倍数 |
|---|---|---|
| 原子操作(seq_cst) | 约 120 ms | 1x |
| 互斥锁 | 约 1800 ms | 约 15x |
你看,原子操作比互斥锁快了整整一个数量级。而且这还是在单线程场景下测的,如果多线程竞争激烈,互斥锁的上下文切换开销会更大,差距会更明显。
8.5 知识体系总览
下面这张图,帮你把本章的核心逻辑串起来:
8.6 总结与避坑
最后,我把本章的要点和坑点整理一下:
- 原子操作适合简单场景:计数器、标志位、指针交换等。复杂逻辑还是用锁。
- 默认用 seq_cst:除非你做过性能分析,确认 relaxed 没问题,否则别乱改内存顺序。
- CAS 要注意 ABA 问题:
compare_exchange比较的是值相等,但如果值从 A 变成 B 又变回 A,CAS 会认为没变过。需要配合版本号或标记指针来解决。 - 原子操作不是万能的:它只解决「单个变量的原子读写」,解决不了「多个变量的原子组合操作」。后者还是得靠锁。
好了,这一章就到这里。原子操作是个好东西,但用对地方才是关键。希望你能在实际项目中找到它的用武之地。