原子操作的内存顺序


📋 前置准备

所有代码均使用 C++17,在 Linux 下用 g++ 编译。请确保安装了编译工具链:

bash 复制代码
sudo apt install g++

通用编译命令模板

bash 复制代码
# 普通编译(用于看正常逻辑)
g++ -std=c++17 -pthread -o demo demo.cpp

# 带 ThreadSanitizer 编译(用于抓数据竞争)
g++ -std=c++17 -pthread -fsanitize=thread -g -o demo_tsan demo.cpp

示例 1:std::memory_order_relaxed (纯原子计数器)

场景:只需要保证"自增"操作本身是原子的,不需要线程间同步其他数据。

1.1 完整代码 (demo1_relaxed.cpp)

cpp 复制代码
#include <atomic>
#include <thread>
#include <iostream>
#include <cassert>

// 全局原子计数器
std::atomic<int> g_counter(0);

// 线程函数:循环自增 100 万次
void increment_1m_times() {
    for (int i = 0; i < 1000000; ++i) {
        // 关键点:使用 relaxed
        // 只保证 fetch_add 是原子的,不保证任何内存顺序
        g_counter.fetch_add(1, std::memory_order_relaxed);
    }
}

int main() {
    std::cout << "Start (Relaxed)..." << std::endl;

    // 启动两个线程同时自增
    std::thread t1(increment_1m_times);
    std::thread t2(increment_1m_times);

    t1.join();
    t2.join();

    // 验证结果
    int result = g_counter.load(std::memory_order_relaxed);
    std::cout << "Final counter: " << result << std::endl;
    
    // Relaxed 保证了原子性,结果一定是 2000000
    assert(result == 2000000); 
    
    return 0;
}

1.2 编译与运行

bash 复制代码
g++ -std=c++17 -pthread -o demo1 demo1_relaxed.cpp
./demo1

输出

text 复制代码
Start (Relaxed)...
Final counter: 2000000

1.3 一步一步详解

  1. 原子性保证fetch_add(1, relaxed) 确保了"读取-修改-写入"这三步是不可分割的。如果这里用的是普通 int g_counterg_counter++,结果会小于 200万。
  2. 无顺序保证:虽然结果正确,但线程 A 并不知道线程 B 已经自增到哪一步了。它们只是各算各的,最后汇总。
  3. 性能最高:这是最快的内存序,因为 CPU 和编译器可以自由重排指令。

示例 2:std::memory_order_release & std::memory_order_acquire (生产者-消费者)

场景 :线程 A 准备数据,线程 B 读取数据。我们需要保证:B 看到"就绪信号"时,一定能看到 A 准备好的数据

2.1 完整代码 (demo2_release_acquire.cpp)

cpp 复制代码
#include <atomic>
#include <thread>
#include <iostream>
#include <cassert>

// 普通共享数据(非原子)
int g_data = 0;
// 原子信号量
std::atomic<bool> g_ready(false);

void producer() {
    // 步骤 1: 先写数据(这是普通的非原子操作)
    g_data = 42; 
    
    std::cout << "Producer: Data prepared." << std::endl;

    // 步骤 2: 发布信号 (使用 Release)
    // 🔑 关键规则:g_data = 42 绝对不会被重排到这行 store 之后
    g_ready.store(true, std::memory_order_release);
}

void consumer() {
    // 步骤 1: 自旋等待信号 (使用 Acquire)
    while (!g_ready.load(std::memory_order_acquire)) {
        // 空转等待
    }

    std::cout << "Consumer: Signal received." << std::endl;

    // 步骤 2: 读取数据
    // 🔑 关键规则:因为上面是 Acquire,这里一定能看到 g_data = 42
    assert(g_data == 42); 
    std::cout << "Consumer: Data is " << g_data << " (Success!)" << std::endl;
}

int main() {
    std::thread t_prod(producer);
    std::thread t_cons(consumer);

    t_prod.join();
    t_cons.join();
    
    return 0;
}

2.2 错误示范 (如果误用 Relaxed)

我们把上面代码中的 releaseacquire 都改成 relaxed,保存为 demo2_wrong.cpp

在 x86 CPU 上直接运行,你可能依然会看到 Success (因为 x86 硬件是强内存模型 TSO,自动帮你做了很多事)。但这是错的! 我们用 ThreadSanitizer 来抓它:

bash 复制代码
g++ -std=c++17 -pthread -fsanitize=thread -g -o demo2_wrong_tsan demo2_wrong.cpp
./demo2_wrong_tsan

TSAN 输出 (关键部分)

text 复制代码
WARNING: ThreadSanitizer: data race (pid=12345)
  Read of size 4 at 0x55c... by thread T2:
    #0 consumer() demo2_wrong.cpp:32
    #1 ...

  Previous write of size 4 at 0x55c... by thread T1:
    #0 producer() demo2_wrong.cpp:12
    #1 ...

这就证明了:用 Relaxed 会导致数据竞争g_data 的读写没有被正确同步。

2.3 一步一步详解

  1. Release (写者):像是"关门"动作。我在关门(store)之前做的所有事(写 data),都必须在关门之前完成。
  2. Acquire (读者):像是"开门"动作。我开了门(load 到 true)之后,门里的东西(data)我就都能看见了。
  3. 配对使用:这是无锁编程中最常用的"黄金组合",性能仅次于 Relaxed,但安全性极高。

示例 3:std::memory_order_acq_rel (获取释放,用于 RMW)

场景 :当一个操作既是"读者"又是"写者"时(即 Read-Modify-Write, RMW 操作),比如 fetch_addexchangecompare_exchange

例子:我们用原子操作实现一个简单的"接力棒"传递。

3.1 完整代码 (demo3_acq_rel.cpp)

cpp 复制代码
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> g_baton(0); // 0: 没人拿, 1: 线程1拿, 2: 线程2拿
int g_shared_value = 0;

void thread1_work() {
    g_shared_value = 100; // 先干活
    
    // 🔑 RMW 操作:把 0 换成 1
    // 使用 acq_rel:
    // 1. "Acquire" 部分:确保我们看到了之前的状态 (0)
    // 2. "Release" 部分:确保 g_shared_value = 100 对下一个拿到的人可见
    int expected = 0;
    while (!g_baton.compare_exchange_weak(expected, 1, std::memory_order_acq_rel)) {
        expected = 0; // 失败了重置 expected
    }
    std::cout << "Thread 1: Passed the baton." << std::endl;
}

void thread2_work() {
    // 等待拿到 baton (1),然后设为 2
    int expected = 1;
    while (!g_baton.compare_exchange_weak(expected, 2, std::memory_order_acq_rel)) {
        expected = 1;
    }
    
    // 因为 Thread 1 用了 Release,这里用了 Acquire,所以能看到 100
    std::cout << "Thread 2: Got baton. Shared value = " << g_shared_value << std::endl;
}

int main() {
    std::thread t1(thread1_work);
    std::thread t2(thread2_work);
    
    t1.join();
    t2.join();
    return 0;
}

3.2 一步一步详解

  1. RMW 的特殊性compare_exchange 先读(看是不是 expected),再写(改成 desired)。
  2. Acq_Rel 的作用
    • 读的那一瞬间 :它是 Acquire,确保能看到之前线程的写入。
    • 写的那一瞬间 :它是 Release,确保自己的写入对后续线程可见。
  3. 适用场景:实现自旋锁(Spinlock)、无锁队列的节点插入等。

示例 4:std::memory_order_seq_cst (顺序一致性,默认选项)

场景 :需要全局总序 (Total Order)。即:所有线程看到的所有原子操作的发生顺序是完全一致的。

这是 C++ 原子操作的默认内存序(如果你不写参数,就是这个)。

4.1 完整代码 (demo4_seq_cst.cpp)

这是一个经典的 IRIW (Independent Read Independent Write) 示例。

cpp 复制代码
#include <atomic>
#include <thread>
#include <iostream>
#include <cassert>

std::atomic<bool> x{false}, y{false};
std::atomic<int> z{0};

void write_x() {
    x.store(true, std::memory_order_seq_cst);
}

void write_y() {
    y.store(true, std::memory_order_seq_cst);
}

void read_x_then_y() {
    while (!x.load(std::memory_order_seq_cst)); // 等 x
    if (y.load(std::memory_order_seq_cst)) {     // 看 y
        z.fetch_add(1, std::memory_order_relaxed);
    }
}

void read_y_then_x() {
    while (!y.load(std::memory_order_seq_cst)); // 等 y
    if (x.load(std::memory_order_seq_cst)) {     // 看 x
        z.fetch_add(1, std::memory_order_relaxed);
    }
}

int main() {
    // 循环跑 1000 次,增加概率观察结果
    for (int i = 0; i < 1000; ++i) {
        x = false; y = false; z = 0;
        
        std::thread a(write_x), b(write_y);
        std::thread c(read_x_then_y), d(read_y_then_x);
        
        a.join(); b.join(); c.join(); d.join();
        
        // 🔑 Seq_Cst 保证:z 绝对不可能是 0
        // 因为所有操作有一个全局顺序,要么 x 在 y 前,要么 y 在 x 前
        // 至少有一个读线程会看到两个都是 true
        assert(z.load() != 0);
    }
    std::cout << "All tests passed (Seq_Cst guarantees no z=0)." << std::endl;
    return 0;
}

4.2 一步一步详解

  1. 全局时钟seq_cst 就像给整个程序安了一个全局时钟。所有原子操作按时间戳排列,所有线程看到的顺序都一样。
  2. 为什么 z 不能为 0
    • 如果是 acq_rel,理论上可能出现:线程 C 看到 x=truey=false,同时线程 D 看到 y=truex=false(因为没有全局总序),导致 z=0
    • seq_cst 禁止了这种情况。
  3. 性能代价 :这是最慢的内存序(在 x86 上通常需要 MFENCE 指令)。但它是最直观、最不容易出错的。

📊 最终总结与决策树

为了让你好记,我做了一个简单的决策流程:

  1. 我只是做个计数器/统计,不依赖它同步别的数据?
    • ✅ 用 std::memory_order_relaxed
  2. 我有两个线程,一个发信号,一个收信号,收信号后要读发信号前写的数据?
    • ✅ 发信号用 std::memory_order_release
    • ✅ 收信号用 std::memory_order_acquire
  3. 我在做 fetch_add / compare_exchange 这种 RMW 操作,需要它承上启下?
    • ✅ 用 std::memory_order_acq_rel
  4. 我不知道该用什么 / 逻辑很复杂 / 图省事 / 需要全局顺序?
    • ✅ 用 std::memory_order_seq_cst (默认)

相关推荐
ALex_zry2 小时前
C++模板元编程实战技巧
网络·c++·windows
ambition202422 小时前
斐波那契取模问题的深入分析:为什么提前取模是关键的
c语言·数据结构·c++·算法·图论
艾莉丝努力练剑2 小时前
C++ 核心编程练习:从基础语法到递归、重载与宏定义
linux·运维·服务器·c语言·c++·学习
牢姐与蒯2 小时前
模板的进阶
c++
小樱花的樱花2 小时前
1 项目概述
开发语言·c++·qt·ui
ALex_zry2 小时前
gRPC服务熔断与限流设计
c++·安全·grpc
6Hzlia3 小时前
【Hot 100 刷题计划】 LeetCode 41. 缺失的第一个正数 | C++ 原地哈希题解
c++·leetcode·哈希算法
十五年专注C++开发3 小时前
达梦数据库在Linux备份报错 -8003: 缺少本地或者远程归档 解决方案
数据库·c++·dm·备份复原
yy_xzz3 小时前
【Linux开发】I/O 复用:select 模型
linux·c++·select