C#闭包陷阱1
一、测试代码
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 没关系啊。
残酷的现实: 不能回收!内存泄漏了!
这种泄漏的致命之处
- 隐蔽性极强:你在写
useCounter时,肉眼看代码完全找不到它和hugeData的关系。 - “猪队友”效应:
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 函数时,内存中发生了什么?
- GC Root:
ClosureTest.Program.LongLivingAction(静态变量,活着) - 引用:
Action(useCounter 委托,活着) - 引用:
Action.Target属性 (指向那个newobj出来的<>c__DisplayClass2_0实例) - 引用:
<>c__DisplayClass2_0.hugeData字段 (指向 100MB 的 byte[]) - 结果:
byte[]活着!无法回收!
总结
贴出的 IL 代码清晰地展示了:
- 合并:
hugeData和counter被合并到了同一个类'<>c__DisplayClass2_0'中。 - 共享:虽然
useCounter(即b__1) 的代码逻辑里根本没出现hugeData(见 IL_0000 到 IL_002a 部分),但因为b__1是个实例方法 (instance void),它必须持有this指针。 - 连坐:这个
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!");
四、总结
-
按需捕获:
编译器非常聪明,匿名函数里完全没用到的局部变量,绝对不会被捕获。 -
变量提升(Variable Hoisting):
一旦变量被捕获,它就不再是栈上的局部变量,而是堆上闭包对象的字段。 -
共享闭包陷阱:
在同一个作用域(方法)内,如果有多个 lambda,编译器通常会将它们合并到同一个闭包类中。
结果:只要其中一个 lambda 捕获了大对象,所有其他的 lambda(即使没用大对象)在持有闭包实例时,都会间接导致大对象无法释放。
更多推荐



所有评论(0)