为什么无栈协程需要动态分配内存?

5

这个问题不是关于C++20中的协程,而是关于协程的一般概念。

最近我正在学习C++20协程。从 协程介绍中了解了有关堆叠型和非堆叠型协程的知识。我还在Stack Overflow上寻找更多信息。

以下是我对非堆叠型协程的理解:

  1. 非堆叠型协程在运行时存在于调用者的堆栈上。

  2. 当它自己暂停时,由于非堆叠型协程只能在顶层函数中暂停,因此它的堆栈是可预测的,并且有用的数据存储在某个特定区域。

  3. 当它没有运行时,它没有堆栈。它与一个句柄绑定,客户端可以通过该句柄恢复协程。

协程TS规定,在为协程帧分配存储空间时调用非数组operator new。但是,我认为这是不必要的,因此提出了这个问题。

一些解释/考虑:

  1. 在哪里放置协程的状态呢?原本存储指针的句柄中。
  2. 动态分配并不意味着存储在堆上。但我的目的是避免调用 operator new,无论它是如何实现的。
  3. 来自 cppreference

    即使使用自定义分配器,对 operator new 的调用也可以被优化掉,如果

    • 协程状态的生命周期严格嵌套在调用者的生命周期内,以及

    • 协程帧的大小在调用点已知

    对于第一个要求,如果协程的生命周期超过了调用者,则仍然可以将状态直接存储在句柄中。

    至于其他方面,如果调用者不知道大小,怎么能组合参数调用 operator new 呢?其实,我甚至无法想象在哪种情况下调用者不知道大小。

  4. 根据 this question,Rust 似乎有不同的实现。


协程会存储外部函数的堆栈以及CPU寄存器的状态。 - Oliv
@Oliv 外部函数?整个堆栈还是堆栈指针?当从顶层函数中挂起时,堆栈指针就已知。 - jerry_fuyi
“根据这个问题,Rust似乎有不同的实现。” Rust也是一种不同的语言。 - Nicol Bolas
4个回答

7
一个无栈协程在运行时不会有调用者的栈。
这就是你误解的源头。基于Continuation的协程(也就是“无栈协程”)是一种协程机制,旨在能够为其他代码提供一个协程,在某些异步进程完成后恢复其执行。这个恢复可能发生在另一个线程中。
因此,不能假定堆栈位于“调用者的堆栈”上,因为调用者和调度协程恢复的进程不一定在同一个线程中。协程需要能够存在于调用者之外,所以协程的堆栈不能在调用者的堆栈上(通常情况下是这样的。在某些co_yield式的情况下,它可以这样)。
协程句柄表示协程的堆栈。只要该句柄存在,协程的堆栈也存在。
当协程没有运行时,它没有堆栈。通过一个句柄与客户端绑定,在客户端调用resume函数后,协程将会被恢复。
那么,“句柄”如何存储协程的所有局部变量?显然它们被保留了下来(如果它们没有被保留,那就是一个糟糕的协程机制),所以它们必须存储在某个地方。函数的局部变量所在的位置称为“堆栈”。
把它叫做“句柄”并不能改变它是什么。
嗯……你不能这样做。如果从来不调用new是编写您正在编写的任何软件的重要组成部分,则不能使用co_await风格的协程连续体。没有一套规则可以保证在协程中省略new的调用。如果您使用特定的编译器,则可以进行一些测试,以查看它省略了什么以及没省略什么,但也仅限于此。
你引用的规则只是可能使调用省略的案例。
对于另一个问题,如果调用者不知道大小,如何组合参数调用operator new?
记住:在C ++中,co_await协程实际上是函数的“实现细节”。调用者不知道它调用的任何函数是否是协程。所有协程从外部看起来都像常规函数。
创建协程堆栈的代码在函数调用内部执行,而不是在函数外部执行。

1
@user541686:co_await协程可能在调用者的堆栈上创建,但仅在特定情况下才能实现。这仅在协程的主体对调用者可见且调用者本身是唯一从协程接收数据的情况下才可能发生(即:没有线程)。 - Nicol Bolas
1
@user541686: 因为调用者只需要知道一个整数:要保留多少字节,这只能通过编译该函数来了解。而编译需要函数的定义。在C++中不允许使用VLA。实际上,我认为C++没有任何需要运行时栈大小机制的机制。 - Nicol Bolas
我的意思是目前还没有做到,但为什么不能呢?主流的编译器已经支持 alloca 了;他们只需要调用它。更有限的实现(出于任何原因)可以像以前一样使用堆。我真的看不到这里会有什么障碍——它使得这种优化成为可能,而不会强制任何人使用它,对吧?从语言层面来看,唯一需要做的就是在声明处标记函数为协程的关键字,这是一个相当小的代价。 - user541686
1
@user541686: "但为什么不行呢?" 因为 co_await 协程的基本设计是旨在用于线程环境中。它们可以用于本地生成器是一种优化可能性,而不是主要用例。因此,该优化需要编译器知道协程的实现不会在其他地方安排其恢复或进行任何有趣的操作。标准甚至没有说明会触发这些优化的情况;这纯粹是一个生活质量问题。 - Nicol Bolas
挑刺:这个省略要求并不需要在另一个线程上恢复协程,实际上有一个很好的用例(fork-and-join并行)。事实上,我运行的一个小实验显示,即使在悬停点将协程句柄传递给未知的外部函数时,clang也能够省略使用“operator new”的使用。您可以通过在离开函数之前调用destroy()作为提示告诉编译器已经执行了必要的同步来使其发生。 - rdb
显示剩余8条评论

5
栈式协程和非栈式协程的根本区别在于协程是否拥有一个完整的、理论上无限制但实际上有限制的堆栈,就像线程一样。
在栈式协程中,协程的局部变量与其他变量一样存储在其拥有的堆栈上,无论在执行时还是挂起时都是如此。
在非栈式协程中,协程的局部变量可以在协程运行或未运行时存在于堆栈中。它们存储在非栈式协程拥有的固定大小的缓冲区中。
理论上,非栈式协程可以存储在其他人的堆栈上。然而,在C++代码中无法保证这一点。
创建协程时省略operator new操作符就是做到了这一点。如果协程对象存储在某个人的堆栈上,并且由于其状态已经足够小而省略了new,则可能存在完全位于其他人的堆栈上的非栈式协程。
在当前的C++协程实现中无法保证这一点。试图将其纳入编译器开发者的反对意见,因为协程进行的最小捕获发生得比他们需要知道协程在编译器中的大小要晚。
这导致了实践上的差异。栈式协程更像线程。您可以调用普通函数,并且那些普通函数可以在其主体中与协程操作(如挂起)进行交互。
非栈式协程不能调用与协程机制相互作用的函数。只允许在非栈式协程本身中与协程机制进行交互。
栈式协程具有线程的所有机制,而不需要在操作系统上进行调度。非栈式协程是一个增强的函数对象,其中包含goto标签,使它能够在其主体的一部分被恢复。
有一些理论上的非栈式协程实现没有“可能调用new”功能。C++标准不要求这种类型的非栈式协程。
有些人提出了这个问题。他们的提议输给了当前的提议,部分原因是当前的提议更加完善,更接近于被交付。一些替代提案的语法最终出现在成功的提案中。
我认为有一个令人信服的论点表明,“更严格”的固定大小无新协程实现并没有被当前的提议排除在外,可以随后添加,这有助于淘汰替代提案。

因为协程的确切最小捕获发生的时间比编译器需要知道协程大小的时间要“晚”。我并不完全相信这是一个阻碍因素。如果编译器为协程发出运行时元数据(如vtable),指示其框架大小,则调用者将能够有效地查找框架大小并调用alloca(),然后将缓冲区传递给协程。程序员只需要一些在声明站点标记协程的机制。这应该允许在调用站点进行分配,对吧? - user541686
(^很明显,并不是在每个情况下都有效,比如协程分配数量本身就是未知的时候,仍然需要动态分配回退,但我认为这将使得在“好”情况下避免动态分配变得可能?) - user541686
@user541686 对于协程的普遍抱怨是程序员无法静态保证“这个协程适合在这里的这个内存中,这个结构中,没错”。即使标准未指定大小并且可能在C++实现之间有所不同,大多数其他C++结构也可以做到这一点。虽然编译器可以优化协程以不使用动态分配,但那确实不完全相同。 - Yakk - Adam Nevraumont
但是allocaoperator new有什么区别呢?无论如何,调用者都不知道结构体的大小,但也没人关心它的大小,就像没有人关心虚表的大小一样。要明确的是,我想知道在generator<int> iota(int n) { ... }之前是否可以使用[[co_routine]](甚至是[[dynamic_frame]]),这将生成元数据(一个整数),指示帧大小,并且它将允许(但不需要)调用者在开头使用该大小的alloca代替operator new。我看到了优势,但是……没有缺点吗? - user541686

3

考虑以下假设情境:

void foo(int);

task coroutine() {
    int a[100] {};
    int * p = a;
    while (true) {
       co_await awaitable{};
       foo (*p);
       }
    }

p指向a的第一个元素,如果在两个恢复点之间,a的内存位置发生变化,p将无法保存正确的地址。

为了保证函数堆栈的内存在挂起和下一个恢复之间得以保留,必须以这样的方式分配内存。但是,如果某些对象引用内存中的对象,那么这个内存就不能被移动或复制(或者至少不能添加复杂性)。这就是为什么有时编译器需要将这个内存分配到堆上的原因。


你已经证明在这种情况下,协程的堆栈不能位于调用者的堆栈上,但这让我更加困惑:协程的堆栈在哪里?(因为它可能调用其他函数)如果它在句柄中,它可能会溢出;如果它在堆上,那么无栈状态怎么办?还有其他可能的位置吗? - jerry_fuyi
@jerry_fuyi:“如果在句柄中,它可能会溢出” 特定函数的堆栈不是动态属性。协程调用的函数的堆栈与协程本身的堆栈不同。 “如果在堆上,那么无堆栈呢” “无堆栈”一词仅用于与“有堆栈”协程进行对比,后者保留了从某个点开始的整个调用堆栈。 “无堆栈”协程仅保留挂起的特定函数的堆栈。 - Nicol Bolas
@jerry_fuyi 协程的堆栈可以在恢复者/调用者堆栈上增长。只有必须在挂起->恢复期间保留的堆栈部分必须存储在堆上。如果您将其编译为x86-64目标,则可以在编译器资源管理器上看到它,我敢打赌本地变量与rsp无关。 - Oliv
@jerry_fuyi 如果您查看此生成的代码,您将看到必须在暂停/恢复期间保留的本地数组a分配在堆上(请参见生成的汇编的第13行和25行),不需要在堆上分配的本地数组b也分配在堆上(但是错过了优化)。另一方面,您可以看到在调用foo之前未操作rsp:堆栈是调用者/恢复者堆栈。 - Oliv

0

不要将协程的与协程的状态混淆。

堆栈协程同时托管状态和堆栈,存储在分别分配在堆上的单独帧中。

无栈协程将其状态托管在堆上的帧中,但使用恢复线程的栈来推入和弹出值。如果这些值对协程的状态有重要影响,推送和弹出操作将直接影响帧中的状态字段;否则,它只是用于临时处理。编译器如何决定哪些操作会影响协程的状态?这在编译期间通过协程转换过程实现。

正如您可能猜到的那样,堆栈协程有一个很大的缺点,即你永远无法预先知道为完整堆栈帧分配多少堆空间(在每个线程的情况下也是如此)。但与其每个线程都麻烦一次,不如每次创建协程时都进行麻烦。


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