type
status
date
slug
summary
tags
category
icon
password
😀
作为Unity开发老油条都知道Unity里本身GC用的是最老的Mono里的算法,即使il2cpp里也有一套Unity自己开发类似的GC算法,效率低不说,会导致游戏的卡帧。 而闭包函数的滥用也是导致频繁GC的原因之一,所以有必要讲讲如何优化闭包函数的使用。 这里讲的是更广义的闭包,读者不用纠结于一定是要引用外层局部变量的,主要是讲解使用注意和性能优化。 本文需要有一定基础的人观看
文章原创,转载需标明出处

📝 示例代码分析

请看下面C#示例代码。
TestTest是一个测试类,是用来的测试的基础代码,之后的函数都会放在这个类里。
生成的IL类信息如下图,其中<>c开头和<Test2>开头的都是编译器自动生成的代码 ,之后会用到:
notion image

示例1

函数Test1中,调用的委托通过匿名创建出来的,并且不引用上层局部变量,以及不引用类成员变量
这时候肯定有一些有经验的人会说,这种函数会直接被编译器优化掉,会被优化成静态对象。
这里编译器确实会优化,但是和有些人想的方式还是不一样的,可以看以下IL代码。
  1. 其中编译器会在这个类中生成一个静态成员变量,类型为Action,名字为TestTest/'<>c'::'<>9__1_0'
  1. 如果这个委托对象不为空,则跳转代码到IL_0021直接Call这个<>9__1_0的委托。也就是这段IL代码: IL_0008: brtrue.s IL_0021
  1. 否则向下执行,也就是会创建这个委托对象
  1. 最后是IL_0021,也就是执行委托
 
编译器优化代码的思路如下,这个和实际生成代码是不一样的,只是简要说明原理,让不懂的人能够快速明白
💡
这种情景下,不会导致GC垃圾的原理就是创建了一个静态委托对象然后通过判空的方式,只实例化一次来优化。 所以这种可以被静态化的匿名委托是会被编译器优化的
 

示例2

函数Test2中,调用的委托也是匿名创建出来的,但是调用的成员变量name
这时候肯定又有一些有经验的人会说,这种函数也会直接被编译器优化掉,不会导致一直创建委托。
但是这个实际上是不会被优化的,是不是很意外,可以看以下这段IL代码。
💡
其中<Test2>b__2_0是编译器生成的成员函数。所有它确实是可以直接调用name变量。但是这里还是需要生成一个Action委托对象来包装这个成员函数,所以这个情景下也是会导致会有GC垃圾的。
但是也是可以优化的,例如创建一个成员变量的Action,在构造函数或者其他初始化的时候创建好这个委托,这样就不会产生GC垃圾了,也就是跟示例1的优化原理差不多,如下代码所示:
 

示例3

函数Test3中,调用的委托也是匿名创建出来的,但是调用了上层函数体中的局部变量x
这个就不用有太多的争议了,都会知道这个会导致GC垃圾,但是我们接下来分析其中的原理,看看编译器生成IL代码是什么样的,IL代码如下所示:
分析如下:
  1. 首先编译器在代码中生成一个’<>c__DisplayClass3_0’的对象,它内部有个成员函数,并且有个成员变量x
  1. Test3函数中会new一个这个’<>c__DisplayClass3_0’对象
  1. 然后给这个对象中的x赋值为100,这时候可以看到生成的IL代码中的函数局部变量x已经没有了,被放到了编译器生成’<>c__DisplayClass3_0’对象的成员变量。
  1. 接着就是创建新的Action委托对象,来包装生成对象的成员函数
  1. 接着就是调用TestAction,传入这个Action委托对象
  1. 最后打印x
💡
结论其实就是这种调用方式会产生两个对象,性能不太好,业务中尽量少用。如果可以的话,可以自己封装一个类(继承一个接口或者基类),持有x,然后传入其他需要调用之处。通用其中的成员函数调用的方式,而不是通过Action委托函数的方式来处理。这样首先少了一个Action对象的创建,其次,可以给这个对象封装对象池,这样就可以达到 0 GC Alloc了。
 

示例4

Test4直接把成员变量函数传入TestAction中调用,相信之前不懂得人一定会觉得这个代码是没有GC垃圾的。
看过示例2的人应该知道了,这个会创建一个Action对象来包装成员变量的方法。IL代码如下所示:
💡
所以这个基本上跟示例2没有太多的差别,只是示例2是编译器生成的一个成员函数,优化方式也是一样的。
 

示例5

有一定编程经验的人肯定知道Test5这个情景。通过for循环创建匿名闭包函数,然后访问for循环的index下标i。如下所示:
根据前面的示例,有人会说了,这样会创建5个带i的对象和5个Action委托对象,但是实际上不是。
看到这段代码,相信有经验的人都知道最终会打印出来的是5次5。而不是1到5。但是通过IL代码能够很清晰的分析出来。
IL下面如下:
代码很长,但是不要慌,实际上流程非常简单
  1. 创建一个Action的数组
  1. 重点来了,看IL知道这里只创建了一个’<>c__DisplayClass5_0’对象,这个对象里面有一个变量i和一个成员函数’<Test5>b__0’。
  1. 初始化这个对象的i为0,这个对象的i为函数的遍历索引
  1. 接着就是循环逻辑,其中i对比和i++,以及循环的代码跳转我们略过,只看核心部分。那就是创建Action委托对象包装上述的’<>c__DisplayClass5_0’的成员函数’<Test5>b__0’,然后放入数组中
  1. 最后就是循环调用数组中的Action函数。
💡
结论:其实只有一个带i的变量被创建出来了,也就是为啥最终会输出5次5,因为是共用的同一个带i的临时对象。这种情况其实就是一个临时对象+5个Action对象被创建出来,也是效率比较低的写法。 优化的方式也是可以通过封装一个对象,和多次调用这个对象中的函数的方式来优化,优化方式比较简单,这里就不展开了。
 

示例6

Test6,循环中创建匿名闭包,并且引用上层的for循环索引i以及名为local循环内局部变量,代码如下所示:
根据上述代码,以及结合之前的示例,这里应该都知道打印出来的 5 0 5 1 5 2 5 3 5 4
那么结合IL看看这里实际上编译器会帮我们生成出什么样的代码
先来就来分析分析。
  1. 创建一个Action的数组
  1. 跟示例5一样,创建了一个’<>c__DisplayClass6_0’对象,这个对象里面有一个变量i和一个成员函数’<Test5>b__0’。 以下简称 Class60
  1. 初始化这个对象的i为0,这个对象的i为函数的遍历索引
  1. 进入循环体,创建了新的对象,类型为'<>c__DisplayClass6_1' ,这个对象的成员有local、一个Class60类型的对象名为'CS$<>8__locals1’、以及函数'<Test6>b__0’。'<Test6>b__0’里就是匿名函数的内容。以下简称这个对象为 Class61
  1. Class61 会持有 Class60 ,并且for中的local变量,也变成了 Class61 的成员变量。
  1. 然后就是循环体中,创建了Action并且包装了 Class61 的成员函数'<Test6>b__0’
  1. 最后就是for循环调用数组中的Action。
💡
其中 Class61 的成员函数'<Test6>b__0’里的内容就是通过调用自己的成员变量local 。 以及成员变量'CS$<>8__locals1’类型为Class60 中的变量i。 所以local有5个,但是i是同一个。 最终创建出来的对象是一个Class60,5个Class61,5个Action委托对象。 这种方式会导致创建出很多GC垃圾是非常不建议的。
 
 

🖥️ 总结

除了示例1以外,其他的所有的情况下调用都需要特别主意,不能因为是实名函数,或者因为是没有访问上层局部变量就觉得使用闭包函数没有GC Alloc。
使用建议:
  • 不访问上层局部变量,也不访问类成员变量的,这样闭包函数不需要处理,因为这种函数可以被静态化,编译器会优化。
  • 访问类成员变量的,或者直接传入类成员函数名到Action的,建议创建一个Action成员变,然后初始化的时候先设置好。
  • 访问上层局部变量的情况少用。想要优化,就是创建自己创建类去持有你需要的变量,然后走对象,走函数调用,不走委托调用。
 
最后再一个提示,当我们调用String.Format的时候以及$””这样代码的时候,建议手动调用值类型的ToString()方法,不然像$”{i} {local}” 这样的代码,其实通过IL查看是要走两次的box指令的,导致了额外的装箱操作,然后Format函数会调用object的ToString()方法转成string。

📘 附录

完整C#代码如下,IL代码可以自行用ILSpy查看
游戏GC优化(3)-Unity中Lua的GC原理Unity中DOTween生命周期管理
Loading...