让我试着通过分解来澄清这个复杂的问题。
什么是“阅读介绍”?
“阅读介绍”是一种优化,其中代码:
public static Foo foo; // I can be changed on another thread!
void DoBar() {
Foo fooLocal = foo;
if (fooLocal != null) fooLocal.Bar();
}
通过消除局部变量进行优化。编译器可以推断,如果只有一个线程,那么foo
和fooLocal
是同一件事。编译器被明确允许进行任何在单个线程上不可见的优化,即使它在多线程场景中变得可见。因此允许编译器将其重写为:
void DoBar() {
if (foo != null) foo.Bar();
}
现在有一个竞争条件。如果foo
检查后从非空变为空,则可能foo
第二次读取它,第二次读取它可能为空,然后会崩溃。从诊断崩溃转储的人的角度来看,这将是完全神秘的。
这真的会发生吗?
正如您链接到的文章所指出的:
请注意,您将无法在 x86-x64 上的 .NET Framework 4.5 中使用此代码示例重现 NullReferenceException。阅读介绍很难在 .NET Framework 4.5 中重现,但在某些特殊情况下确实会出现。
x86/x64 芯片具有“强”内存模型,并且 jit 编译器在这方面并不激进;他们不会做这个优化。
如果你碰巧在弱内存模型处理器上运行你的代码,比如 ARM 芯片,那么所有的赌注都没有了。
当您说“编译器”时,您指的是哪个编译器?
我的意思是 jit 编译器。C# 编译器从不以这种方式引入读取。(这是允许的,但实际上从不这样做。)
在没有内存屏障的线程之间共享内存不是一种不好的做法吗?
是的。这里应该做一些事情来引入内存屏障,因为的值foo
可能已经是处理器缓存中的一个过时的缓存值。我更喜欢引入内存屏障是使用锁。您也可以制作 field volatile
,或使用VolatileRead
,或使用其中一种Interlocked
方法。所有这些都引入了内存屏障。(volatile
仅介绍“半栅栏”仅供参考。)
仅仅因为存在内存屏障并不一定意味着不执行读取引入优化。但是,对于影响包含内存屏障的代码的优化,抖动要小得多。
这种模式还有其他危险吗?
当然!假设没有阅读介绍。你仍然有一个竞争条件。如果另一个线程foo
在检查后设置为 null,并且还修改了将要使用的全局状态Bar
怎么办?现在您有两个线程,其中一个认为它foo
不为 null 并且全局状态可以调用Bar
,另一个线程认为相反,并且您正在运行Bar
。这是灾难的秘诀。
那么这里的最佳做法是什么?
首先,不要跨线程共享内存。程序主线内部有两个控制线程的想法一开始就很疯狂。它本来就不应该是一件事。使用线程作为轻量级进程;给他们一个独立的任务来执行,它根本不与程序主线的内存交互,只是用它们来分出计算密集型的工作。
其次,如果您要跨线程共享内存,则使用锁来序列化对该内存的访问。如果没有争用锁,那么锁很便宜,如果你有争用,那么就解决这个问题。众所周知,低锁定和无锁定解决方案很难正确解决。
第三,如果您要跨线程共享内存,那么您调用的每个涉及共享内存的方法都必须在面对竞争条件时保持稳健,或者必须消除竞争。这是一个沉重的负担,这就是为什么你不应该首先去那里。
我的观点是:阅读介绍很可怕,但坦率地说,如果您编写的代码可以轻松地跨线程共享内存,那么它们是您最不担心的。首先要担心一千零一件事。