理解C++20 Coroutine: co_await与Awaiter

理解C++20 Coroutine: co_await与Awaiter

这是我学习 C++20 Coroutine 笔记的第二篇,打算做成一个系列,如果感兴趣可以从头开始读:

  1. 理解 C++20 Coroutine: 协程的概念

这篇笔记参考的原文是:C++ Coroutines: Understanding operator co_await,更推荐大家直接看原文,因为原文写的就很好,我的笔记大部分都是翻译的原文。

协程提案给我们提供了什么?(What does the Coroutines TS give us?)

  1. 三个新关键词:co_awaitco_yieldco_return
  2. 几个新类型:
    1. coroutine_handle<P>
    2. coroutine_traits<Ts…>
    3. suspend_always
    4. suspend_never
  3. 一套通用的机制,库的编写者可以使用它与协程交互并自定义协程的行为。
  4. 一种使得编写异步代码更简单的语言工具。

C++20 协程提案所提供的工具可以被认为是协程版的“低级汇编语言”。这些工具很难被安全地、直接地使用,它们的主要目的是让库的编写者去构建其他开发者可以安全使用的、高度抽象的程序。

编译器和库之间的交互(Compiler <-> Library interaction)

协程提案实际上并没有定义协程语义。它没有定义如何产生返回给调用者的值。它没有定义如何处理传递给 co_return 语句的返回值或如何处理从协程传播的异常。它也没有定义协程将会在哪一条线程恢复运行。

取而代之的是,它为库代码指定了一种通用的机制,可以通过实现符合特定接口的类型以定制协程的行为。编译器会生成调用这个类型实例方法的代码。这种方法类似于库编写者可以通过定义 begin()/end() 方法来自定义 range-based for-loop。

事实上,协程提案没有规定任何特定的协程机制语义使得它成为了一个很强大的工具。这允许库的编写者可以为了各种不同的目的,定义不同类型的协程。

举例来说,你可以定义一个协程,它异步地产生一个值;或者定义一个协程,它“懒惰地”(lazily)产生一系列值(即在需要时才计算值,而不是预先计算出整个序列再返回其中一个);或定义一个协程来简化控制流程,如果 std::optional<T> 的值是 std::nullopt,那么就尽早退出执行。

协程提案定义了两种接口:Promise 接口Awaiter 接口

!注意,这里原文用的是 Awaitable 接口,但是我觉得应该用 Awaiter 接口,因为下文提到:“实现了三个方法的类型称为 Awaiter 类型”,那么这三个方法所对应的接口称为 Awaiter 接口会更容易理解,因此我这里采用了“Awaiter 接口”。

这里的接口指的就是面向对象里接口的含义。只要你的类型实现了标准所指定的一系列函数,也就是实现了 Promise 接口 或 Awaiter 接口,那么这个类型就是 Promise type 或 Awaiter type。

Promise 接口指定了一些方法来控制协程自身的行为。库的编写者可以自定义当协程被调用时会发生什么、当协程返回时会发生什么(这里的返回既可以指寻常意义的函数返回,也可以指因为未捕获的异常而退出)、以及自定义协程内所有 co_await 或 co_yield 表达式的行为(这里指的是 await_transform 的工作)。

Awaiter 接口指定了一些方法来控制 co_await 表达式的语义。当一个值被 co_await 时,代码会被翻译成 awaitable 对象的一系列方法的调用。Awaiter 接口允许你:是否挂起当前的协程(1)、当协程已经挂起后执行额外的逻辑来安排协程之后何时恢复执行、在协程恢复执行并产生 co_await 表达式的结果后执行一些逻辑。

(1).比如一种可以被多次 co_await 的设计,第一次会挂起进行计算并保存计算结果,之后再co_await 都直接返回计算结果而无需挂起

Awaiter和Awaitable:解释operator co_await(Awaiters and Awaitables: Explaining operator co_await)

操作符 co_await 是一个新的可以作用于一个值的一元操作符,比如说:co_await someValue

操作符 co_await 只能被用于协程中。 这是一种“恒真逻辑”(tautology)的思想,因为根据定义,所有包含co_await操作符的函数会被编译成协程。

其实我更喜欢 C# 的方式,C# 将带有 async 关键词的方法编译为异步函数,通过关键词这样可以一眼看出一个函数是不是异步函数。
而 C++ 的这种方式,协程和普通函数的区别就没那么清晰了。
我注意到有人提案添加 async 关键词,类似 C#,将有 async 关键词标记的函数编译为协程。并同时将 co_await、co_yield及co_return分别简化为 await、yield、return。
这个提案的结果是“没有达成一致认识”,看来是失败了呢。

一个支持 co_await 操作符的类型被称为 Awaitable 类型(Awaitable type).

请注意一个类型是否可以被 co_await 操作符应用可以依赖于 co_await 表达式出现的上下文。协程所使用的 Promise type 可以使用它的 await_transform 方法来改变 co_await 表达式的含义。

await_transform 方法我还没有使用过,但是我猜测它可以把一个类型转化为一个 Awaitable 类型。这样一个即使不是 Awaitable 类型(比如std::string),在某些协程里也可以使用: co_await std::string 的用法。

更具体地说,在需要时,我喜欢使用术语 Normal Awaitable 来描述在没有 await_transform 成员的协程上下文中支持 co_await 运算符的类型。而且我喜欢使用术语 Contextually Awaitable 来描述一种类型,该类型仅在某些类型的协程的上下文中支持 co_await 运算符,因为协程的 promise 类型中存在 await_transform 方法。

总结:

  1. Normal Awaitable:因为实现了 Awaiter 接口而变得 Awaitable 的类型。
  2. Contextually Awaitable: 因为 promise type 实现了相关 await_transform 方法而变得 Awaitable 的类型。

一个 Awaiter 类型是实现了三个特定方法的类型,这些方法作为 co_await 表达式的一部分被调用,这三个特定方法是:await_readyawait_suspendawait_resume

请注意,我无耻地从C# async关键词的机制中“借用”了术语 “Awaiter”,该机制是根据 GetAwaiter() 方法实现的,它返回对象的接口类似于C++中Awaiter 的概念。(这里的“我”,是原文的作者,我只是翻译)

请注意,一个类型可以同时是 Awaitable 类型和 Awater 类型。

实现了这三个方法的类型根据定义自然是一个 Awater type,而 Awaiter 可以被 co_await 关键词应用,天然是 Awaitable type。但是Awaitable type 不一定是 Awater type!比如你可以重载 operator co_await,然后返回一个 Awaiter,这样的类型是 Awaitable type,但是因为没有实现 Awaiter 接口,所以不是 Awaiter type。

获取 Awaiter (Obtaining the Awaiter)

对于被 co_await 的值,编译器做的第一件事情是生成获取 Awaiter 对象的代码。N4680 第 5.3.8(3) 节列出了一些获取 awaiter 对象的步骤。

让我们假设被 co_await 的协程的 Promise type 是 P,而且变量promise 是当前协程的 Promise object 的左值引用。

如果 Promise type P 有一个名为 await_transform 的成员函数,那么表达式 <expr> 首先被传入到函数调用 promise.await_transform(<expr>) 中以获取 Awaitable 值,awaitable

如果没有 await_transform 成员函数,那么表达式 <expr> 的结果被直接认为是 Awaitable 对象,awaitable

然后,如果 Awaitable 对象 awaitable 有一个 operator co_await 操作符重载,那么操作符重载会被调用来获取 Awaiter 对象。反之,awaitable 将用作 awaiter 对象。

如果把这些流程写成代码,那么两个函数 get_awaitable()get_awaiter() 可能看起来像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<typename P, typename T>
decltype(auto) get_awaitable(P& promise, T&& expr)
{
if constexpr (has_any_await_transform_member_v<P>)
return promise.await_transform(static_cast<T&&>(expr));
else
return static_cast<T&&>(expr);
}

template<typename Awaitable>
decltype(auto) get_awaiter(Awaitable&& awaitable)
{
if constexpr (has_member_operator_co_await_v<Awaitable>)
return static_cast<Awaitable&&>(awaitable).operator co_await();
else if constexpr (has_non_member_operator_co_await_v<Awaitable&&>)
return operator co_await(static_cast<Awaitable&&>(awaitable));
else
return static_cast<Awaitable&&>(awaitable);
}

这一段主要是想表达对于 co_await <expr> 这么一行代码,其中这个 <expr> 可以是很多很多种情况。
比如 <expr> 可以是一个 Awaiter type 或者 Awaitable type 的 object,那么肯定行得通。
又比如 <expr> 可以是一个函数调用,但是它的返回值是 Awaitable type,那么也行得通!
再极端点,<expr> 既不是 Awaiter type 也不是 Awaitable type,但是因为协程的 Promise type 实现了相关的 await_transform() 函数,那么这个 co_await <expr> 也是合法的代码。

Awaiting the Awaiter

这个标题还真不知道怎么翻译好。这一部分主要讲解 C++ 编译器是如何把 co_await <expr> 翻译成一系列 Awaiter 接口的函数调用的。

我们假设将 <expr> 的结果转化为 Awaiter 的逻辑可以封装成上面所说的两个函数,那么对于代码 co_await <expr> 的语义可以被粗糙地翻译为以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
auto&& value = <expr>;
auto&& awaitable = get_awaitable(promise, static_cast<decltype(value)>(value));
auto&& awaiter = get_awaiter(static_cast<decltype(awaitable)>(awaitable));
if (!awaiter.await_ready())
{
using handle_t = std::experimental::coroutine_handle<P>;

using await_suspend_result_t =
decltype(awaiter.await_suspend(handle_t::from_promise(p)));

<suspend-coroutine>

if constexpr (std::is_void_v<await_suspend_result_t>)
{
awaiter.await_suspend(handle_t::from_promise(p));
<return-to-caller-or-resumer>
}
else
{
static_assert(
std::is_same_v<await_suspend_result_t, bool>,
"await_suspend() must return 'void' or 'bool'.");

if (awaiter.await_suspend(handle_t::from_promise(p)))
{
<return-to-caller-or-resumer>
}
}

<resume-point>
}

return awaiter.await_resume();
}

方法 await_suspend() 根据返回值类型可以分为两个版本,void 版本和bool 版本。其中,void 版本无条件地将执行转移回 caller/resumer。而bool 版本允许 awaiter 对象根据条件选择是否立即恢复协程的执行,而不返回到 caller/resumer。

bool版本的 await_suspend() 方法可以被用于这种情况:awaiter 可以异步地进行计算,但有时也想同步地执行。

<suspend-coroutine> 节点,编译器会生成一些代码来保存当前协程的状态并准备协程的恢复。这包括保存恢复点的地址以及将寄存器中的值保存到协程帧的内存中。

<suspend-coroutine> 的操作完成之后,当前的协程就被认为是处于挂起状态。你能观察挂起的协程的第一个点在 await_suspend() 的调用里。一旦协程被挂起,它就可以被恢复或者销毁。

函数 await_suspend() 的责任是安排协程在未来的恢复(或销毁)。注意,从 await_suspend() 返回 false 算作是安排协程在当前线程立刻恢复。

函数 await_ready() 的目的是允许你避免 <suspend-coroutine> 操作的消耗,在某些情况,当你已知要同步地进行,就不需要挂起协程再恢复。

<return-to-caller-or-resumer> 节点,执行会被转移回 caller/resumer,当前栈帧弹出,但是保持协程帧活跃。

当挂起的协程最终被恢复,则在 <resume-point> 恢复执行。

函数 await_resume() 的返回值类型就是 co_await 表达式的结果类型。函数 await_resume() 也可以抛出异常,来向外传播 co_await 表达式中的异常。

请注意,如果一个异常在调用 await_suspend() 时传播出来,那么协程会自动地恢复,并且不会调用 await_resume()。

协程句柄(Coroutine Handles)

类型 coroutine_handle<P> 表示协程帧的一个非拥有句柄(non-owning handle),可以通过它来恢复协程的执行或销毁协程帧。它也可以被用来访问协程的 promise object。

什么是 non-owning handle?我不知道怎么翻译好。

概括的说,协程句柄有这些接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
namespace std::experimental
{
template<typename Promise>
struct coroutine_handle;

template<>
struct coroutine_handle<void>
{
bool done() const;

void resume();
void destroy();

void* address() const;
static coroutine_handle from_address(void* address);
};

template<typename Promise>
struct coroutine_handle : coroutine_handle<void>
{
Promise& promise() const;
static coroutine_handle from_promise(Promise& promise);

static coroutine_handle from_address(void* address);
};
}

当你实现一个 Awaiter type 时,你将会用到 coroutine_handle 里一个关键的 resume 方法。当某些操作完成时,你想要恢复协程的执行,就调用resume 方法。resume 方法将会在遇到下一个 <return-to-caller-or-resumer> 节点时返回(有两种情况:1.再次遇到 co_await,并且将会再次挂起协程; 2.执行到协程的末尾;)。

destroy 方法会销毁协程帧,调用所有作用范围内的变量的析构函数,并且释放协程帧所用的内存。你通常不需要(而且应该尽量避免)去调用 destroy 函数,除非你是一个库的作者,正在实现协程的 Promise type。通常来说,协程帧会被某种 RAII 类型所持有,并且来自协程调用的返回值。所以调用destroy 而没有和 RAII 对象协作的话,会导致双重析构的BUG。

promise 方法返回当前协程的 promise object 的引用。和 destroy 方法一样,它通常只有在你是协程的 promise type 的作者时才会用到。

通常来说,对于绝大多数的 Normally Awaitable 类型,应该使用 coroutine_handle<void> 作为 await_suspend() 方法的参数类型,而不是 coroutine_handle<Promise>。如果考虑为某些协程 Promise type 做特别实现,才使用 coroutine_handle<Promise>

方法 coroutine_handle<P>::from_promise(P& promise) 允许你从协程的 Promise object 的引用重新构造 coroutine handle。请注意,你必须确保类型 P 和具体协程的 promise type 相匹配。如果尝试构造一个 cotoutine_handle<Base> 而具体协程的 promise type 是 Derived 会导致未定义行为。

方法 address()/from_address() 允许你将 coroutine handle 转化为/转化自 一个 void* 指针。

这主要是为了允许作为“上下文”参数传递到现有的 C-Style API,你在某些情况下实现 Awaiter type 时才会发现它的用处。不过,在我所发现的大多数案例里,需要额外传递信息给这个“上下文”参数。所以我通常将 coroutine_handle 储存在一个 struct 中,然后把 struct 的指针传递给这个“上下文”参数,而不是使用 address 的返回值。

无需同步的异步代码(Synchronisation-free async code)

co_await 操作符的一个强大的设计特性是可以在协程暂停后将执行返回到caller/resumer之前执行代码。

这允许 Awaiter object 在协程已经挂起后去初始化异步操作,将已经挂起的协程的 coroutine_handle 传入给异步操作。当异步操作完成后,这可以安全地恢复(可能在另一条线程)而不需要额外的同步。

当协程恢复之后的第一件事情是调用 await_resume 来获取返回值,并且通常立即销毁 Awaiter object(举例说,await_suspend 里使用 this 指针)。协程是有可能在await_suspend返回之前就运行到结尾、销毁协程和 promise object的。

所以,在 await_suspend 方法里,一旦协程有可能在另一条线程恢复,你需要确保避免访问 this 指针和协程的promise object(通过.promise()方法),因为它们可能都已经被销毁了。通常来说,在异步操作开始之后和协程已经被安排恢复,在await_suspend方法里你可以安全地访问的唯一东西就是await_suspend的局部变量。

我看的一脸懵逼,没有相关编程经验,不知道到底说的什么情况。

总结

原文作者很详细地讲解了 co_await <expr> 语句是如何被翻译成 Awaiter 接口的调用的。我感觉原文不适合给初学者了解 C++ 协程,它非常枯燥,而且有很多新概念(我也是写了一些协程代码后,再回头看这篇文章,才顺利地看明白了)。我觉得原文更适合那些希望了解 C++ 协程工作细节的人,绝对值得多次阅读。

Awaiter type

实现了 Awaiter 接口的类型。

它用三个函数控制 co_await 表达式如何工作。

  1. bool await_ready(); : 表示 co_await 是否要挂起,false -> 挂起。
  2. void/bool await_suspend(coroutine_handle<P>); : 在这个函数里安排何时恢复协程的执行(通过调用 resume() 方法)。
  3. T await_resume() : 在这个函数返回执行的返回值。类型 T 表示 co_await <expr> 表达式的类型。

Awaitable type

可以被 co_await 的类型。(可以是 Awaiter type 也可以是重载了 operator co_await 的类型)

作者

uint128.com

发布于

2022-02-21

更新于

2022-08-22

许可协议

评论