C++协程初探

什么是协程

协程是一段可以挂起(suspend)恢复(resume) 的程序,一般来说就是支持挂起恢复的函数。

可以看下面这个例子:

cpp 复制代码
void Fun() {
	std::cout << 1 << std::endl;
	std::cout << 2 << std::endl;
	std::cout << 3 << std::endl;
	std::cout << 4 << std::endl;
}

Fun是一个普通的函数:

  • 它有四行代码
  • 这四行代码一行一行一依次执行
  • 这四行代码连续执行 这个函数一旦开始,就无法暂停。

如果一个函数能够暂停,那它就可以被认为是我们开头提到的协程。所以挂起 可以理解成暂停,恢复可以理解成从暂停的地方继续执行。

下面给出一个C++协程的不完整例子:

cpp 复制代码
Result Coroutine() {
	std::cout << 1 << std::endl;
	co_await std::suspend_always{};
	std::cout << 2 << std::endl;
	std::cout << 3 << std::endl;
	co_await std::suspend_always{};
	std::cout << 4 << std::endl;
};

Result的定义后面再谈论,只需要知道Result是按照协程的规则定义的类型,在C++中,一个函数的返回值类型如果是符合协程的规则的类型,那么这个函数就是一个协程。

大家留意一下函数体当中的co_await std::suspend_always{};,其中co_await是个关键字,它的出现,通常来说会使得当前函数(协程)的执行被挂起。也就是说我们在控制台看到输出1以后,很可能过了很久才看到2,这个"很久"也一般不是因为当前执行的线程被阻塞了,而是当前函数(协程)执行的位置被存起来,在将来某个时间点又读取出来继续执行的。

协程的状态

我们刚接触到协程这个概念时,通常会觉得挂起恢复充满了神秘色彩而无法理解。 我们以音频文件的播放为例,我们将其与协程的执行做对比,例如整个音频文件对比协程的函数体(即协程体),完整的对比见下表:

音频 协程
音频文件 协程体
音频播放 协程执行
播放暂停 执行挂起
播放恢复 执行恢复
播放异常 执行异常
播放完成 协程返回

音频暂停的时候需要记录音频暂停的位置,同时之前正在播放的音频也不会销毁(即便销毁重建,也要能够完全恢复原样)。

协程挂起时,我们需要记录函数执行的位置,C++协程会在开始执行的第一步就使用operator new来开辟一块内存来存放这些信息,这些内存或者说这个对象又被称为协程的状态(coroutine state)

协程的状态不仅会被用于存放挂起时的位置(后称为挂起点),也会在协程开始执行时存入协程体开始执行时存入协程体的参数值。例如:

cpp 复制代码
Result Coroutine(int start_value) {
	std::cout << start_value << std::endl;
	co_await std::suspend_always{};
	std::cout << start_value + 1 << std::endl;
}

这里的start_value就会被存入协程的状态当中。

需要注意的是,如果参数是值类型,他们的值或被移动或被复制(取决于类型自身的复制构造和移动构造的定义)到协程的状态当中;如果是引用、指针类型,那么存入协程的状态的值是引用或指针本身,而不是其指向的对象,这时候需要开发者自行保证协程在挂起后恢复执行时参数引用或者指针指向的对象仍然存活。

与创建相对应,在协程执行完成或者被外部主动销毁之后,协程的状态也随之被销毁释放。

协程的挂起

协程的挂起时协程的灵魂。C++通过co_await表达式来处理协程的挂起,表达式的操作对象则为等待体(awaiter)。 等待体需要实现三个函数,这三个函数在挂起和恢复时分别调用。

await_ready

cpp 复制代码
bool await_ready();

await_ready返回bool类型,如果返回true,则表示已经就绪,无需挂起;否则表示需要挂起。

标准库中提供了两个非常简单直接的等待体,struct suspend_always表示总是挂起,struct suspend_nerver表示总是不挂起。不难想到,这二者的功能主要就是依赖await_ready函数的返回值:

cpp 复制代码
struct suspend_nerver {
	constexpr bool await_ready() const noexcept {
		return true; // 返回 true,总是不挂起
	}
	...
};

struct suspend_always {
	constexpr bool await_ready() const noexcept {
		return false; // 返回false,总是挂起
	}
	...
};

await_suspend

await_ready返回false时,协程就挂起了。这时候协程的局部变量和挂起点都会存入协程的状态当中,await_suspend被调用到。

cpp 复制代码
??? await_suspend(std::coroutine_handle<> coroutine_handle);

参数coroutine_handle用来表示当前协程,我们可以在稍后合适的时机通过调用resume来恢复执行当前协程:

cpp 复制代码
coroutine_handle.resume();

注意到await_suspend函数的返回值类型我们没有明确给出,因为它有以下几种选项:

  • 返回void类型或者返回true,表示当前协程挂起之后将执行权还给当初调用或者恢复当前协程的函数。
  • 返回false,则恢复执行当前协程。注意此时不同于await_ready返回true的情形,此时协程已经挂起,await_suspend返回false相当于挂起又立即恢复。
  • 返回其他协程的coroutine_handle对象,这时候返回的coroutine_handle对应的协程被恢复执行。
  • 抛出异常,此时当前协程恢复执行,并在当前协程当中抛出异常。 可见,await_suspend支持的情况非常多,也相对复杂。实际上这也是C++协程当中最为核心的函数之一了。

await_resume

cpp 复制代码
??? await_resume()

同样地,await_resume的返回值类型也是不限定的,返回值将作为co_await表达式的返回值。

示例

了解以上内容后,我们可以自己定义一个简单的等待体:

cpp 复制代码
struct Awaiter {
	int value;

	bool await_ready() {
		// 协程挂起
		return false;
	}

	void await_suspend(std::coroutine_handle<> coroutine_handle) {
		// 切换线程
		std::async([=]() {
			using namespace std::chrono_literals;
			// sleep 1s
			std::this_thread::sleep_for(1s);
			// 恢复协程
			coroutine_handle.resume();
		});
	}

	int await_resume() {
		// value 将作为 co_await 表达式的值
		return value;
	}
};
cpp 复制代码
Result Coroutine() {
	std::cout << 1 << std::endl;
	std::cout << co_await Awaiter{.value = 1000} << std::endl;
	std::cout << 2 << std::endl;
};

程序运行结果如下:

yaml 复制代码
1
1000
2

其中"1000"在"1"输出1秒之后输出。

说明co_await后面的对象也可以不是等待体,这类情况需要定义其他的函数和运算符来转换成等待体。这个后面再讨论。

co_await详解

知道什么是awaiter类型之后, 就能聊一聊co_await这个关键字了. co_await是一个一元运算符,通常情况下其操作数就是表达式<expr>的值, 称它为awaitable对象. 编译器在处理这个关键字时会添加一些代码来完成一系列的操作, 这些操作大概如下:

获取awaiter对象

  • 这一步的操作可以通过操作符重载完成.
  • 如果没有对应的重载函数则最后的awaiterawaitable本身
  • 这里可能就有人要晕了, 又是awaiter又是awaitable. 那我们来举个例子
cpp 复制代码
// include headers

// using namespace

/** class resumable {
...
} **/

resumable foo() {
  cout << "Hello, ";
  co_await async(launch::deferred, [](){
    this_thread::sleep_for(chrono::seconds(2));
  }); // 这里 async(...) 的值为 awaitable, 由于我们没有给future<void>重载 co_await 运算符
          // 因此此处最后的 awaiter 对象就是 future<void>
  cout << "world!" << endl; 
}

但是future<void>不符合我们上文所说的awaiter类型的条件, 所以这个代码是没有办法编译的

所以我们要让某个类型能够被 co_await 的方法有两种: 1. 为其实现await_ready() , await_suspend(coroutine_handle<>), await_resume() 3个成员函数使其成为一个满足条件的awaiter类型; 2. 为其重载 operator co_await 返回一个满足条件的awaiter类型

处理awaiter对象

  • 调用awaiter.await_ready()
  • await_ready() 的返回值为 true 则直接调用 awaiter.await_resume(). 调用结果为 co_await <expr> 的结果
  • await_ready() 的返回值为 false则会挂起协程, 然后调用 awaiter.await_suspend(coroutine_handle<>). 最后返回至最初协程的调用者. 当协程被唤醒时(coroutine_handle<>.resume()函数被调用), 调用awaiter.await_resume(), 结果为co_await <expr>.

这些操作写成代码的形式大概如下:

cpp 复制代码
{
  auto&& awaitable = <expr>;
  auto&& awaiter = get_awaiter(awaitable);
  if (!awaiter.await_ready())
  {
    using handle_t = std::experimental::coroutine_handle<Promise>;

    <suspend-coroutine>
    
    awaiter.await_suspend(handle_t::from_promise(p));
    <return-to-caller-or-resumer>
    
    <resume-point>
  }

  return awaiter.await_resume();
}

<suspend-coroutine> 处, 编译器使用了魔法来将相关变量以及resume-point保存起来, 此时协程被视作挂起状态. 虽然协程被视作挂起, 但还返回至协程的调用者, 而是根据前篇文章的提到promise_type生成对应的coroutine_handle<> 作为参数继续调用awaiter.await_suspend(coroutine_handle<>)函数. 调用完成之后便返回至协程的调用者.

从上面的规则可以得出, 我们可以通过awaiter.await_suspend(coroutine_handle<>)函数, 来完成一些异步操作. 说了那么多, 就修改下上文 future<void> 的例子, 来实现一个类似其他语言协程的delay功能:

cpp 复制代码
// include headers

// using namespace

/** class resumable {
...
} **/

auto delay(int sec) {
  struct future_awaiter {
    future<void> f_;

    future_awaiter(future<void> &&f) : f_(move(f)) {}

    bool await_ready() noexcept {
      return false;
    }

    void await_suspend(coroutine_handle<> handle) noexcept {
      thread([&]() {
        f_.get();
        handle.resume();
      }).detach();
    }

    void await_resume() noexcept {}
  };

  return future_awaiter { async(launch::deferred, [=](){
    this_thread::sleep_for(chrono::seconds(sec));
  }) };
}

resumable foo() {
  using time = chrono::system_clock;
  
  cout << time::to_time_t(time::now()) << " Hello, " << endl;
  co_await delay(2);
  cout << time::to_time_t(time::now()) << " world!" << endl;
}

int main() {
  auto r = foo();
  r.resume();
  r.join(); // 防止main函数提前结束
}

协程的返回值类型

我们前面提到,区别一个函数是不是协程,是通过它的返回值类型来判断的。如果它的返回值类型满足协程的规则,那这个函数就会被编译成协程。

那么,这个协程的规则 是什么呢?规则就是返回值类型能够实例化下面的模板类型 _Coroutine_traits

cpp 复制代码
template <class _Ret, class = void>
struct _Coroutine_traits {};

template <class _Ret>
struct _Coroutine_traits<_Ret, void_t<typename _Ret::promise_type>> {
    using promise_type = typename _Ret::promise_type;
};

template <class _Ret, class...>
struct coroutine_traits : _Coroutine_traits<_Ret> {};

简单来说,就是返回值类型 _Ret 能够找到一个类型 _Ret::promise_type 与之相匹配。这个 promise_type 既可以是直接定义在 _Ret 当中的类型,也可以通过 using 指向已经存在的其他外部类型。

此时,我们就可以给出 Result 的部分实现了:

cpp 复制代码
struct Result {
  struct promise_type {
    ...
  };
};

协程返回值对象的构建

再看一下协程的示例:

cpp 复制代码
Result Coroutine(int start_value) {
  std::cout << start_value << std::endl;
  co_await std::suspend_always{};
  std::cout << start_value + 1 << std::endl;
};

这时你已经了解 C++ 当中如何界定一个协程。不过你可能会产生一个新的问题,返回值是从哪儿来的?协程体当中并没有给出 Result 对象创建的代码。

实际上,Result 对象的创建是由 promise_type 负责的,我们需要定义一个 get_return_object 函数来处理对 Result 对象的创建:

cpp 复制代码
struct Result {
  struct promise_type {

    Result get_return_object() {
      // 创建 Result 对象
      return {};
    }

    ...
  };
};

不同于一般的函数,协程的返回值并不是在返回之前才创建,而是在协程的状态创建出来之后马上就创建的。也就是说,协程的状态被创建出来之后,会立即构造 promise_type 对象,进而调用 get_return_object 来创建返回值对象。

promise_type 类型的构造函数参数列表如果与协程的参数列表一致,那么构造 promise_type 时就会调用这个构造函数。否则,就通过默认无参构造函数来构造 promise_type

协程体的执行

在协程的返回值被创建之后,协程体就要被执行了。

initial_suspend

为了方便灵活扩展,协程体执行的第一步是调用 co_await promise.initial_suspend()initial_suspend 的返回值就是一个等待对象(awaiter),如果返回值满足挂起的条件,则协程体在最一开始就立即挂起。这个点实际上非常重要,我们可以通过控制 initial_suspend 返回的等待体来实现协程的执行调度。有关调度的内容我们后面会专门探讨。

协程体的执行

接下来执行协程体。

协程体当中会存在 co_await、co_yield、co_return 三种协程特有的调用,其中

  • co_await 我们前面已经介绍过,用来将协程挂起。
  • co_yield 则是 co_await 的一个马甲,用于传值给协程的调用者或恢复者或被恢复者,我们后面会专门用一篇文章给出例子介绍它的用法。
  • co_return 则用来返回一个值或者从协程体返回。

协程体的返回值

对于返回一个值的情况,需要在 promise_type 当中定义一个函数

cpp 复制代码
??? return_value();

例如:

cpp 复制代码
struct Result {
  struct promise_type {

    void return_value(int value) {
      ...
    }

    ...

  };
};

此时,我们的 Coroutine 函数就需要使用 co_return 来返回一个整数了:

cpp 复制代码
Result Coroutine() {
  ...
  co_return 1000;
};

1000 会作为参数传入,即 return_value 函数的参数 value 的值为 1000。

这时候读者可能会疑惑,这个值好像没什么用啊?大家别急,这个值可以存到 promise_type 对象当中,外部的调用者可以获取到。

协程体返回void

除了返回值的情况以外,C++ 协程当然也支持返回 void。只不过 promise_type 要定义的函数就不再是 return_value 了,而是 return_void 了:

cpp 复制代码
struct Result {
  struct promise_type {
    
    void return_void() {
      ...
    }

    ...
  };
};

这时,协程内部就可以通过 co_return 来退出协程体了:

cpp 复制代码
Result Coroutine() {
  ...
  co_return;
};

协程体抛出异常

协程体除了正常返回以外,也可以抛出异常。异常实际上也是一种结果的类型,因此处理方式也与返回结果相似。我们只需要在 promise_type 当中定义一个函数,在异常抛出时这个函数就会被调用到:

cpp 复制代码
struct Result {
  struct promise_type {
    
    void unhandled_exception() {
      exception_ = std::current_exception(); // 获取当前异常
    }

    ...
  };
};

final_suspend

当协程执行完成或者抛出异常之后会先清理局部变量,接着调用 final_suspend 来方便开发者自行处理其他资源的销毁逻辑。final_suspend 也可以返回一个等待体使得当前协程挂起,但之后当前协程应当通过 coroutine_handle 的 destroy 函数来直接销毁,而不是 resume。

相关推荐
码上一元2 小时前
SpringBoot自动装配原理解析
java·spring boot·后端
枫叶_v4 小时前
【SpringBoot】22 Txt、Csv文件的读取和写入
java·spring boot·后端
杜杜的man4 小时前
【go从零单排】Closing Channels通道关闭、Range over Channels
开发语言·后端·golang
java小吕布5 小时前
Java中Properties的使用详解
java·开发语言·后端
2401_857610036 小时前
Spring Boot框架:电商系统的技术优势
java·spring boot·后端
杨哥带你写代码8 小时前
网上商城系统:Spring Boot框架的实现
java·spring boot·后端
camellias_8 小时前
SpringBoot(二十一)SpringBoot自定义CURL请求类
java·spring boot·后端
背水8 小时前
初识Spring
java·后端·spring
晴天飛 雪8 小时前
Spring Boot MySQL 分库分表
spring boot·后端·mysql
weixin_537590458 小时前
《Spring boot从入门到实战》第七章习题答案
数据库·spring boot·后端