1. 程式人生 > >譯文:C#中的弱事件(Weak Events in C#)

譯文:C#中的弱事件(Weak Events in C#)

目錄

翻譯前序

本文涉及到的.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)就是建立一個從事件源到到監聽物件的強引用。 如果事件源物件比監聽者物件具有更長的生存期,且事件監聽者沒有被其它物件引用也不再需要該事件,這時使用正常的.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);
於是,我們不得不區分:執行緒安全的事件、非執行緒安全的事件。 在這一部分中假設事件是一個正常的C#事件(強引用事件處理程式),且任何清理工作都在監聽方完成。
 void RegisterEvent()
 {
    eventSource.Event += OnEvent;
 } 
 void DeregisterEvent()
 {
    eventSource.Event -= OnEvent
 } 
 void OnEvent(object sender, EventArgs e)
 {
    ... 
 }
上面就是我們經常用到的簡單有效的形式。然而,當物件不再使用時,通常不能確保DeregisterEvent方法被呼叫。可以嘗試用Dispose模式(它通常意味著非託管資源),但終結器(Finalizer)不會被執行:垃圾收集器不會呼叫這個終結器,因為事件源仍然保持了監聽物件的引用!

優點:

如果物件已經標記為disposed就簡單(意味著可以呼叫Filalizer了——譯者注)。

缺點:

顯式記憶體管理較難,可能忘記呼叫Dispose。
 void RegisterEvent()
 {
    eventSource.Event += OnEvent;
 }

 void OnEvent(object sender, EventArgs e)
 {
    if (!InUse) {
        eventSource.Event -= OnEvent;
        return;
    }
    ... 
 }
現在,不需要有人指出何時監聽者不再使用:事件呼叫時它只需要檢查自己即可。然而,如果我們不能使用解決方案0,那麼通常情況下也無法從監聽物件中確定InUse。假如你正在閱讀本文,您可能已經遇到過其中的一個情形了。 但是,比較解決方案0,這個“解決方案”已經有一個嚴重的缺點了:如果事件是從未激發(即OnEvent從未被呼叫——譯者注),那麼也將洩漏監聽物件。想象這種情況,許多物件註冊到一個靜態“SettingsChanged”事件上——所有這些物件將不能被垃圾回收,直到一個設定改變——在程式的生存期內這種設定改變或許永遠不會發生。

優點:

-

缺點:

當事件從未激發時記憶體洩漏,通常情況下“InUse”不易確定。 這個解決方案几乎等同於前一個,區別在於:我們把事件處理程式碼移到一個包裝器類中,該包裝器類轉發呼叫到一個弱引用(有關弱引用的概念請參考(WeakReference)——譯者注)的監聽者例項。監聽者存活時,這個弱引用將容易被檢測到。
 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;
     }
 }

優點:

允許垃圾回收監聽物件。

缺點:

事件從未激發時洩漏包裝器例項,為每個事件處理程式寫一個包裝器類將重複大量程式碼。 請注意,上述方案中儲存了一個EventWrapper引用,並有一個公有方法Deregister,可以給監聽者增加一個終結器(Finalizer),它可以呼叫包裝器的登出方法。
 ~ListenerObject() {
     ew.Deregister();
 }
這個方案顧全了記憶體洩漏問題,但是有代價的:對垃圾回收器而言,可終結物件是高代價的。當沒有監聽物件引用時(除弱引用外),它將在第一次垃圾回收時生存下來並升級一代。假設終結器執行,在接下來的第二次垃圾收集時被回收(新一代的物件)。此外,終結器執行在終結器執行緒上,如果註冊/登出事件的事件源不是執行緒安全的,也可能引發問題。請記住,C#編譯器產生的預設事件不是執行緒安全的!

優點:

允許垃圾回收監聽物件、不會漏包裝器例項。

缺點:

終結器延時GC監聽者、需要執行緒安全的事件源、大量重複程式碼。 下載程式碼中包含一個可重複使用的包裝器類(WeakEventHandler),並使用lambda表示式以適應特定的應用情況:註冊事件處理程式、登出事件處理程式、轉發事件給私有方法。
 eventWrapper = WeakEventHandler.Register(
     eventSource,
     (s, eh) => s.Event += eh, //  註冊程式碼
     (s, eh) => s.Event -= eh, //  登出程式碼
     this, //  事件監聽者
     (me, sender, args) => me.OnEvent(sender, args) //  轉發程式碼
 );
返回的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完成。

優點:

允許垃回收監聽物件;程式碼開銷不算太差。

缺點:

事件從未激發時洩漏包裝器例項。 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執行緒上不易使用。 這裡將探討修改事件源實現弱事件的各種方法。對比監聽方的弱事件,所有這些方法都有一個共同的優點:可以較容易地進行執行緒安全的註冊/登出事件處理程式。 本節還得提及WeakEventManager:作為包裝器,它附加(“listening-side”)到正常C#事件,也提供(“source-side”)一個弱事件給客戶端。WeakEventManager中定義IWeakEventListener介面,監聽物件實現介面,事件源只需擁有一個監聽者弱引用並呼叫介面方法即可。

優點:

簡單有效。

缺點:

當監聽者處理多個事件時,HandleWeakEvent方法中附有許多過濾事件型別與事件源的條件。 這是WPF中處理弱事件的另一種辦法:CommandManager.InvalidateRequery看起來像正常的.NET事件,但事實並非如此:它只保持委託的弱引用,註冊到這個靜態事件不會造成記憶體洩漏。 雖然這是一個簡單的解決方案,但事件消費者容易忘記使用也容易誤用:
 CommandManager.InvalidateRequery += OnInvalidateRequery;
 // 或 
 CommandManager.InvalidateRequery += new EventHandler(OnInvalidateRequery);
問題是CommandManager只有委託的弱引用,且監聽者沒有引用它。因此,在GC的下一次執行時,委託將被垃圾回收,並且OnInvalidateRequery不能再被呼叫,即使監聽物件仍在使用。為了確保委託存活足夠長的時間,監聽者負責維持對它的引用。
 class  Listener {
     EventHandler strongReferenceToDelegate;
     public void RegisterForEvent()
     {
         strongReferenceToDelegate = new  EventHandler(OnInvalidateRequery);
         CommandManager.InvalidateRequery += strongReferenceToDelegate;
     }
     void OnInvalidateRequery(...) {...}
 }
下載程式碼中的WeakReferenceToDelegat給出了一個事件實現例子,它是執行緒安全的,當增加另一個處理程式時清除處理程式連結串列。

優點:

不洩露委託例項。

缺點:

容易誤用:忘記委託的強引用,僅當下次垃圾回收時激發事件,可能會造成bugs發現困難。 WeakEventManager採用瞭解決方案0,而本解決方案採用了WeakEventHandler包裝器:註冊一個(object,ForwarderDelegate)對:
 eventSource.AddHandler(this, eventSource.AddHandler
     (me, sender, args) => ((ListenerObject)me).OnEvent(sender, args));

優點:

簡單有效。

缺點:

非常規簽名方式註冊事件,轉發lambda表示式需要型別轉換(cast)。 下載程式碼的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 ,事件激發時用反射呼叫該方法。 這裡的一個可能問題是:有人可能會附加一個匿名方法作為事件處理程式,並在匿名方法中捕獲一個變數。
 int localVariable = 42;
 eventSource.Event += delegate { Console.WriteLine(localVariable); };
在這種情況下,委託目標物件是閉環的(closure)、可以立即垃圾回收,因為沒有其它物件引用它。然而,SmartWeakEvent能夠檢測這種情況下並丟擲一個異常,所以不會有任何除錯上的困難,因為事件處理程式在我們認為應該登出之前已經登出了。
 if (d.Method.DeclaringType.GetCustomAttributes( 
   typeof (CompilerGeneratedAttribute), false ).Length != 0)
     throw new ArgumentException(...);

優點:

似乎是一個真正的弱事件;幾乎沒有程式碼開銷。

缺點:

反射呼叫速度慢。 功能和使用與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及以上平臺上應用。文章內容深奧難懂,不論正確好壞與否先翻譯出來,留待以後實際應用時再慢慢學習與體會。

第一次翻譯技術文章,加之對.NET 3.0/3.5/4.0的相關技術認識不深,譯文中的不當或錯誤之處請讀者指正。