资讯详情

资讯详情

08-03-优化-闭包与匿名函数的GC分配

闭包与匿名函数的 GC 分配篇章08-核心技术-性能优化篇 |阅读时间约 30 分钟一、引言闭包Closure是 C# 编译器的语法糖它让 lambda 表达式可以捕获外部变量。但这份语法糖不是免费的编译器为了实现捕获生成了一个隐藏的类实例来存储被捕获的变量这就是 GC 分配。在 Unity 项目里这件事的严重性完全由调用频率决定。配置加载时写一个捕获变量的 lambda整局游戏也执行不了几次分配多少都无所谓同样的写法放进 Update、事件回调、协程热路径就是每帧、每次点击、每场战斗都在给 GC 喂新对象。Boehm 不分代也不压缩见 03 篇增量式 GC 架构临时对象涨到阈值就是一次全堆标记清扫几十毫秒的停顿直接砸穿帧预算。本文回答四个问题编译器为闭包生成了什么、这些生成物各值多少字节、Unity 里哪些常见写法在持续制造闭包、以及怎么把这类分配从热路径里清出去。第二节拆机制第三节讲委托这另一半成本第五、六节给出陷阱地图第七节是可以直接照抄的改造方案第八节给量级估算表。二、闭包分配机制2.1 捕获局部变量显示类被提升到堆上lambda 捕获的是变量而不是值这是 C# 的语义约定闭包看到的是同一个存储位置外部改了闭包里也跟着变。编译器要维持这个语义唯一的办法是把局部变量从栈搬到一个同时对 lambda 方法可见的地方它选的地方是一个编译器生成的隐藏类社区惯称显示类display class。// 代码 Action CreateCounter() { int count 0; // 被捕获的变量 return () count; } // 编译器生成的代码简化 class c__DisplayClass0_0 { public int count; public void CreateCounterb__0() { count; } } Action CreateCounter() { var closure new c__DisplayClass0_0(); // 堆分配 closure.count 0; return new Action(closure.CreateCounterb__0); // 委托分配 }逐段解读这段反编译示意。第一段是业务代码CreateCounter声明了一个局部变量count返回的 lambda 对它执行自增。第二段是编译器实际生成的形态count不再是栈上的局部变量而是显示类c__DisplayClass0_0的实例字段lambda 的方法体被搬成这个类上的实例方法CreateCounterb__0。第三段还原了调用时的真实开销方法体一进来就new一个显示类实例把局部变量的值拷进字段然后new一个Action委托委托的 target 指向显示类实例、method 指向那个实例方法最后返回。所以每次调用 CreateCounter 都创建一个闭包类实例堆分配 一个委托实例堆分配。两次分配一次都省不掉而且它们的生命周期从这一行就开始了只要返回的Action还被人握着显示类实例就活着显示类实例活着它字段里那份局部变量也就一直活着哪怕原方法早已返回、栈帧早已销毁。把同一份机制再用反编译视角看一遍这次连循环一起看// 编译器实际生成的代码(反编译) class c__DisplayClass0_0 { // 闭包类 public int i; // 被捕获的变量提升为字段 public void Exampleb__0() { Console.WriteLine(i); } } void Example() { var closure new c__DisplayClass0_0(); // 堆分配! for (closure.i 0; closure.i 10; closure.i) { // 每次迭代都使用同一个闭包实例(第1次迭代创建) // 但不是所有闭包都能被编译器优化为单实例 } }逐段解读这段可视化。Example方法一进来就new出闭包实例循环变量i被搬进closure.i循环本体每次改的其实是那个堆对象的字段。循环十次只创建一个闭包实例因为显示类实例的创建点在循环外侧的方法体入口不在循环内部注释里那句不是所有闭包都能被编译器优化为单实例要认真对待单实例的前提是显示类创建点只执行一次一旦这个方法本身被多次调用每次调用都会重新走一遍入口实例数随之翻倍。2.2 关键认知方法被调用 N 次就创建 N 个实例显示类实例不是全局一份它跟随包含 lambda 的方法调用而创建。关键认知编译器生成的闭包类在调用方法体时创建。如果包含闭包的方法被调用 N 次则创建 N 个闭包实例。把这句话翻译成 Unity 语言CreateCounter写在 Update 里一秒钟 60 次调用就是每秒 60 个显示类实例加 60 个委托对象一分钟 3600 份直到某次 GC 把它们批量清掉。有个例外值得单独记同一次方法调用内同一个 lambda 表达式被转换多次委托时显示类实例只创建一次委托在多数实现里也只创建一次后续转换复用已有的 target 与方法指针。换句话说每次 new 委托的成本按 lambda 表达式出现的位置算不按转换次数线性爆炸。真正线性增长的是方法调用次数调用一次一套显示类加委托。2.3 捕获 this 与捕获局部变量的差异捕获形态不止一种代价也不同这是本节最值得记住的分野。第一种捕获局部变量含参数如 2.1 所示生成独立的显示类每次方法调用 new 一个实例显示类实例加委托两笔分配。第二种只捕获this即 lambda 体内只访问实例字段或实例方法不碰任何局部变量。这种情况下编译器不需要引入新的类它直接在宿主类上生成一个实例方法lambda 编译成宿主类.方法名b__序号委托的 target 就是this自己。没有显示类只剩委托这一笔分配。很多我写了 lambda 为什么没看到 DisplayClass的困惑都源于此Profiler 里找不到显示类不代表没有分配委托对象照样在堆上。第三种什么都不捕获。编译器把它编进一个缓存用的嵌套类通常叫c整个程序集每种 lambda 签名一份单例单例缓存在静态字段里委托实例也缓存在单例的字段里。第一次执行到该 lambda 时创建单例与委托之后每次转换直接复用缓存的委托稳态下零分配。这是不捕获变量的 lambda 可以被缓存这句话的完整含义缓存的不是魔法是编译器替你做的单例加字段缓存。三种形态的内存关系画成图形态 A捕获局部变量最贵 方法调用栈 堆 ─────────────────────────────────────────────────────────── Example() ┌─────────────────────┐ ├─ new c__DisplayClass0_0 ─────────────► │ 显示类实例 │ ~24B1字段64位估算 │ closure.i 0 │ ├ 对象头 ~16B │ │ │ └ int i提升的局部量│ ├─ new Action(closure.b__0) ────────────► │ Action 委托 │ ~40B估算 │ │ ├ target → 显示类实例 │ │ │ └ method → 方法指针 │ └─ return action └─────────────────────┘ 形态 B只捕获 this中等 ┌────────────────────────────┐ │ 宿主类实例本来就有 │ 无显示类 │ ├ 实例字段被 lambda 直接读写 │ 只多一个 Action 委托target 指向自己 │ └ Updateb__3 方法 │ └────────────────────────────┘ 形态 C不捕获任何变量最省 静态字段 c.9 ──► 单例缓存类实例全程序一份 └─ 9__0_0 ──► 缓存的 Func... 委托全程序一份复用 稳态零分配首次执行有一次性的创建成本对象头与委托大小为 64 位 Mono/Boehm 布局下的典型估算非实测常数不同 Unity 版本、Mono 与 IL2CPP 之间会有浮动。2.4 IL2CPP 下同样成立有人以为闭包分配是 Mono JIT 的产物IL2CPP 会好一些。事实相反IL2CPP 把显示类照样翻译成 C 类把委托照样翻译成堆上对象分配一次不少变化的只是这些对象由 IL2CPP 的分配器经 Boehm 管理。也就是说本文所有优化建议对 Mono 与 IL2CPP 两个后端同等有效不存在换个后端闭包就免费的选项。三、委托对象本身另一半分配成本把显示类干掉不等于零分配委托对象自己就是一次堆分配。Action、Func...都是类继承自MulticastDelegatenew Action(obj.Method)的本质是创建一个堆对象内部至少持有目标对象引用与方法指针两块内容64 位下典型估算 40B 上下多播链还会追加节点。关于委托有四条实用规则。其一方法组转换与等价 lambda 的分配行为一致list.Sort(Compare)写成方法组还是写成 lambda只要不捕获变量都走 2.3 里形态 C 的缓存路径。其二委托实例不可变但可组合会创建新的委托对象挂上新链热路径里反复等于反复分配加反复拷贝链表。其三staticlambdaC# 9 起Unity 2021.2 起的编译器支持该语言版本给编译器一个承诺这个 lambda 绝不捕获任何变量编译期直接报错来保证缓存路径因此对它是确定性的而不是恰好没捕获的巧合。其四缓存委托要缓存对的对象实例方法的委托持有 target把带this的委托缓存进静态字段会把整个实例钉在堆上这在 MonoBehaviour 上就是场景对象泄漏第六节展开。Unity 版本与语言特性对应关系一句话带过Unity 2021.2 之后的编译器链支持 C# 9staticlambda、函数指针语法都可用更老的项目用显式缓存到字段达到同样效果思路完全一样只是要手写。写法上的差别直接看这组对照// C# 9 的 static lambda 不捕获任何变量, 可被缓存 var predicate static (int x) x 10; // 静态委托, 零分配 // 与普通 lambda 对比: Funcint, bool f1 x x 10; // 可能产生委托分配(热路径) Funcint, bool f2 static (int x) x 10; // 编译器缓存为静态字段逐段解读这组对比。f1是普通 lambda它是否分配取决于上下文与实现细节编译器完全有能力把不捕获的它也编进缓存类但这段代码没有向编译器承诺任何事读代码的人同样无从判断注释里可能产生委托分配描述的就是这种不确定性。f2加了static承诺由编译器强制一旦它试图引用任何外部变量编译直接失败于是缓存行为变成硬保证热路径里写static就是在给性能上锁。第一条语句展示的写法与f2等价区别只在f2把委托存进了变量复用关系更明显。四、捕获变量 vs 不捕获热路径对照// ❌ 捕获变量 → 闭包分配 int threshold 10; var predicate (int x) x threshold; // new 闭包类 // ✅ 不捕获变量 → 可缓存静态委托 var predicate (int x) x 10; // 编译器缓存为静态字段逐段解读这对代码。上半部分捕获了局部变量threshold编译器必须生成显示类每次执行到这一行就是一次显示类分配加一次委托分配值为 10 这个事实救不了它因为语义上threshold是可变的编译器必须假设它会在 lambda 执行前被改掉。下半部分把常量直接写进 lambda 体没有任何变量可捕获编译器把它编进c缓存类委托在首次执行后全程复用稳态零分配。两行代码的可读性几乎相同GC 行为却是天壤之别。这条对照落到工程里有个直接推论热路径上的 lambda先问一句它捕获了什么。捕获列表为空随便写捕获了任何局部变量就去查这份捕获在当前场景里每秒产生多少次。再给一组把捕获改成参数传递的对照这是消除捕获的通用手术// ❌ 每帧创建捕获 threshold 的闭包 void Update() { var targets enemies.Where(e e.Hp threshold).ToList(); // 闭包 迭代器见 08-02 } // ✅ 阈值不进闭包委托不捕获比较逻辑提成静态方法 static readonly FuncEnemy, bool s_aboveThreshold e e.Hp s_cachedThresholdField; // ↑ 若 threshold 是实例字段静态 lambda 不能碰改为静态方法 显式参数 static bool AboveThreshold(Enemy e, float th) e.Hp th; void Update() { for (int i 0; i enemies.Count; i) if (AboveThreshold(enemies[i], threshold)) { /* ... */ } }上半部分的代价是三笔起步显示类、委托、外加 LINQ 迭代器与结果容器后两笔由 08-02 专门清算这里只算闭包账。下半部分把判断提成静态方法比较逻辑直接以参数收值委托可以缓存甚至整个消掉用手动循环替代 LINQ 后连迭代器也一并消失。注意第二段注释里的提醒静态 lambda 访问不到实例字段这不是缺陷而是设计它逼着你把隐式捕获改成显式传参热路径代码因此变得更可测、更少隐藏依赖。五、Unity 中的常见陷阱// 陷阱 1事件中的闭包 for (int i 0; i 10; i) { buttons[i].onClick.AddListener(() Debug.Log(i)); // 每个按钮创建一个闭包闭包中都持有 i 的引用 } // 修复缓存到局部变量 foreach (var btn in buttons) { int index btn.index; btn.onClick.AddListener(() Debug.Log(index)); } // 陷阱 2协程中的闭包 IEnumerator Coroutine() { // WaitForSeconds 每次 new → GC yield return new WaitForSeconds(1f); } // 修复缓存 WaitForSeconds static readonly WaitForSeconds s_wait new WaitForSeconds(1f);逐段解读陷阱 1。循环体里每个按钮都执行一次AddListener(() Debug.Log(i))lambda 捕获了循环变量i于是每次迭代创建一套显示类加委托。更隐蔽的是语义问题在 C# 5 之后的foreach里迭代变量本身每次迭代都是新的存储位置捕获它是安全的但老式for循环的i是同一个变量十个闭包共享同一个i全部指向循环结束后的值点击任何按钮打印的都是 10。修复代码引入index局部变量做每份闭包自己的快照既修语义又让分配次数与按钮数量绑定而非与点击次数绑定初始化一次分配十个之后点击不再产生新闭包。逐段解读陷阱 2。yield return new WaitForSeconds(1f)每次执行到这一行都新建一个WaitForSeconds。协程恢复机制决定了它每次从 yield 返回都要对返回值做一次类型检查而检查发生在对象创建之后这个分配躲不掉只能让它不发生第二次。缓存成static readonly字段后全程序一份WaitForSeconds(1f)反复复用。这条修复对所有可缓存的 yield 指令通用WaitForSeconds、WaitForFixedUpdate、WaitForEndOfFrame这类无状态对象都该缓存WaitForSecondsRealtime同理。带参数的变体比如等待时长可配用Dictionaryfloat, WaitForSeconds做个小池子即可时长种类通常是有限集合。陷阱 3协程里的 lambda 捕获this。协程方法里写item.OnDone () MarkDirty()这个 lambda 访问实例方法按 2.3 属于形态 B没有显示类但每次注册都 new 一个委托委托 target 持有当前 MonoBehaviour。若这个事件源是全局单例或静态事件场景销毁后单例还活着委托链上的旧 MonoBehaviour 就被整条拽着不能回收这是跨场景泄漏的经典成因第六节展开。陷阱 4Update 里顺手写的 lambda。典型形态是delayCall () ApplyBuff(buffData)然后每帧判断、偶尔赋值看起来只在需要时创建。问题出在每帧判断这行本身如果写成list.ForEach(x x.Tick())或者把 lambda 当作比较器临时传出去分配就回到每帧一次的节奏。Update 里的每一行都应假定自己一秒执行 60 次这是本篇所有判断的基准频率。陷阱 5闭包加字符串的叠加放大。onClick.AddListener(() ShowTip($玩家 {name} 得分 {score}))一行里同时踩闭包、字符串拼接、插值装箱三个来源量级是闭包的几十字节乘以插值产生的字符串几百字节再乘以点击频率。字符串那部分的账在 08-01这里只提醒一句闭包经常不是孤立作案它把其他分配一起带进热路径。六、事件订阅中的闭包泄漏与重复订阅闭包导致的泄漏和普通对象泄漏机制不同它不靠悬空引用靠的是引用链太长。只要事件源按钮、全局事件总线、静态事件还活着委托就活着委托的 target 是闭包实例或 MonoBehaviour闭包字段里又有被捕获的场景对象。整条链的根在事件源源不动链上一个都别想回收。// ❌ 静态事件 捕获 this 的 lambda场景销毁后无法解除 void OnEnable() GlobalEvents.OnScoreChanged () RefreshUI(score); void OnDisable() GlobalEvents.OnScoreChanged - () RefreshUI(score); // ↑ - 的是另一个新 lambda等于没退订旧 lambda 永远挂在静态事件上 // ✅ 持有委托实例成对增删或直接用静态方法 显式传参 System.Action _handler; void OnEnable() { _handler RefreshUI; GlobalEvents.OnScoreChanged _handler; } void OnDisable() { GlobalEvents.OnScoreChanged - _handler; _handler null; } void RefreshUI(int score) { /* ... */ }逐段解读坏代码。两处 lambda 各自编译成独立的委托对象语义相等的 lambda 也不相等的委托-按实例比对自然解不掉结果是每次开关对象都在静态事件上多挂一条链链越挂越长每条链都钉着一个已经销毁的 MonoBehaviour。这类泄漏在 Profiler 里的表现是退出战斗场景后本该死掉的对象数量随重开次数线性上涨Memory 快照里能看到委托对象连成串。逐段解读好代码。_handler字段保存委托实例注册与注销引用同一实例委托比对按实例相等成立退订干净。把处理逻辑换成实例方法RefreshUI后委托依然持有this但因为成对注销泄漏链在 OnDisable 就被切断。若事件源允许更彻底的写法是静态方法加显式传参GlobalEvents.OnScoreChanged OnGlobalScore;配static void OnGlobalScore(int s)委托不持有任何实例连忘写 OnDisable的最坏情况也只是丢一次回调而不是泄漏一个对象。工程上给三条硬规则。第一凡是订阅静态对象或全局单机的事件OnEnable 与 OnDisable 必须成对出现且可核对代码评审直接查这一对。第二绝不对静态事件注册捕获局部状态的 lambda要传状态就把状态挂到回调参数或由订阅者自查。第三注册接口若提供RemoveAllListeners如 Unity UI 的 ButtonEventTrigger 一族且确认没有其他订阅者销毁前调用一次兜底有其他订阅者时不能调否则会把别人的回调一并清掉这条要写进团队约定。七、优化方案先给结论清单再逐条讲适用边界不捕获变量的 lambda 用 staticC# 9频繁创建的事件处理器缓存委托协程中固定 WaitForSeconds 等 yield 指令热路径中避免使用匿名函数方案阶梯从零成本到需要重构排列方案分配前提适用位置static lambda稳态零lambda 不捕获任何变量一切位置热路径首选缓存委托到字段/静态字段首次一次之后零委托内容与实例无关或缓存属该实例每帧/每点击重复使用的处理器实例方法替代匿名函数每次转换委托一次可再缓存逻辑与实例状态绑定事件回调、比较器静态方法 显式参数零逻辑可以不依赖实例字段热路径里的比较、转换逻辑局部函数不逃逸零只在本方法内调用不转委托一段逻辑需要拆分但不外传函数指针C# 9零签名兼容、静态方法、仅限非托管约定极端热路径如逐元素批量操作逐条说明。static lambda 的价值不只是省几字节它同时是一份契约读代码的人立刻知道这个 lambda 不依赖上下文编译器帮你把违约变成编译错误。缓存委托适合处理器本身固定、只是反复挂载的场景注意缓存归属实例处理器缓存在实例字段静态处理器才允许进静态字段。实例方法替代匿名函数的收益是少一次显示类分配若该方法组还会被反复转换配上缓存字段就把委托分配也省掉。局部函数在不逃逸时由编译器编译为普通私有方法调用是直接调用连委托对象都不产生一旦被转成委托往外传它立刻退化成和 lambda 一样的分配行为这个边界值得在评审时明确。函数指针是最后一档它要求目标是静态方法且签名满足非托管约定托管对象作为参数时它管不住生命周期所以只适合输入输出都是裸数据的极端路径普通业务代码用前面几档就够。关于热路径中避免使用匿名函数这条落地方式不是禁止语法而是改变数据流把每帧的逻辑提成命名方法需要参数就走显式参数需要缓存就在 Awake 或首次使用时建好委托。命名方法还有一个附带收益Profiler 采样时栈帧名字可读匿名函数与显示类方法名在采样树里是Updateb__12_0这类乱码排查成本高一截。八、量级估算一次闭包分配值多少字节下表全部为典型估算用于判断值不值得治不是任何机型上的实测常数。对象头按 64 位 Mono/Boehm 布局 16B 计实际随后端浮动。分配来源单次字节典型估算频率假设每秒垃圾量显示类实例1 个捕获字段~24B每帧 1 次~1.4KB/sAction 委托对象~40B每帧 1 次~2.4KB/s捕获变量的 lambda显示类 委托~64B每帧 1 次~3.8KB/s只捕获 this 的 lambda~40B每帧 1 次~2.4KB/s不捕获的 lambda稳态0任意0协程每次 yield new WaitForSeconds~32B 对象本身每秒 10 次~0.3KB/s循环内为 10 个按钮各建闭包~640B仅初始化时一次性读表要点有三个。第一单看每次几十字节很容易放松警惕真正的风险是频率列每秒几 KB 的稳态分配在小堆上也许几分钟才触发一次收集堆涨到几十 MB 后触发阈值变高、单次收集变长两头恶化这正是 Boehm 非分代结构的特点详见 03 篇。第二捕获 this 比捕获局部变量便宜一个显示类但便宜不等于免费Update 里每帧一次的形态 B 依然是每秒 2.4KB 量级该缓存还是要缓存。第三最后一行说明优化不是一刀切初始化期一次性创建十个闭包完全可接受要杀的是频率与帧率挂钩的那类分配。自查方法给一条捷径Unity Profiler 的 Timeline 上开 GC Alloc 计数器对着嫌疑方法逐个注释二分分配归零的那次注释对应的 lambda 就是源头装了 Burst 或用 IL2CPP 时也可以在生成的 C 文件里搜DisplayClass每个命中都对应一处显示类分配。九、常见误区与反模式误区一lambda 很短应该没有分配。 错在哪分配次数与 lambda 体的长度完全无关只与捕获形态和调用频率有关。空体 lambda 只要捕获了变量显示类与委托一笔不少。正确做法是查捕获列表不是数语句。误区二把 lambda 缓存成静态字段就万事大吉。 错在哪静态字段缓存委托时委托的 target 若是某个实例该实例就无法回收缓存带this的委托等于把一个 MonoBehaviour 钉在全局。正确做法静态 lambda 才进静态字段实例委托缓存在实例字段并在销毁时置空。误区三委托复用只要逻辑一样就行-用什么写法无所谓。 错在哪委托按实例比对语义相同的两个 lambda 是两个实例-解不掉这是第六节泄漏事故的直接原因。正确做法保存委托实例成对增删或者改用静态方法订阅。误区四IL2CPP 下闭包会被优化掉。 错在哪IL2CPP 只是把闭包翻译成 C 类堆分配照旧Boehm 照旧管理见 2.4。正确做法两个后端执行同一套优化验收都以真机 Profiler 的 GC Alloc 为准。十、与相关章节的对比章节负责的分配来源与本文的界线08-01 字符串拼接、格式化、ToString闭包常把字符串分配带进热路径字符串本身的账归那边08-02 LINQ迭代器、结果容器Where/Select 里的 lambda 分配归本文迭代器与 ToList 归那边08-04 装箱与反射隐式装箱、反射元数据lambda 参数若是值类型走 object 重载会在闭包之外再叠一层装箱08-06 async 状态机async lambda 的状态机类async lambda 在闭包账之外还有状态机分配见那边08-08 Unity API 陷阱引擎 API 返回值分配yield 指令缓存一条在本文第五节清单在那边本文在全专栏的位置一句话它处理的是编译器替你偷偷 new 对象这一类里最常见的一种。字符串是显式可见的分配闭包是语法层面的分配两者叠加几乎覆盖了 Unity 项目热路径 GC 的半壁江山。本章总结闭包分配来自编译器生成的隐藏类实例。捕获变量的 lambda 产生闭包类实例和委托分配。捕获形态决定代价捕获局部变量是显示类加委托两笔只捕获 this 只有委托一笔不捕获走编译器缓存稳态零分配。方法被调用 N 次就创建 N 套闭包实例频率与调用点挂钩写进 Update 就是每帧一份。Unity 陷阱集中在三处循环内给事件挂捕获变量的 lambda、协程每次 yield new、静态事件上订阅捕获 this 的匿名函数。事件订阅泄漏的根子在引用链事件源活着委托活着委托引用的场景对象全活着解药是成对注销与静态方法订阅。优化方案不捕获变量用 static lambda、缓存委托、协程中固定 yield 指令热路径优先走静态方法加显式参数。一句话记忆点看到 lambda 先问捕获了什么、每秒跑几次答案乘以几十字节就是你喂给 Boehm 的账单。对不捕获变量的 lambda使用 static 修饰符消除委托分配。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →