6、原子操作(Atomic):std::atomic的基本类型与操作、内存顺序(memory_order)、无锁编程简介
原子操作,说白了就是「不可分割的操作」。
我刚开始学多线程时,总觉得加锁就能解决一切。后来线上一个高并发服务频繁卡顿,用 perf 一分析,锁竞争占了 30% 的 CPU 时间。嗯,那时候我才意识到——有些场景,根本不需要锁,原子操作就够了。
6.1 std::atomic 的基本类型
C++11 引入了 std::atomic,它是个模板类。你可以用它包装任何 trivially copyable 的类型。不过我个人最常用的,还是下面这些特化版本:
| 类型别名 | 底层类型 | 说明 |
|---|---|---|
std::atomic_bool |
bool |
原子布尔,常用于标志位 |
std::atomic_int |
int |
32位原子整数,最常用 |
std::atomic_uint |
unsigned int |
无符号版本 |
std::atomic_long |
long |
平台相关,通常是64位 |
std::atomic_llong |
long long |
保证64位 |
std::atomic_size_t |
size_t |
索引、计数器专用 |
std::atomic_int 就别用 std::atomic<int>,少打几个尖括号,代码也清爽。
6.2 基本操作
原子变量的操作其实不多,但每个都有讲究。我列一下最核心的:
- load:读取值。相当于「看一眼」。
- store:写入值。相当于「放进去」。
- exchange:写入并返回旧值。原子化的「先拿后放」。
- compare_exchange_weak / strong:CAS 操作。这是无锁编程的基石。
- fetch_add / fetch_sub:原子加减。比用锁快一个数量级。
看个例子:
#include <atomic>
#include <iostream>
std::atomic_int counter{0};
void increment() {
for (int i = 0; i < 1000; ++i) {
counter.fetch_add(1); // 原子加1
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << counter.load() << std::endl; // 一定是2000
return 0;
}
关键区别:fetch_add 返回旧值,operator++ 返回新值。我踩过这个坑——想用旧值做判断,结果拿到的却是新值,排查了半天。
6.3 内存顺序(memory_order)
这是原子操作里最让人头疼的部分。说实话,我刚开始也觉得这东西玄乎。但后来在项目中调试过一个诡异的 bug——两个线程之间看到的数据不一致,就是因为内存顺序没选对。
内存顺序有六种,但常用的就三种:
| 枚举值 | 含义 | 性能 | 使用场景 |
|---|---|---|---|
memory_order_relaxed |
只保证原子性,不保证顺序 | 最快 | 计数器、统计量 |
memory_order_acquire |
之后的读写不能重排到之前 | 中等 | 读取锁状态 |
memory_order_release |
之前的读写不能重排到之后 | 中等 | 写入锁状态 |
memory_order_acq_rel |
acquire + release | 较慢 | read-modify-write 操作 |
memory_order_seq_cst |
全局顺序一致 | 最慢 | 默认值,通用场景 |
我曾经踩过的坑:用 memory_order_relaxed 实现了一个自旋锁,结果两个线程同时「看到」锁是空闲的,双双进入临界区。后来改成 acquire/release 对才解决。记住——relaxed 只保证原子性,不保证可见性。
为什么会有这些顺序?因为 CPU 和编译器会重排指令。你写的是:
data = 42;
flag.store(true, memory_order_release);
但 CPU 可能先执行 flag.store,再执行 data = 42。另一个线程看到 flag 为 true 时,data 可能还是旧值。这就是内存顺序要解决的问题。
6.4 无锁编程简介
无锁编程,不是真的「不用锁」,而是用原子操作代替锁。它的核心思想是:如果操作失败,就重试,而不是阻塞。
最简单的例子——无锁栈:
template<typename T>
class LockFreeStack {
struct Node {
T data;
Node* next;
};
std::atomic<Node*> head{nullptr};
public:
void push(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
)) {
// 失败就重试,new_node->next 会被更新为最新的 head
}
}
};
这里的关键是 compare_exchange_weak。它做三件事:
- 比较 head 是否等于 new_node->next
- 如果相等,把 head 设为 new_node
- 如果不相等,把 new_node->next 更新为 head 的最新值
然后循环重试。这就是无锁的核心模式——CAS + 重试。
我的建议:无锁编程虽然性能好,但调试难度极高。我见过一个团队花了两周才修好一个 ABA 问题。如果你不是写底层库,优先用锁。等性能瓶颈真的来了,再考虑无锁。
6.5 知识体系图
下面这张图总结了本章的核心脉络:
6.6 避坑指南
最后,分享几个我亲身踩过的坑:
- 不要用 volatile 代替 atomic。 volatile 只防止编译器优化,不保证原子性,也不保证内存可见性。我见过有人用 volatile int 做计数器,结果多线程下数据全乱套了。
- compare_exchange_weak 可能假失败。 某些 CPU 上,即使值相等,CAS 也可能失败。所以一定要放在循环里重试。strong 版本不会假失败,但性能稍差。
- 无锁数据结构要处理 ABA 问题。 简单说就是:线程 A 读到指针 P,然后被挂起;线程 B 释放了 P 又分配了 P,地址相同但内容不同;线程 A 醒来后 CAS 成功,但数据已经变了。解决方案是加版本号或使用 hazard pointer。
一句话总结:原子操作是并发编程的「轻武器」——灵活、高效,但用不好容易伤到自己。先从 memory_order_seq_cst 开始,性能不够再优化内存顺序。
公众号:蓝海资料掘金营,微信deep3321