C++20的无栈协程是否存在问题?

88

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

协程状态通过非数组operator new在堆上分配。 https://en.cppreference.com/w/cpp/language/coroutines

  1. 我的有关C++协程的假设是否错误,为什么?

编辑:

我正在查看有关协程的cppcon演讲,如果我找到任何答案,我会发布它(目前还没有)。

CppCon 2014:Gor Nishanov“await 2.0:无栈可恢复函数”

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

CppCon 2016: James McNellis “C++协程入门"

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


23
“Stackful协程”意味着“我分配一个完整的线程式堆栈”,而不是“使用调用者的堆栈空间”。您混淆了两个独立的问题:有堆栈(stackful)与无堆栈(stackless)以及在自动存储器中存储协程状态的能力。您对此的混淆使问题变得不连贯,因为大多数有堆栈(stackful)协程不能存在于其他人的堆栈中。与此同时,在无堆栈(stackless)情况下,存在于别人的自动存储器中是可行的。 - Yakk - Adam Nevraumont
3
“嵌入式(无分配)生成器”这个部分,以我的直觉来看,似乎具有实际的兴趣价值。” - jwimberley
3
假设有人抱怨枪支管制,他们的抱怨混淆了“不能控制开枪的人”和“规定谁可以拥有枪支”的概念。你用同一个术语(stackless)来混淆了两个不同的概念。虽然这两个问题都是我们可以讨论的有效问题,但当你使用一个术语来指代两个不同的问题,并且似乎不理解它们是两个问题时,交流变得非常困难。 - Yakk - Adam Nevraumont
14
而且,你所谈论的两个不同的“stackful”问题是相互对立的。储存在创建者自动存储器中的栈上协程(on-stack coroutine)一般 不会是 stackful,因为通常没有足够的空间来让协程拥有自己的堆栈。stackful 协程意味着协程有一个堆栈。几乎任何生存在创建者自动存储器中的协程实现(on-stack coroutine)都将是 无堆栈的。 - Yakk - Adam Nevraumont
3
我说“通常不会是堆栈式的”,因为我看到过使用setjmp/longjmp的协程将父堆栈划分成多个片段并共享它。但这是一种可怕的hack,实际上并没有节省任何资源,反而会带来其他问题。它只是一种在不支持协程的语言中实现协程的方法。 - Yakk - Adam Nevraumont
显示剩余11条评论
3个回答

93
我在小型、硬实时 ARM Cortex-M0 目标上使用无栈协程,其 RAM 为 32KB,没有堆分配器:所有内存都是静态预分配的。无栈协程非常重要,而之前我使用的有栈协程难以正确实现,并且基本上是一个完全依赖于特定实现行为的 hack。从那个混乱到符合标准、可移植的 C++ 是很棒的。我不敢想象有人会建议返回过去。
  • 无栈协程并不意味着使用堆: 您可以通过承诺类型中的 void * operator new(size_t) 成员(通过此链接了解更多)完全控制协程帧的分配方式。
  • co_await 可以嵌套,事实上这是一个常见用例,可以很好地支持
  • 有栈协程也必须在某处分配这些堆栈,并且可能具有讽刺意味的是,它们不能使用线程的主要堆栈。这些堆栈是在堆上分配的,例如通过从堆获取块然后对其进行子划分的池分配器。
  • 无栈协程实现可以省略帧分配,因此不会调用承诺的 operator new,而有栈协程始终为协程分配堆栈,无论是否需要,因为编译器无法帮助协程运行时避免它(至少在 C/C++ 中无法)。
  • 这些分配可以通过使用编译器可以证明协程的生命周期不会离开呼叫者范围的堆栈来省略。这是使用 alloca 的唯一方法。所以,编译器已经替您处理了。多酷啊! 现在,并没有要求编译器实际执行此省略,但据我所知,所有实现都会在如何复杂的“证明”问题上设置一些合理的限制(如果我没记错的话)。此外,如果您知道具有特定承诺类型的所有协程仅嵌套(适用于小型嵌入式项目但不仅限于此),则可以在承诺类型中声明 operator new但不定义它,然后如果编译器出了问题,则代码不会链接。 可以向特定编译器实现添加 pragma 来声明特定协程帧不会逃逸,即使编译器无法聪明地证明它——我没有检查是否有人费心编写这些,因为我的用例足够合理,所以编译器总是做正确的事情。 使用 alloca 分配的内存在从调用者返回后不能再使用。实际上,alloca 的用例是比表达 gcc 的变量大小自动数组扩展更具移植性的略微方式。
在几乎所有使用类C语言实现的堆栈式协程中,堆栈式的唯一“好处”是使用传统的基指针相关寻址访问帧,以及在适当的情况下进行压入和弹出,因此“普通”的C代码可以在这个虚构的堆栈上运行,而无需更改代码生成器。没有基准测试支持这种思维方式,如果有很多活跃的协程,那么这是一个好策略,但前提是有限的协程数量,并且你有足够的内存来浪费。 由于需要超额分配堆栈,降低引用的局部性:典型的堆栈式协程至少使用一个完整页面作为堆栈,而使该页面可用的成本不与其他任何东西共享:单个协程必须承担所有成本。这就是为什么为了多人在线游戏服务器而开发堆栈无关的Python的原因。 如果只有几个协程,那就没有问题。如果有数千个网络请求都由堆栈式协程处理,并且具有不会导致性能垄断的轻量级网络堆栈,那么缓存未命中的性能计数器将使你哭泣。如Nicol在另一个回答中所述,随着协程与处理异步操作的任何东西之间的层数越来越多,这变得不那么重要。 自从任何32位CPU都已经具有内存访问固有的性能优势以来,已经很长时间了。重要的是友好的缓存访问模式以及利用预取、分支预测和投机执行。分页内存及其后备存储器只是另外两个级别的缓存(桌面CPU上的L4和L5)。
1. C++为什么选择使用堆栈无关的协程?因为它们表现更好,且没有更差的表现。在性能方面,它们只有好处。因此,从性能角度来看,使用堆栈无关的协程是一个显而易见的选择。 2. 我可以使用alloca()来避免通常用于协程创建的堆分配吗?不行。这是解决不存在的问题的解决方案。堆栈式协程实际上并没有在现有堆栈上分配:它们创建新的堆栈,并且默认情况下这些堆栈是在堆上分配的,就像C++协程帧一样(默认情况下)。 3. 我对C++协程的假设是否错误?请参见上文。 4. 由于需要自定义分配器和内存池,代码变得更冗长。如果想让堆栈式协程表现良好,你将会对管理堆栈的内存区域进行相同的处理,而且结果发现这还更难。你需要最小化内存浪费,因此你需要在99.9%的用例中最小化过度分配堆栈,并以某种方式处理耗尽这个堆栈的协程。 我在C++中处理它的方法之一是在代码分支点进行堆栈检查,指示可能需要更多堆栈的代码分支点,然后如果堆栈将溢出,则抛出异常,撤销协程的工作(系统的设计必须支持它!),然后使用更多堆栈重新启动工再来一个趣闻:我正在尝试在Windows内核模式驱动程序中使用协程,而在那里,无堆栈确实很重要——如果硬件允许,您可以一起分配数据包缓冲区和协程的帧,这些页面在提交给网络硬件执行时被固定。当中断处理程序恢复协程时,页面就在那里,如果网络卡允许,甚至可以为您预取它,以便它在缓存中。所以这很好用——只是一个用例,但由于您想要嵌入式,我已经有嵌入式了:)。
也许不常见将桌面平台上的驱动程序视为“嵌入式”代码,但我看到了很多相似之处,并且需要具有嵌入式思维方式。你最不想要的是内核代码过度分配,特别是如果它会增加每个线程的开销。典型的桌面PC有几千个线程存在,其中大部分用于处理I / O。现在想象一下使用iSCSI存储的无盘系统。在这样的系统上,任何I / O绑定到网络硬件和网络堆栈的都不会绑定到USB或GPU。
最后:相信基准测试,而不是相信我,并阅读Nicol的答案!我的观点是由我的用例塑造的——我可以概括,但我没有对性能不太重要的“通用”代码中的协程进行第一手经验。对于通用应用程序代码,这很少会成为问题。在库代码中,它确实变得“有趣”,必须开发一些模式来允许库用户自定义此行为。随着越来越多的库使用C ++协程,这些模式将被发现和普及。

6
我认为区分3种协程实现方式很重要:有栈、堆上无栈和结构体无栈。你的回答已经相当全面地涵盖了有栈和堆上无栈的情况,但是没有提到结构体无栈的潜在方法。结构体无栈本质上是创建一个匿名类型(类似于 lambda),用于在挂起点之间保存数据。例如,Rust 的 async/await 实现采用了这种方法。我相信对于 C++ 这种方法存在声明/定义和 ABI 方面的问题。 - Matthieu M.
3
@patstew: ...“尽管从理论上讲这不是一个难以克服的挑战,但前端结构的重大改造可能是必要的。两个编译器前端(Clang、EDG)的专家表示,这不是一个切实可行的方法。” 因此,简短的答案似乎是由于现有C++编译器及其“直线”流程的技术负债,前端无法预测代码生成器传统上处理的某些簿记信息所需的大小。我想知道为什么lambda表达式没有这个问题,但我会听取Richard Smith关于Clang的意见。 - Matthieu M.
4
Lambda函数只在对象中存储捕获内容,你永远不想优化这些内容(例如,即使Lambda中没有直接引用,你可能仍希望延长对象的生命周期)。协程必须为所有跨挂起点引用的本地变量分配空间,并且你希望尽可能少地存储。我预计Rust前端中的所有生命周期检查等内容对此非常有用(并且不特别容易附加到C++上)。 - patstew
3
“the one and only”?能够像任何函数一样执行线程同步,从而暂停任何函数,似乎在某些情况下比代码生成的细节更具概念上的优势。 - Davis Herring
1
@Timo 协程在某种程度上是与目标无关的,因此即使运行时库支持滞后,使用最新的编译器也不是什么大问题。我正在使用“trunk”clang,它运行良好。对于一个前沿技术来说,它出奇地稳定。免责声明:所有运行时库二进制文件都是为这些构建定制的,并且我也编译了libc++(只编译其中的部分)。我没有重用任何现有的二进制文件,并且尽可能以最“裸”的方式使用llvm和lld作为独立工具。我没有使用ARM系统抽象 - 纯C++编写了自己的代码 :) - Kuba hasn't forgotten Monica
显示剩余4条评论

67

前言:当本文提到“协程”时,我指的是协程的概念,而不是特定于C++20的功能。当谈论该功能时,我将引用它为“co_await”或“co_await协程”。

关于动态分配

Cppreference有时使用比标准更松散的术语。作为一个功能,“co_await”“需要”动态分配;这种分配是来自堆还是静态内存块或其他的分配者所决定的问题。这些分配可以在任意情况下省略,但由于标准没有明确说明它们,你仍然必须假设任何co_await协程可能要动态分配内存。

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

作为一个功能,“co_await”被很好地设计用于从任何可等待对象和功能中删除冗余语句。 co_await机制非常复杂和精细,具有许多类型对象之间的相互作用。但在暂停/恢复点,它始终看起来像co_await <some expression>。为您的可等待对象和promises添加分配器支持需要一些冗长,但该冗长存在于那些东西被使用的地方之外。

使用alloca来实现协程在大多数情况下是高度不适当的,尽管关于这个特性的讨论试图掩盖它,但事实是co_await作为一个特性是为异步使用而设计的。这是它的预期目的:暂停函数的执行并将该函数的恢复调度到可能在另一个线程上的位置,然后将任何最终生成的值引导到一些接收代码中,这些代码可能与调用协程的代码相距甚远。 alloca对于这种特定用例不合适,因为允许/鼓励协程的调用者去做任何事情,以便该值可以由其他线程生成。由alloca分配的空间因此将不再存在,这对于驻留其中的协程来说是有问题的。
还要注意,在这种情况下,分配性能通常会被其他考虑所压倒:线程调度、互斥锁和其他东西通常需要正确地安排协程的恢复,更不用说从提供它的任何异步过程中获取该值所需的时间了。因此,在这种情况下需要动态分配并不是一个实质性的考虑因素。
现在,确实存在一些情况下,原地分配是合适的。生成器用例是当您想要暂停函数并返回一个值,然后从函数离开的地方继续执行并可能返回一个新值时使用的情况。在这些情况下,调用协程的函数的堆栈肯定还会存在。

co_await通过co_yield支持这些场景,但至少在标准方面,它的效果不是最优的。因为该功能设计用于向上和向外挂起,将其转换为挂起下来的协程会导致产生不需要动态的动态分配。

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

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

它如何进入标准

简短的版本是,它进入标准是因为背后的人付出了努力,而替代方案的人没有这样做。

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

记住上一次发生的事情,“听起来可行”被用作采用功能的基础。

如果你不知道它能否实现,就不要将其标准化。如果你不知道它是否真正解决了预期的问题,也不要将其标准化。

微软的Gor Nishanov和他的团队投入了多年时间来实现co_await。他们不断改进自己的实现方式等。其他人在实际生产代码中使用了他们的实现,并且似乎对其功能非常满意。Clang甚至也实现了它。尽管我个人不太喜欢它,但无可否认的是,co_await是一项成熟的功能。

相比之下,一年前作为与co_await竞争的替代方案提出的“核心协程”选择没有获得足够的关注部分原因是它们难以实现。这就是为什么采用了co_await:因为它是一个经过验证、成熟和可靠的工具,人们想要并表现出了改进他们代码的能力。

co_await并不适合每个人。就我个人而言,我可能不会经常使用它,因为纤程对我的用例更加有效。但是对于它的特定用例——上下文切换非常好。


好奇 - 你为什么说纤程在你的用例中表现更好?是因为你通常预分配它们,所以在C++编译器中的所有函数调用优化(如RVO、NRVO、堆栈变量局部性等)中使用纤程内的本地状态要便宜得多吗? - Curious
1
@Curious:我的用例涉及任务级编程,而不是单个函数。能够在任务的任何位置暂停整个任务,并稍后恢复它非常有用。所有这些都不需要中间代码知道挂起的情况。这与分配无关,而与正在执行的操作有关。 - Nicol Bolas

10

无栈协程

  • 无栈协程 (C++20) 进行代码转换(状态机)
  • 这里的无栈意味着应用程序栈不用于存储局部变量(例如算法中的实例变量)
  • 否则,在无栈协程暂停后,普通函数的调用会覆盖无栈协程的局部变量
  • 无栈协程确实需要内存来存储局部变量,特别是在协程被挂起时需要保留局部变量
  • 为此,无栈协程分配并使用称为激活记录(等效于堆栈帧)的东西。
  • 如果所有中间函数也是无栈协程,则可以从深层调用堆栈挂起 (病毒式),否则将导致堆栈损坏
  • 一些clang开发人员对于堆分配消除优化(HALO)是否总是适用持怀疑态度

有栈协程

  • 本质上,有栈协程只是切换堆栈和指令指针
  • 分配一个侧边栈,它像一个普通的堆栈一样工作(存储局部变量,为被调用函数推进堆栈指针)
  • 侧栈只需要分配一次(也可以池化),所有后续函数调用都很快(因为只需推进堆栈指针)
  • 每个无栈协程都需要自己的激活记录 -> 在深层调用链中调用时会创建/分配很多激活记录
  • 有栈协程允许在深层调用链中挂起,而其中的函数可以是普通函数(非病毒性)
  • 有栈协程可以超出其调用者/创建者的生存期
  • skynet基准测试的一个版本生成了100万个有栈协程,并显示有栈协程非常高效(优于使用线程的版本)
  • 还未实现使用无栈协程的skynet基准测试版本
  • boost.context将线程的主要栈表示为有栈协程/纤程 - 即使在ARM上也是如此
  • boost.context支持按需增长堆栈(GCC拆分堆栈)

  • 4
    a stackless coroutine can not outlive its caller/creator” 的意思是“一个无栈协程不能超越其调用者/创建者的生命周期”。但事实上,它们可以做到这一点,这也正是它们的优点所在。 - Nicol Bolas
    1
    “激活记录不应驻留在线程的主堆栈上”这句话并不正确。根据无堆栈协程的使用方式,“激活记录”确实可以存在于该线程的堆栈上。 - Nicol Bolas
    1
    @Nicol Nolas:只有当编译器能够证明线程堆栈上的激活记录不会被覆盖时,才可以这样做。 - xlrg
    1
    你说“必须不”,这意味着它是被禁止的。如果在某些情况下可以允许,那么“必须不”是错误的。 - Nicol Bolas
    2
    好的,你的观点是什么?如果你只使用一个协程,并且它是内联的(因为它是一个简单的生成器),并且你没有将它传递到任何地方...那么你的代码满足了这个条件。这正是省略是值得和有用的情况。HALO的目的不是省略每个协程;它是允许在有用的情况下进行省略。特别是生成器场景。 - Nicol Bolas
    显示剩余6条评论

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接