现代并发编程的核心:C++11 原子操作(std::atomic)

之前我研究了机器人开发中的 ROS2(Jazzy)系统相关内容。并将官网中比较重要的教程和概念,按照自己的学习顺序翻译成了中文,并进行了整理和记录。到目前为止,已经整理了20多篇文章。如果你想回顾之前的内容,可以查阅主页中 ROS2(Jazzy)相关文章。

在研究 ROS2 的过程中,我发现它使用了不少 C++11 的新特性。这让我意识到,深入掌握这些特性对于深入理解 ROS2 的实现原理和优化代码非常重要。

因此,我萌生了撰写 C++11 系列文章的想法。目前已经完成了以下几篇文章:

  1. C++11 ROS2性能狂飙:C++11移动语义‘偷梁换柱’实战
  2. C++11 Lambda 表达式 以及 std::function和std::bind
  3. C++11 智能指针:unique_ptr、shared_ptr 和 weak_ptr
  4. C++11 的线程管理(std::thread)

本文是第五篇,主要总结的是 C++11 原子操作 std::atomic。

C++11 引入了 std::atomic 模板类,它提供了无锁线程安全操作的能力,允许在多线程环境中安全地访问和修改共享数据,而无需显式使用互斥锁。它是现代并发编程的核心组件。

一、原子操作的核心概念与原理

1. 为什么需要原子操作?

加入原子操作主要是为了解决以下问题和需求:

  • 数据竞争问题:多线程同时读写同一个变量导致的未定义行为
  • 锁的开销问题:互斥锁可能导致上下文切换、死锁、优先级反转等问题
  • 性能需求:原子操作利用硬件指令实现高效的无锁同步

2. 原子操作的本质

  • 不可分割性:操作要么完全执行,要么完全不执行
  • 内存可见性:确保修改对所有线程立即可见
  • 顺序约束:通过内存序控制指令重排序,这点在后面详细介绍。

二、基本用法与类型支持

1. 支持的类型

std::atomic 支持所有基本类型:

std::atomic<int> atomicInt(0);       // 原子整型
std::atomic<bool> atomicFlag(false); // 原子布尔
std::atomic<double> atomicDouble;    // 原子浮点(有限操作)

2. 常用的原子操作接口

// 存储值
atomicInt.store(42);

// 读取值
int value = atomicInt.load();

// 交换值
int old = atomicInt.exchange(100);

// 比较交换(CAS)
bool success = atomicInt.compare_exchange_weak(expected, desired);

3. 在算术运算(整数类型)中使用原子操作

std::atomic<int> counter(0);

counter.fetch_add(1);    // 原子加(返回旧值)
counter.fetch_sub(1);    // 原子减
counter++;               // 等价于 fetch_add(1) + 1
++counter;               // 等价于 fetch_add(1)

counter.fetch_and(0xF0); // 位与
counter.fetch_or(0x0F);  // 位或

4. 指针运算

struct Data { /*...*/ };
Data dataArray[10];
std::atomic<Data*> ptr(dataArray);

ptr.fetch_add(2); // 原子指针前进2个元素
Data* current = ptr.load();

三、原子操作的内存序

在多线程环境中,内存序(Memory Ordering)是原子操作控制内存访问顺序和可见性的关键机制。它定义了原子操作之间的顺序约束,告诉编译器和 CPU 如何对指令进行重排、何时将数据同步到其他线程,从而在保证正确性的前提下实现高效并发。

1. 那么为什么需要内存序呢?

这是因为现代计算机系统中存在多级优化行为:

  1. 编译器重排:编译器为优化性能可能调整指令顺序。
  2. CPU 重排:处理器乱序执行指令,写操作可能延迟提交到内存。
  3. 缓存不一致:不同 CPU 核心的缓存可能暂存不同数据副本。

2. 什么是重排(Reordering)?

重排是理解内存序的核心基础。

在计算机系统中,程序指令的实际执行顺序与程序员在源代码中编写的顺序不一致的现象

  • 程序顺序(Program Order): 就是你写在代码里的语句顺序。比如你先写变量 A = 10;,然后再写 B = 20;
  • 执行顺序(Execution Order): 就是 CPU 实际执行这些指令的顺序,或者这些指令的效果(特别是内存读写)对其他 CPU 核心可见的顺序。

而重排则是指执行顺序没有严格按照程序代码顺序进行。

重排不是错误,也不是随意的行为。它是现代计算机系统为了追求极致性能而进行的一系列复杂优化的必然结果。主要包括编译器重排 (Compiler Reordering):处理器(CPU)重排 (Hardware / CPU Reordering)两个层面。这里就不展开了。

我们需要知道的是,由于重排时编译器和 CPU 的优化时都只考虑单线程的正确性,它们不知道这段代码会在多线程环境下运行,也不知道其他线程正在观察内存状态,这就带来了多线程问题。

比如,线程 A 修改了共享变量 X,但由于 Store Buffer,这个修改还没刷到主存/其他核心的缓存。线程 B 读取 X,读到的还是旧值。

又比如,线程 A 按顺序执行 X=1; 然后 Y=1;。但由于重排,Y=1; 的效果可能比 X=1; 的效果更早地被线程 B 观察到。线程 B 看到 Y 变成了 1,但 X 还是 0。这在源代码顺序里是不可能的,但在实际执行中发生了。

所以std::memory_order(如 acquire, release, seq_cst)就是为了告诉编译器和 CPU:“在这个点上,你必须保证某些操作的顺序和可见性,以满足多线程同步的需求,即使这会牺牲一点性能。

它们通过在特定位置插入内存屏障(Memory Barrier) 或使用具有隐式屏障的原子指令来实现阻止重排:

  • #StoreStore 屏障:屏障前的 Store 必须在屏障后的 Store 之前完成(对其他核心可见)。
  • #LoadLoad 屏障:屏障后的 Load 必须在屏障前的 Load 之后执行(使用最新值)。
  • #LoadStore 屏障:屏障前的 Load 必须在屏障后的 Store 之前完成。
  • #StoreLoad 屏障:屏障前的 Store 必须在屏障后的 Load 之前完成(对所有核心可见)。这是最重的屏障。

同时,它还确保一个线程的修改在特定时刻对其他线程可见(例如,release 操作确保之前的修改在 acquire 操作看到这个 release 写入时,也能被看到)。

四、 C++标准定义了6种内存序,它们可分为四类:

  • 顺序一致性序(Sequential Consistency):std::memory_order_seq_cst
  • 获取释放序(Acquire-Release):std::memory_order_acquire、std::memory_order_release、std::memory_order_acq_rel
  • 松散序(Relaxed):std::memory_order_relaxed
  • 消费序(Consume):std::memory_order_consume(已被弃用,不建议使用)

下面分别介绍这四种内存序:

1) 顺序一致性序(Sequential Consistency)

std::memory_order_seq_cst (Sequential Consistency,顺序一致性) 是 C++ 原子操作中最严格、最直观但也通常开销最大的内存顺序模型。

它的核心原理在于为所有线程提供一个单一、全局一致的操作执行顺序的视图。

也就是说,所有使用 seq_cst 的原子操作(读、写、读-修改-写)会被放入一个单一的、全局所有线程都同意的操作执行序列中。

这个序列(顺序)必须满足在每个线程内部,该线程执行的操作顺序必须与程序代码中指定的顺序一致,即**程序顺序 (Program Order)**。

而且每个 seq_cst 操作(尤其是写操作)一旦在这个全局序列中变得可见,它对所有线程的后续 seq_cst操作都必须是立即可见的,这叫做全局可见顺序 (Global Visibility Order):

这个全局序列就像是所有线程的操作被交错地、但保持各自程序顺序地记录在一条单一的时间线上。

seq_cst 保证所有 seq_cst 操作构成一个全局总序(Total Order)。 对于任意两个 seq_cst 操作 A 和 B(无论它们来自哪个线程,无论操作的是哪个原子变量),所有线程都一致地看到要么 A 在 B 之前发生,要么 B 在 A 之前发生。没有模棱两可的情况。

示例

std::atomic<int> x{0}, y{0};
int r1, r2;

// 线程1
void thread1() {
    x.store(1, std::memory_order_seq_cst);  // 操作A
    r1 = y.load(std::memory_order_seq_cst);  // 操作B
}

// 线程2
void thread2() {
    y.store(1, std::memory_order_seq_cst);  // 操作C
    r2 = x.load(std::memory_order_seq_cst);  // 操作D
}

特性解析

  • 所有线程观察到的操作顺序一致(例如不可能出现线程1看到A→B→C→D,而线程2看到C→D→A→B
  • 结果不可能同时为r1=0r2=0(因为全局顺序必须是A→B→C→DC→D→A→B或其他明确顺序)
  • 性能代价:在多处理器系统上需要插入内存屏障,性能最低

2) 获取释放序(Acquire-Release)

获取释放序(Acquire-Release Ordering)是 C++ 内存模型中比顺序一致性更高效但仍能提供线程同步保证的模型。其核心原理基于成对的同步操作而非全局总序。

获取释放序的核心思想是:释放操作(release)与获取操作(acquire)形成同步对,建立跨线程的"发生在先"(happens-before)关系:

以下是三种获取释放序原理介绍:

(1) std::memory_order_release(释放操作)

适用于原子存储(store)或读-修改-写(RMW)操作。它的作用原理为:

  • 禁止重排序

保证该操作之前的所有内存操作(包括非原子操作和其他原子操作)不会被重排到该存储之

相当于插入 #StoreStore + #LoadStore 屏障

  • 建立同步点

将当前线程的所有修改(直到该操作)打包提交到主内存

为后续获取操作提供可见性锚点

// 线程A
data = 42;                          // (1) 非原子写入
flag.store(true, std::memory_order_release); // (2) 释放操作:保证(1)不会重排到(2)之后

(2) std::memory_order_acquire(获取操作)

适用于原子加载(load)或读-修改-写(RMW)操作。它的作用原理为:

  • 禁止重排序

保证该操作之后的所有内存操作不会被重排到该加载之前

相当于插入 #LoadLoad + #LoadStore 屏障

  • 接收同步

若加载的值由其他线程的释放操作写入,则与该释放操作形成同步对

此时能看见释放操作之前的所有修改

// 线程B
while (!flag.load(std::memory_order_acquire)); // (3) 获取操作
assert(data == 42);                    // (4) 保证看到线程A的(1)写入

(3) std::memory_order_acq_rel(获取-释放操作)

适用于读-修改-写(RMW)操作 (如 exchange, fetch_add)。它的作用原理为:

  • 双重屏障

加载部分应用获取语义(acquire

存储部分应用释放语义(release

  • 同步双向传递

与之前其他线程的释放操作同步(获取部分)

与之后其他线程的获取操作同步(释放部分)

// 线程C:计数器自增
counter.fetch_add(1, std::memory_order_acq_rel);

3) 松散序(Relaxed)

松散序(std::memory_order_relaxed)是 C++ 中最弱的内存顺序模型,它仅保证原子操作的原子性(atomicity),而不提供任何线程间的同步或顺序约束。其核心特点是:允许编译器和硬件进行最大限度的指令重排优化。

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

// 线程1
void thread1() {
    cnt.fetch_add(1, std::memory_order_relaxed);  // 操作A
}

// 线程2
void thread2() {
    int t1 = cnt.load(std::memory_order_relaxed);  // 操作B
    int t2 = cnt.load(std::memory_order_relaxed);  // 操作C
}

特性

  • 结果可能是t1=0, t2=1t1=1, t2=1t1=1, t2=0(是的,t2可能小于t1
  • 只保证原子性,不保证可见性和顺序性
  • 性能最佳:通常不插入内存屏障,仅使用原子指令

4) 消费序(Consume)

已被弃用的内存序,功能类似acquire但更弱,仅保证依赖链上的操作可见:

std::atomic<int*> ptr{nullptr};
int data = 0;

// 生产者
void producer() {
    data = 42;
    ptr.store(&data, std::memory_order_release);
}

// 消费者
void consumer() {
    int* p;
    while (!(p = ptr.load(std::memory_order_consume)));
    // 仅保证*p可见(即*p == 42)
    // 其他独立变量可能不可见
}

五、最佳实践与性能考量

  1. 默认使用顺序一致性:除非有明确性能需求
// 默认安全
counter.fetch_add(1);
  1. 避免虚假共享(False Sharing)
struct alignas(64) CacheLineAligned { // 64字节对齐
  std::atomic<int> counter;
};
  1. 原子操作不是万能药
  2. 复杂操作仍需互斥锁
  3. 高频争用时锁可能更高效
  4. 性能基准(原子 vs 互斥锁):场景原子操作互斥锁无竞争5 ns20 ns轻度竞争50 ns200 ns重度竞争500 ns1000 ns
  5. 错误处理
try {
   atomicInt.wait(old_value); // C++20
} catch (const std::system_error& e) {
   // 处理原子操作异常
}

总结:

我们可以利用决策树来决定如何使用原子操作,或者换成其它方式,比如考虑考虑互斥锁或无锁设计:

虽然 std::atomic 提供了强大而且高效的无锁编程能力,但我们需要深入理解其语义:

  • 对于简单操作:优先使用原子操作
  • 对于复杂数据结构:考虑互斥锁或无锁设计
  • 始终考虑内存序的影响
  • 性能敏感场景进行基准测试

正确使用原子操作可以显著提升并发程序的性能和可伸缩性,是现代C++高效并发编程的基石。


欢迎关注 【智践行】 一起学习机器人开发,发送【C++】获得学习资料。

原文链接:,转发请注明来源!