69、STL与原子操作:atomic类型、内存序、无锁编程
多线程编程,说白了就是几个工人同时干活。但问题来了——他们抢工具怎么办?
我早年做后台服务时,就吃过这个亏。一个全局计数器,多个线程同时++,结果跑着跑着数值对不上了。查了半天,原来是CPU缓存捣的鬼。嗯,从那天起,我就认真研究起了原子操作。
一、为什么需要atomic?
先看一个经典问题:
int counter = 0;
// 线程A
counter++;
// 线程B
counter++;
你以为结果是2?不一定。counter++ 在底层是三步:读、改、写。两个线程可能同时读到0,各自加1,最后写回1。这就是数据竞争。
解决办法?加锁可以,但锁太重了。对于简单的整数操作,atomic 是更好的选择。
核心观点:atomic 类型提供了无锁的线程安全操作,性能远优于互斥锁。
二、atomic 类型怎么用?
C++11 开始,STL 提供了 std::atomic 模板。我最常用的几个:
#include <atomic>
std::atomic<int> counter(0);
std::atomic<bool> flag(false);
std::atomic<long long> total(0);
// 原子自增
counter.fetch_add(1);
// 等价于 counter++,但线程安全
// 原子交换
int old = counter.exchange(10);
// CAS操作——无锁编程的核心
int expected = 5;
bool success = counter.compare_exchange_weak(expected, 10);
我在项目中遇到过这样一个场景:多个生产者线程往队列里写数据,一个消费者线程读。用 atomic 做计数器,比用 mutex 快了将近三倍。
个人习惯:能用 atomic 就别用锁。但注意,atomic 只保证单个变量的原子性,多个 atomic 操作之间仍然需要同步。
三、内存序——这个坑我踩过
atomic 操作还有一个隐藏参数:内存序(memory order)。很多人忽略它,直接用默认值。但如果你做无锁数据结构,内存序就是命根子。
先看有哪些内存序:
| 内存序 | 含义 | 性能 |
|---|---|---|
| memory_order_relaxed | 只保证原子性,不保证顺序 | 最快 |
| memory_order_consume | 依赖关系排序(很少用) | 较快 |
| memory_order_acquire | 读操作,后续读写不能重排到前面 | 中等 |
| memory_order_release | 写操作,前面的读写不能重排到后面 | 中等 |
| memory_order_acq_rel | 同时具有 acquire 和 release 语义 | 较慢 |
| memory_order_seq_cst | 全局顺序一致(默认) | 最慢 |
为什么会这样?因为现代CPU为了性能,会乱序执行指令。内存序就是告诉CPU:这里不能乱。
我曾经写过一个无锁队列,用了 memory_order_relaxed,结果在ARM架构上跑出来数据全乱套了。查了两天才发现,x86上 relaxed 没问题,但ARM的内存模型更弱,必须用 acquire/release。
避坑指南:如果你不确定用哪个内存序,就用默认的 memory_order_seq_cst。虽然慢一点,但至少不会出错。等性能瓶颈出现了,再针对性优化。
四、无锁编程实战——一个简单的无锁栈
理论说完了,来点实际的。下面是一个基于CAS的无锁栈:
template<typename T>
class LockFreeStack {
private:
struct Node {
T data;
Node* next;
Node(const T& d) : data(d), next(nullptr) {}
};
std::atomic<Node*> head{nullptr};
public:
void push(const T& value) {
Node* new_node = new Node(value);
new_node->next = head.load(std::memory_order_relaxed);
// CAS循环:如果head没变,就替换;否则重试
while (!head.compare_exchange_weak(
new_node->next,
new_node,
std::memory_order_release,
std::memory_order_relaxed
)) {
// 循环体为空,CAS失败会自动更新new_node->next
}
}
bool pop(T& result) {
Node* old_head = head.load(std::memory_order_relaxed);
while (old_head && !head.compare_exchange_weak(
old_head,
old_head->next,
std::memory_order_acquire,
std::memory_order_relaxed
)) {
// 循环体为空
}
if (old_head) {
result = old_head->data;
delete old_head; // 注意:这里存在ABA问题,实际生产需处理
return true;
}
return false;
}
};
这段代码里,我用了 release 和 acquire 配对。push 时 release,保证新节点数据对其他线程可见;pop 时 acquire,保证读到的是最新的 head。
注意:上面的代码为了演示简化了内存管理。实际无锁编程中,内存回收是个大难题(ABA问题、内存泄漏等)。工业级实现通常用 hazard pointer 或 RCU。
五、知识体系总览
下面这张图,是我自己总结的原子操作知识结构:
六、总结与建议
原子操作这东西,说难不难,说简单也不简单。我给你的建议是:
- 日常开发:用默认内存序,别瞎折腾。计数器、标志位这些场景,atomic 比锁好使多了。
- 性能敏感:可以试试 relaxed,但一定要在目标平台上做充分测试。x86 上跑得欢,ARM 上可能就崩了。
- 无锁数据结构:这是高手领域。如果你不是写底层库,建议直接用现成的(比如 boost.lockfree)。
我记得有一次,团队里一个小伙子非要用无锁队列实现一个高频交易系统。我劝他先用 mutex 版本跑通,再优化。他不听,结果 ABA 问题搞了整整一周。嗯,有时候「先做对,再做好」才是最快的路。
最后说一句:atomic 不是银弹。如果你的逻辑涉及多个变量的协同修改,老老实实用 mutex。无锁编程的正确性,比性能重要得多。
公众号:蓝海资料掘金营,微信deep3321