百度C++工程师的那些极限优化(并发篇)

图片

导读:对于工程经验比较丰富的同窗,并发应该也并非陌生的概念了,可是每一个人所理解的并发问题,却又每每并不统一,本文系统梳理了百度C++工程师在进行并发优化时所做的工做。前端

全文15706字,预计阅读时间24分钟。算法

1、背景

简单回顾一下,一个程序的性能构成要件大概有三个,即算法复杂度、IO开销和并发能力。因为现代计算机体系结构复杂化,形成不少时候,工程师的性能优化会更集中在算法复杂度以外的另外两个方向上,即IO和并发,在以前的《百度C++工程师的那些极限优化(内存篇)》中,咱们介绍了百度C++工程师工程师为了优化性能,从内存IO角度出发所作的一些优化案例。编程

图片

此次咱们就再来聊一聊另一个性能优化的方向,也就是所谓的并发优化。和IO方向相似,对于工程经验比较丰富的同窗,并发应该也并非陌生的概念了,可是每一个人所理解的并发问题,却又每每并不统一。因此下面咱们先回到一个更根本的问题,从新梳理一下所谓的并发优化。json

2、为何咱们须要并发?

是的,这个问题可能有些跳跃,可是在天然地进展到如何处理各类并发问题以前,咱们确实须要先停下来,回想一下为何咱们须要并发?后端

这时第一个会冒出来的概念可能会是大规模,例如咱们要设计大规模互联网应用,大规模机器学习系统。但是咱们仔细思考一下,不管使用了那种程度的并发设计,这样的规模化系统背后,都须要成百上千的实例来支撑。也就是,若是一个设计(尤为是无状态计算服务设计)已经能够支持某种小规模业务。那么当规模扩大时,极可能手段并非提高某个业务单元的处理能力,而是增长更多业务单元,并解决可能遇到的分布式问题。设计模式

其实真正让并发编程变得有价值的背景,更可能是业务单元自己的处理能力没法知足需求,例如一次请求处理时间太久,业务精细化致使复杂度积累提高等等问题。那么又是什么致使了近些年来,业务单元处理能力问题不足的问题呈现更加突出的趋势?数组

可能下面这个统计会很说明问题:缓存

https://www.karlrupp.net/2015/06/40-years-of-microprocessor-trend-data/)安全

图片

上图从一个长线角度,统计了CPU的核心指标参数趋势。从其中的晶体管数目趋势能够看出,虽然可能逐渐艰难,可是摩尔定律依然尚能维持。然而近十多年,出于控制功耗等因素的考虑,CPU的主频增加基本已经停滞,持续增长的晶体管转而用来构建了更多的核心。性能优化

从CPU厂商角度来看,单片处理器所能提供的性能仍是保持了持续提高的,可是单线程的性能增加已经显著放缓。从工程师角度来看,最大的变化是硬件红利再也不能透明地转化成程序的性能提高了。随时代进步,更精准的算法,更复杂的计算需求,都在对的计算性能提出持续提高的要求。早些年,这些算力的增加需求大部分还能够经过处理器更新换代来天然解决,但是随着主频增加停滞,若是没法利用多核心来加速,程序的处理性能就会随主频一同面临增加停滞的问题。所以近些年来,是否可以充分利用多核心计算,也愈来愈成为高性能程序的一个标签,也只有具有了充分的多核心利用能力,才能随新型硬件演进,继续表现出指数级的性能提高。而伴随多核心多线程程序设计的普及,如何处理好程序的并发也逐渐成了工程师的一项必要技能。

图片

上图描述了并发加速的基本原理,首先是对原始算法的单一执行块拆分红多个可以同时运行的子任务,并设计好子任务间的协同。以后利用底层的并行执行部件能力,将多个子任务在时间上真正重叠起来,达到真正提高处理速度的目的。

须要注意的是还有一条从下而上的反向剪头,主要表达了,为了正确高效地利用并行执行部件,每每会反向指导上层的并发设计,例如正确地数据对齐,合理的临界区实现等。虽然加速看似彻底是由底层并行执行部件的能力所带来的,程序设计上只须要作到子任务拆分便可。可是现阶段,执行部件对上层还没法达到透明的程度,致使这条反向依赖对于最终的正确性和性能依然相当重要。既了解算法,又理解底层设计,并结合起来实现合理的并发改造,也就成为了工程师的一项重要技能。

3、单线程中的并行执行

提到并行执行部件,你们的第一个印象每每时多核心多线程技术。不过在进入到多线程以前,咱们先来看看,即便是单线程的程序设计中,依然须要关注的那些并行执行能力。回过头再仔细看前文的处理器趋势图其实能够发现,虽然近年主频再也不增加,甚至稳中有降,可是单线程处理性能其实仍是有细微的提高的。这其实意味着,在单位时钟周期上,单核心的计算能力依然在提高,而这种提高,很大程度上就得益于单核心单线程内的细粒度并行执行能力。

3.1 SIMD

其中一个重要的细粒度并行能力就是SIMD(Single Instruction Multiple Data),也就是多个执行单元,同时对多个数据应用相同指令进行计算的模式。在经典分类上,通常单核心CPU被纳入SISD(Single Instruction Single Data),而多核心CPU被纳入MIMD(Mingle Instruction Multiple D ata),而GPU才被纳入SIMD的范畴。可是现代CPU上,除了多核心的MIMD基础模型,也同时附带了细粒度SIMD计算能力。

图片

图片

上图是Intel关于SIMD指令的一个示意图,经过增长更大位宽的寄存器实如今一个寄存器中,“压缩”保存多个较小位宽数据的能力。再经过增长特殊的运算指令,对寄存器中的每一个小位宽的数据元素,批量完成某种相同的计算操做,例如图示中最典型的对位相加运算。以这个对位相加操做为例,CPU只须要增大寄存器,内存传输和计算部件位宽,针对这个特殊的应用场景,就提高到了8倍的计算性能。相比将核心数通用地提高到8倍大小,这种方式付出的成本是很是少的,指令流水线系统,缓存系统都作到了复用。

从CPU发展的视角来看,为了可以在单位周期内处理更多数据,增长核心数的MIMD强化是最直观的实现路径。可是增长一套核心,就意味增长一套 完整的指令部件、流水线部件和缓存部件,并且实际应用时,还要考虑额外的核心间数据分散和聚合的传输和同步开销。一方面高昂的部件需求, 致使完整的核心扩展成本太高,另外一方面,多核心间传输和同步的开销针对小数据集场景额外消耗过大,还会进一步限制应用范围。为了最大限度利用好有限的晶体管,现代CPU在塑造更多核心的同时,也在另外一个维度上扩展单核心的处理和计算位宽,从而实现提高理论计算性能(核心数 * 数据宽度)的目的。

不过提起CPU上的SIMD指令支持,有一个绕不开的话题就是和GPU的对比。CPU上早期SIMD指令集(MMX)的诞生背景,和GPU的功能定位就十分相似,专一于加速图像相关算法,近些年又随着神经网络计算的兴起,转向通用矩阵类计算加速。可是因为GPU在设计基础上就以面向密集可重复计算负载设计,指令部件、流水线部件和缓存部件等能够远比CPU简洁,也所以更容易在量级上进行扩展。这就致使,当计算密度足够大,数据的传输和同步开销被足够冲淡的状况下(这也是典型神经网络计算的的特性),CPU仅做为控制流进行指挥,而数据批量传输到GPU协同执行反而 会更简单高效。

因为Intel自身对SIMD指令集的宣传,也集中围绕神经网络类计算来展开,而在当前工程实践经验上,主流的密集计算又以GPU实现为主。这就致使了很多CPU上SIMD指令集无用论应运而生,尤为是近两年Intel在AVX512初代型号上的降频事件,进一步强化了『CPU就应该作好CPU该作的事情』这一论调。可是单单从这一的视角来认识CPU上的SIMD指令又未免有些片面,容易忽视掉一些真正有意义的CPU上SIMD应用场景。

图片

对于一段程序来说,若是将每读取单位数据,对应的纯计算复杂度大小定义为计算密度,而将算法在不一样数据单元上执行的计算流的相同程度定义为模式重复度,那么能够以此将程序划分为4个象限。在大密度可重复的计算负载(典型的重型神经网络计算),和显著小密度和非重复计算负载(例如HTML树状解析)场景下,业界在CPU和GPU的选取上实际上是有相对明确“最优解”的。不过对于过渡地带,计算的重复特征没有那么强,  或者运算密度没有那么大的场景下,双方的弱点都会被进一步放大。即使是规整可重复的计算负载,随着计算自己强度减少,传输和启动成本逐渐显著。另外一方面,即使是不太规整可重复的计算负载,随着计算负荷加大,核心数不足也会逐渐成为瓶颈。这时候,引入SIMD的CPU和引入SIMT 的GPU间如何选择和使用,就造成了没有那么明确,见仁见智的权衡空间。

即便排除了重型神经网络,从程序的通常特性而言,具备必定规模的重复特性也是一种广泛现象。例如从概念上讲,程序中的循环段落,都或多或少意味着批量/重复的计算负载。尽管由于掺杂着分支控制,致使重复得没有那么纯粹,但这种必定规模的细粒度重复,正是CPU上SIMD发挥独特价值的地方。例如最多见的SIMD优化其实就是memcpy,现代的memcpy实现会探测CPU所能支持的SIMD指令位宽,并尽力使用来加速内存传输。另外一方面现代编译器也会利用SIMD指令来是优化对象拷贝,进行简单循环向量化等方式来进行加速。相似这样的一类优化方法偏『自动透明』,也是默默支撑着主频不变状况下,性能稍有上升的重要推手。

惋惜这类简单的自动优化能作到的事情还至关有限,为了可以充分利用CPU上的SIMD加速,现阶段还很是依赖程序层进行主动算法适应性改造,有 目的地使用,换言之,就是主动实施这种单线程内的并发改造。一个没法自动优化的例子就是《内存篇》中提到的字符串切分的优化,现阶段经过编译器分析还很难从循环 + 判断分支提取出数据并行pattern并转换成SIMD化的match&mask动做。而更为显著的是近年来一批针对SIMD指令从新设计的算法,例如Swiss Table哈希表,simdjson解析库,base64编解码库等,在各自的领域都带来了倍数级的提高,而这一类算法适应性改造,就已经彻底脱离了自动透明所能触及的范围。能够预知近些年,尤为随着先进工艺下AVX512降频问题的逐渐解决,还会/也须要涌现出更多的传统基础算法的SIMD改造。而熟练运用SIMD指令优化技术,也将成为C++工程师的一项必要技能。

3.2 OoOE

另外一个重要的单线程内并行能力就是乱序执行OoOE(Out of Order Execution)。经典教科书上的CPU流水线机制通常描述以下(经典5级RISC流水线)。

图片

指令简化表达为取指/译码/计算/访存/写回环节,当执行环节遇到数据依赖,以及缓存未命中等场景,就会致使总体停顿的产生。其中MEM环节的影响尤为显著,主要也是由于缓存层次的深化和多核心的共享现象,带来单次访存所需周期数良莠不齐的现象愈来愈严重。上图中的流水线在多层缓存下的表现,可能更像下图所示:

图片

为了减轻停顿的影响,现代面向性能优化的CPU通常引入了乱序执行结合超标量的技术。也就是一方面,对于重点执行部件,好比计算部件,访存部件等,增长多份来支持并行。另外一方面,在执行部件前引入缓冲池/队列机制,通用更长的预测执行来尽量打满每一个部件。最终从流水线模式,转向了更相似『多线程』的设计模式:

图片

乱序执行系统中,通常会将经过预测维护一个较长的指令序列,并构建一个指令池,经过解析指令池内的依赖关系,造成一张DAG(有向无环图) 组织的网状结构。经过对DAG关系的计算,其中依赖就绪的指令,就能够进入执行态,被提交到实际的执行部件中处理。执行部件相似多线程模型中的工做线程,根据特性细分为计算和访存两类。计算类通常有相对固定可预期的执行周期,而访存类因为指令周期差别较大,采用了异步回调的模型,经过Load/Store Buffer支持同时发起数十个访存操做。

乱序执行系统和传统流水线模式的区别主要体如今,当一条访存指令由于Cache Miss而没法当即完成时,其后无依赖关系的指令能够插队执行(相似于多线程模型中某个线程阻塞后,OS将其挂起并调度其余线程)。插队的计算类指令能够填补空窗充分利用计算能力,而插队的访存指令经过更早启动传输,让访存停顿期尽可能重叠来减少总体的停顿。所以乱序执行系统的效率,很大程度上会受到窗口内指令DAG的『扁平』程度的影响,依赖深度较浅的DAG能够提供更高的指令级并发能力,进而提供更高的执行部件利用率,以及更少的停顿周期。另外一方面,因为Load/Store Buffer也有最大的容量限制,处理较大区域的内存访问负载时,将可能带来更深层穿透的访存指令尽可能靠近排布,来提升访存停顿的重叠,也可以有效减小总体的停顿。

虽然理论比较清晰,但是在实践中,仅仅从外部指标观测到的性能表现,每每难以定位乱序执行系统内部的热点。最直白的CPU利用率其实只能表达线程未受阻塞,真实在使用CPU的时间周期,可是其实并不能体现CPU内部部件真正的利用效率如何。稍微进阶一些的IPC(Instruction Per Cyc le),能够相对深刻地反应一些利用效能,可是影响IPC的因素又多种多样。是指令并行度不足?仍是长周期ALU计算负载大?又或者是访存停顿太久?甚至多是分支预测失败率太高?真实程序中,这几项问题每每是并存的,并且单一地统计每每又难以统一比较,例如10次访存停顿/20次ALU 未打满/30个周期的页表遍历,到底意味着瓶颈在哪里?这个问题单一的指标每每就难以回答了。

3.3 TMAM

TMAM(Top-down Microarchitecture Analysis Method)是一种利用CPU内部PMU(Performance Monitoring Unit)计数器来从上至下分解定位部件瓶颈的手段。例如在最顶层,首先以标定最大指令完成速率为标准(例如Skylake上为单周期4条微指令),若是没法达到标定,则认为瓶颈在于未能充分利用部件。进一步细分以指令池为分界线,若是指令池未满,可是取指部件又没法满负荷输出微指令,就代表『前端』存在瓶颈。另外一种没法达到最大指令速率的因素,是『前端』虽然在发射指令到指令池,可是由于错误的预测,最终没有产出有效结果,这类损耗则被纳入『错误预测』。除此之外的问题就是由于指令池调度执行能力不足产生的反压停顿,这一类被归为『后端』瓶颈。进一步例如『后端』瓶颈还能够根 据,停顿发生时,是否伴随了ALU利用不充分,是否伴随了Load/Store Buffer满负荷等因素,继续进行分解细化,造成了一套总体的分析方法。例如针对Intel,这一过程能够经过pmu-tools来被自动完成,对于指导精细化的程序瓶颈分析和优化每每有很大帮助。

int array\[1024\];
for (size\_t i = 0; i < 1024; i += 2) {
 int a = array\[i\];
 int b = array\[i + 1\];
 for (size\_t j = 0; j < 1024; ++j) { 
 a = a + b;
 b = a + b;}
 array\[i\] = a;
 array\[i + 1\] = b;
}

例如这是里演示一个多轮计算斐波那契数列的过程,由于计算特征中深层循环有强指令依赖,且内层循环长度远大于常规乱序执行的指令池深度, 存在较大的计算依赖瓶颈,从工具分析也能够印证这一点。

图片图片

程序的IPC只有1,内部瓶颈也显示集中在『后端』内部的部件利用效率(大多时间只利用了一个port),此时乱序执行并无发挥做用。

int array\[1024\];
for (size\_t i = 0; i < 1024; i += 4) {
  int a = array\[i\];
  int b = array\[i + 1\];
  int c = array\[i + 2\];
  int d = array\[i + 3\];
  for (size\_t j = 0; j < 1024; ++j) {
    a = a + b;
    b = a + b;
    c = c + d;
    d = c + d;
  }
  array\[i\] = a;
  array\[i + 1\] = b;
  array\[i + 2\] = c;
  array\[i + 3\] = d;
}

这里演示了典型的的循环展开方法,经过在指令窗口内同时进行两路无依赖计算,提升了指令并行度,经过工具分析也能够确认到效果。

图片图片

不过实践中,可以在寄存器上反复迭代的运算并不常见,大多状况下比较轻的计算负载,搭配比较多的访存动做会更常常遇到,像下面的这个例子:

struct Line {     
    char data\[64\];
};
Line\* lines\[1024\]; // 其中乱序存放多个缓存行
for (size\_t i = 0; i < 1024; ++i) {   
  Line\* line = lines\[i\];
  for (size\_t j = 0; j < 64; ++j) {   
    line->data\[j\] += j; 
 }
}

这是一个非连续内存上进行累加计算的例子,随外层迭代会跳跃式缓存行访问,内层循环在连续缓存行上进行无依赖的计算和访存操做。

图片

能够看到,这一次的瓶颈到了穿透缓存后的内存访存延迟上,但同时内存访问的带宽并无被充分利用。这是由于指令窗口内虽然并发度不低,不过由于缓存层次系统的特性,内层循环中的多个访存指令,其实最终都是等待同一行被从内存加载到缓存。致使真正触发的底层访存压力并不足以打满传输带宽,可是程序却表现出了较大的停顿。

for (size\_t i = 0; i < 1024; i += 2) { 
  Line\* line1 = lines\[i\];
  Line\* line2 = lines\[i + 1\];
  ...
  for (size\_t j = 0; j < 64; ++j) { 
    line1->data\[j\] += j;
    line2->data\[j\] += j;
    ...
   }
 }

图片

经过调整循环结构,在每一轮内层循环中一次性计算多行数据,能够在尽可能在停顿到来的指令窗口内,让更多行出于同时从内存系统进行传输。从统计指标上也能够看出,瓶颈重心开始从穿透访存的延迟,逐步转化向访存带宽,而实际的缓存传输部件Fill    Buffer也开始出现了满负荷运做的状况。

3.4 总结一下单线程并发

现代CPU在遇到主频瓶颈后,除了改成增长核心数,也在单核心内逐步强化并行能力。若是说多进程多线程技术的普及,让多核心的利用技术多少不那么罕见和困难,那么单核心内的并行加速技术,由于更加黑盒(多级缓存加乱序执行),规范性不足(SIMD),相对普及度和利用率都会更差一些。虽然硬件更多的细节向应用层暴露让程序的实现更加困难,不过困难和机会每每也是伴随出现的,既然客观发展上这种复杂性增长已经无可避免,那么是否能善加利用也成了工程师进行性能优化时的一项利器。随着体系结构的进一步复杂化,可见的将来一段时间里,可否利用一些体系结构的原理和工具来进行优化,也会不可避免地成为服务端工程师的一项重要技能。

4、多线程并发中的临界区保护

相比单线程中的并发设计,多线程并发应该是更为工程师所熟悉的概念。现在,将计算划分到多线程执行的应用技术自己已经相对成熟了,相信各个服务端工程师都有各自熟悉的队列+线程池的小工具箱。在不作其余额外考虑的状况下,单纯的大任务分段拆分,提交线程池并回收结果可能也仅仅是几行代码就能够解决的事情了。真正的难点,其实每每不在于『拆』,而在于『合』的部分,也就是任务拆分中没法避免掉的共享数据操做环节。若是说更高的分布式层面,还能够尽量地利用Share Nothing思想,在计算发生以前,就先尽可能经过任务划分来作到尽量充分地隔离资源。可是深刻到具体的计算节点内部,若是再进行一些细粒度的拆分加速时,共享每每就难以完全避免了。如何正确高效地处理这些没法避免的共享问题,就涉及到并发编程中的一项重要技术,临界区保护。

4.1 什么是临界区

图片

算法并发改造中,通常会产生两类段落,一类是多个线程间无需交互就能够独立执行的部分,这一部分随着核心增多,能够顺利地水平扩展。而另外一类是须要经过操做共享的数据来完成执行,这部分操做为了可以正确执行,没法被多个核心同时执行,只能每一个线程排队经过。所以临界区内的代码,也就没法随着核心增多来扩展,每每会成为多线程程序的瓶颈点。也是由于这个特性,临界区的效率就变得相当重要,而如何保证各个线程安全地经过临界区的方法,就是临界区保护技术。

4.1.1 Mutual Exclusion

图片

最基本的临界区保护方法,就是互斥技术。这是一种典型的悲观锁算法,也就是假设临界区高几率存在竞争,所以须要先利用底层提供的机制进行仲裁,成功得到全部权以后,才进入临界区运行。这种互斥算法,有一个典型的全局阻塞问题,也就是上图中,当临界区内的线程发生阻塞,或被操做系统换出时,会出现一个全局执行空窗。这个执行空窗内,不只自身没法继续操做,未得到锁的线程也只能一同等待,形成了阻塞放大的现象。可是对于并行区,单一线程的阻塞只会影响自身,一样位于在上图中的第二次阻塞就是如此。

因为真实发生在临界区内的阻塞每每又是不可预期的,例如发生了缺页中断,或者为了申请一块内存而要先进行一次比较复杂的内存整理。这就会让阻塞扩散的问题更加严重,极可能改成让另外一个线程先进入临界区,反而能够更快顺利完成,可是如今必须全部并发参与者,都一块儿等待临界区持有者来完成一些并无那么『关键』的操做。由于存在全局阻塞的可能性,采用互斥技术进行临界区保护的算法有着最低的阻塞容忍能力,通常在『非阻塞算法』领域做为典型的反面教材存在。

4.1.2 Lock Free

图片

针对互斥技术中的阻塞问题,一个改良型的临界区保护算法是无锁技术。虽然叫作无锁,不过主要是取自非阻塞算法等级中的一种分类术语,本质上是一种乐观锁算法。也就是首先假设临界区不存在竞争,所以直接开始临界区的执行,可是经过良好的设计,让这段预先的执行是无冲突可回滚的。可是最终设计一个须要同步的提交操做,通常基于原子变量CAS(Compare And Swap),或者版本校验等机制完成。在提交阶段若是发生冲突,那么被仲裁为失败的各方须要对临界区预执行进行回滚,并从新发起一轮尝试。

无锁技术和互斥技术最大的区别是,临界区核心的执行段落是能够相似并行段落同样独立进行,不过又不一样于真正的并行段落,同时执行的临界区中,只有一个是真正有效的,其他最终将被仲裁为无效并回滚。可是引入了冗余的执行操做后,当临界区内再次发生阻塞时,不会像互斥算法那样在参与线程之间进行传播,转而让一个次优的线程成功提交。虽然从每一个并发算法参与线程的角度,存在没有执行『实质有效』计算的段落,可是这种浪费计算的段落,必定对应着另外一个参与线程执行了『有效』的计算。因此从整个算法层面,可以保证不会全局停顿,老是有一些有效的计算在运行。

4.1.3 Wait-Free

图片

无锁技术主要解决了临界区内的阻塞传播问题,可是本质上,多个线程依然是排队顺序通过临界区。形象来讲,有些相似交通中的三叉路口汇合, 不管是互斥仍是无锁,最终都是把两条车道汇聚成了一条单车道,区别只是指挥是否高明能保证没有断流出现。但是不管如何,临界区内全局吞吐下降成串行这点是共同的缺陷。

而Wait Free级别和无锁的主要区别也就体如今这个吞吐的问题上,在无全局停顿的基础上,Wait Free进一步保障了任意算法参与线程,都应该在有限的步骤内完成。这就和无锁技术产生了区别,不仅是总体算法时时刻刻存在有效计算,每一个线程视角依然是须要持续进行有效计算。这就要求了多线程在临界区内不能被细粒度地串行起来,而必须是同时都能进行有效计算。回到上面三叉路口汇聚的例子,就觉得着在Wait Free级别下,最终汇聚的道路依旧须要是多车道的,以保证能够同时都可以有进展。

图片

虽然理论角度存在很多有Wait Free级别的算法,不过大多为概念探索,并不具有工业使用价值。主要是因为Wait Free限制了同时有进展,可是并无描述这个进展有多快。所以进一步又提出了细分子类,以比较有实际意义的Wait-Free Population Oblivious级别来讲,额外限制了每一个参与线程必需要在预先可给出的明确执行周期内完成,且这个周期不能和与参与线程数相关。这一点明确拒绝了一些相似线程间协做的方案(这些方案每每引发较大的缓存竞争),以及一些须要很长很长的有限步来完成的设计。

图片

上图实例了一个典型的Wait Free Population Oblivious思路。进行临界区操做前,经过一个协同操做为参与线程分配独立的ticket,以后每一个参与线程能够经过获取到的ticket做为标识,操做一块独立的互不干扰的工做区,并在其中完成操做。工业可用的Wait Free算法通常较难设计,例如ticket机制要求在协调动做中原子完成工做区分配,而不少数据结构是不容易作到这样的拆分的。时至今日各类数据结构上工业可用的Wait    Free算法依旧是一项持续探索中的领域。

4.2 无锁不是万能的

从非阻塞编程的角度看,上面的几类临界区处理方案优劣有着显著的偏序关系,即Wait Free > Lock Free > Mutual Exclusion。这主要是从阻塞适应性角度进行的衡量,原理上并不能直接对应到性能纬度。可是依然很容易给工程师形成一个普适印象,也就是『锁是很邪恶的东西,不使用锁来实现算法能够显著提升性能』,再结合广为流传的锁操做自身开销很重的认知,不少工程师在实践中会有对锁敬而远之的倾向。那么,这个指导思想是不是彻底正确的?

让咱们先来一组实验:

// 在一个cache line上进行指定步长的斐波那契计算来模拟临界区计算负载
uint64\_t calc(uint64\_t\* sequence, size\_t size) {
    size\_t i;
    for (i = 0; i < size; ++i) {
        sequence\[(i + 1) & 7\] += sequence\[i & 7\];
    }
    return sequence\[i & 7\];
}
{   // Mutual Exclusion
    ::std::lock\_guard<::std::mutex> lock(mutex);
    sum += calc(sequence, workload);
}
{   // Lock Free / Atomic CAS
    auto current = atomic\_sum.load(::std::memory\_order\_relaxed);
    auto next = current;
    do {
        next = current + calc(sequence, workload);
    } while (!atomic\_sum.compare\_exchange\_weak(
                 current, next, ::std::memory\_order\_relaxed));
}
{   // Wait Free / Atomic Modify
    atomic\_sum.fetch\_add(calc(sequence, workload), ::std::memory\_order\_relaxed);
}

这里采用多线程累加做为案例,分别采用上锁后累加,累加后CAS提交,以及累加后FAA(Fetch And Add)提交三种方法对全局累加结果作临界区保护。针对不一样的并发数量,以及不一样的临界区负载,能够造成以下的三维曲线图。

其中Latency项除以临界区规模进行了归一,便于形象展现临界区负载变化下的临界区保护开销趋势,所以跨不一样负载等级下不具有横向可比性。Cycles项表示多线程协同完成总量为一样次数的累加,用到的CPU周期总和,整体随临界区负载变化有少许自然倾斜。100/1600两个截面图将3中算法叠加在一块儿展现,便于直观对比。

图片图片图片

图片

图片

从上面的数据中能够分析出这样一些信息

一、基于FAA的Wait Free模式各方面都显著赛过其余方法;

二、无锁算法相比互斥算法在平均吞吐上有必定优点,可是并无达到数量级水平;

三、无锁算法随竞争提高(临界区大小增大,或者线程增多),cpu消耗显著上升;

基于这些信息来分析,会发现一个和以前提到的『锁性能』的常规认知相悖的点。性能的分水岭并无出如今基于锁的互斥算法和无锁算法中间, 而是出如今同为『未使用锁』的Lock Free和Wait Free算法中间。并且从CPU消耗角度来看,对临界区比较复杂,竞争强度高的场景,甚至Lock Free由于『无效预测执行』过多反而引发了过多的消耗。这代表了锁操做自己的开销虽然稍重于原子操做,但其实也并不是洪水猛兽,而真正影响性能的,是临界区被迫串行执行所带来的并行能力折损。

所以当咱们遇到临界区保护的问题时,能够先思考一下,是否能够采用Wait Free的方法来完成保护动做,若是能够的话,在性能上可以接近彻底消除了临界区的效果。而在多数状况下,每每仍是要采用互斥或Lock Free来进行临界区的保护。此时临界区的串行不可避免,因此充分缩减临界区的占比是共性的第一要务,而是否进一步采用Lock Free技术来减小临界区保护开销,讨论的前提也是临界区已经显著很短,不会引发过多的无效预 测。除此之外,因为Lock Free算法通常对临界区须要设计成两阶段提交,以便支持回滚撤销,所以每每须要比对应的互斥保护算法更复杂,局部性也可能更差(例如某些场景必须引入链表来替换数组)。综合来看,通常若是没法作到Wait Free,那么无需对Lock Free过分执着,充分优化临界区的互斥方法每每也足以提供和Lock Free至关的性能表现了。

4.3 并发计数器优化案例

从上文针对临界区保护的多种方法所作的实验,还能够发现一个现象。随着临界区逐渐减少,保护措施开销随线程数量增长而提高的趋势都预发显著,即使是设计上效率和参与线程数本应无关的Wait Free级别也是同样。这对于临界区极小的并发计数器场景,依旧会是一个显著的问题。那么咱们就先从锁和原子操做的实现角度,看看这些损耗是如何致使的。

图片

首先给出一个典型的锁实现,左侧是锁的fast path,也就是若是在外层的原子变量操做中未发现竞争,那么其实上锁和解锁其实就只经历了一组原子变量操做。当fast  path检测到可能出现冲突时,才会进入内核,尝试进行排队等待。fast  path的存在大幅优化了低冲突场景下的锁表现,并且现代操做系统内核为了优化锁的内存开销,都提供了『Wait On Address』的功能,也就是为了支持这套排队机制,每一个锁常态只须要一个整数的存储开销便可,只有在尝试等待时,才会建立和占用额外的辅助结构。

所以实际设计中,锁能够建立不少,甚至很是多,只要可以达到足够细粒度拆解冲突的效果。这其中最典型的就是brpc中计数器框架bvar的设计。

图片

这是bvar中基础统计框架的设计,局部计数和全局汇聚时都经过每一个tls附加的锁来进行临界区保护。由于采集周期很长,冲突能够忽略不记,所以虽然默认使用了大量的锁(统计量 * 线程数),可是并无很大的内存消耗,并且运行开销其实很低,可以用来支持任意的汇聚操做。这个例子也能进一步体现,锁自己的消耗其实并不显著,竞争带来的软件或硬件上的串行化才是开销的核心。

图片

不过即便竞争很低,锁也仍是会由一组原子操做实现,而当咱们本身查看原子操做时,实际是由cache锁操做保护的原子指令构成,并且这个指令会在乱序执行中起到内存屏障的效果下降访存重叠的可能性。所以针对很是经常使用的简单计数器,在百度内部咱们进行了进一步去除局部锁的改造,来试图进一步下降统计开销。

图片

例如对于须要同时记录次数和总和的IntRecorder,由于须要两个64位加法,曾经只能依赖锁来保证原子更新。但随着新x86机型的不断普及,在比较新的X86和ARM服务端机型上已经能够作到128bit的原子load/store,所以能够利用相应的高位宽指令和正确对齐来实现锁的去除。

图片

另外一个例子是Percentile分位值统计,因为抽样数据是一个多元素容器,并且分位值统计须要周期清空重算,所以常规也是采用了互斥保护的方法。不过若是引入版本号机制,将清空操做转交给计数线程本身完成,将sample区域的读写彻底分离。在这个基础上,就能够比较简单的作到线程安全,并且也不用引入原子修改。严格意义上,异步清空存在边界样本收集丢失的可能性,不过由于核心的蓄水池抽样算发自己也具备随机性,在监控指标统计领域已经拥有足够精度。

图片

除了运行时操做,线程局部变量的组织方式原先采用锁保护的链表进行管理,采用分段数据结合线程编号的方法替换后,作到空间连续化。最终总体进一步改善了计数器的性能。

image.png

4.4 并发队列优化案例

另外一个在多线程编程中常常出现的数据结构就是队列,为了保证能够安全地处理并发的入队和出队操做,最基础的算法是整个队列用锁来保护起来。

图片

这个方法的缺点是显而易见的,由于队列每每做为多线程驱动的数据中枢位置,大量的竞争下,队列操做被串行很容易影响总体计算的并行度。所以一个天然的改进点是,将队列头尾分开保护,先将生产者和消费者解耦开,只追加必要的同步操做来保证不会过分入队和出队。这也是Jave中LinkedBlockingQueue所使用的作法。

图片

在头尾分离以后,进一步的优化进入了两个方向。首先是由于单节点的操做具有了Lock Free化的可能,所以产生了对应的Michael & Scott无锁队列算法。业界的典型实现有Java的ConcurrentLinkedQueue,以及boost中的boost::lockfree::queue。

图片

而另外一个方向是队列分片,即将队列拆解成多个子队列,经过领取token的方式选择子队列,而子队列内部使用传统队列算法,例如tbb:: concurrent_queue就是分片队列的典型实现。

图片

image.png

对两种方式进行对比,能够发现,在强竞争下,分片队列的效果其实显著赛过单纯的无锁处理,这也是前文对于无锁技术真实效果分析的一个体现。

除了这类通用队列,还有一个强化竞争发布,串行消费的队列也就是bthread::ExecutionQueue,它在是brpc中主要用于解决多线程竞争fd写入的问题。利用一些有趣的技巧,对多线程生产侧作到了Wait Free级别。

图片

整个队列只持有队尾,而无队头。在生产侧,第一步直接将新节点和当前尾指针进行原子交换,以后再将以前的队尾衔接到新节点以后。由于不管是否存在竞争,入队操做都能经过固定的两步完成,所以入队算法是Wait Free的。不过这给消费侧带来的麻烦,消费一样从一个原子交换开始,将队尾置换成nullptr,以后的消费动做就是遍历取到的单链表。可是由于生产操做分了两部完成,此时可能发现部分节点尚处于『断链』状态,因为消费者无从知晓后续节点信息,只能轮询等待生产者最终完成第二步。因此理论上,生产/消费算法其实甚至不是Lock Free的,由于若是生产者在两阶段中间被换出,那么消费者会被这个阻塞传播影响,整个消费也只能先阻塞住。可是在排队写入fd的场景下,专项优化生产并发是合理,也所以能够得到更好的执行效率。

image.png

不过为了能利用原子操做完成算法,bthread::ExecutionQueue引入了链表做为数据组织方式,而链表自然存在访存跳跃的问题。那么是否能够用数组来一样实现Wait Free的生产甚至消费并发呢?

这就是babylon::ConcurrentBoundedQueue所但愿解决的问题了。

不过介绍这个队列并发原理以前,先插入一个勘误信息。其实这个队列在《内存篇》最后也简单提到过,不过当时粗略的评测显示了acquire- release等级下,即便不作cache line隔离性能也能够保障。文章发表后收到业界同好反馈,讨论发现当时的测试用例命中了Intel Write Combining 优化技术,即当仅存在惟一一个处于等待加载的缓存行时,只写动做能够无阻塞提早完成,等缓存行真实加载完毕后,再统一提交生效。可是因为内存序问题,一旦触发了第二个待加载的缓存行后,对于第一个缓存行的Write Combine就没法继续生效,只能等待第二个缓存行的写完成后,才能继续提交。原理上,Write Combine技术确实缓解了只写场景下的False Sharing,可是只能等待一个缓存行的限制在真实场景下想要针对性利用起来限制至关大。例如在队列这个典型场景下,每每会同时两路操做数据和完成标记,极可能同时处于穿透加载中,此时是没法应用Write Combine技术的。此外,可以在缓存行加载周期内,有如此充分的同行写入,可能也只有并没有真实意义的评测程序才能作到。因此从结论上讲,一般意义上的多线程cache line隔离仍是颇有必要的。

图片

回到babylon::ConcurrentBoundedQueue的设计思路上,实际上是将子队列拆分作到极致,将同步量粒度下降到每一个数据槽位上。每一个入队和出队  请求,首先利用原子自增领取一个递增的序号,以后利用循环数组的存储方式,就能够映射到一个具体的数据槽位上。根据操做是入队仍是出队, 在循环数组上发生了多少次折叠,就能够在一个数据槽位上造成一个连续的版本序列。例如1号入队和5号出队都对应了1号数据槽位,而1号入队预期的版本转移是0到1,而5号出队的版本转移是2到3。这样针对同一个槽位的入队和出队也能够造成一个连续的版本变动序列,一个领到序号的具体操做,只须要明确检测版本便可确认本身当前是否能够开始操做,并经过本身的版本变动和后续的操做进行同步。

经过同步量下放到每一个元素的方式,入队和出队操做在能够除了最开始的序号领取存在原子操做级别的同步,后续均可以无干扰并行开展。而更连续的数据组织,也解决了链表存储的访存跳跃问题。生产消费双端可并发的特色,也提供了更强的泛用性,实际在MPMC(Multiple Producer Mult iple Consumer)和MPSC(Multiple Producer Single Consumer)场景下都有不错的性能表现,在具有必定小批量处理的场景下尤为显著。

图片

招聘信息

欢迎出色的C++ 工程师加入百度,与大神一块儿成长。关注同名公众号百度Geek说,输入内推便可,咱们期待你的加入!

推荐阅读

百度C++工程师的那些极限优化(内存篇)

|百度大规模Service Mesh落地实践

一种基于实时分位数计算的系统及方法

---------- END ----------

百度Geek说

百度官方技术公众号上线啦!

技术干货 · 行业资讯 · 线上沙龙 · 行业大会

招聘信息 · 内推信息 · 技术书籍 · 百度周边

欢迎各位同窗关注