程序员眼中的多处理器共享的内存:
作为一个程序员,很容易把多处理器的内存想象成这样:每个处理器的 load & store会按照program order执行;内存在同一时刻只能接受一个处理器的load/store。
毫无疑问,程序员们非常喜欢这个模型,它与单处理器下的 内存模型 是一致的,这是我们最习惯的编程方式,而且为单处理器写出的多线程程序可以很安全的拿到多处理器上运行。
但是,这个模型的性能很差,阻止了很多的CPU和编译器的优化,为了获得高效率,几乎所有编译器和CPU都在不同程度上破坏了这个模型。
CPU和编译器对这个模型的破坏:
现代的CPU基本上都支持out-of-order execution、multiple instruction issue和memory reorder buffer(write buffer),这就使得load & store的program order无法保证。当然啦,这种reorder是在不影响程序正确性的前提下进行的,但是CPU只能保证当前thread的正确性,没有顾及对其他CPU上的thread的影响。
此外,在NUMA的机器(现在高端的x86也有NUMA的机器)上,一个CPU对不同的memory module的访问延迟是不同的。所以即使两个store按照program order被执行,它们完成(被其他的CPU看到)的顺序也会被改变。
很多的编译优化也会打乱load/store的program order(甚至把变量装在register中而移除相应的load & store )。这些优化包括:code motion、register allocation、common sub-expression elimination、 software pipelining等等。与CPU的out-of-order一样,编译器的out-of-order只会保证当前thread的正确性,没有顾及对其他thread的影响。
一些具体的会出错例子:
初始状态:flag1=flag2=0
Thread 1 on processor 1:
flag1=1;
if (flag2==0) then
enter critical section
Thread 2 on processor 2:
flag2=1;
if (flag1==0) then
enter critical section
由于编译器或是CPU可能会在thread 1上先load flag2再store flag1;在thread 2上先load flag1再store flag2,从而使两个thread同时进入critical section!
初始状态:B=0
Thread1 on processor 1:
A=1000;
B=1;
Thread2 on processor 2:
while(B!=1);
C=A;
由于CPU或是编译器可能会reorder thread1中对A、B的store,C的值不一定是1000。更可能的情况是编译器会把B的值装在register中,而使thread2进入一个死循环!
下面我们看一个更加复杂,并且更加现实的例子:
Singleton* Singleton::instance () {
if (pInstance == 0) {
Lock lock;
If (pInstance == 0) {
pInstance = new Singleton();
}
}
return pInstance;
}
这是一段用double checked locking来实现singleton的经典代码,使用它的程序不计其数,但是它有bug:它不是thread-safe的!甚至在单处理器上,也不能保证正确工作。
按照program order,CPU应该先调用Singleton的constructor(一般来说,里面会包含一些store),然后再赋值给pInstance(一个store)。但是编译器可能会inline Singleton(),然后reorder这些store,使得Singleton的constructor还没有执行完,pInstance的值已经不再等于0,造成的结果就是其他的thread会得到一个不完整的Singleton! 即使编译器不做任何的reorder,CPU也有能做同样的事情!