在“坚果壳中的C#4”中,作者表明这个类有时可以在没有MemoryBarrier
的情况下写0,但我无法在我的Core2Duo中重现:
public class Foo
{
int _answer;
bool _complete;
public void A()
{
_answer = 123;
//Thread.MemoryBarrier(); // Barrier 1
_complete = true;
//Thread.MemoryBarrier(); // Barrier 2
}
public void B()
{
//Thread.MemoryBarrier(); // Barrier 3
if (_complete)
{
//Thread.MemoryBarrier(); // Barrier 4
Console.WriteLine(_answer);
}
}
}
private static void ThreadInverteOrdemComandos()
{
Foo obj = new Foo();
Task.Factory.StartNew(obj.A);
Task.Factory.StartNew(obj.B);
Thread.Sleep(10);
}
这种需求对我来说似乎很疯狂。如何识别出现这种情况的所有可能情况?我认为如果处理器改变了操作顺序,它需要保证行为不会改变。
你还懒得使用障碍吗?
答案 0 :(得分:66)
你将很难再现这个bug。事实上,我会说你永远无法使用.NET Framework重现它。原因是Microsoft的实现使用强大的内存模型进行写入。这意味着写入被视为易失性。易失性写入具有锁定释放语义,这意味着必须在当前写入之前提交所有先前写入。
但是,ECMA规范的内存模型较弱。因此,从理论上讲,Mono甚至.NET Framework的未来版本可能会开始展示错误行为。
所以我所说的是,删除障碍#1和#2不太可能对程序的行为产生任何影响。当然,这不是保证,而是基于CLR当前实施的观察。
消除障碍#3和#4肯定会产生影响。这实际上很容易重现。好吧,本身不是这个例子,但下面的代码是一个比较着名的演示。它必须使用Release版本进行编译,并在调试器之外运行。错误是程序没有结束。您可以通过在Thread.MemoryBarrier
循环内拨打while
或将stop
标记为volatile
来解决此问题。
class Program
{
static bool stop = false;
public static void Main(string[] args)
{
var t = new Thread(() =>
{
Console.WriteLine("thread begin");
bool toggle = false;
while (!stop)
{
toggle = !toggle;
}
Console.WriteLine("thread end");
});
t.Start();
Thread.Sleep(1000);
stop = true;
Console.WriteLine("stop = true");
Console.WriteLine("waiting...");
t.Join();
}
}
一些线程错误难以重现的原因是因为用于模拟线程交错的相同策略实际上可以修复错误。 Thread.Sleep
是最值得注意的例子,因为它会产生内存障碍。您可以通过在while
循环内放置一个调用并观察该错误消失来验证这一点。
您可以看到我的回答here,以便对您引用的书中的示例进行另一次分析。
答案 1 :(得分:10)
第二个任务甚至开始运行时,第一个任务完成的几率非常。如果两个线程同时运行该代码并且没有中间缓存同步操作,则只能观察到此行为。你的代码中有一个,StartNew()方法将在某个地方的线程池管理器中锁定。
让两个线程同时运行此代码非常很难。此代码在几纳秒内完成。您将不得不尝试数十亿次并引入可变延迟以获得任何赔率。当然没有太多指向,当真正的问题是,当你不期待它时,它会随机发生。
远离这一点,使用lock语句编写合理的多线程代码。
答案 2 :(得分:2)
如果您使用volatile
和lock
,则会内置内存屏障。但是,是的,您确实需要它。话虽如此,我怀疑你需要的数量是你的例子的一半。
答案 3 :(得分:2)
很难重现多线程错误 - 通常你必须多次运行测试代码(数千)并进行一些自动检查,以便在发生错误时进行标记。你可能会尝试在某些行之间添加一个简短的Thread.Sleep(10),但同样不能保证你会遇到与没有它相同的问题。
内存障碍是为那些需要对其多线程代码进行真正的硬核低级性能优化的人们推出的。在大多数情况下,使用其他同步原语(即volatile或lock)会更好。
答案 4 :(得分:1)
我将引用一篇关于多线程的精彩文章:
考虑以下示例:
class Foo
{
int _answer;
bool _complete;
void A()
{
_answer = 123;
_complete = true;
}
void B()
{
if (_complete) Console.WriteLine (_answer);
}
}
如果方法A和B在不同的线程上并发运行,可能就是这样 B可以写“0”吗?答案是肯定的 - 对于以下内容 原因:
编译器,CLR或CPU可能会将程序的指令重新排序 提高效率。编译器,CLR或CPU可能会引入缓存 优化使得对变量的赋值不可见 其他线程马上。 C#和运行时非常小心 确保此类优化不会破坏普通的单线程 代码 - 或正确使用锁的多线程代码。外 在这些场景中,您必须通过明确地击败这些优化 创建内存屏障(也称为内存屏障)来限制 指令重新排序和读/写缓存的效果。
全围栏
最简单的内存屏障是完整内存 屏障(全栅栏),防止任何类型的指令重新排序 或围绕那个围栏缓存。调用Thread.MemoryBarrier生成一个 全围栏;我们可以通过应用四个完整的栅栏来修复我们的例子 如下:
class Foo
{
int _answer;
bool _complete;
void A()
{
_answer = 123;
Thread.MemoryBarrier(); // Barrier 1
_complete = true;
Thread.MemoryBarrier(); // Barrier 2
}
void B()
{
Thread.MemoryBarrier(); // Barrier 3
if (_complete)
{
Thread.MemoryBarrier(); // Barrier 4
Console.WriteLine (_answer);
}
}
}
Thread.MemoryBarrier
背后的所有理论以及为什么我们需要在非阻塞场景中使用它来使代码安全和健壮,这里有很好的描述:http://www.albahari.com/threading/part4.aspx
答案 5 :(得分:0)
如果您要触摸两个不同线程的数据,可能会发生这种情况。这是处理器用来提高速度的技巧之一 - 你可以构建没有这样做的处理器,但它们会慢得多,所以没有人再这么做了。您应该阅读Hennessey and Patterson之类的内容来识别所有不同类型的竞争条件。
我总是使用某种更高级别的工具,比如监视器或锁,但在内部他们正在做类似的事情或者用障碍实现。