一. 問題引入
通常,一個C語言學習者登堂入室的標誌就是學會使用了指標,而成為高手的標誌又是“玩轉指標”。指標是如此奇妙,通過一個地址,可以指向一個數,結構體,物件,甚至函式。最後的一種函式,我們稱之為“函式指標”(和“指標函式”可不一樣!)就像如下的程式碼:
1 2 3 |
int func(int x); /* 宣告一個函式 */ int (*f) (int x); /* 宣告一個函式指標 */ f=func; /* 將func函式的首地址賦給指標f */ |
C語言因為函式指標獲得了極強的動態性,因為你可以通過給函式指標賦值並動態改變其行為,我曾在微控制器上寫的一個小系統中,任務排程機制玩的就是函式指標。
在.NET時代,函式指標有了更安全更優雅的包裝,就是委託。而事件,則是為了限制委託靈活性引入的新“委託”(之所以為什麼限制,後面會談到)。同樣,熟練掌握委託和事件,也是C#登堂入室的標誌。有了事件,大大簡化了程式設計,類庫變得前所未有的開放,訊息傳遞變得更加簡單,任何熟悉事件的人一定都深有體會。
但你也知道,指標強大,高效能,帶來的就是危險,你不知道這個指標是否安全,出了問題,非常難於除錯。事件和委託這麼好,可是當你寫了很多程式碼,完成大型系統時,心裡是不是總覺得怪怪的?有當年使用指標時類似的感覺?
如果是的話,請看如下的問題:
- 若多次新增同一個事件處理函式時,觸發時處理函式是否也會多次觸發?
- 若新增了一個事件處理函式,卻執行了兩次或多次”取消事件“,是否會報錯?
- 如何認定兩個事件處理函式是一樣的? 如果是匿名函式呢?
- 如果不手動刪除事件函式,系統會幫我們回收嗎?
- 在多執行緒環境下,掛接事件時和物件建立所在的執行緒不同,那事件處理函式中的程式碼將在哪個執行緒中執行?
- 當程式碼的層次複雜時,開放委託和事件是不是會帶來更大的麻煩?
列下這些問題,下面就讓我們討論這些”尖酸刻薄“的問題。
二. 事件訂閱和取消問題
我們考慮一個典型的例子:加熱器,加熱器內部加熱,在達到溫度後通知外界”加熱已經完成“。 嘗試寫下如下測試類:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
/// /// 熱水器 /// public class Heater { public event EventHandler OnBoiled; private void RasieBoiledEvent() { if(OnBoiled==null) { Console.WriteLine("加熱完成處理訂閱事件為空"); } else { OnBoiled(this, new EventArgs()); } } private Thread heatThread; public void Begin() { heatTime = 5; heatThread = new Thread(new ThreadStart(Heat)); heatThread.Start(); Console.WriteLine("加熱器已經開啟", heatTime); } private int heatTime; private void Heat() { while (true) { Console.WriteLine("加熱還需{0}秒", heatTime); if (heatTime == 0) { RasieBoiledEvent(); return; } heatTime--; Thread.Sleep(1000); } } } |
OK,簡單了,下面是main函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Program { static void Main(string[] args) { var test = new Heater(); test.OnBoiled += TestOnBoiled; test.OnBoiled += TestOnBoiled; test.Begin(); Console.ReadKey(); } static void TestOnBoiled(object sender, EventArgs e) { Console.WriteLine("Hello事件被呼叫"); } } |
我們有意將事件掛載了兩次,看看執行效果:
很明顯,如果多次掛載同一事件處理函式,函式將會執行多次。
這就是第一個問題的答案。
1 2 3 |
test.OnBoiled += TestOnBoiled; test.OnBoiled -= TestOnBoiled; test.OnBoiled -= TestOnBoiled; |
在實際開發中,這種情況是很普遍的,誰都有可能取消訂閱多次,結果如何呢?
在執行過程中,刪除兩次事件沒有報錯,但當觸發事件時,由於事件訂閱列表為空,所以,第二個問題的答案:
多次刪除同一事件是不會報錯的,即使事件只被訂閱了一次。若出現訂閱三次,取消訂閱兩次時,依舊執行一次。
這個事情是好理解的,事件列表,實際上就是List,最簡單的增刪問題。
三. 有了匿名函式後?
自從學習匿名函式後,筆者就特別喜歡用它,除非程式碼量特別長,否則十行之內的事件訂閱,我都會用匿名函式。可是事情變得有意思了,寫了匿名函式後,幾乎沒人記得取消訂閱,那麼,發生了什麼事情呢?
和上次一樣,我們將前面紅色程式碼改成下面的樣子:
1 |
test.OnBoiled += (s, e) => Console.WriteLine("加熱完成事件被呼叫");<br>test.OnBoiled -= (s, e) => Console.WriteLine("加熱完成事件被呼叫");<br>test.Bein(); |
Resharper直接給我畫了灰線,如下圖:
我估計情況不太樂觀,執行之後:
果然!加熱完成事件還是被呼叫了,也就是說,看著形式完全一致的兩個匿名函式,編譯器生成的方法簽名是不一致的,根本就是兩個不同的函式。因此,匿名函式完全沒法取消訂閱! 這是第三個問題的答案。
事件不能被取消訂閱!這下可慘了,我真的要取消怎麼辦?沒辦法,只能乖乖的寫完整的事件函式。匿名方法雖好,千萬別用過頭。
但是,真正麻煩的問題來了,一個複雜的動態系統中,一定隨時會有大量的物件生成和銷燬,你也一定會給它訂閱一些事件,當你用匿名函式後,這些函式是不是就像死神一樣,一直掐著你的脖子? 如果事件處理函式涉及重要操作,比如給對方付款,執行多次你是不是就要哭死了?
四. 垃圾回收和事件
垃圾回收機制攙和進來後,故事變的更有意思了。
我“殷切”的希望,垃圾回收器會幫我解決第三節最後一段談到的問題,幫我收拾掉那些函式,那真實的情況呢?我們做個試驗:
同樣的,替換掉紅色部分:
1 2 3 4 |
test.OnBoiled += (s, e) => Console.WriteLine("加熱完成事件被呼叫"); test=new Heater(); GC.Collect(); //強制垃圾回收實際上可有可無 test.Bein(); |
下面是執行結果:
哈,起碼在我更新了物件引用,new了新物件之後,原來的匿名事件確實沒有了。看來編譯器還是夠意思的。
可是,多數實際開發情況中,我們很少直接new一個物件覆蓋掉原來的引用。而是重新new了一個物件出來。這種情況的程式碼如下
1 2 3 4 5 6 |
test.OnBoiled += (s, e) => Console.WriteLine("加熱完成事件被呼叫"); var heaters = new List() { test, test }; heaters.Clear(); test.Begin(); test = null; GC.Collect(); |
執行結果如下圖:
這種情況下,test即使被賦值為null,事件還是會乖乖執行,因為是匿名函式,你也沒法取消訂閱,而GC強制收集也沒用! 這就是我們真實場景中最可怕的事情,你認為它已經消失了,可是它還掛在事件上!
其實這裡有個破綻:Heater類裡開了執行緒,我即使賦值為null,執行緒肯定還沒有被銷燬,事件確實可能會執行,時間所限,我沒有嘗試在寫一個類測試不開執行緒的情況,有興趣的讀者可以幫忙試一試。
而且,經過我查閱資料,當你的物件訂閱了外部的事件,而又沒有取消訂閱,那麼該物件是不會被GC回收的!這會造成很恐怖的問題,產生了幾千萬個物件沒法被回收。可是,匿名函式讓我怎麼麼取消訂閱?!
所以我們得到了結論,除非確實是一般場景,比如介面開發的window,生成了一直存在,或者在應用程式關閉時回收,否則少用匿名函式吧!記得取消事件訂閱!否則會是非常麻煩的事情!
五.高潮: 多執行緒和事件
多執行緒本來就是程式設計師頭疼的問題,筆者在多執行緒知識上只是入門,沒開發過高併發系統,倒是經常用並行庫加速演算法執行。 讓我們看看多執行緒和事件兩個最難搞的東西糾纏在一起時是個什麼樣子。
一種常見的場景,是事件處理很耗時,比如執行長時間的IO操作,或者進行了複雜的數學計算,我們不想影響主執行緒,那麼你想當然的會通過多執行緒的方法解決。
建立物件的執行緒,一般是主執行緒(或者UI執行緒),那麼,怎麼讓事件處理函式在另外一個執行緒執行呢? 你真的保證處理函式在另外一個執行緒中執行了?非同步呼叫?好辦法,不過我們此處不說這個。
//////////////////**************///////////////////////////
修正:經過了重新的測試,發現我的測試用例寫的有問題,為了讓Heater類自己觸發事件,我在內部寫了一個新執行緒,導致測試不準確。
結論應該是: 不論是不是在多執行緒環境下,事件處理函式一定在觸發事件位置所在的執行緒中,和事件訂閱者的建立執行緒,訂閱事件時所在的執行緒無關。。。。。。我第五節的內容,有多半都是錯的。。。。
因此,若是觸發事件所線上程是主執行緒的話,基本上只能用我提出的第二種做法,通過事件內部使用執行緒池來執行了。感謝 West Continent 的討論。
/////////////////*************/////////////////////
1. 新建執行緒方法:
初學者會這麼做:
1 2 3 4 5 6 7 8 9 10 11 12 |
test.OnBoiled += (s, e) => { var newThread = new Thread( new ThreadStart( () => { Thread.Sleep(2000); //模擬長時間操作 Console.WriteLine("總算把熱好的水加到了暖瓶裡"); })); newThread.Start(); }; test.Begin(); |
可是,稍微有點基礎的人就知道,當事件被頻繁觸發時,執行緒就會被頻繁生成,執行緒同樣是非常昂貴的系統資源,更何況,執行緒的啟動時間是不確定的,可能會耽誤大事。這不是個好方案。
2. 執行緒池
採用.NET 4.0的執行緒池試試看,程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
var mainThread = Thread.CurrentThread; test.OnBoiled += (s, e) => { ThreadPool.QueueUserWorkItem((d) => { Thread.Sleep(2000); //模擬長時間操作 Console.WriteLine("總算把熱好的水加到了暖瓶裡"); if (Thread.CurrentThread != mainThread) { Console.WriteLine("兩者執行的是不同的執行緒"); } else { Console.WriteLine("兩者執行的是相同的執行緒"); } }); }; test.Begin(); |
我們通過快取主執行緒,並比較處理函式中的執行緒,得到結果如下:
確實,採用執行緒池時,會是兩個是不一樣的執行緒,執行緒池由於內部做了管理,因此可以有效的利用執行緒,避免瘋狂新開執行緒造成的嚴重的效能問題。
可是,我覺得還是麻煩,尤其是有多種事件時,挨個寫執行緒池還是太麻煩了。那麼,我們是不是有兩種方案?
一種是將建構函式寫在一個新執行緒中,另外一種是將事件訂閱函式寫在新執行緒中,兩者會發生怎樣的情況呢?
3. 物件的建構函式處在新執行緒時:
如下測試程式碼:
1 2 3 4 5 6 7 8 9 10 |
var mainThread = Thread.CurrentThread; var autoResetEvent = new AutoResetEvent(false); //通過訊號機制保證物件首先被建立 ThreadPool.QueueUserWorkItem((d) => { test=new Heater(); autoResetEvent.Set(); }); autoResetEvent.WaitOne(); test.OnBoiled += (s, e) => Console.WriteLine(Thread.CurrentThread != mainThread ? "兩者執行的是不同的執行緒" : "兩者執行的是相同的執行緒"); test.Begin(); |
程式碼值得一提的是,為了保證物件被首先建立,採用了訊號機制實現執行緒同步,當建立後,主執行緒才會往下執行,否則會丟擲空引用的異常.
結果如下:
可見: 主執行緒稱為Main, 若物件建構函式在B執行緒執行,事件不在主執行緒中執行。那是不是在B執行緒中執行呢?暫時還不知道。
4. 物件的事件訂閱函式處在新執行緒時:
在另外一個執行緒裡建立物件是更麻煩的,你要解決執行緒同步問題,噁心不,哈哈。
那麼,若訂閱事件的程式碼線上程B時,情況是怎樣的呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
var mainThread = Thread.CurrentThread; ThreadPool.QueueUserWorkItem((d) => { var bThread = Thread.CurrentThread; test.OnBoiled += (s, e) => { if(Thread.CurrentThread == mainThread ) Console.WriteLine("事件在主執行緒中執行"); else if (bThread==Thread.CurrentThread) { Console.WriteLine("事件在訂閱事件的執行緒B中執行"); } else { Console.WriteLine("事件在第三個執行緒中執行"); } }; }); test.Begin(); |
結論:
說實話,我看到這個場景的時候大吃一驚,居然執行事件的程式碼不在主執行緒,不在訂閱事件的執行緒,而在另外一個第三者執行緒!這可能就是執行緒池的無敵之處吧,它連事件訂閱函式都給託管了!真是碉堡了!!
不過,管它是什麼執行緒裡執行,反正我主執行緒是不會被堵塞了,哈哈.
六.結語
本來想今天把最後一個問題都解決的,可是時間實在太晚,而且文章已經夠長了。不妨最後一個問題,“在複雜軟體環境下,如何理性正確的使用委託和事件”放在第二部分吧。有些問題我也沒搞清,在做實驗的情況下,才逐漸接近結論。 寫完這篇文章,我深有收穫。
其實,按照慣例,應該把IL程式碼好好搞出來給大家看才算是“專業”的選擇,不過我確實不懂IL,就不拿出來丟人了,高手們請自行腦補。
本文介紹了C#的委託和事件的訂閱和取消訂閱,並在匿名函式和多執行緒兩個環境下討論了一些問題。如果你覺得這篇文章對你有幫助,請點一下推薦,若有任何問題,歡迎留言討論,共同學習。
測試程式碼見附件,請將不同Region的程式碼解開註釋進行測試。