stackless C++20协程是个问题吗?

Are stackless C++20 coroutines a problem?

根据以下内容,C++20 中的协程似乎是无堆栈的。

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

我担心的原因有很多:

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

With a stackless coroutine, only the top-level routine may be suspended. Any routine called by that top-level routine may not itself suspend. This prohibits providing suspend/resume operations in routines within a general-purpose library.

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() 来避免通常用于协程创建的任何堆分配吗?

coroutine state is allocated on the heap via non-array operator new. https://en.cppreference.com/w/cpp/language/coroutines

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

编辑:

我现在正在讨论协程的 cppcon 会谈,如果我找到我自己的问题的任何答案,我会 post 它(到目前为止没有)。

CppCon 2014:戈尔·尼山诺夫 "await 2.0: Stackless Resumable Functions"

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

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

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

我在小型 hard-realtime ARM Cortex-M0 目标上使用无堆栈协程,具有 32kb RAM,其中根本没有堆分配器:所有内存都是静态预分配的。 stackless 协程是 make-or-break,而我之前使用的 stackful 协程很难正确使用,并且本质上是完全基于 implementation-specific 行为的 hack。从那个烂摊子到 standards-compliant,可移植的 C++,真是太棒了。一想到有人会建议回去,我就不寒而栗。

  • Stackless 协程并不意味着使用堆:您对协程框架的分配方式有 full control(通过承诺类型中的 void * operator new(size_t) 成员)。

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

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

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

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

    现在,不要求编译器实际执行此省略,但 AFAIK 那里的所有实现都这样做,对“证明”的复杂程度有一些理智的限制 - 在某些情况下,这不是一个可确定的问题( IIRC).此外,很容易检查编译器是否按预期执行:如果您知道所有具有特定 promise 类型的协程都是 nested-only(在小型嵌入式项目中合理,但不仅如此!),您可以声明 operator new 在 promise 类型中但不定义它,如果编译器“搞砸了”,代码将不会 link。

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

    在调用者 return 之后无法使用使用 alloca 分配的内存。 实际上,alloca 的用例是一个表达 gcc variable-size 自动数组扩展的更便携的方式。

在 C-like 语言中的所有堆栈协同程序的实现中,唯一的 假设 stackfull-ness 的“好处”是访问框架使用通常的 base-pointer-relative 寻址,并在适当的地方使用 pushpop,因此“普通”C 代码可以 运行 在此 made-up 堆栈上,而无需更改代码生成器。但是,如果您有很多协程处于活动状态,则没有基准测试支持这种思维模式 - 如果它们的数量有限,那么这是一个很好的策略,并且您有足够的内存来开始。

堆栈必须过度分配,减少引用的位置:一个典型的堆栈协程至少使用一个完整的页面作为堆栈,并且使这个页面可用的成本不与其他任何东西共享:单个协程有承受这一切。这就是为什么为多人游戏服务器开发无堆栈 python 是值得的。

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

很久以来,任何 32+ 位 CPU 都具有通过任何特定寻址模式访问内存所固有的性能优势。重要的是 cache-friendly 访问模式和利用预取、分支预测和推测执行。分页内存及其后备存储只是另外两级缓存(桌面上的 L4 和 L5 CPUs)。

  1. 为什么 C++ 会选择使用无堆栈协程? 因为它们性能更好,不会更差。在性能方面,对他们来说只有好处。所以这是一个no-brainer,performance-wise,只是使用它们。

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

  3. 我对 c++ 协程的假设是错误的吗?为什么? 见上文。

  4. 更冗长的代码,因为需要自定义分配器和内存池。如果你想让堆栈协程表现良好,你会做同样的事情管理堆栈的内存区域的事情,事实证明它更难。您需要最大限度地减少内存浪费,因此您需要为 99.9% 的用例最大限度地过度分配堆栈,并以某种方式处理耗尽该堆栈的协程。

    我在 C++ 中处理它的一种方法是在代码分析表明可能需要更多堆栈的分支点进行堆栈检查,然后如果堆栈溢出,则抛出异常,协程的工作被撤消(系统的设计必须支持它!),然后使用更多堆栈重新开始工作。这是一种快速失去紧凑 stack-fuls 优势的简单方法。哦,我必须提供我自己的 __cxa_allocate_exception 才能工作。好玩吧?

还有一个轶事:我正在 Windows kernel-mode 驱动程序中使用协同程序,那里的无堆栈确实很重要 - 如果硬件允许,您可以分配数据包buffer 和 coroutine 的 frame 放在一起,这些页面在提交给网络硬件执行时被固定。当中断处理程序恢复协程时,页面就在那里,如果网卡允许,它甚至可以为您预取它,这样它就会在缓存中。所以效果很好——这只是一个用例,但既然你想要嵌入——我已经嵌入了:)。

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

最后:相信基准,而不是我,也请阅读 Nicol 的回答!。我的观点是由我的用例决定的——我可以概括,但我声称没有 first-hand 在性能不太受关注的“通才”代码中使用协程的经验。无堆栈协程的堆分配在性能跟踪中通常很难注意到。在 general-purpose 应用程序代码中,这很少会成为问题。它在库代码中确实变得“有趣”,并且必须开发一些模式以允许库用户自定义此行为。随着越来越多的库使用 C++ 协程,这些模式将会被发现并得到推广。

转发:当这个 post 只说 "coroutines" 时,我指的是协程的 概念 ,而不是特定的 C++20 功能.在谈到这个功能时,我将其称为“co_await”或"co_await coroutines"。

关于动态分配

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

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

co_await 作为一项功能是 well-designed 到 删除 任何 co_await-able 对象和功能的使用点的冗长。 co_await 机器非常复杂和错综复杂,多种类型的对象之间存在大量交互。但是在 suspend/resume 点,它 总是 看起来像 co_await <some expression>。为您的可等待对象和承诺添加分配器支持需要一些冗长,但冗长存在于使用这些东西的地方之外。

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

alloca 不适合那个特定的用例,因为协程的调用者是 allowed/encouraged 去做任何事情,以便其他线程可以生成该值。因此 alloca 分配的 space 将不复存在,这对其中的协程来说有点不好。

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

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

co_await 支持这种情况(尽管 co_yield),但它以 less-than-optimal 的方式这样做,至少在标准方面是这样。因为该功能是为 up-and-out 暂停而设计的,将其变成 suspend-down 协程具有这种动态分配的效果,不需要是动态的。

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

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

它是如何进入标准的

简而言之,它进入标准是因为它背后的人付出了努力,而替代方案背后的人却没有。

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

记住 what happened the last time "sounds implementable" 被用作采用功能的基础。

如果您不知道某件事可以实施,您就不想对其进行标准化。如果您不知道它是否真正解决了预期的问题,您就不想将其标准化。

Gor Nishanov 和他在 Microsoft 的团队致力于实施 co_await。他们这样做了 ,改进了他们的实施等。其他人在实际生产代码中使用了他们的实现,并且似乎对其功能非常满意。铿锵连我放开它。尽管我个人不喜欢它,但不可否认的是 co_await 是一个 成熟的 功能。

相比之下,一年前作为与 co_await 的竞争想法提出的 "core coroutines" 备选方案未能获得关注 in part because they were difficult to implement。这就是 co_await 被采用的原因:因为它是人们想要的经过验证、成熟且可靠的工具,并且具有改进代码的能力。

co_await 并不适合所有人。就我个人而言,我可能不会经常使用它,因为光纤更适合我的用例。但它非常适合其特定用例:up-and-out 暂停。

无堆栈协程

  • stackless coroutines (C++20) 进行代码转换(状态机)
  • stackless 在这种情况下意味着应用程序堆栈不用于存储局部变量(例如算法中的实例变量)
  • 否则stackless协程挂起后普通函数调用会覆盖stackless协程的局部变量
  • stackless 协程也需要内存来存储局部变量,尤其是当协程挂起时,需要保留局部变量
  • 为此,stackless 协程分配并使用 so-called 激活记录(相当于堆栈帧)
  • 仅当其间的所有函数也是无堆栈协程时才可能从深度调用堆栈挂起(病毒式;否则你会得到一个损坏的堆栈)
  • 一些 clang 开发人员怀疑 堆分配 eLision 优化 (HALO) 始终可以应用

堆栈协程

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