【问题标题】:Are stackless C++20 coroutines a problem?无堆栈 C++20 协程有问题吗?
【发布时间】:2019-12-01 11:05:09
【问题描述】:

根据以下内容,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() 来避免通常用于协程创建的任何堆分配。

协程状态通过非数组分配在堆上 新的操作员。 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

【问题讨论】:

  • 堆栈式协程意味着“我分配了一个实体线程式堆栈”,而不是“我使用调用者的堆栈空间”。您混淆了两个独立的问题; stackful vs stackless,以及在自动存储中存储协程状态的能力。您对它的混淆程度使问题变得不连贯,因为大多数堆栈协同程序不能存在于其他人的堆栈中。同时对于 stackless,住在某人的自动存储中是合理的。
  • “嵌入式(非分配)生成器”部分在我看来很天真,好像它可能具有实际意义
  • @DavidLedger 所以,想象一下有人抱怨枪支管制。他们的抱怨将“无法控制射击的人”与“控制谁可以拥有枪支的规定”混为一谈。您正在使用相同的术语(无堆栈)混合两种不同的事物。确实,您的两个不同的事情都是我们可以讨论的有效问题,但是当您使用一个术语来指代两者并且似乎不理解它们是两个问题 真的很难沟通。
  • 更重要的是,您所说的两个不同的“stackful”问题彼此对立。堆栈上的协程(存储在创建者的自动存储中)不会堆满,因为协程通常没有空间拥有自己的堆栈。 stackful coroutines 表示协程有一个堆栈。几乎所有存在于其创建者的自动存储中的协程实现(堆栈上协程)都将是stackless
  • 我说“通常不会堆叠”,因为我见过 setjmp/longjmp 协程将父堆栈分成几块并共享它。但这是一个可怕的 hack,它并没有真正节省任何资源并产生其他问题;这只是一种将协程破解为不支持它们的语言的方法。

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


【解决方案1】:

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

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

  • co_awaitcan be nested just fine,其实这是一个常见的用例。

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

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

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

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

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

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

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

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

如果只有几个 couroutines - 没问题。如果您有成千上万的网络请求全部由堆栈式协程处理,并且使用轻量级网络堆栈不会施加垄断性能的开销,那么缓存未命中的性能计数器会让您哭泣。正如 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 绑定都将绑定到网络硬件和网络堆栈。

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

【讨论】:

  • 我认为区分 3 类协程实现很重要:stackful、stackless-on-heap、stackless-as-struct。您的回答非常彻底地涵盖了 stackful vs stackless-on-heap 的情况,但是它忽略了潜在的 stackless-as-struct 方法。 Stackless-as-struct 本质上是创建一个匿名类型(ala lambda),用于跨暂停点保存数据。例如,这是 Rust 为其 async/await 实现所采用的方法。我相信这种 C++ 方法存在声明/定义和 ABI 问题。
  • 据我了解,您所称的 stackless-as-struct 的问题在于,在所有编译器优化通过之后,您才知道结构的大小,并且这些优化通过需要知道每种类型有多大。编译器人员说修复它是不切实际的。因此,您需要使其足够大,以递归方式将每个局部变量存储在协程中,即使优化后的挂起点不需要大部分或所有这些变量。让每个协程都比必要的大得多的悲观情绪被认为比可能分配更糟糕。
  • @patstew: AFAIK,Rust 使用 stackless-as-struct 进行异步/等待实现,其编译器 (1) 并不神奇, (2) 主要将优化委托给 LLVM,因此运行优化是不必要;尽管它可能是可取的。我记得我把P1492R0 藏起来阅读并重新挖掘。stackless-as-struct 在论文中被命名为 Early Split,在第 8 页的底部,我们可以阅读...续.
  • @patstew: ...“虽然理论上这不是一个不可克服的挑战​,但它可能是对前端结构和两个编译器前端专家的重大重新设计工作( Clang, EDG) 表示这不是一种实用的方法。”因此,简短的回答似乎是,由于现有 C++ 编译器及其“直接”管道的技术债务,前端无法预期一些传统上由代码生成器处理的簿记信息所需的大小。我想知道为什么 lambda 没有问题,但我会接受 Richard Smith 关于 Clang 的话。
  • Lambdas 仅将捕获存储在对象中,并且您永远不想优化它们(例如,您可能希望延长对象的生命周期,即使它没有在 lambda 中直接引用)。协程必须为跨挂起点引用的所有局部变量分配空间,并且您希望存储尽可能少。我希望 rust 前端中的所有生命周期检查内容都对此非常有用(并且不是特别容易固定到 C++ 上)。
【解决方案2】:

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

关于动态分配

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

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

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

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

【讨论】:

  • 很好奇 - 为什么你说光纤更适合你的用例?是不是您通常预先分配它们,因此在纤程中使用本地状态以及围绕 C++ 编译器中的函数调用进行的所有优化要便宜得多? (如 RVO、NRVO、堆栈变量局部性等)
  • @Curious:我的用例涉及任务级编程,而不是单个函数。能够从任务中的任何位置暂停整个任务并稍后恢复它是很有用的。中间没有任何代码,无需了解暂停。这与分配无关;这是关于正在做的事情。
【解决方案3】:

无堆栈协程

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

堆栈式协程

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

【讨论】:

  • "无堆栈协程不能比它的调用者/创建者活得更久" 是的,他们可以。这就是他们的重点。
  • "激活记录不能驻留在线程的主堆栈中" 这也是不正确的。根据无堆栈协程的使用方式,“激活记录”可能确实存在于该线程的堆栈中。
  • @Nicol Nolas:只有编译器可以证明线程堆栈上的激活记录不能被覆盖
  • 你说“不得”,意思是禁止。如果在某些情况下可以允许,那么“不得”是错误的。
  • 对。你想说什么?如果你只使用一个协程,并且它是内联的(因为它是一个简单的生成器),并且你没有在任何地方传递它......那么你的代码满足这个条件。而这正是省略是必要和有用的条件。 HALO 的重点不是省略每个协程;它允许在有用的地方省略。特别是生成器场景。
猜你喜欢
  • 2015-05-12
  • 1970-01-01
  • 2021-07-30
  • 2019-01-14
  • 1970-01-01
  • 2015-12-18
  • 2020-02-26
  • 2019-02-23
  • 1970-01-01
相关资源
最近更新 更多