回到顶部 暗色模式

C++协程(3):理解promise

        这篇文章是C++协程标准系列的第三篇文章。
        之前的文章可以在这里查看:

        在这篇文章我会讲解你写的代码是怎么被编译器编译成协程代码的,并且你可以通过定义自己的 $Promise$ 类型来自定义协程行为。

协程concepts

        协程标准添加了三个新的关键字:$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$-$statement$> ,函数体包含了某个协程关键字 ( $co_-return$,$co_-await$,$co_-yield$ ),那么协程体将转变为 ( 大致地 ) 如下形式:

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

        一个协程函数被调用后,在执行之前会有一系列的准备步骤,这些步骤与常规的函数会有些不同。
        以下是一个步骤总结 ( 我会在接下来详细介绍每个步骤 ):

  1. 通过 $operator$ $new$ ( 可选的 ) 分配协程帧。
  2. 将所有函数参数拷贝到协程帧。
  3. 调用 $promise$ 对象的构造函数,$promise$ 类型记为 $P$ 。
  4. 调用 $promise.get_-return_-object\left(\right)$ 方法获取协程首次暂停时需要返回给主调的返回值,并保存为一个局部变量。
  5. $co_-await$ 调用 $promise.initial_-suspend\left(\right)$ 方法获取结果。
  6. 当 $co_-await$ $promise.initial_-suspend\left(\right)$ 表达式恢复 ( 可能立即恢复或者异步恢复 ) 后,协程开始执行你编写的协程体语句。

        当协程执行到 $co_-return$ 语句时,会进行一些额外的步骤:

  1. 调用 $promise.return_-void\left(\right)$ 或者 $promise.return_-value$(<$expr$>)。
  2. 以与创建顺序相反的顺序自动销毁所有具有自动生命周期的变量。
  3. $co_-await$ 调用 $promise.final_-suspend\left(\right)$ 获取结果。

        如果执行因为一个未处理异常停止,那么会发生:

  1. catch块中捕获异常,调用 $promise.unhandled_-exception\left(\right)$ 。
  2. $co_-await$ 调用 $promise.final_-suspend\left(\right)$ 获取结果。

        一旦执行到协程体之外,协程帧就会被销毁。通过以下步骤销毁协程帧:

  1. 调用 $promise$ 对象的析构函数。
  2. 调用拷贝的函数参数的析构函数。
  3. 调用 $operator$ $delete$ 释放协程帧的内存空间 ( 可选的 )。
  4. 返回控制权给主调/恢复者。

        当执行首次到达 $co_-await$ 表达式的 <$return$-$to$-$caller$-$resumer$> 点,或者协程没有到达任何 <$return$-$to$-$caller$-$or$-$resumer$> 点就完成时,协程要么被暂停,要么被销毁,然后之前 $promise.get_-return_-object\left(\right)$ 调用返回的结果就会被返回给协程的主调。

分配协程帧

        首先,编译器生成一个对协程帧的 $operator$ $new$ 调用来分配内存。
        如果 $promise$ 类型 $P$,定义了一个特殊的 $operator$ $new$ ,那么就会调用它定义的,否则就调用全局的。
        这里有几个重要点需要注意:
        传给 $operator$ $new$ 的大小不是 $sizeof\left(P\right)$ ,而是整个编译过程中编译器计算出来的协程帧大小,包括了入参的数量和大小、$promise$ 对象的大小、局部变量的数量和大小,以及一些其他编译器相关的用来管理协程状态的空间。
        如果满足以下条件,编译器可以省掉对 $operator$ $new$ 的调用:

        在这种情况下,编译器可以在主调的调用栈 ( 栈帧部分或者协程帧部分 ) 上分配协程帧的空间。
        协程标准没有定义什么情况下需要跳过分配,所以你需要在协程帧分配可能抛出 $std$::$bad_-alloc$ 异常的前提下编写代码。这意味着你不应该把一个协程函数声明成noexcept,除非你确定协程分配协程帧内存失败后会调用 $std$::$terminate\left(\right)$ 。
        然而,有一个后备方案可以处理协程帧分配失败。在不允许异常的环境下,例如集成场景或者高性能场景这种不能容忍异常开销的场景,这种方式十分必要。
        如果 $promise$ 类型提供了一个静态的 $P$::$get_-return_-object_-on_-allocation_-failure\left(\right)$ 成员函数,编译器会生成一个对 $operator$ $new$($size_-t$, $nothrow_-t$) 的重载调用。如果调用返回nullptr,那么协程会立即调用 $P$::$get_-return_-object_-on_-allocation_-failure\left(\right)$ 并把结果返回给主调,而不是抛出异常。

自定义协程帧内存分配

        你的 $promise$ 类型可以重载 $operator$ $new$ ,这样如果编译器需要分配协程帧内存,就会使用你定义的 $operator$ $new$ 而不是全局的 $operator$ $new$ 。
        例如:

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);
  }

  ...
}

        我知道你想问什么:“但是自定义 $allocator$ 呢?”。
        你也可以提供 $P$::$operator$ $new\left(\right)$ 的重载,接收额外的参数,如果能找到合适的重载,将使用协程函数参数的左值引用调用。这种方式可以用来 $hook$ $operator$ $new$ ,使其调用某个作为参数传递给协程函数的 $allocator$ 的 $allocate\left(\right)$ 方法。
        你需要做一些额外工作来在分配好的内存中拷贝一份 $allocator$ 的副本,这样才能在相应的 $operator$ $delete$ 调用中引用它,因为 $allocator$ 不会作为参数传递给的 $operator$ $delete$ 。这是因为入参会被存储在协程帧,所以他们有可能会在 $operator$ $delete$ 调用之前就被销毁了。
        例如,你可以实现 $operator$ $new$ 来给协程帧分配一些额外内存,并利用这个空间来存储 $allocator$,用于释放协程帧内存。
        例如:

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.
    allocatorFactory.deallocate(ptr, allocatorOffset + sizeof(ALLOCATOR));
  }
}

        为了 $hook$ 成功,让自定义的 $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>;
  };
}

        注意,即使你自定义了协程的内存分配策略,编译器还是可以跳过你的内存分配过程。

拷贝参数到协程帧

        协程需要从原始主调中把所有传给协程的参数拷贝到协程帧,这样才能保证协程被暂停后,它们依然有效。
        如果参数是值传递的,那么这些参数将会通过移动构造函数拷贝到协程帧。
        如果参数以引用传递 ( 左值或者右值 ),那么只有引用会被拷贝到协程帧,而不是它们指向的值。
        注意如果类型的析构函数是默认的,并且在到达 <$return$-$to$-$caller$-$or$-$resumer$> 点后不再引用,编译器可以跳过析构该参数副本。
        在以引用方式传递参数给协程的过程中,存在几个陷阱,也因此你不能在协程生命周期中依赖引用。许多常见的用于普通函数的技术,例如完美转发和通用引用,用到协程上可能导致代码出现未定义行为。如果你想了解更多,Toby Allsopp 写了一篇关于这个主题的好文章
        如果某个参数的拷贝/移动构造函数抛出了异常,那么已经构造好的参数的析构函数将被调用,协程帧会被释放,异常会传播给主调。

构造 promise 对象

        一旦所有参数被拷贝到协程帧,协程会构造 $promise$ 对象。
        参数在 $promise$ 对象之前构造的原因是让 $promise$ 对象可以在构造函数中访问先前拷贝的参数。
        首先,编译器检查 $promise$ 构造函数是否存在接收每个拷贝的参数左值引用的重载函数,如果找到了这种函数,编译器会生成对这个构造函数的调用,如果没有找到,编译器会使用 $promise$ 类型的默认构造函数。
        注意 $promise$ 构造函数可以“看到”入参是一个对协程标准相对较新的改动,在 Jacksonville 2018 会议的N4723 中被采纳,提案为P0914R1。因此对于一些较老版本的Clang或者MSVC来说可能还不支持。
        如果 $promise$ 构造函数抛出了一个异常,那么入参的拷贝会析构,协程帧会在异常传播回给主调之前被释放。

获取返回对象

        协程要对 $promise$ 对象做的第一件事是调用 $promise.get_-return_-object\left(\right)$ 获取返回对象。
        返回对象是协程函数初始暂停或者运行完成移交控制权后要返回给主调的值。
        你可以认为控制流是这样的 ( 大致的 ):

// Pretend there's a compiler-generated structure called 'coroutine_frame'
// thta holds all of the state needed for the coroutines. It's constructor
// takes a copy of parameters and default-constructs a promise object.
struct coroutine_frame { ... };

T some_corouine(P param)
{
  auto* f = new coroutine_frame(std::forward<P>(param));

  auto returnObject = f->promise.get_return_object();

  // Start execution of the coroutine body by resuming it.
  // This call will return when the coroutine gets to the first.
  // suspend-point or when the coroutine runs to completion.
  coroutine_handle<decltype(f->promise)>::from_promise(f->promise).resume();

  // Then the return object is returned to the caller.
  return returnObject;
}

        注意我们需要在协程体之前获取返回对象,因为协程帧 ( 或者说 $promise$ 对象 ) 可能在 $coroutine_-handle$::$resume\left(\right)$ 调用返回前就被销毁,可能在当前线程销毁,也可能在其他线程,所以在协程体开始执行后调用 $get_-return_-object\left(\right)$ 是不安全的。

初始暂停点

        协程帧初始化,获取返回对象之后,要做的下一件事是执行 $co_-await$ $promise.initial_-suspend\left(\right);$ 。
        这让 $promise$ 类型的开发者可以控制协程在执行编写好的协程体代码之前是否需要等待,或者立即执行协程体。
        如果协程在初始暂停点暂停,那么它可以在之后你选择的某个时间点对 $coroutine_-handle$ 调用 $resume\left(\right)$ 来恢复,或者调用 $destroy\left(\right)$ 销毁。
        $co_-await$ $promise.initial_-suspend\left(\right)$ 表达式的结果会被丢弃,所以实现 $awaiter$ 的 $await_-resume\left(\right)$ 函数实现应该返回void
        注意这个语句是在try/catch块保护之外的 ( 如果你忘记了,可以往上翻翻再看下代码 )。这意味着 $co_-await$ $promise.initial_-suspend\left(\right)$ 抛出的异常等同于到达它的 <$return$-$to$-$caller$-$or$-$resumer$> 点,会在协程销毁调用栈和返回对象后,再抛回给主调。
        要明白如果你的返回对象是RAII,即会在协程帧被析构的时候销毁,那么你需要保证 $co_-await$ $promise.initial_-suspend\left(\right)$ 是noexcept的,这样才能避免 double-free。

注意这里有个提案,希望调整语义,以便 $co_-await$ $promise.initial_-suspend\left(\right)$ 表达的全部或者部分位于try/catch块内,所以这里的明确语义可能会在最终确定前发生变化。

        对于许多协程类型来说,$initial_-suspend\left(\right)$ 方法要么返回 $std$::$experimental$::$suspend_-always$ ( 如果操作懒开始 ),要么返回 $std$::$experimental$::$suspend_-never$ ( 如果操作立即开始 )。而它们都是noexcept的 $awaitable$ ,所以这通常不是问题。

返回给主调

        当协程函数达到首个 <$return$-$to$-$caller$-$or$-$resumer$> 点 ( 或者没有到达,但是协程执行完成 ) 时,通过 $get_-return_-object\left(\right)$ 获取的返回对象会返回给协程的主调。
        注意返回对象的类型并不需要与协程函数的返回类型相同。在必要的时候,返回对象可以是一个能够隐式转换为协程返回类型的类型。

注意Clang的协程实现 ( 从$5.0$ 开始 ) 会在返回对象从协程调用返回后才会转换,而MSVC自从 2017 Update 3 开始会在 $get_-return_-object\left(\right)$ 调用之后立即转换。虽然协程标准没有显式给出预期行为,我相信MSVC有计划把它们的实现改成更像Clang的实现,因为这种实现允许一些有趣的用例

使用 co_return 从协程返回

        当协程到达 $co_-return$ 语句,它会被翻译成一个 $promise.return_-void\left(\right)$ 调用或者 $promise.return_-value$(<$expr$>) 以及一个 $goto$ $FinalSuspend$ 调用。
        翻译规则如下:

        随后的 $goto$ $FinalSuspend;$ ,会在 $co_-await$ $promise.final_-suspend\left(\right)$ 之前触发所有自动生命周期的局部变量按照与构造顺序相反的顺序析构。
        注意如果协程函数体代码没有以 $co_-return$ 语句结束,会等同于以 $co_-return;$ 结束。在这个例子中,如果 $promise$ 类型没有 $return_-void\left(\right)$ 方法,那么行为会是未定义的。
        如果 <$expr$> 或者 $promise.return_-void\left(\right)$ 调用或者 $promise.return_-value\left(\right)$ 抛出了一个异常,那么异常仍然会传给 $promise.unhandled_-exception\left(\right)$ ( 见以下 )。

处理传播到协程体以外的异常

        如果一个异常传播到协程体之外,那么异常会被catch块捕获,并调用 $promise.unhandled_-exception\left(\right)$ 方法。
        这个方法典型实现是调用 $std$::$current_-exception\left(\right)$ 来捕获异常的副本,在后续抛出到其他上下文。
        其他可选实现是立即通过 $throw;$ 语句重新抛出异常,例如 $folly$::$Optional$。然而,这么做会 ( 或者说很可能,见以下 ) 导致协程帧被立即销毁,并将异常传播回主调 / 恢复者。这可能会导致某些 $abstractions$ 出现问题,因为它们假设 / 要求对 $coroutine_-handle$::$resume\left(\right)$ 的调用是noexcept,所以你应该只在能够完全控制谁 / 什么调用 $resume\left(\right)$ 时使用这种方式。
        注意现在协程标准在调用 $unhandled_-exception\left(\right)$ 时重新抛出异常 ( 或者在try-catch块之外抛出异常 ) 的预期行为描述并不是很清晰
        我现在对标准的解释是如果控制权离开了协程体,可以通过 $co_-await$ $promise.initial_-suspend\left(\right)$ 、$promise.unhandled_-exception\left(\right)$ 或者 $co_-await$ $promise.final_-suspend\left(\right)$ 把异常传播出去,也可以通过 $co_-await$ $promise.final_-suspend\left(\right)$ 调用同步结束协程执行。无论哪种方式,都会在返回给主调 / 恢复者前自动销毁协程帧。然而,这种解释也有问题。
        我希望在未来版本中能够细化并阐述清楚这种情况。不管如何,在那之前我不会从 $initial_-suspend\left(\right)$ 、$final_-suspend\left(\right)$ 或者 $unhandled_-exception\left(\right)$ 中抛出异常。敬请关注!

最终暂停点

        一旦协程体执行离开了用户定义的部分后,结果会通过 $return_-void\left(\right)$ 、$return_-value\left(\right)$ 或者 $unhandled_-exception\left(\right)$ 调用捕获,所有局部变量被析构,在返还控制权给主调 / 恢复者之前,协程可以执行一些额外的逻辑。
        这时候协程执行的是 $co_-await$ $promise.final_-suspend\left(\right);$ 语句。
        它允许协程执行一些逻辑,比如生成结果,发出完成信号或者恢复一个任务。它也允许协程选择性地在协程完成、协程帧被销毁前立即暂停。
        注意 $resume\left(\right)$ 一个在 $final_-suspend$ 点暂停的协程是未定义行为。对于这种协程,你只能调用 $destroy\left(\right)$。
        根据 Gor Nishanov 的观点,这种限制的理由是它能减少一些协程的暂停状态,以及一些潜在的分支,让编译器可以做出更好的优化。
        注意协程也可以不在 $final_-suspend$ 点暂停,只是建议你设计一个会在这个点暂停的协程。因为这样可以强制你在协程外调用 $.destroy\left(\right)$ ( 通常通过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;

        如果函数是非静态成员函数,class类型会作为第二个模板参数传递给 $coroutine_-traits$。注意如果你的方法是右值引用重载的,那么第二个模板参数也会是右值引用。
        例如,如果你有以下方法:

task<void> my_class::method1(int x) const;
task<foo> my_class::method2() &&;

        编译器会使用以下 $promise$ 类型:

// method1 promise type
typename coroutine_traits<task<void>, const my_class&, int>::promise_type;

// method2 promise type
typename coroutine_traits<task<foo>, my_class&&>::promise_type;

        $coroutine_-traits$ 模板会默认查找返回类型是否存在内嵌 $promise$ 类型,并使用其作为 $promise$ 类型,例如这样 ( 不过通过一些SFINAE魔法,如果 $RET$::$promise_-type$ 没有定义,那么 $promise$ 类型也是未定义的 ):

namespace std::experimental
{
  template <typename RET, typename... ARGS>
  struct coroutine_traits<RET, ARGS...>
  {
    using promise_type = typename RET::promise_type;
  };
}

        所以对于你可以控制的协程返回类型,你可以在里面定义一个内嵌的 $promise$ 类型,让编译器直接使用作为协程的 $promise$ 对象。
        例如:

template <typename T>
struct task
{
  using promise_type = task_promise<T>;
  ...
};

        然而,对于你无法控制的协程返回类型,你可以在不修改类型的前提下,通过指定 $coroutine_-traits$ 来控制 $promise$ 类型。
        例如,给一个返回 $std$::$optional$<$T$> 的协程定义 $promise$ 类型:

namespace std::experimental
{
  template <typename T, typename... ARGS>
  struct coroutine_traits<std::optional<T>, ARGS...>
  {
    using promise_type = optional_promise<T>;
  };
}

识别特定协程调用栈

        当你调用一个协程函数时,协程帧会被创建。为了恢复协程或者销毁协程帧,你需要一些方法识别或者引用对应的协程帧。
        协程标准通过 $coroutine_-handle$ 类型提供了这个机制。
        类型接口 ( 大致的 ) 如下:

namespace std::experimental
{
  template <typename Promise = void>
  struct coroutine_handle;

  // Type-erased coroutine handle. Can refer to any kind of coroutine.
  // Doesn't allow access to the promise object.
  template <>
  struct coroutine_handle<void>
  {
    // Constructs to the null handle.
    constexpr coroutine_handle();

    // Convert to/from a void* for passing into C-style interop functions.
    constexpr void* address() const noexcept;
    static constexpr coroutine_handle from_address(void* addr);

    // Query if the handle is non-null.
    constexpr explicit operator bool() const noexcept;

    // Query if the coroutine is suspended at the final_suspend point.
    // Undefined behavior if coroutine is not currently suspend.
    bool done() const;

    // Resume/Destroy the suspended coroutinie
    void resume();
    void destroy();
  };

  // Coroutine handle for coroutines with a known promise type.
  // Template argument must exactly match coroutine's promise type.
  template <typename Promise>
  struct coroutine_handle : coroutine_handle<>
  {
    using coroutine_handle<>::coroutine_handle;

    static constexpr coroutine_handle from_address(void* addr);

    // Access to the coroutine's promise object.
    Promise& promise() const;

    // You can reconstruct the coroutine handle from the promise object.
    static coroutine_handle from_promise(Promise& promise);
  };
}

        你可以通过两种方式获取一个协程的 $coroutine_-handle$ :

  1. $coroutine_-handle$ 会在 $co_-await$ 表达式中被传递给 $await_-suspend\left(\right)$ 。
  2. 如果你有一个协程 $promise$ 对象的引用,你可以通过 $coroutine_-handle$<$Promise$>::$from_-promise\left(\right)$ 重新构造它的 $coroutine_-handle$ 。

        挂起协程的 $coroutine_-handle$ 会在 $co_-await$ 表达式中,协程到达 <$suspend$-$point$> 被暂停后,传给 $awaiter$ 的 $await_-suspend\left(\right)$ 方法。你可以认为这个 $coroutine_-handle$ 代表 连续传递风格 ( $continuation-passing$ $style$ ) 调用协程的连续传递。
        注意 $coroutine_-handle$ 不是RAII对象。你必须手动调用 $.destroy\left(\right)$ 来销毁协程帧,释放资源。可以把它视为一个用于管理内存的 $void*$ 值。这么设计是出于性能原因:RAII会带来额外的开销,比如需要引用计数管理。
        你应该尝试使用更高级的支持协程RAII语义的类型,例如cppcoro提供的 ( 不要脸地打广告 ),或者编写一个你自己的更高级的类型,封装你协程类型的协程帧生命周期。

自定义 co_await 行为

        $promise$ 类型可以自定义出现在协程体内的每个 $co_-await$ 表达式行为。
        通过简单地定义 $promise$ 类型的 $await_-transform\left(\right)$ 方法,编译器会把协程体里的每个 $co_-await$ <$expr$> 转换成 $co_-await$ $promise.await_-transform$(<$expr$>)。
        这里有一些重要且有用的用法:
        它能挂起一些非 $awaitable$ 类型。
        例如,一个返回 $std$::$optional$<$T$> 类型的协程可能会重载 $promise$ 类型的 $await_-transform\left(\right)$ ,使用 $std$::$optional$<$U$> 并返回一个 $awaitable$ 类型,该类型会返回类型 $U$ 的值,或者在nullopt时暂停协程。

template <typename T>
class optional_promise
{
  ...

  template <typename U>
  auto await_transform(std::optional<U>& value)
  {
    class awaiter
    {
      std::optional<U>& value;
    public:
      explicit awaiter(std::optional<U>& x) noexcept : value(x) {}
      bool await_ready() noexcept { return value.has_value(); }
      void await_suspend(std::experimental::coroutine_handle<>) noexcept {}
      U& await_resume() noexcept { return *value; }
    };
    return awaiter{ value };
  }
}

        它可以让你通过删除 $await_-transform$ 方法来禁止挂起一个特定类型。
        例如,一个 $std$::$generator$<$T$> 返回类型的 $promise$ 类型可能会声明一个已删除的 $await_-transform\left(\right)$ 且接收所有类型的模板成员函数。这基本上禁止了所有在协程内的 $co_-await$ 调用。

template <typename T>
class generator_promise
{
  ...

  // Disable any use of co_await within this type of coroutine.
  template <typename U>
  std::experimental::suspend_never await_transform(U&&) = delete;
};

        它可以适配和改变一般 $awaitable$ 值的行为。
        例如,你可以定义一个协程类型,通过把 $awaitable$ 包装在 $resume_-on\left(\right)$ 操作中 ( 参考 $cppcoro$::$resume_-on\left(\right)$ ),保证协程始终在关联的 $executor$ 上的 $co_-await$ 表达式中恢复。

template <typename T, typename Executor>
class executor_task_promise
{
  Executor executor;

public:

  template <typename Awaitable>
  auto await_transform(Awaitable&& awaitable)
  {
    using cppcoro::resume_on;
    return resume_on(this->executor, std::forward<Awaitable>(awaitable));
  }
};

        作为 $await_-transform\left(\right)$ 介绍的最后一句话,要注意的是,如果 $promise$ 类型定义了 $await_-transform\left(\right)$ 成员,编译器就会把所有 $co_-await$ 替换成 $promise.await_-transform\left(\right)$ 调用。这意味着如果你想要只给某些类型定制 $co_-await$ 行为,你也需要重载默认的只转发参数的 $await_-transform\left(\right)$ 实现。

自定义 co_yield 行为

        最后一个你可以通过 $promise$ 类型自定义行为的是 $co_-yield$ 关键字。
        如果 $co_-yield$ 关键字在协程内出现,编译器会把表达式 $co_-yield$ <$expr$> 翻译成 $co_-await$ $promise.yield_-value$(<$expr$>) 。因此 $co_-yield$ 的行为可以通过定义一个或多个 $promise$ 类型的 $yield_-value\left(\right)$ 方法定制。
        注意,不像 $await_-transform$ ,如果 $promise$ 类型没有定义 $yield_-value\left(\right)$ 方法,$co_-yield$ 没有默认行为。所以如果需要禁止 $co_-await$ 表达式,$promise$ 类型需要显式删除 $await_-transform\left(\right)$ 方法,但是 $co_-yield$ 则不用。
        一种典型的 $promise$ 类型的 $yield_-value\left(\right)$ 方法实现是 $generator$<$T$> 类型:

template <typename T>
class generator_promise
{
  T* valuePtr;
public:
  ...

  std::experimental::suspend_always yield_value(T& value) noexcept
  {
    // Stash the address of the yielded value and then return an awaitable
    // that will cause the coroutine to suspend at the co_yield expression.
    // Execution will then return from the call to coroutine_handle<>::resume()
    // inside either generator<T>::begin() or generator<T>::iterator::operator++().
    valuePtr = std::addressof(value);
    return {};
  }
};

总结

        在这篇文章中,我依次介绍了编译器将函数编译成协程时的每个转换。
        希望这篇能够帮你理解怎么通过定义自己的 $promise$ 类型的方式来定制不同类型的协程行为。协程机制中有许多可以变动的部分,所以有许多种自定义行为的方式。
        然而,编译器有一个更重要的转换我还没讲——把协程体转换成状态机。不过要是讲这块的话,这篇文章就太长了,所以我把它放到了下一篇文章。请保持关注!

C++协程(3):理解promise