从无法释放的对象谈.Net事件机制

467 阅读8分钟

缘由

   最近在开发的一个Winform项目中遇到了一个奇怪的GC无法释放对象的问题,下面先简单介绍下项目与问题的表现。

   项目是一个c/s架构Winform桌面软件,采用wcf进行通信。wcf客户端为一个单例模式并利用事件向业务界面公布wcf回调产生的信息。每个业务的界面均可新建与关闭,业务界面通过注册wcf客户端事件获取信息。问题出现了,关闭业务的界面后,界面的资源均无法正常释放,最后界面会因为内存与GDI对象的增长而无法使用。

原因

  先说明对象无法被GC释放的原因,问题就是出在wcf客户端事件,需要在关闭界面时取消对wcf客户端事件的注册(即-=)。原因是简单的,但是理解他的原理就要从.NET事件机制开始说起。在此之前我们需要先补充一些背景知识。

背景知识

   众所周知,.NET和Java类似,是运行在CLR之上的,C#代码并不直接编译为x86-64指令集的机器指令,而是编译为MSIL(Microsoft Intermediate Language)。 IL利用了PE文件格式使得windows可以直接利用JIT编译并运行,他有很多细节,我们只需要知道他有两个重要的组成部分,元数据表与IL代码(当然不仅仅只有这些)。

  元数据表说明了DLL中包含了哪些类哪些函数哪些字段等等,而IL代码则在JIT即时编译后负责执行(很容易理解IL代码由指令语句构成)。 元数据表巧妙的与PE文件格式整合在一起,具体有哪些表可以参考这篇认识元数据和IL(中)<第四篇>。更加详细的资料可以参考 Expert .NET 2.0 IL Assembler by Serge Lidin,中文版《MSIL权威指南》由包建强翻译(这本译本确实有些难懂与错误,但也没网上说的那么不堪,可能和我看的不够细有关)。

  了解以上背景,那我们需要了解的关键点就来了,IL中没有任何事件相关的指令,事件其实就是继承自MulticastDelegate的委托,下面我们通过一个例子来说明。

一个最简的例子

源代码

  新建一个控制台程序,代码如下


    class Program
    {

    }

    public class Person
    {
        public event EventHandler ReceiveMoneyWithNoWorkEvent;

        public void DoInvoke()
        {
            ReceiveMoneyWithNoWorkEvent?.Invoke(this, EventArgs.Empty);
        }
    }

元数据表解析

  编译后使用ILSpy打开元数据表:我们首先关注12 EventMap这张表,这张表描述了所有的事件信息,在查看元数据表时,一定要注意Token的对应关系,就很容易理解他们的关系。

RIDTokenOffsetParentEventList
112000001000005CC0200000314000001

其中:

  • RID:表的主键。
  • Token:表记录的标识,有的翻译为令牌,表之间靠Token相互引用记录。
  • Offset: 大概与表记录在数据流的位置有关。
  • Parent: 是一个Token,指向 02 TypeDef 表中包含这个事件的类的元数据表信息。
  • EventList: 同样为一个Token,指向 14 Event 表。

下面我们看 14 Event 表如何定义的,这个表描述事件的信息。

RIDTokenOffsetAttributeNameType
114000001000005D000000000ReceiveMoneyWithNoWorkEvent01000011

其中:

  • Token:前表引用的Token。
  • Attribute:可以不关注,内部属性。
  • Name: 我们定义的事件名称。
  • Type: 同样为一个Token,指向 02 TypeDef 表或者 01 TypeRef 表,说明我们定义的事件对应委托的类型信息。

只有这些信息好像并不够,我们没有任何函数或属性等的地址信息,也自然无法调用(注意:实际上元数据表中有04 Field表描述了该类的字段,含有该多播委托,实际上是可以调用的,类似于属性对应的内部字段)。接下来就要引入18 MethodSemantics 表,这个表描述事件、属性与方法的关联信息。

RIDTokenOffsetSemanticsMethodAssociation
11800000100000474000000080600000314000001
21800000200000482000000100600000414000001

其中:

  • Semantics:00000008 表示这个一个Adder 00000010 表示这是一个Remover
  • Method:指向 06 Method表的Token。
  • Association: 14 Event 表中的事件的Token,表明Adder和Remover分别属于哪个事件或属性。

我们继续看 06 Method 表中与前表对应的方法信息,这个表截取相关的信息如下。

RIDTokenOffsetAttributesImplAttributesRVANameSignature
4060000030000048200000886000000000000205Cadd_ReceiveMoneyWithNoWorkEvent62
50600000400000490000008860000000000002094remove_ReceiveMoneyWithNoWorkEvent62

其中:

  • Token: 前表Method栏中对应的Token。
  • Attributes与ImplAttributes:方法的一些特性信息,在ILSpy中鼠标放上去就会显示格式化的信息,感兴趣的自行查阅。
  • RVA:模块中方法体的RVA(相对虚拟地址),方法体由头、IL代码、和托管的异常处理描述符组成,RVA必须指向PE文件的只读节(书中原文)
  • Signature: 方法签名,指向Blob流(这个就不展开说了)。

  综上,.NET事件的事件实际上就是通过上述的表提供信息最终找到对应的Adder与Remover并注册委托(这部分需要结合IL代码来看,下面的例子的代码可以说明这里为什么这样说),其实质就是一个类内部的多播委托字段,为了遵从发布-订阅者模型,事件不允许在定义事件的类的外面Invoke,也不允许获取这个事件被注册多少次,被谁注册,事实上这些都是可以通过反射做到的。代码如下。

class Program
    {
        static void Main(string[] args)
        {
            var person = new Person();
            foreach (var item in typeof(Person).GetFields(BindingFlags.NonPublic | BindingFlags.Instance))
            {
                if (item.Name == "ReceiveMoneyWithNoWorkEvent")
                {
                    var prop = item.GetValue(person) as MulticastDelegate;
                    // 获取有多少订阅者,实际上没有注册事件时prop会为null,这与MulticastDelegate的实现相关。
                    var length = prop.GetInvocationList().Length;
                    // 在定义事件的类外Invoke事件
                    prop.DynamicInvoke(null, EventArgs.Empty);
                }
            }
        }
    }

    public class Person
    {
        public event EventHandler ReceiveMoneyWithNoWorkEvent;

        public void DoInvoke()
        {
            ReceiveMoneyWithNoWorkEvent?.Invoke(this, EventArgs.Empty);
        }
    }

  我们可以由上述代码看出,ReceiveMoneyWithNoWorkEvent事件只是一个普通的(当然还是有一定区别的)多播委托字段。如果希望了解更详细的信息,可以查阅.NET的源代码

问题场景复原

  回到最开始项目中的问题,为什么资源不能释放?我们可以用如下代码模拟下当时的场景。

class Program
    {
        static void Main(string[] args)
        {
            var person = new Person();
            var company = new Company(person);
            company = null;
            GC.Collect();
        }
    }

    public class Person
    {
        public event EventHandler ReceiveMoneyWithNoWorkEvent;

        public void DoInvoke()
        {
            ReceiveMoneyWithNoWorkEvent?.Invoke(this, EventArgs.Empty);
        }
    }

    public class Company
    {
        public Company(Person p)
        {
            p.ReceiveMoneyWithNoWorkEvent += P_ReceiveMoneyEvent;
        }

        private void P_ReceiveMoneyEvent(object sender, EventArgs e)
        {
            throw new NotImplementedException();
        }
    }

  这里需要补充一点GC的知识,GC决定对象是否回收是从根开始标记这个对象有没有被引用的,如果没有被标记到,证明这个对象是可以回收的,而不管你的对象之间如何循环引用。关于GC部分的知识可以自行了解。

  虽然company对象被置为null,但是person对象的委托中注册了company作为事件的订阅者,Delegate的创建的过程是需要传入目标对象作为Target Object的参数(此处自行查阅.NET源代码)。对GC来说,从根标记的顺序为,person -> ReceiveMoneyWithNoWorkEvent委托 -> company ,只有没被标记到的对象才会被GC回收,最终导致这个问题。读者可以自行在vs中验证,通过Memory Usage中不同断点的内存的snapshot即可以验证这个问题。

  这点也可以通过IL的代码验证,Company类的构造函数的IL代码如下:

// Methods
    .method public hidebysig specialname rtspecialname 
        instance void .ctor (
            class demo.Person p
        ) cil managed 
    {
        // Method begins at RVA 0x210d
        // Code size 28 (0x1c)
        .maxstack 8

        IL_0000: ldarg.0 // 加载this到栈上,并在随后调用基类Object的构造函数
        IL_0001: call instance void [mscorlib]System.Object::.ctor() //调用完成后栈会清空
        IL_0006: nop  // 跳过
        IL_0007: nop
        IL_0008: ldarg.1 // 加载person对象到栈上
        IL_0009: ldarg.0 // 加载this到栈上
        IL_000a: ldftn instance void demo.Company::P_ReceiveMoneyEvent(object, class [mscorlib]System.EventArgs) // 将即将要注册的函数指针加载到栈上
        IL_0010: newobj instance void [mscorlib]System.EventHandler::.ctor(object, native int) // 利用栈上的person对象和this 与 函数指针 创建EventHandler对象,并返回放到栈上(此时栈上只有该EventHandler对象)
        IL_0015: callvirt instance void demo.Person::add_ReceiveMoneyWithNoWorkEvent(class [mscorlib]System.EventHandler) // 调用前文中提到的add_ReceiveMoneyWithNoWorkEvent方法注册事件
        IL_001a: nop
        IL_001b: ret //返回
    } // end of method Company::.ctor

  读者可以自行用ILSpy验证并查看。

  能力有限说的有些模糊,也肯定有很多错误,请大家指正。