65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 中的弱事件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (234投票s)

2008年10月5日

MIT

12分钟阅读

viewsIcon

510005

downloadIcon

5441

弱事件的不同实现方法

目录

引言

在使用普通的 C# 事件时,注册事件处理程序会在事件源对象和监听对象之间创建一个强引用。

NormalEvent.png

如果事件源对象的生命周期比监听对象长,并且监听对象在没有其他对其的引用时就不再需要事件了,那么使用普通的 .NET 事件会导致内存泄漏:事件源对象会持有本应被垃圾回收的对象。

这个问题有很多不同的解决方案。本文将解释其中一些,并讨论它们的优缺点。我将这些方案分为两类:首先,我们将假设事件源是一个具有普通 C# 事件的现有类;然后,我们将允许修改事件源以实现不同的方案。

什么是事件?

许多程序员认为事件是委托的列表,这是完全错误的。委托本身就具有“多播”的能力。

EventHandler eh = Method1;
eh += Method2;

那么,事件到底是什么呢?基本上,它们就像属性:它们封装了一个委托字段并限制了对它的访问。公共委托字段(或公共委托属性)可能意味着其他对象可以清除事件处理程序的列表,或引发事件——但我们希望只有定义该事件的对象才能这样做。

属性本质上是 `get` / `set` 方法对。事件只是 `add` / `remove` 方法对。

public event EventHandler MyEvent {
   add { ... }
   remove { ... }
}

只有添加和删除处理程序是 `public` 的。其他类不能请求处理程序列表,不能清除列表,也不能调用事件。

现在,有时会引起混淆的是 C# 有一个简写语法

public event EventHandler MyEvent;

这会展开为

private EventHandler _MyEvent; // the underlying field
// this isn't actually named "_MyEvent" but also "MyEvent",
// but then you couldn't see the difference between the field
// and the event.
public event EventHandler MyEvent {
  add { lock(this) { _MyEvent += value; } }
  remove { lock(this) { _MyEvent -= value; } }
}

是的,默认的 C# 事件会在 `this` 上锁定!你可以用反编译器验证这一点——`add` 和 `remove` 方法都带有 `[MethodImpl(MethodImplOptions.Synchronized)]` 属性,这相当于在 `this` 上锁定。

事件的注册和注销是线程安全的。但是,以线程安全的方式引发事件则留给编写引发事件代码的程序员,而且常常被错误地处理:最常用的引发代码通常不是线程安全的。

if (MyEvent != null)
   MyEvent(this, EventArgs.Empty);
   // can crash with a NullReferenceException
   // when the last event handler is removed concurrently.

第二种最常见的策略是将事件委托首先读入一个局部变量。

EventHandler eh = MyEvent;
if (eh != null) eh(this, EventArgs.Empty);

这线程安全吗?答案是:取决于。根据 C# 规范的内存模型,这是 **不** 线程安全的。JIT 编译器可以消除局部变量,请参阅 理解低锁技术对多线程应用的影响 [^]。然而,Microsoft .NET 运行时有一个更强的内存模型(从 2.0 版本开始),在那里,这段代码是线程安全的。在 Microsoft .NET 1.0 和 1.1 中,它碰巧也是线程安全的,但这是一个未公开的实现细节。

根据 ECMA 规范,一个正确的解决方案必须将赋值到局部变量的操作移入 `lock(this)` 块中,或使用 `volatile` 字段来存储委托。

EventHandler eh;
lock (this) { eh = MyEvent; }
if (eh != null) eh(this, EventArgs.Empty);

这意味着我们将不得不区分线程安全的事件和非线程安全的事件。

第一部分:监听方弱事件

在本部分,我们将假设该事件是普通的 C# 事件(对事件处理程序的强引用),并且任何清理都必须在监听方完成。

方案 0:直接取消注册

void RegisterEvent()
{
    eventSource.Event += OnEvent;
}
void DeregisterEvent()
{
    eventSource.Event -= OnEvent;
}
void OnEvent(object sender, EventArgs e)
{
    ...
}

简单有效,这是在可能的情况下应该使用的。但是,通常情况下,不能轻易地确保在对象不再使用时调用 `DeregisterEvent` 方法。你可以尝试 Dispose 模式,尽管它通常用于非托管资源。终结器将不起作用:垃圾回收器不会调用它,因为事件源仍然持有对我们对象的引用!

优点

如果对象本身有 Dispose 的概念,那么很简单。

缺点

显式内存管理很困难,代码可能会忘记调用 `Dispose`。

方案 1:事件触发时取消注册

void RegisterEvent()
{
    eventSource.Event += OnEvent;
}

void OnEvent(object sender, EventArgs e)
{
    if (!InUse) {
        eventSource.Event -= OnEvent;
        return;
    }
    ...
}

现在,我们不要求有人告诉我们监听器何时不再使用:它会在事件被调用时自行检查。然而,如果我们不能使用方案 0,那么通常也无法从监听对象内部确定“`InUse`”。而且,既然你正在阅读这篇文章,你很可能遇到了其中一种情况。

但是,这个“解决方案”与方案 0 相比已经有一个重要的缺点:如果事件从未触发,那么我们将泄漏监听对象。试想一下,许多对象注册到一个 `static` 的“`SettingsChanged`”事件——所有这些对象都无法被垃圾回收,直到设置被更改——这在程序的生命周期中可能永远不会发生。

优点

无。

缺点

当事件从未触发时会发生泄漏;通常,“`InUse`”无法轻易确定。

方案 2:带弱引用的包装器

此解决方案几乎与前一个相同,除了我们将事件处理代码移入一个包装器类,该类将调用转发给一个通过弱引用引用的监听实例。这个弱引用可以轻松检测监听对象是否仍然存活。

WeakEventWithWrapper.png

EventWrapper ew;
void RegisterEvent()
{
    ew = new EventWrapper(eventSource, this);
}
void OnEvent(object sender, EventArgs e)
{
    ...
}
sealed class EventWrapper
{
    SourceObject eventSource;
    WeakReference wr;
    public EventWrapper(SourceObject eventSource,
                        ListenerObject obj) {
        this.eventSource = eventSource;
        this.wr = new WeakReference(obj);
        eventSource.Event += OnEvent;
   }
   void OnEvent(object sender, EventArgs e)
   {
        ListenerObject obj = (ListenerObject)wr.Target;
        if (obj != null)
            obj.OnEvent(sender, e);
        else
            Deregister();
    }
    public void Deregister()
    {
        eventSource.Event -= OnEvent;
    }
}

优点

允许垃圾回收监听对象。

缺点

当事件从未触发时会泄漏包装器实例;为每个事件处理程序编写包装器类需要大量的重复代码。

方案 3:在终结器中取消注册

请注意,我们存储了对 `EventWrapper` 的引用,并且有一个公共的 `Deregister` 方法。我们可以为监听器添加一个终结器,并使用它来取消事件注册。

~ListenerObject() {
    ew.Deregister();
}

这应该可以解决我们的内存泄漏问题,但它是有代价的:可终结化的对象对垃圾回收器来说成本很高。当对监听对象没有其他引用时(除了弱引用),它将在第一次垃圾回收时幸存下来(并移到更高的代),运行终结器,然后只能在下一次(新代的)垃圾回收后才能被收集。

另外,终结器在终结器线程上运行;如果事件源的事件注册/注销不是线程安全的,这可能会导致问题。记住,C# 编译器生成的默认事件 **不** 是线程安全的!

优点

允许垃圾回收监听对象;不泄漏包装器实例。

缺点

终结器延迟监听对象的 GC;需要线程安全的事件源;大量重复代码。

方案 4:可重用包装器

代码下载中包含一个可重用的包装器类版本。它通过采用 lambda 表达式来处理需要根据特定用途进行调整的代码部分:注册事件处理程序、注销事件处理程序、将事件转发给 `private` 方法。

eventWrapper = WeakEventHandler.Register(
    eventSource,
    (s, eh) => s.Event += eh, // registering code
    (s, eh) => s.Event -= eh, // deregistering code
    this, // event listener
    (me, sender, args) => me.OnEvent(sender, args) // forwarding code
);

WrapperWithLambdas.png

返回的 `eventWrapper` 公开一个 `public` 方法:`Deregister`。现在,我们需要小心 lambda 表达式,因为它们被编译成可能包含其他对象引用的委托。这就是为什么事件监听器被作为“`me`”返回的原因。如果我们写了 `(me, sender, args) => this.OnEvent(sender, args)`,lambda 表达式将捕获“`this`”变量,导致生成一个闭包对象。由于 `WeakEventHandler` 存储了对转发委托的引用,这将导致从包装器到监听器的强引用。幸运的是,可以检查委托是否捕获了任何变量:编译器将为捕获变量的 lambda 表达式生成实例方法,为不捕获变量的 lambda 表达式生成 `static` 方法。`WeakEventHandler` 使用 `Delegate.Method.IsStatic` 来检查这一点,如果您错误地使用它,它将抛出异常。

这种方法相当可重用,但它仍然需要为每个委托类型使用一个包装器类。虽然你可以用 `System.EventHandler` 和 `System.EventHandler` 走得很远,但你可能希望在有很多不同委托类型时自动化这一点。这可以在编译时使用代码生成,或在运行时使用 `System.Reflection.Emit` 来完成。

优点

允许垃圾回收监听对象;代码开销不太大。

缺点

当事件从未触发时泄漏包装器实例。

方案 5:WeakEventManager

WPF 内置了对监听方弱事件的支持,使用了 `WeakEventManager` 类。它的工作方式与之前的包装器解决方案类似,只是一个 `WeakEventManager` 实例充当多个发送方和多个监听方之间的包装器。由于这个单一实例,`WeakEventManager` 可以避免事件从未被调用时发生的泄漏:向 `WeakEventManager` 注册另一个事件可以触发旧事件的清理。这些清理是通过 WPF 调度程序安排的,它们只会在运行 WPF 消息循环的线程上发生。

此外,`WeakEventManager` 有一个限制,我们之前的解决方案没有:它要求正确设置发送方参数。如果你用它来附加到 `button.Click`,只有 `sender==button` 的事件才会被传递。一些事件实现可能只是将处理程序附加到另一个事件。

public event EventHandler Event {
    add { anotherObject.Event += value; }
    remove { anotherObject.Event -= value; }
}

此类事件不能与 `WeakEventManager` 一起使用。

每个事件都有一个 `WeakEventManager` 类,每个线程都有一个实例。定义这些事件的推荐模式是大量的样板代码:请参阅 MSDN 上的 “弱事件模式” [^]。

幸运的是,我们可以用泛型来简化这一点。

public sealed class ButtonClickEventManager
    : WeakEventManagerBase<ButtonClickEventManager, Button>
{
    protected override void StartListening(Button source)
    {
        source.Click += DeliverEvent;
    }

    protected override void StopListening(Button source)
    {
        source.Click -= DeliverEvent;
    }
}

注意 `DeliverEvent` 接受 `(object, EventArgs)`,而 `Click` 事件提供 `(object, RoutedEventArgs)`。虽然委托类型之间没有转换,但 C# 在从方法组创建委托时支持协变性 [^]。

优点

允许垃圾回收监听对象;不泄漏包装器实例。

缺点

与 WPF 调度程序绑定,在非 UI 线程上不易使用。

第二部分:源方弱事件

在这里,我们将探讨通过修改事件源来实现弱事件的方法。

所有这些都比监听方弱事件有一个共同的 **优点**:我们可以轻松地使注册/注销处理程序成为线程安全的。

方案 0:接口

`WeakEventManager` 也应该在此部分提及:作为一个包装器,它“监听方”附加到普通的 C# 事件,但它也为客户端提供“源方”的弱事件。

在 `WeakEventManager` 中,这就是 `IWeakEventListener` 接口。监听对象实现一个接口,而源对象只需持有对监听对象的弱引用并调用接口方法。

SourceWithListeners.png

优点

简单有效。

缺点

当一个监听器处理多个事件时,你会在 `HandleWeakEvent` 方法中发现大量的条件来过滤事件类型和事件源。

方案 1:委托的弱引用

这是 WPF 中使用的另一种弱事件方法:`CommandManager.InvalidateRequery` 看起来像一个普通的 .NET 事件,但它不是。它只持有对委托的弱引用,所以注册到该 `static` 事件不会导致内存泄漏。

WeakDelegateBug.png

这是一个简单的解决方案,但事件使用者很容易忘记它而弄错。

CommandManager.InvalidateRequery += OnInvalidateRequery;

//or

CommandManager.InvalidateRequery += new EventHandler(OnInvalidateRequery);

这里的问题在于 `CommandManager` 只持有委托的弱引用,而监听器不持有对其的任何引用。所以,在下一次 GC 运行时,委托会被垃圾回收,并且即使监听对象仍在被使用,`OnInvalidateRequery` 也不再被调用。为了确保委托存活足够长的时间,监听器负责保留对其的引用。

WeakDelegates.png

class Listener {
    EventHandler strongReferenceToDelegate;
    public void RegisterForEvent()
    {
        strongReferenceToDelegate = new EventHandler(OnInvalidateRequery);
        CommandManager.InvalidateRequery += strongReferenceToDelegate;
    }
    void OnInvalidateRequery(...) {...}
}

源代码下载中的 `WeakReferenceToDelegate` 展示了一个示例事件实现,该实现是线程安全的,并在添加另一个处理程序时清理处理程序列表。

优点

不泄漏委托实例。

缺点

容易出错:忘记委托的强引用会导致事件仅在下一次垃圾回收前触发。这可能导致难以发现的错误。

方案 2:对象 + 转发器

虽然方案 0 是从 `WeakEventManager` 适配而来,但此方案是从 `WeakEventHandler` 包装器适配而来:注册一个 `object,ForwarderDelegate` 对。

SmartEventForwarding.png

eventSource.AddHandler(this,
    (me, sender, args) => ((ListenerObject)me).OnEvent(sender, args));

优点

简单有效。

缺点

注册事件的签名不常用;转发 lambda 表达式需要强制转换。

方案 3:SmartWeakEvent

源代码下载中的 `SmartWeakEvent` 提供了一个看起来像普通 .NET 事件的事件,但它保留了对事件监听器的弱引用。它不会遭受“必须保留对委托的引用”的问题。

void RegisterEvent()
{
    eventSource.Event += OnEvent;
}
void OnEvent(object sender, EventArgs e)
{
    ...
}

事件定义

SmartWeakEvent<EventHandler> _event
   = new SmartWeakEvent<EventHandler>();

public event EventHandler Event {
    add { _event.Add(value); }
    remove { _event.Remove(value); }
}

public void RaiseEvent()
{
    _event.Raise(this, EventArgs.Empty);
}

它是如何工作的?使用 `Delegate.Target` 和 `Delegate.Method` 属性,每个委托被分解为一个目标(存储为弱引用)和 `MethodInfo`。当事件被引发时,方法使用反射进行调用。

SmartEventReflection.png

这里的一个潜在问题是有人可能会尝试附加一个捕获了变量的匿名方法作为事件处理程序。

int localVariable = 42;
eventSource.Event += delegate { Console.WriteLine(localVariable); };

在这种情况下,委托的目标对象是闭包,它可以立即被收集,因为没有其他对其的引用。但是,`SmartWeakEvent` 可以检测到这种情况并抛出异常,因此你不会遇到调试问题,因为事件处理程序在你认为它应该被注销之前就已经被注销了。

if (d.Method.DeclaringType.GetCustomAttributes(
  typeof(CompilerGeneratedAttribute), false).Length != 0)
    throw new ArgumentException(...);

优点

看起来像真正的弱事件;几乎没有代码开销。

缺点

使用反射调用很慢;在部分信任环境中无效,因为它使用反射访问 `private` 方法。

方案 4:FastSmartWeakEvent

功能和用法与 `SmartWeakEvent` 相同,但性能得到了显著提升。

这是具有两个已注册委托(一个实例方法和一个 `static` 方法)的事件的基准测试结果。

Normal (strong) event...   16948785 calls per second
Smart weak event...           91960 calls per second
Fast smart weak event...    4901840 calls per second

它是如何工作的?我们不再使用反射来调用方法。相反,我们使用 `System.Reflection.Emit.DynamicMethod` 在运行时编译一个转发器方法(类似于前面解决方案中的“转发代码”)。

优点

看起来像真正的弱事件;几乎没有代码开销。

缺点

在部分信任环境中无效,因为它使用反射访问 `private` 方法。

建议

  • 对于在 WPF 应用程序的 UI 线程上运行的任何内容(例如,附加到模型对象的事件的自定义控件),请使用 `WeakEventManager`。
  • 如果你想提供一个弱事件,请使用 `FastSmartWeakEvent`。
  • 如果你想消耗一个事件,请使用 `WeakEventHandler`。

历史

  • 2009 年 4 月 24 日:代码已更新(修复 bug)
    • olivianer 和 Fintan 报告了错误的“派生自 EventArgs”检查
    • `FastSmartWeakEvent` 的类型安全问题
  • 2008 年 10 月 5 日:文章发布
© . All rights reserved.