转载网址:http://www.cnblogs.com/buptzym/archive/2013/03/15/2962300.html

不知道的陷阱:C#委托和事件的困惑

 

一. 问题引入

通常,一个C语言学习者登堂入室的标志就是学会使用了指针,而成为高手的标志又是“玩转指针”。指针是如此奇妙,通过一个地址,可以指向一个数,结构体,对象,甚至函数。最后的一种函数,我们称之为“函数指针”(和“指针函数”可不一样!)就像如下的代码:

int func(int x); /* 声明一个函数 */
    int (*f) (int x); /* 声明一个函数指针 */
   f=func; /* 将func函数的首地址赋给指针f */

C语言因为函数指针获得了极强的动态性,因为你可以通过给函数指针赋值并动态改变其行为,我曾在单片机上写的一个小系统中,任务调度机制玩的就是函数指针。

  在.NET时代,函数指针有了更安全更优雅的包装,就是委托。而事件,则是为了限制委托灵活性引入的新“委托”(之所以为什么限制,后面会谈到)。同样,熟练掌握委托和事件,也是C#登堂入室的标志。有了事件,大大简化了编程,类库变得前所未有的开放,消息传递变得更加简单,任何熟悉事件的人一定都深有体会。

  但你也知道,指针强大,高性能,带来的就是危险,你不知道这个指针是否安全,出了问题,非常难于调试。事件和委托这么好,可是当你写了很多代码,完成大型系统时,心里是不是总觉得怪怪的?有当年使用指针时类似的感觉?

  如果是的话,请看如下的问题:

  1. 若多次添加同一个事件处理函数时,触发时处理函数是否也会多次触发?
  2. 若添加了一个事件处理函数,却执行了两次或多次”取消事件“,是否会报错?
  3. 如何认定两个事件处理函数是一样的? 如果是匿名函数呢?
  4. 如果不手动删除事件函数,系统会帮我们回收吗?
  5. 在多线程环境下,挂接事件时和对象创建所在的线程不同,那事件处理函数中的代码将在哪个线程中执行?
  6. 当代码的层次复杂时,开放委托和事件是不是会带来更大的麻烦?

列下这些问题,下面就让我们讨论这些”尖酸刻薄“的问题。

二. 事件订阅和取消问题

我们考虑一个典型的例子:加热器,加热器内部加热,在达到温度后通知外界”加热已经完成“。 尝试写下如下测试类:

OK,简单了,下面是main函数:

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事件被调用");
        }
    }

我们有意将事件挂载了两次,看看执行效果:

很明显,如果多次挂载同一事件处理函数,函数将会执行多次。

这就是第一个问题的答案

接下来,我们将上文中main函数中红色代码替换成如下蛋疼的代码:
test.OnBoiled += TestOnBoiled;
test.OnBoiled -= TestOnBoiled;
test.OnBoiled -= TestOnBoiled;

在实际开发中,这种情况是很普遍的,谁都有可能取消订阅多次,结果如何呢?

在执行过程中,删除两次事件没有报错,但当触发事件时,由于事件订阅列表为空,所以,第二个问题的答案:
   多次删除同一事件是不会报错的,即使事件只被订阅了一次。若出现订阅三次,取消订阅两次时,依旧执行一次。

这个事情是好理解的,事件列表,实际上就是List,最简单的增删问题。

三. 有了匿名函数后?

      自从学习匿名函数后,笔者就特别喜欢用它,除非代码量特别长,否则十行之内的事件订阅,我都会用匿名函数。可是事情变得有意思了,写了匿名函数后,几乎没人记得取消订阅,那么,发生了什么事情呢?

和上次一样,我们将前面红色代码改成下面的样子:

test.OnBoiled += (s, e) => Console.WriteLine("加热完成事件被调用");<br>test.OnBoiled -= (s, e) => Console.WriteLine("加热完成事件被调用");<br>test.Bein();

Resharper直接给我画了灰线,如下图:

我估计情况不太乐观,执行之后:

果然!加热完成事件还是被调用了,也就是说,看着形式完全一致的两个匿名函数,编译器生成的方法签名是不一致的,根本就是两个不同的函数。因此,匿名函数完全没法取消订阅! 这是第三个问题的答案。

      事件不能被取消订阅!这下可惨了,我真的要取消怎么办?没办法,只能乖乖的写完整的事件函数。匿名方法虽好,千万别用过头。

但是,真正麻烦的问题来了,一个复杂的动态系统中,一定随时会有大量的对象生成和销毁,你也一定会给它订阅一些事件,当你用匿名函数后,这些函数是不是就像死神一样,一直掐着你的脖子? 如果事件处理函数涉及重要操作,比如给对方付款,执行多次你是不是就要哭死了?

四. 垃圾回收和事件

  垃圾回收机制搀和进来后,故事变的更有意思了。

   我“殷切”的希望,垃圾回收器会帮我解决第三节最后一段谈到的问题,帮我收拾掉那些函数,那真实的情况呢?我们做个试验:

  同样的,替换掉红色部分:

test.OnBoiled += (s, e) => Console.WriteLine("加热完成事件被调用");
test=new Heater();
GC.Collect();  //强制垃圾回收实际上可有可无
test.Bein();

  下面是执行结果:

  哈,起码在我更新了对象引用,new了新对象之后,原来的匿名事件确实没有了。看来编译器还是够意思的。

  可是,多数实际开发情况中,我们很少直接new一个对象覆盖掉原来的引用。而是重新new了一个对象出来。这种情况的代码如下

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. 新建线程方法:

初学者会这么做:

test.OnBoiled += (s, e) =>
                {
                    var newThread = new Thread(
                        new ThreadStart(
                            () =>
                                {
                             Thread.Sleep(2000); //模拟长时间操作
                                    Console.WriteLine("总算把热好的水加到了暖瓶里");
                                }));
                    newThread.Start();
                };          
            test.Begin();

  我的手指还是选择了匿名函数,用起来真爽,这种情况下,显然事件处理函数所在线程和主线程不一样

  可是,稍微有点基础的人就知道,当事件被频繁触发时,线程就会被频繁生成,线程同样是非常昂贵的系统资源,更何况,线程的启动时间是不确定的,可能会耽误大事。这不是个好方案。

2. 线程池

  采用.NET 4.0的线程池试试看,代码如下:

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. 对象的构造函数处在新线程时:

  如下测试代码:

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时,情况是怎样的呢?

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的代码解开注释进行测试。

作者:热情的沙漠 
出处:http://www.cnblogs.com/buptzym/ 
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

 
 

不知道的陷阱:C#委托和事件的困惑的更多相关文章

  1. 你可能不知道的陷阱, IEnumerable接口

    1.  IEnumerable 与  IEnumerator IEnumerable枚举器接口的重要性,说一万句话都不过分.几乎所有集合都实现了这个接口,Linq的核心也依赖于这个万能的接口.C语言的 ...

  2. 编写高质量代码改善C#程序的157个建议[C#闭包的陷阱、委托、事件、事件模型]

    前言 本文已更新至http://www.cnblogs.com/aehyok/p/3624579.html .本文主要学习记录以下内容: 建议38.小心闭包中的陷阱 建议39.了解委托的实质 建议40 ...

  3. 你所不知道的 CSS 阴影技巧与细节 滚动视差?CSS 不在话下 神奇的选择器 :focus-within 当角色转换为面试官之后 NPOI 教程 - 3.2 打印相关设置 前端XSS相关整理 委托入门案例

    你所不知道的 CSS 阴影技巧与细节   关于 CSS 阴影,之前已经有写过一篇,box-shadow 与 filter:drop-shadow 详解及奇技淫巧,介绍了一些关于 box-shadow  ...

  4. 你所不知道的库存超限做法 服务器一般达到多少qps比较好[转] JAVA格物致知基础篇:你所不知道的返回码 深入了解EntityFramework Core 2.1延迟加载(Lazy Loading) EntityFramework 6.x和EntityFramework Core关系映射中导航属性必须是public? 藏在正则表达式里的陷阱 两道面试题,带你解析Java类加载机制

    你所不知道的库存超限做法 在互联网企业中,限购的做法,多种多样,有的别出心裁,有的因循守旧,但是种种做法皆想达到的目的,无外乎几种,商品卖的完,系统抗的住,库存不超限.虽然短短数语,却有着说不完,道不 ...

  5. C# 基础知识系列- 11 委托和事件

    0. 前言 事件和委托是C#中的高级特性,也是C#中很有意思的一部分.出现事件的地方,必然有委托出现:而委托则不一定会有事件出现.那为什么会出现这样的关系呢?这就需要从事件和委托的定义出发,了解其中的 ...

  6. .NET基础拾遗(4)委托、事件、反射与特性

    Index : (1)类型语法.内存管理和垃圾回收基础 (2)面向对象的实现和异常的处理基础 (3)字符串.集合与流 (4)委托.事件.反射与特性 (5)多线程开发基础 (6)ADO.NET与数据库开 ...

  7. 你所不知道的setInterval

    在你所不知道的setTimeout记载了下setTimeout相关,此篇则整理了下setInterval:作为拥有广泛应用场景(定时器,轮播图,动画效果,自动滚动等等),而又充满各种不确定性的这set ...

  8. 你所不知道的setTimeout

    JavaScript提供定时执行代码的功能,叫做定时器(timer),主要由setTimeout()和setInterval()这两个函数来完成.它们向任务队列添加定时任务.初始接触它的人都觉得好简单 ...

  9. 你所不知道的15个Axure使用技巧

    你有用原型开发工具吗?如果有,那你用的是Axure还是别的? 从以前就喜欢使用Axure,主要是觉得它能清楚的表达设计的思路,还有交互的真实再现,能让看的人一目了然,昨天看了这篇博文,便更加确定Axu ...

随机推荐

  1. Toad 中的compare使用方法

    1.首先连接要对比后执行的数据库 2.设置对比内容 3.对比后的执行脚本

  2. HDOJ/HDU 1180 诡异的楼梯(经典BFS-详解)

    Problem Description Hogwarts正式开学以后,Harry发现在Hogwarts里,某些楼梯并不是静止不动的,相反,他们每隔一分钟就变动一次方向. 比如下面的例子里,一开始楼梯在 ...

  3. HTML5 application cache

    Application Cache API (一) 基本应用 http://www.cnblogs.com/blackbird/archive/2012/06/12/2546751.html Appl ...

  4. MSW下wxWidgets的安装与编译

    教程摘自网上各大博客.贴吧.论坛,结合自己的实践做了一些实质性的修改. 一.安装 首先从http://sourceforge.net/projects/wxwindows/files/2.8.12/w ...

  5. [LeetCode] 42. Trapping Rain Water 解题思路

    Given n non-negative integers representing an elevation map where the width of each bar is 1, comput ...

  6. c语言的label后面不能直接跟变量申明

    ; goto JUMP; printf("x is : %d\n",x); JUMP: ; <=== 错误,lable后面不能申明变量,只能是表达式语句(statement) ...

  7. 4 weekend110的hdfs下载数据源码跟踪铺垫 + hdfs下载数据源码分析-getFileSystem(值得反复推敲和打断点源码)

    Hdfs下载数据源码分析 在这里,我是接着之前的,贴下代码 package cn.itcast.hadoop.hdfs; import java.io.FileInputStream; import ...

  8. 完美转换MySQL的字符集 Mysql 数据的导入导出,Mysql 4.1导入到4.0

    MySQL从4.1版本开始才提出字符集的概念,所以对于MySQL4.0及其以下的版本,他们的字符集都是Latin1的,所以有时候需要对mysql的字符集进行一下转换,MySQL版本的升级.降级,特别是 ...

  9. MAC 下安装opencv遇到问题的解决方法(安装homebrew, wget)

    遇到问题: (1)Mac安装OpenCV下载ippicv_macosx_20141027.tgz失败解决方案 先附上当时的报错信息: -- ICV: Downloading ippicv_macosx ...

  10. 【Java重构系列】重构31式之封装集合

    2009年,Sean Chambers在其博客中发表了31 Days of Refactoring: Useful refactoring techniques you have to know系列文 ...