3. C++原子操作详解:atomic类型、load/store操作、fetch_add/compare_exchange等原子操作、memory_order参数详解
说到多线程编程,锁是个好东西,但有时候太重了。我早年做游戏服务器时,一个高频计数器用互斥锁保护,结果性能直接掉了一半。后来换成原子操作,问题迎刃而解。说白了,原子操作就是让你在无锁的情况下,安全地读写共享变量。
3.1 什么是原子操作?
原子操作,就是不可分割的操作。一个线程在做原子操作时,其他线程看不到中间状态。要么全部完成,要么什么都没做。这跟数据库事务的原子性是一个道理。
我遇到过不少新手,以为 i++ 是原子的。其实不是。它至少包含三步:读 i、加 1、写回。两个线程同时执行,结果可能不是你想要的。这时候就需要 std::atomic 出马了。
核心要点:原子操作是构建无锁数据结构的基础,也是实现高性能并发编程的关键工具。
3.2 atomic 类型
C++11 引入了 std::atomic 模板类。你可以用它包装任何 trivially copyable 的类型。不过,最常用的还是整数类型和指针。
#include <atomic>
std::atomic<int> counter(0);
std::atomic<bool> flag(false);
std::atomic<uint64_t> timestamp(0);
std::atomic<int*> ptr(nullptr);
对于整数类型,还有特化版本,比如 std::atomic_int、std::atomic_uint64_t。我个人习惯直接用 std::atomic<int>,更统一,也更容易读。
小提示:atomic 对象默认是锁实现的,但对于 int、long 等基础类型,编译器通常会生成无锁的机器指令。可以用 is_lock_free() 检查。
3.3 load 和 store 操作
最基本的两个操作:读和写。但原子操作的读写,比普通读写多了个 memory_order 参数。
std::atomic<int> value(42);
// 读操作
int x = value.load(std::memory_order_acquire);
// 写操作
value.store(100, std::memory_order_release);
不传 memory_order 的话,默认是 memory_order_seq_cst,最严格但也最慢。我一般只在需要强一致性的场景用默认值,其他时候会选更宽松的顺序。
注意:普通变量的读写不是原子的。即使只是读一个 int,在多线程环境下也可能读到旧值或乱序的值。用 atomic<int> 的 load 才能保证。
3.4 fetch_add / fetch_sub
这两个操作对应的是「读-改-写」模式。比如计数器加一,用 fetch_add 一步搞定。
std::atomic<int> counter(0);
// 原子加 1,返回旧值
int old = counter.fetch_add(1);
// 原子减 1
counter.fetch_sub(1);
// 也可以用 += 运算符,等价于 fetch_add
counter += 1;
我记得有一次,线上服务有个统计计数器,用互斥锁保护。压测时发现锁竞争严重。改成 fetch_add 后,性能提升了 3 倍。嗯,这就是原子操作的价值。
3.5 compare_exchange
这是原子操作的「瑞士军刀」。它实现了一个原子的「比较并交换」逻辑:如果当前值等于期望值,就更新为目标值;否则,把当前值写回期望值。
std::atomic<int> value(0);
int expected = 0;
int desired = 42;
// 如果 value == expected,则 value = desired,返回 true
// 否则,expected = value,返回 false
bool success = value.compare_exchange_weak(expected, desired);
if (success) {
// 更新成功
} else {
// 更新失败,expected 已被更新为当前值
}
这里有两个版本:compare_exchange_weak 和 compare_exchange_strong。weak 版本在某些平台上可能「虚假失败」,需要循环重试。strong 版本保证要么成功要么失败。我一般用 strong,除非在性能敏感的循环里才用 weak。
实战场景:实现无锁链表、无锁栈、无锁队列时,compare_exchange 是核心原语。我曾在项目中用 CAS 实现了一个无锁的引用计数,避免了智能指针的锁开销。
3.6 memory_order 参数详解
这是原子操作最让人头疼的部分。说白了,memory_order 控制的是「内存可见性」和「指令重排」的边界。C++ 提供了 6 种顺序,从松到严:
| memory_order | 含义 | 性能 |
|---|---|---|
| relaxed | 只保证原子性,不保证顺序 | 最快 |
| consume | 数据依赖顺序(很少用) | 较快 |
| acquire | 之后的读写不能重排到之前 | 中等 |
| release | 之前的读写不能重排到之后 | 中等 |
| acq_rel | acquire + release | 较慢 |
| seq_cst | 全局顺序一致 | 最慢 |
我画了一张图,帮你理解这些顺序的关系:
你想想看,为什么需要这么多顺序?因为不同的场景对一致性的要求不一样。比如一个统计计数器,只要求最终结果正确,用 relaxed 就够了。但如果你要实现一个无锁队列,生产者消费者之间需要同步,那就得用 release/acquire 配对。
3.7 实际应用:无锁计数器
来看一个完整的例子。假设我们要统计某个接口的调用次数:
#include <atomic>
#include <thread>
#include <iostream>
class AtomicCounter {
private:
std::atomic<uint64_t> count_{0};
public:
// 计数加一,用 relaxed 就够了
void increment() {
count_.fetch_add(1, std::memory_order_relaxed);
}
// 读取当前值,需要 acquire 保证看到最新值
uint64_t get() const {
return count_.load(std::memory_order_acquire);
}
};
// 使用示例
void worker(AtomicCounter& counter, int times) {
for (int i = 0; i < times; ++i) {
counter.increment();
}
}
int main() {
AtomicCounter counter;
std::thread t1(worker, std::ref(counter), 1000000);
std::thread t2(worker, std::ref(counter), 1000000);
t1.join();
t2.join();
std::cout << "Final count: " << counter.get() << std::endl;
// 输出:2000000
return 0;
}
这里有个细节:increment 用 relaxed,因为多个线程的加操作之间没有顺序要求。但 get 用 acquire,因为读取线程需要看到所有写入线程的最终结果。这就是 release-acquire 语义的典型用法。
避坑指南:我曾经在项目里把所有原子操作都用了 seq_cst,结果性能比用锁还差。后来分析发现,大部分场景用 relaxed 或 acquire/release 就足够了。记住:最严格的顺序不一定是最好的。
3.8 总结
原子操作是并发编程的基石。掌握 atomic 类型、load/store、fetch_add、compare_exchange 以及 memory_order,你就能写出高效且正确的无锁代码。嗯,刚开始可能会觉得 memory_order 很绕,但多写几个例子就明白了。关键是要想清楚:你的线程之间需要什么样的可见性保证?
可可精品资料库