【问题标题】:How does this code have reference to the struct?这段代码如何引用结构?
【发布时间】:2017-09-05 21:31:40
【问题描述】:

我试过这段代码

struct Bar {
    public int Value;
}

async Task doItLater(Action fn) {
    await Task.Delay(100);
    fn();
}
void Main() {
    Bar bar = new Bar { Value = 1 }; //Bar is a struct
    doItLater(() => {
        Console.WriteLine(bar.Value);
    }).Wait();
}

并得到输出1。现在这让我很困惑。我的逻辑如下

  • Bar 是一个结构。因此所有实例都应该存储在堆栈中
  • Task.Delay(100) 被命中时,执行线程完成,并请求TPL 稍后执行fn()
  • bar 存储在堆栈中,当我们在闭包中访问它时,该帧不应该存在。

那么我到底是怎么得到1的输出的呢?

【问题讨论】:

  • And therefore all instances should be stored on the stack 这是一个不正确的假设,因此由此得出的结论并不可靠。 bar is stored on the stack 也是虚假陈述。
  • 使用反编译器,如 Redgate Reflector 或 dotPeek 或 ILSpy,看看它的神奇之处。真的没那么复杂。
  • bar 存储在堆上,因为它参与了 closure,因为它是引用它的匿名函数。如果它没有将其存储在闭包中,则值 bar 将在 Main 退出时从内存中删除,这将对您的匿名函数造成严重问题。
  • @Servy 我认为structs 的实例存储在堆栈中?这不总是正确的吗?这是真的吗?
  • 一个简单的事实是,你看这个完全错误的方式。正确的思考方式是变量是存储,并且已知存储的寿命不超过方法激活,或者可能比方法激活的寿命更长。已知具有短生命周期的变量可以进入 短生命周期池,也称为“堆栈”。所有其他变量都必须放在堆上。不管变量是持有一个 int 还是对一个字符串的引用,这都是正确的。

标签: c# struct closures


【解决方案1】:

彼得的回答是正确的;总结:

  • 值类型不会“进入堆栈”。 变量已知其生命周期很短时进入堆栈。变量的类型无关;当变量的生命周期不短时,包含 int 的变量会进入堆。如果已知其生命周期很短,则包含对字符串的引用的变量会进入堆栈。

  • await 不会“终止线程”。 async-await 的全部意义在于它确实需要另一个线程!异步等待的重点是在等待异步操作完成的同时继续使用当前线程。如果您认为 await 与线程有任何关系,请阅读“没有线程”。没有。

但我想解决您关于堆栈的基本错误作为延续的具体化

什么是延续?这只是一个花哨的词,表示“在这一点上,这个程序接下来要做什么?”

在普通代码中——没有等待、没有产量、没有 lambda、没有什么花哨的——事情非常简单。当你有:

y = 123;
x = f();
g(x);
return y;

f 的延续是“为 x 赋值并将其传递给 g”,而 g 的延续是“运行我现在正在使用的任何方法的延续,为其赋予 y 的值”。

如您所知,我们可以通过使用堆栈来具体化普通程序中的延续。栈数据结构其实维护了三样东西:

  • 局部变量的状态——这是激活信息
  • 正常延续的代码地址——“返回地址”
  • 足够的信息在运行时计算异常延续;也就是说,“接下来会发生什么?”抛出异常时。

但是这个数据结构只是一个堆栈,因为函数激活在正常程序中逻辑上形成一个堆栈。函数 Foo 调用 Bar,然后 Bar 调用 Blah,然后 Blah 返回 Bar,Bar 返回 Foo。

现在让我们来讨论一下:

int y = 123;
Func<int> f = () => y;
return f;

现在本地 y 的值即使在当前方法返回后也必须保留,因为可能会调用委托。 因此 y 的生命周期比当前激活的生命周期长,并且不会进入堆栈

或者这个:

int y = 123;
yield return y;
yield return y;

现在 y 必须在迭代器块的 MoveNext 调用中保留,因此,y 必须是不在堆栈上的变量;它的生命周期很长,所以它必须放在堆上。但请注意,这比前一种情况更奇怪,因为方法的激活可以被 yield 暂停并被未来的 MoveNext 恢复。现在我们遇到了一个方法调用在逻辑上没有形成堆栈的情况,因此继续信息也不能再进入堆栈

await 也一样;再一次,我们遇到了一个方法可以暂停的情况,其他事情可以发生在同一个线程上,然后该方法会以某种方式从它停止的地方恢复。

Await 和 yield 都是称为 coroutines 的更通用功能的示例。一个普通的方法可以做三件事:抛出、挂起或返回。协程可以做第四件事:挂起,稍后恢复。

普通方法只是协程的一个特例;普通方法是不会挂起的协程。并且由于它们有这个限制,普通方法可以获得能够使用堆栈作为其延续语义的具体化的好处。 协程没有。由于协程的激活不形成堆栈,因此堆栈不用于它们的局部变量激活记录;它也不用于存储返回地址等后续信息。

您陷入了这样的陷阱:认为特殊情况(不暂停的例程)就是世界必须的样子,但事实并非如此。相反,非挂起方法是可以通过使用堆栈来存储其延续信息来优化的特殊方法。

【讨论】:

    【解决方案2】:

    您的分析不止一处未达标:

    •Bar 是一个结构。因此所有实例都应该存储在堆栈中

    这不是真的。值类型对象可以存储在堆栈中,但并非所有值类型都存储在堆栈中。请参阅 Eric Lippert 著名的 The Stack Is An Implementation Detail 文章。

    在您的示例中,bar 对象实际上没有存储在堆栈中,因为它是在您传递给doItNow() 的闭包中捕获的。编译器创建一个隐藏类,其中存储了bar 对象,并且该类在堆上分配。所以bar 对象本身也分配在堆上。

    •当 Task.Delay(100) 被命中时,该线程执行完毕,并请求 TPL 稍后执行 fn()。

    其实就是await 被命中的时候。简单地调用Task.Delay() 只不过是创建并启动一个新的Task,它将在100 毫秒内完成。直到await 被执行,doItLater() 方法才返回。这与 “那个执行线程”“完成” 不同。线程继续(在您的情况下,只要在doItLater() 方法返回的Task 对象上调用Wait())。

    •bar 存储在堆栈中,当我们在闭包中访问它时,该框架不应该存在。

    因为您调用了Wait(),即使bar 对象确实存储在堆栈中,当执行doItLater() 中的延续和fn() 委托调用时,该堆栈帧仍然存在被执行。在 Wait() 方法完成之前,Main() 方法无法返回,并且直到 doItLater() 任务完全完成(包括调用传递给它的 fn 委托)后才会返回。

    换句话说,即使我们忽略了您的其他误解,在这种情况下也不会出现问题,因为无论如何bar 对象仍然存在。

    【讨论】:

    • 好吧,很公平,我明白你所说的关于Task.Delay 和框架保持打开状态(我试图用new Thread() 来做到这一点,但我现在在 linqpad 中并且玩的不好)。然而,第 1 点仍然令人困惑,因为这表明 bar 实际上应该是包含函数中的 copy,对吗?然而我实际上可以 assignbar 并在闭包之外更改它的值......那么发生了什么? cmets 中的 JohnWu 暗示如果结构参与闭包,它将进入堆......这是真的吗?从那里经过的时候呢?
    • 可变值类型充满危险,因为很难跟踪您是在处理副本还是原始值。但是,在您的示例中,您永远不会在任何地方复制 bar 的值;如果您要修改bar 变量,无论是通过重新分配变量本身,还是通过分配bar.Value,那么任何使用bar 的代码都会看到这种变化。如果您将bar 的值复制到某个其他变量,然后修改那个 变量,那么bar 不会改变。这与堆栈/堆无关,与值类型与引用类型语义有关。
    • 对,但是你说闭包被编译成一个类。这意味着必须将bar 传递给该类。哪个应该不应该创建副本?是的,该副本将存储在堆上,但它仍然是一个副本。抱歉,我想使用反编译器,但就像我说的,我现在在 linqpad 前面,而且我在阅读 IL 方面不够熟练。
    • “这意味着必须将 bar 传递到该类中” -- 不,不是那个意思。这意味着bar 是该类的一部分。您在代码中使用bar 的任何地方,包括作为局部变量,都隐式使用了类的bar 成员。没有复制发生;通过捕获变量,局部变量被隐藏类中的字段替换为。如果变量被捕获,则根本不再有任何局部变量。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-04-19
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多