CppCon 2016 学习:Using Weakly Ordered C++ Atomics Correctly

为什么需要原子操作?

"程序通常会确保在一个线程写入某个内存位置时,其他线程不会同时访问这个位置。"

这句话的意思是:为了避免数据竞争(data race) ,多个线程在访问共享变量时,必须使用某种同步机制来保证安全。

传统做法:使用锁(mutex)

示例代码:
cpp 复制代码
void inc_shared() {
    lock_guard<mutex> _(mtx);
    x++;
}
  • mutex 来保护对 x 的访问。
  • 保证同一时间只有一个线程在修改 x

使用锁的优点

  • 能保护复杂操作,比如修改多个变量或者有条件地更新数据。
  • 易于理解和使用(特别是对初学者)。

使用锁的缺点

  1. 死锁 / 锁顺序问题
    • 如果两个线程互相等待对方持有的锁,会导致死锁
    • 多锁使用时需要小心锁的顺序。
  2. 不能在信号处理函数或中断处理器中使用
    • 锁在异步上下文中是不安全的,可能造成死锁或未定义行为。
  3. 性能问题
    • 使用锁会导致线程阻塞,降低并发度。
    • 如果持锁线程被系统中断或切换上下文,其他线程就会被卡住等待
    • 锁带来的开销比较大,特别是在竞争激烈的场景下。

原子操作(Atomics)是一种更轻量的替代方案

C++ 提供 <atomic> 头文件,用于支持无锁并发编程

示例:
cpp 复制代码
std::atomic<int> x;
void inc_shared() {
    x.fetch_add(1, std::memory_order_relaxed);
}
  • std::atomic<int> 是一种线程安全的整数。
  • fetch_add(1) 是一个原子加操作,不需要加锁。

原子操作的优点

  • 无锁,不阻塞线程 → 没有死锁问题。
  • 可以在中断/信号处理器中使用。
  • 性能更好,适用于高并发场景(尤其是简单数据)。

原子操作的限制

  • 只能用于简单操作(如计数器、自旋锁、标志位)。
  • 若要同时操作多个原子变量,写法会复杂且难以正确实现。
  • 需要理解内存模型和内存序(memory order),有一定门槛。

附注:事务内存(Transactional Memory)

"¹ 参见事务内存规范(transactional memory specification)。"

事务内存是一种更高级的技术,试图用事务来代替锁,实现多个变量的一致性更新。

  • 优点:像数据库事务那样"要么全部成功,要么全不改动"。
  • 缺点:目前 C++ 标准库中还没有正式支持,属于实验性技术。

总结:什么时候使用原子操作?

使用原子操作的场景:

  • 只需要对一个变量进行简单同步(如++计数、设置标志)。
  • 对性能要求高,不能容忍线程阻塞。
  • 在中断处理器或信号处理器中进行并发操作。
    仍然使用锁的场景:
  • 涉及多个变量、复杂逻辑。
  • 希望通过结构化方式简化并发逻辑。

为什么要使用原子操作?(第二部分)

C++11 之前的经验:为什么会催生 atomics?

C++11 之前 ,标准库中没有提供原子操作内存模型支持。所以开发者面对并发编程时,只能靠锁(mutex)来保护共享变量:

cpp 复制代码
std::mutex m;
int x = 0;
void safe_increment() {
    std::lock_guard<std::mutex> lock(m);
    ++x;
}

但是------

开发者会"创造性地规避"锁的规则

锁虽然有效,但有时开发者会觉得"太重了"或"不够快",于是就尝试用一些危险甚至错误的方法来避开锁:

常见的规避方式:

1. volatile + 汇编代码
cpp 复制代码
volatile int x = 0;

很多人以为 volatile 能保证线程安全,其实 不能

  • volatile 只保证了编译器每次都访问内存,不会缓存变量。
  • 不保证原子性,也不提供同步语义。
  • 程序员还常用内联汇编来手动实现"原子操作",但这通常是不可靠且难以移植的。
    volatile 的正确用途是防止优化 ,用于 硬件寄存器访问信号处理器变量等特殊场景,不是线程同步。
2. 编写有数据竞争的代码

程序员有时会写出竞争访问(racing access),例如:

cpp 复制代码
int x = 0;
void racing_increment() {
    x++;  // 非原子,多个线程可能同时修改 x
}

这类代码可能在测试时没出问题,但实际上:

  • 会触发 未定义行为(UB)
  • 编译器可能会做激进优化,导致程序逻辑完全错乱
  • 破坏编译器的假设模型
    举例:Fedor Pikus 的演讲(你提到的)展示了许多由于错误并发写法造成的灾难性问题,其中很多程序"表面看似工作正常",但其实随时可能崩溃。

"性能优化"是想象的,还是实际的?

有些开发者做这些规避操作,是为了所谓的"性能优化":

  • 他们担心锁太慢、会阻塞线程。
  • 于是尝试"裸奔式"并发:直接访问变量,不加锁,不同步。
    现实情况是
  • 有时优化确实带来了微小性能提升;
  • 但多数时候是想象出来的,"测不出实际提升",反而带来bug;
  • 甚至导致程序随机崩溃或在不同平台上表现不一致。

解决办法:引入 std::atomic

C++11 引入 <atomic> 标准头文件,提供了:

  • 原子变量类型 (如 std::atomic<int>
  • 明确定义的内存模型(memory_order)
  • 线程安全、跨平台、无需手写汇编的原子操作
    使得我们可以正确、安全地写出高性能并发代码。

总结

问题 传统方法 原子操作(C++11 起)
性能优化 不安全地规避锁(volatile等) 原生无锁实现
线程安全 必须用锁,开销大 提供原子性保证
可移植性 汇编不通用,易错 标准库统一支持
编译器兼容 易触发未定义行为 避免破坏假设

C++ 原子操作(atomics)的设计理念

你声明了一个 atomic<T>,意味着什么?

在 C++ 中,如果你用 std::atomic<T> 声明一个变量,比如:

cpp 复制代码
std::atomic<int> counter;

这表示:

这个变量可以在多个线程之间安全并发访问,而不需要你显式加锁。

每个 atomic<T> 操作都是"不可分割的"(indivisible)

这是什么意思?

  • 每个线程只会看到操作"要么完成了,要么还没开始"。
  • 比如如果一个线程执行了 counter++,另一个线程不会看到一个"半加完成"的状态。
  • 也就是说,原子操作是线程之间不可中断的动作

默认内存语义:顺序一致性(sequentially consistent)

这是 C++ 原子操作的默认行为。

所有线程看起来是"按照某种全局顺序"在执行操作。

举个例子:

cpp 复制代码
std::atomic<int> x = 0;
void thread1() { x.store(1); }
void thread2() { int val = x.load(); }

即使两个线程并发执行,在默认的顺序一致性模型下,它看起来就像:

  • 要么是 thread1 写入完成后,thread2 才读取;
  • 要么是 thread2 先读取了旧值 0,然后 thread1 写入了 1。
    也就是说,在这个模型下,你可以像写单线程程序一样去推理变量的变化顺序,这就是它的好处。

注意:只要你不访问非原子变量,就能保持这一"顺序一致性"

这很关键:

如果你在多线程下访问一个非原子变量 ,而没有同步机制,那就属于数据竞争(data race) ,你的程序就触发了未定义行为(UB)

正确写法(全原子):

cpp 复制代码
std::atomic<int> flag = 0;
void thread() {
    flag.store(1);
}

错误写法(混用原子和普通变量):

cpp 复制代码
bool ready = false; // 非原子!
void thread() {
    ready = true;    // 写操作不是原子
}

🔻 这种写法可能在某些优化下让其他线程永远看不到 ready == true,因为编译器可以缓存、重排等。

彩蛋:不要做"非常愚蠢的事"

"And you avoid some really silly things. Like implementing Dekker's mutual exclusion algorithm using std::get_terminate() and std::set_terminate()."

这句话有点幽默地警告程序员:
不要滥用 C++ 的其他机制来做并发控制!

std::set_terminate() 是给程序设置异常终止处理器用的,跟同步无关。但某些开发者为了绕过并发模型、强行用这些东西模拟"同步",结果只会造成更混乱的行为。

总结:原子操作的核心理念

特性 解释
atomic<T> 表示 T 类型的变量支持线程安全的并发访问
不可分割(indivisible) 原子操作要么完全成功,要么完全没做;不会出现"中间状态"
顺序一致性(sequential consistency) 所有线程看到的操作顺序与代码中的先后顺序一致
禁止数据竞争 不能与非原子变量并发访问同一个内存区域,否则程序行为未定义
不要滥用机制 不要用和并发无关的函数(如 set_terminate())来模拟同步

这段内容的中文解释与深入理解 ,主要是关于 C++ 中 顺序一致性原子(sequentially consistent atomics) 的设计理念和硬件实现要求。

什么是 顺序一致性原子(Sequentially Consistent Atomics)

在 C++ 中,如果你不指定 memory_order,默认的原子操作是 memory_order_seq_cst ,即顺序一致性

它的语义是:

所有线程看起来像是在某个全局顺序中一个一个地执行原子操作。

它的行为就像你对每个原子变量都加了一个互斥锁 mutex(虽然底层并没有真的加锁)

  • 所以你可以像用锁一样安全地使用它们 ,只要每次访问的临界区只有一个原子操作

  • 例如:

    cpp 复制代码
    std::atomic<int> x = 0;
    x++;  // 安全:原子加法,行为像加锁一样可控

它比 mutex 快吗?

是的,在某些简单的场景下:

  • 如果你仅仅需要执行一个操作(例如读/写/加法):
    • 原子比锁快(因为不需要等待、抢占、内核调度等)。
  • 但是如果操作变复杂,就不一定了。

更复杂的操作怎么办?

这就是为什么有大量研究关注无锁(lock-free)算法

它们的目标是:把复杂的逻辑"分解"成多个原子操作,但程序整体看起来仍然是"原子的"。

例如:

cpp 复制代码
// 理想逻辑
x = x + 1; y = y + 2; // 想让它"看起来"是同时完成的
// 但其实要用很多原子指令+控制逻辑来实现这个假象

很多学术研究试图设计出这样的算法,但很容易出错,即使是论文也经常存在 bug。

顺序一致性的底层硬件要求

顺序一致性原子需要满足以下 4 项核心要求:

标记 含义 解释 硬件开销
I Indivisible 操作不可分割 原子操作是"全有或全无",不能被打断 对于小且对齐的 load/store,一般硬件本身就支持
S Store ordering 原子写入不能被重新排序在之前的内存操作之前 在 x86 上几乎是"免费"的;在 ARMv7 需要内存栅栏(memory fence)
L Load ordering 原子读取必须在后续操作之前完成 x86 同样很便宜;ARMv7 同样需要 memory fence
SL Store-Load 不可重排 原子写入后不能马上执行原子读取(必须等写入"生效") 对所有平台都很贵(即便是 x86 也需内存栅栏)

平台区别简要

架构 顺序一致性支持
x86 天然强内存模型,很多顺序性要求是"自动满足"的
ARMv7 / ARM64 较弱内存模型,需要明确插入 memory fence(如 dmb, dsb

总结要点

  • 顺序一致性原子(memory_order_seq_cst
    • 语义最强 → 最易用、最安全
    • 性能最差(尤其在弱内存模型平台如 ARM)
  • 行为类似加锁的"原子操作"
    • 每次操作都不可分割
    • 多线程下像是"排队执行的"
  • 硬件层面
    • x86 平台天然满足大多数顺序要求
    • ARM 平台必须显式加内存屏障

这是一个经典的 "消息传递(Message Passing)" 示例,用来说明 C++ 顺序一致性原子(memory_order_seq_cst 如何在多线程中确保数据的同步可见性与执行顺序。

示例代码结构(两线程):

cpp 复制代码
// 线程 1
x = 17;
x_init = true;
// 线程 2
if (x_init) {
    assert(x == 17);
}
cpp 复制代码
int x;                          // 普通变量
std::atomic<bool> x_init(false); // 原子布尔变量

这个程序的目标是什么?

我们希望:

只要 x_init == true,那么 x == 17 一定是 已经发生了并且对线程 2 可见的结果

换句话说,x_init = true 作为"信号",告诉另一个线程 "x 已经被初始化为 17"。

为什么这能工作?(顺序一致性的保障)

要实现这个"消息传递"语义,需要三个关键保障:

标志 名称 含义
I Indivisibility 原子操作本身不可中断(x_init = true 要么完成,要么未开始)
S Store ordering x = 17; x_init = true; 顺序不能颠倒
L Load ordering 如果线程 2 读取了 x_init == true,就必须在这之后读取 x
注意:不需要 SL(Store→Load 不可乱序) ,因为我们没有在写 x_init 后立刻读取别的原子变量。

用图表示内存操作顺序:

复制代码
线程 1:         线程 2:
x = 17;          if (x_init) {
x_init = true;       assert(x == 17);
                 }

借助顺序一致性语义:

  • 线程 1 的操作看起来是"同时"对所有线程可见的。
  • 线程 2 看到 x_init == true 这一事实,就等于看到了线程 1 的"整个历史"。

如果 x_init 不是原子变量会怎样?

就可能出现 data race(数据竞争)

  • 线程 2 看到 x_init == true
  • x = 17 的写入尚未完成或尚未被刷新到内存中
  • 导致 x == 17 的断言失败

总结

项目 说明
目的 用原子变量作为信号/标志,实现跨线程数据同步
原子提供保障 不可分割性(I)、写入顺序性(S)、读取顺序性(L)
不需要的 不涉及 SL(Store→Load 乱序)
风险 如果不用原子,可能引发读取未同步数据,断言失败或 UB

这个例子展示了在 C++ 中使用 顺序一致性原子(memory_order_seq_cst 实现一种经典的并发控制协议 ------ Dekker's algorithm(德克尔算法) 的简化变体,用于实现互斥访问(mutual exclusion)

目标

防止两个线程 同时通过检查执行各自的关键区(如改变交通灯)

保证:只有一个线程能看到对方"没设置标志",自己才执行关键操作。

代码说明

cpp 复制代码
// 原子布尔变量,初始为 false
std::atomic<bool> x(false), y(false);

线程 1:

cpp 复制代码
x.store(true);
if (!y.load()) 
    turn_EW_lights_green(); // 东西方向绿灯

线程 2:

cpp 复制代码
y.store(true);
if (!x.load()) 
    turn_NS_lights_green(); // 南北方向绿灯

正确性依赖于什么?

必须确保以下两点:

要求 描述
SL Store → Load 不可乱序 :必须先完成 store,再执行 load
全局一致性视图 两个线程观察到的操作顺序必须一致。

解释:

  • 假如线程 1 和线程 2 都 乱序执行:先 load 后 store ,那么:
    • 线程 1 看到 y == false
    • 线程 2 看到 x == false
    • → 两个线程都执行了各自的绿灯代码 → 违背互斥
      而顺序一致性原子能防止这种情况!

顺序一致性(memory_order_seq_cst)保证了什么?

对这个例子而言:

  • 每个线程都必须
    • 先执行 store
    • 再执行 load
  • 并且整个程序中所有线程观察到的操作顺序是统一的 (全局时间线)
    → 至少一个线程能看到另一个线程的 store,并据此阻止自己继续

为什么需要 SL(Store-Load 不可乱序)?

原子操作 平台表现
S(Store 顺序) x86 免费,ARM 需 memory fence
L(Load 顺序) x86 免费,ARM 需 memory fence
SL(Store→Load 不可重排) x86 和 ARM 都需要 memory fence,代价大!
在这个 Dekker 示例中,为了正确性必须禁止 store→load 的乱序,所以需要 SL。

如果不用顺序一致性会怎样?

如果改为 memory_order_relaxed(放松原子):

  • store 和 load 可以被重排序
  • 可能出现"两个线程都看不到对方已设置标志"的情形
  • 两个线程都执行关键区 ,导致违反互斥

总结

项目 内容
目标 使用原子变量模拟互斥,确保只有一个线程进入关键区
使用 顺序一致性原子:x.store(true)x.load() 等价于加了 memory fence 的操作
保障 需要 SL(Store→Load 不可乱序)
风险 如果使用 relaxed 模式,则程序可能失效(两线程同时进入)

顺序一致性原子操作(sequentially consistent atomics) 在不同平台上的性能成本,以及这些成本在实际并发程序中的表现。

顺序一致性原子的代价在哪里?

核心开销:内存栅栏(memory fence)

原子操作为了保证全局操作顺序,需要插入 fence(内存屏障)来禁止 CPU 对指令乱序执行。这个 fence 是主要的性能瓶颈。

每次原子操作的成本(在微基准中):

开销 描述
2~200 cycles(CPU周期) 取决于架构和具体情况
20~30 cycles 在现代 CPU 中常见值
远比大多数指令贵 比如加法指令可能只要 1 cycle
比缓存未命中便宜 cache miss(尤其跨线程)往往更贵
如果没有线程争用,fence 成为主要成本 即使只有一个线程,也要插入 fence(无谓浪费)

各架构细节

架构 加载(Load) 存储(Store) 原子 RMW 操作
x86 加载已天然有序(无须额外fence) 需要插入fence 已自带 fence
ARMv7 加载需 1 个 fence 存储或 RMW 需 2 个 fence
ARMv8 有专门支持顺序一致性的原子指令,更高效

隐患:聪明的 lock-free ≠ 性能好

虽然 lock-free 代码可以避免死锁等问题,但如果:

  • 你频繁使用顺序一致原子
  • 每个原子操作都带 fence
  • 且没有并发竞争来掩盖这部分成本
    那么:
    ** 可扩展性(scalability)可能提升**(线程不会互相等待)
    ** 实际性能(throughput)可能下降**(每个线程本地执行更慢)

结论总结

要点 描述
顺序一致性是最强的内存模型,但代价不低
栅栏(fence)是主要开销来源
对于不争用的线程,fence 成本尤为显著
有时候 clever lock-free 实现反而比 mutex 更慢
在 x86 上好一些,但在 ARM 上可能严重影响性能
ARMv8 原生支持 SC 原子,解决一部分问题
如果你希望在项目中高效使用原子操作,可以考虑:
  • 按需使用 memory_order_relaxed / acquire / release
  • 减少原子访问次数
  • 批处理写入
  • 使用 thread-local 缓冲或"epoch-based GC" 等高级优化

这部分内容是关于 如何降低顺序一致性原子操作的成本 ------通过使用弱内存序(weakly ordered atomics) 来实现更高性能。

目标:降低顺序一致性原子(SC atomics)的性能成本

顺序一致性原子操作 (默认的 memory_order_seq_cst)提供了最强的内存可见性保障,但:

  • 需要插入昂贵的 内存栅栏(fence)
  • 特别在 ARM 架构 上,性能损耗尤为明显
    于是,C++ 提供了几种弱化内存序选项。

弱内存序的类型与特点

1. memory_order_acquire

  • 适用于 读取操作
  • 保证:后续的读写不会被重排到前面
  • 几乎不需要 fence(在 x86 上)
  • 不保证:读取之前的写操作对其他线程可见

2. memory_order_release

  • 适用于 写入操作
  • 保证:先前的读写不会被重排到 release 写之后
  • 可用于 "发布-获取" 同步场景(message passing)

3. memory_order_acq_rel

  • 适用于 读改写操作(RMW)
  • 同时具备 acquire 和 release 的语义

以上三种:牺牲 SL(store-load)顺序

  • 优点: 避免 store 后插入的 fence
  • 缺点: 不能完全模拟 SC 行为

4. memory_order_relaxed

  • 最弱的内存序
  • 仅保证操作本身是原子的(即 I
  • 牺牲所有顺序(S、L、SL)
  • 读写操作之间可以被重排
  • 不能用于同步线程间顺序
  • 非常适合用在只需要计数、统计、随机数生成等用途中

在 ARM 上的优化效果

操作 SC (强序) acquire/release relaxed
store 2 个 fence 1 个或无
load 1 个 fence 通常无
RMW 自带 fence 自带部分顺序 更快

总结对比表

内存序 原子性 (I) 顺序保障(S) 加载前序(L) 存-载禁止重排(SL) 性能 适用场景
seq_cst 默认选择,强保障
acquire 消费者线程加载
release 生产者线程存储
acq_rel RMW操作
relaxed 更块 仅用于非同步场景
如果你写的是高性能并发代码,推荐的思路是:
  • 默认用 seq_cst(安全)
  • 性能受限时,改为 acquire/release 组合
  • 进一步优化时再考虑 relaxed
  • 但必须用 工具验证同步性(如 TSAN、formal verification)

弱内存序原子操作的"坑"和复杂性

弱内存序的"丑陋"一面

1. 极度复杂的内存模型规则

  • 弱内存序的行为规则非常难懂,甚至对专家来说都不直观。
  • 可能会导致程序行为出现意想不到的结果,尤其是在多线程竞态条件复杂的情况下。
  • 标准委员会直到现在还没完全明确 memory_order_relaxed 的细节定义。

2. 误用和理解风险大

  • 如果程序员对内存序没有深入理解,使用弱内存序很容易出错,导致难以发现的并发 bug。
  • 甚至memory_order_consume(更弱的一种)都被"弃用"了,因为其语义更难理解且几乎没被正确实现。

3. 库设计难题

  • 库内部使用弱内存序通常会影响到库的用户代码,因为内存序是接口的一部分。
  • 这使得内存序的复杂性无法很好地封装和隐藏,暴露给使用者增加使用难度。

小结

虽然弱内存序可以带来性能优势,但代价是程序复杂度和错误风险大幅增加。除非非常必要且非常了解内存模型,否则不建议轻易使用。

总结了多线程编程中选择同步机制和原子操作的建议

同步机制使用建议

  1. 优先使用 mutex(或事务内存)
    • 容易理解,能保证任意复杂操作的原子性。
    • 简单且安全,适合大多数情况。
  2. 临界区仅包含单次变量访问时
    • 使用顺序一致(sequentially consistent)原子操作
    • 简单且相对高效,避免死锁和复杂度。
  3. 信号处理函数中访问变量时
    • 必须非常谨慎地使用顺序一致原子操作
    • 因为信号处理环境特殊,不允许使用锁。
  4. 性能瓶颈明显时
    • 可以考虑复杂的无锁算法(lock-free algorithms)。
    • 但要记住,无锁不一定更快,且更难正确实现。
  5. 使用弱内存序原子操作
    • 可以提升性能,但极易引入 bug,需谨慎。
    • 适合对性能要求极高且有丰富经验的场景。

总结

  • 安全优先:先用 mutex,保证代码正确。
  • 性能权衡:再用顺序一致原子操作。
  • 高级优化:才考虑无锁和弱内存序。

弱顺序原子操作(weakly-ordered atomics)的第一个陷阱

陷阱1:复合操作不是原子的!

  • atomic<int> x;
  • 表达式 x = x + 1; 不是原子操作!
  • x++; 是调用了原子的成员函数,单次操作是原子的。

重点:

  • 原子类型的单个成员函数调用 (比如 fetch_add++storeload)是原子的。
  • 表达式 x = x + 1; 实际上包含多步操作 :先读取 x,加一,再写回 x,中间不是原子的,可能导致竞态。
  • 想要写出多个原子访问合成的不可分割操作非常难,需要使用专门的原子操作函数或同步机制。
    这就是为什么要用x.fetch_add(1)x++,而不能用x = x + 1atomic变量操作。

这部分强调了弱顺序原子操作中一个特别容易出错的点:

弱顺序原子操作的陷阱1:顺序约束非常微妙!

  • 在弱内存序的情况下,操作的执行顺序和可见性并不像顺序一致那样直观
  • 程序员很容易误解操作之间的先后关系,导致竞态和难以察觉的bug。
  • 例如在引用计数(reference counting)等场景里,这种错误尤为常见。

小结

弱顺序原子需要对内存序模型有深入理解,才能正确使用。否则很容易出错。

如果你想,我可以帮你举一个引用计数的例子,说明这些顺序约束是如何导致错误的。

这段内容讲的是弱顺序原子操作的第二个陷阱:

陷阱2:release 存储操作可能与顺序一致(seq_cst)加载操作发生重排序!

cpp 复制代码
x.store(true, memory_order_release);
if (!y.load()) 
    turn_EW_lights_green();
y.store(true, memory_order_release);
if (!x.load()) 
    turn_NS_lights_green();
  • 两个线程分别执行上面代码。
  • 虽然 storememory_order_release,保证写操作的顺序性,但 releaseseq_cst 加载之间不一定有强顺序保证
  • SL(store-load)顺序保证只对 全部使用顺序一致(seq_cst)操作有效。
  • 因此,release 存储和顺序一致加载之间可能发生重排序,导致程序行为超出预期。

总结:

弱顺序内存序(release/acquire)不能保证跨线程所有操作的全序执行,某些重排序是允许的,需要特别注意。

这是弱顺序原子操作的第三个坑,和锁的交互有关:

弱顺序原子操作的陷阱3:与锁(mutex)交互时的微妙问题

cpp 复制代码
// 线程1
x.store(true, memory_order_relaxed);
{
  lock_guard<mutex> _(m1);
}
if (!y.load(memory_order_relaxed))
  turn_EW_lights_green();
// 线程2
y.store(true, memory_order_relaxed);
{
  lock_guard<mutex> _(m2);
}
if (!x.load(memory_order_relaxed))
  turn_NS_lights_green();
  • 这段代码中,两个线程分别对 xy 做弱顺序的存储和加载操作。
  • 中间有锁保护的临界区,但临界区本身不保证内存操作的顺序性,因为锁并不是内存栅栏。
  • 因此,内存模型允许把存储操作"移动"到锁的临界区里,或者加载操作"移出"临界区,这样可能导致意想不到的重排序。
  • 如果 m1m2 是不同的互斥锁,锁并不能建立跨线程的同步顺序,因此不会阻止这种重排序。
  • 只有当两个线程都使用同一个互斥锁时,锁才会起到同步内存的作用。

总结:

  • 弱顺序的原子操作与不同锁的结合可能导致顺序和可见性问题。
  • 锁本身不等同于内存栅栏,不能替代内存顺序保证。
  • 需要确保共享数据的访问不仅用锁保护,还需注意内存序,或使用同一锁同步。

强调了弱顺序原子操作相关的陷阱2和陷阱3越来越严重,原因有两个:

传统原因

  • 编译器通常不会在同步操作(如锁、原子操作)之间乱序重排代码。
  • 同步操作一般是用硬件内存栅栏(fence)实现的,能强制保证顺序。

现在情况变了

  • 编译器优化越来越激进,可能会跨同步点重排序代码。
  • ARMv8等现代架构提供了弱顺序的同步指令,不是传统的强制内存屏障,不能保证全序一致性。

结果

  • 程序员不能再依赖传统同步点"天然"阻止重排序。
  • 必须更小心地使用内存序(memory_order),明确指明需要的同步和顺序保证。
    如果需要,我可以帮你总结具体对代码设计的影响,或者给出写安全并发代码的建议。

弱顺序原子操作的第四个陷阱:

依赖(Dependencies)并不能跨线程强制顺序

代码示例:

cpp 复制代码
ptr = x.load(memory_order_relaxed);
if (ptr == y) {
    result = ptr->field.load();
}
  • ptrx 以 relaxed 顺序加载。
  • 条件判断依赖于 ptr 是否等于 y
  • 硬件可能预测条件为真,提前执行 ptr->field.load()
  • 编译器可能直接把 ptr->field 优化成 y->field,忽略了依赖关系。
  • 这样两个加载可能并发发出,第二个加载甚至可能先完成。

关键点

依赖链并不保证跨线程的顺序。

弱顺序模型下,依赖不会强制加载顺序,硬件和编译器都可能重排序。

所以依赖不能当做同步或排序手段

需要保证正确顺序的话,要用更强的内存序(如 memory_order_acquire),或者其他同步机制。

弱顺序原子操作的设计原则和使用建议:

基本原则

  • Acquire-Release(获取-释放)语义
    • 保证在释放(release)存储操作之前的内存操作,在其他线程通过获取(acquire)加载读取该值后,对该线程可见(happens-before关系)。
    • 但其他的操作顺序不能被假定,仍然需要谨慎推理。
  • Relaxed(放宽)语义
    • 只保证对同一内存位置的操作顺序。
    • 无法通过加载值推断其他状态。
    • 因此代码更难做形式验证。
  • Consume(依赖)语义
    • 目前建议避免使用,因为实现复杂且支持不足。

使用建议

  • 弱顺序原子操作非常难用,必须深入理解C++内存模型。
  • 但有一些常见用例和"使用食谱"是被认可的。

这部分讲了几种用弱内存序(memory_order_relaxed / acquire-release)操作原子变量的常见模式:

1. 单字数据结构(Single-word data structures)

  • 如果在修改期间,数据的当前内容不会被其他计算依赖,可以用 memory_order_relaxed
  • 例子:
    • 计数器,只在所有线程结束后读取:counter.fetch_add(1, memory_order_relaxed);
    • 简单位集合,用位运算累积信息:bit_set.fetch_or(1 << new_element, memory_order_relaxed);
  • 关键:不关心即时的读取结果,只要最后正确即可。

2. 计算 compare_exchange 的猜测值

  • 有时加载的值对程序正确性没影响。

  • 例:

    cpp 复制代码
    old = x.load(memory_order_relaxed);
    while (x.compare_exchange_weak(old, foo(old))) {}
  • 其实可替换成:

    cpp 复制代码
    old = 42; // 直接猜测一个值,不必真实加载
  • 说明这里不依赖 load 的实时值。

3. 非竞态访问(Non-racing accesses)

  • 有些原子访问其实不必用原子操作,也不构成竞态。

  • 例:双重检查锁模式(Double-checked locking)

    cpp 复制代码
    atomic<bool> x_init(false);
    if (!x_init) {
        lock_guard<mutex> _(mtx);
        if (!x_init.load(memory_order_relaxed)) {
            initialize x;
            x_init = true;
        }
    }
  • 第一次检查不是原子,第二次是放宽的原子。

4. 简单单向通信(Simple one-way communication)

  • 线程1写入普通变量 x = 17;
  • 然后用 store(true, memory_order_release) 发布初始化完成。
  • 线程2用 load(memory_order_acquire) 读取信号。
  • 确保:只要线程2看到 x_init == true,就能看到写入的 x=17
  • 不保证 store 之前的操作顺序,只保证同步效果。

这部分讲了几个经典的多线程同步场景及其对应的原子操作和内存序使用建议:

双重检查锁(Double-checked locking)

cpp 复制代码
atomic<bool> x_init(false);
if (!x_init.load(memory_order_acquire)) {
    lock_guard<mutex> _(x_init_mtx);
    if (!x_init.load(memory_order_relaxed)) {
        initialize x;
        x_init.store(true, memory_order_release);
    }
}
  • 目的:避免重复初始化,且减少加锁次数。
  • 这里用 acquire 保证后续访问看到初始化的内容。
  • release 在初始化完成后通知其他线程。
  • 第二次检查用 relaxed 是因为持有锁时已保证互斥。

简单自旋锁实现

cpp 复制代码
while (lock.exchange(true, memory_order_acquire)) {}  // 获取锁
lock.store(false, memory_order_release);             // 释放锁
  • 这种自旋锁的内存序保证:
    • 获取锁后,后续操作不会提前执行。
    • 释放锁前,先执行完关键代码。
  • 警告:这是最简单粗暴的自旋锁,实际应用需慎重。

复杂示例1:引用计数

cpp 复制代码
// 增加引用计数
count.fetch_add(1, memory_order_relaxed);
// 减少引用计数,计数到0时释放资源
if (count.fetch_sub(1, memory_order_acq_rel) == 1) {
    deallocate();
}
  • 这里减引用使用 acq_rel,保证本线程对资源的操作对后续线程可见。
  • 客户端保证在最后一次减引用前所有修改都已完成。

引用计数替代写法

cpp 复制代码
if (count.fetch_sub(1, memory_order_release) == 1) {
    atomic_thread_fence(memory_order_acquire);
    deallocate();
}
  • 使用 release + 明确的 acquire 栅栏分离操作。
  • 在某些架构(如 ARMv7、Power)效率更高。
  • x86/ARMv8差别不大。
  • 个人不喜欢栅栏,因为难以阅读和维护。

关于**版本计数(seqlocks)**的同步策略,适用于"写少读多"的场景,且重点在于:

Seqlocks 基本思路

  • 写线程使用版本号version(偶数表示稳定状态,奇数表示正在写入)
  • 写线程写前版本号自增(变为奇数,表示写入开始)
  • 写线程写后版本号再自增(变为偶数,表示写入完成)
  • 读线程:
    • 读版本号 v1
    • 读数据(无锁)
    • 再次读版本号 v2
    • 如果版本号在读数据时变了(或者版本号为奇数,表示写线程正在写),重试。

未优化的读线程示例

cpp 复制代码
do {
  unsigned int v1 = version;
  read_data();
  unsigned int v2 = version;
} while ((v1 & 1) != 0 || v1 != v2);
  • 缺点:数据访问和版本号不是原子同步,可能产生未定义行为

优化后读线程示例

cpp 复制代码
do {
  unsigned int v1 = version.load(memory_order_acquire);
  auto my_data_1 = data_1.load(memory_order_relaxed);
  // 读其它数据
  atomic_thread_fence(memory_order_acquire);
  unsigned int v2 = version.load(memory_order_relaxed);
} while ((v1 & 1) != 0 || v1 != v2);
  • 关键是用atomic的load操作保证数据读写同步。
  • 使用内存栅栏(fence)保证版本号和数据读取顺序。
  • 这个方案比加锁读操作开销小。

总结

  • 弱内存序的原子操作非常复杂且容易出错!
  • 能避免弱内存序时尽量避免。
  • 通常有一小套成熟"套路"能满足大部分需求。
  • 要正确使用复杂的内存序,必须彻底理解C++内存模型。
相关推荐
虾球xz17 分钟前
CppCon 2017 学习:10 Core Guidelines You Need to Start Using Now
开发语言·c++·学习
cainiao08060529 分钟前
基于Python的气象数据分析及可视化研究
开发语言·python·数据分析
南岩亦凛汀31 分钟前
在Linux下使用wxWidgets进行跨平台GUI开发(三)
c++·跨平台·gui·开源框架·工程实战教程
Q_Q196328847534 分钟前
python大学校园旧物捐赠系统
开发语言·spring boot·python·django·flask·node.js·php
星蓝_starblue36 分钟前
利用Java进行验证码的实现——字母数字验证码
java·开发语言
onceco41 分钟前
使用duckduckgo_search python api 进行免费且不限次数的搜索
开发语言·python·搜索引擎
AgilityBaby1 小时前
UE5创建蒙太奇动画并播放和在动画蒙太奇中创建动画通知状态
笔记·学习·ue5·游戏引擎·蓝图·蒙太奇动画
帅_shuai_1 小时前
UE5 游戏模板 —— Puzzle 拼图游戏
c++·游戏·ue5·虚幻引擎
字节高级特工1 小时前
每日一篇博客:理解Linux动静态库
linux·运维·服务器·c语言·c++·windows·ubuntu
oioihoii1 小时前
C++11可变参数模板从入门到精通
前端·数据库·c++