无栈C ++ 20协程是否有问题?

Dav*_*ger 56 c++ asynchronous c++20 c++-coroutine

基于以下内容,C ++ 20中的协程看起来像是无堆栈的。

https://en.cppreference.com/w/cpp/language/coroutines

我担心的原因有很多:

  1. 在嵌入式系统上,堆分配通常是不可接受的。
  2. 在低级代码中,co_await的嵌套很有用(我不相信无栈协程允许这样做)。

使用无堆栈协程时,只有顶层例程可以被挂起。该顶级例程调用的任何例程本身都不会暂停。这禁止在通用库中的例程中提供挂起/恢复操作。

https://www.boost.org/doc/libs/1_57_0/libs/coroutine/doc/html/coroutine/intro.html#coroutine.intro.stackfulness

  1. 由于需要自定义分配器和内存池,因此代码更加详细。

  2. 如果任务等待操作系统为它分配一些内存(没有内存池),则速度较慢。

鉴于这些原因,我真的希望我对当前的协程是错误的。

问题分为三个部分:

  1. 为什么C ++选择使用无堆栈协程?
  2. 关于在无堆栈协程中保存状态的分配。我可以使用alloca()避免通常用于协程创建的任何堆分配。

协程状态是通过非数组运算符new在堆上分配的。 https://en.cppreference.com/w/cpp/language/coroutines

  1. 我对C ++协程的假设是错误的,为什么?

编辑:

我现在正在为协程进行cppcon讨论,如果我对自己的问题有任何答案,我将其发布(到目前为止没有任何内容)。

CppCon 2014:Gor Nishanov“等待2.0:无堆栈可恢复函数”

https://www.youtube.com/watch?v=KUhSjfSbINE

CppCon 2016:James McNellis“ C ++协程简介”

https://www.youtube.com/watch?v=ZTqHjjm86Bw

Rei*_*ica 54

我在具有32kb RAM的小型硬实时ARM Cortex-M0目标上使用了无堆栈协程,其中根本不存在堆分配器:所有内存都是静态预分配的。无堆栈协程是成败的,而我以前使用的堆栈协程很难使人正确,并且本质上是完全基于实现特定行为的黑客。从一团糟到符合标准的可移植C ++,真是太好了。我不禁以为有人可能会建议回去。

  • 无堆栈协程并不意味着使用堆:您可以完全控制协程框架的分配方式(通过void * operator new(size_t)promise类型的成员)。

  • co_await 可以很好地嵌套,实际上这是一个常见的用例。

  • 堆栈式协程也必须将这些堆栈分配到某个位置,这很讽刺,他们不能为此使用线程的主堆栈。这些堆栈可能是通过池分配器在堆上分配的,该池分配器从堆中获取一个块,然后对其进行细分。

  • 无栈协程实现可以消除帧分配,从而operator new根本不调用promise ,而有栈协程始终为协程分配堆栈,无论是否需要,因为编译器无法帮助协程运行时消除它(至少不在C / C ++中)。

  • 通过使用堆栈,编译器可以证明协程的寿命不会超出调用程序的范围,可以精确地消除分配。这是您可以使用的唯一方法alloca。因此,编译器已经为您处理了它。多么酷啊!

    现在,不要求编译器实际执行此操作,但是AFAIK那里的所有实现都可以执行此操作,并且对“证明”的复杂程度有一些理智的限制-在某些情况下,这不是一个可确定的问题(IIRC)。另外,很容易检查编译器是否按您的预期进行:如果您知道具有特定promise类型的所有协程都是仅嵌套的(在小型嵌入式项目中是合理的,但不仅如此!),您可以声明operator newpromise类型,但不能声明定义它,然后如果编译器“出错”,代码将不会链接。

    可以将杂注添加到特定的编译器实现中,以声明特定的协程框架不会逃逸,即使该编译器不够聪明也无法证明这一点-我没有检查是否有人打扰过这些,因为我使用了这种情况足够合理,编译器总是会做正确的事。

    从调用方返回后,无法使用分配给alloca的内存。alloca实际上,的用例是表达gcc可变大小的自动数组扩展的一种更便携的方式。

在类似C的lanaguage的所有堆栈协程的基本实现中,唯一和唯一的 “充满”的“好处”是使用通常的基指针相对寻址来访问帧,push并且pop在适当的情况下使用“纯” C代码可以在此组合堆栈上运行,而无需更改代码生成器。但是,没有基准支持这种思维方式,如果您有大量的协同程序活跃-如果它们数量有限,这是一个很好的策略,并且您有浪费的存储空间。

堆栈必须被过度分配,从而降低引用的位置:典型的堆栈式协程至少要使用整个页面作为堆栈,并且使该页面可用的成本不会与其他任何东西共享:单个协程必须承担全部。因此,值得为多人游戏服务器开发无堆栈python。

如果只有几个couroutines-没问题。如果您有成千上万的网络请求全部由堆栈式协程处理,并且轻量级的网络堆栈不会造成垄断性能的开销,则缓存未命中的性能计数器会让您大哭。正如Nicol在另一个答案中指出的那样,协程和它所处理的任何异步操作之间的层越多,这种关联就越不重要。

很长一段时间以来,任何32位以上的CPU都具有通过任何特定寻址模式进行内存访问所固有的性能优势。重要的是缓存友好的访问模式,并利用预取,分支预测和推测性执行。分页内存及其后备存储只是缓存的另外两个级别(台式机CPU上的L4和L5)。

  1. 为什么C ++选择使用无堆栈协程?因为他们的表现更好,而且也不差。在性能方面,只能给他们带来好处。因此,仅使用它们就可以轻松地实现性能。

  2. 我可以使用alloca()避免通常用于协程创建的任何堆分配。不,这将是一个不存在的问题的解决方案。堆栈式协程实际上不会在现有堆栈上分配:它们会创建新的堆栈,并且默认情况下会在堆上分配这些堆栈,就像C ++协程框架一样(默认情况下)。

  3. 我对C ++协程的假设是错误的,为什么?往上看。

  4. 由于需要自定义分配器和内存池,因此代码更加详细。如果要使堆栈协程性能良好,您将做同样的事情来管理堆栈的内存区域,事实证明,这甚至更加困难。您需要最大程度地减少内存浪费,因此需要在99.9%的用例中最小化整个堆栈,并以某种方式处理耗尽该堆栈的协程。

    我用C ++处理它的一种方法是在分支点进行堆栈检查,在分支点代码分析表明可能需要更多的堆栈,然后如果堆栈溢出,则抛出异常,协程的工作被撤销(系统的设计已经以支持它!),然后以更多的堆栈重新开始工作。这是一种快速失去紧密堆积的堆栈优势的简便方法。哦,我必须为此提供自己__cxa_allocate_exception的东西。好玩吗?

另一个轶事:我正在Windows内核模式驱动程序中使用协程,并且在那里的无堆栈性很重要-如果硬件允许,则可以一起分配数据包缓冲区和协程的框架,这些页面是提交给网络硬件执行时固定。当中断处理程序恢复协程时,页面就在那儿,如果网卡允许,它甚至可以为您预取它,以便将其保存在缓存中。因此效果很好-这只是一个用例,但是由于您想要嵌入-我已经嵌入了:)。

将台式机平台上的驱动程序视为“嵌入式”代码也许并不常见,但是我看到了很多相似之处,并且需要嵌入式思维方式。您想要的最后一件事是内核代码分配过多,尤其是如果它会增加每个线程的开销。一台典型的台式PC上有数千个线程,其中很多线程可以处理I / O。现在想象一下使用iSCSI存储的无盘系统。在这样的系统上,任何未绑定到USB或GPU的I / O绑定都将绑定到网络硬件和网络堆栈。

最后:相信基准,而不是我,也请阅读Nicol的答案!。我的观点是由用例决定的-我可以概括一下,但我不主张对性能不太关心的“通用”代码中的协程进行过第一手的了解。无堆栈协程的堆分配在性能跟踪中通常很少被注意到。在通用应用程序代码中,这很少会成为问题。它确实引起了库代码的“兴趣”,并且必须开发一些模式以允许库用户自定义此行为。随着越来越多的库使用C ++协程,将发现并推广这些模式。

  • Lambda仅将捕获存储在对象中,并且您永远都不想对其进行优化(例如,即使未在Lambda中直接引用该对象,您也可能希望延长其寿命)。协程必须为跨吊点引用的所有局部变量分配空间,并且您希望存储的空间尽可能少。我希望锈前端中的所有进行生命周期检查的东西都非常有用(并且并非特别容易连接到C ++)。 (4认同)
  • 我认为区分3种协程实现很重要:堆栈式,堆上无堆栈,结构式无堆栈。您的答案相当彻底地涵盖了堆栈与堆上无堆栈的情况,但是它没有考虑潜在的“结构上无堆栈”方法。Stackless-as-struct本质上是在创建一个匿名类型(ala lambda),该类型用于保存跨挂起点的数据。例如,这是Rust用于其异步/等待实现的方法。我相信对于C ++这种方法存在声明/定义和ABI的关注。 (3认同)
  • @patstew:...“虽然理论上这不是一个不可克服的挑战​,但它可能是对前端结构的重大重新设计​工作,并且两个编译器前端(Clang、EDG)的专家表示这这不是一个实用的方法。” 因此,简短的答案似乎是,由于现有 C++ 编译器及其“直接”管道的技术债务,前端无法预测传统上由代码生成器处理的某些簿记信息所需的大小。我想知道为什么 lambda 没有这个问题,但我会相信 Richard Smith 关于 Clang 的说法。 (3认同)
  • “*唯一*”?能够从任何函数中挂起,就像任何函数都可以执行线程同步一样,似乎(在某些情况下)比代码生成的细节具有更大的概念优势。 (3认同)

Nic*_*las 44

转发:当这篇文章只说“协程”时,我指的是协程的概念,而不是特定的C ++ 20功能。在谈论此功能时,我将其称为“ co_await”或“ co_await协程”。

关于动态分配

Cppreference有时使用比标准宽松的术语。co_await作为一项功能,“需要”动态分配;此分配是来自堆还是来自静态内存块,或者与分配提供者有关。可以在任意情况下忽略此类分配,但是由于标准并未明确说明它们,因此您仍然必须假定任何co_await协程都可以动态分配内存。

co_await协程确实具有供用户为协程状态提供分配的机制。因此,您可以将堆/空闲存储分配替换为您喜欢的任何特定内存池。

co_await因为一项功能经过精心设计,可以从任何co_await可行的对象和功能使用的角度消除冗长的内容。这种co_await机器极其复杂和复杂,几种类型的对象之间存在许多相互作用。但是在挂起/恢复点,它始终看起来像co_await <some expression>。向等待的对象和promise添加分配器支持需要一定的冗长性,但是冗长性生活在使用这些东西的地方之外。

alloca协程用于...对于大多数用途来说是非常不合适的co_await。尽管围绕此功能的讨论试图隐藏它,但事实是,co_await该功能是为异步使用而设计的。那是它的预期目的:停止某个函数的执行并安排该函数在另一个线程上的恢复,然后将最终生成的值扩展到某些可能与调用协程的代码有些距离的接收代码。

alloca不适合该特定用例,因为协程的调用者被允许/被鼓励去做任何事情,以便该值可以由其他线程生成。alloca因此,由分配的空间将不再存在,这对于其中居住的协程来说是一种不利。

还要注意,在这种情况下,分配性能通常会因其他因素而相形见::通常需要线程调度,互斥锁和其他东西来适当地调度协程的恢复,更不用说从异步获取值所花费的时间。过程正在提供它。因此,在这种情况下,实际上不需要考虑动态分配的事实。

现在,在某些情况下原地分配是合适的。生成器用例适用于您希望基本上暂停一个函数并返回一个值,然后在该函数停止的地方继续并可能返回新值的情况。在这些情况下,调用协程的函数的堆栈肯定仍然存在。

co_await支持这种情况(尽管co_yield),但至少在标准方面,它以一种并非最佳的方式来支持。由于该功能是专为上下悬挂式设计的,因此将其转变为向下悬挂的协程具有动态分配的效果,而该动态分配不需要是动态的。

这就是为什么该标准不需要动态分配的原因。如果编译器足够聪明以检测生成器的使用模式,则它可以删除动态分配,而只需在本地堆栈上分配空间。但是,这又是编译器可以做的,不是必须要做的。

在这种情况下,alloca基于分配是合适的。

它如何成为标准

简短的版本是它之所以成为标准,是因为它背后的人投入了工作,而替代方案的人却没有。

任何协程的想法都是复杂的,并且关于它们的可实施性总是存在疑问。例如,“ 可恢复功能 ”建议看起来很棒,我很希望在标准中看到它。但是实际上没有人在编译器中实现它。因此,没有人能证明这实际上是您可以做的事情。哦,可以肯定,这听起来可以实现,但这并不意味着它是可以实现的。

记住上一次使用 “可实现的声音”作为采用功能的基础时发生了什么

如果您不知道某些事情可以实现,则不想将其标准化。而且,如果您不知道某事物是否确实解决了预期的问题,那么您就不想对其进行标准化。

Gor Nishanov和他的Microsoft团队投入了实施工作co_await。他们这样做了很多,完善了实现方式等等。其他人在实际的生产代码中使用了其实现,并且对其功能似乎很满意。Clang甚至实现了它。尽管我个人不喜欢它,但不可否认的co_await是它是一个成熟的功能。

相比之下,一年前由于竞争性思想而提出的“核心协程”替代方案co_await未能获得广泛关注,部分原因是它们难以实施。这就是为什么co_await要采用它:因为它是人们想要的,成熟且可靠的工具,并且具有改进代码的能力。

co_await不适合所有人。就个人而言,我可能不会使用太多,因为光纤在我的用例中效果更好。但这对于它的特定用例非常有用:向上和向下悬挂。


xlr*_*lrg 5

无堆栈协程

  • stackless coroutines (C++20) 做代码转换(状态机)
  • 在这种情况下,无堆栈意味着应用程序堆栈不用于存储局部变量(例如算法中的变量)
  • 否则,在挂起无堆栈协程后调用普通函数会覆盖无堆栈协程的局部变量
  • 无堆栈协程也需要内存来存储局部变量,特别是如果协程被挂起,则需要保留局部变量
  • 为此,无堆栈协程分配并使用所谓的活动记录(相当于堆栈帧)
  • 只有当中间的所有函数也是无堆栈协程时,才可能从深度调用堆栈中挂起(病毒式传播;否则您会得到一个损坏的堆栈
  • 一些开发商铿锵是持怀疑态度堆分配优化省音(HALO)总是可以被应用

堆叠协程

  • 本质上,堆栈式协程只是简单地切换堆栈和指令指针
  • 分配一个像普通堆栈一样工作的侧堆栈(存储局部变量,为调用的函数推进堆栈指针)
  • 侧栈只需要分配一次(也可以被池化)并且所有后续的函数调用都很快(因为只推进栈指针)
  • 每个无堆栈协程都需要自己的激活记录 -> 在深度调用链中调用,必须创建/分配很多激活记录
  • 堆栈式协程允许从深度调用链中挂起,而中间的函数可以是普通函数(不是病毒式的
  • 堆栈式协程可以比它的调用者/创建者更长寿
  • 一个版本的天网基准测试产生了100 万个堆栈式协程,并表明堆栈式协程非常有效(性能优于使用线程的版本)
  • 尚未实现使用 stackless cooutiens 的天网基准测试版本
  • boost.context将线程的主堆栈表示为堆栈式协程/纤程 - 即使在 ARM 上
  • boost.context 支持按需增长堆栈(GCC 拆分堆栈)

  • “*无堆栈协程不能比其调用者/创建者寿命更长*”是的,他们可以。这就是他们的重点。 (4认同)
  • 正确的。你想说什么?如果您只使用一个协程,并且它是内联的(因为它是一个简单的生成器),并且您没有将其传递到任何地方......那么您的代码满足此条件。这“正是”省略是合理且有用的条件。HALO 的目的不是要消除*每个*协程;而是要消除*每个*协程。这是为了允许在有用的地方进行省略。特别是发电机场景。 (2认同)