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

dfddblmv  于 2023-01-31  发布在  .NET
关注(0)|答案(2)|浏览(116)

在一次代码评审中,我无意中发现了这个(简化的)代码片段来注销一个事件处理程序:

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
    调试代码显示,注销事件后Fire变为null
    我知道对于事件处理程序和委托,编译器会在幕后生成大量代码,但我还是不明白为什么我的推理是错误的。
    我错过了什么?
    补充问题:根据Fire在没有注册事件时是null这一事实,我得出结论,无论在何处触发事件,都需要针对null进行检查。
rsl1atfo

rsl1atfo1#

C#编译器添加事件处理程序的默认实现调用Delegate.Combine,而删除事件处理程序的默认实现调用Delegate.Remove

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

Framework对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");

它被赋值给一个临时的局部变量,以防止在其他线程上取消订阅事件处理程序时可能出现的争用情况(有关将事件处理程序赋值给局部变量的线程安全性的详细信息,请参见my blog post)。另一种防止此问题的方法是创建一个始终被订阅的空委托;虽然这会占用更多的内存,但事件处理程序永远不能为空(代码也可以更简单):

public static event MyDelegate Fire = delegate { };
pqwbnv8z

pqwbnv8z2#

在触发委托之前,你应该检查它是否没有目标(它的值为null)。如前所述,一种方法是用一个不做任何事情的匿名方法订阅,这个方法不会被删除。

public event MyDelegate Fire = delegate {};

然而,这只是一个避免NullReferenceExceptions的技巧。
仅仅在调用前检查委托是否为null并不是线程安全的,因为其他线程可以在null检查后取消注册,并在调用时使其为null。还有一个解决方案是将委托复制到临时变量中:

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

不幸的是,JIT编译器可能会优化代码,消除临时变量,并使用原始委托。(根据Juval Lowy - Programming .NET Components)
因此,为了避免这个问题,可以使用接受委托作为参数的方法:

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

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

FireEvent(Fire,"Hello 3");

相关问题