我前面幾篇博客中提到過.net中的事件與Windows事件的區別,本文討論的是前者,也就是我們代碼中經常用到的Event。Event很常見,Button控件的Click、KeyPress等等,PictureBox控件的Paint等等都屬於本文討論范疇,本文會例舉出有關“事件編程”的幾種方法,還會提及由“事件編程”引起的Memory Leak(跟“內存泄露”差不多),以及由“事件編程”引起的一些異常。
引子:
.net中事件最常用在“觀察者”設計模式中,事件的發布者(subject)定義一個事件,事件的觀察者(observer)注冊這個事件,當發布者激發該事件時,所有的觀察者就會響應該事件(表現為調用各自的事件處理程序)。知道這個邏輯過程后,我們可以寫出以下代碼:

1 Class Subject 2 { 3 public event XXEventHandler XX; 4 protected virtual void OnXX(XXEventArgs e) 5 { 6 If(XX!=null) 7 { 8 XX(this,e); 9 } 10 } 11 public void DoSomething() 12 { 13 //符合某一條件 14 OnXX(new XXEventArgs()); 15 } 16 } 17 delegate void XXEventHandler(object sender,XXEventArgs e); 18 Class XXEventArgs:EventArgs 19 { 20 21 }
以上就是一個最最原始的含有事件類的定義。外部對象可以注冊Subject對象的XX事件,當某一條件滿足時,Subject對象就會激發XX事件,所以觀察者作出響應。注:編碼中請按照標准的命名方式,事件名、事件參數名、虛方法名、參數名等等,標准請參考微軟。
事件觀察者注冊事件代碼為:

Subject sub = new Subject(); Sub.XX += new XXEventHandler(sub_XX); void sub_XX(object sender,XXEventArgs e) { //do something }
以上是一個最簡單的“事件編程”結構代碼,其余所有的寫法都是從以上擴展出來的,基本原理不變。
升級:
在定義事件變量時,有時候我們可以這樣寫:

1 Class Subject 2 { 3 private XXEventHandler _xx; 4 public event XXEventHandler XX 5 { 6 add 7 { 8 _xx = (XXEventHandler)Delegate.Combine(_xx,value); 9 } 10 remove 11 { 12 _xx = (XXEventHandler)Delegate.Remove(_xx,value); 13 } 14 } 15 protected virtual void OnXX(XXEventArgs e) 16 { 17 if(_xx!=null) 18 { 19 _xx(this,e); 20 } 21 } 22 public void DoSomething() 23 { 24 //符合某一條件 25 OnXX(new XXEventArgs()); 26 } 27 }
其余代碼跟之前一樣,升級后的代碼顯示的實現了“add/remove”,顯示實現“add/remove”的好處網上很多人都說可以在注冊事件之前添加額外的邏輯,這個就像“屬性”和“字段”的關系,

1 public event XXEventHandler XX 2 { 3 add 4 { 5 //添加邏輯 6 _xx = (XXEventHandler)Delegate.Combine(_xx,value); 7 } 8 remove 9 { 10 //添加邏輯 11 _xx = (XXEventHandler)Delegate.Remove(_xx,value); 12 } 13 }
沒錯,確實與“屬性(Property)”的作用差不多,但它不止這一個好處,我們知道(不知道的上網看看),在多線程編程中,很重要的一點就是要保證對象“線程安全”,因為多線程同時訪問同一資源時,會出現預想不到的結果。當然,在“事件編程”中也要考慮多線程的情況。“引子”部分代碼經過編譯器編譯后,確實可以解決多線程問題,但是存在問題,它經過編譯后:

1 public event XXEventHandler XX; 2 //該行代碼編譯后類似如下: 3 4 private XXEventHandler _xx; 5 [MethodImpl(MethodImplOptions.Synchronized)] 6 public void add_XX(XXEventHandler handler) 7 { 8 _xx = (XXEventHandler)Delegate.Combine(_xx,handler); 9 } 10 11 [MethodImpl(MethodImplOptions.Synchronized)] 12 public void remove_XX(XXEventHandler handler) 13 { 14 _xx = (XXEventHandler)Delegate.Remove(_xx,handler); 15 }
以上轉換為編譯器自動完成,事件(取消)注冊(+=、-=)間接轉換由add_XX和remove_XX代勞,通過在add_XX方法和remove_XX方法前面添加類似[MethodImpl(MethodImplOptions.Synchronized)]聲明,表明該方法為同步方法,也就是說多線程訪問同一Subject對象時,同時只能有一個線程訪問add_XX或者是remove_XX,這就確保了不可能同時存在兩個線程操作_xx這個委托鏈表,也就不可能發生不可預測結果。那么,[MethodImpl(MethodImplOptions.Synchronized)]是怎么做到線程同步的呢?其實查看IL語言,我們不難發現,[MethodImpl(MethodImplOptions.Synchronized)]的作用類似於下:

1 Class Subject 2 { 3 private XXEventHandler _xx; 4 public void add_XX(XXEventHandler handler) 5 { 6 lock(this) 7 { 8 _xx = (XXEventHandler)Delegate.Combine(_xx,handler); 9 } 10 } 11 public void remove_XX(XXEventHandler handler) 12 { 13 lock(this) 14 { 15 _xx = (XXEventHandler)Delegate.Remove(_xx,handler); 16 } 17 } 18 }
如我們所見,它就相當於給自己加了一個同步鎖,lock(this),我不知道諸位在使用同步鎖的時候有沒有刻意去避免lock(this)這種,我要說的是,使用這種同步鎖要謹慎。原因至少兩個:
1) 將自己(Subject對象)作為鎖定目標的話,客戶端代碼中很可能仍以自己為目標使用同步鎖,造成死鎖現象。因為this是暴露給所有人的,包括代碼使用者。

1 private void DoWork(Subject sub) //客戶端代碼 2 { 3 lock(sub) //客戶端代碼鎖定sub對象 4 { 5 sub.XX+=new XXEventHandler(…); //嵌套鎖定同一目標 6 // sub.add_XX(new XXEventHandler(…));相當於調用add_XX,出現死鎖 7 // 8 // 9 // 10 //do other thing 11 } 12 }
2) 當Subject類包含多個事件,XX1、XX2、XX3、XX4…時,每注冊(或取消)一個事件時,都需要鎖定同一目標(Subject對象),這完全沒必要。因為不同的事件有不同的委托鏈表,多個線程完全可以同時訪問不同的委托鏈表。然而,編譯器還是這樣做了。

1 Class Subject 2 { 3 private XXEventHandler _xx1 4 private EventHandler _xx2; 5 public void add_XX1(XXEventHandler handler) 6 { 7 lock(this) 8 { 9 _xx1 = (XXEventHandler)Delegate.Combine(_xx1,handler); 10 } 11 } 12 public void remove_XX1(XXEventHandler handler) 13 { 14 lock(this) 15 { 16 _xx1 = (XXEventHandler)Delegate.Remove(_xx1,handler); 17 } 18 19 } 20 public void add_XX2(EventHandler handler) 21 { 22 lock(this) 23 { 24 _xx2 = (EventHandler)Delegate.Combine(_xx2,handler); 25 } 26 } 27 public void remove_XX2(EventHandler handler) 28 { 29 lock(this) 30 { 31 _xx2= (EventHandler)Delegate.Remove(_xx2,handler); 32 } 33 } 34 }
在一個線程中執行sub.XX1+=new XXEventHandler(…)(間接調用sub.add_XX1(new XXEventHandler(…)))的時候,完全可以在另一線程中同時執行 sub.XX2+=new EventHandler(…)(間接調用sub.add_XX2(new EventHandler(…)))。_xx1和_xx2兩個沒有任何聯系,訪問他們更不需要線程同步。如果這樣做了,影響性能效率(編譯器自動轉換成的代碼就是這樣子)。
結合以上兩點,可以將“升級”部分代碼修改為以下,從而可以很好的解決“線程安全”問題而且不會像編譯器自動轉換的代碼那樣影響效率:

1 Class Subject 2 { 3 private XXEventHandler _xx; 4 private object _xxSync = new object(); 5 6 public event XXEventHandler XX 7 { 8 add 9 { 10 lock(_xxSync) 11 { 12 _xx = (XXEventHandler)Delegate.Combine(_xx,value); 13 } 14 } 15 remove 16 { 17 lock(_xxSync) 18 { 19 _xx = (XXEventHandler)Delegate.Remove(_xx,value); 20 } 21 } 22 } 23 protected virtual void OnXX(XXEventArgs e) 24 { 25 if(_xx!=null) 26 { 27 _xx(this,e); 28 } 29 } 30 public void DoSomething() 31 { 32 //符合某一條件 33 OnXX(new XXEventArgs()); 34 } 35 }
在Subject類中增加一個同步鎖目標“_xxSync”,不再以對象本身為同步鎖目標,這樣_xxSync只在類內部可見(客戶端代碼不可使用該對象作為同步鎖目標),不會出現死鎖現象。另外,如果Subject有多個事件,那么我們可以完全增加多個類似“_xxSync”這樣的東西,比如“_xx1Sync、_xx2Sync…”等等,每個同步鎖目標之間沒有任何關聯。
當一個類(比如前面提到的Subject)中包含的事件增多時,幾十個甚至幾百個,而且派生類還會增加事件,在這種情況下,我們需要統一管理這些事件,由一個集合來統一管理這些事件是個不錯的選擇,比如:

1 Class Subject 2 { 3 protected Dictionary<object,Delegate> _handlerList = new Dictionary<object,Delegate>(); 4 Static object _XX1_KEY = new object(); 5 Static object _XX2_KEY = new object(); 6 Static object _XXn_KEY = new object(); 7 8 //事件 9 public event EventHandler XX1 10 { 11 add 12 { 13 if(_handlerList.ContainsKey(_XX1_KEY)) 14 { 15 _handlerList[_XX1_KEY] = Delegate.Combine(_handlerList[_XX1_KEY],value); 16 } 17 else 18 { 19 _handlerList.Add(_XX1_KEY,value); 20 } 21 } 22 remove 23 { 24 if(_handlerList.ContainsKey(_XX1_KEY)) 25 { 26 _handlerList[_XX1_KEY] = Delegate.Remove(_handlerList[_XX1_KEY],value); 27 } 28 } 29 } 30 public event EventHandler XX2 31 { 32 add 33 { 34 if(_handlerList.ContainsKey(_XX2_KEY)) 35 { 36 _handlerList[_XX2_KEY] = Delegate.Combine(_handlerList[_XX2_KEY],value); 37 } 38 else 39 { 40 _handlerList.Add(_XX2_KEY,value); 41 } 42 } 43 remove 44 { 45 if(_handlerList.ContainsKey(_XX2_KEY)) 46 { 47 _handlerList[_XX2_KEY] = Delegate.Remove(_handlerList[_XX2_KEY],value); 48 } 49 } 50 } 51 public event EventHandler XXn 52 { 53 add 54 { 55 if(_handlerList.ContainsKey(_XXn_KEY)) 56 { 57 _handlerList[_XXn_KEY] = Delegate.Combine(_handlerList[_XXn_KEY],value); 58 } 59 else 60 { 61 _handlerList.Add(_XXn_KEY,value); 62 } 63 64 } 65 remove 66 { 67 if(_handlerList.ContainsKey(_XXn_KEY)) 68 { 69 _handlerList[_XXn_KEY] = Delegate.Remove(_handlerList[_XXn_KEY],value); 70 } 71 72 } 73 } 74 protected virtual void OnXX1(EventArgs e) 75 { 76 if(_handlerList.ContainsKey(_XX1_KEY)) 77 { 78 EventHandler handler = _handlerList[_XX1_KEY] as EventHandler; 79 If(handler != null) 80 { 81 Handler(this,e); 82 } 83 } 84 } 85 protected virtual void OnXX2(EventArgs e) 86 { 87 if(_handlerList.ContainsKey(_XX2_KEY)) 88 { 89 EventHandler handler = _handlerList[_XX2_KEY] as EventHandler; 90 if(handler != null) 91 { 92 Handler(this,e); 93 } 94 } 95 } 96 protected virtual void OnXXn(EventArgs e) 97 { 98 if(_handlerList.ContainsKey(_XXn_KEY)) 99 { 100 EventHandler handler = _handlerList[_XXn_KEY] as EventHandler; 101 If(handler != null) 102 { 103 Handler(this,e); 104 } 105 } 106 } 107 108 public void DoSomething() 109 { 110 //符合某一條件 111 OnXX1(new EventArgs()); 112 OnXX2(new EventArgs()); 113 OnXXn(new EventArgs()); 114 } 115 }
存放事件委托鏈表的容器為Dictionary<object,Delegate>類型,該容器存放各個委托鏈表的表頭,每當有一個“事件注冊”的動作發生時,先查找字典中是否有表頭,如果有,直接加到表頭后面;如果沒有,向字典中新加一個表頭。“事件注銷”操作類似。
圖1
字典的作用是將每個委托鏈表的表頭組織起來,便於查詢訪問。可能有人已經看出來修改后的代碼並沒有考慮“線程安全”問題,的確,引進了集合去管理委托鏈表之后,再也沒辦法解決“線程安全”而又不影響效率了,因為現在各個事件不再是獨立存在的,它們都放在了同一集合。另外,集合Dictionary<object,Delegate>聲明為protected,子類完全可以使用該集合對子類的事件委托鏈表進行管理。
注:上圖中委托鏈中各節點引用的都是實例方法,沒有列舉靜態方法。
其實,.net中所有從System.Windows.Forms.Control類繼承下來的類,都是用這種方式去維護事件委托鏈表的,只不過它不是用的字典(我只是用字典模擬),它使用一個EventHandlerList類對象來存儲所有的委托鏈表表頭,作用跟Dictionary<object,Delegate>差不多,並且,.net中也沒去處理“線程安全”問題。總之,CLR在處理“線程安全”問題做得不是足夠好,當然,一般事件編程也基本用在單線程中(比如Winform中的UI線程中),打個比方,在UI線程中創建的Control(或其派生類),基本上都在同一線程中訪問它,基本不涉及跨線程去訪問Control(或其派生類),所以大可不必擔心事件編程中遇到“線程安全”問題。
事件編程中的內存泄露
說到“內存泄露”,可能很多人認為這不應該是.net討論的問題,因為GC自動回收內存,不需要編程的人去管理內存,其實不然。凡是發生了不能及時釋放內存的情況,都可以叫“內存泄露”,.net中包括“托管內存”也包括“非托管內存”,前者由GC管理,后者必然由編程者考慮了(類似C++中的內存),這里我們討論的是前者,也就是托管內存的泄露。
我們知道(假設諸位都知道),當一個托管堆中的對象不可達時,也就是程序中沒有對該對象有引用時,該對象所占堆內存就屬於GC回收的范圍了。可是,如果編程者認為一個對象生命期應該結束(該對象不再使用)的時候,同時也理所當然地認為GC會回收該對象在堆中占用的內存時,情況往往不是TA所認為的那樣,應為很有可能(概率很大),該對象在其他的地方仍然被引用,而且該引用相對來說不會很明顯,我們叫這個為“隱式強引用”(Implicit strong reference),而對於Class A = new Class();這樣的代碼,A就是“顯示強引用”(Explicit strong reference)了。(至於什么是強引用什么是弱引用,這個在這里我就不說了)那么,不管是“顯示強引用”還是“隱式強引用”都屬於“強引用”,一個對象有一個強引用存在的話,GC就不會對它進行內存回收。
事件編程中,經常會產生“隱式強引用”,參考前面的“圖1”中委托鏈表中的每個節點都包含一個target,當一個事件觀察者向發布者注冊一個事件時,那么,發布者就會保持一個觀察者的強引用,這個強引用不是很明顯,因此我們稱之為隱式強引用。因此,當觀察者被編程者理所當然地認為生命期結束了,再沒有任何對它的引用存在時,事件發布者卻依然保持了一個強引用。如下圖:
圖2
盡管有時候,Observer生命期結束(我們理所當然地那樣認為),Subject(發布者)卻依舊對Observer有一個強引用(strong reference)(圖2中紅色箭頭),該引用稱作為“隱式強引用”。GC不會對Observer進行內存回收,因為還有強引用存在。如果Observer為大對象,且系統存在很多這樣的Observer,當系統運行時間足夠長,托管堆中的“僵屍對象”(有些對象雖然已經沒有使用價值了,但是程序中依舊存在對它的強引用)越來越多,總有一個時刻,內存不足,程序崩潰。
事件編程中引起的異常
其實還是因為我們的Observer注冊了事件,但在Observer生命期結束(編程者認為的)時,釋放了一些必備資源,但是Subject還是對Observer有一個強引用,當事件發生后,Subject還是會通知Observer,如果Observer在處理事件的時候,也就是事件處理程序中用到了之前已經釋放了的“必備資源”,程序就會出錯。導致這個異常的原因就是,編程者以為對象已經死了,將其資源釋放,但對象本質上還未死去,仍然會處理它注冊過的事件。

1 //Form1.cs中: 2 private void form1_Load(object sender,EventArgs e) 3 { 4 Form2 form2 = new Form2(); 5 form2.Click += new EventHandler(form2_Click); 6 form2.Show(); 7 } 8 private void form2_Click(object sender,EventArgs e) 9 { 10 this.Show(); 11 }
form1為Observer,form2為Subject,form1監聽form2的Click事件,在事件處理程序中將自己Show出來,一切運行良好,但是,當form1關閉后,再次點擊form2激發Click事件時,程序報錯,提示form1已經disposed。原因就是我們關閉form1時,認為form1生命期已經結束了,事實上並非如此,form2中還有對form1的引用,當事件發生后,還是會通知form1,調用form1的事件處理程序(form2_Click),而碰巧的是,事件處理程序中調用了this.Show()方法,意思要將form1顯示出來,可此時form1已經關閉了。
小結
不管是內存泄露還是引起的異常,都是因為我們注冊了某些事件,在對象生命期結束時,沒有及時將已注冊的事件注銷,告訴事件發布者“我已死,請將我的引用刪除”。因此一個簡單的方法就是在對象生命期結束時將所有的事件注銷,但這個只對簡單的代碼結構有效,復雜的系統幾乎無效,事件太多,根本無法記錄已注冊的事件,再者,你有時候根本不知道對象什么時候生命期結束。下次介紹利用弱引用概念(Weak reference)引申出來的弱委托(Weak delegate),它能有效地解決事件編程中內存泄露問題。原理就是將圖2中每個節點中的Target由原來的強引用(Strong Reference)改為弱引用(Weak Reference)。
希望有幫助O(∩_∩)O~。
跟之前一樣,代碼未調試運行,可能有錯誤。