理解Promise语义

2023/04/25 协程 共 3700 字,约 11 分钟

写在前面

C++20无栈协程主要提供了2种接口,Promise 接口和 Awaitable 接口。通过自定义这些接口从而自定义协程的行为。这篇文章简单介绍Promise接口。

Promise 接口指定用于自定义协程本身行为的方法。让库作者能够自定义调用协程时发生的事件,如协程返回时(通过正常方式或通过未处理的异常返回),或者自定义协程中任何 co_awaitco_yield 表达式的行为。

Awaitable 接口指定控制 co_await 表达式语义的方法。当一个值为 co_await 时,代码被转换为对 awaitable 对象上的方法的一系列调用。它可以指定:是否暂停当前协程,暂停调度协程以便稍后恢复后执行一些逻辑,还有在协程恢复后执行一些逻辑以产生 co_await 表达式的结果。

Keyword

C++给协程提供了3个关键词,co_awaitco_yieldco_return,C++的协程在形式上就是一个的函数,如果函数存在这3个关键词之一,并且协程的返回值应该是满足一定要求的类型。那么它就是一个协程,之后编译器会为协程生成许多代码,最终实现一个协程状态机。我们所要做的就是自定义一些行为去约束状态机。

  • co_await 往往是一个协程的暂停点和恢复点
  • co_yield 是用来暂停协程并且往绑定的 promise 里面放入一个值.
  • co_return 是往绑定的 promise 里面放入一个值, 同时结束这个协程的.

Understanding the promise type

每一个协程都会有一个promisepromise可以让用户自定义协程的一些行为,比如初始化协程应该会发生什么,协程结束的时候会发生什么,异常和调用yield的时候会发生什么。

这里的promisestd::future那个没有什么特别的联系。

当写一个协程的时候,编译器会把协程转换成以下的伪码。其中 <body-statements> 是我们的函数主体。

{
  co_await promise.initial_suspend();
  try
  {
    <body-statements>
  }
  catch (...)
  {
    promise.unhandled_exception();
  }
FinalSuspend:
  co_await promise.final_suspend();
}

在开始调用协程的时候,会按下面的顺序执行 :

  • 使用operator new在堆上分配一个协程帧(coroutine frame) coroutine_handle
  • 把函数的参数拷贝到协程帧里
  • 在协程帧里创建promise对象 promise对象的类型通过 coroutine_handle<T>中的T传递。
  • 调用这个promise对象的get_return_object方法创建一个promise对象
  • 调用promise.get_return_object方法,以获得在协程首次挂起时返回给调用方的结果。将结果另存为局部变量。
  • 调用promise.initial_suspend方法并co_await结果。
  • co_await promise.initial_suspend表达式resume(立即或异步)时,协程开始执行协程主体语句。

当执行达到co_return 语句时,将执行一些附加步骤:

  • 调用promise.return_voidpromise.return_value
  • 销毁所有局部变量,销毁顺序与创建顺序相反。
  • 调用promise.final_suspendco_await结果。

相反,如果由于未处理的异常,则:

  • 捕获异常并从catch块中调用promise.unhandled_exception
  • 调用promise.final_suspendco_await结果。

一旦执行传播到协程主体之外,那么协程框架就会被破坏。销毁协同程序框架涉及多个步骤:

  • 调用promise对象的析构函数。
  • 调用函数参数副本的析构函数。
  • 调用操作符delete以释放协程框架使用的内存
  • 将执行转移回调用方/唤醒方。

以上就是较为详细的promise语义的大纲。其中一些具体的行为如下:

Allocating a coroutine frame

首先,编译器生成对运算符new的调用,为协程框架分配内存。如果promise类型P定义了一个自定义运算符new方法,则调用该方法,否则调用全局运算符new。其中,分配的size大小是整个协程框架,由编译器根据参数的数量和大小、promise对象的大小、局部变量的数量和尺寸以及协程状态管理所需的其他编译器特定存储自动确定。当如果是一些严格嵌套的,可以不用分配内存的协程,可以使用栈代替堆。

Copying parameters to the coroutine frame

协程需要将原始调用方传递给协程函数的任何参数复制到协程框架中,以便它们在协程挂起后保持有效。如果参数是按值传递给协程的,那么这些参数将通过调用类型的move构造函数复制到协程框架中。如果参数是通过引用(左值或右值)传递给协程的,那么只有引用被复制到协程框架中,而不是它们指向的值。如果任何参数复制/移动构造函数抛出异常,那么已经构造的任何参数都将被销毁,协程框架将被释放,异常将传播回调用方。

Constructing the promise object

一旦所有参数都被复制到协程框架中,协程就会构造promise对象。在构造promise对象之前复制参数的原因是允许promise对象在其构造函数中访问复制后的参数。首先,编译器检查promise构造函数是否有重载,该重载可以接受对每个复制参数的左值引用。如果编译器发现这样的重载,则编译器生成对该构造函数重载的调用。如果没有找到这样的重载,那么编译器将返回到生成对promise类型的默认构造函数的调用。如果promise构造函数抛出异常,那么在异常传播到调用程序之前,参数副本将被销毁,并在堆栈展开期间释放协程框架。

Obtaining the return object

协程对promise对象所做的第一件事是通过调用promise.get_return_object来获得返回对象。return对象是当协程首次挂起时,或者在它运行到完成并且执行返回给调用方之后,返回给协程函数调用方的值。

The initial-suspend point

一旦协程框架初始化并获得返回对象,协程执行的下一件事就是执行语句co-await promise.initial_suspend。这允许promise_type的作者控制是在执行源代码中出现的协程主体之前挂起协程,还是立即开始执行协程主体。如果协同程序在初始挂起点挂起,则可以稍后通过调用协同程序的coroutine_handle上的resume或destroy,在您选择的时间恢复或销毁它。co-await promise.initial_suspend表达式的结果被丢弃,因此实现通常应该从awaiter的await_resume方法返回void。对于许多类型的协程,promise.initial_suspend方法要么返回suspend_always(执行后挂起),要么返回suspend_never(立即执行协程主体)。

Returning to the caller

当协程第第一次到达挂起点的时候,协程会返回给caller promise.get_return_object的返回值

Returning from the coroutine using co_return

当协程到达一个co_return语句时,它被转换为对promise.return_voidpromee.return_value的调用,然后是goto FinalSuspend.

随后的goto FinalSuspend导致所有协程的局部变量按照构造的相反顺序析构。

Handling exceptions that propagate out of the coroutine body

如果异常从协程主体传播出去,则捕获该异常,并在catch块内部调用promise.unhandled_exception方法。此方法的实现通常调用std::current_exception来捕获异常的副本,并将其存储起来,以便稍后在不同的上下文中重新抛出。

The final_suspend point

一旦执行退出了协程主体的用户定义部分,并且通过调用return_voidreturn_valueunhanded_exception捕获了结果,并且析构了全部的局部变量,协程就会执行co_await promise.final_suspend;这允许协程执行一些逻辑,例如发布结果、发出完成信号或恢复继续。它还允许协程在执行完协程并销毁协程框架之前立即挂起。

reference

C++ Coroutines: Understanding the promise type

C++20 协程原理和应用

C++20 新特性 协程 Coroutines(1) - 知乎 (zhihu.com)

C++20 新特性 协程 Coroutines(2) - 知乎 (zhihu.com)

Search

    Table of Contents