C++协程:promise类型(上)

照例,原文链接如下:
understanding-the-promise-type

这是对于协程简单理解的第三篇文章,前两篇的链接如下:

协程的概念

正如之前所说,协程新增了三个关键字:co_await,co_yieldco_return
前两篇文章介绍了Awaitable接口,本文将用来介绍Promise类型。

Promise对象

promise对象将在每次协程被唤醒时,在coroutine frame中被构造。也可以将协程的promise对象视作"协程状态控制",其状态包括suspendresume

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

以下是当一个协程被调用时所发生的步骤,每个步骤都会在接下来被更加详细的进一步解释:

  1. 分配一个coroutine frame。
  2. 将函数中的参数拷贝到coroutine frame中。
  3. 调用promise对象的构造器,P
  4. 调用promise.get_return_object()来获得当协程第一次暂停时返回到调用者的结果。将该结果保存为本地变量。
  5. 调用promise.initial_suspend()co_await这个结果。
  6. promise.initial_suspend()表达式恢复(立即恢复或异步恢复),紧接着协程将会开始计算你写的body-statements。

当执行到co_return声明时,一些额外的步骤将会被执行:

  1. 调用promise.return_void()promise.return_value(<expr>)
  2. 以创建的相反方向释放所有具有自动存储时间的变量。
  3. 调用promise.final_suspend()co_await结果。

但是如果发生了无法解决的意外,那么将会发生如下步骤:

  1. 捕获异常并调用promise.unhandled_exception()
  2. 调用promise.final_suspend()co_await结果。

一旦执行离开了协程的主题,那么coroutine frame将会被销毁。销毁coroutine frame包含以下几个步骤:

  1. 调用promise对象的析构。
  2. 调用函数参数复制的析构。
  3. 调用operator delete来释放coroutine frame所使用的内存。
  4. 将执行点返回到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类型类似,也有一些不同,后面会专门写一篇来比较两者的不同。

  • 4
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值