C# 使用 Emit动态生成函数,附带与反射,硬编码的测试结果。

  因为托管.Net 程序代码最终被编译的结果为CIL(Common Intermediate Language,直译为公共中间语言,在很多场景下也称MSIL),在运行时,经过CLR加载执行类型可用性,安全性检查,并最终由JIT根据本地CPU的指令集生成对应的本地代码以执行,

所以理论而言,我们可以使用CIL构建最终生成的程序集,当然,前提是使用者必须懂得一些CIL,好在相对于汇编语言,CIL要可读性要更强,难度更低,BCL(Basic Class Library,基本类库)提供了Emit方式以供使用者能够直接构建CIL代码,相关API对应的命名空间为System.Reflection.Emit;

  本人才疏学浅,现学现卖,本随笔简单介绍一下如何使用CIL构建一个简单的数积函数,一来方便为不懂的人能够提供入门的理解,二来方便自己记忆,三来希望本随笔若有不实或不恰当的地方,请各位大佬在评论区或者发送到我的邮箱[email protected]指正.

    /// <summary>
    /// 获取一个使用Emit创建的简单的乘法的委托;
    /// </summary>
    /// <returns></returns>
    private static Func<int, int, int> CreateEmitMultiDelegate() {
        var addMethod = new DynamicMethod(nameof(Multi), typeof(int), new Type[] { typeof(int), typeof(int) });

        var ilgenerator = addMethod.GetILGenerator();

        ///将第0(1)个参数入栈,此处使用了参数入栈的精简指令格式<see cref="OpCodes.Ldarg_0"/>,
        ///对应的参数非精简指令语句为
        ///ilgenerator.Emit(OpCodes.Ldarg, 0);
        ilgenerator.Emit(OpCodes.Ldarg_0);
        //同上,将第1(2)个参数入栈
        ilgenerator.Emit(OpCodes.Ldarg_1);

        ///使用乘法指令以进行数积运算,此操作会将栈中的两个元素取出,并将数积结果压入栈顶。
        ///由于CIL并非最终生成的代码,在内存的位运算中究竟如何处理数积运算,将由JIT处理得到;
        ilgenerator.Emit(OpCodes.Mul);

        ///返回,对于CIL代码函数,无论其是否具备返回值,都必须显示指定返回,否则CLR将会抛出程序不可用异常。
        ///对于具备返回值的函数,在返回时,栈中有且只能存在一个元素。
        ///反之,栈须为空。
        ilgenerator.Emit(OpCodes.Ret);

        return addMethod.CreateDelegate(typeof(Func<int, int, int>)) as Func<int, int, int>;
    }

上面的代码完成了一个非常简单的,未进行溢出检查的Int32乘积函数,翻译为高级语言,也就是我们今天将要使用的代码,它非常简单。

 /// <summary>
    /// 乘法函数;
    /// </summary>
    /// <param name="number0"></param>
    /// <param name="number1"></param>
    /// <returns></returns>
    private static int Multi(int number0, int number1) => number0 * number1;

其经过发布模式编译,逆向得到的代码如下.(顺带一提,我使用的逆向工具为dnSpy).

// Token: 0x06000002 RID: 2 RVA: 0x00002173 File Offset: 0x00000373
.method private hidebysig static 
    int32 Multi (
        int32 number0,
        int32 number1
    ) cil managed 
{
    // Header Size: 1 byte
    // Code Size: 4 (0x4) bytes
    .maxstack 8

    /* (51,57)-(51,74) E:\CilDemoSolution\ConsoleDemo\Program.cs */
    /* 0x00000374 02           */ IL_0000: ldarg.0
    /* 0x00000375 03           */ IL_0001: ldarg.1
    /* 0x00000376 5A           */ IL_0002: mul
    /* 0x00000377 2A           */ IL_0003: ret
} // end of method Program::Add

可以看到,其主要内容和之前使用Emit的代码是相呼应的。

最后我们使用反射,Emit,硬编码三种方式进行1000_000(一千万次),2048 * 2048的运算。

测试代码如下,其中EmitTest为测试类,以上的两端代码中出现的方法均在本测试类中。

sw.Restart();
            for (int i = 0; i < times; i++) {
                res = Multi(addNum0, addNum1);
            }
            sw.Stop();
            Trace.WriteLine($"Code: {sw.ElapsedMilliseconds}");
        }

使用发布模式编译多次运行测试后得到以下平均结果:

Debug Trace:
Reflection:3361
Emit: 30
Code: 3

通过结果可以看到反射的方式是比Emit慢几乎两个数量级左右,而Emit比硬编码慢一个数量级左右,代码中可能存在着诸多不合理之处影响了测试结果的准确性,比如调用Emit函数的方式可能具有更快的方法,反射方式存在进一步优化空间,但三者的性能优先级应该是硬编码>Emit>反射。

再使用调试模式生成并运行得到的结果如下:

Debug Trace:
Reflection:3387
Emit: 42
Code: 38

在调试模式下,Emit和硬编码性能相差是不大的,没有发布模式下那么大的差距,而这两种模式的硬编码方式所生成的CIL代码是一致的,这是我没太明白的地方——究竟调试和发布在什么地方出现了不同的差异导致其在运行时的性能差距如此之大?

最后,我来理解一下使用Emit的场景,在极少数需要灵活的(当然,这也取决于你的使用场景)情况下,我们需要动态地生成代码以减少重复的劳动以及人工的原因导致的错误,根据某些必要信息动态地生成行为。在多数生产环境下,Emit代码的首次生成仍然需要借助反射,在首次执行反射完毕后,之后的执行将不再需要反射,这种方式可以更好地提升性能,以减少过多地通过反射访问元数据的方式带来的拆装箱,类型转换等性能影响,这也是很多ORM框架正在使用的方式之一。

猜你喜欢

转载自www.cnblogs.com/ponus/p/10356809.html