Stackful协程和Stackless Resumable函数

背景

在2014年6月的Rapperswil,Nat Goodspeed和Oliver Kowalke向SG1提出了在c++中加入协程的建议。Niklas Gustafsson提出了在c++中添加断点函数的建议。第1研究组指示我们将这些方案放在一个单一的概念空间中,以便尽可能地将它们统一起来。
Nat, Oliver, Niklas和Torvald Riegel在Rapperswil上详细讨论了这个概念空间。Nat和Gor Nishanov在雷德蒙德进一步进行了讨论。本文试图回应委员会的要求。

概述

Coroutine

N3985提出了一个c++协程库。编码器实例化一个asymmetric_coroutine<t>::pull_type,传递一个可调用对象。该可调用对象接受一个非const引用到补充库配套的 asymmetric_coroutine<t>::push_type; 这个push_type对象上的方法切换上下文并传输数据。(这个主题有很多变化,但就目前的目的而言,这个例子就足够了。)
普通函数可以从函数体的中间向调用者返回一个值。
这些协程的附加特性是,调用者可以将控制权传递回函数体中间最后一次挂起执行的位置。
N3985中提出的协程故意是有栈的。作者断言,这类协程的大部分价值在于它们的有栈特性。

Resumables

N3858、N3977和D4134提出了支持可恢复(resumable)函数的新语言。不管编译器是否需要一个关键字来识别这样的函数,一个可恢复函数的特征是它的函数体中至少存在一个await关键字。(还有关于“yield”关键字的讨论,但目前我们只考虑“await”。)
可恢复函数向调用者返回一个潜在值(future)。与future一样,在调用方收到它的时候,它可能持有一个值,也可能不持有。这就是所谓的“returned” 的future。(该建议还支持其他返回类型,但为了简单起见,我们将讨论future。)
函数体中的’ return ‘语句为返回的future填充一个值,然后退出函数。
函数体中的异常向返回的future填充一个异常。
函数体中的await表达式引入了对其他显式指定的future的依赖。这就是“awaited”的future。如果所等待的future已经持有一个值,’ await ‘将传递所等待的future的值并继续执行函数体。但是,如果等待的未来仍然悬而未决,则每当等待的future填充一个值时,’ await ‘就会安排重新获得控制权。’ await ‘然后将一个待处理的future返回给它的调用者。在稍后的某个时间点,等待的future将被填充;这将导致在挂起函数的await处重新进入可恢复函数。它将从那个点开始,最终执行一个’ return ‘语句或另一个’ await '语句——或者抛出一个异常。
虽然N3858同时考虑了可恢复函数的有栈和无栈实现,但D4134的作者断言,可伸缩性的设计目标需要无栈实现。

暂停(Suspension)

正如Torvald所指出的,N3985协程和D4134可恢复函数的共同特性是挂起操作: 控制可以从函数体中传递出去,这样以后就可以在同一点上恢复。

语义

到目前为止,大多数关于将协程与可恢复函数统一的讨论很快就进入了实现的领域。对于本文的大部分内容,我们努力停留在必需的语义领域。在标准的许多情况下,不同的实现可以产生相同的语义行为;这里我们必须考虑语义本身是如何分歧的。

N3985 coroutine

typedef asymmetric_coroutine<int> coro_t; 
void coro(coro_t::pull_type& source) 
{
 before_suspend();
 source();
 int value = source.get();
 after_suspend();
} 

D4134 resumable function

future<int> somefunc();
future<int> resumable()
{
 before_suspend();
 int value = await somefunc();
 after_suspend();
 return value;
}

很明显,在入口时,coro()resumable()都会调用before_suspend()。对source()的调用无条件地挂起coro();我们假定,至少在某些情况下,somefunc()需要等待某些外部资源,因此将挂起resumable()。最后,在恢复时,coro()resumable()都会调用after_suspend()。这些就是相似之处。
关键的区别在于每个函数的挂起方式。

为了便于讨论,让我们将函数调用链看作是向下增长的。也就是说,程序的main()堆栈帧位于顶部。由main()调用的函数foo()的堆栈帧低于main的; foo()调用的函数bar()的函数值都在它们下面。

Stackless Suspension

调用者的责任

无栈可恢复函数必须通过向上传递控制来挂起。也就是说,从根本上说,函数必须以某种方式将控制权返回给它的调用者。这不是实现细节; 它是“无栈”的本质所要求的。称之为实现上的约束。
这意味着调用方有义务区分普通返回(有值)和暂停(还没有值)。考虑一个可恢复函数链A→B→C。在这两种情况下,B的行为肯定非常不同。如果C暂停,B也必须暂停; 当C返回一个值时,B可以继续执行它自己的函数体。

resume可以通过几种方式将这种区别传达给它的调用者。例如:

  1. 可恢复函数可以通过抛出异常挂起。调用者可以捕获它,更新它自己的恢复信息,然后将异常重新抛出给它的调用者。可恢复函数的正常返回意味着值是可用的。这具有一定的直接吸引力,尽管可能会对性能产生不利影响。
  2. 可恢复函数可以返回可能包含值也可能不包含值的类型,例如std::future。现有技术使用了这种策略,利用std::future::then()在填充了等待的future之后,将控制权传递回挂起的可恢复函数。
  3. 可恢复函数可以接受一个非常量引用形参,通过它向调用者传递挂起差异。这与前面的要点非常相似,因此我们将合并这两者。
  4. 可恢复函数可以设置一些“全局”状态——更确切地说,在某个执行代理数据区域中的状态。

要点是: 不管实现细节如何,调用无栈可恢复函数的代码必须知道函数可能返回值,也可能不返回值。(然而,一个已经返回std::future的函数可以透明地与一个可恢复函数进行转换: 调用者不需要关心future是如何生成的。)

维护的影响

D4134向我们保证无栈可恢复函数可以无缝地调用现有代码、库和操作系统API,没有限制。
考虑一个大型的现有代码库,部分使用可恢复函数来管理异步I/O; 其他不需要挂起的部分是简单的c++ 14,它同步地返回值,而不是std::future

不幸的情况是,当我们发现,在以前从不需要挂起的代码中的调用树深处,我们引入了异步获取值的需求。当我们调用A→B→C→D→E→F; 而函数F必须得到新的值。
因此,我们必须更改F的签名,使其可恢复。
不仅是E,而且每个调用F的函数都必须被修改,使其本身成为可恢复的,并对F使用适当的调用。调用E、D、C、B或A的每个函数也是如此。
这种需要对本应是本地增量维护的内容进行大规模、普遍的更改的需求,是任何无栈协程实现的弱点。这对开发人员来说已经够糟糕的了,但对QA来说更是一场噩梦。

Stackful Suspension

一个有栈的协程通过向下传递控制挂起。也就是说,它执行它认为是完美的普通函数调用。它将挂起和恢复的实际机制委托给被调用的函数。这就是允许有栈协程的库实现的原因: 调用者像任何普通的c++函数一样编译,不知道在调用特定的其他函数时可能会发生魔术。
这种幸福的无知是可以传递的。使用堆栈挂起,函数的调用者同样不知道可能挂起的函数。它也是用普通的方式编译的。
的确,N3985协程接受一个特殊的协程参数,该参数的方法执行实际的挂起和数据传递,并且该参数通常在任何此类挂起涉及的每一级函数调用中传递。
然而,将指向“当前”协程对象的指针存储在线程本地存储中只是一个简单的步骤,从而消除了显式传递指针的需要。另一个小步骤引入了userland调度器,同样是通过线程本地指针找到的。这样的调度程序可以拥有协程对象的集合,识别“当前”对象并在就绪对象之间调度。
在任何一种情况下,运行在有栈协程上的函数都不需要以任何方式专门指定。编译器不需要为函数体或调用者生成特殊的代码。其签名不必反映可能需要暂停。
在我们假设的大型代码库中,如果函数F需要引入一个新的异步调用,那么F应该是唯一受影响的函数。

不相交的?

无栈挂起(向上)与有栈挂起(向下)之间的区别是它们之间的主要区别。我们还没有想到,也没有听说过,有一种方法可以统一有栈和无栈之间的语义脱节。

堆栈,处理器或模拟

但是在D4134和N3985之间有什么中间地带吗?
在这里,我们从纯语义转移到实现关注。

为了清晰起见,让我们将函数的“激活框架”称为查找函数返回地址、参数值、局部变量以及与当前激活调用相关的其他内容的地方。通常,激活帧是在处理器堆栈上找到的。稍后再详细说明。

N3985 Coroutine

N3985提出了有栈协程的库实现。这样的库必须依赖于整个程序的运行时堆栈实现。
一个典型的运行时堆栈实现需要一个足够大的连续内存区域,以容纳堆栈上最深的可能调用链的激活帧。对于数据驱动递归,通常不能静态地预测这个大小。一旦超出了堆栈空间,我们就进入了未定义行为的领域。最好的结果是程序崩溃;这可以通过附加一个保护页面,设置权限以禁止任何访问来强制实现。
对于超过预留堆栈空间的严重惩罚导致预留较大的默认堆栈区域。当然,现代操作系统可以根据需要惰性地将物理内存提交到堆栈中。
然而,由于堆栈必须占用一个连续的地址范围,即使物理提交的相对较少,整个地址范围也必须被保留。
D4134讨论了在32位地址空间中运行的高并发程序(大量不同的并发任务)所受的约束。它不需要太多的1MB堆栈就可以耗尽可用的地址。
D4134进一步指出,覆盖默认堆栈大小以指定小的连续堆栈需要编码器作出承诺,通常她不能遵守。
最新版本的Gnu c++编译器引入了拆分栈的概念。这可以减轻问题,但仍然可能太粗糙:D4134提到这个概念只是为了拒绝它。此外,Niklas指出,Windows内部验证明确禁止不连续的处理器堆栈。
事实上,Gor断言虚拟地址的消耗问题是有栈协程的主要障碍。虽然承认在某些方面它们比无栈的协程更强大、更灵活,但他对可伸缩性的要求感到遗憾,这使得它们无法在32位服务器上使用。

D4134可恢复函数

对于D4134可恢复函数,嵌套的挂起函数调用链在概念上类似于单个激活帧的链表,在堆上或在特定的内存池中分配。这样的框架在尺寸上是最小的。与4KB的页面不同,每个可恢复的函数在进入时都精确地为自己的激活帧分配所需的内存。这个帧是可恢复函数在挂起和恢复之间的唯一开销—这就是高可伸缩性的来源。
无栈可恢复函数在挂起时不消耗处理器堆栈,因为挂起涉及到返回到它的调用者。这满足了Windows关于连续处理器堆栈的约束: 栈帧的链表不同于处理器堆栈。

当D4134的可恢复函数被恢复时(例如通过一个等待的futurethen()方法),它会在顶部重新进入。这将创建一个新的处理器堆栈条目。生成的逻辑在它的小激活框架中询问函数的状态,并分派到函数体中适当的恢复点。从那里,每个代码路径都会导致函数以某种方式退出——再次删除它的处理器堆栈条目。
当函数最终返回一个结果时,这个结果被用来填充它返回的future
已填充future的函数可能会将控制权传递给另一个挂起的可恢复函数——它的原始调用者——反过来,该函数将被恢复。
这种策略模拟了处理器堆栈的行为,同时避免了处理器堆栈上的持久条目。

思想实验

在这个领域,编译器是一个公平的游戏: 我们不局限于一个库实现。如果编译器通过构造一个激活帧的链表来实现普通的函数调用和返回,最小化处理器堆栈的使用,会怎么样呢?
每个函数在进入时都将在堆上分配自己的激活帧。(D4134讨论了在这种情况下定制编译器选择的策略,例如用于堆栈帧的特定分配器。)从调用者已经构造的处理器堆栈条目中,函数prolog将把关键项弹出到新的激活帧中,特别是它的返回地址和参数,这些参数必须在对不透明函数的调用中持久化。在函数序言结束时,处理器堆栈指针将恢复到其调用者的值。
从这样的函数返回将从堆激活帧获取返回地址,释放该帧并跳回到调用者。
此外,这样的函数可以调用一个暂停操作,该操作将通过在返回之前交换帧指针来恢复其他一些激活帧链。
对于D4134来说,以这种方式编译的函数可以很容易地调用现有的库代码或操作系统api;它们将像往常一样使用处理器堆栈——在返回到调用函数时像往常一样删除。
可以指示编译器以这种方式编译整个翻译单元,也可以按照现有的先例接受#pragma指令。
想象一下,以这种方式编译的函数可能比D4134中提出的调度策略更简单,甚至可能更高效。它可以使用“stackful”范式挂起,也就是说,通过对一个不太普通的函数进行普通函数调用——而不是安排两种不同的返回方式。恢复不需要派到职能机构的适当位置;被调用的函数将简单地返回到适当的位置。

尽管如此,这种“有栈(stackful)”功能将使用堆分配的、最小大小的激活帧来实现D4134中的可伸缩性。参与函数不需要特殊的签名、特殊的调用代码或特殊的源注释,从而减轻了维护负担。

有人可能会问: 调用如此编译的A→B,然后B调用已为链接激活帧编译的C→D,然后D调用为此编译的E→F,然后F调用已为链接激活帧而编译的G→H,会有什么效果? 怎么看待暂停函数H呢?

显然,A、B、E和F像往常一样消耗处理器堆栈。我们要求整个调用链A→B→C→D→E→F→G→H在语义上与现在编译的所有调用链没有区别(暂且不考虑挂起)。
我们期望驻留在处理器堆栈上的最低级别的激活帧(在本例中为F)定义了一个边界,在这个边界之下,独立的链接激活帧协程可以协作地共享相同的内核线程。在这个例子中,我们忽略了C和D已经被编译为链接激活帧的事实:E和F的处理器堆栈帧渲染得没有意义。
以这种方式引入调试模式诊断来捕捉处理器堆栈的意外“锚定”似乎是完全合理的。

总结

D4134利用编译器支持来生成无栈协程。这保证了高可伸缩性,但以普遍的标记为代价: 花费巨大开销将代码移植到该环境。
N3985提出了有栈协程的库实现。这保证了更早的可用性(现在已经发布了实现!)以及更容易的维护负担——但代价是每个协程需要大量的保留地址范围,限制了可伸缩性。
编译器对“有栈”协程的支持似乎可以提供这两种方法的优点,利用针对D4134的原型技术。这本身并不是一个提案,只是一个建议,在提交任何一个现有提案之前,先探索设计空间。

原文

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值