可以网站可以做免费的文案广告语,个人网站域名名字,网站备案的服务器,网站开发说明“In multithreaded programming, if you think you know what’s happening, you’re probably wrong.” 为什么我们需要内存模型#xff1f;
在单核CPU时代#xff0c;我们编写程序时可以对代码执行顺序有着直观的期待——指令基本上按照书写顺序执行。然而#xff0c;随…“In multithreaded programming, if you think you know what’s happening, you’re probably wrong.”为什么我们需要内存模型在单核CPU时代我们编写程序时可以对代码执行顺序有着直观的期待——指令基本上按照书写顺序执行。然而随着多核处理器的普及这个美好的假设被彻底打破了。现代CPU为了提升性能进行了大量优化编译器指令重排编译器可以重新排列指令顺序以优化性能CPU乱序执行CPU可以打乱指令执行顺序以充分利用执行单元多层缓存结构每个CPU核心都有自己的缓存通过缓存一致性协议维护数据一致性。编译器指令重排编译器在保证单线程执行结果as-if规则不变的前提下为了优化性能如更好地利用寄存器、减少指令依赖会调整指令顺序。考虑这个简单的例子两个线程共享两个变量x和y初始值都为0// 代码顺序intx0,y0;voidfoo(){x10;// 写Xy20;// 写Y}在编译器眼里x和y没有任何关系。为了优化流水线它完全可能先赋值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 Buffer和Invalidate 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(){inta1;// 1intb2;// 2a10;// 3b20;// 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操作之间。考虑以下场景intdata0;std::atomicboolflagfalse;// 线程 Adata1;flag.store(true,std::memory_order_release);// 线程 Bwhile(!flag.load(std::memory_order_acquire));assert(data1);在线程 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内存顺序详解C11在语言标准中引入了原子操作库(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::atomicintx{0},y{0};// 线程 Ax.store(1,std::memory_order_relaxed);// (1)y.store(1,std::memory_order_relaxed);// (2)// 线程 Bintr1y.load(std::memory_order_relaxed);// (3)intr2x.load(std::memory_order_relaxed);// (4)线程 A 中的x.store和y.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% 确定不需要同步’的人准备的而这种人通常不存在。发布-获取模型releaseacquire这是构建无锁数据结构Lock-Free的中流砥柱。它们成对出现构成了Synchronizes-With关系。memory_order_releasememory_order_release(写)的含义是我写了这个原子变量后我在原本代码中排在它前面的所有读写操作包括普通变量都必须做完且对acquire这一方可见。具体来说就是指令重排约束禁止编译器将release之前的内存写入重排到之后。可见性保证所有在该release操作之前完成的内存写入对其他线程可见在硬件上通常需要刷空Store Buffer。memory_order_acquirememory_order_acquire(读)的含义是我读了这个原子变量后我在原本代码中排在它后面的所有读写操作都不能提到它前面去执行。具体来说就是指令重排约束该acquire操作之后的所有内存操作都不能被重排到该acquire操作之前。可见性保证release之前的所有写入在acquire之后都可见在硬件上通常需要先处理完Invalidate Queue在 C 语义层面release/acquire只保证 Synchronizes-With 关系具体是否通过 fence、带语义的原子指令还是无需额外指令完全取决于目标架构和编译器实现。回顾上面在介绍Synchronizes-With关系是的例子intdata0;std::atomicboolflagfalse;// 线程 A (发布者)data1;// 1. 普通写flag.store(true,std::memory_order_release);// 2. Release 写// 线程 B (消费者)while(!flag.load(std::memory_order_acquire));// 3. Acquire 读assert(data1);// 4. 安全在线程A中尽管data 1和flag.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保证data在store之前并允许这些操作被跨线程观察到但是不保证有人一定能看到。acquire则在本线程上接住了relase所在线程的时间线与release形成synchronizes-with关系保证acquire之后的操作都能看到对方release之前的结果load之后一定能看到修改后的data从而在形成了整体的Happens-Before关系memory_order_acq_relmemory_order_acq_rel用于读-改-写Read-Modify-Write, RMW操作如exchange,compare_exchange_strong,fetch_add。它同时具有acquire和release的语义对于操作之前的访问它具有release语义对于操作之后的访问它具有acquire语义它自身是一个原子操作保证了“读取值”和“写入新值”这两个步骤之间不会被任何其他线程的写入所打断。是实现自旋锁spinlock、引用计数等同步原语的基石。std::atomicintlock{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_consumeconsume设计上比acquire更弱旨在只同步数据依赖data dependency于该原子负载的操作。例如你原子加载了一个指针ptr你解引用*ptr是安全的但其他无关变量不保证可见。理想很丰满显示很骨感这玩意儿太难实现了。编译器很难追踪复杂的依赖链。因此目前主流编译器GCC, Clang, MSVC通常直接把consume提升为acquire处理。标准委员会自己也承认这是一个失败的设计C20 之后标准几乎已经“名存实亡”地放弃了 consume 的可用性。参见https://isocpp.org/files/papers/P3475R1.pdf因此在当前的实践中强烈建议直接使用acquire/release并避免使用consume。顺序一致性memory_order_seq_cst顺序一致性Sequentially Consistentseq_cst是 C 原子操作的默认内存序是最严格、也是最容易理解的模型。它除了包含acq_rel的所有语义外还额外保证所有使用seq_cst的操作无论是读、写还是RMW在所有线程眼中都有一个单一的、全局一致的执行顺序。用人话说就是所有线程看到的原子操作发生的顺序都是一模一样的。为什么需要这个对于多个生产者-多个消费者的情况顺序排序可能是必要的所有消费者必须观察以相同顺序发生的所有生产者的操作。std::atomicboolx{false};std::atomicbooly{false};std::atomicintz{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_x和read_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-DRFC内存模型建立在SC-DRFSequential 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最弱仅原子性计数器、标志位无需同步时最佳实践默认使用memory_order_seq_cst不要过早优化seq_cst提供的强一致性是符合人类直觉的正确性远高于性能。如果你的程序连逻辑都是错的跑得再快也是错的。只有在 Profiler 告诉你这是瓶颈时才考虑降级只有在性能分析Profiling明确表明原子操作是瓶颈且与内存序相关时才考虑使用更弱的内存序。Code Review 必须加倍严格。 任何使用了relaxed的代码都应该被视作“由于使用了魔法而可能随时爆炸”的危险区域必须写清楚注释说明为什么这里不需要同步。避免使用memory_order_consume除非你在为特定平台如Linux内核编写极致底层代码并且完全了解其编译器的具体实现否则请远离它。使用现成模式库对于大多数应用使用标准库提供的互斥锁(std::mutex)、条件变量(std::condition_variable)、以及高级并发结构(std::async,std::future)比直接使用裸原子操作和内存序要安全得多。这些库的接口已经为你封装了正确的内存序。总结C 内存模型是一个抽象机器Abstract Machine的规则集合它定义了哪些重排是允许的哪些原子操作之间可以建立Happens-Before程序在多线程下“可被观察到的行为边界”微信公众号午夜游鱼个人博客原文深入浅出现代C内存模型