"Loads are not reorderd with other loads" is a FACT!! 再续:.NET MM IS BROKEN

上一篇随笔“"Loads are not reorderd with other loads" is a FACT!! 续:不要指望 volatile”中已经提到了。.NET的内存模型在volatile load上的实现是错误的。这在今天终于是半个官方的结论了。有关这个讨论的结论可以参考“A bit more formalism as to why CLR's MM is broken on x86/x64 ”。

 

关于内存模型(MM)的问题枯燥,缩略词跟别的领域有过之无不及,为了便于说明趁着这个机会罗列一下。

 

内存模型(MM),实际上我们关注的是位于多处理器共享内存处理器的内存一致性模型(Memory Consistency Model for Shared Memory Multi-Processor)之上的,由某一个标准规定的软的内存模型。例如可以运行托管代码的.NET虚拟机,也像一个真正的物理的计算机一样在管理内存,那么对内存读写时其的行为是什么样的呢,这就需要内存模型来规定(这是一个软的内存模型)。

 

好了,内存模型对于多线程的影响主要有这么两个问题,第一个问题是次序调整问题,也就是memory reorder问题。第二个问题是可见性问题。这两个问题是有关联的,但是必须分开!一会儿就会看到他们实际上不是一回事。

 

为什么要reorder,而不是干脆顺序执行算了,厄~这是因为能够最大限度的利用处理器的性能和资源。当然,这种特性使得多线程程序少不留神就会引入Bug(在《程序员》上看到一个笑话,说,如果让程序员来做汽车,那么现在汽车百公里内油耗将是原来的十分之一,而性能确实现在的十倍。但有一点,这汽车肯定一年爆炸一次,无一幸免)。

 

memory reorder在现代处理器中是非常常见的。例如,对于Intel x86处理器来说,有如下规则(详见Intel Memory Ordering 白皮书):

 

(1) load操作不会和其他load操作调整次序

(2) store操作不会和其他store操作调整次序

(3) store操作不会和之前的load操作调整次序

(4) load可能会和之前的store操作调整次序,但仅仅限于操作不同位置的内存。如果操作的内存相同,则不会交换。

(5) 在多处理器系统中,内存操作次序调整代表了可见性的次序(注意,仅仅是次序,并不代表这种次序立杆见影的体现出来。对于这种情况,参见Intel白皮书内第2.4或参见前一篇随笔

(6) 在多处理器系统中,对于相同位置的store不会调整。

(7) 在多处理器系统中,lock修饰的指令不会调整。

(8) 所有被lock修饰的load和store都不会调整。

 

 好了,以上就是memory reorder的内容了。下面的问题是可见性的问题。让人疯狂的是,memory指令的结束并不意味着可见性。为什么,厄~这是CPU内存结构优化的结果。我们可以另写一篇文章了。

 

因此,memory reorder的内容对于另一个(逻辑)处理器来说可能不是真理,但是对自己来说,一定是可见的。可惜,我们以前没有意识到这个问题。而是想当然的认为其有acquire或者release语义。(当然除了lock的情况)

 

等等,什么叫做acquire和release语义。我们在这里给出严格定义:

 

acquire语义:一个操作具有acquire语义,则系统中的其他(逻辑)处理器一定能够在看到其后的命令的结果之前看到其结果。

release语义:一个操作具有release语义,则系统中其他(逻辑)处理器一定能够看到在这之前的所有处理了的指令的结果。

 

可见,acquire和release规定了指令允许调整的方向,即一个指令有acquire语义则其之前的指令可能调整到其后执行,但是其后的指令一定不可能在它执行之前执行;一个指令有release语义,则其之后的指令可能调整到其前执行,但是其前的指令一定不会调整到其后执行。更进一步的,acquire和release语义规定了可见性。这个是非常要命的。

 

好,明白了这个,我们再看看我们原始的例子:

 

X = 1;              Y = 1;
R0 = X;             R2 = Y;
R1 = Y;             R3 = X;

 

我们假设X和Y都是volatile的。这样,如果.NET的MM正确实现,则所有的volatile load均有acquire语义,所有的store,不管是不是volatile的,都具有release属性!由于相关性,X=1不可能和R0=X交换,并且,R0=X也不可能和R1=Y交换。又依照可见性,不论如何都不会出现全部执行完毕之后R1=0,R3=0的结果。可是这个结果恰恰出现了!

 

好,那么我们如何解决可见性的问题呢?答案是恰当的memory fence.参考<<Intel® 64 and IA-32 Architectures Software Developer's Manual>>

 

LFENCE:强制按顺序执行load操作,其后的所有load操作将等待直到先前的load全局可见!

SFENCE:强制按顺序执行store操作,其后的所有store操作将等待直到先前的store全局可见!

MFENCE:强制按顺序执行load和store操作,其后的load或store操作等待直到先前的load或store全局可见!

 

慢,那LOCK呢,厄,实际上手册上对于LOCK保证不会有多个处理器同时更新其内容(不论是锁定总线还是锁定Cache)但是对于可见性的问题只字未提。实际上,LOCK以及隐式的XCHG之类都没有这种保证。我们来看一个InterlockedExchange的实现:

 

 1 static long InterlockedExchange(volatile long* ptr, long value)
 2 {
 3   long result;
 4   volatile long* p = ptr;
 5   __asm
 6   {
 7     __asm mov edx, p
 8     __asm mov eax, value
 9     __asm lock xchg [edx], eax
10     __asm result, eax
11   }
12   load_with_acquire(*ptr); // make sure it is visible
13   return result;
14 }
15 
16 template <typename T>
17 static long load_with_acquire(const T& ref)
18 {
19   long to_return = ref;
20   _ReadWriteBarrier();
21   return to_return;
22 }

 

 好了,问题到此可以告一段落了。最后想要提醒一句,如果你的程序仅仅只有一个线程并没有跨进程的共享资源,你可以根本不关心这些问题,因为对于单线程来说,处理器中间虽然做了很多的“你不知道的事情”,但是结果“看上去”就好像和顺序执行下来的结果一样;而对于多线程开发人员来说,使用同步对象或者InterlockedXxx之类的时候已经隐式的包含了恰当的memory fence。因此大可不必担心;而如果你是底层人员,那么你不得不着手解决这些繁琐的问题了。

posted @ 2008-07-21 01:52  TW-刘夏  阅读(1675)  评论(6编辑  收藏