.net 编译器在堆上创建对象来引用堆栈上的局部变量?[副本]

im9ewurl  于 2023-08-08  发布在  .NET
关注(0)|答案(4)|浏览(94)

此问题在此处已有答案

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不是在堆栈中的每次迭代都会被赋予一个新的值吗?

9rygscc1

9rygscc11#

要调用StartNew,它需要提供一个委托;委托 * 只是 * 一个函数指针(和可选的对象引用);委托没有自动访问i的方法,所以编译器所做的就是将其重写为:

class CaptureState {
    public int i;
    public void TheMethod() {
         Console.WriteLine(i);
    }
}
var magic = new CaptureState();
for (magic.i = 0; magic.i < 10; magic.i++)
{
    Task.Factory.StartNew(magic.TheMethod);
}

字符串

  • 现在 * 您可以看到只有一个对象,并且i在所有worker之间共享。如果我们按照指示重写它,那么它会改变,因为捕获范围是由声明点处理的:
class CaptureState {
    public int toCaptureI;
    public void TheMethod() {
         Console.WriteLine(toCaptureI);
    }
}
for (int i = 0; i < 10; i++)
{
    var magic = new CaptureState();
    magic.toCaptureI = i;
    Task.Factory.StartNew(magic.TheMethod);
}


现在可以看到,每个捕获对象都独立于相关循环周期中的 value

agyaoht7

agyaoht72#

我仍然不明白,如果在堆上创建一个新的对象来保存toCaptureI的引用,那么toCaptureI在堆栈中的每次迭代都将被分配一个新的值,那么这不是和上面的错误代码一样吗?
编译器将创建特殊生成类型的新示例,存储循环的每次迭代的闭包以捕获局部循环变量,与使用循环迭代变量i时创建的单个示例相比。
你可以使用decompilation@sharplab.io。第一个会产生类似的结果(其中<>c__DisplayClass0_是编译器生成的类来存储闭包,<<Main>$>b__0是表示匿名lambda的方法):

<>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
<>c__DisplayClass0_.i = 0;
while (<>c__DisplayClass0_.i < 10)
{
    Task.Factory.StartNew(new Action(<>c__DisplayClass0_.<<Main>$>b__0));
    <>c__DisplayClass0_.i++;
}

字符串
而第二个将产生如下内容:

int num = 0;
while (num < 10)
{
    <>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
    <>c__DisplayClass0_.toCaptureI = num;
    Task.Factory.StartNew(new Action(<>c__DisplayClass0_.<<Main>$>b__0));
    num++;
}

jjjwad0x

jjjwad0x3#

这就像async-await工作原理一样,delegate只是一个指针,StartNew在i变为10之后执行,但是当你为StartNew使用await时,就像:

await Task.Factory.StartNew(() => Console.WriteLine(i));

字符串
其工作方式如下:(相同结果)

for (int i = 0; i < 10; i++)
{
    int toCaptureI = i;
    Task.Factory.StartNew(() => Console.WriteLine(toCaptureI));
}
Console.ReadLine();


它只显示了委托和流程工作方式

bqucvtff

bqucvtff4#

“i不是一个整数类型吗?它是一个驻留在堆栈上的值类型吗?“
不幸的是,这并不总是case。此外,编译器在幕后所做的事情比你想象的要多,这是因为闭包和委托的复杂性(又名Action),这会影响变量的存储方式。
通过访问https://sharplab.io/(或任何其他反编译器、ILDASM等)并将代码粘贴到那里,您可以更好地了解实际情况。C# decompile选项提供了以下内容:

public class Program
{
    [CompilerGenerated]
    private sealed class <>c__DisplayClass0_0
    {
        public int i;

        internal void <Main>b__0()
        {
            Console.WriteLine(i);
        }
    }

    public static void Main()
    {
        <>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
        <>c__DisplayClass0_.i = 0;
        while (<>c__DisplayClass0_.i < 10)
        {
            Task.Factory.StartNew(new Action(<>c__DisplayClass0_.<Main>b__0));
            <>c__DisplayClass0_.i++;
        }
        Console.ReadLine();
    }
}

字符串
正如你所看到的,“闭包”是编译器生成的类<>c__DisplayClass0_0,它包含变量i。这是所有内容都要查看的共享变量。
对比“固定”代码:

public class Program
{
    [CompilerGenerated]
    private sealed class <>c__DisplayClass0_0
    {
        public int toCaptureI;

        internal void <Main>b__0()
        {
            Console.WriteLine(toCaptureI);
        }
    }

    public static void Main()
    {
        int num = 0;
        while (num < 10)
        {
            <>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
            <>c__DisplayClass0_.toCaptureI = num;
            Task.Factory.StartNew(new Action(<>c__DisplayClass0_.<Main>b__0));
            num++;
        }
        Console.ReadLine();
    }
}


正如您所看到的,循环现在使用了一个独立变量num,它不受所有其他函数并发运行的影响。
我们如何在堆上创建一个对象,使这个对象包含一个对栈上本地i变量的内存引用?
参见Eric Lippert的解释here。需要注意的是,你不应该试图解决“堆栈”与“堆”的问题,因为我们并不总是完全控制“如何”存储东西。这只是理解编译器在幕后发生了什么。

相关问题