你不知道的陷阱:C#委托和事件的困惑
一. 問(wèn)題引入
通常,一個(gè)C語(yǔ)言學(xué)習(xí)者登堂入室的標(biāo)志就是學(xué)會(huì)使用了指針,而成為高手的標(biāo)志又是“玩轉(zhuǎn)指針”。指針是如此奇妙,通過(guò)一個(gè)地址,可以指向一個(gè)數(shù),結(jié)構(gòu)體,對(duì)象,甚至函數(shù)。最后的一種函數(shù),我們稱之為“函數(shù)指針”(和“指針函數(shù)”可不一樣!)就像如下的代碼:
- int func(int x); /* 聲明一個(gè)函數(shù) */
- int (*f) (int x); /* 聲明一個(gè)函數(shù)指針 */
- f=func; /* 將func函數(shù)的首地址賦給指針f */
C語(yǔ)言因?yàn)楹瘮?shù)指針獲得了極強(qiáng)的動(dòng)態(tài)性,因?yàn)槟憧梢酝ㄟ^(guò)給函數(shù)指針賦值并動(dòng)態(tài)改變其行為,我曾在單片機(jī)上寫的一個(gè)小系統(tǒng)中,任務(wù)調(diào)度機(jī)制玩的就是函數(shù)指針。
在.NET時(shí)代,函數(shù)指針有了更安全更優(yōu)雅的包裝,就是委托。而事件,則是為了限制委托靈活性引入的新“委托”(之所以為什么限制,后面會(huì)談到)。同樣,熟練掌握委托和事件,也是C#登堂入室的標(biāo)志。有了事件,大大簡(jiǎn)化了編程,類庫(kù)變得前所未有的開放,消息傳遞變得更加簡(jiǎn)單,任何熟悉事件的人一定都深有體會(huì)。
但你也知道,指針強(qiáng)大,高性能,帶來(lái)的就是危險(xiǎn),你不知道這個(gè)指針是否安全,出了問(wèn)題,非常難于調(diào)試。事件和委托這么好,可是當(dāng)你寫了很多代碼,完成大型系統(tǒng)時(shí),心里是不是總覺(jué)得怪怪的?有當(dāng)年使用指針時(shí)類似的感覺(jué)?
如果是的話,請(qǐng)看如下的問(wèn)題:
1.若多次添加同一個(gè)事件處理函數(shù)時(shí),觸發(fā)時(shí)處理函數(shù)是否也會(huì)多次觸發(fā)?
2.若添加了一個(gè)事件處理函數(shù),卻執(zhí)行了兩次或多次”取消事件“,是否會(huì)報(bào)錯(cuò)?
3.如何認(rèn)定兩個(gè)事件處理函數(shù)是一樣的? 如果是匿名函數(shù)呢?
4.如果不手動(dòng)刪除事件函數(shù),系統(tǒng)會(huì)幫我們回收嗎?
5.在多線程環(huán)境下,掛接事件時(shí)和對(duì)象創(chuàng)建所在的線程不同,那事件處理函數(shù)中的代碼將在哪個(gè)線程中執(zhí)行?
6.當(dāng)代碼的層次復(fù)雜時(shí),開放委托和事件是不是會(huì)帶來(lái)更大的麻煩?
列下這些問(wèn)題,下面就讓我們討論這些”尖酸刻薄“的問(wèn)題。
#p#
二. 事件訂閱和取消問(wèn)題
我們考慮一個(gè)典型的例子:加熱器,加熱器內(nèi)部加熱,在達(dá)到溫度后通知外界”加熱已經(jīng)完成“。 嘗試寫下如下測(cè)試類:
- ///
- /// 熱水器
- ///
- 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("加熱器已經(jīng)開啟", heatTime);
- }
- private int heatTime;
- private void Heat()
- {
- while (true)
- {
- Console.WriteLine("加熱還需{0}秒", heatTime);
- if (heatTime == 0)
- {
- RasieBoiledEvent();
- return;
- }
- heatTime--;
- Thread.Sleep(1000);
- }
- }
- }
OK,簡(jiǎn)單了,下面是main函數(shù):
- 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事件被調(diào)用");
- }
- }
我們有意將事件掛載了兩次,看看執(zhí)行效果:
很明顯,如果多次掛載同一事件處理函數(shù),函數(shù)將會(huì)執(zhí)行多次。
這就是第一個(gè)問(wèn)題的答案。
- 接下來(lái),我們將上文中main函數(shù)中紅色代碼替換成如下蛋疼的代碼:
- test.OnBoiled += TestOnBoiled;
- test.OnBoiled -= TestOnBoiled;
- test.OnBoiled -= TestOnBoiled;
在實(shí)際開發(fā)中,這種情況是很普遍的,誰(shuí)都有可能取消訂閱多次,結(jié)果如何呢?
在執(zhí)行過(guò)程中,刪除兩次事件沒(méi)有報(bào)錯(cuò),但當(dāng)觸發(fā)事件時(shí),由于事件訂閱列表為空,所以,第二個(gè)問(wèn)題的答案:多次刪除同一事件是不會(huì)報(bào)錯(cuò)的,即使事件只被訂閱了一次。若出現(xiàn)訂閱三次,取消訂閱兩次時(shí),依舊執(zhí)行一次。
這個(gè)事情是好理解的,事件列表,實(shí)際上就是List,最簡(jiǎn)單的增刪問(wèn)題。
#p#
三. 有了匿名函數(shù)后?
自從學(xué)習(xí)匿名函數(shù)后,筆者就特別喜歡用它,除非代碼量特別長(zhǎng),否則十行之內(nèi)的事件訂閱,我都會(huì)用匿名函數(shù)??墒鞘虑樽兊糜幸馑剂耍瑢懥四涿瘮?shù)后,幾乎沒(méi)人記得取消訂閱,那么,發(fā)生了什么事情呢?
和上次一樣,我們將前面紅色代碼改成下面的樣子:
- test.OnBoiled += (s, e) => Console.WriteLine("加熱完成事件被調(diào)用");test.OnBoiled -= (s, e) => Console.WriteLine("加熱完成事件被調(diào)用");test.Bein();
Resharper直接給我畫了灰線,如下圖:
我估計(jì)情況不太樂(lè)觀,執(zhí)行之后:
果然!加熱完成事件還是被調(diào)用了,也就是說(shuō),看著形式完全一致的兩個(gè)匿名函數(shù),編譯器生成的方法簽名是不一致的,根本就是兩個(gè)不同的函數(shù)。因此,匿名函數(shù)完全沒(méi)法取消訂閱! 這是第三個(gè)問(wèn)題的答案。
事件不能被取消訂閱!這下可慘了,我真的要取消怎么辦?沒(méi)辦法,只能乖乖的寫完整的事件函數(shù)。匿名方法雖好,千萬(wàn)別用過(guò)頭。
但是,真正麻煩的問(wèn)題來(lái)了,一個(gè)復(fù)雜的動(dòng)態(tài)系統(tǒng)中,一定隨時(shí)會(huì)有大量的對(duì)象生成和銷毀,你也一定會(huì)給它訂閱一些事件,當(dāng)你用匿名函數(shù)后,這些函數(shù)是不是就像死神一樣,一直掐著你的脖子? 如果事件處理函數(shù)涉及重要操作,比如給對(duì)方付款,執(zhí)行多次你是不是就要哭死了?
#p#
四. 垃圾回收和事件
垃圾回收機(jī)制攙和進(jìn)來(lái)后,故事變的更有意思了。
我“殷切”的希望,垃圾回收器會(huì)幫我解決第三節(jié)最后一段談到的問(wèn)題,幫我收拾掉那些函數(shù),那真實(shí)的情況呢?我們做個(gè)試驗(yàn):
同樣的,替換掉紅色部分:
- test.OnBoiled += (s, e) => Console.WriteLine("加熱完成事件被調(diào)用");
- test=new Heater();
- GC.Collect(); //強(qiáng)制垃圾回收實(shí)際上可有可無(wú)
- test.Bein();
下面是執(zhí)行結(jié)果:
哈,起碼在我更新了對(duì)象引用,new了新對(duì)象之后,原來(lái)的匿名事件確實(shí)沒(méi)有了??磥?lái)編譯器還是夠意思的。
可是,多數(shù)實(shí)際開發(fā)情況中,我們很少直接new一個(gè)對(duì)象覆蓋掉原來(lái)的引用。而是重新new了一個(gè)對(duì)象出來(lái)。這種情況的代碼如下
- test.OnBoiled += (s, e) => Console.WriteLine("加熱完成事件被調(diào)用");
- var heaters = new List() { test, test };
- heaters.Clear();
- test.Begin();
- test = null;
- GC.Collect();
執(zhí)行結(jié)果如下圖:
這種情況下,test即使被賦值為null,事件還是會(huì)乖乖執(zhí)行,因?yàn)槭悄涿瘮?shù),你也沒(méi)法取消訂閱,而GC強(qiáng)制收集也沒(méi)用! 這就是我們真實(shí)場(chǎng)景中最可怕的事情,你認(rèn)為它已經(jīng)消失了,可是它還掛在事件上!
其實(shí)這里有個(gè)破綻:Heater類里開了線程,我即使賦值為null,線程肯定還沒(méi)有被銷毀,事件確實(shí)可能會(huì)執(zhí)行,時(shí)間所限,我沒(méi)有嘗試在寫一個(gè)類測(cè)試不開線程的情況,有興趣的讀者可以幫忙試一試。
而且,經(jīng)過(guò)我查閱資料,當(dāng)你的對(duì)象訂閱了外部的事件,而又沒(méi)有取消訂閱,那么該對(duì)象是不會(huì)被GC回收的!這會(huì)造成很恐怖的問(wèn)題,產(chǎn)生了幾千萬(wàn)個(gè)對(duì)象沒(méi)法被回收??墒牵涿瘮?shù)讓我怎么么取消訂閱?!
所以我們得到了結(jié)論,除非確實(shí)是一般場(chǎng)景,比如界面開發(fā)的window,生成了一直存在,或者在應(yīng)用程序關(guān)閉時(shí)回收,否則少用匿名函數(shù)吧!記得取消事件訂閱!否則會(huì)是非常麻煩的事情!
#p#
五.高潮: 多線程和事件
多線程本來(lái)就是程序員頭疼的問(wèn)題,筆者在多線程知識(shí)上只是入門,沒(méi)開發(fā)過(guò)高并發(fā)系統(tǒng),倒是經(jīng)常用并行庫(kù)加速算法執(zhí)行。 讓我們看看多線程和事件兩個(gè)最難搞的東西糾纏在一起時(shí)是個(gè)什么樣子。
一種常見(jiàn)的場(chǎng)景,是事件處理很耗時(shí),比如執(zhí)行長(zhǎng)時(shí)間的IO操作,或者進(jìn)行了復(fù)雜的數(shù)學(xué)計(jì)算,我們不想影響主線程,那么你想當(dāng)然的會(huì)通過(guò)多線程的方法解決。
創(chuàng)建對(duì)象的線程,一般是主線程(或者UI線程),那么,怎么讓事件處理函數(shù)在另外一個(gè)線程執(zhí)行呢? 你真的保證處理函數(shù)在另外一個(gè)線程中執(zhí)行了?異步調(diào)用?好辦法,不過(guò)我們此處不說(shuō)這個(gè)。
//////////////////**************///////////////////////////
修正:經(jīng)過(guò)了重新的測(cè)試,發(fā)現(xiàn)我的測(cè)試用例寫的有問(wèn)題,為了讓Heater類自己觸發(fā)事件,我在內(nèi)部寫了一個(gè)新線程,導(dǎo)致測(cè)試不準(zhǔn)確。
結(jié)論應(yīng)該是: 不論是不是在多線程環(huán)境下,事件處理函數(shù)一定在觸發(fā)事件位置所在的線程中,和事件訂閱者的創(chuàng)建線程,訂閱事件時(shí)所在的線程無(wú)關(guān)。。。。。。我第五節(jié)的內(nèi)容,有多半都是錯(cuò)的。。。。
因此,若是觸發(fā)事件所在線程是主線程的話,基本上只能用我提出的第二種做法,通過(guò)事件內(nèi)部使用線程池來(lái)執(zhí)行了。感謝 West Continent 的討論。
/////////////////*************/////////////////////
1. 新建線程方法:
初學(xué)者會(huì)這么做:
- test.OnBoiled += (s, e) =>
- {
- var newThread = new Thread(
- new ThreadStart(
- () =>
- {
- Thread.Sleep(2000); //模擬長(zhǎng)時(shí)間操作
- Console.WriteLine("總算把熱好的水加到了暖瓶里");
- }));
- newThread.Start();
- };
- test.Begin();
我的手指還是選擇了匿名函數(shù),用起來(lái)真爽,這種情況下,顯然事件處理函數(shù)所在線程和主線程不一樣。
可是,稍微有點(diǎn)基礎(chǔ)的人就知道,當(dāng)事件被頻繁觸發(fā)時(shí),線程就會(huì)被頻繁生成,線程同樣是非常昂貴的系統(tǒng)資源,更何況,線程的啟動(dòng)時(shí)間是不確定的,可能會(huì)耽誤大事。這不是個(gè)好方案。
2. 線程池
采用.NET 4.0的線程池試試看,代碼如下:
- var mainThread = Thread.CurrentThread;
- test.OnBoiled += (s, e) =>
- {
- ThreadPool.QueueUserWorkItem((d) =>
- {
- Thread.Sleep(2000); //模擬長(zhǎng)時(shí)間操作
- Console.WriteLine("總算把熱好的水加到了暖瓶里");
- if (Thread.CurrentThread != mainThread)
- {
- Console.WriteLine("兩者執(zhí)行的是不同的線程");
- }
- else
- {
- Console.WriteLine("兩者執(zhí)行的是相同的線程");
- }
- });
- };
- test.Begin();
我們通過(guò)緩存主線程,并比較處理函數(shù)中的線程,得到結(jié)果如下:
確實(shí),采用線程池時(shí),會(huì)是兩個(gè)是不一樣的線程,線程池由于內(nèi)部做了管理,因此可以有效的利用線程,避免瘋狂新開線程造成的嚴(yán)重的性能問(wèn)題。
可是,我覺(jué)得還是麻煩,尤其是有多種事件時(shí),挨個(gè)寫線程池還是太麻煩了。那么,我們是不是有兩種方案?
一種是將構(gòu)造函數(shù)寫在一個(gè)新線程中,另外一種是將事件訂閱函數(shù)寫在新線程中,兩者會(huì)發(fā)生怎樣的情況呢?
3. 對(duì)象的構(gòu)造函數(shù)處在新線程時(shí):
如下測(cè)試代碼:
- var mainThread = Thread.CurrentThread;
- var autoResetEvent = new AutoResetEvent(false); //通過(guò)信號(hào)機(jī)制保證對(duì)象首先被創(chuàng)建
- ThreadPool.QueueUserWorkItem((d) =>
- {
- test=new Heater();
- autoResetEvent.Set();
- });
- autoResetEvent.WaitOne();
- test.OnBoiled += (s, e) => Console.WriteLine(Thread.CurrentThread != mainThread ? "兩者執(zhí)行的是不同的線程" : "兩者執(zhí)行的是相同的線程");
- test.Begin();
代碼值得一提的是,為了保證對(duì)象被首先創(chuàng)建,采用了信號(hào)機(jī)制實(shí)現(xiàn)線程同步,當(dāng)創(chuàng)建后,主線程才會(huì)往下執(zhí)行,否則會(huì)拋出空引用的異常.
結(jié)果如下:
可見(jiàn): 主線程稱為Main, 若對(duì)象構(gòu)造函數(shù)在B線程執(zhí)行,事件不在主線程中執(zhí)行。那是不是在B線程中執(zhí)行呢?暫時(shí)還不知道。
4. 對(duì)象的事件訂閱函數(shù)處在新線程時(shí):
在另外一個(gè)線程里創(chuàng)建對(duì)象是更麻煩的,你要解決線程同步問(wèn)題,惡心不,哈哈。
那么,若訂閱事件的代碼在線程B時(shí),情況是怎樣的呢?
- var mainThread = Thread.CurrentThread;
- ThreadPool.QueueUserWorkItem((d) =>
- {
- var bThread = Thread.CurrentThread;
- test.OnBoiled += (s, e) =>
- {
- if(Thread.CurrentThread == mainThread )
- Console.WriteLine("事件在主線程中執(zhí)行");
- else if (bThread==Thread.CurrentThread)
- {
- Console.WriteLine("事件在訂閱事件的線程B中執(zhí)行");
- }
- else
- {
- Console.WriteLine("事件在第三個(gè)線程中執(zhí)行");
- }
- };
- });
- test.Begin();
結(jié)論:
說(shuō)實(shí)話,我看到這個(gè)場(chǎng)景的時(shí)候大吃一驚,居然執(zhí)行事件的代碼不在主線程,不在訂閱事件的線程,而在另外一個(gè)第三者線程!這可能就是線程池的無(wú)敵之處吧,它連事件訂閱函數(shù)都給托管了!真是碉堡了?。?/p>
不過(guò),管它是什么線程里執(zhí)行,反正我主線程是不會(huì)被堵塞了,哈哈.
六.結(jié)語(yǔ)
本來(lái)想今天把最后一個(gè)問(wèn)題都解決的,可是時(shí)間實(shí)在太晚,而且文章已經(jīng)夠長(zhǎng)了。不妨最后一個(gè)問(wèn)題,“在復(fù)雜軟件環(huán)境下,如何理性正確的使用委托和事件”放在第二部分吧。有些問(wèn)題我也沒(méi)搞清,在做實(shí)驗(yàn)的情況下,才逐漸接近結(jié)論。 寫完這篇文章,我深有收獲。
其實(shí),按照慣例,應(yīng)該把IL代碼好好搞出來(lái)給大家看才算是“專業(yè)”的選擇,不過(guò)我確實(shí)不懂IL,就不拿出來(lái)丟人了,高手們請(qǐng)自行腦補(bǔ)。
本文介紹了C#的委托和事件的訂閱和取消訂閱,并在匿名函數(shù)和多線程兩個(gè)環(huán)境下討論了一些問(wèn)題。如果你覺(jué)得這篇文章對(duì)你有幫助,請(qǐng)點(diǎn)一下推薦,若有任何問(wèn)題,歡迎留言討論,共同學(xué)習(xí)。
測(cè)試代碼見(jiàn)附件,請(qǐng)將不同Region的代碼解開注釋進(jìn)行測(cè)試。
原文鏈接:http://www.cnblogs.com/buptzym/archive/2013/03/15/2962300.html