C++ memory order循序渐进(一)—— 多核编程和memory model

目录

  • 1. 多核编程面临的问题
  • 2. 多核编程中的临界区保护
    • 2.1 互斥
    • 2.2 lockfree
    • 2.3 wait-free
    • 2.4 lock-free和wait-free 相关技术
  • 3. Memory model
    • 3.1 reorder类型和Memory model的强弱
    • 3.2 Compiler Barrier和Runtime Memory Barrier
  • 4. c++ 11 memory order

前面看brpc的源码的时候发现很多地方为了追求性能很多地方都指定了memory_order,于是也去专门学习了一阵,同时准备写博客记录下,原本觉得一两篇就够了,但是一路看过来感觉要讲明白一两篇着实不够,所以打算写一个系列,循序渐进地聊一聊C++ 11标准正式引入的memory order,这是第一篇,算是个引子吧,主要是聊聊memory order相关的lock free和memory model,方便引出c++ memory order,如有不准确的地方欢迎大家指正。

1. 多核编程面临的问题

在cpu的发展史上,早期的性能提升主要来自于主频和架构的提升,在这条道路出现瓶颈后,多核cpu开始流行起来,到目前为止,从桌面cpu到各类移动设备cpu,多核心几乎已经成为标配,很显然,多核心可以同时执行能极大提高性能,但也对硬件架构和软件编写提出了更大的挑战,各核心都有自己的cache,还有不同层级的cache,彼此共享内存,一个典型的多核cpu架构如下:
C++ memory order循序渐进(一)—— 多核编程和memory model_第1张图片
利用多核的核心在于各个核之间互相配合完成任务,如何进行各个核心之间的数据同步(各核心所属L1 cache的数据同步)是关键问题,为了追求高性能,现代cpu使用的是一套包括MESI协议、store buffer、invalid queue等技术在内的数据同步方式,以及流水线乱序的执行模式,数据在各个核之间的一致性和可见性并不是那么理想,再加上编译器也会做优化,最终的结果就是各个核的指令执行顺序和各个变量值的可见性变得不确定,从某种意义上都可以统称为重排,也就是我们观察到原本应该有个全序的内存读写操作被打乱了,当然,cpu和编译器也是有底线的,无论产生什么样的重排,都会保证对于单线程内部的执行结果不会有任何区别,但是进入多核时代后,原本人畜无害的重排可能会出现大问题,因为单个核的内部的执行和优化并不会考虑和其他的核互动,对于单纯执行指令并不了解业务的cpu来说也没法做到兼顾乱序带来的性能优化和多核之间的同步,一个简单的例子如下:
C++ memory order循序渐进(一)—— 多核编程和memory model_第2张图片
对于Thread1内部,p和ready没有关联,完全可以被重排而不影响正确性,而Thread2依赖ready做标识位,一旦重排,Thread2在看到ready=true的时候p都可能没有init,显然这是有问题的。因此我们需要有手段来控制此类操作,在这个例子里就是t2一旦看到ready为true就能100%确保p已经init了。而c++ 11引入的的memory order就能帮我们做到相关的事情。

所幸的是,各种对并行编程支持较好的高级语言发展到现在都有许多成熟的工具能够让我们方便地进行多核编程不用关心底层的这些东西,最典型的就是封装完善的锁,接触过多线程编程的程序员对锁肯定不会陌生,锁很好理解,用起来也很方便,能够让程序员在不需要知道太多底层细节地情况下写出正确的多核环境适用的代码,但是在并发很高的时候锁竞争很容易成为瓶颈,因此在进行多核编程的某些场景下锁并不能满足要求。

2. 多核编程中的临界区保护

在正式介绍memory model之间,我想先聊聊多核并发编程中最核心的技术:临界区保护,利用多线程做并发的任务中通常都会有公共的临界区,比如最常用的一种数据结构:并发队列,生产者和消费者需要访问队列的公共内存进行写和读。目前对于临界区保护通常可以分为三个级别:互斥、lock-free和wait-free。

2.1 互斥

最简单大家也最熟悉的的临界区保护技术自然是互斥,每个线程访问之前都需要获得互斥锁,如果被别的线程占用了就阻塞等待。这是一种典型的悲观锁算法,很明显,当进入临界区的线程发生阻塞,或被操作系统换出时,会出现全局阻塞,因为获得锁的线程被换出无法执行操作,而未获得锁的线程也只能一同等待,出现了阻塞传播,如果另一个线程先进入临界区,有可能反而可以更快顺利完成。因为存在全局阻塞的可能性,采用互斥技术进行临界区保护的算法有着最低的阻塞容忍能力。

2.2 lockfree

程序员总是追求极致的,在各路大神的引领下,针对互斥的阻塞问题,,Lock free的概念应运而生并逐渐流行起来。Lock free programing,字面意思就是无锁编程,很多人的理解是成没有用到各类显式锁的编程,这个理解并不准确,其实主要是取自非阻塞算法等级中的一种分类术语,本质上是一种乐观锁算法。这个概念有着不同的表述方式,这里先贴一个preshing大佬博客里通俗易懂的图:
C++ memory order循序渐进(一)—— 多核编程和memory model_第3张图片
维基百科上的定义如下:
Lock free允许单独的线程个体阻塞,但是会保证系统整体上的吞吐,如果一个算法对应的程序的线程在运行了足够长时间的情况下,至少有一个线程取得了进展,那么我们说这个算法是lock-free的。

概括起来就是,如果涉及到共享内存的多线程代码在多线程执行下不可能互相影响导致被hang住,不管OS如何调度线程,至少有一个线程在做有用的事,那么就是lock-free。前面讲到了使用了锁的代码肯定不是lock free,因为一个线程加锁后如果被系统切出去了其他所有线程都处于等待中。但是没用锁也不一定是lock free,因为普通的代码逻辑也可能会导致一个线程hang住另一个线程。锁之所以在高并发的时候表现很差,主要原因就是加锁的线程会hang住其他待加锁的线程,lock-free可以很好的解决这一问题。

具体到实现上,本质上就是首先假设临界区不存在竞争,各个线程直接开始临界区的执行,但是通过良好的设计,让这段预先的执行是无冲突可回滚的。最终有一个需要同步的提交操作,一般基于原子变量CAS,或者版本校验等机制完成。在提交阶段如果发生冲突,那么被仲裁为失败的各方需要对临界区预执行进行回滚,并重新发起一轮尝试。

需要强调的一点是,并不是说lock-free的算法就一定比加锁的算法好,lock-free需要处理更多更复杂的race condition和ABA等问题,编写出合理的lock-free代码也需要更深厚的功底,需要对底层有更多地了解,完成相同目的的代码会比用锁更复杂,执行时间可能更长,代码也更难理解。很多场景合理地使用锁就能很好的胜任,lock-free和锁之间在应用场景上更多的是一种互补的关系。lock-free算法的价值在于其保证了一个或所有线程始终在做有用的事,而不是绝对的高性能。但lock-free相较于锁在并发度高(竞争激烈导致上下文切换开销变得突出)的某些场景下会有很大的性能优势,比如实现一个多线程的lock-free queue,总的来说,在多核环境下,lock-free是很有意义的。

2.3 wait-free

lock-free技术主要解决了临界区内的阻塞传播问题,但是本质上,依然是多个线程排队顺序经过临界区。而wait-free级别和lock-free的主要区别也就体现在这个吞吐的问题上,在无全局停顿的基础上,lock-free进一步保障了任意算法参与线程,都应该在有限的步骤内完成。不只是整体算法时时刻刻存在有效计算,每个线程视角依然是需要持续进行有效计算。这就要求了多线程在临界区内不能被细粒度地串行起来,而必须是同时都能进行有效计算。虽然理论角度存在不少有Wait Free级别的算法,但大多并不具备工业使用价值。主要是由于wait-free限制了同时有进展,但是并没有描述这个进展有多快。因此进一步又提出了细分子类,以比较有实际意义的Wait-Free Population Oblivious级别来说,额外限制了每个参与线程必须要在预先可给出的明确执行周期内完成,且这个周期不能和与参与线程数相关。这一点明确拒绝了一些类似线程间协作的方案(这些方案往往引起较大的缓存竞争),以及一些需要很长很长的有限步来完成的设计。时至今日各种数据结构上工业可用的wait-free算法依旧是一项持续探索中的领域。

2.4 lock-free和wait-free 相关技术

lock-free和wait-free编程既然要抛弃传统意义上的锁,那么往更底层走自然成为必然,其中最重要的两个相关技术就是原子操作和控制memory order。

作为程序员或多或少对原子操作都有一定概念,cpu保证没有线程能观察到原子操作的中间态,也就是对于一个原子操作,对于所有的线程来说要么做了要么没做,对于现代cpu,很多操作本身就是原子的。原子操作主要包括赋值原子操作、Read-Modify-Write(比如c++11里的fetch_add)、Compare-And-Swap(比如c++11里的compare_exchange_strong)。原子操作保证了各线程在进行共享内存的存取的时候能读到完整的值。

而memory order,字面意思是内存序,这是一个比较笼统的概念,对于cpu来说,执行代码对内存的操作就形成了memory order,而这个order受编译器和cpu运行时重排的影响,当然这两种重排是有方法可以限制的。,显示的锁在底层实现上往往通过手段保证了一些顺序和可见性,因为无锁算法没有显式的锁,将会直接观察到这些和代码顺序不一致的重排,给无锁算法的实现带来了挑战。c++ 11后也正式引入了memory order的概念,c++的memory oder更多则是一种约定和方法,给程序员提供了一种跨平台的通用方法来限制上述两种重排,这也极大地方便了我们实现无锁算法,在此之前,c++程序员如果想要限制重排,通常需要手动根据不同的平台选择使用特定的内存屏障指令。

关于程序运行时的memory order,重点提一下sequential consistency,假设没有重排,那么就是sequential consistency的内存序,也就是顺序一致,所有的线程都能看到一样的内存修改顺序,同时这个顺序和程序的源码是一致的,也就是各线程的内存操作是按顺序交织的,这种memory order下,程序的行为很好预期,但目前没有任何主流cpu默认提供sequential consistency保证,也就是都可能会进行不同程度的指令重排,最终会出现什么样的memory oder,则是由对应的memory model决定的。

3. Memory model

编译器在编译时和cpu在运行时都可能对代码和指令进行重排,导致出现和我们编写的代码不一致的情况,但是这些重排必须遵循某些规则,否则就乱套了,而Memory model,内存模型,就定义了特定处理器上或者工具链上的重排情况,换句话说,某个处理器或者工具链对于代码的重排会严格遵循对应的memory model,什么情况可以重排、什么情况不能重排。这里强调一点,这里讨论的重排只是针对单个线程内部在单个核内的指令的执行顺序问题。本质上,可以理解为我们指定memory_order,就是通过限制重排来来保证共享数据的可见性和正确同步。

3.1 reorder类型和Memory model的强弱

对内存的操作可以概括为读和写,也就是load和store,因此reorder也就可以整体上分为以下四种类型:
(1)Loadload reorder:两个读操作之间重排
(2)Loadstore reorder:原来在写操作之前的读操作重排到之后
(3)Storeload reorder:原来在读操作之前的写操作重排到之后
(4)Storestore reorder:两个写操作之间重排

这几种重排在不同的平台上是否会出现也决定了平台对应的memory model是强是弱,memory model既有软件层面的software memory model,又有硬件平台的hardware memory model,这里再引用一张preshing博客里的图,图中涵盖了典型的memory order,从左到右越来越强。总的来说memory model按照严格程度可以分为strong和weak两种,然后根据强弱程度的不同又可以再细分。
C++ memory order循序渐进(一)—— 多核编程和memory model_第4张图片
图中的DEC Alpha,也是被各种文章屡屡提起的明星示例,四种reorder都有可能发生,只要不改变单线程内部的执行正确性。

ARM架构的cpu则是在此基础上额外保证了数据依赖顺序。

至于X86/X64这种就属于强memory model的范畴了,此类平台只可能发生Storeload reorder。

上面说的这几种都是cpu平台的hardware memory model,对于上面提到过的最强的Sequential Consistency,目前的主流cpu都不会在硬件层面上直接强制提供此类保证,因为代价太大,很多时候也没有必要。但是如果确实有需要可以在软件层面进行相关限制让cpu实现,像图里的C++ 11的原子操作的默认内存序属于software memory model的范畴,就可以实现顺序一致的效果。

3.2 Compiler Barrier和Runtime Memory Barrier

无论是哪种memory model,hardware memory model还是 software memory model,上面说到的这些可能的重排都是指的在没有其他限制的情况,为了能够保证程序的正确性,CPU和编译器(语言)的设计者都给我们留了手段来改变这些重排,这类手段可以抽象成一个统一的概念barrier,屏障。无论上面提到的那些memory model是定义在哪个层面的,要想限制重排,具体到最后的程序的编写、编译、运行步骤,总是需要程序员用代码来限制编译阶段和运行阶段的重排,因此可以分为Compiler Barrier和runtime memory barrier。

Compiler Barrier,顾名思义,编译器层面的屏障,可以防止编译器在将源码转换成机器码的过程中重排,一个例子如下:
C++ memory order循序渐进(一)—— 多核编程和memory model_第5张图片
对于以上代码,用GCC4.6.1整体不开启优化进行编译得到的汇编代码如下:
C++ memory order循序渐进(一)—— 多核编程和memory model_第6张图片
可以看到没有重排,因为重排的目的就是优化。开启优化后如下:
C++ memory order循序渐进(一)—— 多核编程和memory model_第7张图片
出现了重排,如果想要整体开启优化,但是对于这部分代码不想要重排,那么就可以使用Compiler Barrier,在GCC里,asm volatile("" ::: “memory”)就是这么一个Compiler Barrier。对于以下代码:
C++ memory order循序渐进(一)—— 多核编程和memory model_第8张图片
开启优化编译后如下,和最初的一致:
C++ memory order循序渐进(一)—— 多核编程和memory model_第9张图片
上面说的asm volatile("" ::: “memory”)是显式的Compiler Barrier,它只能保证编译阶段不重排,在多核系统里,光做到这一点还不够,因为它没法对cpu核心运行时的重排做出限制。因此,在多核编程中,更多的时候我们需要同时对编译重排和运行时重排做出限制,所以我们还需要 run time memory barrier。

runtime memory barrier是限制运行时重排的手段,通常是通过特定的CPU 指令来实现,上面提到了重排基本可以分为四种类型,因此对应的barrier也有四种
(1)Loadload barrier:保证barrier前的读操作比barrier后的读操作先完成。
(2)Loadstore barrier:保证barrier前的读操作比barrier后的写操作先完成。
(3)Storeload barrier:保证barrier前的写操作比barrier后的读操作先完成。
(4)Storestore barrier:保证barrier前的写操作比barrier后的写操作先完成。

还是以GCC为例,GCC对于PowerPC,定义了一个宏:
#define RELEASE_FENCE() asm volatile(“lwsync” ::: “memory”)
lwsync是PowerPC平台的一个cpu指令,可以限制运行时重排,它同时具有LoadLoad barrier, LoadStore barrier StoreStore barrier的效果。具有runtime memory barrier的效果,而且需要重点注意的是,一旦在代码里插入了RELEASE_FENCE(),除了会插入一条lwsync指令限制运行时重排之外,它也会阻止编译器编译阶段重排,也就是说,RELEASE_FENCE()不光是runtime memory barrier ,也是隐式的Compiler Barrier。

4. c++ 11 memory order

Memory order概念的引入,可以说是c++ 11标准最重要的特性之一了。在此之前,标准C++甚至连thread都没有,自然也就没有像现在的memory order这种相对便利的控制内存序的机制,在各种各样的不同的平台面前,要用c++实现无锁代码没那么容易,更别说跨平台了,不同的平台底层控制内存序的指令之类的都不尽相同,c++引入了标准的memory order后将最底层的细节隐藏了起来,程序员只需要关注c++ memory order这个软件层面的memory order,可以理解为语言和编译器层面定了一套通用协议,程序员按照协议规定的方式编写代码就能得到想要的内存序,而不用关心是什么编译器编译、在什么平台运行,具体底层是怎么做的。有点像后端服务调用下游接口,只需要提供对方定义好的一些参数,对方根据你的参数返回对应的结果,你不用关心下游接口运行在哪、内部是怎么得到这些结果的。

后续将会针对一步步c++ 11的memory order展开详细介绍,希望能有时间保持更新频率。

参考:
https://preshing.com
https://github.com/apache/incubator-brpc/blob/master/docs/en/atomic_instructions.md
百度C++工程师的那些极限优化(并发篇)

你可能感兴趣的:(c++,memory_order,c++,多线程,c++11,并发编程,后端)