dotnet 委托的实现解析

缘起

最近被问到什么是.Net中的委托。问题虽然简单却无从回答。只能说委托是托管世界的函数指针,这么说没啥大毛病,但也都是毛病(当时自己也知道这么说不太对,不过自己不太爱用这个也没准备确实没有更好的答案)。

执行效率

正巧前段时间看Core CLR的文档看到不同方式调用函数效率的比较正巧有这个,摘录如下。这段内容在 clr官方文档
为什么反射很慢 ?里。

Reading a Property (‘Get’)

Method Mean StdErr Scaled Bytes Allocated/Op
GetViaProperty 0.2159 ns 0.0047 ns 1.00 0.00
GetViaDelegate 1.8903 ns 0.0082 ns 8.82 0.00
GetViaILEmit 2.9236 ns 0.0067 ns 13.64 0.00
GetViaCompiledExpressionTrees 12.3623 ns 0.0200 ns 57.65 0.00
GetViaFastMember 35.9199 ns 0.0528 ns 167.52 0.00
GetViaReflectionWithCaching 125.3878 ns 0.2017 ns 584.78 0.00
GetViaReflection 197.9258 ns 0.2704 ns 923.08 0.01
GetViaDelegateDynamicInvoke 842.9131 ns 1.2649 ns 3,931.17 419.04

Writing a Property (‘Set’)

Method Mean StdErr Scaled Bytes Allocated/Op
SetViaProperty 1.4043 ns 0.0200 ns 6.55 0.00
SetViaDelegate 2.8215 ns 0.0078 ns 13.16 0.00
SetViaILEmit 2.8226 ns 0.0061 ns 13.16 0.00
SetViaCompiledExpressionTrees 10.7329 ns 0.0221 ns 50.06 0.00
SetViaFastMember 36.6210 ns 0.0393 ns 170.79 0.00
SetViaReflectionWithCaching 214.4321 ns 0.3122 ns 1,000.07 98.49
SetViaReflection 287.1039 ns 0.3288 ns 1,338.99 115.63
SetViaDelegateDynamicInvoke 922.4618 ns 2.9192 ns 4,302.17 390.99

上表分别列出了读取和设置属性通过不同方式的耗时等结果,我们可以看到直接通过属性读取和通过委托读取速度的平均值相差了接近10倍。这么看委托显然就不是函数指针了(函数指针的性能损失很小),那么下面就具体看下究竟是啥。

解析

先上实例代码如下:

    internal class HelloWorld
    {
        public static void HelloWorld1()
        {
            Console.WriteLine("hello world1");
        }

        public delegate void SayHi();

        public void Main()
        {
            SayHi? helloWorld = new SayHi(HelloWorld1);
            helloWorld.Invoke();
        }
    }

很简单的代码,编译后用ILSpy打开。

元数据与IL

首先看下元数据表,毫不例外的在02 TypeDef表里找到了委托对象类型定义,毕竟一切皆对象,这个应该和事件是一个处理方法。

Name BaseType FieldList MethodList
SayHi 0x100000E 0x4000000 0x600006

剩下的表暂时先不看了(主要时间太长不记得类型方法在表里是咋对应起来的了)

下面先把类型SayHi的定义相关的IL代码贴出来

.class nested public auto ansi sealed SayHi
    extends [System.Runtime]System.MulticastDelegate
{
    // Methods
    .method public hidebysig specialname rtspecialname 
        instance void .ctor (
            object 'object',
            native int 'method'
        ) runtime managed 
    {
    } // end of method SayHi::.ctor

    .method public hidebysig newslot virtual 
        instance void Invoke () runtime managed 
    {
    } // end of method SayHi::Invoke

    .method public hidebysig newslot virtual 
        instance class [System.Runtime]System.IAsyncResult BeginInvoke (
            class [System.Runtime]System.AsyncCallback callback,
            object 'object'
        ) runtime managed 
    {
    } // end of method SayHi::BeginInvoke

    .method public hidebysig newslot virtual 
        instance void EndInvoke (
            class [System.Runtime]System.IAsyncResult result
        ) runtime managed 
    {
    } // end of method SayHi::EndInvoke

} // end of class SayHi

这里第一个意外出来了,我一直以为委托是继承自System.Delegate但是没想到却是继承自System.MulticastDelegate。大家都知道后者继承前者主要就是是为了实现 += 这种多播委托的方式(也就是天天写事件用的这种方式)。 那么委托像事件那么注册好多个就是合情又合理了。也就是如下这种。

    internal class HelloWorld
    {
        public static void HelloWorld1()
        {
            Console.WriteLine("hello world1");
        }

        public static void HelloWorld2()
        {
            Console.WriteLine("hello world2");
        }

        public delegate void SayHi();

        public void Main()
        {
            SayHi? helloWorld = new SayHi(HelloWorld1);
            helloWorld += HelloWorld2;
            helloWorld.Invoke();
        }
    }

果然是可以的,可惜大家(我们组的其他同事)宁愿用事件的方式,从来没见这么用过。
IL里定义的其他方法也没啥稀奇的Invoke这类的都是编译器加进去的,直接调用clr里处理,这里看不到实现。

小小的结论与一些疑惑

先说结论: (大胆猜测:)委托实际上和事件类似都是编译成一个对象,然后JIT执行到这个stub时再以FCall的形式(也许是QCall(FQ傻傻分不清),毕竟是动态生成的类不是很了解)调用到CLR里。我不爱用这个果然是对的。

再说说疑惑:
实际上最近在混合调试托管代码时遇到了很大问题。也就是

  • 只调试托管代码或者System.Private.CoreLib时没有问题。
  • 只调试core clr时也没问题(虽然大部分看不懂)。
  • 一旦混合调试时(托管代码调用clr的功能如 GetHashcode 或者 lock时)就有很多函数进不去,但是也不是也不是完全进不去,还是可以看见一部分混合调用的堆栈的。导致我现在很多只能靠猜,例如GetHashcode()是以FCall的形式调用到CLR里,直接在Core CLR里相关的代码打断点就能进入断点。

希望有缘人解答一下,我已经按clr的官方文档处理了,现在只剩下无奈与黔驴技穷了。
当然文中的其他问题也希望有缘人不吝指出。感谢。

Tags: