.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

参见更多:随笔导读:同步与异步


(未完待续...)

 

posted @ 2017-03-07 11:00  把爱延续  阅读(822)  评论(0编辑  收藏  举报