(译) C++ Coroutines: Understanding operator co_await
这是 Lewis Baker C++ 协程介绍的第一篇文章(从 0 开始计数)。现在因为看不太懂第二篇,所以打算将第一篇翻译出来,以便后续回来反复学习。第 0 篇文章讲的通用的协程理论,比较容易理解,不涉及 C++20 协程内容,暂时没有翻译计划。翻译错误及不太好的地方,欢迎大家指正。
C++ 协程:理解 co_await 操作符
在上一篇协程理论文章中,我描述了函数和协程之间的高层区别,但并没有详细介绍 C++ Coroutines TS (N4680) 所描述的协程的语法和语义。
Coroutines TS 向 C++ 语言添加了暂停一个协程,并允许它稍后恢复这个重要的新功能。TS 通过新的 co_await
操作符来提供该机制。
理解 co_await
是如何工作的,可以帮助我们揭开协程神秘的面纱,让我们理解它们是如何挂起和恢复的。在这篇文章中,我将解释 co_await
操作符的机制,并介绍与之相关的 Awaitable 和 Awaiter 这两个类型的概念。
在我们深入 co_await
操作符之前,我想提供一个 Coroutines TS 所提供内容的概览。
Coroutines 带给我们了什么?
- 三个语言关键字:
co_await
,co_yield
和co_return
std::experimental
名字空间的几个新类型:(译注:原作者文章写于 2017 年,因此位于std::experimental
名字空间内)corotine_handle<P>
corotine_traits<Ts...>
suspend_always
suspend_never
- 一套通用的机制,协程库作者可以使用它们来与协程进行交互并自定义他们协程的行为。
- 一种使编写异步代码更加容易的语言基础设施!
C++ Coroutines TS 在语言中提供的设施可以被认为是用于协程的底层汇编语言。这些设施可以通过安全的方式被不同地使用,并主要是面向协程库开发者,让他们构建更高层的抽象,来使应用开发者们可以安全地使用、工作。
我们的计划是将这些新的底层工具交付到即将到来的语言标准(希望是 C++20)中,以及标准库中一些附带的高级类型,这些类型包装了这些底层的构建块,并使应用程序开发者以一种安全的方式更容易地使用协程。
编译器 <-> 库 交互
有趣的是,Coroutines TS 实际上并没有定义协程的语义。他没有定义如何产生返回给调用者的值。它没有定义返回值如何被传递给 co_return
语句,或者如何处理从协程传播出去的异常。它没有定义协程在哪个线程上恢复运行。
与之代替的是,它为库代码指定了一种通用机制,通过实现符合特定接口类型来定制协程的行为。然后,编译器生成代码,在库提供的类型实例上调用方法。这种方法与库开发者可以通过定义 begin()
/ end()
方法和一个 iterator
类型来自定义基于范围的 for
循环行为相似。
Coroutines TS 没有为协程机制规定任何特定的语义,这使得它成为一个强大的工具。它允许库开发者定义许多不同种类的协程,用于各种不同的目的。
举例来说,你可以定义一个协程来异步生产一个有符号的数值,或者定义一个协程来惰性产生一系列地值,或者定义一个协程来简化用于消费 optional<T>
,如果遇到 nullopt
值则提前退出的控制流。
Coroutines TS 定义了两类接口:Promise 接口和 Awaitable 接口。
Promise 接口指定了定制协程本身行为的方法。协程库开发者可以定制当调用协程时会发生什么,协程返回时发生什么(要么通过正常方式,要么通过未处理的异常),并定制协程中任何 co_await
或 co_yield
表达式的行为。
Awaitable 接口指定了控制 co_await
表达式语义的方法。当一个值被 await
时,代码被转换为对 awaitable 对象上方法的一系列调用,这些方法允许它进行指定:是否挂起当前协程,在挂起后执行一些用于后续恢复的逻辑,以及在协程恢复后执行一些逻辑来产生 co_await
表达式的结果。
我将在后续的文章中介绍 Promise 接口的细节,现在让我们看向 Awaitable 接口。
Awaiters 和 Awaitable:理解 operator co_await
co_await
是一个新的可以用于一个值的一元操作符,如 co_await someValue
。
co_await
操作符只可以在协程上下文中使用。这有点像一个重复,因为在定义上,任何函数体中使用了 co_await
操作符,他将被编译成为一个协程。
一个支持 co_await
操作符的类型被称作 Awaitable 类型。
注意,是否可以将 co_await
操作符应用于一个类型依赖于 co_await
表达式出现的上下文。用于协程的 promise 类型可以通过它的 await_transform
方法(后续详细介绍)在协程内部改变 co_await
表达式的含义。
更具体地说,我喜欢使用 Normally Awaitable 来描述在协程上下文中支持 co_await
操作符的类型,该类型的 promise 类型中不含有 await_transform
成员。我喜欢使用术语 Contextually Awaitable 来描述仅在特定类型的协程的上下文中支持 co_await
操作符的类型,(因这种类型协程具有 await_transform
方法才支持)。(我期待对这两个名字更好的建议)
Awaiter
类型实现了三个特定的方法,await_ready
, await_suspend
和 await_resume
,这些方法作为 co_await
表达式的一部分被调用。
注意,我在这里 “毫不羞耻” 地从 C# async
关键字的机制中借用了术语 ‘Awaiter’,该机制是通过 GetAwaiter()
方法实现的,该方法返回一个具有接口的对象,该接口与 C++ Awaiter
概念惊人似的对象。有关 C# awaiters 的更多细节,请参阅这篇文章。
注意,一个类型,可以既是 Awaitable
类型,又是 Awaiter
类型。
当编译器遇到一个 co_await <expr>
表达式时,根据所涉及到的类型,实际上可以将其转换为许多可能的内容。
获取 Awaiter
编译器做的第一件事是生成代码以获取被 await 值的 Awaiter 对象。在 N4680 中的第 5.3.8 (3) 节中介绍了获取 await 对象的许多步骤。
让我们假设正在异步等待的协程的 promise 对象类型为 P,并且这个 promise 是对当前协程 promise 对象的左值引用。
如果 promise 类型 P 有 await_transform
成员,那么 <expr>
会首先被传到 promise.await_transform(<expr>)
调用中来获取 Awaitable 值,(我们称其名字为)awaitable。否则,如果 promise 类型没有 await_transform
成员,我们会使用求值后的 <expr>
直接作为 Awaitable 对象,awaitable。
然后,如果这个 Awaitable 对象,awaitable,有一个合适的 operaor co_await()
重载,那么它将会被调用来获取 Awaiter 对象。否则,这个 awaitable 对象,会被作为 awaiter 对象来使用。
如果我们将这些规则编码到函数 get_awaitable()
和 get_awaiter()
中,它们看上去像这样:
1 | template<typename P, typename T> |
Awaiting the Awaiter
因此,假设我们已经将 <expr>
结果转换为一个 Awaiter 对象的逻辑封装进了上述函数中,那么,co_await <expr>
的语义将会被(大概)翻译成以下:
1 | { |
当调用 await_supsend()
返回时, 返回 void
版本的 await_suspend()
会无条件地将执行转移回调用者 / 恢复者,而返回 bool
版本的会允许 awaiter 对象在满足一定条件下立即恢复协程而不返回调用者 / 恢复者。
当 awaiter
启动异步操作而该异步操作可以同步完成而不需要等待时,await_suspend()
返回 bool
的 版本会非常有用。在这种情况下,它可以同步完成, await_suspend()
方法可以返回 false
来指定协程应该立即恢复并继续执行。
在 <suspend-coroutine>
点,编译器生成一些代码来保存当前协程的状态,并为恢复做准备。这包括储存 <resume-point>
点位置,以及将当前保存在寄存器中的任何值保存到协程帧内存中。
<suspend-coroutine>
操作完成后,当前协程会被认为挂起。你可以观察到挂起协程的第一个点是在 await_suspend()
调用的内部。一旦协程被挂起,就可以恢复或销毁它。
await_ready()
方法的目的是,如果了解操作可以被同步地完成而不需要挂起,在这种情况下,你可以避免 <suspend_coroutine>
操作的消耗。
在 <return-to-caller-or-resumer>
点,执行权将被转移回调用者或者恢复者,弹出本地栈帧,但要保持协程帧存活。
当(或者如果)被挂起的协程最终被恢复时,执行会在 <resume-point>
恢复,即在调用 await_resume()
方法以获取操作结果之前。
await_resume
方法调用的返回值会成为 co_await
表达式的结果。await_resume()
方法也可以抛出异常,在这种情况下,异常从 co_await
表达式传播出去。
注意,如果异常在 await_suspend()
调用中传播出去,那么会自动恢复协程,并且在 co_await
表达式中传播而无需调用 await_resume()
。
Coroutine Handles
或许你已经注意到了 coroutine_handle<P>
类型的使用,该类型传递给了 co_await
表达式的 await_suspend()
调用。
这个类型表示一个非拥有权的协程帧的句柄,并且可以被用于恢复协程的运行或者销毁协程帧。它也可以被用于访问协程的 promise 对象。
coroutine_handle
类型具有以下(缩写)接口:
1 | namespace std::experimental |
当实现 Awaitable 类型时,你将在 coroutine_handle
上使用的关键的方法是 .resume()
,当操作完成并且你想恢复一个正在等待中的协程的执行时,应该调用它。在一个 coroutine_handle
上调用 .resume()
将会在 <resume-point>
激活一个被挂起的协程。当协程遇到下一个 <return-to-caller-or-resumer>
时,对 .resume()
的调用将会返回。
.destory()
方法将会销毁协程帧,调用所有作用域内变量(in-scope variables)的析构函数,并且释放协程帧使用的内存。你一般不需要(实际上应该避免)调用 .destroy()
除非你是一个在实现协程 promise 类型的库作者。通常,协程帧将由调用协程返回的某种 RAII 类型所拥有。因此,在不与 RAII 对象合作的情况下调用 .destory()
可能会导致 double free bug。
.promise()
方法的返回一个协程 promise 对象的引用。然后,像 .destory()
方法,它通常只在你正编写协程 promise 类型时才有用。你应该将协程的 promise 对象视为协程的内部实现细节。对于大多数 Normally Awaitable 类型,你应该使用 coroutine_handle<void>
作为 await_suspend()
方法的参数类型,而不是 coroutine_handle<Promise>
。
coroutine_handle<P>::from_promise(P& promise)
函数允许从一个协程 promise 对象的引用重建它的协程句柄。注意,你必须要保证类型 P 精确地匹配协程帧使用的 promise 类型。尝试从 Derived
的 promise 类型创建 coroutine_handle<Base>
会导致未定义行为。
.address()
/from_address
函数允许协程句柄与 void *
指针间的相互转换。这主要是为了传递一个上下文参数到现有的 C 风格的 API 中,因此你会发现这在一些实现 Awaitable
类型的场景中是有用的。然而,在大多数情况下,我发现有必要将附加信息传递给这个上下文参数中回调函数,因此,我一般将协程句柄存储在结构体里,并在上下文参数中传递指向该结构体的指针,而不是使用 .address()
的返回值。
无同步的异步代码
co_await
操作符一上强大的设计特性就是可以在暂停协程之后、在返回调用者 / 恢复者之前执行代码。
这允许一个 Awaitable 对象在协程挂起后初始化一个异步操作,将挂起的协程的 coroutine_handle
传递给这个操作,当操作完成(可能在另一个线程上)时,它可以安全的恢复协程,而不需要额外的同步。
例如,当协程已经挂起时,在 await_suspend()
中启动异步读操作,意味着我们可以在操作完成时恢复协程,而不需要任何线程同步来协调启动该操作的线程和完成该操作的线程。
1 | Time Thread 1 Thread 2 |
在利用这种方法时需要非常小心的一件事是,一旦你启动了将协程句柄发布到其他线程的操作,那么可能会在 await_suspend()
返回之前在另一个线程上恢复协程,并可能继续与 await_suspend()
方法的其余部分并发执行。
协程在恢复时做的第一件事就是调用 await_resume
来获取结果,然后它一般会立即析构 Awaiter 对象(如,await_suspend()
调用中的 this
指针)。之后,协程可能运行到完成,销毁协程和 promise 对象,这些都在 await_suspend()
返回之前完成。
因此,在 await_suspend()
方法中,一旦协程可以在另一个线程上并发地恢复,您需要确保避免访问 this
或协程的 .promise()
对象,因为这两个对象都可能已经被销毁。一般来说,在启动操作和调度恢复协程之后,唯一可以安全访问的对象是 await suspend()
中的本地变量。
与有栈协程比较
我想简单地比较一下 Coroutines TS 无栈协程在协程挂起后执行逻辑的能力,以及一些现有的常用的有栈协程设施,如 Win32 纤程或 boost::context。
在许多有栈协程框架中,一个协程的挂起操作与另一个协程的恢复结合在一起,形成上下文切换操作。使用这种上下文切换操作,通常没有机会在挂起当前协程后,恢复执行另一个协程之前执行逻辑。
这意味着如果我们想在有栈协程之上实现类似的异步文件读取操作,那么我们必须在挂起协程之前启动该操作。因此,在协程挂起并有资格恢复之前,操作可能在另一个线程上完成。在另一个线程上完成的操作和协程挂起之间的潜在竞争需要某种线程同步来仲裁和决定赢家。
通过使用蹦床上下文,可以在初始上下文挂起之后启动操作来代表初始上下文。(不太理解这,原文为 There are probably ways around this by using a trampoline context that can start the operation on behalf of the initiating context after the initiating context has been suspended. )然而,这将需要额外的基础设施和额外的上下文切换来使其工作,这可能带来的开销将大于它试图避免的同步成本。
避免分配内存
异步操作通常需要存储一些每个操作的状态,以跟踪操作的进度。这个状态通常需要在操作期间持续,并且只应在操作完成后立即释放。
例如,调用异步 Win32 I/O 函数需要您分配并将指针传递给 OVERLAPPED
结构。调用者负责确保此指针在操作完成前保持有效。
对于传统的基于回调的 api,这种状态通常需要在堆上分配,以确保它具有合适的生命周期。如果您正在执行许多操作,则可能需要为每个操作分配和释放此状态。如果性能是一个问题,那么可以使用自定义分配器从池中分配这些状态对象。
然而,当我们使用协程时,我们可以利用协程帧内的局部变量在协程挂起时保持活跃的事实,从而避免为操作状态分配堆上存储。
通过将每个操作状态放在 Awaiter 对象中,我们可以高效地从协程帧中借用内存,用于存储 co_await
表达式期间的每个操作状态。一旦操作完成,协程将恢复,Awaiter 对象将被销毁,释放协程帧中的内存供其他局部变量使用。
最终,协程帧仍可能被分配到堆上。然而,一旦分配了,这个协程帧就可以使用这个单一的堆分配来执行许多异步操作。
如果你仔细想想,协程帧就像一种真正高性能的 arena 内存分配器。编译器在编译时计算出所有局部变量所需的总 arena 大小,然后能够根据需要将这些内存分配给局部变量,zero overhead!尝试使用自定义分配器来击败它;)
一个栗子:实现一个简单的线程同步原语
现在我们已经介绍了 co_await
操作符的许多机制,我想通过实现一个基本的 awaitable 同步原语(异步手动重置事件)来展示如何将这些知识应用到实践中。
此事件的基本需求是它需要被多个并发执行的协程可等待(Awaitable),当等待时需要挂起等待的协程,直到一些线程调用 .set()
方法,此时任何等待的协程都将恢复。如果某个线程已经调用了 .set()
,那么协程应该继续而不挂起。
理想情况下,我们还希望使它成为 noexcept
,不需要堆分配,并有一个无锁的实现。
2017./11/23 修改:添加 async_manual_reset_event
使用示例
使用示例类似这样:
1 | T value; |
让我们首先考虑下事件可能的状态:未设置
和 已设置
。
当它处于未设置状态时,会有一个 (可能为空的) 等待中的协程列表等待它被设置。
当它处于设置状态时,将不会有任何等待协程,因为在此状态下等待事件的协程可以继续而不暂停。
这个状态实际上可以用一个 std::atomic<void *>
来表示。
- 为
已设置
状态的指针保留一个特殊的指针值。在这种情况下,我们使用事件的this
指针,因为我们知道它不能与任何列表项的地址相同。 - 否则,事件处于未设置状态,值是指向等待状态中的协程结构的单链表头部的指针。
通过将节点存储在协程帧上的 ‘awaiter’ 对象内,我们可以避免为堆上的链表分配节点的额外调用。
让我们从一个类接口开始,它看起来像这样:
1 | class async_manual_reset_event |
这里我们有一个相当直接和简单的接口。此时需要注意的主要事情是,它有一个操作符 co_await()
方法,该方法返回一个尚未定义的类型 awaiter
。
让我们现在来定义 awaiter
类型。
定义 Awaiter
首选,我们需要知道它将等待哪一个 async_manual_reset_event
,因此它需要一个对事件的引用和一个构造函数来初始化它。
它还需要充当一个由 awaiter
值组成的链表的节点,因此它需要保存一个指向列表中下一个 awaiter
对象的指针。
它还需要存储正在执行 co_await
表达式的等待中协程的 coroutine_handle
,以便事件可以在被设置时恢复协程。我们不关心协程的 promise 类型是什么,因此我们使用 coroutine_handle
就好,(coroutine_handle<void>
的编写)。
最终, 它需要实现 Awaiter 接口,因此,它需要三个特定的方法 await_spend
,await_ready
和 await_resume
。我们不需要从 co_await
表达式中返回值,因此 await_resume
可以返回 void
。
一旦我们把所有这些放在一起,一个服务生的基本类接口看起来像这样。
1 | struct async_manual_reset_event::awaiter |
现在,当我们 co_await
一个事件时,如果事件已经被设置,我们不想挂起让协程挂起。所以,如果事件已经被设置,我们可以将 await_ready()
返回为 true
。
1 | bool async_manual_reset_event::awaiter::await_ready() const noexcept |
接下来,让我们看看 await_suspend()
方法。这通常是 awaitable 类型中最神奇的事情发生的地方。
首先,它需要将等待中协程的协程句柄存储到 m_awaitingCoroutine
成员中,以便事件稍后可以对其调用 .resume()
。
完成这些之后,我们需要尝试将 awaiter
原子地入队到等待者的链表中。如果我们成功地将其入队,那么我们返回 true
,表示我们不想立即恢复协程,否则,如果我们发现事件同时被更改为已设置状态,那么我们返回 false
,表示应该立即恢复协程。
1 | bool async_manual_reset_event::awaiter::await_suspend( |
注意,当我们加载旧的状态时,我们使用 acquire
内存顺序,因此,如果我们读到了特定的 ‘已设置’ 值,我们可以看到调用 set()
之前发生的写操作。
如果比较 - 替换成功,我们需要 release
语义,因此后续调用 set()
将看到我们写入 m_awaitingCoroutine 和写入之前协程状态。
补充事件类的其他部分
现在我们已经定义了 awaiter
类型,让我们回过头来看看 async_manual_reset_evnet
方法的实现。
首先,构造函数。它需要初始化到未设置状态,并使用空的等待者列表(如 nullptr
)或初始化到 ‘已设置’ 状态(即 this
)。
1 | async_manual_reset_event::async_manual_reset_event( |
然后,is_set()
方法非常直接 —— 通过判断它是否有特殊的 this
值表示它是否被设置。
1 | bool async_manual_reset_event::is_set() const noexcept |
接下来,reset()
方法。如果它处于 ‘已设置’ 状态,我们希望转换回未设置时空链表的状态,而不是维持原样。
1 | void async_manual_reset_event::reset() noexcept |
在 set
方法中,我们希望通过将当头状态与特殊设定值 this
交换来转换到 ‘已设置’ 状态。然后检查原来的值是什么。如果有任务等待中的协程,那么我们希望在返回之前依次恢复每个协程。
1 | void async_manual_reset_event::set() noexcept |
最后,我们需要实现 operator co_await()
方法。这只需要构造一个 awaiter
对象。
1 | async_manual_reset_event::awaiter |
我们创造了它。一个可异步等待的手动重置事件,它具有无锁、无内存分配、noexcept
的实现。
如果您想使用代码玩耍,或查看其在 MSVC 和 Clang 上的编译,可以在 godbolt 上查看源代码。
你也可以在 cppcoro 库中找到该类的实现,以及其他一些有用的 awaitable 类型,如 async_mutex
和 async_auto_reset_event
。
结束语
本文介绍了 operator co_await
是如何实现的,以及如何根据 Awaitable 和 Awaiter 概念定义的。
本文还介绍了如何实现一个可等待的异步线程同步原语,该原语利用了在协程帧上分配等待对象以避免额外堆分配的事实。
我希望这篇文章可以帮助你揭开新 co_await
操作符的神秘面纱。
在下一篇文章中,我将探讨 Promise
概念,以及协程类型作者如何定制他们协程的行为。
致谢
我想特别感谢 Gor Nishanov 在过去几年中耐心和热情地回答了我关于协程的许多问题。
并向 Eric Niebler 审查并提供有关本文早期草案的反馈。
留言
非常欢迎在 这个 Github issue 上进行留言。