乐趣区

关于数据库:一文走进多核架构下的内存模

一、走进多核编程

CPU 倒退晚期阶段,性能的晋升次要来自于主频的晋升和架构的优化,当这条优化路径呈现瓶颈后,多核 CPU 开始流行起来。多外围同时执行工作极大地提高了零碎整体性能,但也对硬件架构和软件编写提出了更大的挑战。各个外围都有本人的 Cache,以及不同层级的 Cache,彼此共享内存。一个典型的多核 CUP 架构如下图所示:

利用多外围的劣势在各个核之间互相配合实现工作,如何进行各个外围间的数据同步(各个外围所属 L1 Cache/L2 Cache 数据的同步)是问题的关键所在。尽管倒退出多种数据同步形式,以及流水线乱序执行的模式,但数据在各个核之间的一致性和可见性并不是那么现实;再加上编译器也会做优化,最终导致各个核的指令执行程序和各个变量值的可见性变得不确定。

这种景象能够通称为重排,即本来应该有全序的内存读写操作被打乱。不过无论产生什么样的重排,都会保障对于单线程外部的执行后果不会有任何区别。上面是一个简略例子:

1.  // Thread1  
2.  // ready was initialized to false  
3.  p.init();  
4.  ready = true; 

1.  // thread2  
2.  if(ready){
3.      p.bar();  
4.  } 

对于 Thread 1 外部,p 和 ready 没有关联,齐全能够被重排而不影响正确性,而 Thread 2 依赖 ready 做标识位,一旦重排,Thread 2 在看到 ready 为 true 的时候 p 都可能没有 init,显然这是有问题的。

二、多核编程中临界区爱护

利用多线程做并发的工作中通常都会有公共的临界区,比方最罕用的一种数据结构:并发队列,生产者和消费者须要拜访队列的公共内存进行写入和读取。目前对于临界区的保护方式通常能够分为三个级别:互斥、Lock-free 和 Wait-free。

1、互斥

互斥,顾名思义每个线程拜访临界区之前都须要取得互斥锁,如果被别的线程占用了就阻塞期待。当进入临界区的线程产生阻塞,或被操作系统换出时,会呈现全局阻塞,因为取得锁的线程被换出无奈执行操作,而未取得锁的线程也只能一起期待,呈现了阻塞流传。如果另一个线程先进入临界区,有可能反而能够更快的顺利完成。因为存在全局阻塞的可能性,采纳互斥技术进行临界区爱护的算法有着最低的阻塞容忍能力。

2、Lock-free

Lock-free 容许单个线程阻塞,然而会保证系统整体层面上的吞吐。如果当程序线程运行足够长时间的状况下,至多有一个线程获得了停顿,那么就能够说这个算法是 Lock-free 的。如果一个线程被挂起,那么 Lock-free 算法保障残余的线程依然能够进行。

应用锁的代码肯定不是 Lock-free 的,因为一个线程加锁后如果被零碎切出去了,其余所有线程都处于期待中。然而没用锁也不肯定是 Lock-free,因为一般的代码逻辑也可能会导致一个线程夯住另一个线程。锁之所以在高并发的时候体现很差,次要起因是加锁的线程会夯住其余期待加锁的线程,Lock-free 能够很好地解决这一问题。

在实现上个别先假如临界区不存在竞争,各个线程间接开始在临界区的执行,执行过程中通过良好的程序设计,让这段事后的执行是无抵触并且是可回滚的。最终有一个须要同步的提交操作,个别基于原子变量 CAS 操作,或者版本校验等机制实现。在提交阶段如果发生冲突,那么被仲裁为失败的各方须要对临界区预执行进行回滚,并从新发动一轮尝试。

留神,并不是说 Lock-free 的算法就肯定比加锁的算法好,Lock-free 须要解决更多更简单的 race condition 移机 ABA 等问题,编写出正当的 Lock-free 代码也须要更深厚的技术功底,须要对底层有更多地理解,实现雷同目标的代码会比用锁更简单,执行工夫可能更长,代码也更难了解。

很多场景下正当地应用锁就能很好的胜任,Lock-free 和锁之间在利用场景上更多的是一种互补的关系。Lock-free 算法的价值在于其保障了一个或所有线程始终在做有用的事,而不是相对的高性能。但 Lock-free 相较于锁在并发度高(竞争强烈导致上下文切换开销变得突出)的某些场景下会有很大的性能劣势,比方实现一个多线程的 Lock-free queue。总的来说,在多核环境下,Lock-free 是很有意义的。

3、Wait-free

Lock-free 技术次要解决了临界区内的阻塞流传问题,然而实质上,仍然是多个线程排队程序通过临界区。而 Wait-free 和 Lock-free 的次要区别也就体现在零碎吞吐上。在无全局进展的根底上,Wait-free 进一步保障了执行任意算法的线程,都应该在无限的步骤内实现。不只是整体算法时时刻刻都存在无效计算,每个线程仍然是须要继续进行无效的计算。这就要求多线程在临界区内不能被细粒度地串行起来,而必须是同时都能进行无效计算。尽管实践角度存在不少有 Wait-free 的算法,但大多并不具备工业应用的价值。

4、相干技术

Lock-free 和 Wait-free 编程中最重要的两个相干技术就是原子操作和管制 Memory Order。

CPU 保障没有线程能察看到原子操作的两头态,也就是说一个原子操作对于所有的线程来说要么做了要么没做。原子操作次要包含赋值原子操作、Read-Modify-Write(比方 C ++ 11 里的 fetch_add)、Compare-And-Swap(比方 C++ 11 里的 Compare_exchange_strong)等操作。原子操作保障了各线程在进行共享内存的存取的时候能读到残缺的值。

Memory Order 即内存排序,指 CPU 拜访主存的程序。能够是编译器在编译时产生,也能够是 CPU 在运行时产生。为了充分利用不必内存的总线带宽,古代处理器大多是乱序执行的。无锁算法没有显式的锁,将会间接察看到这些和代码程序不统一的重排,C++ 11 引入的 Memory Order 给使用者提供了一种跨平台的通用办法来限度上述两种重排。

三、Memory Order

Memory Model 内存模型,定义了特定处理器上或者工具链上的重排状况。某个处理器或者工具链对代码的重排会严格遵循对应的 Memory Model。这里探讨的重排只是针对单个线程外部在单个核内的指令的执行程序问题。能够了解为指定 Memory order,就是通过限度重排来保障共享数据的可见性和正确同步。

1、Reorder 类型和 Memory Order 的强弱

对内存的操作能够概括为读和写,能够示意为 Load 和 store 操作,因而 Reorder 也就能够整体上分为以下四种类型:

Load-load reorder:两个读操作之间重排;
Load-store reorder:原来在写操作之前的读操作重排到之后;
Store-load reorder:原来在读操作之前的写操作重排到之后;
Store-store reorder:两个写操作之间重排。

Memory Model 既有软件层面的 Software Memory Model,又有硬件平台的 Hardware Memory Model,下图中是几种 CPU 架构下的 Hardware Memory Model。

DEC Alpha 架构下,上述四种 Reorder 都有可能产生,只保障不扭转单线程外部的执行正确性。
ARM 架构下的 CPU 也容许四种 Reorder 的产生,额定保障了数据依赖程序。
X86/X64 平台属于强 Memory Model 的领域,只可能产生 Store-load reorder。
C++ 11 中原子操作的内存序属于 Software Memory Model 的领域,在软件层面进行相干限度,让 CPU 实现相应操作的成果。

2、Compiler Barrier 与 Runtime Memory Barrier

无论是哪种 Memory Model 中波及的重排,都是指的在没有其余限度的状况。为了可能保障程序的正确性,CPU 和编译器(语言)的设计者都预留了手办法来扭转这些重排,这类办法能够形象成一个对立的概念 Barrier:屏障。须要使用者用代码来限度编译阶段和运行阶段的重排,因而能够分为 Compiler Barrier 和 Runtime Memory Barrier。

Compiler Barrier,编译器层面的屏障,能够避免编译器在将源码转换成机器码的过程中重排。简略的例子如下:

  int a, b;  
  int main()  
  {
    a = b + 1;     
   // asm volatile(“”:::”memory”);    
    b = 0;    
    return 0;  
  } 

对于以上代码,应用 gcc 4.9.4 整体不开启优化进行编译,失去汇编代码如下:

$ gcc -S main.cpp
$ cat main.s
      …    
    movl    _b(%rip), %eax    
    addl    $1, %eax    
    movl    %eax, _a(%rip)    
    movl    $0, _b(%rip)    
    movl    $0, %eax    
    popq    %rbp   
    …  

同样应用 gcc 4.9.4 整体开启优化进行编译,失去汇编代码如下:

$ gcc –O2 -S main.cpp
$ cat main.s

  ...  
  movl    _b(%rip), %eax  
  movl    $0, _b(%rip)  
  addl    $1, %eax  
  movl    %eax, _a(%rip)  
  xorl    %eax, %eax  
  ...  

如果想要整体开启优化,然而对于局部代码不想要重排,那么就能够应用 Compiler Barrier,在 gcc 里,asm volatile(“” :::“memory”) 就是这么一个 Compiler Barrier。在应用 Compiler Barrier 后,应用应用 gcc 4.9.4 整体开启优化进行编译,失去汇编代码如下:

$ gcc -O2 -S main.cpp
$ cat main.s

  ...  
  movl    _b(%rip), %eax  
  addl    $1, %eax  
  movl    %eax, _a(%rip)  
  movl    $0, _b(%rip)  
  xorl    %eax, %eax  
  ...  

能够看到和未开启编译优化时的后果保持一致。

Compiler Barrier 只能保障编译阶段不重排。在多核零碎里,光做到这一点还不够,因为它没法对 CPU 外围运行时的重排做出限度。因而,在多核编程中,通常须要同时对编译重排和运行时重排做出限度,须要应用到 Runtime Memory Barrier。

后续的技术博客会持续深刻介绍 C++ 11 中的 Memory Order,敬请期待。

退出移动版