无堆栈 C 20 协程有问题吗?

新手上路,请多包涵

基于以下内容,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

原文由 David Ledger 发布,翻译遵循 CC BY-SA 4.0 许可协议

阅读 513
2 个回答

转发:当这篇文章只说“协程”时,我指的是协程的 _概念_,而不是特定的 C++20 特性。在谈到这个特性时,我将它称为“ co_await ”或“co_await coroutines”。

关于动态分配

Cppreference 有时使用比标准更宽松的术语。 co_await 作为特性“需要”动态分配;这种分配是来自堆还是来自静态内存块,或者分配提供者的任何事情。这种分配可以在任意情况下省略,但由于标准没有明确说明,您仍然必须假设任何 co_await 协程都可以动态分配内存。

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

co_await 作为一项功能经过精心设计,可以从任何 co_await 对象和功能的使用点 消除 冗长。 co_await 机器非常复杂和错综复杂,在多种类型的对象之间有大量的交互。但在暂停/恢复点,它 总是 看起来像 co_await <some expression> 。为您的可等待对象和承诺添加分配器支持需要一些冗长,但这种冗长存在于使用这些东西的地方之外。

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

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

另请注意,在这种情况下,分配性能通常会因其他考虑而相形见绌:通常需要线程调度、互斥锁和其他东西来正确安排协程的恢复,更不用说从任何异步获取值所需的时间了过程正在提供它。因此,在这种情况下,需要动态分配这一事实并不是真正需要考虑的问题。

现在,在 某些 情况下,就地分配是合适的。生成器用例适用于您想要暂停一个函数并返回一个值,然后从函数停止的地方开始并可能返回一个新值。在这些情况下,调用协程的函数的堆栈肯定仍然存在。

co_await 支持这样的场景(虽然 co_yield ),但它以一种不太理想的方式这样做,至少就标准而言。因为该功能是为上下挂起而设计的,所以将其变成一个挂起协程具有不需要动态的动态分配的效果。

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

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

它是如何进入标准的

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

任何协程的想法都是复杂的,并且总会有关于它们的可实施性的问题。例如,“ 可恢复功能”提案看起来很棒,我很想在标准中看到它。但是没有人真正在编译器中 实现 它。所以没有人能证明这实际上是你可以做的事情。哦,当然,这 听起来 可以实现,但这并不意味着它 是可以 实现的。

请记住 上次使用“听起来可实现”作为采用功能的基础时发生的事情

如果你不知道它可以被实施,你就不想标准化一些东西。如果你不知道它是否真的解决了预期的问题,你也不想标准化。

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

相比之下,一年前作为与 co_await 竞争的想法提出的“核心协程”替代方案未能获得关注 ,部分原因是它们难以实施。这就是为什么 co_await 被采用的原因:因为它是一种经过验证的、成熟的、可靠的工具,人们想要并且已经证明了改进代码的能力。

co_await 并不适合所有人。就我个人而言,我可能不会经常使用它,因为纤维对我的用例来说效果更好。但它非常适合其特定用例:上下悬挂。

原文由 Nicol Bolas 发布,翻译遵循 CC BY-SA 4.0 许可协议

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

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

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

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

  • 无栈协程实现可以省略帧分配,这样承诺的 operator new 根本不会被调用,而有栈协程总是为协程分配堆栈,无论是否需要,因为编译器无法帮助协程运行时省略它(至少不是在 C/C++ 中)。

  • 通过使用堆栈可以精确地省略分配,编译器可以证明协程的生命周期不会离开调用者的范围。这是您可以使用的唯一方法 alloca 。因此,编译器已经为您处理好了。多么酷啊!

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

可以将编译指示添加到特定的编译器实现中,以声明特定的协程框架不会转义,即使编译器不够聪明来证明它 - 我没有检查是否有人打扰编写这些,因为我的使用案例足够合理,编译器总是做正确的事情。

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

基本上在类 C 语言中堆栈协同程序的所有实现中,堆栈满的 唯一 假定“好处”是使用通常的基指针相对寻址访问帧,并且 pushpop 在适当的情况下,“普通”C 代码可以在这个组成的堆栈上运行,而无需更改代码生成器。但是,没有基准支持这种思维模式,但是,如果您有很多活动的协程 - 如果它们的数量有限,这是一个很好的策略,并且您有内存可以浪费开始。

堆栈必须被过度分配,降低了引用的局部性:一个典型的堆栈式协程至少使用一个完整的页面作为堆栈,并且使这个页面可用的成本不与其他任何东西共享:单个协程必须承担所有这些。这就是为什么值得为多人游戏服务器开发无堆栈 python 的原因。

如果只有几个 couroutines - 没问题。如果您有成千上万的网络请求全部由堆栈式协程处理,并且使用轻量级网络堆栈不会施加垄断性能的开销,那么缓存未命中的性能计数器会让您哭泣。正如尼科尔在另一个答案中所说的那样,协程与其正在处理的任何异步操作之间的层数越多,这就越不相关。

很久以来,任何 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++ 协程,这些模式将会被发现和普及。

原文由 Kuba hasn‘t forgotten Monica 发布,翻译遵循 CC BY-SA 4.0 许可协议

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题