并发面试必备系列之并发基础与内存模型

3,117 阅读30分钟

坐标上海松江高科技园,诚聘高级前端工程师/高级 Java 工程师,有兴趣的看 JD:www.lagou.com/jobs/636156…

并发面试必备系列之并发基础与内存模型

《Awesome Interviews》 归纳的常见面试题中,无论前后端,并发与异步的相关知识都是面试的中重中之重,本系列即对于面试中常见的并发知识再进行回顾总结;你也可以前往 《Awesome Interviews》,在实际的面试题考校中了解自己的掌握程度。也可以前往《Java 实战》、《Go 实战》等了解具体编程语言中的并发编程的相关知识。

随着硬件性能的迅猛发展与大数据时代的来临,为了让代码运行得更快,单纯依靠更快的硬件已无法满足要求,并行和分布式计算是现代应用程序的主要内容;我们需要利用多个核心或多台机器来加速应用程序或大规模运行它们,并发编程日益成为编程中不可忽略的重要组成部分。

简单定义来看,如果执行单元的逻辑控制流在时间上重叠,那它们就是并发(Concurrent)的;由此定义可扩展到非常广泛的概念,其向下依赖于操作系统、存储等,与分布式系统、微服务等,而又会具体落地于 Java 并发编程、Go 并发编程、JavaScript 异步编程等领域。云计算承诺在所有维度上(内存、计算、存储等)实现无限的可扩展性,并发编程及其相关理论也是我们构建大规模分布式应用的基础。

并发编程

并发与并行

并发就是可同时发起执行的程序,指程序的逻辑结构;并行就是可以在支持并行的硬件上执行的并发程序,指程序的运⾏状态。换句话说,并发程序代表了所有可以实现并发行为的程序,这是一个比较宽泛的概念,并行程序也只是他的一个子集。并发是并⾏的必要条件;但并发不是并⾏的充分条件。并发只是更符合现实问题本质的表达,目的是简化代码逻辑,⽽不是使程序运⾏更快。要是程序运⾏更快必是并发程序加多核并⾏。

简言之,并发是同一时间应对(dealing with)多件事情的能力;并行是同一时间动手做(doing)多件事情的能力。

image.png

并发是问题域中的概念——程序需要被设计成能够处理多个同时(或者几乎同时)发生的事件;一个并发程序含有多个逻辑上的独立执行块,它们可以独立地并行执行,也可以串行执行。而并行则是方法域中的概念——通过将问题中的多个部分并行执行,来加速解决问题。一个并行程序解决问题的速度往往比一个串行程序快得多,因为其可以同时执行整个任务的多个部分。并行程序可能有多个独立执行块,也可能仅有一个。

具体而言,早期的 Redis(6.0 版本后也引入了多线程) 会是一个很好地区分并发和并行的例子,它本身是一个单线程的数据库,但是可以通过多路复用与事件循环的方式来提供并发地 IO 服务。这是因为多核并行本质上会有很大的一个同步的代价,特别是在锁或者信号量的情况下。因此,Redis 利用了单线程的事件循环来保证一系列的原子操作,从而保证了即使在高并发的情况下也能达到几乎零消耗的同步。再引用下 Rob Pike 的描述:

A single-threaded program can definitely provides concurrency at the IO level by using an IO (de)multiplexing mechanism and an event loop (which is what Redis does).

并发维度

线程级并发

从 20 世纪 60 年代初期出现时间共享以来,计算机系统中就开始有了对并发执行的支持;传统意义上,这种并发执行只是模拟出来的,是通过使一台计算机在它正在执行的进程间快速切换的方式实现的,这种配置称为单处理器系统。从 20 世纪 80 年代开始,多处理器系统,即由单操作系统内核控制的多处理器组成的系统采用了多核处理器与超线程(HyperThreading)等技术允许我们实现真正的并行。多核处理器是将多个 CPU 集成到一个集成电路芯片上:

image

超线程,有时称为同时多线程(simultaneous multi-threading),是一项允许一个 CPU 执行多个控制流的技术。它涉及 CPU 某些硬件有多个备份,比如程序计数器和寄存器文件;而其他的硬件部分只有一份,比如执行浮点算术运算的单元。常规的处理器需要大约 20 000 个时钟周期做不同线程间的转换,而超线程的处理器可以在单个周期的基础上决定要执行哪一个线程。这使得 CPU 能够更好地利用它的处理资源。例如,假设一个线程必须等到某些数据被装载到高速缓存中,那 CPU 就可以继续去执行另一个线程。

指令级并发

在较低的抽象层次上,现代处理器可以同时执行多条指令的属性称为指令级并行。实每条指令从开始到结束需要长得多的时间,大约 20 个或者更多的周期,但是处理器使用了非常多的聪明技巧来同时处理多达 100 条的指令。在流水线中,将执行一条指令所需要的活动划分成不同的步骤,将处理器的硬件组织成一系列的阶段,每个阶段执行一个步骤。这些阶段可以并行地操作,用来处理不同指令的不同部分。我们会看到一个相当简单的硬件设计,它能够达到接近于一个时钟周期一条指令的执行速率。如果处理器可以达到比一个周期一条指令更快的执行速率,就称之为超标量(Super Scalar)处理器。

单指令、多数据

在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执行的操作,这种方式称为单指令、多数据,即 SIMD 并行。例如,较新的 Intel 和 AMD 处理器都具有并行地对 4 对单精度浮点数(C 数据类型 float)做加法的指令。

同步、异步、阻塞、非阻塞

在并发与并行的基础概念之后,我们还需要了解同步、异步、阻塞与非阻塞这几个概念的关系与区别。

同步即执行某个操作开始后就一直等着按部就班的直到操作结束,异步即执行某个操作后立即离开,后面有响应的话再来通知执行者。从编程的角度来看,如果同步调用,则调用的结果会在本次调用后返回。如果异步调用,则调用的结果不会直接返回。会返回一个 Future 或者 Promise 对象来供调用方主动/被动的获取本次调用的结果。

而阻塞与非阻塞在并发编程中,主要是从对于临界区公共资源或者共享数据竞态访问的角度来进行区分。某个操作需要的共享资源被占用了,只能等待,称为阻塞;某个操作需要的共享资源被占用了,不等待立即返回,并携带错误信息回去,期待重试,则称为非阻塞。

值得一提的是,在并发 IO 的讨论中,我们还会出现同步非阻塞的 IO 模型,这是因为 IO 操作(read/write 系统调用)其实包含了发起 IO 请求与实际的 IO 读写这两个步骤。阻塞 IO 和非阻塞 IO 的区别在于第一步,发起 IO 请求的进程是否会被阻塞,如果阻塞直到 IO 操作完成才返回那么就是传统的阻塞 IO,如果不阻塞,那么就是非阻塞 IO。同步 IO 和异步 IO 的区别就在于第二步,实际的 IO 读写(内核态与用户态的数据拷贝)是否需要进程参与,如果需要进程参与则是同步 IO,如果不需要进程参与就是异步 IO。如果实际的 IO 读写需要请求进程参与,那么就是同步 IO;因此阻塞 IO、非阻塞 IO、IO 复用、信号驱动 IO 都是同步 IO。

并发级别

在实际的部署环境下,受限于 CPU 的数量,我们不可能无限制地增加线程数量,不同场景需要的并发需求也不一样;譬如秒杀系统中我们强调高并发高吞吐,而对于一些下载服务,则更强调快响应低时延。因此根据不同的需求场景我们也可以定义不同的并发级别:

  • 阻塞:阻塞是指一个线程进入临界区后,其它线程就必须在临界区外等待,待进去的线程执行完任务离开临界区后,其它线程才能再进去。

  • 无饥饿:线程排队先来后到,不管优先级大小,先来先执行,就不会产生饥饿等待资源,也即公平锁;相反非公平锁则是根据优先级来执行,有可能排在前面的低优先级线程被后面的高优先级线程插队,就形成饥饿

  • 无障碍:共享资源不加锁,每个线程都可以自有读写,单监测到被其他线程修改过则回滚操作,重试直到单独操作成功;风险就是如果多个线程发现彼此修改了,所有线程都需要回滚,就会导致死循环的回滚中,造成死锁

  • 无锁:无锁是无障碍的加强版,无锁级别保证至少有一个线程在有限操作步骤内成功退出,不管是否修改成功,这样保证了多个线程回滚不至于导致死循环

  • 无等待:无等待是无锁的升级版,并发编程的最高境界,无锁只保证有线程能成功退出,但存在低级别的线程一直处于饥饿状态,无等待则要求所有线程必须在有限步骤内完成退出,让低级别的线程有机会执行,从而保证所有线程都能运行,提高并发度。

量化模型

多线程不意味着并发,但并发肯定是多线程或者多进程;多线程存在的优势是能够更好的利用资源,有更快的请求响应。但是我们也深知一旦进入多线程,附带而来的是更高的编码复杂度,线程设计不当反而会带来更高的切换成本和资源开销。如何衡量多线程带来的效率提升呢,我们需要借助两个定律来衡量。

Amdahl 定律

Amdahl 定律可以用来计算处理器平行运算之后效率提升的能力,其由 Gene Amdal 在 1967 年提出;它描述了在一个系统中,基于可并行化和串行化的组件各自所占的比重,程序通过获得额外的计算资源,理论上能够加速多少。任何程序或算法可以按照是否可以被并行化分为可以被并行化的部分 1 - B 与不可以被并行化的部分 B,那么根据 Amdahl 定律,不同的并行因子的情况下程序的总执行时间的变化如下所示:

如果 F 是必须串行化执行的比重,那么 Amdahl 定律告诉我们,在一个 N 处理器的机器中,我们最多可以加速:

当 N 无限增大趋近无穷时,speedup 的最大值无限趋近 1/F,这意味着一个程序中如果 50% 的处理都需要串行进行的话,speedup 只能提升 2 倍(不考虑事实上有多少线程可用);如果程序的 10% 需要串行进行,speedup 最多能够提高近 10 倍。

Amdahl 定律同样量化了串行化的效率开销。在拥有 10 个处理器的系统中,程序如果有 10% 是串行化的,那么最多可以加速 5.3 倍(53 %的使用率),在拥有 100 个处理器的系统中,这个数字可以达到 9.2(9 %的使用率)。这使得无效的 CPU 利用永远不可能到达 10 倍。下图展示了随着串行执行和处理器数量变化,处理器最大限度的利用率的曲线。随着处理器数量的增加,我们很明显地看到,即使串行化执行的程度发 生细微的百分比变化,都会大大限制吞吐量随计算资源增加。

Amdahl 定律旨在说明,多核 CPU 对系统进行优化时,优化的效果取决于 CPU 的数量以及系统中的串行化程序的比重;如果仅关注于提高 CPU 数量而不降低程序的串行化比重,也无法提高系统性能。

Gustafson

系统优化某部件所获得的系统性能的改善程度,取决于该部件被使用的频率,或所占总执行时间的比例。

内存模型

如前文所述,现代计算机通常有两个或者更多的 CPU,一些 CPU 还有多个核;其允许多个线程同时运行,每个 CPU 在某个时间片内运行其中的一个线程。在存储管理一节中我们介绍了计算机系统中的不同的存储类别:

image

每个 CPU 包含多个寄存器,这些寄存器本质上就是 CPU 内存;CPU 在寄存器中执行操作的速度会比在主内存中操作快非常多。每个 CPU 可能还拥有 CPU 缓存层,CPU 访问缓存层的速度比访问主内存块很多,但是却比访问寄存器要慢。计算机还包括主内存(RAM),所有的 CPU 都可以访问这个主内存,主内存一般都比 CPU 缓存大很多,但速度要比 CPU 缓存慢。当一个 CPU 需要访问主内存的时候,会把主内存中的部分数据读取到 CPU 缓存,甚至进一步把缓存中的部分数据读取到内部的寄存器,然后对其进行操作。当 CPU 需要向主内存写数据的时候,会将寄存器中的数据写入缓存,某些时候会将数据从缓存刷入主内存。无论从缓存读还是写数据,都没有必要一次性全部读出或者写入,而是仅对部分数据进行操作。

并发编程中的问题,往往源于缓存导致的可见性问题、线程切换导致的原子性问题以及编译优化带来的有序性问题。以 Java 虚拟机为例,每个线程都拥有一个属于自己的线程栈(调用栈),随着线程代码的执行,调用栈会随之改变。线程栈中包含每个正在执行的方法的局部变量。每个线程只能访问属于自己的栈。调用栈中的局部变量,只有创建这个栈的线程才可以访问,其他线程都不能访问。即使两个线程在执行一段相同的代码,这两个线程也会在属于各自的线程栈中创建局部变量。因此,每个线程拥有属于自己的局部变量。所有基本类型的局部变量全部存放在线程栈中,对其他线程不可见。一个线程可以把基本类型拷贝到其他线程,但是不能共享给其他线程,而无论哪个线程创建的对象都存放在堆中。

原子性

所谓的原子性,就是一个或者多个操作在 CPU 执行的过程中不被中断的特性,CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符。我们在编程语言中部分看似原子操作的指令,在被编译到汇编之后往往会变成多个操作:

i++

# 编译成汇编之后就是:
# 读取当前变量 i 并把它赋值给一个临时寄存器;
movl i(%rip), %eax
# 给临时寄存器+1;
addl $1, %eax
# 把 eax 的新值写回内存
movl %eax, i(%rip)

我们可以清楚看到 C 代码只需要一句,但编译成汇编却需要三步(这里不考虑编译器优化,实际上通过编译器优化可以将这三条汇编指令合并成一条)。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。按照原子操作解决同步问题方式:依靠处理器原语支持把上述三条指令合三为一,当做一条指令来执行,保证在执行过程中不会被打断并且多线程并发也不会受到干扰。这样同步问题迎刃而解,这也就是所谓的原子操作。但处理器没有义务为任意代码片段提供原子性操作,尤其是我们的临界区资源十分庞大甚至大小不确定,处理器没有必要或是很难提供原子性支持,此时往往需要依赖于锁来保证原子性。

对应原子操作/事务在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。Java 内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized 和 Lock 来实现。由于 synchronized 和 Lock 能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

有序性

顾名思义,有序性指的是程序按照代码的先后顺序执行。现代编译器的代码优化和编译器指令重排可能会影响到代码的执行顺序。编译期指令重排是通过调整代码中的指令顺序,在不改变代码语义的前提下,对变量访问进行优化。从而尽可能的减少对寄存器的读取和存储,并充分复用寄存器。但是编译器对数据的依赖关系判断只能在单执行流内,无法判断其他执行流对竞争数据的依赖关系。就拿无锁环形队列来说,如果 Writer 做的是先放置数据,再更新索引的行为。如果索引先于数据更新,Reader 就有可能会因为判断索引已更新而读到脏数据。

禁止编译器对该类变量的优化,解决了编译期的重排序并不能保证有序性,因为 CPU 还有乱序执行(Out-of-Order Execution)的特性。流水线(Pipeline)和乱序执行是现代 CPU 基本都具有的特性。机器指令在流水线中经历取指、译码、执行、访存、写回等操作。为了 CPU 的执行效率,流水线都是并行处理的,在不影响语义的情况下。处理器次序(Process Ordering,机器指令在 CPU 实际执行时的顺序)和程序次序(Program Ordering,程序代码的逻辑执行顺序)是允许不一致的,即满足 As-if-Serial 特性。显然,这里的不影响语义依旧只能是保证指令间的显式因果关系,无法保证隐式因果关系。即无法保证语义上不相关但是在程序逻辑上相关的操作序列按序执行。从此单核时代 CPU 的 Self-Consistent 特性在多核时代已不存在,多核 CPU 作为一个整体看,不再满足 Self-Consistent 特性。

简单总结一下,如果不做多余的防护措施,单核时代的无锁环形队列在多核 CPU 中,一个 CPU 核心上的 Writer 写入数据,更新 index 后。另一个 CPU 核心上的 Reader 依靠这个 index 来判断数据是否写入的方式不一定可靠。index 有可能先于数据被写入,从而导致 Reader 读到脏数据。

在 Java 中与有序性相关的经典问题就是单例模式,譬如我们会采用静态函数来获取某个对象的实例,并且使用 synchronized 加锁来保证只有单线程能够触发创建,其他线程则是直接获取到实例对象。

if (instance == null) {
    synchronized(Singleton.class) {
        if (instance == null){
            instance = new Singleton();
        }
    }
}

不过虽然我们期望的对象创建的过程是:内存分配、初始化对象、将对象引用赋值给成员变量,但是实际情况下经过优化的代码往往会首先进行变量赋值,而后进行对象初始化。假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

可见性

所谓的可见性,即是一个线程对共享变量的修改,另外一个线程能够立刻看到。单核时代,所有的线程都是直接操作单个 CPU 的数据,某个线程对缓存的写对另外一个线程来说一定是可见的;譬如下图中,如果线程 B 在线程 A 更新了变量值之后进行访问,那么获得的肯定是变量 V 的最新值。多核时代,每颗 CPU 都有自己的缓存,共享变量存储在主内存。运行在某个 CPU 中的线程将共享变量读取到自己的 CPU 缓存。在 CPU 缓存中,修改了共享对象的值,由于 CPU 并未将缓存中的数据刷回主内存,导致对共享变量的修改对于在另一个 CPU 中运行的线程而言是不可见的。这样每个线程都会拥有一份属于自己的共享变量的拷贝,分别存于各自对应的 CPU 缓存中。

CPU 读写流程

传统的 MESI 协议中有两个行为的执行成本比较大。一个是将某个 Cache Line 标记为 Invalid 状态,另一个是当某 Cache Line 当前状态为 Invalid 时写入新的数据。所以 CPU 通过 Store Buffer 和 Invalidate Queue 组件来降低这类操作的延时。如图:

当一个核心在 Invalid 状态进行写入时,首先会给其它 CPU 核发送 Invalid 消息,然后把当前写入的数据写入到 Store Buffer 中。然后异步在某个时刻真正的写入到 Cache Line 中。当前 CPU 核如果要读 Cache Line 中的数据,需要先扫描 Store Buffer 之后再读取 Cache Line(Store-Buffer Forwarding)。但是此时其它 CPU 核是看不到当前核的 Store Buffer 中的数据的,要等到 Store Buffer 中的数据被刷到了 Cache Line 之后才会触发失效操作。而当一个 CPU 核收到 Invalid 消息时,会把消息写入自身的 Invalidate Queue 中,随后异步将其设为 Invalid 状态。和 Store Buffer 不同的是,当前 CPU 核心使用 Cache 时并不扫描 Invalidate Queue 部分,所以可能会有极短时间的脏读问题。当然这里的 Store Buffer 和 Invalidate Queue 的说法是针对一般的 SMP 架构来说的,不涉及具体架构。事实上除了 Store Buffer 和 Load Buffer,流水线为了实现并行处理,还有 Line Fill Buffer/Write Combining Buffer 等组件。

典型案例:并发加

可见性问题最经典的案例即是并发加操作,如下两个线程同时在更新变量 test 的 count 属性域的值,第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。

Thread th1 = new Thread(()->{
    test.add10K();
});

Thread th2 = new Thread(()->{
    test.add10K();
});

// 每个线程中对相同对象执行加操作
count += 1;

在 Java 中,如果多个线程共享一个对象,并且没有合理的使用 volatile 声明和线程同步,一个线程更新共享对象后,另一个线程可能无法取到对象的最新值。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。通过 synchronized 和 Lock 也能够保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

Cache Line & False Sharing | 缓存行与伪共享

缓存系统中是以缓存行(Cache Line)为单位存储的,缓存行是 2 的整数幂个连续字节,一般为 32-256 个字节。最常见的缓存行大小是 64 个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。

image.png

若两个变量放在同一个缓存行中,在多线程情况下,可能会相互影响彼此的性能。如上图所示,CPU1 上的线程更新了变量 X,则 CPU 上的缓存行会失效,同一行的 Y 即使没有更新也会失效,导致 Cache 无法命中。同样地,若 CPU2 上的线程更新了 Y,则导致 CPU1 上的缓存行又失效。如果 CPU 经常不能命中缓存,则系统的吞吐量则会下降。这就是伪共享问题。

解决伪共享问题,可以在变量的前后都占据一定的填充位置,尽量让变量占用一个完整的缓存行。如上图中,CPU1 上的线程更新了 X,则 CPU2 上的 Y 则不会失效。同样地,CPU2 上的线程更新了 Y,则 CPU1 的不会失效。参考 Java 内存布局可知,所有对象都有两个字长的对象头。第一个字是由 24 位哈希码和 8 位标志位(如锁的状态或作为锁对象)组成的 Mark Word。第二个字是对象所属类的引用。如果是数组对象还需要一个额外的字来存储数组的长度。每个对象的起始地址都对齐于 8 字节以提高性能。因此当封装对象的时候为了高效率,对象字段声明的顺序会被重排序成下列基于字节大小的顺序:

doubles (8) 和 longs (8)
ints (4) 和 floats (4)
shorts (2) 和 chars (2)
booleans (1) 和 bytes (1)
references (4/8)
<子类字段重复上述顺序>

一条缓存行有 64 字节, 而 Java 程序的对象头固定占 8 字节(32 位系统)或 12 字节(64 位系统默认开启压缩, 不开压缩为 16 字节)。我们只需要填 6 个无用的长整型补上 6*8=48 字节,让不同的 VolatileLong 对象处于不同的缓存行, 就可以避免伪共享了;64 位系统超过缓存行的 64 字节也无所谓,只要保证不同线程不要操作同一缓存行就可以。这个办法叫做补齐(Padding):

public final static class VolatileLong
{
    public volatile long value = 0L;
    public long p1, p2, p3, p4, p5, p6; // 添加该行,错开缓存行,避免伪共享
}

某些 Java 编译器会将没有使用到的补齐数据, 即示例代码中的 6 个长整型在编译时优化掉, 可以在程序中加入一些代码防止被编译优化。

public static long preventFromOptimization(VolatileLong v) {
	return v.p1 + v.p2 + v.p3 + v.p4 + v.p5 + v.p6;
}

屏障

编译器优化乱序和 CPU 执行乱序的问题可以分别使用优化屏障 (Optimization Barrier)和内存屏障 (Memory Barrier)这两个机制来解决:

  • 优化屏障 (Optimization Barrier):避免编译器的重排序优化操作,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。这就保证了编译时期的优化不会影响到实际代码逻辑顺序。
  • 内存屏障 (Memory Barrier)分为写屏障(Store Barrier)、读屏障(Load Barrier)和全屏障(Full Barrier),其作用有两个:防止指令之间的重排序、保证数据的可见性。

多处理器同时访问共享主存,每个处理器都要对读写进行重新排序,一旦数据更新,就需要同步更新到主存上 (这里并不要求处理器缓存更新之后立刻更新主存)。在这种情况下,代码和指令重排,再加上缓存延迟指令结果输出导致共享变量被修改的顺序发生了变化,使得程序的行为变得无法预测。为了解决这种不可预测的行为,处理器提供一组机器指令来确保指令的顺序要求,它告诉处理器在继续执行前提交所有尚未处理的载入和存储指令。同样的也可以要求编译器不要对给定点以及周围指令序列进行重排。这些确保顺序的指令称为内存屏障。具体的确保措施在程序语言级别的体现就是内存模型的定义。

POSIX、C++、Java 都有各自的共享内存模型,实现上并没有什么差异,只是在一些细节上稍有不同。这里所说的内存模型并非是指内存布 局,特指内存、Cache、CPU、写缓冲区、寄存器以及其他的硬件和编译器优化的交互时对读写指令操作提供保护手段以确保读写序。将这些繁杂因素可以笼统的归纳为两个方面:重排和缓存,即上文所说的代码重排、指令重排和 CPU Cache。简单的说内存屏障做了两件事情:拒绝重排,更新缓存

C++11 提供一组用户 API std::memory_order 来指导处理器读写顺序。Java 使用 happens-before 规则来屏蔽具体细节保证,指导 JVM 在指令生成的过程中穿插屏障指令。内存屏障也可以在编译期间指示对指令或者包括周围指令序列不进行优化,称之为编译器屏障,相当于轻量级内存屏障,它的工作同样重要,因为它在编译期指导编译器优化。屏障的实现稍微复杂一些,我们使用一组抽象的假想指令来描述内存屏障的工作原理。使用 MB_R、MB_W、MB 来抽象处理器指令为宏:

  • MB_R 代表读内存屏障,它保证读取操作不会重排到该指令调用之后。
  • MB_W 代表写内存屏障,它保证写入操作不会重排到该指令调用之后。
  • MB 代表读写内存屏障,可保证之前的指令不会重排到该指令调用之后。

这些屏障指令在单核处理器上同样有效,因为单处理器虽不涉及多处理器间数据同步问题,但指令重排和缓存仍然影响数据的正确同步。指令重排是非常底层的且实 现效果差异非常大,尤其是不同体系架构对内存屏障的支持程度,甚至在不支持指令重排的体系架构中根本不必使用屏障指令。具体如何使用这些屏障指令是支持的 平台、编译器或虚拟机要实现的,我们只需要使用这些实现的 API(指的是各种并发关键字、锁、以及重入性等,下节详细介绍)。这里的目的只是为了帮助更好 的理解内存屏障的工作原理。

内存屏障的意义重大,是确保正确并发的关键。通过正确的设置内存屏障可以确保指令按照我们期望的顺序执行。这里需要注意的是内存屏蔽只应该作用于需要同步的指令或者还可以包含周围指令的片段。如果用来同步所有指令,目前绝大多数处理器架构的设计就会毫无意义。

延伸阅读

您可以通过以下导航来在 Gitbook 中阅读笔者的系列文章,涵盖了技术资料归纳、编程语言与理论、Web 与大前端、服务端开发与基础架构、云计算与大数据、数据科学与人工智能、产品设计等多个领域:

此外,你还可前往 xCompass 交互式地检索、查找需要的文章/链接/书籍/课程;或者在 MATRIX 文章与代码索引矩阵中查看文章与项目源代码等更详细的目录导航信息。最后,你也可以关注微信公众号:『某熊的技术之路』以获取最新资讯。