【转】C#中的弱事件:不同的解决方法

中文原文:http://www.itgrass.com/a/csharp/C-sl/200811/05-8388.html

英文出处:http://www.codeproject.com/KB/cs/WeakEvents.aspx 

本文涉及到的.NET 2.0的内容包括:委托(delegate)、事件(event)、强引用(strong reference)、弱引用(weak reference)、终结器(finalizer)、垃圾收集器(garbage collector)、闭环对象(closure object)、反射(reflect)、线程安全(thread safe)、内存泄露(leak),等等。进一步理解需要.NET 3.0/3.5/4.0的几个概念:弱事件(weak event)、弱事件管理器(WeakEventManager)、lambda表达式、分派器(dispatcher),等等。

 

引言

 

使用正常C#事件情况时,注册一个事件处理程序(handler)就是创建一个从事件源到到监听对象的强引用。

 

C#中的弱事件:不同的解决方法(Weak Events in C#: Different approaches to weak eve

 

如果事件源对象比监听者对象具有更长的生存期,且事件监听者没有被其它对象引用也不再需要该事件,这时使用正常的.NET事件将导致内存泄漏:事件源对象在内存中保持了应该被垃圾(garbage)回收的监听对象的引用。

 

这类问题存在许多不同的解决方法。本文将解释其中的一些方法,探讨它们的优缺点。我将这些方法分为两类:首先,我们假设事件源是一个有正常C#事件的类;然后,我们允许修改事件源以适应不同的方法。

 

究竟什么是事件?

 

许多程序员认为事件是委托链表。这是完全错误的。事实上,委托自己有“多播”(multi-cast)能力:
 EventHandler eh = Method1;
eh += Method2;
那么,什么是事件?初步看,它们类似属性(properties):封装一个委托字段并限制其访问。通常情况下,一个公共委托字段(或公共委托属性)意味着其它对象可以清除事件处理程序或激发事件,而我们只希望事件的定义者具有有这种操作能力。本质上,属性是一对get/set方法、事件是一对add/remove方法。
 public event EventHandler MyEvent
{
add {...}
remove {...}
}
上述代码中,只有增加与移除操作是公开的,其它类不能请求执行处理程序链表,不能清除链表,也不能调用事件。使用这种形式带来的问题是,C#事件简写语法有时引起编程者的困惑:
 public event EventHandler MyEvent;
进一步扩展到下面情况:
 private EventHandler _MyEvent; //  下划线起头的字段
// 它不是实际的命名"_MyEvent",而是"MyEvent",
// 于是你也不能区分字段和事件。
public event EventHandler MyEvent
{
add { lock (this) { _MyEvent += value; } }
remove { lock (this) { _MyEvent -= value; } }
}
值得注意的是,默认的C#事件是对this加锁的,可以使用一个反汇编器(disassembler)验证这一点:add/remove方法标记了属性[MethodImpl(MethodImplOptions.Synchronized)],这等价于对this加锁。这样,注册和注销事件是线程安全的。然而,以线程安全方式激发事件的编码工作交由程序员实现,而他们往往做得不对——通常情况下可能使用的代码不是线程安全的:
 if (MyEvent != null)
MyEvent(this, EventArgs.Empty);
// 当最后的事件处理程序并发移除导致
// NullReferenceException时系统可能崩溃。
第二个常见的策略是先读取事件委托到一个局部变量中:
 EventHandler eh = MyEvent;
if (eh != null) eh(this, EventArgs.Empty);
这是线程安全的吗?答案:还要看。根据C#规范中的内存模型,这也不是线程安全的。JIT编译器允许消去这个局部变量(参见“理解多线程应用中的低锁技术影响”(Understand the Impact of Low-Lock Techniques in Multithreaded Apps))。然而,从2.0版开始微软.NET运行时有更强的内存模型,这时上述码又是线程安全的。碰巧的是,在微软.NET1.0和1.1上它也是线程安全的,但是其实现细节没有在相关文档中说明。

 

根据欧洲计算机制造商协会(ECMA)规范,一个正确的解决方法是把局部变量赋值语句移到lock(this)块中,或者使用易失性(volatile)字段保存这个委托。
 EventHandler eh; EventHandler;
lock (this) { eh = MyEvent; }
if (eh != null) eh(this, EventArgs.Empty);

于是,我们不得不区分:线程安全的事件、非线程安全的事件。

 

第1部分:监听方(Listener-side)的弱事件

 

在这一部分中假设事件是一个正常的C#事件(强引用事件处理程序),且任何清理工作都在监听方完成。

 

解决方案0:仅仅注销

 void RegisterEvent()
{
eventSource.Event += OnEvent;
}
void DeregisterEvent()
{
eventSource.Event -= OnEvent
}
void OnEvent(object sender, EventArgs e)
{
...
}
上面就是我们经常用到的简单有效的形式。然而,当对象不再使用时,通常不能确保DeregisterEvent方法被调用。可以尝试用Dispose模式(它通常意味着非托管资源),但终结器(Finalizer)不会被执行:垃圾收集器不会调用这个终结器,因为事件源仍然保持了监听对象的引用!

 

 

优点:

如果对象已经标记为disposed就简单(意味着可以调用Filalizer了——译者注)。

 

缺点:

显式内存管理较难,可能忘记调用Dispose。

 

解决方案1:事件调用后注销

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

void OnEvent(object sender, EventArgs e)
{
if (!InUse) {
eventSource.Event -= OnEvent;
return;
}
...
}
现在,不需要有人指出何时监听者不再使用:事件调用时它只需要检查自己即可。然而,如果我们不能使用解决方案0,那么通常情况下也无法从监听对象中确定InUse。假如你正在阅读本文,您可能已经遇到过其中的一个情形了。

 

但是,比较解决方案0,这个“解决方案”已经有一个严重的缺点了:如果事件是从未激发(即OnEvent从未被调用——译者注),那么也将泄漏监听对象。想象这种情况,许多对象注册到一个静态“SettingsChanged”事件上——所有这些对象将不能被垃圾回收,直到一个设置改变——在程序的生存期内这种设置改变或许永远不会发生。

 

优点:

-

 

缺点:

当事件从未激发时内存泄漏,通常情况下“InUse”不易确定。

 

解决方案2:带弱引用(WeakReference)的包装器

 

这个解决方案几乎等同于前一个,区别在于:我们把事件处理代码移到一个包装器类中,该包装器类转发调用到一个弱引用(有关弱引用的概念请参考(WeakReference)——译者注)的监听者实例。监听者存活时,这个弱引用将容易被检测到。

 

C#中的弱事件:不同的解决方法(Weak Events in C#: Different approaches to weak eve

 

 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); // 创建一个ListenerObj的弱引用——译者注
eventSource.Event += OnEvent;
}
void OnEvent(object sender, EventArgs e)
{
ListenerObject obj = (ListenerObject)wr.Target; // 获取Listener对象——译者注
if (obj != null)
obj.OnEvent(sender, e);
else
Deregister();
}
public void Deregister()
{
eventSource.Event -= OnEvent;
}
}

 

优点:

允许垃圾回收监听对象。

 

缺点:

事件从未激发时泄漏包装器实例,为每个事件处理程序写一个包装器类将重复大量代码。 

 

解决方案3:终结器(Finalizer)中注销

 

请注意,上述方案中储存了一个EventWrapper引用,并有一个公有方法Deregister,可以给监听者增加一个终结器(Finalizer),它可以调用包装器的注销方法。
 ~ListenerObject() {
ew.Deregister();
}
这个方案顾全了内存泄漏问题,但是有代价的:对垃圾回收器而言,可终结对象是高代价的。当没有监听对象引用时(除弱引用外),它将在第一次垃圾回收时生存下来并升级一代。假设终结器运行,在接下来的第二次垃圾收集时被回收(新一代的对象)。此外,终结器运行在终结器线程上,如果注册/注销事件的事件源不是线程安全的,也可能引发问题。请记住,C#编译器产生的默认事件不是线程安全的!

 

优点:

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

 

缺点:

终结器延时GC监听者、需要线程安全的事件源、大量重复代码。

 

解决方案4:可重复使用的包装器

 

下载代码中包含一个可重复使用的包装器类(WeakEventHandler),并使用lambda表达式以适应特定的应用情况:注册事件处理程序、注销事件处理程序、转发事件给私有方法。
 eventWrapper = WeakEventHandler.Register(
eventSource,
(s, eh) => s.Event += eh, // 注册代码
(s, eh) => s.Event -= eh, // 注销代码
this, // 事件监听者
(me, sender, args) => me.OnEvent(sender, args) // 转发代码
);

 

C#中的弱事件:不同的解决方法(Weak Events in C#: Different approaches to weak eve

 

返回的eventWrapper暴露了单一公共方法:Deregister。现在,我们必须小心处理lambda表达式,因为它们编译成可能引用其它对象的委托,这也是事件监听者传递“me”的原因。假设我们写成(me, sender, args) => this.OnEvent(sender, args), 这个lambda表达式将捕获”this“变量,从而产生一个闭环对象(closure object)。因为WeakEventHandler存储了一个转发委托的引用,这将导致一个从包装器到监听者的强引用。幸运的是,它可以检查是否一个委托捕获到了任何变量:编译器将为lambda表达式生成一个捕获变量的实例方法,以及一个不捕获变量的静态方法。WeakEventHandler使用Delegate.Method.IsStatic检查这种情况,并在使用不当时抛出异常。

 

这种做法是高度可重复使用的,但对每个委托类型它仍然需要一个包装器类。当使用System.EventHandler和System.EventHandler<T>做得得心应手时,我们也许想自动完成这项工作,特别是有许多不同的委托类型时。这可以在编译时使用代码生成,或在运行时使用System.Reflection.Emit完成。

 

优点:

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

 

缺点:

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

 

解决方案5:弱事件管理器(WeakEventManager)

 

WPF内置的WeakEventManager类支持监听方弱事件,它类似前面的包装器解决方案,区别在于:一个单一WeakEventManager实例充当了多个发送者和多个监听者之间的包装器。由于是单一实例,WeakEventManager可避免事件从未调用时的泄漏现象:在WeakEventManager上注册另一个事件时可以触发旧事件的清理工作。这些清理由WPF分派者(dispatcher)调度,且运行在WPF消息循环线程上。

 

此外,WeakEventManager有一个前面解决方案没有的限制:要求正确设置发送者参数。使用它附加button.Click时,只有sender==button的事件才能被传递转发。注意,WeakEventManager不适用于如下类型的事件:简单附加处理程序到另一个事件:
 public event EventHandler Event {
add { anotherObject.Event += value; }
remove { anotherObject.Event -= value; }
}

 

每个事件有一个WeakEventManager类,每个线程一个实例。定义这类事件时建议参考一个大的样板模式代码: 见MSDN上的“WeakEvent模式”(WeakEvent Patterns)。幸运的是,我们可以使用泛型来简化这项工作:
 public sealed class ButtonClickEventManager
: WeakEventManagerBase
{
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#从方法组中创建委托时支持逆变(contravariance when creating delegates from method groups) 。

 

优点:

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

 

缺点:

绑定WPF分派者,非UI线程上不易使用。

 

第2部分:事件源(Source-side)的弱事件

 

这里将探讨修改事件源实现弱事件的各种方法。对比监听方的弱事件,所有这些方法都有一个共同的优点:可以较容易地进行线程安全的注册/注销事件处理程序。

 

解决方案0:接口

 

本节还得提及WeakEventManager:作为包装器,它附加(“listening-side”)到正常C#事件,也提供(“source-side”)一个弱事件给客户端。WeakEventManager中定义IWeakEventListener接口,监听对象实现接口,事件源只需拥有一个监听者弱引用并调用接口方法即可。

 

C#中的弱事件:不同的解决方法(Weak Events in C#: Different approaches to weak eve

 

 

优点:

简单有效。

 

缺点:

当监听者处理多个事件时,HandleWeakEvent方法中附有许多过滤事件类型与事件源的条件。

 

解决方案1:弱引用委托

 

这是WPF中处理弱事件的另一种办法:CommandManager.InvalidateRequery看起来像正常的.NET事件,但事实并非如此:它只保持委托的弱引用,注册到这个静态事件不会造成内存泄漏。

 

C#中的弱事件:不同的解决方法(Weak Events in C#: Different approaches to weak eve

 

虽然这是一个简单的解决方案,但事件消费者容易忘记使用也容易误用:
 CommandManager.InvalidateRequery += OnInvalidateRequery;
// 或
CommandManager.InvalidateRequery += new EventHandler(OnInvalidateRequery);
问题是CommandManager只有委托的弱引用,且监听者没有引用它。因此,在GC的下一次运行时,委托将被垃圾回收,并且OnInvalidateRequery不能再被调用,即使监听对象仍在使用。为了确保委托存活足够长的时间,监听者负责维持对它的引用。

 

C#中的弱事件:不同的解决方法(Weak Events in C#: Different approaches to weak eve

 

 class  Listener {
EventHandler strongReferenceToDelegate;
public void RegisterForEvent()
{
strongReferenceToDelegate = new EventHandler(OnInvalidateRequery);
CommandManager.InvalidateRequery += strongReferenceToDelegate;
}
void OnInvalidateRequery(...) {...}
}
下载代码中的WeakReferenceToDelegat给出了一个事件实现例子,它是线程安全的,当增加另一个处理程序时清除处理程序链表。

 

优点:

不泄露委托实例。

 

缺点:

容易误用:忘记委托的强引用,仅当下次垃圾回收时激发事件,可能会造成bugs发现困难。

 

解决方案2:对象+转发器(Forwarder)

 

WeakEventManager采用了解决方案0,而本解决方案采用了WeakEventHandler包装器:注册一个(object,ForwarderDelegate)对:

 

C#中的弱事件:不同的解决方法(Weak Events in C#: Different approaches to weak eve

 

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

 

优点:

简单有效。

 

缺点:

 非常规签名方式注册事件,转发lambda表达式需要类型转换(cast)。

 

解决方案3:智能弱事件(SmartWeakEvent)

 

下载代码的SmartWeakEvent提供了一个类似正常.NET事件的事件,它保持了事件监听者的弱引用,但不受“必须保持委托引用”问题的困扰。
 void RegisterEvent()
{
eventSource.Event += OnEvent;
}
void OnEvent(object sender, EventArgs e)
{
...
}
事件定义:
 SmartWeakEvent _event
= new SmartWeakEvent();

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 ,事件激发时用反射调用该方法。

 

C#中的弱事件:不同的解决方法(Weak Events in C#: Different approaches to weak eve

 

这里的一个可能问题是:有人可能会附加一个匿名方法作为事件处理程序,并在匿名方法中捕获一个变量。
 int localVariable = 42;
eventSource.Event += delegate { Console.WriteLine(localVariable); };
在这种情况下,委托目标对象是闭环的(closure)、可以立即垃圾回收,因为没有其它对象引用它。然而,SmartWeakEvent能够检测这种情况下并抛出一个异常,所以不会有任何调试上的困难,因为事件处理程序在我们认为应该注销之前已经注销了。
 if (d.Method.DeclaringType.GetCustomAttributes( 
typeof (CompilerGeneratedAttribute), false ).Length != 0)
throw new ArgumentException(...);

 

优点:

似乎是一个真正的弱事件;几乎没有代码开销。

 

缺点:

反射调用速度慢。

 

解决方案4:快速智能弱事件(FastSmartWeakEvent)

 

功能和使用与SmartWeakEvent相同,但显著改善了性能。下面是有两个注册委托(一个实例的方法和一个静态方法)的事件的测试结果:
 Normal (strong) event...  16948785 调用每秒 
Smart weak event... 91960 调用每秒
Fast smart weak event... 4901840 调用每秒
如何工作?不再使用反射调用方法,而在运行时使用System.Reflection.Emit.DynamicMethod编译一个转发器方法(类似前面方案的“转发代码”)。

 

优点:

似乎是一个真正的弱事件;几乎没有代码开销。

 

缺点:

建议

  • 运行在WPF的UI线程上的任何对象(例如,附加事件到定制控件),使用WeakEventManager;
  • 如果想提供一个弱事件,使用FastSmartWeakEvent;
  • 如果想消费一个事件,使用WeakEventHandler。

 

翻译后记

 最近,特别关注.NET上的委托和事件及相关实现技术。浏览codeproject时看到一篇关于Weak Events的文章,因好奇这个概念就多读了几遍,发现其中的一些构思和方法比较有深度和技巧,也澄清了几个在事件概念上的误解和模糊点。该文主要探讨.NET 3.0及以后平台的实现技术。但是其中的基本思想(如:WeakReference)还是可以在.NET 2.0及以上平台上应用。文章内容深奥难懂,不论正确好坏与否先翻译出来,留待以后实际应用时再慢慢学习与体会。


posted @ 2009-10-21 16:54  primeli  阅读(1128)  评论(0编辑  收藏  举报