.NET 同步与异步 之 警惕闭包(十)
本随笔续接:.NET 同步与异步 之 原子操作和自旋锁(Interlocked、SpinLock)(九)
至此、同步与异步 相关的常规操作(比较常见的操作)、差不多已经介绍完毕。 本随笔就着重说一下闭包、因闭包可能会导致一些意想不到的的bug。
(PS:至于 WaitHandle家族相关随笔、最后补充)
一、警惕闭包
int total = 0; List<Task> taskList = new List<Task>(); for (int i = 0; i < 10; i++) { var task = Task.Run(() => { System.Threading.Interlocked.Add(ref total, i); }); taskList.Add(task); } Task.WaitAll(taskList.ToArray()); PrintInfo(total.ToString()); // 输出结果
这个demo逻辑很简单、在循环中异步累加循环变量i的值,当然所有异步操作完成后,输出累加结果。 1+2+3 ... + 9 结果应该是 45.
如果你看到这里没有什么问题、也没发现什么问题。那么你可能掉坑里了、这是并发中比较常见的一类问题、也是本随笔的要着重说明的一点:警惕闭包。
其实这里的输出结果是随机的、应该在 [45 ~ 100] 之间。 看到这里,如果你能想明白为什么,那么本篇随笔的一半内容已经明白了。
二、闭包的本质
从本质上说,闭包是一段可执行的代码块,但是这段代码块额外维护了一块上下文环境(内存),即使上下文环境中的某个局部变量、已经超出了其原本所在的代码块的作用域,闭包也依然可以对其进行访问。
/// <summary> /// 窥探闭包的本质 /// </summary> public void Demo2() { var func = GetFunc(); PrintInfo($"result:{func().ToString()}"); // 输出结果 结果为 12 } private Func<int> GetFunc() { int result = 10; Func<int> func = () => { result++; return result; }; result++; return func; }
在上面例子里的Demo中,局部变量result 的作用域是 GetFunc 方法,但是当 GetFunc 方法执行完毕后,在 Demo2 方法中 调用 匿名委托 Func<int> 时,依然可以访问 result 变量,这就是闭包。
三、一探究竟
让我们来借助IL来一探究竟、看看这个过程中究竟发生了什么、首先看一下 GetFunc 这个方法,顺带这个机会、较为深入的介绍一下IL:
.method private hidebysig instance class [mscorlib]System.Func`1<int32> GetFunc() cil managed {
// 标示分配的堆栈的大小、在该方法执行过程中、堆栈中最多可同时存放3个数据项 // 代码大小 50 (0x32) .maxstack 3
// 声明该方法中需要的四个局部变量,形如: [索引] 类型 名称
// 索引为 0 的类型,是编译器自动生成的类型、该类型有两个成员、一个int result、一个 返回值为 int类型的方法,具体详情稍后再介绍。 .locals init ([0] class ParallelDemo.Demo.VariableCapturingClass/'<>c__DisplayClass3_0' 'CS$<>8__locals0', [1] class [mscorlib]System.Func`1<int32> func, [2] int32 V_2, [3] class [mscorlib]System.Func`1<int32> V_3)
// new 一个对象、其类型为编译器自动生成的类型、新 new出来的对象的引用 将被push到堆栈上 IL_0000: newobj instance void ParallelDemo.Demo.VariableCapturingClass/'<>c__DisplayClass3_0'::.ctor()
// POP堆栈, 并将POP出的数据赋值给 索引为0的局部变量。 此时堆栈中已经没有数据了 IL_0005: stloc.0
// 无意义的操作 IL_0006: nop
// 将索引为0的局部变量push到堆栈 IL_0007: ldloc.0
// 将整形数字 10 push到堆栈 IL_0008: ldc.i4.s 10
// POP 堆栈中的两个数据, 并将第二个数据赋值给 第一个数据(引用)的result字段。 此时堆栈中已经没有数据了 【完成了 result = 10 的赋值操作】 IL_000a: stfld int32 ParallelDemo.Demo.VariableCapturingClass/'<>c__DisplayClass3_0'::result
// 将索引为0的局部变量push到堆栈 IL_000f: ldloc.0
// 将其方法所对应的非托管代码指针 push到堆栈上 IL_0010: ldftn instance int32 ParallelDemo.Demo.VariableCapturingClass/'<>c__DisplayClass3_0'::'<GetFunc>b__0'()
// POP 构造函数所需要的两个参数、 并 new 一个 Func<int> 类型委托, 并将新对象引用push到堆栈 IL_0016: newobj instance void class [mscorlib]System.Func`1<int32>::.ctor(object, native int)
// POP堆栈, 并将值赋值给索引为1的局部变量 此时堆栈中已经没有数据了 【完成了 new Func<int> 的操作】 IL_001b: stloc.1
// 将索引为0的局部变量push到堆栈 IL_001c: ldloc.0
// POP堆栈, 并将POP出的数据(引用)的result字段值 push到堆栈 IL_001d: ldfld int32 ParallelDemo.Demo.VariableCapturingClass/'<>c__DisplayClass3_0'::result
// POP堆栈, 并将POP出的数据赋值给 索引为 2的局部变量 此时堆栈中已经没有数据了 IL_0022: stloc.2
// 将索引为0的局部变量push到堆栈 IL_0023: ldloc.0
// 将索引为2的局部变量push到堆栈 IL_0024: ldloc.2
// 将 数字1push到堆栈 IL_0025: ldc.i4.1
// POP两个数据 并求和, 并将结果push到堆栈 IL_0026: add
// POP两个数据, 并将第二个数据的值赋值给 第一个数据(引用)的result字段 此时堆栈中已经没有数据了 【完成了 result++ 的操作】 IL_0027: stfld int32 ParallelDemo.Demo.VariableCapturingClass/'<>c__DisplayClass3_0'::result
// 将索引为1的局部变量push到堆栈 IL_002c: ldloc.1
// POP堆栈, 并将POP出的数据赋值给 索引为3的局部变量 此时堆栈数据为空 【完成返回值的准备工具】 IL_002d: stloc.3
// 跳转代码至 IL_0030 IL_002e: br.s IL_0030
// push索引为3的局部变量到堆栈 IL_0030: ldloc.3
// 返回 return IL_0031: ret } // end of method VariableCapturingClass::GetFunc
看完上面的IL的代码解释,你可能唯一不太明白的就是 编译器自动生成的类型,那接下来,我们将看一看 这个自动生成的类型,到底是什么东西:
根据上图,我们可以确定:
1、新生成的类型是一个class
2、含有 result字段 ,类型为int32.
3、含有一个 <GetFunc>b__0的方法, 返回值 为 int32类型
让我们在看看 <GetFunc>b__0 这个方法的IL代码:
.method assembly hidebysig instance int32 '<GetFunc>b__0'() cil managed { // 代码大小 28 (0x1c) .maxstack 3 .locals init ([0] int32 V_0, [1] int32 V_1) IL_0000: nop
// push this引用到堆栈 IL_0001: ldarg.0
// POP堆栈, 并将 POP出的数据(引用)的result字段值 push到堆栈 其他IL代码就不一一解释了, 因为前文都已经介绍过了。 IL_0002: ldfld int32 ParallelDemo.Demo.VariableCapturingClass/'<>c__DisplayClass3_0'::result IL_0007: stloc.0 IL_0008: ldarg.0 IL_0009: ldloc.0 IL_000a: ldc.i4.1 IL_000b: add IL_000c: stfld int32 ParallelDemo.Demo.VariableCapturingClass/'<>c__DisplayClass3_0'::result IL_0011: ldarg.0 IL_0012: ldfld int32 ParallelDemo.Demo.VariableCapturingClass/'<>c__DisplayClass3_0'::result IL_0017: stloc.1 IL_0018: br.s IL_001a IL_001a: ldloc.1 IL_001b: ret } // end of method '<>c__DisplayClass3_0'::'<GetFunc>b__0'
看完IL代码,你会发现 <GetFunc>b__0 这个方法实际就是匿名委托Func<int> 所指向的方法。 而 闭包所维护的上下文环境 其实就是 result 字段。
四、回顾
最后、我们再回头看一下第一个Demo:警惕闭包。
在这个demo中、循环变量 i 是闭包中的上下文环境之一(total也是),由于累加是在Task任务中进行的,Task任务什么时候被执行是由调度器和线程池两个因素决定的,并且task任务被执行的时间点往往会略有延迟,因此 循环变量 i的值 会被累加的过大,因此结果会偏大,所以结果是一个随机数 [45 ~ 100] .
随笔暂告一段落、下一篇随笔: 线程安全的集合(预计1篇随笔)
附,Demo : https://files.cnblogs.com/files/08shiyan/ParallelDemo.zip
参见更多:随笔导读:同步与异步
(未完待续...)