如何正确注销事件处理程序

70

在一次代码审查中,我发现了这个(简化后的)代码片段来取消注册一个事件处理程序:

 Fire -= new MyDelegate(OnFire);

我原本认为这样并不会注销事件处理程序,因为它创建了一个从未注册过的新委托。但是在查看MSDN文档后,我发现了几个使用这种语法的代码示例。

于是我开始了一个实验:

internal class Program
{
    public delegate void MyDelegate(string msg);
    public static event MyDelegate Fire;

    private static void Main(string[] args)
    {
        Fire += new MyDelegate(OnFire);
        Fire += new MyDelegate(OnFire);
        Fire("Hello 1");
        Fire -= new MyDelegate(OnFire);
        Fire("Hello 2");
        Fire -= new MyDelegate(OnFire);
        Fire("Hello 3");
    }

    private static void OnFire(string msg)
    {
        Console.WriteLine("OnFire: {0}", msg);
    }

}

令我惊讶的是,接下来发生了以下情况:

  1. Fire("Hello 1"); 产生了两条消息,与预期相符。
  2. Fire("Hello 2"); 只产生了一条消息!
    这使我相信注销new委托会起作用!
  3. Fire("Hello 3"); 抛出了一个NullReferenceException
    通过调试代码,发现在取消注册事件后,Firenull

我知道对于事件处理程序和委托,编译器在幕后生成了很多代码。但我仍然不明白我的推理有何错误。

我错过了什么?

附加问题:从Fire为空时没有事件注册的事实中,我得出结论:在触发事件的任何地方都需要检查是否为null

2个回答

89

C#编译器默认使用Delegate.Combine来添加事件处理程序,使用Delegate.Remove来移除事件处理程序:

Fire = (MyDelegate) Delegate.Remove(Fire, new MyDelegate(Program.OnFire));

框架中对Delegate.Remove的实现不关注MyDelegate对象本身,而是关注委托所引用的方法(Program.OnFire)。因此,在取消现有事件处理程序时创建新的MyDelegate对象是完全安全的。由于这一点,C#编译器允许您在添加/删除事件处理程序时使用一种简写语法(在幕后生成完全相同的代码):您可以省略new MyDelegate部分:

Fire += OnFire;
Fire -= OnFire;

当最后一个委托从事件处理程序中移除时,Delegate.Remove返回 null。正如您已经发现的那样,在引发事件之前检查事件是否为 null 是很重要的:

MyDelegate handler = Fire;
if (handler != null)
    handler("Hello 3");

为避免在其他线程上取消订阅事件处理程序时出现的可能竞态条件,它被分配给一个临时本地变量。(请参见我的博客文章,了解将事件处理程序分配给本地变量的线程安全细节。) 另一种避免此问题的方法是创建一个始终已订阅的空委托;尽管这会使用更多内存,但事件处理程序永远不会为空(代码可以更简单)。

public static event MyDelegate Fire = delegate { };

3
没错。补充一下:这个问题曾经让我困惑了一段时间,特别是在C#中你可以这样做:Fire += new MyDelegate(OnFire)或者Fire += OnFire;后者看起来更简单,但实际上只是前者的语法糖。 - Nicholas Piasecki
@Nicholas Piasecki:谢谢你,我更新了我的回答以注明这个非常有用的速记法。 - Bradley Grainger
值得注意的是,如果在多播委托中添加后再取消订阅它,可能会产生不正确的结果,尤其是当其中任何一个方法也被单独订阅和取消订阅时。 - supercat

15

在触发委托之前,应始终检查委托是否没有目标(其值为null)。 正如之前所述,一种方法是使用一个不执行任何操作的匿名方法进行订阅,该方法不会被移除。

public event MyDelegate Fire = delegate {};

然而,这只是一种避免NullReferenceExceptions的hack。

仅仅在调用之前检查委托是否为空并不是线程安全的,因为另一个线程可以在空检查后取消注册并将其设置为null。另一种解决方案是将委托复制到一个临时变量中:

public event MyDelegate Fire;
public void FireEvent(string msg)
{
    MyDelegate temp = Fire;
    if (temp != null)
        temp(msg);
}

不幸的是,JIT编译器可能会优化代码,消除临时变量,并使用原始委托。(根据Juval Lowy - 编程.NET组件)

因此,为了避免这个问题,您可以使用一个接受委托作为参数的方法:

[MethodImpl(MethodImplOptions.NoInlining)]
public void FireEvent(MyDelegate fire, string msg)
{
    if (fire != null)
        fire(msg);
}

请注意,如果没有使用MethodImpl(NoInlining)属性,JIT编译器可能会内联该方法,使其变得无用。 由于委托是不可变的,因此这个实现是线程安全的。 您可以将此方法用作:

FireEvent(Fire,"Hello 3");

非常感谢您澄清JIT问题。到处都是陷阱和 pitfalls。 - gyrolf
8
实际上,由于Microsoft的CLR 2.0具有更强的内存模型,因此这是不可能的。参考链接:http://code.logos.com/blog/2008/11/events_and_threads_part_4.html - Bradley Grainger

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接