【翻译】 C++ Coroutines: understanding-the-promise-type
这是Lewis Baker讲解协程的第三篇。本篇主要描述如何自定义协程关键字的行为。
原文:https://lewissbaker.github.io/2018/09/05/understanding-the-promise-type
本篇主要讲解编译器如何转义协程中的代码,以及如何通过自定义Promise类型来定制协程行为。
协程概念
协程提案添加了3个关键字:co_await, co_yield和co_return。无论何时只要在函数体内使用了其中之一的关键字,就会触发编译器把该函数编译为协程而不是普通函数。
编译器使用了很机械的转换,即转换代码为状态机,允许在函数内特定位置暂停,以及之后的恢复执行。
前面两篇中我描述了协程提案中介绍的两个新接口中的第一个:Awaitable接口。第二个对于代码转换很重要的接口是Promise接口。
Promise接口指定了定义协程本身行为的方法。库编写者可以自定义协程被调用后的行为,协程返回后的行为(包括正常返回以及异常返回),以及协程内任何co_await或者co_yield表达式的行为。
Promise对象
Promise对象定义并控制了协程本身的行为,通过实现协程执行中在特殊点调用的方法来达成。
在继续之前,我希望你能摆脱对”promise“的任何预想的观点。虽然在一些用例中,协程的promise对象确实和
std::future对的std::promise角色很像,对于其他的用例,这样的类比会有一点延伸。把协程的promise对象想象成”协程状态控制者“对象更容易,即控制协程的行为以及可以被用来跟踪它的状态。
promise对象的实例是在每一个协程函数调用中的协程栈内构造的。
编译器会在协程运行中的关键点生成调用promise对象的对应方法。
接下来的例子中,假设对一个协程的贴别调用中在协程栈上创建的promise对象为promise。
以及一个协程函数,函数体为<body-statements>,包含了3个关键字之一(co_return,co_await,co_yield),然后协程体会大致转义类似如下:
{ |
当一个协程函数被调用,会有一系列步骤在协程体之前执行,这和正常函数有一点不同。
这是概要的步骤(后续会进一步讲解这些步骤):
- 使用
operator new来分配一个协程栈(可选); - 拷贝任何函数参数到协程栈;
- 调用promise对象类型
P的构造函数; - 在协程第一次暂停时调用
promise.get_return_object()方法获取结果来返回调用者。保存结果为局部变量; - 调用
promise.initial_suspend()方法并co_await结果; - 当
co_await promise.initial_suspend()表达式恢复(立即或者异步的),然后协程开始执行编码的协程体语句;
执行到co_return声明时会执行额外的步骤:
- 调用
promise.return_void()或者promise.return_value(<expr>); - 按创建的倒序销毁所有自动存续期内的变量;
- 调用
promise.final_suspend()并co_await结果
如果执行是在<body-statements>中通过未处理异常返回的:
- 在catch-block中捕获异常并调用
promise.unhandled_exception() - 调用
promise.final_suspend()并co_await结果
一旦执行传播到协程体之外,协程栈就会被销毁。销毁协程栈包括如下几步:
- 调用promise对象额析构函数;
- 调用复制的函数参数的析构;
- 调用
operator delete来释放协程栈使用的内存; - 转移执行回到调用者/恢复者
当在co_await表达式中的执行第一次遇到<return-to-caller-or-resumer>点时,或者协程执行到完成而没有遇到<return-to-caller-or-resumer>,然后协程要么挂起要么销毁,并且之前通过调用promise.get_return_object()的返回对象就返回给协程的调用者。
分配一个协程栈
首先编译器生成operator new的调用来给协程栈分配内存。
如果promise类型P自定义了operator new方法,那么调用自定义的,否则调用全局的operator new。
有一些需要注意的重点:
传入operator new的大小不是sizeof(P),而是包括了整个协程栈的大小以及编译器根据参数个数和大小、promise对象大小、局部变量的个数与大小和编译器特定的用于管理协程状态的存储开销一起自动决定的。
编译器可以根据如下情形来优化掉operator new:
- 可以确定协程栈的生命周期严格嵌套在调用者内;
- 编译器可以在调用端看到协程栈的大小;
在这些情况中,编译器可以再调用者的栈帧上(要么是栈空间要么是协程栈内)为协程栈分配存储空间。
协程提案并没有指定任何情形分配内存的优化时一定会存在,所以仍然需要对协程栈可能的std::bad_alloc失败编写一些代码。这也意味着,通常不应该声明一个协程函数为noexcept,除非你能接受在协程栈分配内存失败时会被调用std::terminate()。
然而,有一类退化方法可以用来替代处理分配协程栈失败的异常。这在不允许异常的环境中非常有必要,例如嵌入式环境或者高性能环境,这些环境对于异常的开销是无法忍受的。
如果promise类型提供了一个静态的成员函数P::get_return_object_on_allocation_failure(),那么编译器就会生成重载operator new(size_t, nothrow_t)的调用。如果这个调用返回了nullptr然后协程会立即调用P::get_return_object_on_allocation_failure(),然后返回该结果给协程的调用者而不是抛出异常。
自定义协程栈的内存分配器
promise类型可以重载operator new(),在编译器需要为使用了你的promise类型的协程分配内存时,就会使用自定义的分配器来替代全局的operator new。
例如:
struct my_promise_type |
“这哪里是自定义了分配器?”,我听到了你的疑问。
你同时可以提供包括额外参数的P::operator new()重载,如果有合适的重载,就会被调用并传入协程函数参数的左值引用。这可被用于替代operator new来调用分配器上的allocate()方法,来作为参数传递给协程函数。
你需要做一些额外的工作在分配的内存中拷贝分配器,由此你可以在对应的operator delete调用中引用,因为并没有参数传递到对应的operator delete调用。这么做是因为这些参数存储在协程栈上,所以调用operator delete时他,他们已经被析构了。
例如,你可以实现operator new来在协程栈之后分配一些额外的空间,使用这些空间来暂存分配器的备份,最后可以用来释放协程栈的内存。
例如:
template<typename ALLOCATOR> |
要使用把std::allocator_arg作为第一个参数的自定义my_promise_type用于协程,需要特例化coroutine_traits类(下文会对coroutine_traits做更详细的解释)。
例如:
namespace std::experimental |
注意到,即使自定义了协程的内存分配策略,编译仍然被允许忽略你的内存分配器。
拷贝参数到协程栈
协程需要拷贝原始调用者传递给协程函数的任何参数到协程栈,由此在协程挂起后仍然是有效的。
如果参数是通过值传递到协程的,那么这些参数会通过调用类型的move构造函数来拷贝到协程栈。
如果参数通过引用传递到协程(包括左值和右值引用),那么只有引用拷贝到了协程栈,而不是指向的值。
注意到对于有trivial析构的类型,如果参数在<return-to-caller-or-resumer>点之后不再被引用,编译器完全可以跳过拷贝。
这里有很多陷进,当通过引用来传递参数给协程时,你不能在协程生命周期内完全依赖引用依旧有效。很多用于普通函数的常用技术,例如完美转发和全局引用,可能会在协程中导致为定义行为。Toby Allsopp写了一篇很棒的文章来阐述这个话题。
如果在调用参数的拷贝/移动构造函数是抛出了异常,那么任何已经构造的参数就会被析构,协程栈会被释放,且该异常会被传回给调用者。
构造promise对象
一旦所有的参数已经被拷贝到了协程栈,然后协程就会构造promise对象。
参数的拷贝优先于promise对象构造的是为了允许promise对象可以在构造函数中访问被拷贝的参数。
首先编译器会检查promise对象是否有可以接收已拷贝参数左值引用的重载构造函数。如果有发现,那么编译器会生成该重载构造函数的调用。如果没找到,那么编译器会退化生成promise类型默认构造函数的调用。
注意到,promise构造函数可以“挑选”参数的能力和协程提案最近的修改有关,是在Jacksonville 2018 meeting中采用到N4723。可以在P0914R1中找到该提案。因此这个特性可能还没有被老版本的Clang后者MSVC支持。
如果promise在构造函数中抛出了异常,那么已拷贝的参数会被析构,且在异常传递给调用者之前,协程栈会在stack的退栈中被释放。
获取返回对象
对于promise对象协程首先要做的是通过调用promise.get_return_object()来获取return-object。
在协程第一次挂起或者在协程运行到完成且执行返回到调用者时,return-object是作为返回给协程函数调用者的值。
你可以参考控制流是(大体上)如下流转的:
// Pretend there's a compiler-generated structure called 'coroutine_frame' |
注意到我们需要在开始协程体之前获取返回对象,因为协程栈(也即promise对象)可能在调用coroutine_handle::resume()返回前已经被析构了,要么在当前线程要么在另外的线程,所以在开始协程体执行后调用get_return_object()是不安全的。
初始挂起点
一旦协程栈被初始化完以及返回对象已经被获取后,协程下一步要做的是执行co_await promise.initial_suspend();。
这允许promise_type的作者可以控制协程在执行协程体代码之前是应该挂起,还是立即执行。
如果协程在最初的挂起点挂起,那么在之后某刻就可以通过对协程的句柄(coroutine_handle)调用resume()或者destroy()来恢复或者销毁。
co_await promise.initial_suspend()表达式的返回结果会被丢弃,所以通常在等待者的await_resume()方法实现中返回void。
重要的是,try/catch块之外的声明保护了协程剩余的部分(如果忘了是如何定义的可以放回到前文协程体的定义)。这意味着在co_await promise.initial_suspend()求值中抛出的任何异常都早于<return-to-caller-or-resumer>点,会在销毁协程栈和返回对象之后抛回给协程的调用者。
如果你的return-object有在析构时销毁协程栈的RAII语义时需要特别注意。这种情况下你需要确保co_await promise.initial_suspend()是noexcept来避免协程栈的double-free。
注意到之前有一个提议来修复这样的语义,要求协程体中必须全部而不是部分的
co_await promise.initial_suspend()表达式都位于try/catch块之内,因此在协程最终确定前这里的确切语义可能会发生变化。
对于很多协程类型,initial_suspend()方法要么返回std::experimental::suspend_always(在操作是惰性启动时),或者std::experimental::suspend_never(在操作是立即启动时),这两类都是noexcept的可等待类型所以通常情况这不是问题。
返回调用者
当协程函数运行到第一个<return-to-caller-or-resumer>点(或者没有这样的点那么是运行到完成),然后从get_return_object()调用中返回的return-object会被返回被协程的调用者。
注意return-object的类型并不一定需要和协程函数的返回类型一致。如果有必要,return-obejct会被隐式转换为协程的返回类型。
注意Clang(5.0)的协程实现中把该转换推迟到了return-object已经从协程调用中返回的时候,而MSVC(2017 Update 3)的实现中该转换会在调用
get_return_object()时立即执行。虽然协程提案中并没有明确预期的行为,我相信MSVC已经计划改变他们的实现,而更接近Clang的版本,因为这种设计可以实现一些有趣的用例。
用co_return从协程中返回
当协程运行到co_return语句,会转义为要么调用promise.return_void()或者promise.return_value(<expr>),接着是goto FinalSuspend;。
转义遵循如下规则:
co_return;
->promise.return_void();co_return <expr>;
-><expr>; promise.return_void();if<expr>has typevoid
->promise.return_value(<expr>);if<expr>dose not have typevoid
随后的goto FinalSuspend;会首先引发自动存储周期的所有局部变量以构造顺序的逆序来析构,然后求值co_await promise.final_suspend();。
注意如果运行到协程结束也没有co_return语句那么就等价于在函数体最后是co_return;。在这种情况下,如果promise_type没有return_void()方法,那么行为就是未定义的。
如果<expr>的求值或者对promise.return_void()的调用,或者promise.return_value(),中抛出了异常,那么异常仍然会传递给promise.unhandled_exception()(参考下文)。
处理协程内透出的异常
如果异常从协程体内传播出,那么异常会被捕获,且catch块内的promise.unhandled_exception()方法会被调用。
该方法的实现中通常会调用std::current_exception()来捕获异常的一个副本来保存起来,用于之后在不同的上下文中重新抛出。
可选的是,实现中可以通过运行throw;语句来立即重新抛出异常。例如参考folly::Optional。然而这么做(有可能 - 参考下文)会导致协程栈被立即析构并把异常传递给调用者/恢复者。这对于一些假设/要求调用coroutine_handle::resume()为noexcept的抽象会带来问题,所以你应该大体上只能在完全掌控谁或者什么来调用resume()时使用该方法。
注意当前的Coroutines TS中对于调用unhandled_exception()重新抛出异常(或者任何try块之外的任何逻辑抛出一个异常)后预期行为的语法有一点不明确。
我当前对该语法的解释是如果在协程体中有控制,要么通过把异常抛出co_await promise.initial_suspend(),promise.unhandled_exception()或者co_await promise.final_suspend()或者当协程运行到完成来通过co_await p.final_suspend()来完成同步,然后在返回调用者/恢复者之前协程栈会自动析构。然而这样的解释也有问题。
未来版本的协程说明有希望会清晰的阐述该场景。然而目前为止,我会说保持避免在initial_suspend(), final_suspend() 或者 unhandled_exception()中抛出异常。敬请期待!
最后的挂起点
一旦运行从用户定义的协程体中退出,且结果已经通过调用return_void(),return_value()或者unhandled_exception()捕获,以及所有的局部变量已经被析构,这时候在返回到调用者/恢复者之前协程有一个机会来运行一些额外的逻辑。
协程会运行co_await promise.final_suspend();语句。
这允许了协程来运行一些逻辑,例如通知结果,发送完成的信号或者恢复继续执行。这同样允许了协程可以在运行到结束前且协程栈被销毁前立即挂起。
注意resume()一个在final_suspend点挂起的协程行为是未定义的。在这个点挂起的协程你只能对其执行destroy()。
根据Gor Nishanov,这个限制的理由是,编译器可以有机会做一些优化,因为协程对应的挂起状态的数量变少了,以及潜在的所需的分支也变少了。
注意虽然一个协程不能在final_suspend点挂起,但是推荐对协程结构化进而在可能的时候在final_suspend点挂起。因为这样强制了需要在协程之外调用.destroy()(一般是通过RAII对象的析构函数)且这会让编译器更容易发现协程栈的生命周期是否嵌套在调用者。这进一步使得编译器可以省略协程栈的内存分配。
编译器如何选择promise类型
让我们来看下编译器如何决定一个给定协程的promise对象类型。
promise对象的类型可以通过使用std::experimental::coroutine_traits类来从协程的签名中确定。
如果有一个协程函数的签名如下:
task<float> foo(std::string x, bool flag); |
然后编译器会通过把返回类型和参数类型作为模板参数传递给coroutine_traits来推导协程的promise类型。
typename coroutine_traits<task<float>, std::string, bool>::promise_type; |
如果函数是非静态的成员函数,那么类的类型会作为第二个模板参数传递给coroutine_traits。注意如果你的方法是右值引用的重载,那么第二个模板参数就是右值引用的。
例如,如果有如下类型:
task<void> my_class::method1(int x) const; |
然后编译器会使用如下promise类型:
// method1 promise type |
coroutine_traits模板的默认通过查找返回类型上嵌套的promise_typetypedef来定义promise_type。如下面的例子(但是在一些特殊的SFINAE魔法中如果RET::promise_type没有定义那么promise_type也是未定义的)。
namespace std::experimental |
对于可以控制协程的返回类型的情况,所以可以通过在类内定义一个嵌套的promise_type,来使编译器把这个类型作为协程的promise对象的类型。
例如:
template<typename T> |
然而对于不能控制协程返回类型时,你可以特例化coroutine_traits来定义promise类型而不用修改该类型。
例如,定义使用std::optional<T>作为promise类型的协程:
namespace std::experimental |
识别特定协程的栈帧
当调用一个协程函数时,协程栈会被创建。为了恢复对应的协程或者销毁协程栈,你需要能识别或者找到特定的协程栈。
协程提案中提供的机制是coroutine_handle类型。
该类型的精简接口如下:
namespace std::experimental |
你可以有两种方法来获取一个协程的coroutine_handle:
- 在
co_await表达式中它会被传递给await_suspend()方法; - 如果你有协程promise类型的引用,你可以通过
coroutine_handle<Promise>::from_promise()来重新构建出coroutine_handle;
协程在co_await表达式中的<suspend-point>挂起后,把coroutine_handle会被传递到等待着的await_suspend()方法。你可以把coroutine_handle作为继续协程的一个continuation-passing style调用。
注意coroutine_handle并不是RAII对象。你可以手工调用.destroy()来销毁协程栈并释放资源。可以把他作为管理内存的一个void*指针。这是为了性能考虑:如果做成RAII对象,会给协程增加额外的开销,例如需要维护引用计数。
你通常需要尝试使用高层了类型来给协程提供RAII语义,例如使用coppcoro中提供的类型,或者自己编写高层类型囊括协程栈的整个生命周期。
自定义co_await的行为
promise类型可选的可以自定义协程体中每一个co_await表达式的行为。
可以很简单的通过为promise类型定义await_transform(),编译器就会把协程体中每一个co_await <expr>转义为co_await promise.await_transform(<expr>)。
这可以实现很多重要且有用的场景:
可以实现等待类型不像通常的变为可等待的。
例如协程的promise类型拥有std::optional<T>返回类型,可以提供一个await_transform()重载并接收std::optional<U>,那么就会返回一个可等待类型,要么返回类型为U的值,或者在等待值为nullopt时挂起协程。
template<typename T> |
可以通过声明await_transform重载为deleted来阻止等待特定类型
例如返回类型为std::generator<T>的promise类型可以声明一个接收任何类型的deleted的await_transform()模板成员函数。这就阻止了对在该协程内使用co_await。
template<typename T> |
可以实现适配且改变通常可等待值的行为。
例如,你可以通过包装resume_on()操作符内的可等待实现,在定义一个协程类型确保协程总是会在一个相关的执行者上从co_await表达式恢复(参考cppcoro::resume_on())。
template<typename T, typename Executor> |
最后对await_transform()提一点,需要特别注意的是如果promise类型定义了任何await_transform()成员,那么就会触发编译器对所有的co_await表达式转义为调用promise.await_transform()。这意味着如果你只想自定义一部分co_await的行为,你需要提供一个退化版本的await_transform()来直接转发这些参数。
自定义co_yield的行为
最后一个可以通过promise类型自定义的是co_yield关键字的行为。
如果co_yield关键字出现在协程中,那么编译器会把co_yield <expr>转义为co_await promise.yield_value(<expr>)。promise类型因此可以通过对promise对象定义一个或者多个yield_value()方法来自定义co_yield关键字的行为。
注意,不像await_transform,promise类型在没有定义yield_value()方法时对co_yield没有默认行为。所以promise类型可以通过声明一个deleted的await_transform()来明确不允许co_await,相反promise类型需要特地的声明来支持co_yield。
promise类型定义yield_value()方法的典型例子为generator<T>类型:
template<typename T> |
总结
在本篇中,我描述了编译器在编译协程时要做的每一个独立转换。
希望本篇有助于你理解如何通过定义不同的promise类型来自定义不同协程类型的行为。协程机制中有很多可以灵活配置的,所以你可以有很多方法来自定义他们的行为。
然而还有一个编译器会做的更重要的转换还没涉及,如何把协程体转义为状态机。本篇已经足够长了所以我会放在下一篇中讲解。保持期待!