你可能不知道的陷阱:C#委托和事件的困惑. 问题引入通常,一个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. 当代码的层次复杂时,开放委托和事件是不是会带来更大的麻烦? 列下这些问题,下面就让我们讨论这些”尖酸刻薄“的问题。
二. 事件订阅和取消问题我们考虑一个典型的例子:加热器,加热器内部加热,在达到温度后通知外界”加热已经完成“。
尝试写下如下测试类:12 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 4344/// /// 热水器 /// 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 4 接下来,我们将上文中main 函数中红色代码替换成如下蛋疼的代码: test.OnBoiled += TestOnBoiled;test.OnBoiled -= TestOnBoiled;test.OnBoiled -= TestOnBoiled;在实际开发中,这种情况是很普遍的,谁都有可能取消订阅多次,结果如何呢?在执行过程中,删除两次事件没有报错,但当触发事件时,由于事件订阅列表为空,所以,第二个问题的答案:多次删除同一事件是不会报错的,即使事件只被订阅了一次。
若出现订阅三次,取消订阅两次时,依旧执行一次。
这个事情是好理解的,事件列表,实际上就是List ,最简单的增删问题。
三. 有了匿名函数后?自从学习匿名函数后,笔者就特别喜欢用它,除非代码量特别长,否则十行之内的事件订阅,我都会用匿名函数。
可是事情变得有意思了,写了匿名函数后,几乎没人记得取消订阅,那么,发生了什么事情呢?和上次一样,我们将前面红色代码改成下面的样子:1 test.OnBoiled += (s, e) => Console.WriteLine("加热完成事件被调用");test.OnBoiled -= (s, e) => Console.WriteLine("加热完成事件被调用");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 test.OnBoiled += (s, e) =>{var newThread = new Thread(new ThreadStart(() =>{7 8 9 10 11 12 Thread.Sleep(2000); //模拟长时间操作Console.WriteLine("总算把热好的水加到了暖瓶里");}));newThread.Start();};test.Begin();我的手指还是选择了匿名函数,用起来真爽,这种情况下,显然事件处理函数所在线程和主线程不一样。