一个系列 之二
按照上一篇中的计划,这一篇应当从实践的角度分析如何在Lock Free Code中注意Out Of Order问题带来的影响。但是不想这一段时间出了这么多的事情,包括.NET的内存模型实现上出了Bug让人们更加关注这部分的问题。那么这篇就对前几篇小小随笔做一个全面的解释,改正文章里出现的理解错误,或容易被理解错误的地方。
首先附上前几篇文章的链接:
"Loads are not reorderd with other loads" is a FACT!!
"Loads are not reorderd with other loads" is a FACT!! 续:不要指望 volatile
"Loads are not reorderd with other loads" is a FACT!! 再续:.NET MM IS BROKEN
一开始我们就开始提内存模型,实际上,关于内存模型的问题对于底层代码编写人员显得格外重要(把话说明确一点,就是内存模型对于写无锁代码的程序员非常重要!)而对于使用高级同步结构的同志就不那么明显了。关于内存模型我们在文章中强调的问题始终是:你的代码并不一定向你想象的那样在执行。实际上这不是全部,我忘记了一个地方就是内存模型不是一个层次就能够决定的。
一、内存一致性模型
想要正确的编写共享内存平台下的并行程序的话,需要了解在多个处理器上,内存的读写操作是以一种什么样的方式来执行的。这种对多处理器下内存的读写行为进行的描述,称为称为内存一致性模型。也就是我们关注的所谓“内存模型”。(参见: Hans-J. Boehm,Threads Cannot be Implemented as a Library,Internet Systems and Storage Laboratory,HP Laboratories Palo Alto,November 12, 2004)
内存一致性模型为多处理器共享内存系统中的内存系统对程序开发者的影响提供了一种正式的描述。化解了开发者的期望与实际系统行为之间的差异。为了保证有效性,内存一致性模型往往对于可返回的共享内存数据添加了一些限制。直观的比如,一个“读”操作应当返回最后一次对应位置“写”操作所写入的数据。在单处理器系统中,“最后一次”是对于程序顺序而言的,即,在程序中内存操作指令出现的顺序。但是在多处理器系统中,这个概念不再适用。例如,对于上述程序来说,对于data的读与写并不是以程序顺序进行衡量的,因为他们分别在两个不同的处理器上执行。然而,直接将单一处理器的模式应用到多处理器系统中却是可行的。这中模型称为顺序一致性模型。不准确的说,顺序一致性模型就是使内存的访问必须一个一个的执行,并且对于其中的任一个处理器而言,都是按照程序指定的顺序进行执行的。这样对于上述程序来说,就可以保证,每次读到的data均是P1写入时的data。(参见:Sarita Adve,Phd: Designing Memory Consistency Models for Shared-Memory Multiprocessors,University of Wisconsin-Madison Technical Report #1198, December 1993)
顺序的内存一致性模型为我们提供了一种简单的并且直观的程序模型。但是,这种模型实际上阻止了硬件或者编译器对程序代码进行的大部分优化操作。为此,人们提出了很多松弛的(relaxed)内存模型,给予处理器权利对内存的操作进行适当的调整。例如Alpha处理器,PowerPC处理器以及我们现在使用的x86, x64系列的处理器等等。这些内存模型各不相同,为跨平台的应用带来了很多障碍。
内存一致性模型是联系程序开发人员与系统的接口。他会影响共享内存应用程序 的开发(开发人员必须使用这些约定来保证其程序的正确性)。需要指出的是,内存一致性模型会在某些程度上影响系统的性能,因为他部分的限制了硬件或者软件的优化机制。并且,即使在有内存一致性模型的情况下,不同系统之间由于内存一致性模型的不同也会给程序的移植工作带来困难。
注:以上所述的内容说明了,内存一致性模型的繁杂使得开发跨平台的并行程序难度加大,因此,如果希望实现跨平台就需要一定程度上统一内存模型,当然,这会带来一定的性能冲击。
注:以下将要说明的就是我们前一阶段文章忽略了的问题。就是内存一致性模型并不仅仅是CPU决定的,而是系统的各个层面上的组合。
内存一致性模型需要在各种的程序与系统的各个层次上定义内存访问的行为。在机器码与的层次上,其定义将影响硬件的设计者以及机器码开发人员;而在高级语言层次上,其定义将影响高级语言开发人员以及编译器开发人员和硬件设计人员。因此以上所谓的可编程性,性能以及可移植性在各种层次上都需要进行考虑。
注:因此所谓的语言的,编译器的内存模型说的就是语言层次上的。例如,目前C++对内存一致性模型的描述非常弱,因此我们在编程的时候就非常依赖编译器的内存一致性模型(当然造成的结果也就是程序的正确性对编译器的依赖)。
因此,内存一致性模型不但会从程序的角度影响并行程序开发人员,而且从系统设计人员的角度看影响了并行系统的方方面面(处理器,内存系统,互联,编译器以及程序语言)。如果能够在不同的实际平台上保证一定的宽松的(Relaxed)内存一致性模型。就可以在保持性能的同时从很大程度上保证多线程程序库的通用性与正确性。
[END:内存一致性模型]
可见,所谓统一内存模型的.NET Framework,其内存模型并不仅仅由CLI的实现者决定,而是CLI的实现者,编译器,内存系统以及处理器共同作用而形成的。即我们在第一篇文章中提到的:话说你某一天编写了一段代码-->你在编译器上进行编译发现很顺利(但是在编译的过程中可能你的代码顺序已经被改变了,这种改变应该属于执行顺序改变)-->你试图运行目标代码(目标文件中的代码的顺序应该是程序顺序)-->你的代码开始被执行(但是执行过程中CPU对内存操作进行了乱序,这属于执行顺序的改变)
那么有了内存一致性模型,我们对内存操作的规则就有了一个大概的了解。那么有没有什么办法来对内存访问的顺序进行限制呢?有!那就是内存栅障!这里我们重提这个部分就是因为以前的文章对这一部分的说明相当容易让人误解。这回来一个彻底了断。
二、内存栅障
如果运行平台的共享内存模型是确定的,则遵照这个模型编写的多线程程序库可以在支持这个运行平台的任何底层平台上正确的运行。但是,有时算法(尤其是无锁算法)需要我们自己实现某一种特定的内存操作的语义以保证算法的正确性。这时我们就需要显式的使用一些指令来控制内存操作指令的顺序以及其可见性定义。这种指令称为内存栅障(内存栅栏)。
我们刚才提到了。内存一致性模型需要在各种的程序与系统的各个层次上定义内存访问的行为。在机器码与的层次上,其定义将影响硬件的设计者以及机器码开发人员;而在高级语言层次上,其定义将影响高级语言开发人员以及编译器开发人员和硬件设计人员。即,内存操作的乱序在各个层次都是存在的。这里,所谓的程序的执行顺序有三种:
(1)程序顺序:指在特定CPU上运行的,执行内存操作的代码的顺序。这指的是编译好的程序二进制镜像中的指令的顺序。编译器并不一定严格按照程序的顺序进行二进制代码的编排。编译器可以按照既定的规则,在执行代码优化的时候打乱指令的执行顺序,也就是上面说的程序顺序。并且,编译器可以根据程序的特定行为进行性能优化,这种优化可能改变算法的形式与算法的执行复杂度。(例如将switch转化为表驱动序列)
(2)执行顺序:指在CPU上执行的独立的内存相关的代码执行的顺序。执行顺序和程序顺序可能不同,这种不同是编译器和CPU优化造成的结果。CPU在执行期(Runtime)根据自己的内存模型(跟编译器无关)打乱已经编译好了的指令的顺序,以达到程序的优化和最大限度的资源利用。
(3)感知顺序:指特定的CPU感知到他自身的或者其他CPU对内存进行操作的顺序。感知顺序和执行顺序可能还不一样。这是由于缓存优化或者内存优化系统造成的。
(参见:Memory Ordering in Modern Microprocessors)
而最终的共享内存模型的表现形式是由这三种“顺序”共同决定的。即从源代码到最终执行进行了至少三个层次上的代码顺序调整,分别是由编译器和CPU完成的。我们上面提到,这种代码执行顺序的改变虽然在单线程程序中不会引发副作用,但是在多线程程序中,这种作用是不能够被忽略的,甚至可能造成完全错误的结果。因此,在多线程程序中,我们有时需要人为的限制内存执行的顺序。而这种限制是通过不同层次的内存栅障完成的。
注:在以前我们没有提到不同层次的内存栅障而实际上,如果您在使用Visual Studio 2005/2008,您就会发现API中出现了_ReadBarrier, _WriteBarrier, _ReadWriteBarrier等等,看看MSDN得知这是内存栅障,可是这是什么层次上的内存栅障呢?
严格意义上来讲,内存栅障仅仅应用于硬件层次而并非软件。即,内存栅障并不是对于编译器而言的。但是由于编译器更改代码顺序的现象确实存在,因此又引入了编译器内存栅障的概念。以下给出其定义:
- 编译器内存栅障指,编译器保证在内存栅障两侧的代码不会跨越内存栅障,但是不能够阻止CPU改变代码的执行顺序。
- 内存栅障指,一系列强制促使CPU按照一定顺序,在其两侧按照一致性规则执行内存指令的指令。
以Visual C++ 8.0/9.0编译器为例。编译器规则中规定,Visual C++编译器有权利对声明为volatile的变量的操作调整其顺序以达到优化的效果,因此,在Platform SDK中引入了编译器内存栅障——_ReadBarrier(),_WriteBarrier(),_ReadWriteBarrier()。恰当的使用这些函数可以确保在多线程模式下,代码的执行顺序不会因为编译器优化的原因而更改。否则,优化之后的程序行为可能会被改变。可见,这种优化仅仅是对于编译器一级而言的。而并不保证执行过程。
所以,在"Loads are not reorderd with other loads" is a FACT!! 再续:.NET MM IS BROKEN ,才有了这样的代码:
{
long result;
volatile long* p = ptr;
__asm
{
__asm mov edx, p
__asm mov eax, value
__asm lock xchg [edx], eax
__asm result, eax
}
load_with_acquire(*ptr);
return result;
}
template <typename T>
static long load_with_acquire(const T& ref)
{
long to_return = ref;
#if (_MSC_VER >= 1300)
_ReadWriteBarrier();
#endif
return to_return;
}
为什么在InterlockedExchange中还需要load_with_acquire呢?原文中的注释欠考虑了,实际上是因为阻止编译器对 voltaile进行优化的原因。而如果编译器保证volatile不会进行顺序调整,例如Intel编译器也就不用再来一次load_with_acquire了。读者可能要问了,那么如果_ReadWriteBarrier仅仅是编译器一级的,那么执行顺序不就变化了吗?厄,单纯对于这个load是不会的,因为这个代码是IA-32下的,CPU已经保证了load的语义,只要编译器不擅自改变就OK了。
而真正的内存栅障是硬件一级的。是采用了CPU提供的某些特定的指令。例如,在Microsoft Platform SDK中,MemoryBarrier宏即该类型的内存栅障。其定义如下(在x86,x64,IA64平台下):
#define MemoryBarrier __faststorefence
#endif
#ifdef _IA64_
#define MemoryBarrier __mf
#endif
// x86
FORCEINLINE
VOID
MemoryBarrier(void)
{
LONG Barrier;
__asm {
xchg Barrier, eax
}
}
通过以上说明可见,如果希望得到正确的内存操作顺序,就需要在程序中恰当的使用软件的或者真正的内存栅障。内存栅障使用过度,会造成程序性能比较严重的下降(因为CPU的内存操作顺序优化和Cache优化不能发挥作用);而使用不当则会造成非常隐蔽而难以调试的错误。
[END:内存栅障]
三、Cache的一致性
在“之一”中,有一句话:“CPU 1操作了内存单元1进而操作了内存单元2,但是另一个CPU先看到了内存单元2的改变而后又看到了内存单元1的改变”需要指出的是:这个在大多数处理器中都是不存在的,大多数处理器保证了这种顺序的可见性与操作顺序是一致的。(对于Intel处理器,参见:Intel 64 and IA-32 Architectures Software Developer's Manual,Volume 3A: System Programming Guide,10.4)
在大多数有关多线程的文章中,都会提到由于内存的优化,以至于可见性...需要澄清的是,这种可见性的差异在大多数情况下都不是Cache造成的。应该说是Memory Optimization造成的还不错。因为正因为为了避免Cache的不一致,才有了Cache一致性协议的研究。才有了Intel关于我的Cache采用了四种状态云云,实际上就是所谓的采用了总线监听协议。
我们在"Loads are not reorderd with other loads" is a FACT!! 系列文章中,通过了一个例子说明.NET MM是有问题的,但是如果Cache都没问题,那么到底是什么造成的这个问题呢?答案是,Store Buffer!!我们先来看看Cache的工作:
如果CPU发现系统内存的操作数是可以被缓存的(并非所有的内存单元都是可以被缓存的),CPU便将一个Cache Line全部读到恰当的缓存中L1,L2或者L3(如果有的话)。该操作称为Cache Line Fill。如果CPU发现需要的操作数已经存在在Cache中,则CPU直接从缓存中而不是内存中读取操作数,这种情况称之为Cache Hit。
当CPU试图向可缓存的系统内存写入操作数时,其首先检查Cache中是否已经缓存了该操作数。如果一个有效地(Valid)Cache Line的确存在在缓存中,CPU(根据当前写操作数的策略)可以将操作数写入Cache中而不是系统内存中。这种操作称之为Write Hit。反之,如果缓存中并不包含该操作数的地址,则CPU执行一次Cache Line Fill操作,并将操作数写入缓存,同时(根据当前写操作数的策略)可以将操作数写回系统内存。如果真的要将操作数写回内存,则其首先写回存储缓冲区(Store Buffer),然后等待总线空闲,并从存储缓冲区(Store Buffer)回写到内存中。(参见:Intel 64 and IA-32 Architectures Software Developer's Manual,Volume 3A: System Programming Guide,10.2)
好了,现在我们知道Store Buffer是什么东西了。如果没有Store Buffer那么我们必须等待总线空闲再将操作数写回内存,但是现在,即使总线不是空闲的Cache回写的操作也能立即返回!但是,当一个Store临时的处于Store Buffer时,他满足了自身处理器的Load操作但是不能满足其他处理器的Load操作。也就是,此时如果其他CPU需要Load这个地址,则他不能看到新的值!这不是因为Cache不一致造成的,但是现象好像是Cache不一致造成的!(参见:Intel 64 Architecture Memory Ordering White Paper,2.4)
因此,我们说,如果要修正这个错误就要Flush Store Buffer,而不是Flush Cache!而如何Flush Store Buffer呢?列举如下:
当一个CPU异常或者中断产生的时候;
当一个顺序执行指令执行的时候;
当一个I/O指令执行的时候;
当一个LOCK操作执行的时候;
当一个BINIT操作执行的时候;
当使用LFENCE限制操作的调整的时候;
当使用MFENCE限制操作调整的时候;
(参见:Intel 64 and IA-32 Architectures Software Developer's Manual,Volume 3A: System Programming Guide,10.10)
而我们知道,System.Threading.Thread.MemoryBarrer()实际上就是一个xchg(对于IA-32),属于一个LOCK操作,因此具有Flush Store Buffer的功能!因此我们说System.Threading.Thread.MemoryBarrer()可以修正这个错误,但是原因并不是Cache,而是Store Buffer!
至此,对于上述文章的说明与修正可以到一个段落了。与大家共同学习:-)