堆栈结构是否用于异步进程?


10

埃里克·利珀特(Eric Lippert)对该问题有一个很好的回答,描述了堆栈的用途。我已经了解了一年(通常来说)是什么堆栈以及如何使用堆栈,但是他的部分回答让我怀疑这种堆栈结构在当今以异步编程为标准的情况下是否被较少使用。

从他的回答:

堆栈是在没有协程的语言中延续性的一部分。

具体来说,没有协程的部分让我感到奇怪。

他在这里解释了更多:

协程是可以记住它们所在位置的功能,可以暂时控制另一个协程的功能,然后在稍后中断的位置恢复,但不一定是在所谓的协程产量之后立即执行。想想C#中的“收益回报”或“等待”,它们必须记住当请求下一项或异步操作完成时它们在哪里。具有协程或类似语言功能的语言需要比堆栈更高级的数据结构才能实现延续。

就堆栈而言,这是极好的选择,但是当堆栈太简单以至于无法处理需要更高级数据结构的这些语言功能时,我将不得不回答的问题是什么?

随着技术的进步,堆栈会消失吗?用什么代替它?它是混合类型的东西吗?(例如,.NET程序是否使用堆栈,直到遇到异步调用,然后切换到其他结构,直到完成,此时堆栈才退回到可以确定是否有下一个项目的状态,依此类推? )

这些场景对于堆栈而言太高级了,这是很合理的,但是什么能代替堆栈呢?几年前,当我了解到这一点时,堆栈就在那里了,因为它闪电般快速且轻巧,因为它支持对即将完成的任务进行高效管理(双关语?),所以它在应用程序中从堆分配了一块内存。有什么变化?

Answers:


14

它是混合类型的东西吗?(例如,.NET程序是否使用堆栈,直到遇到异步调用,然后切换到其他结构,直到完成,此时堆栈才退回到可以确定是否有下一个项目的状态,依此类推? )

基本上是。

假设我们有

async void MyButton_OnClick() { await Foo(); Bar(); }
async Task Foo() { await Task.Delay(123); Blah(); }

这是对连续化方式进行极简化的解释。实际的代码要复杂得多,但这可以使想法完整。

您单击按钮。一条消息排队。消息循环处理消息并调用单击处理程序,从而将消息队列的返回地址放在堆栈上。也就是说,在处理程序完成之后发生的事情是消息循环必须继续运行。因此,处理程序的延续就是循环。

单击处理程序调用Foo(),将其自身的返回地址放在堆栈中。也就是说,Foo的延续是单击处理程序的其余部分。

Foo调用Task.Delay,将其自身的返回地址放在堆栈中。

Task.Delay会执行立即返回Task所需的所有魔术。堆栈弹出,我们回到Foo中。

Foo检查返回的任务以查看其是否完成。它不是。在连续的中的await是调用布拉赫(),所以富创建这就要求布拉赫()的委托,和迹象表明,委托了作为任务的延续。(我只是做了一个小小的错误陈述;您是否抓住了它?如果没有,我们将在稍后揭露。)

然后,Foo创建自己的Task对象,将其标记为未完成,然后将其返回堆栈到Click处理程序中。

点击处理程序检查Foo的任务,发现它不完整。处理程序中await的继续是调用Bar(),因此单击处理程序将创建一个委托,该委托调用Bar()并将其设置为Foo()返回的任务的继续。然后,它将堆栈返回到消息循环。

消息循环继续处理消息。最终,由延迟任务创建的计时器魔术完成了它的工作,并向队列中发布了一条消息,表明现在可以执行延迟任务的继续。因此,消息循环调用任务继续,将其照常放入堆栈中。该代表调用Blah()。Blah()执行其操作并返回堆栈。

现在会发生什么?这有点棘手。延迟任务的继续不仅会调用Blah()。 它也必须触发对Bar()的调用,但是该任务对Bar不了解!

Foo 实际上创建了一个委托,该委托(1)调用Blah(),而(2)调用Foo创建的任务的继续并将其交还给事件处理程序。这就是我们所谓的调用Bar()的委托的方式。

现在,我们已经按照正确的顺序完成了所有需要做的事情。但是,我们从未停止在消息循环中处理消息很长时间,因此应用程序仍保持响应状态。

这些场景对于堆栈而言太高级了,这是很合理的,但是什么能代替堆栈呢?

任务对象图,其中包含通过委托的闭合类相互引用。这些闭包类是状态机,它们跟踪最近执行的等待位置和本地值。另外,在给出的示例中,由操作系统和执行这些操作的消息循环实现的操作的全局状态队列。

练习:您如何假设这一切在没有消息循环的世界中有效?例如,控制台应用程序。在控制台应用程序中等待是完全不同的;您能从到目前为止的知识中推断出它是如何工作的吗?

几年前,当我了解到这一点时,堆栈就在那里了,因为它闪电般快速且轻巧,因为它支持对即将完成的任务进行高效管理(双关语?),所以它在应用程序中从堆分配了一块内存。有什么变化?

当方法激活的有效期形成堆栈时,堆栈是有用的数据结构,但在我的示例中,单击处理程序Foo,Bar和Blah的激活不形成堆栈。因此,代表工作流程的数据结构不能是堆栈。相反,它是代表工作流的堆已分配任务和委托的图形。等待是工作流中的点,在这些点上,直到较早开始的工作完成,才能在工作流中进一步取得进展。在我们等待,我们可以执行其他的,它的工作依赖于在完成这些特定的启动任务。

堆栈只是一个框架数组,其中框架包含(1)指向函数中间(调用发生的位置)的指针和(2)局部变量和临时变量的值。任务的延续是同一回事:委托是指向函数的指针,并且它具有一个状态,该状态引用函数中间的特定点(发生等待的位置),并且闭包具有用于每个局部变量或临时变量的字段。框架不再构成一个很好的整洁数组,但是所有信息都是相同的。


1
非常有帮助,谢谢。如果我可以将两个答案都标记为可接受的答案,我
会这样做

3
@ jdl134679:如果您认为自己的问题已得到回答,我建议您将某些内容标记为答案;这发出了一个信号,人们要想一个好答案而不是一个答案,就应该来这里。(当然,鼓励总是写好的答案。)我不在乎谁打勾。
埃里克·利珀特

8

阿佩尔(Appel)写道,旧的纸质垃圾收集可以比堆栈分配更快。还请阅读他的“ 使用延续进行编译”书和垃圾收集手册。某些GC技术非常直观(非常直观)。在延续传递风格定义了一个规范的整个程序变换(CPS的转变)摆脱栈(概念上堆分配更换调用帧的封锁,换句话说“reifying”单个呼叫帧作为单独的“价值”或“对象” )。

但是调用栈仍然非常广泛地使用,并且当前的处理器具有专用于调用栈的专用硬件(堆栈寄存器,缓存机制等)(之所以如此,是因为大多数低级编程语言(尤其是C)更容易实现)使用调用堆栈来实现)。还要注意,堆栈是缓存友好的(这对性能非常重要)。

实际上,调用堆栈仍在这里。但是我们现在有很多,有时调用堆栈被分成许多较小的段(例如,每个4KB的几页),这些段有时被垃圾回收或分配了堆。可以将这些堆栈段组织在某个链接列表中(或在需要时使用更复杂的数据结构)。例如,GCC编译器一个-fsplit-stack选项(对Go及其“ goroutines”和“异步进程”特别有用)。使用拆分堆栈,您可以有成千上万个由数百万个小型堆栈段组成的堆栈(并且协例程更容易实现),并且“展开”堆栈可能会更快(或至少与单块内存一样快)堆)。

(换句话说,堆栈和堆之间的区别是模糊的,但是可能需要整个程序转换,或者不兼容地更改调用约定和编译器)

另请参见thisthat和许多讨论CPS转换的论文(例如this)。另参阅有关ASLRcall / cc的信息。阅读(&STFW)有关延续的更多信息。

由于许多务实的原因,.CLR和.NET实现可能没有最新的GC和CPS转换。这是与整个程序转换有关的权衡(以及使用低级C例程的简便性,并具有使用C或C ++编码的运行时)。

Chicken Scheme正在通过CPS转换以非常规的方式使用机器(或C)堆栈:每次分配都在堆栈上发生,并且当分配太大时,世代GC复制和转发步骤就会移动最近的堆栈分配值(并且可能当前延续)到堆,然后将堆大幅度减少setjmp


阅读SICP编程语言实用程序,《龙书》,《Lisp成小块》


1
非常有帮助,谢谢。如果我可以将两个答案都标记为可接受的答案,我
会这样做
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.