news 2025/12/30 23:36:09

深入浅出现代C++内存模型

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入浅出现代C++内存模型

“In multithreaded programming, if you think you know what’s happening, you’re probably wrong.”

为什么我们需要内存模型?

在单核CPU时代,我们编写程序时可以对代码执行顺序有着直观的期待——指令基本上按照书写顺序执行。然而,随着多核处理器的普及,这个美好的假设被彻底打破了。

现代CPU为了提升性能,进行了大量优化:

  • 编译器指令重排(编译器可以重新排列指令顺序以优化性能)
  • CPU乱序执行(CPU可以打乱指令执行顺序以充分利用执行单元)
  • 多层缓存结构(每个CPU核心都有自己的缓存,通过缓存一致性协议维护数据一致性)。

编译器指令重排

编译器在保证单线程执行结果(as-if规则)不变的前提下,为了优化性能(如更好地利用寄存器、减少指令依赖),会调整指令顺序。

考虑这个简单的例子:两个线程共享两个变量xy,初始值都为0:

// 代码顺序intx=0,y=0;voidfoo(){x=10;// 写Xy=20;// 写Y}

在编译器眼里,xy没有任何关系。
为了优化流水线,它完全可能先赋值y,再赋值x
如果有另一个线程在监视x的变化来读取y,它可能在看到x变了之后,读到的y还是旧值。

CPU 乱序执行

就算你按着编译器的头,不做任何优化,让它不要乱排指令,CPU 这一关也过不去。

现代CPU采用复杂的流水线、超标量、乱序执行技术来榨取性能。更重要的是,缓存一致性协议(如MESI)只保证最终一致性,不保证顺序一致性

想象两个CPU核心(C1, C2)和一块共享内存:

  • 存储缓冲区 (Store Buffer):当C1要写数据时,它并不直接写回可能被其他核心共享的缓存行,而是先写入自己的Store Buffer,然后继续执行后续指令。这使得“写操作”在 C1 看来是立即完成的,但对 C2 而言,这个写入还不可见
  • 失效队列 (Invalidate Queue):当 C1 需要写入一个缓存行时,它会向其他持有该缓存行的核心发送“失效”消息。 C2 收到后,并不立即处理(清理自己的缓存副本),而是将其放入失效队列并立即回复“收到”,以让 C1 的写入能尽快完成。当 C2 稍后需要读取该数据时,它必须处理失效队列中的消息,这时才会发现自己的缓存副本已失效,从而去C1那获取最新值。

MESI协议保证了最终所有缓存会一致,但Store BufferInvalidate Queue引入了可见性延迟,使得“一个核心的写入”与“另一个核心看到该写入”之间存在一个不确定的间隔。

这破坏了我们对“顺序”的直觉。

为了解决这个问题,我们需要内存屏障(Memory Barrier/Fence)

  • 写屏障(Store Fence, e.g.,sfenceon x86):强制清空当前核心的Store Buffer,确保屏障之前的所有写操作都对其他核心可见。
  • 读屏障(Load Fence, e.g.,lfenceon x86):强制处理当前核心的Invalidate Queue,确保屏障之后的读操作能获取到其他核心的最新写入。

C++ 标准并不要求具体的 fence 类型,实际上不同架构(x86 / ARM / POWER)实现差异极大,有时是fence,有时是带语义的原子指令,有时甚至什么都不插(如 x86 acquire load)。

在弱内存架构下,通常需要通过 fence 或等价机制来实现这些语义

C++内存模型,为我们提供了一套统一、可移植的抽象,来描述并发程序中的操作顺序和内存可见性,并让编译器为我们生成正确的屏障指令。

内存模型的三层抽象

现代 C++ 内存模型的所有规则,本质上都围绕着三种不同层级的“顺序关系”展开。

层级关系名称作用范围核心作用
第一层sequenced-before单线程描述线程内的因果关系与重排边界
第二层synchronizes-with跨线程(原子)在两个线程之间建立同步连接
第三层happens-before跨线程(整体)给出最终的可见性保证

第一层:sequenced-before—— 单线程内的因果关系

sequenced-before是内存模型中最基础的一层,只存在于同一个线程内部不直接限制编译器重排

它是一个关于逻辑因果关系的规则。编译器可以自由重排指令,只要最终的单线程结果(as-if规则)sequenced-before定义的逻辑一致即可。

比如:

intmain(){inta=1;// 1intb=2;// 2a=10;// 3b=20;// 4printf("%d %d",a,b);// 5}

在同一线程内,上述代码要求int a = 1;a = 10;之前执行,因为前者逻辑上决定了后者的初始值。这就是sequenced-before关系。

但是编译器可以将int b = 2;提前到int a = 1;之前执行,因为它们之间没有逻辑依赖关系。

int a = 1;sequenced-beforea = 10;,但int b = 2;a = 10;之间没有sequenced-before关系。

同理,a = 10;b = 20;之间也没有sequenced-before关系,可以互相重排;

即 :只要最终结果和逻辑因果关系一致,编译器可以自由重排指令顺序

这一层关系决定了编译器在单线程中允许哪些指令重排,为后续所有跨线程关系提供“时间线基础”。

可以理解为“每个线程各自拥有一条内部时间线”。

第二层:synchronizes-with—— 原子操作之间的同步握手

如果说sequenced-before是线程内的时间线,那么synchronizes-with就是线程之间的连接点

synchronizes-with的职责并不是直接保证“所有内存的顺序”,而是在两个线程之间建立一条可靠的同步边,这条同步边,是构建更强可见性保证的关键中间步骤。

一个典型的synchronizes-with关系,发生在一个线程的release操作另一个线程的acquire操作之间。

考虑以下场景:

intdata=0;std::atomic<bool>flag=false;// 线程 Adata=1;flag.store(true,std::memory_order_release);// 线程 Bwhile(!flag.load(std::memory_order_acquire));assert(data==1);

在线程 Arelease和 线程 Bacquire之前,两个线程各自的时间线是独立的,互不干扰(即使它们操作同一个原子变量)。

但是,一旦线程 B 成功执行了acquire,它就“接住”了线程 A 的时间线,形成了synchronizes-with关系,线程Bacquire之后的所有操作,都能看到线程 Arelease之前的所有操作。

第三层:happens-before—— 可见性的最终承诺

简单来说:如果 A happens-before B,那么 B 一定能够观察到 A 的结果。

happens-before不是一种原始关系,而是通过同一线程内的sequenced-before和跨线程的synchronizes-with组合而成的。

这正是release / acquire能够安全“发布数据”的根本原因。

小结

  • sequenced-before:定义线程内的因果顺序
  • synchronizes-with:在原子操作上搭建线程间的桥梁
  • happens-before:给出跨线程的最终可见性保证

C++内存顺序详解

C++11在语言标准中引入了原子操作库(<atomic>)和正式的内存模型。其核心是六种内存序(memory_order),定义了原子操作周围非原子内存访问的可见性顺序,让程序员可以在不同严格程度之间进行选择。

typedefenummemory_order{memory_order_relaxed,// 最松散memory_order_consume,// 消费(谨慎使用)memory_order_acquire,// 获取memory_order_release,// 释放memory_order_acq_rel,// 获取-释放memory_order_seq_cst// 顺序一致(默认)}memory_order;

松散模型:memory_order_relaxed

这是约束最弱的内存序。

  • 仅保证对同一个原子变量的修改在所有线程眼中有一个一致的全局顺序(Modification Order),
  • 不建立任何线程间的同步关系(Happens-Before)
  • 不限制编译器或CPU对其周围内存操作的重排

比如:

std::atomic<int>x{0},y{0};// 线程 Ax.store(1,std::memory_order_relaxed);// (1)y.store(1,std::memory_order_relaxed);// (2)// 线程 Bintr1=y.load(std::memory_order_relaxed);// (3)intr2=x.load(std::memory_order_relaxed);// (4)

线程 A 中的x.storey.store没有sequenced-before关系
同样的,线程 B 中的两行代码也没有sequenced-before关系,
因此编译器可以重排线程 A 和线程 B 中的代码

因此,在relaxed语义下,即使线程B在(3)处读到了y == 1,它也不能推断线程A中(1)的写操作x = 1一定已经完成。

因为(1)和(2)可能被重排,或者(1)的结果还卡在Store Buffer里没对B可见。所以r1 == 1 && r2 == 0是一个可能的结果。

memory_order_relaxed既不建立synchronizes-with,也不形成任何跨线程的happens-before,仅保证原子变量的修改顺序

适用场景:只在乎变量本身的原子性,不在乎它和其他变量的关系。比如:单纯的计数器

relaxed是给‘100% 确定不需要同步’的人准备的,而这种人通常不存在。

发布-获取模型:release&acquire

这是构建无锁数据结构(Lock-Free)的中流砥柱。它们成对出现,构成了Synchronizes-With关系。

memory_order_release

memory_order_release(写)的含义是:我写了这个原子变量后,我在原本代码中排在它前面的所有读写操作(包括普通变量),都必须做完,且对acquire这一方可见。

具体来说就是:

  • 指令重排约束:禁止编译器将release之前的内存写入重排到之后。
  • 可见性保证:所有在该release操作之前完成的内存写入对其他线程可见,在硬件上通常需要刷空Store Buffer

memory_order_acquire

memory_order_acquire(读)的含义是:我读了这个原子变量后,我在原本代码中排在它后面的所有读写操作,都不能提到它前面去执行。
具体来说就是:

  • 指令重排约束:该acquire操作之后的所有内存操作,都不能被重排到该acquire操作之前
  • 可见性保证release之前的所有写入在acquire之后都可见,在硬件上通常需要先处理完Invalidate Queue

在 C++ 语义层面,release/acquire只保证 Synchronizes-With 关系;具体是否通过 fence、带语义的原子指令,还是无需额外指令,完全取决于目标架构和编译器实现。

回顾上面在介绍Synchronizes-With关系是的例子:

intdata=0;std::atomic<bool>flag=false;// 线程 A (发布者)data=1;// 1. 普通写flag.store(true,std::memory_order_release);// 2. Release 写// 线程 B (消费者)while(!flag.load(std::memory_order_acquire));// 3. Acquire 读assert(data==1);// 4. 安全!

在线程A中,尽管data = 1flag.store没有逻辑关系,但是因为使用了memory_order_release,相当于告诉编译器,不能将data = 1重排到flag.store后面,同时告诉 CPU 要保证data = 1的操作对其他核可见(如刷空store buffer

类似的,在线程B中,由于memory_order_acquire的存在,assert(data == 1)被禁止重排到flag.load前面,同时告诉 CPU 要保证拿到data的最新结果(如处理完Invalidate Queue

如果没有release/acquire,而是采用relaxed,那么线程A 和 线程B 可以自由的进行重排,即使没有重排,也可能因为没有清空store buffer使得线程B看到的是旧值。

结果就是,线程 B 可能看到flag为 true 时,data还是0(因为 CPU 乱序)。 而有了release/acquire这层关系,只要步骤 3 成功,步骤 1 就一定对步骤 4 可见。

在这里,release固定了本线程中的sequenced-before(保证datastore之前),并允许这些操作被跨线程观察到,但是不保证有人一定能看到。

acquire则在本线程上接住了relase所在线程的时间线,与release形成synchronizes-with关系,保证acquire之后的操作都能看到对方release之前的结果(load之后一定能看到修改后的data

从而在形成了整体的Happens-Before关系

memory_order_acq_rel

memory_order_acq_rel用于读-改-写(Read-Modify-Write, RMW)操作(如exchange,compare_exchange_strong,fetch_add)。它同时具有acquirerelease的语义:

  • 对于操作之前的访问,它具有release语义
  • 对于操作之后的访问,它具有acquire语义
  • 它自身是一个原子操作,保证了“读取值”和“写入新值”这两个步骤之间,不会被任何其他线程的写入所打断

是实现自旋锁(spinlock)、引用计数等同步原语的基石。

std::atomic<int>lock{0};voidlock_acquire(){while(lock.exchange(1,std::memory_order_acq_rel)==1){// 尝试获取锁// spin...}// 进入临界区,能看见之前持有锁的线程的所有 release 写入}voidlock_release(){lock.store(0,std::memory_order_release);// 释放锁,让临界区的写入对后来者可见}

尴尬的存在:memory_order_consume

consume设计上比acquire更弱,旨在只同步数据依赖(data dependency)于该原子负载的操作。

例如:你原子加载了一个指针ptr,你解引用*ptr是安全的,但其他无关变量不保证可见。

理想很丰满,显示很骨感,这玩意儿太难实现了。编译器很难追踪复杂的依赖链。因此,目前主流编译器(GCC, Clang, MSVC)通常直接把consume提升为acquire处理

标准委员会自己也承认这是一个失败的设计,C++20 之后,标准几乎已经“名存实亡”地放弃了 consume 的可用性。(参见:https://isocpp.org/files/papers/P3475R1.pdf)

因此,在当前的实践中,强烈建议直接使用acquire/release,并避免使用consume

顺序一致性:memory_order_seq_cst

顺序一致性(Sequentially Consistent,seq_cst)是 C++ 原子操作的默认内存序,是最严格、也是最容易理解的模型

它除了包含acq_rel的所有语义外,还额外保证:所有使用seq_cst的操作(无论是读、写还是RMW)在所有线程眼中都有一个单一的、全局一致的执行顺序。

用人话说就是:所有线程看到的原子操作发生的顺序,都是一模一样的。

为什么需要这个?

对于多个生产者-多个消费者的情况,顺序排序可能是必要的,所有消费者必须观察以相同顺序发生的所有生产者的操作。

std::atomic<bool>x={false};std::atomic<bool>y={false};std::atomic<int>z={0};voidwrite_x(){x.store(true,std::memory_order_seq_cst);}voidwrite_y(){y.store(true,std::memory_order_seq_cst);}voidread_x_then_y(){while(!x.load(std::memory_order_seq_cst));if(y.load(std::memory_order_seq_cst))++z;}voidread_y_then_x(){while(!y.load(std::memory_order_seq_cst));if(x.load(std::memory_order_seq_cst))++z;}intmain(){std::threada(write_x);std::threadb(write_y);std::threadc(read_x_then_y);std::threadd(read_y_then_x);a.join();b.join();c.join();d.join();assert(z.load()!=0);// will never happen}

上面的例子中,使用seq_cst保证了线程 C 和 D 看到的顺序是一致的,即read_y_then_xread_y_then_x中至少有一个的++z一定会被执行。

如果是release/acquire模型,比如将上面代码中load操作的内存序都改为acquirestore操作的内存序都改为release,那么线程 C 和 D 可能会看到完全相反的顺序

release/acquire只能保证同一个原子对象建立happens-before关系,但是这里有两个原子对象,release/acquire既不保证不同原子对象之间的顺序,也不保证不同同步链之间的相对可见性。

线程C 看到的顺序是x = 1,然后看到y = 0
而线程D可以先看到y = 1,再看到x = 0

结果就是线程C和线程D中的++z可能都不会被执行

seq_cst保证了,如果线程C先看到x = 1,再看到y = 0(即y的写入在x后面),那么线程D看到的顺序和C看到的顺序一样,等到线程D看到y = 1y的写入已完成)时,x一定已经是1了。反之亦然。

代价就是:实现层面需要付出额外成本来维护这种全序幻觉。

比如,在 x86 上可能会插入重磅的lock前缀指令,或在 ARM 上插入dmb ish全屏障。

顺序一致性的理论基础:SC-DRF

C++内存模型建立在SC-DRF(Sequential Consistency for Data Race Free)​ 这一重要理论基础之上。

该理论保证:

只要程序是"无数据竞争(Data Race Free)"的,且所有同步操作都使用memory_order_seq_cst,那么整个程序的行为就会表现得如同顺序一致。

这解释了为什么"默认使用seq_cst"是如此合理的建议:

  • 它让多线程程序的推理变得相对简单——你可以像思考单线程程序一样思考执行顺序
  • 只要避免了数据竞争,你就能获得强一致性的保证
  • 这相当于用性能代价(在某些架构上)换来了开发效率和正确性保证

当你需要优化性能而考虑使用更弱的内存序时,实际上是在脱离 SC-DRF 提供的"安全网",进入需要手动证明正确性的领域。

分类

可以将六种内存序分为三大类,理解其强度与用途:

类别包含的memory_order语义强度典型用途
顺序一致 (SC)seq_cst最强,有全局总序,默认选择,需要强保证的复杂同步
发布-获取 (Release-Acquire)release,acquire,acq_rel强,能建立线程间同步,锁、条件变量、生产者-消费者、单次初始化
松散 (Relaxed)relaxed最弱,仅原子性计数器、标志位(无需同步时)

最佳实践

  1. 默认使用memory_order_seq_cst:不要过早优化!seq_cst提供的强一致性是符合人类直觉的,正确性远高于性能。如果你的程序连逻辑都是错的,跑得再快也是错的。
  2. 只有在 Profiler 告诉你这是瓶颈时,才考虑降级:只有在性能分析(Profiling)明确表明原子操作是瓶颈,且与内存序相关时,才考虑使用更弱的内存序。
  3. Code Review 必须加倍严格。 任何使用了relaxed的代码,都应该被视作“由于使用了魔法而可能随时爆炸”的危险区域,必须写清楚注释,说明为什么这里不需要同步。
  4. 避免使用memory_order_consume:除非你在为特定平台(如Linux内核)编写极致底层代码,并且完全了解其编译器的具体实现,否则请远离它。
  5. 使用现成模式库:对于大多数应用,使用标准库提供的互斥锁(std::mutex)、条件变量(std::condition_variable)、以及高级并发结构(std::async,std::future),比直接使用裸原子操作和内存序要安全得多。这些库的接口已经为你封装了正确的内存序。

总结

C++ 内存模型是一个抽象机器(Abstract Machine)的规则集合,它定义了:

  • 哪些重排是允许的
  • 哪些原子操作之间可以建立Happens-Before
  • 程序在多线程下“可被观察到的行为边界

微信公众号:午夜游鱼
个人博客原文:深入浅出现代C++内存模型

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2025/12/23 23:05:43

如何在Windows 11离线环境中快速安装.NET Framework 3.5:完整解决方案

在Windows 11离线环境下安装.NET Framework 3.5是许多系统管理员和开发人员面临的常见挑战。无论是企业内网环境、安全隔离网络&#xff0c;还是缺乏稳定互联网连接的场景&#xff0c;掌握离线安装方法都至关重要。本文将为您提供详细的Windows 11离线安装.NET Framework 3.5教…

作者头像 李华
网站建设 2025/12/24 2:05:31

ONNX Runtime线程调度为何失效?深度解析rembg性能优化方案

ONNX Runtime线程调度为何失效&#xff1f;深度解析rembg性能优化方案 【免费下载链接】rembg Rembg is a tool to remove images background 项目地址: https://gitcode.com/GitHub_Trending/re/rembg 在图像处理领域&#xff0c;rembg作为一款优秀的背景移除工具&…

作者头像 李华
网站建设 2025/12/23 21:19:23

5、在Mac上使用Parallels运行Windows的全方位指南

在Mac上使用Parallels运行Windows的全方位指南 在日常的电脑使用中,很多Windows用户都有了购买Mac的想法,但又希望能在Mac上运行Windows系统。Parallels Desktop for Mac就是一款能满足这一需求的出色工具。下面将为大家详细介绍如何在Parallels中启动和运行Windows。 启动…

作者头像 李华
网站建设 2025/12/23 21:21:15

React Native二维码扫描终极指南:从零到一构建扫码功能

还在为React Native应用添加二维码扫描功能而烦恼吗&#xff1f;&#x1f914; 别担心&#xff0c;今天我将带你一步步掌握react-native-qrcode-scanner的使用技巧&#xff0c;让你的应用轻松实现专业的扫码体验&#xff01; 【免费下载链接】react-native-qrcode-scanner A QR…

作者头像 李华
网站建设 2025/12/24 5:51:35

尼达尼布(Nintedanib)真实世界应用效果与疾病进展延缓观察

尼达尼布作为一种多靶点酪氨酸激酶抑制剂&#xff0c;在肺纤维化治疗领域占据重要地位。真实世界研究数据为其临床应用提供了更为全面且贴近实际的证据&#xff0c;尤其在延缓疾病进展方面展现出显著效果。在特发性肺纤维化&#xff08;IPF&#xff09;治疗中&#xff0c;INPUL…

作者头像 李华
网站建设 2025/12/24 18:23:19

Open-AutoGLM命令行模式常用指令大全(资深工程师私藏手册)

第一章&#xff1a;Open-AutoGLM命令行模式概述Open-AutoGLM 是一款基于大语言模型的自动化代码生成工具&#xff0c;支持通过命令行快速调用模型能力&#xff0c;实现代码补全、函数生成、文档翻译等功能。其命令行模式设计简洁高效&#xff0c;适用于开发人员在本地或服务器环…

作者头像 李华