14、C++11 并发编程:std::atomic与内存序,无锁编程的基础

说到并发编程,很多人第一反应就是加锁。互斥锁、读写锁、条件变量…… 锁用多了,性能就下来了,死锁风险也上来了。那有没有不用锁也能保证数据安全的方法?有,这就是我们今天要聊的 std::atomic 和无锁编程。

我个人习惯把原子操作看作是并发编程的「最小积木块」。它不像锁那样需要你去管理临界区,而是直接告诉编译器:这个变量的读写,必须是不可分割的。嗯,说白了,就是让 CPU 在硬件层面保证操作的原子性。

为什么需要原子操作?

先看一个经典问题。两个线程同时对一个 int 变量做 ++ 操作。你觉得结果会是什么?

int counter = 0;
// 线程1
counter++;
// 线程2
counter++;

你可能会说,不就是 2 吗?错。实际运行中,结果可能是 1,也可能是 2。为什么会这样?因为 counter++ 在 CPU 层面其实是三步:读取、修改、写回。两个线程的这三步可能交错执行,导致其中一个线程的修改被覆盖。

我在项目中遇到过类似的 bug,线上服务偶尔出现计数不准的问题。查了两天才发现是并发累加导致的。从那以后,凡是多线程共享的计数器,我全部换成了 std::atomic

std::atomic 的基本用法

用起来其实很简单。把普通变量换成 std::atomic<T> 就行。

#include <atomic>

std::atomic<int> counter{0};

void increment() {
    counter.fetch_add(1);  // 原子加1
}

int main() {
    // 两个线程同时执行 increment
    // counter 最终一定是 2
    return 0;
}

fetch_add 是原子操作,它保证整个「读-改-写」过程不会被其他线程打断。类似的还有 fetch_subexchangecompare_exchange_weak 等。

小提示: compare_exchange_weakcompare_exchange_strong 的区别在于,weak 版本在某些平台上可能出现「伪失败」(spurious failure),需要在循环中使用。我一般优先用 strong 版本,除非在性能敏感的循环中才考虑 weak。

内存序:原子操作的灵魂

很多人以为用了 std::atomic 就万事大吉了。其实不然。原子操作只是保证了操作的不可分割性,但不同线程之间看到的数据顺序,还需要内存序(memory order)来约束。

你想想看,现代 CPU 和编译器为了优化,可能会对指令进行重排。比如:

std::atomic<bool> ready{false};
int data = 0;

// 线程1
data = 42;
ready.store(true, std::memory_order_release);

// 线程2
while (!ready.load(std::memory_order_acquire));
assert(data == 42);  // 这个断言能通过吗?

如果用了 releaseacquire 语义,就能保证线程2看到线程1对 data 的写入。这就是内存序的作用——它定义了原子操作之间的可见性约束。

六种内存序一览

内存序 含义 使用场景
memory_order_relaxed 只保证原子性,不保证顺序 计数器、统计量
memory_order_consume 数据依赖顺序(目前不建议使用) 基本被 acquire 取代
memory_order_acquire 后续读写不能重排到该操作之前 读取标志位
memory_order_release 之前的读写不能重排到该操作之后 写入标志位
memory_order_acq_rel acquire + release 读-改-写操作
memory_order_seq_cst 全局顺序一致(默认) 默认选择,性能稍低
避坑指南: 我曾经在一个项目中,为了追求极致性能,把所有原子操作都改成了 memory_order_relaxed。结果跑出来的数据时对时错。后来花了整整一天排查,才发现是内存序太宽松,导致不同线程看到的数据顺序不一致。所以我的建议是:除非你非常清楚自己在做什么,否则先用默认的 seq_cst,性能不够再优化。

无锁编程的核心思想

无锁编程,说白了就是利用原子操作和内存序,在不使用互斥锁的前提下实现线程安全。它的核心数据结构是「无锁队列」、「无锁栈」等。

最常见的无锁技术是 CAS(Compare-And-Swap),在 C++ 中对应 compare_exchange_weak/strong

std::atomic<int> value{0};

void cas_example() {
    int expected = 0;
    int desired = 42;
    // 如果 value 等于 expected,就换成 desired,返回 true
    // 否则把 value 的值写入 expected,返回 false
    if (value.compare_exchange_strong(expected, desired)) {
        // 成功
    } else {
        // 失败,expected 被更新为当前值
    }
}

无锁编程的难点在于处理 ABA 问题。简单说就是:一个值从 A 变成 B,又变回 A,CAS 检查时发现还是 A,就认为没被修改过。但实际上中间被改过。解决方法是使用带版本号的原子指针,比如 std::atomic<std::shared_ptr<T>> 或者用 tagged pointer。

知识体系图

下面这张图总结了本章的核心知识结构:

std::atomic 并发编程 基本用法 fetch_add / fetch_sub exchange / compare_exchange 内存序 relaxed / acquire / release acq_rel / seq_cst 无锁编程 CAS 操作 ABA 问题 核心原则:原子性 + 可见性 + 顺序性 默认用 seq_cst,性能瓶颈时再优化 实践建议:先保证正确,再追求性能

实际项目中的经验

我在做高性能消息队列时,用过无锁队列。当时对比了 std::mutexstd::atomic 实现的 SPSC(单生产者单消费者)队列。结果很有意思:在低竞争下,无锁版本比加锁版本快 3-5 倍;但在高竞争下,差距反而缩小了,因为 CAS 的失败重试也消耗了 CPU。

所以我的建议是:

  • 简单场景:计数器、标志位,直接用 std::atomic 加默认内存序
  • 中等场景:需要传递数据,用 release/acquire 语义
  • 复杂场景:无锁数据结构,建议先评估是否真的需要。无锁编程的调试难度远高于加锁方案
核心总结: std::atomic 是 C++11 给并发编程带来的利器。它让我们可以在不用锁的情况下安全地操作共享数据。但记住,原子操作只是工具,内存序才是灵魂。无锁编程虽然强大,但复杂度也高。我的经验是:能用锁解决的问题,先用锁解决。等性能瓶颈出现时,再考虑无锁方案。

嗯,关于原子操作和内存序,今天就聊到这里。记住一句话:并发编程没有银弹,理解底层原理,才能做出正确的选择。