并发编程核心概念辨析

本文旨在辨析并发编发中的常见核心概念,目的是防止初学者在学习过程中对相关概念一知半解,互相混淆,越学越懵。本文以澄清概念为主,对部分知识点,比如 MESI 缓存一致性协议,不会深入介绍,感兴趣的读者请自行学习。

一、背景:CPU 多级缓存架构

为了读者在阅读后序章节时,有更清晰、更形象的认知,这里放上现代 CPU 缓存的典型结构。一图胜千言,不多赘述,仅陈述以下要点:

  • 一颗 CPU,往往有多个核心(core)。同一时刻,每个 core 上都可以运行一个线程(thread)。
  • 为了追求更高的效率:
    • 编译器 / CPU 可能重排某些指令,把后面的提前执行。
    • CPU 增加了多级缓存。

这使得某些情况下,某个线程看到的数据,可能是非预期的,进而导致程序出现逻辑错误。

当然,这并不是说编译器或者 CPU 的设计有缺陷,而是一种平衡与妥协:为了效率 , 编译器 / CPU 会适度"放宽政策",不做过于严苛的约束和检查,这在多数情况下是安全的;为了正确性,在并发编程时,开发者需要更加精细的干预。
🔍 点击查看图片

二、概念辨析

1. 缓存一致性(Cache Coherence)

层次:纯硬件机制,在 CPU 内部固化。

解决的问题同一个内存地址(单一变量)多个 CPU 核心的本地缓存(L1/L2 Cache)中如何同步。即,一个核心的写入如何让其它核心感知?

plain 复制代码
核心0的L1缓存:  [addr X] = 1  ← 我刚写入
核心1的L1缓存:  [addr X] = 0  ← 这里还是旧值,怎么同步?

机制 :MESI、MOESI、MESIF协议(状态机)。注意,这些缓存一致性协议是固化在CPU内部的,纯硬件实现。

plain 复制代码
M (Modified)  - 该 cache line 仅存在当前 cache 中,与内存不一致(dirty),其他 CPU core 缓存无效
E (Exclusive) - 该 cache line 仅存在当前 cache 中,与内存一致(clean)
S (Shared)    - 该 cache line 被多核共享,且与内存一致
I (Invalid)   - 该 cache line 已失效,需重新加载

关键特性

  • 地址/变量
  • 最终一致,但不保证何时能看到(因为有 store buffer)。
  • 不保证多个地址之间的对外可见顺序(核心1可能先看到先看到修改后的变量A,后看到修改后的变量B;核心2可能正好赶过来)。
  • 对程序员完全透明,你不需要也不能直接操控它。

通俗理解:它是底层的"群消息同步机制",保证群里所有人看到的单条消息内容是一致的。

2. 内存一致性模型(Memory Consistency Model)

层次:硬件架构规范层,由 CPU 架构决定,不同类型的 CPU 不一样。

解决的问题:多个地址上的读写操作,从其它核心观察时,顺序会不会乱?允许哪些重排?

这才是真正决定"多线程程序正确性"游戏规则的模型:

plain 复制代码
                                                  允许的重排
模型                           Load-Load   Load-Store  Store-Store  Store-Load
───────────────────────────────────────────────────────────────────────────────────────
Sequential Consistency (SC)        ×           ×            ×           ×
Total Store Order (TSO)            ×           ×            ×           ✓  ← 只允许这个
Relaxed / Weak Consistency         ✓           ✓           ✓           ✓

不同架构的选择

  • x86/x64:TSO(Total Store Order)------接近最强,只允许 Store→Load 重排。
  • ARM:非常宽松(weakly ordered),几乎允许所有重排。
  • POWER:类似 ARM,甚至更宽松。

缓存一致性 vs 内存一致性模型的区别

  • 缓存一致性回答:一个地址的写入最终会传播吗?答:会(最终所有核心一定能看到一致结果)
  • 内存一致性模型回答:多个地址的操作,以什么顺序传播?是否允许后操作的先被看到?

通俗理解:你发了消息 A,又发了消息 B。内存一致性模型决定了,群里其他人有没有可能先看到 B,后看到 A。

3. 内存屏障(Memory Barrier / Fence)

层次:硬件指令 + 编译器指令

解决的问题 :在代码的特定位置,强制约束 重排序边界。是程序员/编译器用来干预"内存一致性"的物理武器。

类型

  • Load Barrier:屏障前的所有 load 操作,必须在屏障后的任何 load 操作开始之前,全局完成(对其他处理器可见)。
  • Store Barrier:屏障前的所有 store 操作,必须在屏障后的任何 store 操作开始之前,全局完成(从 Store Buffer 刷新到 L1 Cache,并对其他处理器可见)
  • Full Barrier: 屏障前的所有读和写操作,必须在屏障后的任何读和写操作开始之前,全局完成。

具体指令

plain 复制代码
x86:
  LFENCE  → Load Barrier
  SFENCE  → Store Barrier  
  MFENCE  → Full Barrier(最常用)
  LOCK前缀 → 隐含 Full Barrier

ARM:
  DMB ISH   → Full Barrier(数据内存屏障)
  DSB ISH   → 更强的同步屏障
  ISB       → 指令同步屏障(刷流水线)

两种屏障(注意区分):

cpp 复制代码
// 编译器屏障(只防止编译器重排,CPU不受限)
asm volatile("" ::: "memory");          // GCC
_ReadWriteBarrier();                    // MSVC

// 硬件屏障(同时防止编译器重排 + CPU重排)
asm volatile("mfence" ::: "memory");   // x86 Full Barrier

4. 内存序(Memory Order)

层次:C++ 编程语言层,C++11 引入。

本质 :是对内存屏障的高级抽象,让程序员用语义而非汇编指令来表达需求。

机制 :你写下memory_order,编译器会根据当前的 CPU 架构(x86 还是 ARM),自动帮你翻译成对应架构的内存屏障指令(Memory Barrier)。

六个级别

plain 复制代码
relaxed    → 只保证原子性,不产生任何屏障
acquire    → Load 时用:屏障后的操作不能重排到此 Load 之前
release    → Store 时用:屏障前的操作不能重排到此 Store 之后
acq_rel    → 用于 RMW:同时具备 acquire + release 语义
consume    → acquire 的弱化版(实践中几乎不用)
seq_cst    → 最强:全局顺序一致,等价于 Full Barrier

编译器如何翻译

plain 复制代码
C++ memory_order          x86 生成          ARM 生成
─────────────────────────────────────────────────────
relaxed load          →   MOV              LDR
relaxed store         →   MOV              STR
acquire load          →   MOV              LDAR
release store         →   MOV              STLR
seq_cst store         →   MOV + MFENCE     STLR + DMB
seq_cst load          →   MOV              LDAR

x86 上 acquire/release 不需要额外指令(因为 TSO 已经提供了大部分保证),ARM 上需要专用指令。

5. 四者关系

🔍 点击查看图片

它们的依赖关系

  • 缓存一致性 :硬件底座,没有它,写入根本无法传播,其他一切无从谈起。
  • 内存一致性模型 :硬件架构契约与规则,定义了默认允许什么、禁止什么。
  • 内存屏障 :硬件指令,是工具和手段:当默认规则不够用时,用它来强化约束。
  • 内存序 :是软件层高级抽象,C++ 程序员通过它告诉编译器需要什么保证

三、内存序/内存屏障的作用范围

作用1:防止当前线程内的指令重排(编译器 + CPU)

cpp 复制代码
// 没有屏障,编译器和CPU可能重排这两条指令
data = 42;          // 可能被移到 flag store 之后!
flag = true;

// 有 release 屏障,data = 42 一定在 flag = true 之前完成
data = 42;
flag.store(true, memory_order_release);  // 屏障

作用2:控制跨线程的可见性时序

通过控制"store 何时变得全局可见 "和"load 何时看到最新值 "来影响其他线程的观察结果

plain 复制代码
内存屏障的物理效果(以 x86 TSO 为例):

CPU Core 0:                    Store Buffer         Cache(共享)
─────────────────────────────────────────────────────────────
store data = 42    → 进入 Store Buffer →  [等待提交]
store flag = true  → 进入 Store Buffer →  [等待提交]
MFENCE             → 强制刷新 Store Buffer  → data=42, flag=true 提交到 Cache

CPU Core 1:                                         Cache(共享)
─────────────────────────────────────────────────────────────
load flag          ← 从 Cache 读(必须看到 flag=true 后才能继续)
MFENCE / acquire   ← 确保后续 load 看到最新 Cache 状态
load data          ← 从 Cache 读 → 一定是 42

关键点

  • 内存屏障强制刷新 Store Buffer,让 store 提交到 Cache(对其他核心可见)
  • 缓存一致性协议(MESI)负责在 Cache 之间传播这个更新
  • acquire load 确保从 Cache 读时看到最新状态(不使用过期的缓存行)

所以:

plain 复制代码
防重排(编译器/CPU内部)+ 强制可见性(跨线程)
         ↑                       ↑
         同一个机制,两种效果,不可分割

四、Store-Load 重排

1. 原因

根本原因:Store Buffer(写缓冲区)

当 CPU 执行写操作(Store)时,如果直接写入L1 Cache,由于多核之间的"缓存一致性协议(如MESI)",CPU 必须等待其他核心确认并作废它们对应的缓存行,这个等待过程比较漫长(CPU 视角)。为了不阻塞 CPU,核心会先把数据写到 Store Buffer 中,然后继续执行后续指令。

plain 复制代码
现代 CPU 架构:

CPU Core
  ↓ store
[Store Buffer]  ← store 先写这里(速度快,不用等Cache响应)
  ↓ 异步刷新
[L1 Cache]
  ↓
[L2 Cache]
  ↓
[LLC / 内存]
plain 复制代码
问题场景(Dekker 互斥算法的经典失败案例):

初始值:X = 0, Y = 0

Thread 1:              Thread 2:
  store X = 1            store Y = 1
  load R1 = Y            load R2 = X

期望:R1=1 或 R2=1 至少有一个成立
实际:R1=0 且 R2=0 竟然可能发生!(x86上也会!)

时序解析

plain 复制代码
Time →
Thread 1: store X=1 → [Store Buffer]    ← 还没提交到 Cache!
Thread 1: load  Y=0 ← 从 Cache 读(Y 的 store 还在 Thread2 的 Store Buffer 里)
Thread 2: store Y=1 → [Store Buffer]    ← 还没提交到 Cache!
Thread 2: load  X=0 ← 从 Cache 读(X 的 store 还在 Thread1 的 Store Buffer 里)
// 结果:R1=0, R2=0。两个 store 都"消失了"

Store-Load 重排的本质:不是 CPU 真的"调换了顺序",而是 store 在 store buffer 里异步等待,而 load 已经直接去 cache 读了。效果上等价于 load 跑到了 store 之前。

Store-Load 重排的理解 :从外部观察者 (其它 CPU core)的视角看,load 跑到 store 的前头了。因为从外部观察者的立场来看,store完成的标志,是"你得让我看见" 。现在,在没有让我看到你写的值的情况下,你先执行了后面的 load 指令,那对我来说,你就是先读后写了。

注意:x86/TSO 只允许 Store→Load 重排,其他三种(Load-Load, Load-Store, Store-Store)x86 不允许。ARM 四种都允许。

2. 解决方案

方法1:在 store 和 load 之间插入 Full Barrier

cpp 复制代码
// x86
asm volatile("mfence" ::: "memory");

// C++ 标准方式
std::atomic_thread_fence(std::memory_order_seq_cst);
plain 复制代码
Thread 1:
  store X = 1
  MFENCE          ← 强制刷新 Store Buffer,X=1 提交到 Cache
  load R1 = Y     ← 此时 Y 的最新值一定可见

Thread 2:
  store Y = 1
  MFENCE
  load R2 = X     ← 此时 X=1 一定可见

方法2:使用 seq_cst 原子操作

cpp 复制代码
std::atomic<int> X{0}, Y{0};

// Thread 1
X.store(1, std::memory_order_seq_cst);   // 含隐式 Full Barrier
int r1 = Y.load(std::memory_order_seq_cst);

// Thread 2  
Y.store(1, std::memory_order_seq_cst);
int r2 = X.load(std::memory_order_seq_cst);

// 保证:r1=1 或 r2=1 至少一个成立
相关推荐
良木生香2 小时前
【C++初阶】C++编程基石:编码表&&STL的入门指南
c语言·开发语言·数据结构·c++·算法
并不喜欢吃鱼2 小时前
从零开始C++----四.vector的使用与底层实现
开发语言·c++
沐雪轻挽萤2 小时前
17. C++17新特性-并行算法 (Parallel Algorithms)
java·开发语言·c++
A7bert7772 小时前
【YOLOv8部署至RDK X5】模型训练→转换bin→Sunrise 5部署
c++·人工智能·python·深度学习·yolo·机器学习
EllinY3 小时前
扩展欧几里得算法 exgcd 详解
c++·笔记·数学·算法·exgcd
量子炒饭大师3 小时前
【C++11】RAII 义体加装指南 ——【包装器 与 异常】C++11中什么是包装器?有哪些包装器?C++常见异常有哪些?(附带完整代码讲解)
开发语言·c++·c++11·异常·包装器
炘爚4 小时前
深入解析内存分区:程序运行的秘密
c++
网域小星球4 小时前
C++ 从 0 入门(五)|C++ 面试必知:静态成员、友元、const 成员(高频考点)
开发语言·c++·面试·静态成员·友元函数
|_⊙4 小时前
C++11 右值引用
开发语言·c++