基于以下内容,C ++ 20中的协程看起来像是无堆栈的。
https://en.cppreference.com/w/cpp/language/coroutines
我担心的原因有很多:
对于无堆栈协程,只有顶层例程可以 暂停。该顶级例程调用的任何例程可能本身都不是 暂停。这禁止在以下位置提供暂停/继续操作 通用库中的例程。
更多详细代码,因为需要自定义分配器和内存池。
如果任务等待操作系统为它分配一些内存(没有内存池),则速度会变慢。
基于这些原因,我真的希望我对当前的协程是错误的。
问题包括三个部分:
协程状态通过非数组分配在堆上 运算符new。 https://en.cppreference.com/w/cpp/language/coroutines
编辑:
我现在正在为协程进行cppcon讨论,如果我对自己的问题有任何答案,我将其发布(到目前为止没有任何内容)。
CppCon 2014:Gor Nishanov“等待2.0:无堆栈可恢复函数”
https://www.youtube.com/watch?v=KUhSjfSbINE
CppCon 2016:James McNellis“ C ++协程简介”
答案 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语言的语言中,堆栈协程的实现中,唯一且唯一的“堆栈满度”的“好处”是使用通常的基址相对寻址来访问帧,以及push
和pop
的适当位置,因此“普通” C代码可以在此组合堆栈上运行,而无需更改代码生成器。但是,没有基准支持这种思维方式,如果您有大量的协同程序活跃-如果它们数量有限,这是一个很好的策略,并且您有浪费的存储空间。
堆栈必须被过度分配,从而降低引用的位置:典型的堆栈式协程至少要使用整个页面作为堆栈,并且使该页面可用的成本不会与其他任何东西共享:单个协程必须承担这一切。因此,值得为多人游戏服务器开发无堆栈python。
如果仅存在一些couroutine,则没问题。如果您有成千上万的网络请求全部由堆栈式协程处理,而轻型网络堆栈又没有强加性能的开销,那么缓存未命中的性能计数器会让您大哭。正如Nicol在另一个答案中指出的那样,协程和它所处理的任何异步操作之间的层越多,这种关联就越不重要。
很久以来,任何32位以上的CPU都具有通过任何特定寻址模式进行内存访问所固有的性能优势。重要的是缓存友好的访问模式,并利用预取,分支预测和推测性执行。分页内存及其后备存储只是缓存的另外两个级别(台式机CPU上为L4和L5)。
C ++为什么会选择使用无堆栈协程?因为它们的性能更好,而且没有更差。在性能方面,只能给他们带来好处。因此,仅使用它们就可以了,从性能角度来讲,这是理所当然的。
我可以使用alloca()来避免通常用于协程创建的任何堆分配。否。这将是一个不存在的问题的解决方案。堆栈式协程实际上并不会在现有堆栈上分配:它们会创建新的堆栈,并且默认情况下会在堆上分配这些堆栈,就像C ++协程框架(默认情况下)一样。
我对c ++协程的假设是错误的,为什么?参见上文。
更多的冗长代码,因为需要自定义分配器和内存池。如果您希望堆栈协程表现良好,您将做同样的事情来管理内存区域。堆,事实证明,这甚至更难。您需要最大程度地减少内存浪费,因此需要在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)
无堆栈协程
完整的协程