本章讨论需要什么样的硬件支持才能保证多处理器系统上基于共享存储的并行程序的正确执行。将介绍三种主要类型的硬件支持:
1)缓存一致性协议,确保多个处理器看到的缓存数据是一致的;
2)存储一致性模型,确保存储操作顺序在不同处理器上是一致的;
3)硬件同步支持,提供一个简单、正确和高效的原语来支持程序员控制并行程序。
共享存储的抽象并不总是可以自动实现的:它需要特殊的硬件支持。这与不提供共享存储抽象而依赖于消息通信来实现处理器交互的系统正好相反,这种系统只需要一个消息传递的库来简化消息的发送和接收即可。
将多处理器系统组织成共享存储的系统具有许多优势:
1. 程序的编写可以基于共享存储系统的假设,如基于共享存储的并行程序和针对单处理器系统编写的多线程程序将可以直接在共享存储多处理器上运行。如果系统不支持共享存储,那么这些程序将需要进行大量的修改才能在该系统上运行。
2. 系统上只需要一个操作系统镜像,这就简化了系统维护和线程协同调度。
3. 包括多个节点的基于共享存储的多处理器系统的存储大小等于节点数和每个节点存储大小的乘积。
4. 易支持细粒度共享。程序中的算法需要将数据组织成大的集合并使用较少的大消息一起发送,而不是使用大量的小消息,从而降低消息发送和接收的频率。
5. 通用的数据结构可以被所有线程读取而无须存储多个副本,而在不支持共享存储的系统中这些数据则需要被复制出多个副本,从而产生额外的存储开销。
实际上,在多核处理器上各核共享高速缓存,共享存储可以自动得到支持。然而,提供共享存储的代价随着处理器数量的增加而超线性的增加。相反,对非共享存储系统而言,只要对高带宽的低延迟的需求不是太高(允许使用可扩展不是很好的互联),其系统代价随着处理器数量的增加而相对线性地增加。
6.1 缓存一致性问题
假设一个多处理器系统,每个处理器都有一个私有高速缓存,并且将这些处理器聚合在一起形成基于共享的多处理器系统。假设通过总线将这些处理器互联在一起。现在的问题是:唯一的共享存储抽象能自动实现?答案是否定的。理解发生了什么问题非常重要,只有基于对问题的理解,才可能得出那些硬件支持对于共享存储抽象是必需的。假设a[0]=3,a[1]=7;
float sum = 0;
#pragma omp parallel for
for (int i = 0; i < 2; i++){
sum = sum + a[i];
}
... = sum;
缓存一致性问题展示:
动作 | P0的高速缓存 | P1的高速缓存 | 存储 |
初始状态 | ---- | ---- | sum=0 |
P0读sum | sum=0 | ---- | sum=0 |
P0将a[0]累加到sum | sum=3,脏 | ---- | sum=0 |
P1读sum | sum=3,脏 | sum=0 | sum=0 |
P1将a[0]累加到sum | sum=3,脏 | sum=7,脏 | sum=0 |
至此,读者可能考虑是否将写回高速缓存替换为写直达高速缓存问题就能得到解决?答案是否定的!当线程0更新sum时,写直达传播到主存,主存sum为3,然后线程1读取主存sum=3值,线程1将sum加7,这时线程1中的缓存sum=10,然后写直达回主存,此时线程0缓存sum=3,线程1缓存sum=10,主存sum=10。此时依旧是不正确的。
整体来说,高速缓存的写策略(写直达或者写回)只能控制高速缓存副本的修改如何被传播到外层高速缓存层次(如主存),但并不能控制缓存副本值的修改如何被传播到同级高速缓存的其他副本中。由于该问题的根源在于同一个数据在不同高速缓存中看到的值是不一致的,因此也被称为缓存一致性问题。
为了满足数据在多个高速缓存中具有相同的值,至少需要一个机制来将高速缓存中的修改传播到其他高速缓存中。这种需求也被称为写传播需求。支持缓存一致性的另一个需求是事务串行化。事务串行化本质上要求对同一存储地址的多个操作,在所有处理器看起来其顺序是一致的。
需要在写之间进行串行化,从而保证所有处理器看到的缓存数据是一致的。
由于需要串行化读和写操作,通常将写操作称为写事务,而将读操作称为读事务,这也意味着每一个操作相对于其他操作是原子的。
缓存一致性是通过被称为缓存一致性协议的机制来实现的。为了保存缓存一致性协议的正确性,需要实现写传播和事务串行化。
6.2 存储一致性问题
在多处理器系统上另一个需要解决的问题是存储一致性问题,该问题比缓存一致性问题更加复杂。缓存一致性需要将同一个地址上的值的修改从一个高速缓存中传播到其他高速缓存,并且将这些修改串行化。而存储一致性主要用于解决对不同存储地址的所有存储操作的排序load和store的排序。该问题被称为存储一致性,因为不像缓存一致性问题只发生在高速缓存的系统中,即使在没有高速缓存的系统也存在存储一致性问题,尽管高速缓存可能使得该问题更加严重。
实现信号-等待对的简单方法是使用初始化为0的共享变量。post操作将变量设置为1,而wait操作等待直到该变量被设置为1.
PO: P1:
S1: datum = 5; S3: while(!dataIsReady){};
S2: datumIsReady = 1; S4: print datum;
与源程序对应的二进制代码保留语句原有的执行顺序至关重要。程序源码中呈现出的指令顺序被称为程序顺序。
当今的典型处理器都实现了乱序执行机制,该机制可能会对指令的执行重新排序从而发掘指令级并行,并且仍然保留依赖(数据和控制流)和指令提交的程序顺序。S4程序执行依赖于S3中的循环条件是否满足。S1和S2中的store指令即不存在数据依赖,又不存在控制流依赖。在执行过程中,S2可能在S1之前被执行。
在c/c++中,如果将datumIsReady申明为volatile类型,编译器就会知道对改变了的load或者store操作不能被移除,也不能与其之前或之后的指令调换顺序。程序员只需要记住那些变量需要用于同步,并将其声明为volatile变量。
整体来说,在单处理器系统中,为了代码的正确执行,只需要让编译器对访问同步变量的指令保留程序顺序。那么对多处理器系统呢?需要相关的机制来保证对单个处理器的访问在其他处理器看来都是按照程序顺序进行的,或者说至少部分符合。然而,很容易想到,在多个处理器之间保持完全的程序顺序会造成严重的性能损失,因此一些处理器只保证部分符合程序顺序。具体那种类型的程序顺序得到保证,将由处理器的存储一致性模型来决定。
不同的存储一致性模型在性能和可编程性之间进行取舍。对于程序员而言,了解系统提供的存储一致性模型,知道如何针对该模型编写正确的代码,并且充分发挥该模型的性能优势,是至关重要的。
6.3 同步问题
下一个问题是临界区(omp中的critical)所需的互斥如何实现。互斥要求当有多个线程访问时,在任何时间内只有一个线程可以访问临界区。
一个暴力实现临界区的方法就是针对需要互斥的代码段关闭中断。关闭所有的中断可以保证线程的执行不会被操作系统上下文切换或者中断。这种办法对于单处理器系统而言代价太大。在多处理器系统中,除了开销较大,关闭中断也无法实现排他访问,因为在不同处理器上其他的线程仍然是可以同时执行。
lock/unlock函数的错误实现:
void lock(int* lockvar){
while (*lockvar == 1) {} ;
*lockvar = 1;
}
void umlock(int* lockvar){
*lockvar = 0;
}
对应汇编:
loack: ld R1, &lockvar
bnz R1, lock
st &lockvar, #1
ret
unlock: st &lockvar, #1
ret
简单实现的问题在于读取lockvar变量值和lockvar值得指令序列不是原子的。即使系统正确的实现了缓存一致性协议,上述代码结果仍然是不正确的。缓存一致性协议只能保证对缓存地址新写入的值会被传播到其他的缓存副本中。在本例中,线程1的lockvar副本在线程0写入lockvar后可能会被设置为无效。然而,由于线程1之前已经读取过lockvar的值,因此它不会再次读取lockvar,导致线程1无法看到lockvar的最新值。进而,线程1会尝试着将1写入(覆盖)lockvar。
解决该问题的一个方法是引入原子指令的支持。原子指令将读、修改和写的指令序列作为一个不可分割的单元来执行。原子含义:首先,它意味着要么整个指令序列得到完整执行,要么该指令序列中没有一个指令得到执行。其次,在任意时间点以及无论那个处理器上,该指令序列中都只有一个指令得到执行。实现原子指令依赖于硬件支持。
看待硬件支持的必要性,是否存在一个方法不需要硬件支持?如果有,该方法是否高效?考虑通过软件方法实现互斥,如Peterson算法。
int turn
int interested[n]; // 被初始化为0
void lock(int process){ // process要么为1,要么为0
int other = 1 - process;
interested[process] = TRUE;
turn = process;
while (turn == process && interested[other] == TRUE) {} ;
}
void unlock(int process){
interested[process] = FALSE;
}
首先,代码退出while循环并且成功获得锁的条件为:要么turn不等于process,要么interested[other]值为false。当只有一个线程尝试着获取锁时,因为interested[other]值为false,它将成功获得锁。当线程退出临界区时,只需要将interested[other]值置为false,从而其他线程可以进入临界区。
虽然软件方法也可以实现互斥,但该方法可扩展性比较差。考虑当算法工作在线程大于2的情况下while循环的退出条件。如果有n个线程,需要将线程分为两个1组,每组线程竞争同一个锁。在上一轮胜出的线程会被重新分为一组,并在新的一轮中竞争。重复此过程,直到有一个线程在所有轮次中均胜出。这种循环式的获取方式会产生较大的延迟。因此硬件支持的同步对于降低同步开销和实现可扩展性同步是非常重要的。
同步原语的正确性与处理器提供的存储一致性模型密切相关。本质上,同步操作用于对不同线程的存储访问进行排序。然而,同步操作无法保证由其排序后的存储访问与未由同步操作排序的存储访问之间的顺序,除非硬件要么提供一个严格的存储一致性模型,要么提供一个明确的机制来对存储访问进行排序。