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 = 42ready.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

你看,原子操作比互斥锁快了整整一个数量级。而且这还是在单线程场景下测的,如果多线程竞争激烈,互斥锁的上下文切换开销会更大,差距会更明显。

我的经验: 原子操作适合保护「简单的共享变量」,比如计数器、标志位、指针等。但如果你的临界区代码超过 3 行,或者涉及复杂的数据结构,还是老老实实用互斥锁吧。原子操作不是万能的,用错了反而更麻烦。

8.5 知识体系总览

下面这张图,帮你把本章的核心逻辑串起来:

std::atomic 基本用法 load / store fetch_add / sub CAS (compare_exchange) 内存顺序 relaxed(最宽松) acquire / release seq_cst(最严格) 性能对比:原子操作 vs 互斥锁

8.6 总结与避坑

最后,我把本章的要点和坑点整理一下:

  • 原子操作适合简单场景:计数器、标志位、指针交换等。复杂逻辑还是用锁。
  • 默认用 seq_cst:除非你做过性能分析,确认 relaxed 没问题,否则别乱改内存顺序。
  • CAS 要注意 ABA 问题compare_exchange 比较的是值相等,但如果值从 A 变成 B 又变回 A,CAS 会认为没变过。需要配合版本号或标记指针来解决。
  • 原子操作不是万能的:它只解决「单个变量的原子读写」,解决不了「多个变量的原子组合操作」。后者还是得靠锁。
一句话总结: 能用原子操作解决的问题,就别上锁。但别为了炫技而滥用原子操作,简单可靠才是王道。

好了,这一章就到这里。原子操作是个好东西,但用对地方才是关键。希望你能在实际项目中找到它的用武之地。