内存模型

photo by Urban Vintage(https://unsplash.com/@urban_vintage?utm_source=templater_proxy&utm_medium=referral) on Unsplash|975x647

内存模型

Data Race 与硬件基础

C11 与 _Atomic

C11 标准引入了 _Atomic 类型关键字来支持原子操作,避免数据竞争。这是 C 语言层面解决并发安全问题的首次标准化尝试。

硬件指令支持

现代处理器提供了一系列底层指令来保证原子性,这些是构建高级并发原语(如互斥锁、原子变量)的基石:

  • 边界对齐
    • 处理器通常保证对齐的内存访问是原子的。例如,读写一个字节、字(16位)、双字(32位)或四字(64位)时,如果地址是对齐的,操作通常不会被打断。
    • 撕裂读写:如果数据跨越了缓存行或页边界(未对齐),处理器可能需要多次总线周期来完成读写,这可能导致“撕裂读写”——即读到了一半的新值和一半的旧值。原子操作必须避免这种情况。
  • LOCK# 前缀
    • 机制:在 x86 架构中,LOCK 前缀会使处理器在指令执行期间发出 LOCK# 信号(低电平有效)。
    • 作用:该信号指示总线控制请求被阻塞,确保在当前指令完成前,其他处理器无法通过总线修改内存。
    • 适用范围INC, DEC, SUB, ADD, BTC, BTS 等算术和逻辑指令可以添加 LOCK 前缀。
    • 特殊指令XCHG 指令自动具有 LOCK 语义,是唯一一个默认锁定的交换指令,无需显式前缀。
    • 缓存优化:如果目标内存位置在处理器的私有缓存中,LOCK 不需要封锁整个系统总线,只需利用缓存一致性协议(如 MESI)锁定该缓存行即可,这大大降低了性能开销。
  • 比较交换 (CMPXCHG)
    • 语法cmpxchg [a], [old], [new]
    • 逻辑:累加器(通常是 AL/AX/EAX/RAX)中的值(隐式 old)与目标操作数 [a] 比较。
      • 如果相等:将 new 写入 [a],并设置 ZF (Zero Flag) 标志位为 1。
      • 如果不等:将 [a] 的当前值加载到累加器中,并清零 ZF。
    • 意义:这是实现无锁算法的核心原语(CAS, Compare-And-Swap)。

代码示例

C 语言原子类型

1
2
_Atomic int a; // is lock free, atomic_is_lock_free 检查是否为无锁实现
_Atomic struct {char a[100];} ss; // is not lock free (通常需要锁机制,因为超出了总线原子操作宽度)

汇编实现自旋锁逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
push rax
push rbx
push r11
push r12
push r13

mov r11, 1 ; new value = 1
mov rax, 0 ; old value = 0

lock cmpxchg [rdx], r11 ; if a == rax (0) then a = r11 (1)
jz .return ; if a == old (0), lock acquired

cli ; a != old, sleep (disable interrupts, context switch logic)

mov rax, LAPIC_START_ADDR
mov dword [rax + 0x320], 0x00010000 ; set timer offset

swapgs
mov rax, qword [gs:8] ; PCB of current
mov rbx, qword [gs:32] ; TCB of current
swapgs

乱序执行

基本概念

指令执行顺序不会严格按照程序顺序串行执行。但在单线程环境下,这种乱序对程序结果是透明的,即遵守 as-if 规则:仿佛指令是按顺序执行的。

实现机制

为了提高指令级并行(ILP),现代处理器通过以下机制实现乱序执行:

  1. Re-Order Buffer (ROB, 重排序缓冲区)
    • 指令被译码后进入 ROB。
    • 处理器从 ROB 中分发指令到执行单元,只要操作数就绪,指令即可执行(乱序)。
    • 提交:指令执行完结果暂存,只有当其之前的所有指令都执行完毕时,该指令才会从 ROB 中“退休”并真正修改架构状态。
    • 外部一致性:由于 ROB 保证了按程序顺序提交,因此在单线程看来,副作用是按顺序发生的。
  2. Store/Write Buffer (写缓冲)
    • 问题:如果写指令必须等待真正写入 L1 Cache(可能需要几十个周期)才能退休,会严重阻塞流水线。
    • 解决:引入 Store Buffer。写指令执行时,结果直接写入 Store Buffer,指令即可立即退休。
    • 副作用:处理器后续的读操作可以直接从 Store Buffer 中读取数据(Store-to-Load Forwarding),但其他处理器此时还看不到这个写操作,直到数据被 Flush 到缓存。

乱序规则

  • 允许
    • Out-of-order read:不同地址的读操作可以乱序。
    • Speculative read:预测执行分支内的读操作。
    • Store-Load 重排:这是最著名的重排类型。写操作进入 Buffer 后立即完成,随后的读操作如果 Cache 命中,可能比写操作刷入缓存更早对全局可见。
  • 不允许
    • 同一地址的写操作之间不能乱序(否则数据会丢失)。
    • 依赖同一地址的读写操作之间不能乱序(RAW 依赖)。

硬件数据流

物理内存系统总线接口缓存存写缓冲器存/取部件执行部件

由于 Store Buffer 的存在,内存顺序(Memory Order,其他核看到的顺序)和 程序顺序(Program Order,代码编写的顺序)会出现不一致。

缓存

  • Cache Line (缓存行):处理器与内存交换数据的最小单位,通常为 64 字节。
  • 行为
    • Read Hit:直接从缓存读取,极快。
    • Write Hit:更新缓存行。
  • 一致性策略
    • Write Back (写回):只更新缓存,不立即写内存。当缓存行被替换时才写回。优点是减少总线流量;缺点是实现一致性的逻辑更复杂。
    • Write Through (写通):更新缓存的同时,直接写内存。优点是实现简单;缺点是总线带宽占用大。
  • 伪共享
    • 如果两个不相关的变量位于同一个缓存行中,且被不同的处理器核修改,就会导致缓存行在两个核之间频繁来回传输(乒乓效应),严重影响性能。这是并发编程优化中需要重点避免的问题(通常通过字节填充 padding 解决)。

多处理器顺序一致性

内存子系统

Store/Write Buffer + Cache + Bus + Memory

内存顺序

  • 定义:系统总线或互连网络安排读写操作访问内存的次序。
  • 顺序一致性 (SC)
    • 理想模型:所有处理器的操作瞬间完成,且有一个全局的时钟。
    • 特征:所有处理器看到的写操作顺序完全一致;读操作一旦确定,也是全局可见的。
    • 代价:需要严格禁止所有乱序优化,性能极差,现代硬件通常不直接实现 SC。

x86 TSO (Total Store Order)

x86 架构采用了一种较弱的内存模型 TSO,它提供了比 SC 更好的性能,同时保留了较强的编程保证。

  • Store Buffer 顺序:Store Buffer 中的写操作进入缓存的顺序,一定会被其他处理器认同(即 Store-Store 不重排)。
  • 重排限制
    • 读操作之间不会被重排(Load-Load 不重排)。
    • 写操作之间不会被重排(Store-Store 不重排)。
    • 读操作不会被重排到它前面的写操作之前(Load-Store 不重排)。
    • 但是写操作可能被重排到它后面的读操作之后(Store-Load 重排)。这是 x86 唯一允许的重排。
  • 屏障指令mfence (Memory Fence) 用于强制刷新 Store Buffer,确保之前的所有写操作都对全局可见,从而防止 Store-Load 重排。
  • Lock 指令lock 前缀指令(如 lock xadd)充当了全屏障,它不经过 Store Buffer,直接操作缓存或内存,且禁止任何指令跨越它进行重排。

缓存一致性协议

在多核系统中,每个核心都有自己的私有缓存。当一个处理器读取某个位置时,必须询问其他处理器是否修改了该内存位置(嗅探,Snooping);必要时从其他处理器获取该内存位置的最新值。当一个处理器写某个位置时,必须通知其它处理器使其缓存失效。

MESI 协议

MESI 是最主流的缓存一致性协议,通过状态机来控制缓存行的生命周期。x86 架构下,状态分别为:Modified (修改), Exclusive (独占), Shared (共享), Invalid (无效)。

处理器 0 处理器 1 处理器 2 说明
mov r, [y] E - - P0 独占读取 y (内存中也有最新备份)
S mov r, [y] S - P1 读取,P0 状态降级为 Shared,P1 为 Shared
I I mov [y], 1 M P2 写入,发出 Invalidate 信号,P0/P1 变为 Invalid
mov [y], 2 M I I P0 写入,P2 状态被 Invalidate (通常通过总线仲裁)
S mov r, [y] S I P1 读取,P0 将数据 Flush 到内存,P0/P1 变为 Shared
S S mov r, [y] S P2 读取,从内存或 P0/P1 获取,全部 Shared

状态转换补充: - M (Modified):该行只存在于当前 Cache,且已被修改,与内存不一致。其他核没有该数据的备份。 - E (Exclusive):该行只存在于当前 Cache,且未被修改,与内存一致。其他核没有该数据的备份。可以直接写入而无需通知其他核(直接转 M)。 - S (Shared):该行可能存在于多个 Cache 中,且与内存一致。写入时必须发出 Invalidate 信号。 - I (Invalid):该行数据无效,使用时必须重新从内存或其他核读取。

ARM/Power 弱内存模型

ARM 和 Power 处理器采用弱内存模型,为了追求极致的性能,它们允许比 x86 更多的指令重排。

  • Store Buffer 顺序:ARM 处理器的 Store Buffer 并不保证连续写操作保持顺序执行。
  • Store-Load 重排:对于先写后读不同内存位置,写入 Store Buffer 和读取操作不保证顺序一致(这一点和 x86 类似,但更激进)。
  • Load-Load / Store-Store 重排:与 x86 不同,ARM 允许读读或写写操作之间的重排。
  • 无全局存序:一组不同内存位置的写操作不存在存全序,不同处理器可能会观察到不同的写入顺序。但是,对同一个内存位置的一组操作存在一个全序。
  • 读操作推迟:处理器可以推迟读操作,直到需要使用这个读操作的结果,因此可能会被指令重排。
  • 屏障指令:ARM 提供了 DMB (Data Memory Barrier) 和 DSB (Data Synchronization Barrier) 来显式控制内存顺序。

顺序一致性

顺序一致性模型要求:防止指令重排 + 防止数据竞争/保证操作的原子性。在软件层面,我们可以通过使用 lock 指令或内存屏障来模拟这一模型。

汇编示例

下面的代码展示了如何通过 lock 指令(同步指令)来实现顺序一致性逻辑。lock 指令充当了全屏障,确保其之前的所有操作都全局可见后,才执行后续操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.aa:
mov r0, 0
mov r1, 1
lock cmpxchg [g], r0, r1 ; 至少两次内存访问,lock 标识为同步指令,锁定总线
jne .aa ; if a != old, retry
mov [x], 1
mov r2, [y]
mov [g], 0

.bb:
mov r5, 0
mov r6, 1
lock cmpxchg [g], r5, r6 ; 至少两次内存访问,lock 标识为同步指令,锁定总线
jne .bb ; if a != old, retry
mov [y], 1
mov r7, [x]
mov [g], 0

C/C++ 内存模型

memory_order_seq_cst

memory_order_seq_cst (Sequentially Consistent) 是 C++ 中默认的内存顺序,也是保证最强的内存顺序。它确保:

  1. 程序顺序:单个线程内的原子操作按照代码顺序执行,不会被重排。
  2. 全局顺序:所有线程看到的所有原子操作都有一个单一的、一致的执行顺序。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <atomic>
#include <thread>
#include <cassert>

std::atomic<int> x = {0};
std::atomic<int> y = {0};

void thread1() {
// 写入 x
x.store(1, std::memory_order_seq_cst);
// 读取 y
int r1 = y.load(std::memory_order_seq_cst);
}

void thread2() {
// 写入 y
y.store(1, std::memory_order_seq_cst);
// 读取 x
int r2 = x.load(std::memory_order_seq_cst);
}

int main() {
std::thread t1(thread1);
std::thread t2(thread2);
t1.join();
t2.join();

// 在顺序一致性 (SC) 下,不可能出现 r1 == 0 且 r2 == 0 的情况。
// 因为这意味着两个线程都认为对方的写操作发生在自己写操作之前,
// 这在单一全序中是逻辑矛盾的。
}

执行流程图解

下图展示了 memory_order_seq_cst 如何建立一个全局的单一全序。这防止了 Store-Load 重排(在 x86 TSO 模型下,如果不加 fence 指令,这种重排是可能发生的)。

sequenceDiagram
    autonumber
    participant T1 as Thread 1
    participant T2 as Thread 2
    participant Global as 全局内存顺序

    Note over T1, T2: 初始状态: x=0, y=0

    rect rgb(240, 248, 255)
        Note right of T1: 执行 x.store(1)
        T1->>Global: W(x, 1)
        activate Global
        Note right of Global: 全局顺序确立: W(x, 1) 先发生
        Global-->>T2: W(x, 1) 对 T2 立即可见
        deactivate Global
    end

    rect rgb(240, 255, 240)
        Note right of T2: 执行 y.store(1)
        T2->>Global: W(y, 1)
        activate Global
        Note right of Global: 全局顺序确立: W(y, 1) 后发生
        Global-->>T1: W(y, 1) 对 T1 立即可见
        deactivate Global
    end

    T1->>T1: 读取 y (看到 1)
    T2->>T2: 读取 x (看到 1)

    Note over T1, T2: 结果: r1=1, r2=1<br/>不可能出现 r1=0 且 r2=0
  • 硬件代价:由于需要保证最强的全局一致性,编译器通常会生成对应的 CPU 屏障指令(如 x86 的 mfence 或 ARM 的 dmb ish),以防止 Store-Load 重排,因此性能开销相对较大。
  • 对比 x86 TSO:正如笔记中提到的,x86 硬件只保证 Store-Store 和 Load-Load/Load-Store 不重排,但允许 Store-Load 重排。memory_order_seq_cst 在 x86 上通常会在 Store 后插入屏障指令,以模拟严格的顺序一致性。

C/C++ 内存模型:memory_order_release / acquire

memory_order_releasememory_order_acquire 配对使用,用于建立“同步”关系,保证一个线程中的写操作对另一个线程可见。

  • Release (写):保证在此操作之前的所有写操作(包括非原子变量)都不会被重排到此操作之后。它“释放”之前的修改。
  • Acquire (读):保证在此操作之后的所有读操作都不会被重排到此操作之前。如果读到了 Release 写入的值,它“获取”之前的所有修改。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <atomic>
#include <thread>
#include <cassert>
#include <iostream>

std::atomic<bool> ready{false};
int data = 0; // 非原子共享变量

void writer() {
// 1. 准备数据
data = 42;

// 2. 发布数据
// Release 语义:保证 data=42 不会被重排到这条语句之后
// 同时保证 data=42 的修改对其他看到 ready=true 的线程可见
ready.store(true, std::memory_order_release);
}

void reader() {
// 3. 等待数据发布
// Acquire 语义:保证读取 data 不会被重排到这条语句之前
while (!ready.load(std::memory_order_acquire)) {
// 自旋等待
}

// 4. 读取数据
// 如果走到了这里,说明 ready.load 读到了 true (看到了 writer 的 release)
// 此时一定能看到 data == 42,绝不会看到 data == 0
std::cout << "Data: " << data << std::endl;
assert(data == 42);
}

int main() {
std::thread t1(writer);
std::thread t2(reader);
t1.join();
t2.join();
}

执行流程图解

下图展示了 Release-Acquire 如何建立“先发生”关系,从而防止指令重排并保证数据可见性。

sequenceDiagram
    autonumber
    participant T1 as Writer Thread
    participant Mem as 共享内存
    participant T2 as Reader Thread

    Note over T1, T2: 初始状态: ready=false, data=0

    rect rgb(255, 245, 238)
        Note right of T1: 1. 写入普通变量
        T1->>Mem: data = 42
    end

    rect rgb(238, 232, 255)
        Note right of T1: 2. Release Store
        T1->>Mem: ready.store(true, release)
        Note right of T1: 屏障: 防止 data=42<br/>被重排到 store 之后
    end

    rect rgb(232, 245, 233)
        Note right of T2: 3. Acquire Load
        T2->>Mem: ready.load(acquire)
        Note right of T2: 屏障: 防止读取 data<br/>被重排到 load 之前
    end

    Mem-->>T2: 返回 true

    Note over T2: 同步点建立
    Note right of T2: T2 看到了 T1 的 Release 操作<br/>因此也能看到 T1 之前的所有修改

    rect rgb(255, 250, 205)
        Note right of T2: 4. 读取普通变量
        T2->>Mem: 读取 data
        Mem-->>T2: 返回 42
    end

    Note over T1, T2: 结果: T2 必定看到 data=42<br/>不会看到 data=0
  • 配对使用store(..., release) 必须与对应的 load(..., acquire) 配对才能生效。

  • 非原子变量保护:这是 C++ 内存模型的核心能力之一。它允许我们使用原子变量(如 ready)作为“哨兵”,来保护对非原子变量(如 data)的读写,而不需要将所有变量都声明为原子类型。

  • 硬件代价

    • x86:由于 x86 本身保证了较强的内存顺序(Load-Load, Load-Store, Store-Store 不重排),acquirerelease 通常不需要额外的屏障指令(编译器层面的优化即可)。
    • ARM/Power:需要插入对应的屏障指令(如 Store-Release STLR 和 Load-Acquire LDAR)来防止弱内存模型下的重排。

C/C++ 内存模型:memory_order_relaxed

memory_order_relaxed 是最弱的内存顺序。它只保证操作的原子性,不保证任何顺序约束,也不建立同步关系。

  • 原子性:保证读写操作是不可分割的,不会发生“撕裂读写”。
  • 无序性:不保证不同线程看到的操作顺序一致。
  • 无同步:不包含内存屏障指令,不阻止指令重排。

示例代码

relaxed 通常用于计数器、统计信息等场景,即只关心最终结果的正确性,而不关心操作发生的相对顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>

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

void increment() {
for (int i = 0; i < 1000; ++i) {
// Relaxed: 仅保证原子性递增
// 不保证此操作对其他内存变量的可见性顺序
// 也不保证其他线程能立即看到更新(尽管通常很快)
counter.fetch_add(1, std::memory_order_relaxed);
}
}

int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment);
}

for (auto& t : threads) {
t.join();
}

// 结果一定是 10000,因为对单个变量的修改顺序是保证的
std::cout << "Final counter value: " << counter << std::endl;
}

执行流程图解

下图展示了 relaxed 操作如何独立执行,线程之间不建立同步点,也不保证全局顺序。

sequenceDiagram
    autonumber
    participant T1 as Thread 1
    participant T2 as Thread 2
    participant Mem as Atomic Counter

    Note over T1, T2: 初始状态: counter = 0

    rect rgb(255, 240, 245)
        Note right of T1: fetch_add(1, relaxed)
        T1->>Mem: 写入 1
        Note right of T1: 无屏障<br/>不保证对 T2 立即可见
    end

    rect rgb(240, 245, 255)
        Note right of T2: fetch_add(1, relaxed)
        T2->>Mem: 写入 2
        Note right of T2: 无屏障<br/>可能与 T1 并发或乱序
    end

    Note over Mem: 最终结果: counter = 2
    Note over T1, T2: 特点:<br/>1. 仅保证原子性<br/>2. 线程间无同步<br/>3. 不阻止指令重排
  • 性能最优:由于不需要任何内存屏障指令(Fence),CPU 可以自由乱序执行或缓存优化,因此速度最快。

  • 修改顺序一致性:虽然不保证跨线程的顺序,但对于同一个原子变量,所有线程依然认同一个修改顺序,这保证了计数器等逻辑的正确性。

  • 适用场景

    • 简单的计数器(如 std::shared_ptr 的引用计数)。
    • 统计数据收集。
    • 不适用:用于传递数据或控制其他变量的状态(例如 ready 标志位),这需要 release/acquireseq_cst

C/C++ 内存模型:memory_order_acq_rel

memory_order_acq_rel 结合了 acquirerelease 的语义。它只能用于读-改-写(Read-Modify-Write, RMW)操作,例如 fetch_addexchangecompare_exchange

  • Acquire 语义:保证在此操作之后的读/写操作不会被重排到之前
  • Release 语义:保证在此操作之前的读/写操作不会被重排到之后
  • 双向屏障:它同时充当了当前线程的入口和出口屏障。

示例代码

acq_rel 常用于实现自旋锁。在获取锁时,我们需要 acquire 语义来看到之前持有者修改的数据;在释放锁时,我们需要 release 语义来让我们的修改对下一个持有者可见。由于 compare_exchange 是一个原子操作,使用 acq_rel 可以同时满足这两种需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <atomic>
#include <thread>
#include <iostream>
#include <vector>

std::atomic<int> lock_flag{0}; // 0 = unlocked, 1 = locked
int shared_data = 0;

void critical_section(int id) {
// 尝试获取锁
int expected = 0;

// 使用 compare_exchange_strong (RMW 操作)
// memory_order_acq_rel:
// 1. Acquire: 如果成功获取锁,防止后续代码重排到锁之前(确保看到正确的数据)。
// 2. Release: 如果成功获取锁(即修改了锁状态),防止之前的代码重排到锁之后。
// (注:在锁的实现中,获取锁主要是 Acquire,释放锁主要是 Release。
// 但对于 RMW 操作,acq_rel 提供了最严格的同步保证)。
while (!lock_flag.compare_exchange_strong(expected, 1, std::memory_order_acq_rel)) {
expected = 0; // 如果失败,重置期望值并重试
}

// --- 临界区开始 ---
std::cout << "Thread " << id << " is in critical section." << std::endl;
shared_data += id; // 修改共享数据
// --- 临界区结束 ---

// 释放锁
// 这里只需要 Release 语义,确保临界区内的修改对其他线程可见
lock_flag.store(0, std::memory_order_release);
}

int main() {
std::vector<std::thread> threads;
for (int i = 1; i <= 3; ++i) {
threads.emplace_back(critical_section, i);
}

for (auto& t : threads) {
t.join();
}
}

执行流程图解

下图展示了 acq_rel 在 RMW 操作中如何作为双向屏障工作,确保临界区内的操作既不会“逃逸”到锁之前,也不会有外部操作“入侵”到锁之后。

sequenceDiagram
    autonumber
    participant T1 as Thread 1 (Holder)
    participant T2 as Thread 2 (Waiter)
    participant Lock as Atomic Lock

    Note over T1, T2: 初始状态: lock=0

    rect rgb(255, 245, 238)
        Note right of T1: 临界区操作
        T1->>T1: 修改 shared_data
    end

    rect rgb(238, 232, 255)
        Note right of T1: 释放锁
        T1->>Lock: store(0, release)
        Note right of T1: Release 屏障<br/>确保 shared_data 修改已提交
    end

    rect rgb(232, 245, 233)
        Note right of T2: 尝试获取锁
        T2->>Lock: compare_exchange(0->1, acq_rel)
        Note right of T2: Acquire-Release 屏障<br/>1. Acquire: 防止后续读上移<br/>2. Release: 防止之前写下移
    end

    Lock-->>T2: 返回成功 (获取锁)

    Note over T2: 同步点建立
    Note right of T2: T2 现在可以看到 T1<br/>在临界区内的所有修改

    rect rgb(255, 250, 205)
        Note right of T2: 进入临界区
        T2->>T2: 读取/修改 shared_data
    end
  • 仅限 RMW 操作memory_order_acq_rel 只能用于 fetch_addexchangecompare_exchange 等读-改-写操作,不能用于单纯的 loadstore

  • 双向同步:它同时拥有 acquirerelease 的特性。这意味着它既与之前的 release 操作同步,也与后续的 acquire 操作同步。

  • 典型应用

    • 自旋锁:如示例所示,在原子获取锁的同时建立同步关系。
    • 无锁数据结构:在更新节点指针时,确保既能看到旧数据的最新状态,又能让新数据对后续读者可见。
  • 硬件代价:在弱内存模型架构(如 ARM)上,这通常需要生成成对的屏障指令(如 dmb ish),开销比单纯的 acquirerelease 要大。在 x86 上,通常只需要 lock 前缀指令即可满足。

C/C++ 内存模型:memory_order_consume

memory_order_consume 是一种极其特殊的内存顺序,旨在优化“指针追逐”场景。它是 memory_order_acquire 的一个更弱版本。

  • 依赖序:它只保证依赖于加载值的操作(如解引用指针、访问结构体成员)不会被重排到加载之前。
  • 无副作用屏障:它阻止不依赖于该值的操作(如读取其他全局变量)被重排。
  • 目的:在理论上允许 CPU 和编译器在处理数据依赖时进行更激进的优化,而不需要昂贵的全屏障指令。

示例代码

在这个例子中,consumer 线程读取一个指向结构体的指针。只有当它访问该指针指向的数据(p->data)时,才需要同步保证。访问其他无关变量(other_var)则不需要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <atomic>
#include <thread>
#include <iostream>
#include <cassert>

struct DataNode {
int data;
};

std::atomic<DataNode*> ptr{nullptr};
int other_var = 0; // 与指针没有数据依赖的变量

void producer() {
DataNode* node = new DataNode{42};

// 1. 修改无关变量
other_var = 99;

// 2. 发布指针
// Release 语义:确保 node->data 和 other_var 的修改对其他线程可见
ptr.store(node, std::memory_order_release);
}

void consumer() {
// 3. 加载指针
// Consume 语义:建立依赖序
DataNode* p = ptr.load(std::memory_order_consume);

if (p) {
// 4. 访问依赖数据
// 因为 p->data 依赖于 p,所以这里一定能看到 producer 中写入的 42
std::cout << "Data: " << p->data << std::endl;
assert(p->data == 42);

// 5. 访问无关数据
// 注意:other_var 的读取不依赖于 p。
// 在 consume 语义下,这里**不一定**能看到 99!
// (如果是 acquire,这里一定能看到 99)
std::cout << "Other var: " << other_var << std::endl;
}
}

int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
}

执行流程图解

下图展示了 consume 如何仅沿着“数据依赖链”建立同步,而不像 acquire 那样建立全屏障。

sequenceDiagram
    autonumber
    participant T1 as Producer
    participant Mem as 共享内存
    participant T2 as Consumer

    Note over T1, T2: 初始状态: ptr=nullptr

    rect rgb(255, 245, 238)
        Note right of T1: 1. 准备数据
        T1->>Mem: node->data = 42
        T1->>Mem: other_var = 99
    end

    rect rgb(238, 232, 255)
        Note right of T1: 2. Release Store
        T1->>Mem: ptr.store(node, release)
        Note right of T1: 屏障: 防止之前的写<br/>被重排到 store 之后
    end

    rect rgb(232, 245, 233)
        Note right of T2: 3. Consume Load
        T2->>Mem: p = ptr.load(consume)
        Note right of T2: 依赖屏障: 仅保护<br/>依赖于 p 的操作
    end

    Mem-->>T2: 返回 node 地址

    Note over T2: 依赖链建立
    
    rect rgb(255, 250, 205)
        Note right of T2: 4. 访问依赖数据
        T2->>Mem: 读取 p->data
        Note right of T2: ✅ 保证看到 42<br/>(因为依赖于 p)
        Mem-->>T2: 返回 42
    end

    rect rgb(240, 240, 240)
        Note right of T2: 5. 访问无关数据
        T2->>Mem: 读取 other_var
        Note right of T2: ❌ 不保证看到 99<br/>(因为不依赖于 p)
        Mem-->>T2: 可能返回 0
    end
  • 数据依赖consume 的核心在于“依赖序”。只有当后续操作的结果依赖于原子加载的结果(例如 *ptrarr[index])时,同步才生效。

  • 理论性能优势:在 DEC Alpha 等架构上,或者理论上在 ARM/POWER 的某些弱内存模型场景下,CPU 可以通过跟踪数据依赖来避免昂贵的刷新缓存或流水线停顿。

  • 编译器现实

    • 由于 C++ 编译器很难证明代码中的“依赖关系”(编译器优化可能会破坏依赖链,比如将值缓存到寄存器中),目前大多数主流编译器(GCC, Clang, MSVC)为了安全起见,都会将 memory_order_consume 降级为 memory_order_acquire 处理。
    • 这意味着在实际应用中,consume 很少能带来预期的性能提升,通常建议直接使用 acquire 以保证代码的可移植性和可维护性。
  • 适用场景

    • 极其底层的无锁数据结构,如无锁链表或哈希表的节点遍历。
    • 引用计数(虽然通常 relaxed 足以用于计数本身,但销毁对象时可能涉及依赖序)。

这是关于 Happens-Before (前发关系) 的解释、示例代码和图解。它是理解 C++ 内存模型如何保证多线程程序正确性的核心理论,您可以将其添加到 内存模型 笔记的 C/C++ 内存模型部分。

Happens-Before 关系

Happens-Before 是 C++ 内存模型中用于定义操作执行顺序的一个偏序关系。简单来说,如果操作 A happens-before 操作 B,那么:

  1. A 的执行结果对 B 可见。
  2. A 的副作用(如修改内存)在 B 开始执行前已经完成。

它是判断程序是否存在 Data Race (数据竞争) 的唯一标准:如果两个线程访问同一个非原子变量,且至少有一个是写操作,且它们之间不存在 happens-before 关系,则发生数据竞争,程序行为未定义。

关键组成部分

Happens-Before 关系由以下两部分组合而成:

  1. Sequenced-Before (顺序先行,前序)

    • 单线程内:同一个线程内的操作按照代码顺序(as-if rule)执行。如果语句 A 在代码中位于语句 B 之前,则 A sequenced-before B。
  2. Synchronizes-With (同步于)

    • 多线程间:这是原子操作提供的跨线程同步。例如,线程 A 对原子变量执行 store(release),而线程 B 读取该值并执行 load(acquire),那么 A 的 store synchronizes-with B 的 load。

传递性

Happens-Before 关系具有传递性

  • 如果 A sequenced-before B
  • 且 B synchronizes-with C
  • 且 C sequenced-before D
  • 则:A happens-before D

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <atomic>
#include <thread>
#include <cassert>

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

void writer() {
// 1. 写入非原子变量
data = 42;

// 2. 原子写入
// A sequenced-before B
ready.store(true, std::memory_order_release);
}

void reader() {
// 3. 原子读取
// B synchronizes-with C (如果读到 true)
while (!ready.load(std::memory_order_acquire)) {
// 自旋
}

// 4. 读取非原子变量
// C sequenced-before D
// 结论: A happens-before D
// 因此,这里一定能看到 data == 42
assert(data == 42);
}

int main() {
std::thread t1(writer);
std::thread t2(reader);
t1.join();
t2.join();
}

执行流程图解

下图展示了 Happens-Before 如何通过原子操作将单线程的顺序关系扩展到多线程之间,形成完整的同步链。

sequenceDiagram
    autonumber
    participant T1 as Thread 1 (Writer)
    participant Atomic as Atomic Variable
    participant T2 as Thread 2 (Reader)

    Note over T1, T2: 目标: 建立 data=42 对 T2 的可见性

    rect rgb(255, 245, 238)
        Note right of T1: 操作 A: data = 42
        T1->>T1: 写入普通变量
    end

    Note right of T1: 关系: A sequenced-before B

    rect rgb(238, 232, 255)
        Note right of T1: 操作 B: store(true, release)
        T1->>Atomic: 写入原子变量
    end

    Note over Atomic: 关系: B synchronizes-with C<br/>(跨线程同步点)

    rect rgb(232, 245, 233)
        Note right of T2: 操作 C: load(acquire)
        T2->>Atomic: 读取原子变量
        Atomic-->>T2: 返回 true
    end

    Note right of T2: 关系: C sequenced-before D

    rect rgb(255, 250, 205)
        Note right of T2: 操作 D: assert(data == 42)
        T2->>T2: 读取普通变量
    end

    Note over T1, T2: 结论: A happens-before D<br/>保证 data=42 对 T2 可见
  • 核心作用:Happens-Before 是连接单线程代码顺序和多线程原子操作的桥梁。
  • 避免数据竞争:只要所有对共享变量的访问都通过 Happens-Before 关系有序化,程序就是数据竞争无的,其行为符合预期。
  • 弱内存模型的意义:在 ARM/POWER 等弱内存模型架构上,CPU 可能会乱序执行指令。Happens-Before 关系强制编译器和 CPU 在特定的同步点(如 acquire/release)插入必要的屏障指令,确保逻辑上的顺序性。

C/C++ 原子操作:atomic_store/load_explicit

在 C++ 中,我们通常使用成员函数(如 x.store()x.load())来操作原子变量。但在 C 语言中,没有“成员函数”的概念,因此 C11 标准引入了一组自由函数。C++11 为了兼容 C 和支持泛型编程,也保留了这组接口。

_explicit 后缀意味着调用者必须显式指定内存顺序参数。

函数签名

1
2
3
// C11 / C++11
void atomic_store_explicit(volatile A* obj, C desired, memory_order order);
C atomic_load_explicit(const volatile A* obj, memory_order order);
  • obj: 指向原子对象的指针。
  • desired: 要写入的值。
  • order: 内存顺序(如 memory_order_relaxed, memory_order_release 等)。

与成员函数的对比

特性 成员函数 (C++ Style) 自由函数 (C Style / Explicit)
语法 obj.store(val, order) atomic_store_explicit(&obj, val, order)
语言支持 仅 C++ C 和 C++
默认参数 order 可选,默认为 seq_cst order 必须显式提供
使用场景 面向对象编程,常规 C++ 代码 C 语言代码、泛型编程、C/C++ 互操作

示例代码

下面的代码展示了如何使用这两种方式完成相同的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <atomic>
#include <iostream>
#include <thread>

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

void writer_cpp_style() {
// C++ 风格:使用成员函数
// 如果不写第二个参数,默认为 memory_order_seq_cst
data.store(42, std::memory_order_release);
}

void writer_c_style() {
// C 风格:使用自由函数
// 必须显式传入 data 的地址和内存顺序
std::atomic_store_explicit(&data, 42, std::memory_order_release);
}

void reader() {
int expected = 0;

// 等待数据被写入
do {
// C++ 风格读取
expected = data.load(std::memory_order_acquire);

// 或者使用 C 风格读取
// expected = std::atomic_load_explicit(&data, std::memory_order_acquire);
} while (expected == 0);

std::cout << "Read value: " << expected << std::endl;
}

int main() {
std::thread t1(writer_cpp_style);
std::thread t2(reader);
t1.join();
t2.join();

// 演示 C 风格写入
writer_c_style();
std::cout << "Final value: " << std::atomic_load_explicit(&data, std::memory_order_relaxed) << std::endl;
}

执行流程图解

下图展示了成员函数与自由函数在调用层面的区别。虽然底层生成的机器码通常是一样的,但接口形式不同。

graph LR
    subgraph CppStyle ["C++ 成员函数风格"]
        A1[原子对象 obj] -->|调用| B1[obj.store]
        B1 -->|隐式 this 指针| C1[写入内存]
    end

    subgraph CStyle ["C 自由函数风格"]
        A2[原子对象 obj] -->|取地址 &obj| B2[atomic_store_explicit]
        B2 -->|显式指针参数| C2[写入内存]
    end
    
    C1 --> D[硬件指令<br/>MOV / XCHG]
    C2 --> D

关键点总结

  • 显式控制_explicit 版本强制你思考并指定内存顺序,这有助于编写高性能代码(避免默认的 seq_cst 开销),也能让代码意图更清晰。

  • C 兼容性:这是 C 语言进行并发编程的标准方式。

  • 泛型编程:在编写 C++ 模板时,如果类型 T 可能是原子类型也可能是普通类型,或者你需要处理指向原子变量的指针,自由函数接口会更加灵活。

  • _explicit 版本

    • C 语言中也有 atomic_store(obj, desired),它等价于 atomic_store_explicit(obj, desired, memory_order_seq_cst)
    • C++ 中通常直接使用成员函数的默认参数。

这是关于 原子操作的一致性规则 的详细解释,涵盖了读读、写写和读写三种场景。核心在于理解 C++ 内存模型如何通过 原子性修改顺序 来保证一致性,您可以将其添加到 内存模型 笔记中。

原子操作的一致性规则

C++ 内存模型为原子操作提供了核心的一致性保证,这主要依赖于两个概念:

  1. 原子性:操作不可分割,不会发生“撕裂读写”。
  2. 修改顺序:对于每一个原子变量,所有线程都认同一个单一的、全局的修改顺序。

1. 读读

场景:两个或多个线程同时读取同一个原子变量。

  • 一致性保证

    • 无撕裂:每个线程读到的值都是该原子变量曾经写入过的完整值,绝不会读到“写了一半”的中间状态。
    • 允许差异:不同线程不一定读到相同的值。由于线程执行速度和调度不同,它们可能看到修改顺序中不同时间点的快照。
  • 结论:读读操作是安全的,不会导致数据竞争,但允许读到“陈旧”的值。

2. 写写

场景:两个或多个线程同时写入同一个原子变量。

  • 一致性保证

    • 无撕裂:最终内存中只会存在一个完整的写入值,不会出现两个写入值混合的情况。
    • 单一全序:所有线程(包括写入线程自己)都认同这些写操作发生的先后顺序。例如,如果线程 A 写入 1,线程 B 写入 2,那么所有线程要么看到“先 1 后 2”,要么看到“先 2 后 1”,绝不会出现 A 认为自己先写而 B 认为自己先写的情况。
    • 胜者通吃:在修改顺序中“最后”发生的写操作,其值将成为最终值。
  • 结论:写写操作是安全的,最终结果确定,且所有线程对写入顺序达成共识。

3. 读写

场景:一个线程写入原子变量,另一个线程读取该变量。

  • 一致性保证

    • 无撕裂:读操作要么读到旧值,要么读到新值,绝不会读到未定义的垃圾数据。
    • 修改顺序约束:读操作保证读到修改顺序中某一个写入的值。它不会读到“未来”的值(即修改顺序中尚未发生的写入)。
    • 可见性:如果没有使用 acquire/releaseseq_cst 等同步机制,读操作不一定能立即看到最新的写操作(可能读到旧值)。
  • 结论:读写操作是安全的(无数据竞争),但值的可见性取决于使用的内存顺序。


一致性规则对比表

场景 原子性保证 顺序保证 可见性保证 潜在问题
读读 ✅ 无撕裂 ✅ 认同修改顺序 ❌ 允许读到不同快照 线程间数据可能不一致(陈旧)
写写 ✅ 无撕裂 ✅ 单一全序 (Total Order) ✅ 最终值一致 竞态条件(谁最后写)
读写 ✅ 无撕裂 ✅ 读到顺序中的某值 ⚠️ 取决于 memory_order 可能读到旧值 (Relaxed)

执行流程图解:修改顺序

下图展示了“修改顺序”如何作为单一真理来源,协调不同线程的读写操作。

sequenceDiagram
    autonumber
    participant T1 as Thread 1 (Writer)
    participant T2 as Thread 2 (Writer)
    participant T3 as Thread 3 (Reader)
    participant MO as 修改顺序

    Note over MO: 初始状态: x = 0

    rect rgb(255, 245, 238)
        Note right of T1: 写入 1
        T1->>MO: W(x, 1)
        Note right of MO: 顺序确立: W(1) 在先
    end

    rect rgb(238, 232, 255)
        Note right of T2: 写入 2
        T2->>MO: W(x, 2)
        Note right of MO: 顺序确立: W(2) 在后
    end

    Note over MO: 全局共识: x 从 0 变为 1,再变为 2

    rect rgb(232, 245, 233)
        Note right of T3: 读取 x
        T3->>MO: R(x)
        Note right of T3: 可能读到 0, 1, 或 2<br/>取决于同步时机
        MO-->>T3: 返回值
    end

    Note over T1, T3: 关键点:<br/>1. T3 绝不会读到“撕裂”的数据<br/>2. T1 和 T2 都认同 W(1) 在 W(2) 之前<br/>3. 如果没有同步,T3 可能读到 0 或 1 (陈旧值)

关键点总结

  • 修改顺序是核心:无论使用 relaxed 还是 seq_cst,对于同一个原子变量,所有线程看到的修改顺序是一致的。这是硬件缓存一致性协议(如 MESI)在软件层面的抽象。

  • 原子性是基础:硬件层面的 LOCK 指令或缓存行锁定保证了读写操作的原子性,消除了“撕裂读写”的风险。

  • 内存顺序决定可见性

    • Relaxed:只保证原子性和修改顺序,不保证读操作能立即看到写操作(允许读写乱序)。
    • Acquire/Release:在特定点建立同步,保证写操作对读操作可见。
    • Seq_cst:保证所有原子变量之间存在一个全局的单一全序,防止所有类型的乱序。

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!