目录
介绍
使用普通 C# 事件时,注册事件处理程序会创建从事件源到侦听对象的强引用。
如果源对象的生命周期比侦听器长,并且当没有其他引用时侦听器不再需要这些事件,则使用普通 .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);
这意味着我们必须区分线程安全的事件和非线程安全的事件。
第 1 部分:侦听器端弱事件
在这一部分,我们将假设该事件是一个普通的 C# 事件(对事件处理程序的强引用),并且任何清理都必须在侦听端完成。
解决方案 0:只需注销
void RegisterEvent() { eventSource.Event += OnEvent; }void DeregisterEvent() { eventSource.Event -= OnEvent; }void OnEvent(object sender, EventArgs e) { ... }
简单而有效,这是您应该尽可能使用的方法。但是,通常情况下,确保在DeregisterEvent
不再使用对象时调用该方法并非易事。您可以尝试 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:弱引用的包装器
这个解决方案与之前的解决方案几乎相同,除了我们将事件处理代码移动到一个包装类中,该包装类将调用转发到使用弱引用引用的侦听器实例。如果侦听器还活着,这个弱引用允许轻松检测。
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);
返回eventWrapper
的public
方法公开了一个方法:Deregister
. 现在,我们需要小心 lambda 表达式,因为它们被编译为可能包含更多对象引用的委托。这就是事件侦听器作为“ me
”传回的原因。如果我们编写了(me, sender, args) => this.OnEvent(sender, args)
,lambda 表达式将捕获 " this
" 变量,从而导致生成一个闭包对象。由于WeakEventHandler
存储了对转发委托的引用,这会导致从包装器到侦听器的强引用。幸运的是,可以检查委托是否捕获任何变量:编译器将为捕获变量的 lambda 表达式生成一个实例方法,并static
为不捕获变量的 lambda 表达式生成一个方法。WeakEventHandler
检查这个使用Delegate.Method.IsStatic
, 如果使用不当会抛出异常。
这种方法相当可重用,但它仍然需要每个委托类型的包装类。虽然您可以使用System.EventHandler
and 走得很远,System.EventHandler<T>
但当有许多不同的委托类型时,您可能希望自动执行此操作。这可以在编译时使用代码生成来完成,或者在运行时使用System.Reflection.Emit
.
好处
允许对侦听器对象进行垃圾回收;代码开销还不错。
缺点
当事件从不触发时泄漏包装器实例。
解决方案 5:WeakEventManager
WPF 使用WeakEventManager
类内置了对侦听器端弱事件的支持。它的工作原理类似于以前的包装器解决方案,不同之处在于单个WeakEventManager
实例充当多个发送者和多个侦听器之间的包装器。由于这个单一实例,WeakEventManager
当事件从未被调用时可以避免泄漏:在 a 上注册另一个事件WeakEventManager
可以触发旧事件的清理。这些清理是使用 WPF 调度程序安排的,它们只会发生在运行 WPF 消息循环的线程上。
此外,它WeakEventManager
有一个我们之前的解决方案没有的限制:它需要正确设置发送方参数。如果您使用它附加到button.Click
,则只会sender==button
传递 的事件。一些事件实现可能只是将处理程序附加到另一个事件:
public event EventHandler Event { add { anotherObject.Event += value; } remove { anotherObject.Event -= value; } }
此类事件不能与WeakEventManager
.
WeakEventManager
每个事件有一个类,每个类每个线程都有一个实例。定义这些事件的推荐模式是大量样板代码:请参阅MSDN [^]上的“WeakEvent 模式”。
幸运的是,我们可以使用泛型来简化它:
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 线程上轻松使用。
第 2 部分:源端弱事件
在这里,我们将看看通过修改事件源来实现弱事件的方法。
所有这些都比侦听器端弱事件有一个共同的优势:我们可以轻松地使注册/注销处理程序线程安全。
解决方案0:接口
该WeakEventManager
也值得在本节提到:作为一个包装,它重视(“听方”),以正常的C#的活动,而且还提供了(“源端”)弱事件给客户。
在 中WeakEventManager
,这是IWeakEventListener
界面。监听对象实现了一个接口,源只是对监听器有一个弱引用并调用接口方法。
好处
简单有效。
缺点
当侦听器处理多个事件时,您最终会在HandleWeakEvent
方法中使用许多条件来过滤事件类型和事件源。
解决方案 1:对委托的弱引用
这是 WPF 中使用的弱事件的另一种方法:CommandManager.InvalidateRequery
看起来像一个普通的 .NET 事件,但它不是。它只持有对委托的弱引用,因此注册到该static
事件不会导致内存泄漏。
这是一个简单的解决方案,但事件使用者很容易忘记它并出错:
CommandManager.InvalidateRequery += OnInvalidateRequery;//orCommandManager.InvalidateRequery += new EventHandler(OnInvalidateRequery);
这里的问题是,CommandManager
唯一持有对委托的弱引用,而侦听器不持有对它的任何引用。因此,在下一次 GC 运行时,委托将被垃圾收集,并且OnInvalidateRequery
即使侦听器对象仍在使用中也不会再被调用。为了确保委托存在足够长的时间,侦听器负责保持对它的引用。
class Listener { EventHandler strongReferenceToDelegate; public void RegisterForEvent() { strongReferenceToDelegate = new EventHandler(OnInvalidateRequery); CommandManager.InvalidateRequery += strongReferenceToDelegate; } void OnInvalidateRequery(...) {...} }
WeakReferenceToDelegate
在源代码下载中显示了一个示例事件实现,它是线程安全的,并在添加另一个处理程序时清除处理程序列表。
好处
不泄漏委托实例。
缺点
容易出错:忘记对委托的强引用会导致事件仅在下一次垃圾收集之前触发。这可能会导致难以发现的错误。
方案二:对象+转发器
虽然解决方案 0 改编自WeakEventManager
,但此解决方案改编自WeakEventHandler
包装器:注册一object,ForwarderDelegate
对。
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
. 引发事件时,将使用反射调用该方法。
这里一个可能的问题是有人可能会尝试附加一个匿名方法作为捕获变量的事件处理程序。
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 日:代码更新(错误修复)
olivianer 和 Fintan 报告了不正确的“派生自 EventArgs”检查
类型安全问题
FastSmartWeakEvent
2008 年 10 月 5 日:文章发表

