此问题在此处已有答案:
Detailed Explanation of Variable Capture in Closures(1个答案)
6天前关门了。
我正在阅读一本书,其中显示了下面的例子:
for (int i = 0; i < 10; i++)
{
Task.Factory.StartNew(() => Console.WriteLine(i));
}
Console.ReadLine();
字符串
下面是作者的原话:
代码的明显意图是打印出从0到9的所有数字。它们不一定是按顺序的,因为您只是将工作发布到线程池基础设施,因此您无法控制任务的运行顺序。但是如果你运行代码,你很可能会在屏幕上看到十个10,原因在于关闭;编译器将不得不捕获局部变量i,并将其放置到堆上编译器生成的对象中,以便可以在每个λ中引用局部变量i。问题是,它什么时候创建这个对象?由于局部变量i在循环体外部声明,因此捕获点也在循环体外部。这导致创建单个对象来保存i的值,并且该单个对象用于存储i的每个增量。因为每个任务共享同一个闭包对象,所以当第一个任务运行时,主线程将完成循环,因此i现在是10。因此,所有创建的10个任务将打印出相同的i值,即10。
我不太理解“编译器必须捕获局部变量i并将其放入堆上编译器生成的对象”这一部分,i
不是一个整数类型,它是一个驻留在堆栈上的值类型,我们如何在堆上创建一个对象,使这个对象包含一个内存引用到堆栈上的局部i
变量?
作者提供了一个修复方法:
for (int i = 0; i < 10; i++)
{
int toCaptureI = i;
Task.Factory.StartNew(() => Console.WriteLine(toCaptureI));
}
Console.ReadLine();
型
我仍然不明白,如果在堆上创建一个新的对象来保存toCaptureI
的引用,toCaptureI
不是在堆栈中的每次迭代都会被赋予一个新的值吗?
4条答案
按热度按时间9rygscc11#
要调用
StartNew
,它需要提供一个委托;委托 * 只是 * 一个函数指针(和可选的对象引用);委托没有自动访问i
的方法,所以编译器所做的就是将其重写为:字符串
i
在所有worker之间共享。如果我们按照指示重写它,那么它会改变,因为捕获范围是由声明点处理的:型
现在可以看到,每个捕获对象都独立于相关循环周期中的 value。
agyaoht72#
我仍然不明白,如果在堆上创建一个新的对象来保存
toCaptureI
的引用,那么toCaptureI
在堆栈中的每次迭代都将被分配一个新的值,那么这不是和上面的错误代码一样吗?编译器将创建特殊生成类型的新示例,存储循环的每次迭代的闭包以捕获局部循环变量,与使用循环迭代变量
i
时创建的单个示例相比。你可以使用decompilation@sharplab.io。第一个会产生类似的结果(其中
<>c__DisplayClass0_
是编译器生成的类来存储闭包,<<Main>$>b__0
是表示匿名lambda的方法):字符串
而第二个将产生如下内容:
型
jjjwad0x3#
这就像async-await工作原理一样,delegate只是一个指针,StartNew在i变为10之后执行,但是当你为StartNew使用await时,就像:
字符串
其工作方式如下:(相同结果)
型
它只显示了委托和流程工作方式
bqucvtff4#
“i不是一个整数类型吗?它是一个驻留在堆栈上的值类型吗?“
不幸的是,这并不总是case。此外,编译器在幕后所做的事情比你想象的要多,这是因为闭包和委托的复杂性(又名
Action
),这会影响变量的存储方式。通过访问https://sharplab.io/(或任何其他反编译器、ILDASM等)并将代码粘贴到那里,您可以更好地了解实际情况。C# decompile选项提供了以下内容:
字符串
正如你所看到的,“闭包”是编译器生成的类
<>c__DisplayClass0_0
,它包含变量i
。这是所有内容都要查看的共享变量。对比“固定”代码:
型
正如您所看到的,循环现在使用了一个独立变量
num
,它不受所有其他函数并发运行的影响。我们如何在堆上创建一个对象,使这个对象包含一个对栈上本地i变量的内存引用?
参见Eric Lippert的解释here。需要注意的是,你不应该试图解决“堆栈”与“堆”的问题,因为我们并不总是完全控制“如何”存储东西。这只是理解编译器在幕后发生了什么。