首页 > 解决方案 > 无堆栈 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

标签: c++asynchronousc++20c++-coroutine

解决方案


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

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

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

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

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

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

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

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

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

基本上在类 C 语言中堆栈协同程序的所有实现中,stackfull-ness 的一个也是唯一假定的“好处”是使用通常的基指针相对寻址来访问帧,push并且pop在适当的情况下,如此“简单” 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++ 协程,这些模式将会被发现和普及。


推荐阅读