多数硬件对数据操作与同步操作并不做区分,不同硬件平台的并发内存访问的语义也与我们这里讨论的有较大差别。对于熟悉硬件内存模型的读者,我们对于如何组织内存操作和同步访问的介绍会基于一种假定的硬件平台(类似最近的ARM处理器,但使用Itanium汇编语法)。
首先要说明一点的是我们这种描述并不精确,因为我们缺乏熟悉编译器的人,并且那也不在我们讨论编程模型的范围内。我们希望它能帮助读者理解本文后续章节而提前给出一个概念.
现在的处理器通常依赖缓存或只是缓存频繁访问的内存地址,通过在将这些内存地址的拷贝缓存到一个更快的并通常与每个处理器相关联的记忆体(译者按:通常处理器高速缓存的速度会大大的大于内存)中,以提供更快的访问速度。每个处理器不会直接访问主内存,它需要确保在缓存中存储着正确的内存拷贝,然后从缓存中取数据。有时,当缓存的空间不够用时,部分缓存的数据会写回主内存中。
尽管有些人会指责缓存会在硬件层面导致复杂的内存排序模型,但这种指责从某种角度而言是种误解。硬件层面会遵循缓存一致性协议,例如MESI协议确保缓存内容的连续性。一般通过确保只更新没有被其他高速缓冲引用的内存地址来保证缓存一致性。这样所有处理器共享一个一致可见的内存空间,所有内存操作只是看起来像是在不同的线程中切换一样。(对于更详细的内存以及缓存的讨论,可以参见Ulrich Drepper的“what every programmer should know about memory”).
尽管如此,处理器通常会有一些其它的优化机制,这些优化将会对内存访问顺序进行重排,所以普通的内存访问机制无法满足同步操作的需求。比如,处理器通常不需要等到写操作完成然后再进行后续的运算,它只需要将需要写入的数据缓冲起来让它去写,然后就可以继续做其它事了。当随后的加载完成而写操作还未完成,那么内存重排的影响就变得可见了,这会导致错误的输出结果类似我们一开始那个介绍性例子,如果X与Y都是同步变量的话。
为了解决这个问题,处理器通常同时提供内存屏障指令。这是处理器针对特定指令的禁止优化操作。针对我们假定的硬件,我们假定一个通用的内存屏障可以确保在内存屏障之前发生的内存操作对所有的处理器可见。
一个简单的通用屏障可以暂时阻塞处理器直到所有指令执行完毕然后对整个内存系统可见。没有指令能超越内存屏障去执行。事实上,现在的处理器提供很多不同的屏障指令来确保一组操作可以有序的执行,甚至会根据规则在多处理器的复杂操作中决定它的执行顺序 — 这里我们只考虑最简单的屏障。
为了更精确我们假定我们的硬件架构特征如下:
- 它拥有加载与存储适当大小的数据的指令。对于 ld ri = [x] 这个指令,加载内存地址为X的数据到寄存器 ri。 对于 st[x] = ri 这个指令,我们将寄存器地址为ri的数据存储到内存地址x中。
- 普通的加载与存储需要被访问数据被分配到合适的内存中,比如4字节大小的数据就要存储在4字节的内存中。同时,它们的执行是不可分割的,对于其它线程而言,它们要么是已经执行过的,要么是压根都没有执行的。(通常的硬件架构不能保证这种原子性操作,因为加载与存储指令可能需要访问物理内存多次)
- 加载与存储指令以任意顺序被硬件重排,意味着指令以任意顺序对其它处理器或线程可见。
- 当存储指令的执行结果对另一处理器可见时,该执行结果就会对所有处理器可见。(通常内存系统也类似我们上面的讨论,例如X86架构,一个例外是,其它线程在内核上支持两个线程对写入的缓冲数据可见)
- 处理器支持内存屏障指令,会等待有更高优先级的内存操作完成再进行后续操作。
- 处理器支持 xchg ri, [x] 指令会自动在寄存器ri与内存地址x之间交换数据,尽管这种交换操作是不可分割的操作,但当有其它操作时无法保证其原始的执行顺序。
相对于真实的同步机制的实现,以上的描述是很粗略的,但希望可以传达出正确的概念。
还有一点就是对于屏障指令,可能xchg指令相对于普通的加载与存储而言系统开销过于昂贵。但它必须等待之前挂起的存储器操作完成,所以通常感觉不是那么明显。
这种典型的同步实现产生的代码流程如下,还有就是需要确保编译器本身不会产生任何代码重排而导致连续性被破坏。编译器可能在特定的情况下做得更好:
简单的加载与存储
普通的内存访问会被编译成普通的加载与存储指令,对于真实的架构而言也通常是必不可少的,因为它大量执行单线程代码速度会有所降低。
就像上面的例子指出的那样,在原始代码未修改寄存器的情况下编译器不会将寄存器中的数据写入内存这一点很关键,哪怕这些存储是从同一个内存地址读数据然后再写入同一个内存地址。该存储机制通常会引起资源竞争而导致错误的结果。
更微妙的一点是编译器,经常会在数据加载时候导致资源竞争,得出无效的结果。例如,在while循环里有一个重复使用的变量的值,只被加载一次到寄存器是没问题的,哪怕循环体从未用过它。但这确实会引起资源竞争,因此在C++程序中这种转换是不被许可的,如果结果是另一个C++程序。然而编译器通常生成的机器码对资源竞争有很好的处理。因此这种转换,对机器码而言是有意义且可以接受的(例外可以假设,但机器确实会探测到资源竞争)
对于新引入的存储指令的限制,普通的加载与存储的指令重排对于没有资源竞争的单线程程序是没有影响的,只要这些操作没有与同步操作重合。有些同步操作也可以,只是有更复杂的规则在那里。比如JAVA在语义层面支持处理资源竞争,实际的转换就更复杂。
同步加载
同步加载被编译为普通的加载操作和一个紧接着的内存屏障。要知道为什么这是必须的,请考虑这样一个情景,代码中有一个同步共享的变量等待被设置,然后访问一些共享的数据,比如在延迟初始化的例子中:
声明 x_init为同步变量;
while(!x_init);
此处的x是之前被另一个线程初始化的;
如果最终读取的x_init是在第一次访问x之后,那这段代码极有可能错误的访问一个未被初始化的x。蕃篱指令在同步加载之后插入,这种情况下的x_init,会防止重排序,以确保x只能在被初始化设置后被访问。
同步存储
同步存储的实现是在存储指令的前后加上屏障指令。
要了解为什么要在前面加上屏障指令是必须的,请考虑前面那个例子若线程初始化x,它的实现是这样的:
x = 初始化的值;
x_init = true;
如果在线程中这两次赋值变的无序且可见,那么读线程可能看到x_init被设置成true,这是在x被初始化之前。将屏障指令加在x_init赋值操作之前可以防止指令重排造成的这种情况。
在这个例子中x_init赋值操作之后的屏障保护不是必要的,但是在大多数通常情况下需要防止同步存储的指令重排对后续的同步加载造成影响。要明白这一点,让我们重新检视开篇那个介绍性的例子,变量X与Y作为同步变量:
线程1 : X=1; R1 = Y;
线程2 : Y=1; R2 = X;
在存储指令之前,以及加载指令之后,都没有屏障指令,要防止赋值操作在每个线程中被重排,以导致类似 r1 = r2 = 0这样的非连续一致性的错误结果。我们需要在同步存储之后,或同步加载之前加上屏障指令。
因为加载操作一般更频繁一些,所以我们经常选择在同步存储之后加上屏障指令这种方式。
锁
一个简单的自旋锁可以被表示为一个值为0(解锁)或1(加锁)的变量 l。一个锁操作可以被实现为设置寄存器的一个值,比如设置 r1 为1, 然后一直重复执行 xchg r1,[l] 直到 r1 变为 0, 用以指示线程将要把 l 变为锁定状态。需要在后面加上屏障指令来确保该操作的执行是互斥的,也就是对外不可见,除非锁已经被获取。(真实的实现一般在等待锁时会避免重复执行xchg指令,因为这需要在处理器缓存之间创建大量不必要的通信)
上面的自旋锁将被释放当普通的存储操作将 l 设置为 0. 这次我们需要一个前置的屏障来防止在unlock()解锁操作之后因重要部分变的可见而导致对其误操作。
需要注意的是lock()/unlock()操作每个只需要一个屏障指令,而不是像同步存储操作那样需要两个。这依赖没有资源竞争时差别不可见,这也推导出一些假设关于锁是怎样被访问的,这在下一节“非阻塞获取锁”中有提及。
许多机器的真实架构相对于我们这里讨论的例子允许硬件在进行内存操作时执行更少的指令重排。 比如“X86”机器,以xchg指令实现的同步存储也没有显式的屏障,可能事实上也不需要。另外,同步加载可以用普通的加载指令实现,一个简单的锁也可以被普通的存储指令释放。
原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/140678.html