内存模型

内存模型是一个硬件上的概念,表示机器指令是以什么样的顺序被处理器执行的

如何保证指令执行顺序

保证执行顺序会牺牲一些执行效率,因为这意味着放弃了编译器、处理器等的优化处理。

强顺序的内存模型指: 代码顺序和寄存器实际执行的顺序一致

弱顺序的内存模型指: 寄存器实际执行的顺序与代码顺序不一致,被处理器调整过

 

 

 

Memory barrier 简介

程序在运行时内存实际的访问顺序和程序代码编写的访问顺序不一定一致,这就是内存乱序访问。内存乱序访问行为出现的理由是为了提升程序运行时的性能。内存乱序访问主要发生在两个阶段:

  1. 编译时,编译器优化导致内存乱序访问(指令重排)
  2. 运行时,多 CPU 间交互引起内存乱序访问

Memory barrier 能够让 CPU 或编译器在内存访问上有序。一个 Memory barrier 之前的内存访问操作必定先于其之后的完成。Memory barrier 包括两类:

  1. 编译器 barrier
  2. CPU Memory barrier

很多时候,编译器和 CPU 引起内存乱序访问不会带来什么问题,但一些特殊情况下,程序逻辑的正确性依赖于内存访问顺序,这时候内存乱序访问会带来逻辑上的错误,例如:

C++ 并行编程: 设定 指令执行顺序

typedef enum memory_order {

    memory_order_relaxed,    // 不对执行顺序做保证

    memory_order_acquire,    // 本线程中,所有后续的读操作必须在本条原子操作完成后执行

    memory_order_release,    // 本线程中,所有之前的写操作完成后才能执行本条原子操作

    memory_order_acq_rel,    // 同时包含 memory_order_acquire 和 memory_order_release

    memory_order_consume,    // 本线程中,所有后续的有关本原子类型的操作,必须在本条原子操作完成之后执行

    memory_order_seq_cst    // 全部存取都按顺序执行

    } memory_order;

避免编译时内存乱序访问的办法就是使用编译器 barrier(又叫优化 barrier)Linux 内核提供函数 barrier() 用于让编译器保证其之前的内存访问先于其之后的完成。内核实现 barrier() 如下(X86-64 架构):

    
1.  #define barrier() __asm__ __volatile__("" ::: "memory")

现在把此编译器 barrier 加入代码中:

    
1.  int x, y, r;
2.  void f()
3.  {
4.      x = r;
5.      __asm__ __volatile__("" ::: "memory");
6.      y = 1;
7.  }

这样就避免了编译器优化带来的内存乱序访问的问题了(如果有兴趣可以再看看编译之后的汇编代码)。本例中,我们还可以使用 volatile 这个关键字来避免编译时内存乱序访问而无法避免后面要说的运行时内存乱序访问)。volatile 关键字能够让相关的变量之间在内存访问上避免乱序,这里可以修改 x 和 y 的定义来解决问题:

volatile int x, y;

int r;

void f()

{

    x = r;

    y = 1;

}

现加上了 volatile 关键字,这使得 x 相对于 y、y 相对于 x 在内存访问上有序。在 Linux 内核中,提供了一个宏 ACCESS_ONCE 来避免编译器对于连续的 ACCESS_ONCE 实例进行指令重排。其实 ACCESS_ONCE 实现源码如下:

  1. #define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))

此代码只是将变量 x 转换为 volatile 的而已。现在我们就有了第三个修改方案:

   

  1. int x, y, r;
  2. void f()
  3. {
  4.     ACCESS_ONCE(x) = r;
  5.     ACCESS_ONCE(y) = 1;
  6. }

到此基本上就阐述完了我们的编译时内存乱序访问的问题。下面开始介绍运行时内存乱序访问。

运行时内存乱序访问

在运行时,CPU 虽然会乱序执行指令,但是在单个 CPU 的上,硬件能够保证程序执行时所有的内存访问操作看起来像是按程序代码编写的顺序执行的,这时候 Memory barrier 没有必要使用(不考虑编译器优化的情况下)。这里我们了解一下 CPU 乱序执行的行为。在乱序执行时,一个处理器真正执行指令的顺序由可用的输入数据决定,而非程序员编写的顺序。
早期的处理器为有序处理器(In-order processors),有序处理器处理指令通常有以下几步:

  1. 指令获取
  2. 如果指令的输入操作对象(input operands)可用(例如已经在寄存器中了),则将此指令分发到适当的功能单元中。如果一个或者多个操作对象不可用(通常是由于需要从内存中获取),则处理器会等待直到它们可用
  3. 指令被适当的功能单元执行
  4. 功能单元将结果写回寄存器堆(Register file,一个 CPU 中的一组寄存器)

相比之下,乱序处理器(Out-of-order processors)处理指令通常有以下几步:

  1. 指令获取
  2. 指令被分发到指令队列
  3. 指令在指令队列中等待,直到输入操作对象可用(一旦输入操作对象可用,指令就可以离开队列,即便更早的指令未被执行)
  4. 指令被分配到适当的功能单元并执行
  5. 执行结果被放入队列(而不立即写入寄存器堆)
  6. 只有所有更早请求执行的指令的执行结果被写入寄存器堆后,指令执行的结果才被写入寄存器堆(执行结果重排序,让执行看起来是有序的)

 

Memory barrier 常用场合包括:

  1. 实现同步原语(synchronization primitives)
  2. 实现无锁数据结构(lock-free data structures)
  3. 驱动程序

实际的应用程序开发中,开发者可能完全不知道 Memory barrier 就可以开发正确的多线程程序,这主要是因为各种同步机制中已经隐含了 Memory barrier(但和实际的 Memory barrier 有细微差别),这就使得不直接使用 Memory barrier 不会存在任何问题。但是如果你希望编写诸如无锁数据结构,那么 Memory barrier 还是很有用的

通常来说,在单个 CPU 上,存在依赖的内存访问有序:

在 Linux 内核中,除了前面说到的编译器 barrier — barrier() 和 ACCESS_ONCE(),还有 CPU Memory barrier:

  1. 通用 barrier,保证读写操作有序的,mb() 和 smp_mb()
  2. 写操作 barrier,仅保证写操作有序的,wmb() 和 smp_wmb()
  3. 读操作 barrier,仅保证读操作有序的,rmb() 和 smp_rmb()

注意,所有的 CPU Memory barrier(除了数据依赖 barrier 之外)都隐含了编译器 barrier。

https://blog.csdn.net/world_hello_100/article/details/50131497

 

posted @ 2020-09-25 20:11  PKICA  阅读(8)  评论(0编辑  收藏  举报