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


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

1. 多核编程面临的问题

在cpu的发展史上,早期的性能提升主要来自于主频和架构的提升,在这条道路出现瓶颈后,多核cpu开始流行起来,到目前为止,从桌面cpu到各类移动设备cpu,多核心几乎已经成为标配,很显然,多核心可以同时执行能极大提高性能,但也对硬件架构和软件编写提出了更大的挑战,各核心都有自己的cache,还有不同层级的cache,彼此共享内存,一个典型的多核cpu架构如下:
在这里插入图片描述

如何进行各个核心之间的数据同步是关键问题,CPU层面,为了追求高性能,使用的是一套包括MESI协议、store buffer、invalid queue等技术在内的数据同步方式,数据在各个核之间的一致性和可见性并不是那么理想,再加上编译器也会做优化,最终观察到的现象就是编译和运行的时候都会有重排的情况,也就是内存数据的同步和代码顺序不一致,当然,cpu和编译器也是有底线的,无论产生什么样的重排,都会保证对于单线程内部的执行结果不会有任何区别,但是进入多核时代后,原本人畜无害的重排可能会出现大问题,一个简单的例子如下:
在这里插入图片描述

对于Thread1内部,p和ready没有关联,有可能重排,而Thread2依赖ready做标识位,一旦重排,Thread2在看到ready=true的时候p都可能没有init,显然这是有问题的。所幸的是,各种对并行编程支持较好的高级语言发展到现在都有许多成熟的工具能够让我们方便地进行多核编程不用关心底层的这些东西,最典型的就是封装完善的锁,接触过多线程编程的程序员对锁肯定不会陌生,锁很好理解,用起来也很方便,能够让程序员在不需要知道太多底层细节地情况下写出正确的多核环境适用的代码,但是在并发很高的时候锁竞争很容易成为瓶颈,因此在进行多核编程的某些场景下锁并不能满足要求。

2. lock free

2.1 lock free的定义

程序员总是追求极致的,既然锁在在高并发的时候很容易成为瓶颈,我们需要其他的手段来进行代价更小的数据同步,在各路大神的引领下,Lock free的概念应运而生并逐渐流行起来。Lock free programing,字面意思就是无锁编程,很多人的理解是成没有用到各类显式锁的编程,这个理解并不准确,这个概念有着不同的表述方式,这里先贴一个preshing大佬博客里通俗易懂的图:
在这里插入图片描述

维基百科上的定义如下:
Lock free允许单独的线程个体阻塞,但是会保证系统整体上的吞吐,如果一个算法对应的程序的线程在运行了足够长时间的情况下,至少有一个线程取得了进展,那么我们说这个算法是lock-free的。

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

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

2.2 Lock free 相关技术

Lock free 编程既然要抛弃传统意义上的锁,那么往更底层走自然成为必然,实现lock 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运行时重排的影响,当然这两种重排是有方法可以限制的。因为lock free算法没有显式的锁,将会直接观察到这些和代码顺序不一致的重排,给lock free算法的实现带来了挑战。c++ 11后也正式引入了memory order的概念,c++的memory oder更多则是一种约定和方法,给程序员提供了一种跨平台的通用方法来限制上述两种重排,这也极大地方便了我们实现lock free算法,在此之前,程序员如果想要限制重排,可能需要手动调用不同平台的专有fence。

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

3. Memory model

编译器在编译时和cpu在运行时都可能对代码和指令进行重排,导致出现和我们编写的代码不一致的情况,但是这些重排必须遵循某些规则,否则就乱套了,而Memory model,内存模型,就定义了特定处理器上或者工具链上的重排情况,换句话说,某个处理器或者工具链对于代码的重排会严格遵循对应的memory model,什么情况可以重排、什么情况不能重排。

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两种,然后根据强弱程度的不同又可以再细分。
在这里插入图片描述

图中的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,顾名思义,编译器层面的屏障,可以防止编译器在将源码转换成机器码的过程中重排,一个例子如下:
在这里插入图片描述
对于以上代码,用GCC4.6.1整体不开启优化进行编译得到的汇编代码如下:
在这里插入图片描述

可以看到没有重排,因为重排的目的就是优化。开启优化后如下:
在这里插入图片描述

出现了重排,如果想要整体开启优化,但是对于这部分代码不想要重排,那么就可以使用Compiler Barrier,在GCC里,asm volatile(“” ::: “memory”)就是这么一个Compiler Barrier。对于以下代码:
在这里插入图片描述

开启优化编译后如下,和最初的一致:
在这里插入图片描述

上面说的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++实现lock free代码没那么容易,更别说跨平台了,不同的平台底层控制内存序的指令之类的都不尽相同,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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值