照例,原文链接如下:
understanding-the-promise-type
这是对于协程简单理解的第三篇文章,前两篇的链接如下:
协程的概念
正如之前所说,协程新增了三个关键字:co_await,co_yield和co_return。
前两篇文章介绍了Awaitable接口,本文将用来介绍Promise类型。
Promise对象
promise对象将在每次协程被唤醒时,在coroutine frame中被构造。也可以将协程的promise对象视作"协程状态控制",其状态包括suspend和resume。
{
co_await promise.initial_suspend();
try
{
<body-statements>
}
catch (...)
{
promise.unhandled_exception();
}
FinalSuspend:
co_await promise.final_suspend();
}
以下是当一个协程被调用时所发生的步骤,每个步骤都会在接下来被更加详细的进一步解释:
- 分配一个coroutine frame。
- 将函数中的参数拷贝到coroutine frame中。
- 调用promise对象的构造器,P。
- 调用
promise.get_return_object()
来获得当协程第一次暂停时返回到调用者的结果。将该结果保存为本地变量。 - 调用
promise.initial_suspend()
并co_await
这个结果。 - 当
promise.initial_suspend()
表达式恢复(立即恢复或异步恢复),紧接着协程将会开始计算你写的body-statements。
当执行到co_return
声明时,一些额外的步骤将会被执行:
- 调用
promise.return_void()
或promise.return_value(<expr>)
。 - 以创建的相反方向释放所有具有自动存储时间的变量。
- 调用
promise.final_suspend()
并co_await
结果。
但是如果发生了无法解决的意外,那么将会发生如下步骤:
- 捕获异常并调用
promise.unhandled_exception()
。 - 调用
promise.final_suspend()
并co_await
结果。
一旦执行离开了协程的主题,那么coroutine frame将会被销毁。销毁coroutine frame包含以下几个步骤:
- 调用promise对象的析构。
- 调用函数参数复制的析构。
- 调用
operator delete
来释放coroutine frame所使用的内存。 - 将执行点返回到caller/resumer
以上这段很像RAII机制,会自动分配内存,自动释放内存。
当执行第一次在一个co_await
的表达式中到达一个<reach-to-caller-or-resumer>
点,或者如果协程在没有触及<reach-to-caller-or-resumer>
点,接下来协程将会被挂起或销毁,并且之前通过调用promise.get_return_object()
所返回的返回对象将会被返回给协程的调用者。
分配一个coroutine frame
首先,编译器将会生成一个对operator new
的调用来为coroutine frame分配内存。以下时一些需要注意的重要事项:
如果promise类型,P,定义了自己的operator new
,那么就使用自己定义的操作,如果没有定义,那么就使用默认的。
传到operator new
的体积并不是sizeof(P)
,而是整个coroutine frame的体积。该体积由编译器自动根据参数的数量和体积,promise对象的体积,本地变量的数量和体积,以及其他一些需要用来管理协程状态的编译器特定的存储空间来决定。
在以下情况中,编译器可以通过省略对operator new
的调用来实现优化:
- 能够确保coroutine的生命周期被严格嵌套在调用者的生命周期内。
- 编译器能够确定调用站点所需要的coroutine frame的大小。
在这些情况下,编译器无需再重新分配新的内存,可以在调用者的activation frame种为coroutine frame分配存储空间。(activation frame参见 协程原理)
协程尚未明确在何种情况下可以省略分配。因此你仍然需要编写代码,就好像使用std::bad_alloc
会导致coroutine frame的分配失败一样。这也意味着通常不应该将协程函数声明为noexcept,(程序不会抛出异常)除非您同意在协程无法为协程帧分配内存时调用std::terminate()。
当然,协程还需要有一个用来处理分配coroutine frame失败的异常处理机制。在一些不允许有异常发生的操作环境种(比如嵌入式环境或不能容忍异常开销的高性能环境),这是非常有必要的。
如果promise类型能够提供一个静态的P::get_return_object_on_allocation_failure()
成员函数,那么编译器将会生成一个对operator new(size_t, nothrow_t) overload
的overload调用。如果该调用返回了nullptr,即分配失败,那么协程将立即通过调用P::get_return_object_on_allocation_failure()
来将结果返回给协程调用者,而不是直接抛出异常,阻断程序运行。
自定义coroutine frame的内存分配
正如上文所述,promise类型可以提供一个自己定义的operator new()
以供编译器在为coroutine frame分配内存时使用,例子如下:
struct my_promise_type
{
void* operator new(std::size_t size)
{
void* ptr = my_custom_allocate(size);
if (!ptr) throw std::bad_alloc{};
return ptr;
}
void operator delete(void* ptr, std::size_t size)
{
my_custom_free(ptr, size);
}
...
};
如果有需要,也可以自定义分配器。你可以通过提供一个对P::operator new()
的重载,它接受额外的参数,如果能找到合适的重载,这些参数将被左值引用到协程函数的参数中调用。这可以将operator new()
和被作为协程参数的在分配器上的allocate()
方法联系起来。
此外,你还需要做一些额外的工作来在分配好的内存里创建分配器的副本,这样你就可以在对operator delete
的相应调用中引用它,因为参数没有传递给相应的operator delete
调用。这是因为参数存储在coroutine frame中,因此在调用delete
操作符时,它们已经被销毁了。
举个例子,你可以实现operator new
,以便它在协程帧之后分配额外的空间,并使用该空间存放分配器的副本,该分配器可用于释放协程帧内存。
template<typename ALLOCATOR>
struct my_promise_type
{
template<typename... ARGS>
void* operator new(std::size_t sz, std::allocator_arg_t, ALLOCATOR& allocator, ARGS&... args)
{
// Round up sz to next multiple of ALLOCATOR alignment
std::size_t allocatorOffset =
(sz + alignof(ALLOCATOR) - 1u) & ~(alignof(ALLOCATOR) - 1u);
// Call onto allocator to allocate space for coroutine frame.
void* ptr = allocator.allocate(allocatorOffset + sizeof(ALLOCATOR));
// Take a copy of the allocator (assuming noexcept copy constructor here)
new (((char*)ptr) + allocatorOffset) ALLOCATOR(allocator);
return ptr;
}
void operator delete(void* ptr, std::size_t sz)
{
std::size_t allocatorOffset =
(sz + alignof(ALLOCATOR) - 1u) & ~(alignof(ALLOCATOR) - 1u);
ALLOCATOR& allocator = *reinterpret_cast<ALLOCATOR*>(
((char*)ptr) + allocatorOffset);
// Move allocator to local variable first so it isn't freeing its
// own memory from underneath itself.
// Assuming allocator move-constructor is noexcept here.
ALLOCATOR allocatorCopy = std::move(allocator);
// But don't forget to destruct allocator object in coroutine frame
allocator.~ALLOCATOR();
// Finally, free the memory using the allocator.
allocatorCopy.deallocate(ptr, allocatorOffset + sizeof(ALLOCATOR));
}
}
要将自定义my_promise_type用于将std::allocator_arg作为第一个参数的协程,你需要对coroutine_traits类进行更多的处理(更多细节请参阅下面的coroutine_traits一节)。
举个例子:
namespace std::experimental
{
template<typename ALLOCATOR, typename... ARGS>
struct coroutine_traits<my_return_type, std::allocator_arg_t, ALLOCATOR, ARGS...>
{
using promise_type = my_promise_type<ALLOCATOR>;
};
}
请注意,即便你已经自定义了协程的内存分配策略,编译器仍然能够省略对你的内存分配器的调用。
将参数拷贝到coroutine frame中
协程需要被原始调用者传到协程函数里的参数拷贝到coroutine frame中,因此参数才能在协程被挂起后仍然保持有效。
如果参数以值的形式被传递到协程中,那么这些参数都会以调用类型的移动构造的方式被拷贝到coroutine frame中去。
如果参数以引用的形式被传递到协程中(无论左值还是右值),那么只有引用会被拷贝到coroutine frame中,而不是他们所指向的值。
注意,对于具有简单析构函数的类型,如果形参在协程中可达的<return-to-caller-or-resumer>
点之后从未被引用,则编译器可以自由地省略形参的副本。也就是说,如果该参数在协程被挂起期间,并没有被调用,那么编译器可以直接跳过这一步骤。
当通过引用将参数传递到协程时,会涉及到很多陷阱,首要的就是必须要确保引用在协程的生命周期内保持有效。如果想要了解更多细节,参见Toby Allsopp的文章。
如果任一参数的拷贝/移动构造抛出了一个异常,那么已经构造完成的参数将会被销毁,coroutine frame将会被释放,异常会被传递回调用者。
这玩意儿太长了,剩下的部分将会在下一篇文章中进行进一步的详细解释。
本文主要讲的是promise类型的协程被调用的全过程,主要分为七个步骤,与awaitable类型类似,也有一些不同,后面会专门写一篇来比较两者的不同。