Hardware Memory Models

216 阅读14分钟

1. 前言

写本篇文章的缘由是因为最近看了下 Go 语言的 Memeory model,无意间发现了 这个资料,在这里作者写了三篇博客,分别是 Hardware Memory ModelsProgramming Language Memory ModelsUpdating the Go Memory Model。所以写下了本文,主要介绍了 Hardware Memory Models

注:本篇文章主要是对 Hardware Memory Models 的一些简略介绍和自己的理解。

2. 什么是Hardware Memory Models?

在知道什么是 Hardware Memory Model 之前,我们首先来了解下什么是 Memory Model

2.1 Memory Models

维基百科对 Memory Model 的定义:In computing, a memory model describes the interactions of threads through memory and their shared use of the data。翻译过来就是:在计算中,内存模型描述了线程在内存中的交互以及它们对数据的共享使用。但是当我看到这里我还是一脸懵逼,内存模型在哪里描述了?它们描述了什么?直到我找到了 stackoverflow 上的这个问题下的最高赞回答才解决了我的疑惑。

我们都知道编译性语言,从编写源代码代码到执行一般要经过过程:编写源代码(程序员) -> 编译(编译器) -> 链接(链接器) -> 运行(CPU) 。其中在编译运行阶段,编译器和 CPU 运行都有可能进行优化(为了速度或者空间)。想象你是一个计算机语言规范的制定者,你不能假定用该计算机语言编写的源代码只能在某个特定的编译器编译和某个特定架构的 CPU 下运行。为了程序运行的正确性,语言规范必须定义:编译器编译源代码时,当涉及到访问内存(读写)的源代码时,应当如何正确的产生指令(尤其在多线程编程环境下) 其实这就是 Programming Language Memory Models,但是不在本文讨论的范畴)。作为编译器的实现者,就必须遵循这些语言规范来进行编译器的设计。

所以 Memory Model 可以简单这样理解:线程之间通过内存交互线程读写共享数据的规范的定义。

或者更简单一点:Memory Model 就是一组规范定义

2.2 Hardware Memory Models

前面我们知道了 Memory Models 其实就是线程交互和读写共享数据的规范的定义。那么顾名思义 Hardware Memory Models 就是指:硬件(CPU)执行指令时,当涉及到线程交互以及读写共享数据的指令时,CPU的行为规范。这些规范由 CPU 的设计者编写。

3. 不同的Hardware Memory Model

在许多年前,编写的程序都是单线程的。硬件的升级和编译器的优化的目的都是:使程序运行的更快。在这样的单核处理上,验证优化有效的方式是:如果程序员不能区分一个有效程序的未优化和优化执行之间的区别(除了运行速度变快之外),那么优化就是有效的。

也就是:valid optimizations do not change the behavior of valid programs

但是随着技术的瓶颈,单个处理器的运行速度已经无法得到提升,作为硬件工程师就把目标转向了:设计多核CPU,完成了硬件并行性。但是这样也给语言规范制定者编译器实现者程序员带来了许多问题。

我们来看下面一个例子:

x = 0;

done = 0;

Thread 1Thread 2
x = 1;while (done == 0) { /* loop */}
done = 1;print(x);

这个代码的运行结果根据情况而定,取决于硬件,也取决于编译器。不考虑对指令的执行顺序进行重排,在 x86 多处理器上运行的结果总是 1 。但是在 ARM 或 POWER 多处理器上运行的结果可能是0。除此之外,无论是什么处理器,标准编译器优化都可能使得该程序打印 0 或者 线程 2 无法退出循环。

但是根据情况而定并不是一个令人开心的结果,程序员需要一个明确的答案,即一个程序可以在另一个新的编译器和硬件下继续工作。硬件设计人员和编译器开发人员需要清晰的回答:how precisely the hardware and compiled code are allowed to behave when executing a given program?在这里的主要问题是存储在内存中的数据更改的可见性和一致性。

所以 Hardware Memory Model 规定了:程序员可以从硬件那得到什么保证。

3.1 Sequential Consistency

一个正确的多处理器需要满足如下条件:the result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each individual processor appear in this sequence in the order specified by its program。满足这样的条件的多处理器叫作 sequentially consistent

今天我们不仅要讨论计算机硬件,还要讨论保证顺序一致性的编程语言,当一个程序的唯一可能执行对应于某种线程操作交织成顺序执行时。顺序一致性通常被认为是理想的模型,是程序员最自然使用的模型。它允许您假设程序按照它们在页面上(程序员编写代码的顺序)出现的顺序执行,并且单个线程的执行只是以某种顺序交错,而不是以其他方式重新排列。

3.1.1 Litmus Test

示例程序执行结果的问题称为 litmus test。因为它有一个二元答案(运行结果是可能还是不可能),一个 litmus test 为我们提供了一种清晰的方法来区分内存模型:如果一个模型允许特定方式的执行,而另一个不允许,那么这两个模型显然是不同的。

3.1.2 Litmus Test: Write Queue (also called Store Buffer)

来看下面一个例子:rN 表示 一个 thread-local register,不是共享变量。

x = 0;

y = 0;

这个程序运行结果可以是: r1 = 1, r2 = 0?

Thread 1Thread 2
x = 1;r1 = y;
Y = 1;r2 = x;

如果 litmus test 的执行是 sequentially consistent,那么就会有如下六种执行结果。

  1. x = 1
    y = 1 
              r1 = y(1)
              r2 = x(1)
    
  2. x = 1
              r1 = y(0)
    y = 1 
              r2 = x(1)
    
  3. x = 1
              r1 = y(0)
              r2 = x(1)
    y = 1 
    
  4.           r1 = y(0)
    x = 1   
    y = 1 
              r2 = x(1)
    
  5.           r1 = y(0)
    x = 1 
              r2 = x(1)
    y = 1 
    
  6.               x = 1
                  y = 1 
    r1 = y(1)
    r2 = x(1)
    

当没有交叉执行时,r1 = 1, r2 = 0 的运行结果不可能发生,所以 limus test 的结果是 No。

顺序一致性的一个很好的 mental model 是想象所有的处理器都直接连接到同一个共享内存,它一次可以为一个线程的读或写请求服务。它不涉及缓存,所以每次处理器需要读取或写入内存时,该请求都会进入共享内存。一次使用一次共享内存对所有内存访问的执行施加了一个顺序:顺序一致性。

截屏2023-02-06 18.50.44.png

注:并不只有上图所描述的 model 才能构建顺序一致性的机器,只是为了方便说明。也可以通过可以使用多个共享内存模块和缓存来构建顺序一致的机器。

不幸的是,对于我们程序员来说,放弃严格的顺序一致性可以让硬件更快地执行程序,因此所有现代硬件都没有完全满足顺序一致性。下面会使用了两个例子来进行说明:x86,以及ARM和POWER处理器家族。

3.2 x86 Total Store Order (x86-TSO)

采用了 X86-TSO model 机器的的所有处理器都连接至一个共享内存中的,但是每个处理器拥有一个写队列,并且每一个处理器会将写操作按顺序添加到 local write queue,处理器继续执行其它指令,写操作的结果会发送到了共享内存。处理器上执行读操作,它先会去查询该处理器的 local write queue,如果找不到,才回去从内存读取。并且每一个处理器的 local write queue 是相互隔离的,彼此不可见。X86-TSO memory model diagram 如下图所示:

截屏2023-02-06 18.51.16.png

这样的结果就是:

  • 每一个处理器都比其它处理器能先看到自己的写入操作。
  • 所有处理器都同意写入操作到达共享内存的顺序。
  • 当一个写操作到达共享内存时,后面所有处理器的读操作都能够看到并且使用,直到它被重写。
  • local write queue 是一个标准的先进先出队列:内存写应用到共享内存的顺序与处理器执行内存写的顺序相同。

3.2.1 Litmus Test: Message Passing

x = 0;

y = 0;

这个程序运行结果可以是: r1 = 1, r2 = 0?

Thread 1Thread 2
x = 1;r1 = y;
Y = 1;r2 = x;
  • On sequentially consistent hardware: no.
  • On x86 (or other TSO): no.

因为写顺序由 local write queue 保留,并且因为其他处理器立即看到对共享内存的写入,所以上述 litmus test 的结果仍然为不可能。

写队列保证线程 1 在 y 之前将 x 写入内存,而关于内存写入顺序 (total store order) 保证线程 2 在 y 的新值之前知道 x 的新值。因此,r1 = y 不可能看到新的 y,而 r2 = x 也不可能看到新的 x。这里的存储顺序至关重要:线程 1 在 y 之前写入 x,所以线程 2 不能在写入 x 之前看到对 y 的写入。

序列一致性和 TSO 模型在这种情况下是一致的,但它们在其它 litmus test 的结果不一样。如下面所示:

3.2.1 Litmus Test: Write Queue (also called Store Buffer)

x = 0;

y = 0;

这个程序运行结果可以是: r1 = 0, r2 = 0?

Thread 1Thread 2
x = 1;y = 1;
r1 = y;r2 = x;
  • On sequentially consistent hardware: no.
  • On x86 (or other TSO): yes!

在顺序模型下,序列一致性模型的结果是不可能,而 TSO 模型则是可能。

在任何顺序一致的执行中,x = 1 或 y = 1必须先发生,然后另一个线程中的读取必须遵守它,因此 r1 = 0, r2 = 0是不可能的。但是在 TSO系统上,线程 1 和线程 2 都有可能将写操作排在 local write queue 中,然后在其中一个写操作进入内存之前从内存中读取,这样两个读操作都看到 0。

为了修复依赖于更强内存排序的算法,非顺序一致的硬件提供了称为内存屏障(或 fences)的显式指令,可用于控制排序。我们可以添加一个内存屏障,以确保每个线程在开始读操作之前都将上一次写入内存:

Thread 1Thread 2
x = 1;y = 1;
barrierbarrier
r1 = y;r2 = x;

3.2.3 Litmus Test: Independent Reads of Independent Writes (IRIW)

最后一个例子解释下为啥叫做 TSO memory model。在该模型中,有 local write queue,但是读取操作确没有缓存。当有一个值写入到主存中,当该值没有被覆盖时,该值对于所有处理器都是可见。如下面所示:

x = 0;

y = 0;

这个程序运行结果可以是: r1 = 1, r2 = 0, r3 = 1, r4 = 0?

Thread 1Thread 2Thread 3Thread 4
x = 1;y = 1;r1 = x;r3 = y;
r2 = y;r4 = x;
  • On sequentially consistent hardware: no.
  • On x86 (or other TSO): no.

如果线程 3 看到 x 在 y 之前变化,线程 4 能看到 y 在 x 之前变化吗? 对于 x86 和其他 TSO 机器,答案是否定的:对主存的所有写入都有一个总顺序,所有处理器都同意这个顺序。

3.3 ARM/POWER Relaxed Memory Model

ARM/POWER 系统的 memory model 如下图所示:每一个处理器读操作或者写操作都是直接操作自己的内存(而不是共享内存),并且一个处理器的写操作都会独自传播至其它处理器,并且允许重新排序。并且可以延迟读操作,读操作可以延迟到后面的写操作之后。

截屏2023-02-06 18.51.40.png

3.3.1 Litmus Test: Message Passing

x = 0;

y = 0;

这个程序运行结果可以是: r1 = 1, r2 = 0?

Thread 1Thread 2
x = 1;r1 = y;
y = 1;r2 = x;
  • On sequentially consistent hardware: no.
  • On x86 (or other TSO): no.
  • On ARM/POWER: yes!

在 ARM/POWER 模型中,我们可以认为线程 1 和线程 2 都有各自独立的内存副本,写操作以任意顺序在内存之间传播。如果线程 1 的内存在发送 x 更新之前将 y的更新发送给线程 2,并且如果线程 2 在这两次更新之间执行,它确实会看到结果:r1 = 1, r2 = 0。

这个结果表明 ARM/POWER 内存模型比 TSO 更弱:它对硬件的要求更低。ARM/POWER 模型遵循了 TSO 模型所做的各种重新排序:

3.3.2 Litmus Test: Store Buffering

x = 0;

y = 0;

这个程序运行结果可以是: r1 = 0, r2 = 0?

Thread 1Thread 2
x = 1;y = 1;
r1 = y;r2 = x;
  • On sequentially consistent hardware: no.
  • On x86 (or other TSO): yes!
  • On ARM/POWER: yes!

在 ARM/POWER上,对 x 和 y 的写入可能会被写入本地内存,但当 1 和 2 两个线程的读取操作发生在另一方的写操作传播到本地内存之前。

3.3.4 Litmus Test: Independent Reads of Independent Writes (IRIW)

x = 0;

y = 0;

这个程序运行结果可以是: r1 = 1, r2 = 0, r3 = 1, r4 = 0?

Thread 1Thread 2Thread 3Thread 4
x = 1;y = 1;r1 = x;r3 = y;
r2 = y;r4 = x;
  • On sequentially consistent hardware: no.
  • On x86 (or other TSO): no.
  • On ARM/POWER: yes!

在ARM/POWER上,different threads may learn about different writes in different orders。因此线程 3 可以看到 x 在 y 之前发生变化,而线程 4 可以看到 y 在x 之前发生变化。也就是说对于 1 和 2 线程的写操作传播至线程 3 和 4 的时序,导致了这种结果。

3.3.4 Litmus Test: Load Buffering

x = 0;

y = 0;

这个程序运行结果可以是: r1 = 1, r2 = 1?

Thread 1Thread 2
r1 = x;r2 = y;
y = 1;x = 1;
  • On sequentially consistent hardware: no.
  • On x86 (or other TSO): no.
  • On ARM/POWER: yes!

在ARM/POWER内存模型中,处理器被允许将读取延迟到指令流的后面写入操作之后,所以 y = 1和 x = 1在两次读取之前执行。

3.3.5 Litmus Test: Coherence

x = 0;

y = 0;

这个程序运行结果可以是: r1 = 1, r2 = 2, r3 = 2, r4 = 1?

Thread 1Thread 2Thread 3Thread 4
x = 1;x = 2;r1 = x;r3 = x;
r2 = x;r4 = x;
  • On sequentially consistent hardware: no.
  • On x86 (or other TSO): no.
  • On ARM/POWER: no.

在 ARM/POWER 上:系统中的线程必须对写入单个内存位置的总顺序达成一致。也就是说,线程必须一致同意哪个写操作覆盖其他写操作,这种性质被称为 coherence。

4. 结论

  • Memory Model 是线程之间通过内存交互和线程读写共享数据的规范的定义。

  • Hardware Memory Model 是处理器对线程之间通过内存交互和线程读写共享数据的规范的定义。

  • Sequential Consistency model

    • 指所有处理器共享内存。
    • 一次可以为一个线程的读或写请求服务。
    • 不涉及缓存,所以每次处理器需要读取或写入内存时,该请求都会进入共享内存。
  • 采用x86-TSO model 的处理器

    • 所有处理器连接至一个共享内存中。
    • 每个处理器拥有一个写队列,并且每一个处理器会将写操作按顺序添加到 local write queue,处理器继续执行其它指令,写操作的结果会发送到了共享内存。
    • 处理器上执行读操作,它先会去查询该处理器的 local write queue,如果找不到,才回去从内存读取。
    • 每一个处理器的 local write queue 是相互隔离的,彼此不可见。
  • 采用 ARM/POWER Relaxed Model 的处理器

    • 每一个处理器读操作或者写操作都是直接操作自己的内存(而不是共享内存),
    • 每一个处理器的写操作都会独自传播至其它处理器,并且允许重新排序。
    • 可以延迟读操作,读操作可以延迟到后面的写操作之后。

5. 参考

  1. Hardware Memory Models
  2. Memory Model
  3. C++11 introduced a standardized memory model. What does it mean? And how is it going to affect C++ programming?
  4. litmus test