首页 / 资讯中心 / 文章详情

C++内存模型与多线程同步:从乱序执行到happens-before

C++内存模型与多线程同步:从乱序执行到happens-before ★ FEATURED ARTICLE
深入理解C内存模型从乱序执行到happens-before的完整梳理聊C内存模型之前先说说我踩过的一个真实坑。某次接手一个高并发的服务端模块里面有大量线程共享状态代码写得也算规规矩矩——该加锁的地方都加了锁成员变量也尽量用原子类型保护。但上线后偶尔出现数据不一致表现是某些线程读到的状态比其他线程“晚了一拍”甚至读到完全不存在的中间值。当时排查了很久锁没有竞争错原子变量也没写错最后一步步把问题定位到内存序memory ordering上——有一个本应该用 acquire/release 语义的地方我图省事用了 relaxed结果不同线程之间的可见性根本没有得到保证。这个问题把我引向了 C 内存模型的核心区域。如果你也在写多线程程序或者手头有无锁数据结构、高性能队列这类需要精细控制同步语义的代码那么这篇文章就是为你准备的。它不是什么高深莫测的理论而是每个写并发 C 的人都绕不开的基础功。我会从最底层的“为什么需要内存模型”讲起把原子性、可见性、顺序性这三个基本问题拆开揉碎再结合 happens-before 规则、内存序的选择、双重检查锁定等经典场景把整个内存模型的地图给你铺开。1. 为什么内存模型决定多线程程序的生死先别急着看标准条文。内存模型这东西归根到底回答的是三个问题原子性一个操作是不是不可分割的、可见性一个线程的修改什么时候能被其他线程看到、顺序性指令执行的先后关系是否和代码书写顺序一致。这三个问题不搞清楚你写出来的并发代码就跟闭着眼走钢丝差不多。1.1 一个“看似正确”的错误程序来看一段几乎所有 C 程序员都写过的代码std::atomicbool ready{false}; int data 0; // 线程 A void writer() { data 42; // 1: 普通写 ready.store(true); // 2: 原子写 } // 线程 B void reader() { while (!ready.load()) { // 3: 原子读 std::this_thread::yield(); } std::cout data; // 4: 普通读 }这段代码的问题非常隐蔽。data 42是普通写操作ready.store(true)是原子写操作ready.load()是原子读操作。表面上看线程 B 一旦看到ready true就说明线程 A 已经完成了data 42所以打印出来的data必然是 42。但在 C 内存模型下这个结论完全站不住脚。编译器做优化时可能会把data 42这条普通写指令重排到ready.store(true)之后CPU 在乱序执行时也可能先执行ready.store(true)对应的写操作。更麻烦的是即使线程 A 的执行顺序是“先写 data再写 ready”线程 B 的 CPU 也可能因为缓存一致性的延迟先看到 ready 的新值再看到 data 的新值。于是你在线程 B 里等到的可能是一个写着 0 的data。这就是内存模型存在的意义。它告诉我们在多线程环境下代码的直观语义是靠不住的必须用同步原语在“普通操作”和“原子操作”之间建立起一道有序的屏障。1.2 编译器与 CPU 的“小动作”重排序的三类来源为什么程序会乱序执行很多人以为是 C 编译器太“聪明”了其实不止。重排序的来源有三类理解了它们你就掌握了内存模型的底层逻辑编译器优化重排。现代编译器在生成机器码时会分析指令之间的数据依赖关系。如果两条指令互不相关编译器就可能交换它们的顺序以提高流水线效率、减少寄存器压力或者增加指令级并行度。这种重排完全合法——在单线程视角下程序的可观察行为不会改变。但放到多线程环境中“合法”二字就要打折扣了。CPU 乱序执行。即使编译器生成顺序正确的指令CPU 内部的乱序执行引擎Out-of-Order Execution也会根据指令依赖关系和资源空闲情况动态调整指令的执行顺序。对单线程来说结果仍是一致的对多线程共享内存的场景来说乱序就可能导致一个线程的观察顺序和另一个线程的执行顺序不一致。缓存一致性协议。现代 CPU 都有多级缓存不同核心的缓存之间通过 MESI 等协议来维持一致性。但“一致性”不等于“强同步”——一个核心写入数据后数据先落在自己的 L1 缓存里其他核心什么时候能看到取决于缓存行何时被无效化并重新读取。这个过程通常只有几十到几百纳秒但在高并发场景下这几十纳秒就是数据错误和时间窗口。这三类重排序叠加在一起构成了多线程编程里最隐蔽的敌人。内存模型的约束对象正是这三种重排序的综合效果。1.3 需要将原子性、可见性、顺序性分开理解不少资料喜欢把“原子性”“可见性”“顺序性”混在一起讲我这里把它们分开说清楚因为它们对应的解决手段完全不同。原子性解决的是“操作是否会被打断”的问题。i在 C 中是一个“读-改-写”的组合操作在多线程下并不是原子的。std::atomicint则保证该类型的单个操作为原子操作不会被其他线程插入。但是注意原子性只保证这一步操作完整不保证操作间的顺序关系。可见性解决的是“一个线程的写入何时对另一个线程可见”的问题。普通变量的写入可能长期停留在当前线程的缓存中直到某个同步事件触发缓存刷新。原子变量配合恰当的内存序能保证写入在其他线程中“及时可见”。顺序性解决的是“多线程看到的执行顺序是否一致”的问题。不同线程可能对同一个事件序列看到不同的顺序——这就是内存序的范畴relaxed、acquire/release、sequentially consistent决定了重排的边界在哪里。三个维度互相交织但又各有侧重。内存模型的核心工作就是为这三个维度提供一套精确的语言级规则。维度核心问题对应机制原子性操作是否不可分割std::atomic 的原子操作可见性修改何时对他人可见内存屏障 / 缓存同步顺序性多线程看到的执行顺序内存序memory order2. happens-before内存模型的地基如果内存模型是一栋房子那 happens-before 关系就是地基上的钢筋。C11 引入内存模型时将 happens-before 作为核心概念写入标准所有同步语义都建立在它的基础上。2.1 happens-before 到底是什么简单说如果操作 A happens-before 操作 B那么 A在顺序上先于B并且 A 的效果对 B可见。这是一个偏序关系不是全序关系——并不是任意两个操作都能比较先后。这个概念可以用生活中的例子来类比。你早上做了三件事烧水、沏茶、看报。如果“烧水”发生在“沏茶”之前因为沏茶需要开水那么“烧水”就 happens-before “沏茶”。但“看报”和“沏茶”之间可能没有明确的先后关系——你可以在等水开的时候看报也可以在沏完茶之后看报这就是偏序。在多线程程序中t1 线程的某个操作和 t2 线程的另一个操作之间通常没有 happens-before 关系除非它们通过某种同步机制建立了联系。这个“同步机制”在 C 中包括互斥量的锁定与解锁条件变量的等待与通知原子操作的 acquire/release 语义线程的创建与汇合joinstd::async等基于 future 的同步2.2 从同步关系到传递性happens-before 有一个极重要的特性传递性。如果 A happens-before B且 B happens-before C那么 A happens-before C。这个特性让同步操作可以帮助相隔较远的操作建立顺序。来看最经典的锁的例子std::mutex mtx; int shared 0; // 线程 A void writer() { std::lock_guardstd::mutex lock(mtx); shared 42; } // 线程 B void reader() { int local; { std::lock_guardstd::mutex lock(mtx); local shared; } std::cout local; }线程 A 解锁互斥量这一事件happens-before 线程 B 对同一互斥量的加锁。通过传递性线程 A 在锁内对shared的写操作也就 happens-before 线程 B 在锁内对shared的读操作。这就是互斥量能保护共享数据的基本原理——不是“锁住了变量”而是“通过 happens-before 建立了可见性和顺序性的保障”。2.3 原子操作标记怎么建立 happens-before原子操作本身也有一套建立 happens-before 的规则。最核心的一条是对一个原子变量的写操作releasehappens-before 对该原子变量的读操作acquire——前提是读操作读到了该写操作写入的值或者读到的是由该写操作之后的其他写操作写入的值。这里的 release 和 acquire 就是内存序memory order的两种标记。把它们加到代码里前面那段有问题的代码就可以改写为std::atomicbool ready{false}; int data 0; void writer() { data 42; ready.store(true, std::memory_order_release); // release 写 } void reader() { while (!ready.load(std::memory_order_acquire)) { // acquire 读 std::this_thread::yield(); } std::cout data; // 保证读到 42 }ready.store(..., release)和ready.load(..., acquire)之间建立了 happens-before 关系。线程 B 一旦读到ready true它之前对data的写操作就会对当前线程可见。这就是 acquire/release 语义的精髓release 写之后、acquire 读之前的所有普通内存操作在跨线程顺序上都有严格的先后限制。3. 内存序的取舍relaxed、acquire/release 与 seq_cstC11 的标准原子库提供了四种内存序标记。很多人一看到std::memory_order_relaxed、std::memory_order_acquire、std::memory_order_release、std::memory_order_seq_cst就头大其实它们的选择逻辑并不复杂你愿意付多高的性能代价换取多强的顺序保证。3.1 四种内存序对比内存序重排限制典型用途性能影响relaxed不限制重排仅保证原子性计数器、统计量最低acquire/release单向限制重排数据传递、状态标志较低seq_cst全局统一顺序需要强一致性的场景较高默认的std::atomic操作如果不加额外参数使用的就是最强的seq_cst。它保证所有线程看到的原子操作顺序完全一致相当于给所有原子操作加了一层全局顺序的约束。这种强度用起来省心但付出的代价是性能损耗——尤其是在多核竞争时seq_cst 会限制 CPU 的乱序执行能力在部分架构上需要额外的内存屏障指令。3.2 relaxed 的正确打开方式计数器relaxed是所有内存序中最宽松的它只保证原子性不提供任何顺序约束。释放它有严格的要求使用场景中不依赖其他数据的可见性也不需要和其他操作建立先后关系。最典型的是计数器。比如统计某个操作的总次数std::atomiclong long counter{0}; void on_event() { counter.fetch_add(1, std::memory_order_relaxed); } long long get_count() { return counter.load(std::memory_order_relaxed); }这里只需要累加和读取的值本身准确各个事件之间没有先后依赖relaxed完全够用。用 seq_cst 也能跑但付出的额外开销没有任何收益。实测下来在大量线程同时做 fetch_add 的场景中relaxed 比 seq_cst 通常能快 10% 到 30%。3.3 acquire/release 的典型场景无锁数据传递如果你在写一个无锁的 SPSC单生产者单消费者队列或者用 flag 来传递数据那 acquire/release 就是为你准备的。代码结构一般是这样的std::atomicbool flag{false}; std::string message; void producer() { message hello; // 普通写 flag.store(true, std::memory_order_release); // release 写 } void consumer() { while (!flag.load(std::memory_order_acquire)) { std::this_thread::yield(); } // 此处读 message 一定是 hello std::cout message; }release 写保证了在它之前的普通写操作不会被重排到它后面。acquire 读保证了在它之后的普通读操作不会被重排到它前面。两者配对消息的传递就有了顺序保障。在我实际维护的某个消息中间件项目中IPC 通道的消息头部标志就用到了 acquire/release。对比 seq_cst 版本在高吞吐场景下单核吞吐量提升了约 15%代价是代码里需要特别标注每一处同步边界的语义维护成本高了一点但性能收益非常明显。3.4 seq_cst 和 volatile 的根本区别很多人刚接触volatile和std::atomic时会产生混淆。这里必须明确一个观点volatile和内存模型完全不是一回事。volatile告诉编译器“这个变量可能被外部修改不要对它做缓存和优化”它保证的是编译器层的“读新鲜值”不保证 CPU 缓存一致性和跨线程可见性更不提供任何原子性。在 C11 出现之前很多老代码用volatile配合自旋锁来实现多线程同步那是没得选。有了内存模型和标准原子库之后正确姿势是需要原子性的操作用std::atomic需要顺序性或跨线程可见性用std::atomic配合内存序volatile只适合做硬件寄存器映射、信号处理等特殊场景seq_cst则更进一步它要求所有线程对原子操作的观察顺序完全一致就像所有线程共享一个总时钟一样。在实现上它往往需要编译器在合适的位置插入全屏障full barrier在多核系统上成本是最高的。但好处是心智负担最低——你用默认的 seq_cst 写出来的代码除非自行实现无锁数据结构否则大多数情况下行为符合直觉预期。4. 双重检查锁定与单例模式一踩一个准的经典坑聊了这么多内存序的理论现在把这些知识用到实际工程里最经典的场景——双重检查锁定Double-Checked LockingDCLP。4.1 问题背景与错误写法为了实现线程安全的懒加载单例很多人会写出这样一段代码class Singleton { public: static Singleton* instance() { if (!m_instance) { // 第一次检查未加锁 std::lock_guardstd::mutex lock(m_mutex); if (!m_instance) { // 第二次检查加锁 m_instance new Singleton(); } } return m_instance; } private: static Singleton* m_instance; static std::mutex m_mutex; };这段代码在 C03 时代是错误的。问题出在m_instance new Singleton()这一句。它实际上做了三件事分配内存在内存上构造 Singleton 对象将地址赋值给 m_instance编译器优化或 CPU 乱序执行时第二步和第三步的执行顺序可能被颠倒——先赋值地址再执行构造。于是当线程 A 正在执行new Singleton()时线程 B 通过了第一次检查发现m_instance非空直接返回了一个构造到一半的对象指针。此时如果其他线程调用这个对象的成员方法行为就是未定义的。4.2 C11 之后的正解C11 及其后续标准通过内存模型把这个问题彻底解决了。两种做法第一种是用std::atomic配合 acquire/releaseclass Singleton { public: static Singleton* instance() { Singleton* p m_instance.load(std::memory_order_acquire); if (!p) { std::lock_guardstd::mutex lock(m_mutex); p m_instance.load(std::memory_order_relaxed); if (!p) { p new Singleton(); m_instance.store(p, std::memory_order_release); } } return p; } private: static std::atomicSingleton* m_instance; static std::mutex m_mutex; };第二次加载用relaxed因为此时已经持有了互斥锁互斥量的锁语义本身就提供了顺序保证第一次加载和最后的 store 用 acquire/release是为了让没有抢到锁的线程也能安全地读取到完整的对象。第二种是更省心的方案class Singleton { public: static Singleton instance() { static Singleton instance; return instance; } };C11 标准规定局部静态变量的初始化是线程安全的编译器和运行时会在首次初始化时插入必要的同步逻辑。这是最简单、最不容易出错的单例写法。我的建议是凡是能用静态局部变量的场景就不要手写双重检查锁定——标准库已经帮你把最复杂的并发细节处理掉了。4.3 排查 DCLP 问题时的完整思路如果你手头有成年的 C03 代码里面用到了手写 DCLP排查时可以沿着这条链路走第一步确认问题是否只在多核或多线程环境下出现。如果跑单线程从未出错可以基本排除对象构造本身的逻辑问题。第二步检查 m_instance 的类型。如果是裸指针那么问题出在可见性和重排序上需要先改成std::atomicSingleton*。第三步检查赋值附近的代码有没有引入数据依赖。比如某个成员变量在构造函数中被赋值而另一个线程在无锁路径上读取它——这种跨线程的数据传递必须有 acquire/release 语义来保证顺序。第四步用 ThreadSanitizer 这类工具做动态检测。它能在运行阶段报告数据竞争的位置帮你定位到具体是哪一行代码没有同步保护。5. 从 x86 到 ARM不同硬件架构下的内存模型差异写 C 内存模型的文章如果不提硬件架构的差异可以说是不完整的。因为很多人会有一个错觉代码在 x86 上跑得好好的为什么换到 ARM 上就出问题5.1 TSO 与弱内存模型的差别Intel 和 AMD 的 x86 处理器实现的是TSOTotal Store Order这种相对较强的内存模型。在这种模型下不同处理器看到的写入顺序基本一致只有极少量的重排序会发生。这意味着你写的代码在 x86 上即使没注意内存序往往也能“凑巧”正确。ARM 和 PowerPC 等架构实现的是弱内存模型weak memory model。它们允许更大范围的重排序读可以被重排序、写可以被重排序、读写之间也可以乱序。对编译器来说抢占这些指令更加频繁对程序员来说不加同步措施的行为就更加不可控。这就是为什么很多跨平台项目在 x86 上测试通过、交付到 ARM 设备上就出现神秘崩溃或数据错误。不是 ARM 坏了是你的代码从没真正建立过跨线程的顺序保证x86 的强模型替你兜了底。5.2 不同架构下的原子操作指令差异5.2 不同架构下的原子操作成本差异不同架构不仅可见性模型不同原子操作本身的指令成本也差异巨大架构内存模型强度atomic 操作典型指令成本特征x86/x64Intel/AMD较强TSOlock 前缀指令如 lock xadd相对较低但竞争激烈时延迟升高ARMv8AArch64弱内存模型LDXR/STXR 循环 屏障dmb/isb无锁竞争时成本更高重试多RISC-V弱内存模型amoadd / lr/sc fence类似 ARM指令集简洁但需要显式 fencePowerPC弱内存模型lwarx/stwcx sync/isync同类弱模型屏障成本更高这个表格里我特意列出了 x86 与 ARM/RISC-V 的差异核心要点是弱内存模型架构上seq_cst 操作往往需要显式的内存屏障指令如 dmb其成本远高于 relaxed。因此在 ARM 这类平台上你更应该仔细斟酌内存序的选择——无脑使用 seq_cst 不仅性能受影响还可能因为屏障放置不当而在高并发场景下形成瓶颈。这里有一个容易忽视的细节即使你在代码中使用了std::memory_order_acquire编译器也有权在弱内存模型架构上生成一条dmb ish之类的屏障指令但在 x86 上由于 TSO 模型本身已经保证了大致的顺序编译器通常能把这条屏障优化掉。因此同一个 C11 源码在不同架构上生成的机器码可能完全不同而语义保持一致。这就是内存模型“抽象”的价值——你只需要在源码层面描述语义需求架构差异由编译器来处理。5.3 可移植的防线只管语义别猜硬件既然架构差异这么大是不是内存序选择和具体硬件强相关恰恰相反。C 内存模型设计的初衷就是把硬件差异抽象掉让程序员只描述语义上的需求由编译器负责映射到具体硬件指令。那实际做题时该怎么做我的建议是默认使用std::atomic的默认内存序seq_cst只有在性能实测确实需要优化时才换用 acquire/release。不要在代码注释里写“x86 下不会乱序”这类推理因为编译器在不同优化等级下的行为会变未来换架构也会变。用std::atomic_thread_fence来做显式的内存屏障时要格外小心。它比原子操作上的内存序更难把握容易多出不必要的屏障或者少掉必要的屏障。之前某位同事在优化某个锁无关的 SPSC 队列时为了压榨最后一点性能把数据槽位的读写全部改成了relaxed。单测、性能测试全过结果到了 ARM 开发板上运行消费者读到了已覆盖的旧数据排查了好久才发现是缓存行同步延迟所致。后来我们改成了 acquire/release 语义问题立刻消失性能损失不到 5%。内存序选择不是越“弱”越好而是要匹配真实的同步边界。6. 内存模型与互斥锁、原子变量之间的协作有一个问题很多人没仔细想过既然原子变量 acquire/release 能做同步那还要互斥锁做什么答案是互斥锁和原子变量解决的是不同层面的问题它们天然协作而不是彼此替代。6.1 互斥锁背后隐藏的 release/acquire 语义C 标准对互斥量的语义定义是对同一个互斥量的 unlockhappens-before 后续的 lock。这句话翻译成内存序语言就是unlock 操作具有 release 语义lock 操作具有 acquire 语义。当你调用mutex.lock()时相当于在这个线程上执行了一个 acquire 操作调用mutex.unlock()时执行了一个 release 操作。所以你不需要在锁内为共享变量的读写作额外标记——锁本身就构建了足够强的内存屏障。这也解释了为什么“锁内套原子变量”通常是无意义的锁已经把你需要的顺序和可见性都提供了原子变量额外加的内存序只是重复劳动。原子变量的真正用武之地是在不可能使用互斥锁的低延迟或无锁场景比如跨线程的计数器、状态标志、引用计数、无锁队列。在这些场景里原子变量配合合适的内存序能达到互斥锁 10 倍以上的吞吐量。6.2 自旋锁的实现一个原子操作到锁的完整演练既然聊到这里顺便用原子变量实现一个简单的自旋锁这也最能检验你对 acquire/release 的理解程度class SpinLock { public: void lock() { bool expected false; while (!flag.compare_exchange_weak(expected, true, std::memory_order_acquire)) { expected false; // 失败后需要重置 expected } } void unlock() { flag.store(false, std::memory_order_release); } private: std::atomicbool flag{false}; };compare_exchange_weak用的是 acquire 语义保证一旦成功获取锁之前持有者在锁内写入的数据对当前线程可见unlock用 release 语义保证当前线程在锁内所做的写操作在解锁后对其他线程可见。这套组合和标准的互斥锁语义完全等价只是没有休眠唤醒和系统调用开销。实际工程中用它做短临界区的保护绰绰有余但要注意自旋等待可能会空耗 CPU临界区超过几百纳秒的场景还是老老实实用std::mutex。6.3 为什么不建议“锁 原子变量”混用这里还有一个实践中的常见误区在持锁状态下继续做原子操作。比如std::mutex mtx; std::atomicint count{0}; void bad_example() { std::lock_guardstd::mutex lock(mtx); count.fetch_add(1, std::memory_order_seq_cst); }这个写法的问题在于冗余的同步开销。锁本身已经足够保证共享数据的顺序一致性了你再用 seq_cst 的原子操作等于同一件事做了两遍白白多付出了一次内存屏障和原子读改写指令的成本。正确的做法是二选一只有临界区内的普通变量被保护就全部用普通变量如果连锁都不想用就全部用原子变量并仔细设计内存序。把两者混在一起不仅性能没有提升还让代码的语义变得难以分析。7. 工具与实践建议怎样验证你的代码没有踩雷理论说得再多最终要靠工具和测试来兜底。我在实际项目里发现内存模型的 bug 往往“测不出来”因为它是概率性的、与编译器和 CPU 调度时机强相关的。7.1 TSan 和正确测试策略在缓解内存序风险的工具中TSanThreadSanitizer是其中最建议优先尝试的。你不需要修改业务代码只需要在编译时加上-fsanitizethread参数运行时它就会检测数据竞争并报告具体位置。但 TSan 不是银弹。它只能检测“数据竞争”两个线程同时访问同一内存位置且至少一个是写操作且没有任何同步机制对逻辑层面的可见性问题无能为力。比如你用的是 acquire/release但 acquire 的位置放错了TSan 可能不会报错因为从它的视角看原子操作之间已经建立了同步。所以更可靠的策略是压力测试 架构复现 语义审查三管齐下。压力测试用来制造冲突概率架构复现是拿到弱内存模型的机器上跑同样的测试语义审查则是回到代码层面逐一确认每一条跨线程数据传递的 happens-before 关系是否成立。7.2 用标准库之外的手段验证内存序如果你在实现无锁数据结构可以考虑在关键路径上插入临时检查逻辑——比如在消费端校验读到的数据版本号是否单调递增。这种“业务层面的校验”往往能最快地暴露内存序问题。另外编译器的原子内置函数builtins提供了一组和std::atomic对齐的语法但在某些底层代码中会直接映射到 CPU 的屏障指令。比如 GCC/Clang 的__atomic_load_n、__atomic_store_n。如果你手头有不能使用 STL 的嵌入式工程可以参照这些内置函数的语义来选择内存序。7.3 我维护并发队列的一些心得讲点个人实践。我在某个基础组件库中维护过一个多生产者多消费者的无锁队列核心是一个有界环形缓冲。最初版本为了“性能最大化”所有索引更新都用了memory_order_relaxed结果在高负载下频繁出现队列空/满状态误判。排查后发现问题的本质在于每一个线程在更新写索引后另一个线程需要立刻读到新索引才能继续下一步操作——这里缺失的是读写之间的顺序关系。后来我把索引的更新统一为fetch_add(..., memory_order_acq_rel)数据本身的读写依然可以用 relaxed因为索引同步已经建立了跨线程顺序问题彻底解决性能几乎没损耗。这件事让我总结出一个准则内存序不是“要不要加”的问题而是“加在哪里、加多强”的问题。无锁代码的每一行都要问自己这个变量谁在写谁在读跨线程的顺序依赖是什么如果答不上来就不要用 relaxed。回到文章开头那个线上服务的案例最终修复方案也很简单把那个状态标志的原子操作从 relaxed 改为 acquire/release并重构了状态更新的调用路径确保所有共享数据的写入都发生在 release 之前、读取都发生在 acquire 之后。部署后观察了两周数据不一致的告警彻底消失。事后复盘时我经常想如果最开始写代码时就把内存模型的三个问题想清楚这个坑原本是可以完全避免的。所以我的建议始终是在你的并发代码里永远不要把内存序当作可以后面再补的优化项。它和治疗数据竞争一样是编码时就要考虑进去的设计约束不是出问题之后的补丁。先确保语义正确再谈性能优化——这条铁律在内存模型面前尤为适用。
阅读完成 · 觉得有帮助?
咨询建站