一、测试代码

using System;

namespace ClosureTest
{
    class Program
    {
        // 用一个静态字段模拟长生命周期的引用
        public static Action? LongLivingAction;

        static void Main(string[] args)
        {
            BadClosure();
            
            Console.WriteLine("Done. Now check the IL code.");
        }

        static void BadClosure()
        {
            // 1. 定义一个不需要被捕获的“大对象”
            // (虽然这里是局部变量,但我们看看它会被放在哪)
            byte[] hugeData = new byte[1024 * 1024 * 100]; // 100MB

            // 2. 定义一个小的局部变量
            int counter = 0;

            // 3. Lambda A:引用了 hugeData
            // 编译器必须生成一个闭包类来存 hugeData
            Action useHuge = () =>
            {
                Console.WriteLine("Huge Size: " + hugeData.Length);
            };

            // 4. Lambda B:只引用了 counter
            // 理论上它不需要 hugeData,但实际上...?
            Action useCounter = () =>
            {
                counter++;
                Console.WriteLine("Counter: " + counter);
            };

            // 5. 让 useCounter 逃逸到外部(模拟被按钮持有)
            // 这里的 LongLivingAction 会持有 useCounter 的 Target 对象
            LongLivingAction = useCounter;
            
            // 为了防止 Release 模式下编译器优化掉 useHuge,我们假装用一下
            if (DateTime.Now.Ticks < 0) useHuge();
        }
    }
}

问: BadClosure 执行完后,hugeData 能被回收吗?

直觉分析: 能吧?因为 LongLivingAction 只存了 useCounter,而 useCounter 只用了 counter,跟 hugeData 没关系啊。

残酷的现实: 不能回收!内存泄漏了!


这种泄漏的致命之处

  1. 隐蔽性极强:你在写 useCounter 时,肉眼看代码完全找不到它和 hugeData 的关系。
  2. “猪队友”效应useCounter 本身很无辜,但因为同一个作用域里的 useHuge 引用了 hugeData ,导致大家都被绑在了同一条船(同一个闭包实例)上。

这就是 “多闭包共享作用域” 的高阶陷阱,这是绝大多数 C# 开发者(甚至高级开发者)都会忽略的细节。

二、IL代码

// Type: ClosureTest.Program 
// Assembly: Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
// MVID: AE516248-CF95-46F9-B60B-85CD9952CA01
// Location: D:\WorkSpace\Unity\games\Games\Library\ScriptAssemblies\Assembly-CSharp.dll
// Sequence point data and variable names from d:\workspace\unity\games\games\library\scriptassemblies\assembly-csharp.pdb

.class private auto ansi beforefieldinit
  ClosureTest.Program
    extends [netstandard]System.Object
{

  .class nested private sealed auto ansi beforefieldinit
    '<>c__DisplayClass2_0'
      extends [netstandard]System.Object
  {
    .custom instance void [netstandard]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
      = (01 00 00 00 )

    .field public unsigned int8[] hugeData

    .field public int32 counter

    .method public hidebysig specialname rtspecialname instance void
      .ctor() cil managed
    {
      .maxstack 8

      IL_0000: ldarg.0      // this
      IL_0001: call         instance void [netstandard]System.Object::.ctor()
      IL_0006: ret

    } // end of method '<>c__DisplayClass2_0'::.ctor

    .method assembly hidebysig instance void
      '<BadClosure>b__0'() cil managed
    {
      .maxstack 2
      .locals init (
        [0] int32 V_0
      )

      // [30 17 - 30 68]
      IL_0000: ldstr        "Huge Size: "
      IL_0005: ldarg.0      // this
      IL_0006: ldfld        unsigned int8[] ClosureTest.Program/'<>c__DisplayClass2_0'::hugeData
      IL_000b: ldlen
      IL_000c: conv.i4
      IL_000d: stloc.0      // V_0
      IL_000e: ldloca.s     V_0
      IL_0010: call         instance string [netstandard]System.Int32::ToString()
      IL_0015: call         string [netstandard]System.String::Concat(string, string)
      IL_001a: call         void [netstandard]System.Console::WriteLine(string)

      // [31 13 - 31 14]
      IL_001f: ret

    } // end of method '<>c__DisplayClass2_0'::'<BadClosure>b__0'

    .method assembly hidebysig instance void
      '<BadClosure>b__1'() cil managed
    {
      .maxstack 3
      .locals init (
        [0] int32 V_0
      )

      // [37 17 - 37 27]
      IL_0000: ldarg.0      // this
      IL_0001: ldfld        int32 ClosureTest.Program/'<>c__DisplayClass2_0'::counter
      IL_0006: stloc.0      // V_0
      IL_0007: ldarg.0      // this
      IL_0008: ldloc.0      // V_0
      IL_0009: ldc.i4.1
      IL_000a: add
      IL_000b: stfld        int32 ClosureTest.Program/'<>c__DisplayClass2_0'::counter

      // [38 17 - 38 58]
      IL_0010: ldstr        "Counter: "
      IL_0015: ldarg.0      // this
      IL_0016: ldflda       int32 ClosureTest.Program/'<>c__DisplayClass2_0'::counter
      IL_001b: call         instance string [netstandard]System.Int32::ToString()
      IL_0020: call         string [netstandard]System.String::Concat(string, string)
      IL_0025: call         void [netstandard]System.Console::WriteLine(string)

      // [39 13 - 39 14]
      IL_002a: ret

    } // end of method '<>c__DisplayClass2_0'::'<BadClosure>b__1'
  } // end of class '<>c__DisplayClass2_0'

  .field public static class [netstandard]System.Action LongLivingAction
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(unsigned int8)
      = (01 00 02 00 00 ) // .....
      // unsigned int8(2) // 0x02

  .method private hidebysig static void
    Main(
      string[] args
    ) cil managed
  {
    .maxstack 8

    // [12 13 - 12 26]
    IL_0000: call         void ClosureTest.Program::BadClosure()

    // [14 13 - 14 63]
    IL_0005: ldstr        "Done. Now check the IL code."
    IL_000a: call         void [netstandard]System.Console::WriteLine(string)

    // [15 9 - 15 10]
    IL_000f: ret

  } // end of method Program::Main

  .method private hidebysig static void
    BadClosure() cil managed
  {
    .maxstack 3
    .locals init (
      [0] class [netstandard]System.Action useHuge,
      [1] valuetype [netstandard]System.DateTime V_1
    )

    IL_0000: newobj       instance void ClosureTest.Program/'<>c__DisplayClass2_0'::.ctor()

    // [21 13 - 21 59]
    IL_0005: dup
    IL_0006: ldc.i4       104857600 // 0x06400000
    IL_000b: newarr       [netstandard]System.Byte
    IL_0010: stfld        unsigned int8[] ClosureTest.Program/'<>c__DisplayClass2_0'::hugeData

    // [24 13 - 24 29]
    IL_0015: dup
    IL_0016: ldc.i4.0
    IL_0017: stfld        int32 ClosureTest.Program/'<>c__DisplayClass2_0'::counter

    // [28 13 - 31 15]
    IL_001c: dup
    IL_001d: ldftn        instance void ClosureTest.Program/'<>c__DisplayClass2_0'::'<BadClosure>b__0'()
    IL_0023: newobj       instance void [netstandard]System.Action::.ctor(object, native int)
    IL_0028: stloc.0      // useHuge

    // [35 13 - 39 15]
    IL_0029: ldftn        instance void ClosureTest.Program/'<>c__DisplayClass2_0'::'<BadClosure>b__1'()
    IL_002f: newobj       instance void [netstandard]System.Action::.ctor(object, native int)

    // [43 13 - 43 43]
    IL_0034: stsfld       class [netstandard]System.Action ClosureTest.Program::LongLivingAction

    // [46 13 - 46 40]
    IL_0039: call         valuetype [netstandard]System.DateTime [netstandard]System.DateTime::get_Now()
    IL_003e: stloc.1      // V_1
    IL_003f: ldloca.s     V_1
    IL_0041: call         instance int64 [netstandard]System.DateTime::get_Ticks()
    IL_0046: ldc.i4.0
    IL_0047: conv.i8
    IL_0048: bge.s        IL_0050

    // [46 41 - 46 51]
    IL_004a: ldloc.0      // useHuge
    IL_004b: callvirt     instance void [netstandard]System.Action::Invoke()

    // [47 9 - 47 10]
    IL_0050: ret

  } // end of method Program::BadClosure

  .method public hidebysig specialname rtspecialname instance void
    .ctor() cil managed
  {
    .maxstack 8

    IL_0000: ldarg.0      // this
    IL_0001: call         instance void [netstandard]System.Object::.ctor()
    IL_0006: ret

  } // end of method Program::.ctor
} // end of class ClosureTest.Program

铁证 A:同一个牢房(闭包类定义)

看 IL 代码的开头部分,编译器生成了一个嵌套类 '<>c__DisplayClass2_0'
注意它的字段(Fields)定义:

.class nested private sealed auto ansi beforefieldinit '<>c__DisplayClass2_0'
  extends [netstandard]System.Object
{
    // ...
    // 证据 1:大数组在这里
    .field public unsigned int8[] hugeData 

    // 证据 2:小计数器也在这里
    .field public int32 counter
    // ...
}

编译器强行hugeData(100MB)和 counter(4字节)关进了同一个类里。它们现在的命运已经绑定在一起了。


铁证 B:同一个实例(BadClosure 方法)

让我们看 BadClosure 方法的 IL 代码。这是灾难发生的现场。

1. 创建“牢房”实例(闭包对象)

IL_0000: newobj instance void ClosureTest.Program/'<>c__DisplayClass2_0'::.ctor()

注意:这里只调用了一次 newobj。这意味着在堆上只生成了一个闭包对象实例。

2. 填充数据

// 分配 100MB 内存,并存入上面那个实例的 hugeData 字段
IL_0005: dup
IL_0006: ldc.i4 104857600
IL_000b: newarr [netstandard]System.Byte
IL_0010: stfld unsigned int8[] ClosureTest.Program/'<>c__DisplayClass2_0'::hugeData

3. 制造泄漏链条
关键看这里,LongLivingAction 是如何被赋值的:

// 加载 <BadClosure>b__1 (就是那个只用 counter 的小 lambda)
IL_0029: ldftn instance void ClosureTest.Program/'<>c__DisplayClass2_0'::'<BadClosure>b__1'()

// 创建委托 Action,注意它的 Target (this) 是栈顶的那个闭包实例
IL_002f: newobj instance void [netstandard]System.Action::.ctor(object, native int)

// 将这个委托赋值给静态变量 LongLivingAction
IL_0034: stsfld class [netstandard]System.Action ClosureTest.Program::LongLivingAction

完整的引用链条

根据这份 IL 代码画出引用图。当 BadClosure 执行完毕回到 Main 函数时,内存中发生了什么?

  1. GC Root: ClosureTest.Program.LongLivingAction (静态变量,活着)
  2. 引用: Action (useCounter 委托,活着)
  3. 引用: Action.Target 属性 (指向那个 newobj 出来的 <>c__DisplayClass2_0 实例)
  4. 引用: <>c__DisplayClass2_0.hugeData 字段 (指向 100MB 的 byte[])
  5. 结果: byte[] 活着!无法回收!

总结

贴出的 IL 代码清晰地展示了:

  1. 合并hugeDatacounter 被合并到了同一个类 '<>c__DisplayClass2_0' 中。
  2. 共享:虽然 useCounter (即 b__1) 的代码逻辑里根本没出现 hugeData (见 IL_0000 到 IL_002a 部分),但因为 b__1 是个实例方法 (instance void),它必须持有 this 指针。
  3. 连坐:这个 this 指针就是那个闭包对象,而那个对象里正好存着 hugeData

三、如何解决

如果在同一个方法里既有大对象,又有长生命周期的轻量级闭包,必须手动隔离作用域

修正写法:

void SafeCode()
{
    var huge = new byte[100_000_000];
    
    // 把不想关联的逻辑包到一个局部代码块或单独的方法里
    {
        // 这里的闭包如果用了 huge,那是它自己的事,不影响外面
        Action logHuge = () => Console.WriteLine(huge.Length);
        logHuge();
    } // 这里的引用结束了

    // ---------------------------------------------

    int counter = 0;
    // 这里的 lambda 只能看到 counter,编译器会发现它和 huge 没交集(如果 huge 没被上面的块过度缠绕)
    // 或者更保险的做法:把这部分逻辑提炼成另一个方法
    GlobalButtonAction = CreateCounterAction(); 
}

Action CreateCounterAction()
{
    int counter = 0;
    return () => counter++; // 这里的闭包绝对干净,不可能捕获外部的 huge
}

或者,在 C# 9.0+ 中,如果你确定不需要捕获任何局部变量,可以使用 static 关键字防止意外捕获:

// 这样如果试图访问 huge 会直接编译报错
Action cleanAction = static () => Console.WriteLine("I capture nothing!"); 

四、总结

  1. 按需捕获
    编译器非常聪明,匿名函数里完全没用到的局部变量,绝对不会被捕获。

  2. 变量提升(Variable Hoisting)
    一旦变量被捕获,它就不再是栈上的局部变量,而是堆上闭包对象的字段。

  3. 共享闭包陷阱
    同一个作用域(方法)内,如果有多个 lambda,编译器通常会将它们合并到同一个闭包类中。
    结果:只要其中一个 lambda 捕获了大对象,所有其他的 lambda(即使没用大对象)在持有闭包实例时,都会间接导致大对象无法释放。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐