跳至正文

C++20: Coroutines

Coroutines(协程)是 C++20 引入的几个重要的语言级新特性之一。C++20 引入的协程机制与其他语言的异步(async)或类似机制有相似的地方,但也有很大的不同:C++20 的协程机制能实现的功能更多,但在实现细节上也更加复杂。为了清晰起见,本文不会对其他语言的协程机制或异步机制进行介绍或将其与 C++20 的协程机制进行细节上的比较。

C++20 的协程的主要设计目标是支持保存和恢复代码的执行状态。一个简单的例子是斐波那契数列生成器:

FibonacciGenerator fibonacci() {
  auto prev = 1;
  auto prev2 = 1;

  auto n = 1;
  while (true) {
    if (n <= 2) {
      co_yield 1;              // #1
    } else {
      co_yield prev + prev2;   // #1
      std::swap(prev, prev2);
      prev += prev2;
    }

    ++n;
  }
}

for (const auto x : fibonacci()) {  // #3
  std::cout << x << std::endl;  // #2
}

在上述代码中,fibonacci 是一个协程。其返回值FibonacciGenerator作为协程的返回类型,C++20 对其有一定的要求,我们将在后面进一步讨论。需要重点注意的是两处注释为 #1 的语句,它们使用新的 co_yield 关键字,在效果上 相当于将 co_yield 之后表达式的值“发送”回协程的调用方,保存协程的当前执行状态,挂起协程的执行,并将控制流返回给协程的调用方(即控制流从 #1 返回到 #2)。协程的调用方在“接收”到刚才“发送”回来的值后,可以对这个值进行一定的处理(#2)。在处理完毕后,调用方进入下一轮循环,在每一轮循环的开始,调用方会在 #3 处,从协程保存的自身执行状态中恢复协程的执行,这将会导致控制流跳转到 #1 处。然后,协程计算下一轮需要“发送”给调用方的值。如此一直执行下去。整个过程可以如下图所示。

当然,C++20 的协程也能够实现类似于其他语言的异步(async)机制,例如:

Task<int> readDataFromDisk();

Task<int> getProcessedData() {
  int rawData = co_await readDataFromDisk();  // #1
  int processedData = doSomething(rawData);
  co_return processedData;  // #2
}

在上述代码中,getProcessedData是一个协程,它异步地从 IO 设备中加载数据,然后同步地处理数据,最后将处理后的数据返回给协程的调用方。需要重点注意的地方是两处注释的位置,它们分别包括两个新的关键字:co_awaitco_return。这两个关键字与上一个例子中的co_yield关键字是 C++20 协程机制所引入的三个新关键字(实际上,co_yield关键字所表示的操作是由co_awaitco_return所实现的)。

在 #1 处,co_await关键字挂起当前协程的执行,并将控制流返回给协程的调用方,协程的调用方因此可以继续执行。在协程挂起之前,协程将启动异步 IO 请求,然后告诉协程调度器当异步 IO 请求完成后将自身唤醒。当 IO 请求完成后,协程调度器将唤醒 getProcessedData 协程,控制流将返回至之前挂起的位置(即 #1)。此时,由于 IO 请求已经完成,co_await 表达式的值将被顺利求值为 IO 请求的结果(即读取出的原始数据)。然后,getProcessedData协程将顺利地执行到 #2 处。在这里,co_return 关键字将结束协程的运行,并将处理后的数据作为协程的返回值返回给协程的调用方。上述的流程可以用下图进行直观的说明。

如果你对其他语言的异步机制有了解,下面的 TypeScript 代码片段可能会帮助你了解上述代码的执行过程,它们在语义和行为效果上是等价的:

declare async function readDataFromDisk(): Promise<number>;

async function getProcessedData(): Promise<number> {
  const rawData = await readDataFromDisk();
  const processedData = doSomething(rawData);
  return processedData;
}

如果你对用户态线程这一概念有了解,你可能会意识到 C++20 的协程与用户态线程非常类似。在用户态线程调度器的支持下,使用用户态线程也可以达到与协程类似的效果。但需要注意的是,用户态线程往往是有栈的,即不同的用户态线程拥有属于自己的不同的栈;但 C++20 的协程是无栈的,协程与协程调用方共用同一个栈。

实现原理

如果你是第一次接触协程或者异步,你可能会感觉到前述的几个例子非常神奇。在这一节中,我将介绍协程的实现原理。你将看到,C++20 提供了一组基本的协程操作(即挂起和恢复协程的执行),这些基本的协程功能与上一节的简介中极力回避的协程返回值有机地整合在了一起,允许用户以极高的自由度定制化协程的各种行为,使得 C++ 协程具有相当高的灵活性。具体来说,C++20 对协程的返回值有一定的要求,这些要求概括起来就是这个返回值类型需要实现 co_awaitco_return以及co_yield所需的各种功能。这种设计理念在 C++ 中早有应用的先例。例如,如果想让某个类型支持 range-based for loop 语法,那么这个类型就必须实现 begin 函数和 end 函数。协程的返回值类型也类似,要想使某个函数是一个协程,那么这个函数的返回值类型必须实现一些规定的函数,且协程的函数体内必须至少包含有三个协程关键字之一。

基本概念

为了弄清协程的实现原理,必须先了解几个概念,其中包括 promise object、coroutine handle 以及 coroutine state。这三个概念分别表示三个特殊的对象,协程的所有操作均基于这三个特殊对象进行。

Promise object 是一个对象。在一个协程开始执行时,编译器会构造一个 promise object。Promise object 会被协程内的代码使用。协程内的代码通过这个 promise object 传递协程需要向调用方“发送”的值。如果协程内发生了未捕获的异常,promise object 也负责向协程的调用方传递这个异常。Promise object 的类型是协程相关的,在编译期,promise object 的类型由如下的规则进行推断:

  • 如果协程是一个普通函数或是一个静态成员函数,那么 promise object 的类型是 std::coroutine_traits<R, Args...>::promise_type,其中 R 是协程函数的返回类型,Args是协程函数的参数类型;
  • 如果协程是一个非静态成员函数且没有右值引用限定,那么 promise object 的类型是 std::coroutine_traits<R, ClassT &, Args...>::promise_type,其中 ClassT 是具有与协程函数相同的 CV 限定的、包含协程函数定义的类的类型;
  • 如果协程是一个非静态成员函数且有右值引用限定,那么 promise object 的类型是 std::coroutine_traits<R, ClassT &&, Args...>::promise_type

例如:

Task<int> foo(int, double);  // foo is a coroutine
// Promise object type of foo is:
// std::coroutine_traits<Task<int>, int, double>::promise_type

struct Foo {
  Task<int> bar(int, double) const; // Foo::bar is a coroutine
  // Promise object type of Foo::bar is:
  // std::coroutine_traits<Task<int>, const Foo &, int, double>::promise_type
};

struct Bar {
  Task<int> baz(int, double) &&;  // Bar::baz is a coroutine
  // Promise object type of Bar::baz is:
  // std::coroutine_traits<Task<int>, Baz &&, int, double>::promise_type
};

需要注意,std::coroutine_traits<R, Args...>::promise_type 的默认实现为 R::promise_type,因此大部分情况下我们只需要在协程的返回值类型中定义一个 promise_type 类型别名即可,而不需要显式地特例化 coroutine_traits 模板。

编译器构造 promise object 的规则如下:

  • 如果 promise object 有一个构造器重载可以通过传入协程的所有参数进行调用,那么编译器将选择使用这个构造器;
  • 否则,编译器将使用 promise object 的默认构造器构造 promise object。

例如:

struct Promise {
  Promise();  // #1
  Promise(int, double);  // #2
};

// Assume that std::coroutine_traits<Task<int>, int, double>::promise_type is Promise
Task<int> foo(int, double);  // foo is a coroutine
// Compiler chooses #2 for constructing promise object

// Assume that std::coroutine_traits<Task<int>, int>::promise_type is Promise
Task<int> bar(int);  // bar is a coroutine
// Compiler chooses #1 for constructing promise object

Coroutine handle 是一个对象。在一个协程开始执行时,编译器会构造一个 coroutine handle。Coroutine handle 会被协程的调用方使用。协程的调用方通过 coroutine handle 对协程进行控制,包括恢复协程的运行以及清理协程占用的资源等。STL 为我们定义了一个 coroutine handle 类型 std::coroutine_handle<P>,其中 P 是协程对应的 promise object 的类型。

Coroutine state 是一个对象。Coroutine state 对象的类型是实现相关的,代码中不能也不应该直接对 coroutine state 对象进行操作。在一个协程开始执行时,编译器会在堆上构造一个 coroutine state 对象。这个对象主要保存了如下的信息:

  • 协程的 promise object;
  • 协程的所有参数;
  • 协程的执行状态(例如寄存器现场、程序计数器等);
  • 协程内存活的局部变量。

通过 coroutine state,编译器可以实现随时挂起或恢复协程的执行。

co_await

在介绍 co_await 的实现前,需要先了解两个关键概念:awaitable 以及 awaiter。每一个 co_await 都关联到了一个 awaitable 对象和一个 awaiter 对象,awaitable 对象和 awaiter 对象共同决定了 co_await 的行为。

一个对象是 awaitable 的,当且仅当这个对象可以作为 co_await 关键字后面的表达式的值。当在协程内执行 co_await expr 这一表达式时,编译器会首先按照如下的规则确定 co_await 所需要的 awaitable 对象:

  • 如果 expr 是由 initial suspension point、final suspension point 或者 yield expression 所产生的,那么 awaitable 对象即为 expr 求值后的值(先暂时忽略这一条,之后会介绍这三个概念);
  • 如果当前协程的 promise object 包含一个名为 await_transform 的成员函数,那么 awaitable 对象为 promise.await_transform(expr) 的返回值;
  • 否则,awaitable 对象即为 expr 求值后的值。

当确定了 awaitable 对象后,编译器会根据如下的规则确定 awaiter 对象:

  • 如果 awaitable 对象上有一个 operator co_await 运算符重载,且重载决议没有导致 ambiguous,那么 awaiter 对象即为 awaitable.operator co_await() 或 operator co_await(static_cast<Awaitable &&>(awaitable))
  • 如果对 operator co_await 的重载决议没有找到任何重载,那么 awaitable 对象本身即为 awaiter;
  • 否则,如果重载决议导致了 ambiguous,那么程序是 ill-formed,将导致编译器产生编译错误。

上述流程的示例如下:

struct Awaiter;

struct Awaitable {
  Awaiter operator co_await();
};

struct Promise {
  Awaitable await_transform(int x);
};

// Assume that coroutine's promise type is Promise

co_await 5;
// Awaiter: promise.await_transform(5)
// Awaitable: promise.await_transform(5).operator co_await()

当确定了 awaitable 对象和 awaiter 对象后,便可以开始执行 co_await 的行为。

首先,编译器将调用 awaiter.await_ready() 函数。这个函数应该返回一个可以被转换为 bool 的类型。当返回值被转换为 bool 后,若返回值是 true,则 co_await 表达式将不会产生协程挂起动作,而会进一步调用 awaiter.await_resume() 函数并将这个函数的返回值作为 co_await 表达式的值,然后协程继续执行。否则,协程将被立即挂起。

awaiter.await_ready 函数的作用是在挂起当前协程之前,检查协程挂起所依赖的条件是否得到满足。如果检查结果表明不需要挂起协程便可以直接得到 co_await 的结果,那么便不需要挂起协程再立即恢复,可以提升执行速度。例如,在异步 IO 的场景下,await_ready 函数可能会检查发起的 IO 操作是否已经完成。如果是,那么 IO 操作的结果已经可以直接拿到,不需要挂起协程等待 IO 操作完成。

当协程被挂起后,编译器会调用 awaiter.await_suspend(handle) 函数,并将当前协程的 coroutine handle 作为调用参数。await_suspend 函数可以有多种返回类型,不同的返回类型、不同的返回值将导致不同的 co_await 行为。具体地:

  • 如果返回类型是 void,那么控制流将立即返回到当前协程(已经被挂起)的调用方;
  • 如果返回类型是 bool 且返回值是 true,那么控制流将立即返回到当前协程的调用方;
  • 如果返回类型是 bool 且返回值是 false,那么刚被挂起的协程将立即被恢复执行,协程在恢复执行后将调用 awaiter.await_resume() 函数并将函数的返回值作为 co_await 表达式的求值结果;
  • 如果返回类型是另一个协程的 coroutine handle,那么这个协程将被恢复执行;如果 await_suspend 函数抛出了异常,那么刚被挂起的协程将立即恢复执行,当恢复执行后将立即在协程中重新抛出这个异常。

在需要挂起当前协程的场景下,await_suspend 函数需要负责调度当前协程,使得其在一定的条件(例如请求的 IO 操作完成)下能够被某种调度逻辑恢复执行。当这样的条件到来时,调度逻辑将恢复当前协程的执行(通过调用 coroutine handle 上的 resume 函数)。在当前协程恢复执行后,协程将立即调用 awaiter.await_resume() 函数,并将这个函数的返回值作为 co_await 表达式的求值结果。

上述过程可以由下面的伪代码进行描述:

T CoAwait(Awaiter awaiter) {
  auto ready = static_cast<bool>(awaiter.await_ready());
  if (ready) {
    return awaiter.await_resume();
  }

  SuspendCurrentCoroutine();

  try {
    auto suspend = awaiter.await_suspend(handle);
  } catch (...) {
    ResumeCurrentCoroutine();
    throw;
  }

  if (suspend == void) {
    jmp caller;
  } else if (suspend == true) {
    jmp caller;
  } else if (suspend == false) {
    ResumeCurrentCoroutine();
  } else if (suspend is coroutine handle) {
    suspend.resume();
  }

  // If resumed, this coroutine will be started here
  return awaiter.await_resume();
}

auto x = co_await expr;
// Logically equivalent to:
// auto x = CoAwait(promise.await_transform(expr).operator co_await());

co_return

与普通函数不同,在协程中只能使用 co_return 来结束协程的运行并将控制流和返回值返还给协程的调用方。需要注意到的是,在协程的场景下,返回值这个词可能会有歧义。在协程的场景下,有两类返回值:

  • 在协程的函数原型中声明的返回值;
  • 由 co_return 或 co_yield 向协程调用方提供的返回值。

上述第一种返回值通常是某种包含 coroutine handle 的值,协程的调用方使用普通的函数调用调用协程的代码时便可以直接获得第一种返回值。协程的调用方可以使用第一种返回值与协程进行交互,控制协程的执行。上述第二种返回值通常是协程的最终运行结果,或者是生成器协程(例如开头处的斐波那契数列生成器协程)在每一步迭代中生成的值。协程的调用方必须通过一定的方法才能间接地得到这一类返回值。例如:

FibonacciGenerator fibonacci();  // fibonacci is a coroutine

auto g = fibonacci();  // g is FibonacciGenerator
for (auto x : g) {  // Depending on implementation of FibonacciGenerator, x might be int
  // ...
}

为了防止混淆,之后将用“协程的运行结果”指代的通过 co_return 向协程调用方提供的值,使用“返回值”指代在协程的函数原型中声明的返回值。

依赖于 co_return 后面的表达式的类型,co_return 有不同的行为:

  • 如果 co_return 后面没有表达式或者表达式的类型为 void,那么编译器将生成 promise.return_void() 函数调用;
  • 否则,编译器将生成 promise.return_value(expr) 函数调用,其中 expr 为 co_return 后面的表达式。

Promise object 需要实现 return_void 以及 return_value 两个函数,这两个函数将被协程通过 co_return 语句调用,用于向协程的调用方传递协程的运行结果。通常来说,promise object 将使用成员数据字段保存协程的运行结果,协程的调用方可以通过 coroutine handle 得到 promise object 的引用,进而得到协程的运行结果。

需要特别注意的是协程中产生了未经捕获的异常的情况。当这种情况发生后,协程将在异常处理上下文中调用 promise.unhandled_exception 函数。

在调用 promise.return_void、promise.return_valuepromise.unhandled_exception 后,协程将执行必要的资源清理工作,结束自身的运行,并将控制流返回给协程的调用方。

co_yield

在效果上,co_yieldco_return 类似:它们都向协程的调用方传递协程的运行结果。它们的不同点在于 co_yield 向协程调用方传递运行结果后,不会终止协程的运行,而是挂起协程的运行;协程的调用方可以在未来恢复协程的运行。这样的特性使得 co_yield 可以用于编写基于协程的 value generator,如文章开头的斐波那契数列生成器一样。

co_return 不同,co_yield 通过调用 promise object 上的 yield_value 函数向协程的调用方传递运行结果。由于 co_yield 在传递运行结果后需要挂起协程的运行并将控制流返还给协程的调用方,因此 C++20 规定 co_yield expr 这一语句完全等价于以下的语句:

co_await promise.yield_value(expr);

需要注意,这里的 co_await 不会使用 promise.await_transformyield_value 的返回值进行变换。因此 promise object 的 yield_value 必须返回一个 awaitable 对象。

协程的总体运行流程

我们已经了解了 co_awaitco_return 以及 co_yield 三个协程相关关键字的行为。现在我们可以开始介绍协程的总体运行流程,主要介绍协程刚开始运行时和即将结束运行时的动作。

在协程刚开始运行时,编译器会生成代码执行如下动作:

  • 使用 new 操作符在堆上构造 coroutine state 对象,然后将协程的所有值传递参数移动或拷贝到 coroutine state 对象中、所有引用参数在 coroutine state 对象中保留为引用。
  • 按照之前介绍的规则,推断 promise object 类型并构造 promise object。
  • 调用 promise.get_return_object 获取协程的返回值。前面介绍到,这个返回值即为在协程的函数原型中定义的返回值,协程的调用方可以通过这个返回值与协程进行交互。当协程在之后首次被挂起时,控制流将返回到协程的调用方中,这个返回值也会随着控制流一同返回到协程的调用方中。
  • 执行如下语句(注意不会调用 promise.await_transform 解析新的 awaitable 对象),这一步即称为 initial suspension point:
co_await promise.initial_suspend();

上述调用的目的是区分 lazily-executed coroutine 和 eagerly-executed coroutine。

  • 当上述语句完成时,开始执行协程的函数体。

在上述第一步分配和构造 coroutine state 对象时,用户可以通过自定义 promise object 的 new 运算符替代全局的 new 运算符。但需要注意到的是,编译器在满足一定的条件下,可以优化掉为分配 coroutine state 对象所需空间的 new 操作符调用,即使这个 new 操作符是用户自定义的。

当协程即将完成运行时,首先析构所有的局部变量,然后执行如下语句(注意不会调用 promise.await_transform 解析新的 awaitable 对象),这一步即称为 final suspension point:

co_await promise.final_suspend();

如果协程是由于发生了未经捕获的异常而被终止的,那么上述语句必须导致协程在其资源被清理前永远处于挂起状态,否则将导致未定义行为。对于正常结束的协程,从上述语句恢复执行后,控制流将立即回到协程的调用方,且协程将无法继续被恢复执行。

Coroutine state 的析构需要分情况进行。在不同的情况下,coroutine state 析构的时机不同。

  • 如果协程通过 co_return 正常结束且运行结果是 void,那么 coroutine state 的析构将在控制流返回到调用方之前进行。
  • 如果协程通过 co_return 正常结束且运行结果不是 void,那么 coroutine state 的析构将在最后一个引用该 coroutine state 的 coroutine handle 被析构时执行。
  • 如果协程是由于发生了未经捕获的异常而终止运行的,那么 coroutine state 的析构将在控制流返回到调用方之前进行。

Coroutine state 析构时,将同时析构 promise object 以及协程的参数。Coroutine state 所占用内存的释放是通过调用 delete 运算符进行的。

Appendix: Coroutine Handle & Coroutine State

本节将介绍一个有趣的(实现相关的)小知识:coroutine handle 是如何获得 coroutine state 的引用的。

前面说到,STL 已经为我们定义了一个 coroutine handle 类型 std::coroutine_handle<P>。由于协程的调用方通过 coroutine handle 实现对协程的控制,包括恢复协程的执行等,因此 coroutine handle 中一定包含一个指向 coroutine state 的指针。cpprefenrece 上关于 std::coroutine_handle 的文档也清晰地说明了这一点:

On typical implementations, every specialization of std::coroutine_handle is TriviallyCopyable, and holds a pointer to the coroutine state as its only non-static data member.

标签:

发表回复