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

时间:2019-07-23 11:45:32

标签: 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

3 个答案:

答案 0 :(得分:45)

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

  • 无栈协程并不意味着使用堆:您有full control的协程框架如何分配(通过诺言类型的void * operator new(size_t)成员)。

  • co_await can be nested just fine,实际上,这是一个常见的用例。

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

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

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

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

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

    从调用者返回后,无法使用分配有alloca的内存。实际上,alloca的用例是一种稍稍可移植的表示gcc变量的方式,大小自动数组扩展。

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

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

如果仅存在一些couroutine,则没问题。如果您有成千上万的网络请求全部由堆栈式协程处理,而轻型网络堆栈又没有强加性能的开销,那么缓存未命中的性能计数器会让您大哭。正如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绑定都将绑定到网络硬件和网络堆栈。

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

答案 1 :(得分:29)

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

关于动态分配

Cpreference有时使用比标准宽松的术语。 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的分配是合适的。

它如何成为标准的

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

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

记住what happened the last time“可实现的声音”被用作采用功能的基础。

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

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

与之形成对比的是,一年前与co_await竞争的想法提出的“核心协程”替代方案未能赢得关注in part because they were difficult to implement。这就是采用co_await的原因:因为它是人们想要的,经过验证的,成熟且完善的工具,并且具有改善代码的能力。

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

答案 2 :(得分:0)

无堆栈协程

  • 无堆栈协程(C ++ 20)执行代码转换(状态机)
  • 在这种情况下,stackless意味着应用程序堆栈不用于存储局部变量(例如算法中的变量)
  • 否则,在暂停无栈协程后,普通函数的调用将覆盖无栈协程的局部变量
  • 无堆栈协程也确实需要内存来存储局部变量,特别是如果协程被挂起,则需要保留局部变量
  • 为此目的,无堆栈协程分配并使用了所谓的激活记录(相当于堆栈框架)
  • 激活记录不得驻留在线程的主堆栈
  • 只有在两者之间的所有功能也是无堆栈协程时(病毒;否则,您会得到损坏的堆栈),才可以从深度调用堆栈中挂起
  • 无栈协程不能超过其调用者/创建者
  • 一些clang开发人员对堆分配eLision优化(HALO)始终可以应用
  • 表示怀疑

完整的协程

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