Non-Profit, International

Spirit unsterblich.

Memory Order 再探

字数统计:1275 blog

之前的几篇文章中简单探讨了如何使用 std::atomic 及其特化进行编程,但是对于 memory_order 仍存一定疑虑,本文将从代码,编译器,CPU,内存的角度来介绍 memory_order 的实质。

多线程下程序执行三问题

之前的文章 C++ 和双重检查锁定模式(DCLP)的风险 介绍过由于编译器优化导致的“乱序执行”。

  • 一个浅显简单的例子就是,同时声明的 2 个变量 a 和 b,编译器并不对 a 和 b 的声明顺序做任何保证。更可能的情况是,编译器把变量 a 和 b 的声明移动到 a 和 b 分别使用之前。

问题 1: 编译器通常会以单线程的角度对代码进行优化,编译器只能确保代码满足逻辑需求,而不能保证代码以编写顺序执行。

  • 对于现代 CPU 来说,除了硬盘,内存,寄存器之外还有第四级储存,即 CPU cache,CPU cache 可以被多个 CPU 核心(逻辑处理单元)共享,如常见的 L2,L3 cache,也可以被每个核心独享,如常见的 L1 cache。对于共享的 cache,通常不用考虑数据一致性,但是对于独占的 cache,就必须考虑数据一致性了。

    所谓数据一致性,就是指同一个数据,可能在不同独占缓存中存有不同复制。例如对于同一个整数型变量 A,单线程下先将 A 改为 1,然后将 A 改为 0,最后的结果当然是 0。而如果变为一个线程(1 个核心)将 A 改为 1,另一个核心同时将 A 改为 0。此时线程 1 和线程 2 修改的是 A 的不同复制,分属于不同独占缓存。在某个时间点上,独占缓存中的数据被写入到内存中,如果不加以保护,A 就可能是 0 和 1 中的任意值。

问题 2: 每个线程都维护了自己的数据,对于同一变量的同时写入,如果不加以保护,则结果未知。

  • 对于 string 的拼接操作,拼接操作需要的数据大小可能超过独占缓存大小,此时在函数执行过程中,就存在着缓存和内存的数据交换。

问题 3: 大数据操作会导致缓存和内存交换,不同线程可能存在缓存和内存的交叉数据依赖,如果不加以保护,则线程不安全。

  • 现代 CPU 为了性能设计的非常先进,导致某些指令的执行顺序并不一定是设计时候的顺序(这个设计包括整个代码到二进制),导致指令执行顺序表现为乱序。

对于以上问题,C++ 提出了解决方案:锁和原子操作库。

问题 1

对于问题 1,锁和 memory_order(除了 relaxed`,relaxed 仅保证原子性)都会对编译器优化进行限制。

锁是最强的限制,和 seq_cst 是一样的效果,对于这两种情况,编译器会对代码执行顺序进行强约束,区别在于锁是针对于代码块的,而原子操作针对其他原子操作。

  • consume:当前线程(一般也可理解为函数)中依赖于当前加载的该值的读或写不能被重排到此加载前。其他释放同一原子变量的线程的对数据依赖变量的写入,为当前线程所可见。
  • acquire:当前线程中读或写不能被重排到此加载前。其他释放同一原子变量的线程的所有写入,能为当前线程所见。
  • release:当前线程中的读或写不能被重排到此存储后。当前线程的所有写入,可见于获得该同一原子变量的其他线程),并且对该原子变量的带依赖写入变得对于其他消费同一原子对象的线程可见。
  • acq_rel:带此内存顺序的读修改写操作既是获得操作又是释放操作。当前线程的读或写内存不能被重排到此存储前或后。所有释放同一原子变量的线程的写入可见于修改之前,而且修改可见于其他获得同一原子变量的线程。
  • seq_cst:带此内存顺序的加载操作进行获得操作,存储操作进行释放操作,而读修改写操作进行获得操作和释放操作,再加上存在一个单独全序,其中所有线程以同一顺序观测到所有修改。

问题 2,3

锁和原子操作对于 CPU cache 的访问可能会产生额外的同步指令(如 mfence),将 CPU cache 和内存进行同步,此时多线程退化为单线程执行,保障了线程安全。

除了使用原子操作外,标准还额外提供了 std::atomic_thread_fencestd::atomic_signal_fence 来规定 memory_order,区别是前者可能存在潜在的 CPU 指令,后者只影响编译器优化。

参考:

若无特殊声明,本人原创文章以 CC BY-SA 3.0 许可协议 提供。