15

正如在这个问题中看到的那样: 使用扩展方法引发 C# 事件 - 是不是很糟糕?

我正在考虑使用这种扩展方法来安全地引发事件:

public static void SafeRaise(this EventHandler handler, object sender, EventArgs e)
{
    if (handler != null)
        handler(sender, e);
}

但是 Mike Rosenblum 在 Jon Skeet 的回答中提出了这种担忧:

你们需要将 [MethodImpl(MethodImplOptions.NoInlining)] 属性添加到这些扩展方法中,否则 JITter 可能会优化您将委托复制到临时变量的尝试,从而允许出现空引用异常。

我在 Release 模式下进行了一些测试,以查看当扩展方法未使用 NoInlining 标记时是否可以获得竞争条件:

int n;
EventHandler myListener = (sender, e) => { n = 1; };
EventHandler myEvent = null;

Thread t1 = new Thread(() =>
{
    while (true)
    {
        //This could cause a NullReferenceException
        //In fact it will only cause an exception in:
        //    debug x86, debug x64 and release x86
        //why doesn't it throw in release x64?
        //if (myEvent != null)
        //    myEvent(null, EventArgs.Empty);

        myEvent.SafeRaise(null, EventArgs.Empty);
    }
});

Thread t2 = new Thread(() =>
{
    while (true)
    {
        myEvent += myListener;
        myEvent -= myListener;
    }
});

t1.Start();
t2.Start();

我在发布模式下运行了一段时间的测试,但从未出现过 NullReferenceException。

那么,Mike Rosenblum 在他的评论和方法内联不能导致竞争条件中是错误的吗?

事实上,我想真正的问题是,SaifeRaise 是否会被内联为:

while (true)
{
    EventHandler handler = myEvent;
    if (handler != null)
        handler(null, EventArgs.Empty);
}

或者

while (true)
{
    if (myEvent != null)
        myEvent(null, EventArgs.Empty);
}
4

3 回答 3

7

问题不在于内联该方法——无论它是否被内联,JITter 都会在内存访问方面做一些有趣的事情。

但是,我不认为这首先一个问题。几年前有人提出了这个问题,但我认为这被认为是对记忆模型的错误解读。变量只有一次逻辑“读取”,而 JITter 无法对其进行优化,以使值在一次读取副本和第二次读取副本之间发生变化。

编辑:澄清一下,我完全理解为什么这会给你带来问题。你基本上有两个线程修改同一个变量(因为它们正在使用捕获的变量)。代码完全有可能像这样发生:

Thread 1                      Thread 2

                              myEvent += myListener;

if (myEvent != null) // No, it's not null here...

                              myEvent -= myListener; // Now it's null!

myEvent(null, EventArgs.Empty); // Bang!

这在此代码中比正常情况稍微不那么明显,因为该变量是捕获的变量而不是普通的静态/实例字段。但同样的原则也适用。

安全引发方法的要点是将引用存储在不能从任何其他线程修改的局部变量中:

EventHandler handler = myEvent;
if (handler != null)
{
    handler(null, EventArgs.Empty);
}

现在,线程 2 是否更改了值无关紧要myEvent- 它无法更改处理程序的值,因此您不会得到NullReferenceException.

如果 JIT确实inline SafeRaise,它将被内联到这个片段 - 因为内联参数最终成为一个新的局部变量,有效。只有当 JIT通过保留两个单独的myEvent.

现在,至于为什么您在调试模式下看到这种情况:我怀疑在附加了调试器的情况下,线程之间有更多的空间来相互中断。可能发生了其他一些优化 - 但它没有引入任何破损,所以没关系。

于 2010-02-24T16:07:13.653 回答
5

这是一个内存模型问题。

基本上问题是:如果我的代码只包含一个逻辑读取,优化器可以引入另一个读取吗?

令人惊讶的是,答案是:也许

在 CLR 规范中,没有什么可以阻止优化器这样做。优化不会破坏单线程语义,并且内存访问模式只能保证为易失性字段保留(即使这是一个不是 100% 正确的简化)。

因此,无论您使用局部变量还是参数,代码都不是线程安全的

但是,Microsoft .NET 框架记录了不同的内存模型。在该模型中,不允许优化器引入读取,并且您的代码是安全的(独立于内联优化)。

也就是说,使用 [MethodImplOptions] 似乎是一个奇怪的 hack,因为阻止优化器引入读取只是不内联的副作用。我会改用 volatile 字段或 Thread.VolatileRead。

于 2010-03-02T19:04:33.007 回答
1

使用正确的代码,优化不应该改变它的语义。因此,如果错误不在代码中,优化器不会引入错误。

于 2010-02-24T16:06:42.073 回答