> 文章列表 > C++之深入解析C++20协程的原理和应用

C++之深入解析C++20协程的原理和应用

C++之深入解析C++20协程的原理和应用

一、无栈协程成为 C++20 协程标准

  • 协程分为无栈协程和有栈协程两种,无栈指可挂起/恢复的函数,有栈协程则相当于用户态线程。有栈协程切换的成本是用户态线程切换的成本,而无栈协程切换的成本则相当于函数调用的成本;无栈协程和线程的区别:无栈协程只能被线程调用,本身并不抢占内核调度,而线程则可抢占内核调度。
  • 协程函数与普通函数的区别:普通函数执行完返回,则结束,协程函数可以运行到一半,返回并保留上下文;下次唤醒时恢复上下文,可以接着执行。
  • 协程与多线程:
    • 协程适合 IO 密集型程序,一个线程可以调度执行成千上万的协程,IO 事件不会阻塞线程;
    • 多线程适合 CPU 密集型场景,每个线程都负责 cpu 计算,cpu 得到充分利用。
  • 协程与异步:
    • 都是不阻塞线程的编程方式,但是协程是用同步的方式编程、实现异步的目的,比较适合代码编写、阅读和理解;
    • 异步编程通常使用 callback 函数实现,将一个功能拆分到不同的函数,相比协程编写和理解的成本更高。

二、C++20 为什么选择无栈协程?

  • 有栈(stackful)协程通常的实现手段是在堆上提前分配一块较大的内存空间(比如 64K),也就是协程所谓的“栈”,参数、return address 等都可以存放在这个“栈”空间上。如果需要协程切换,那么通过 swapcontext 一类的形式来让系统认为这个堆上空间就是普通的栈,这就实现了上下文的切换。
  • 有栈协程最大的优势就是侵入性小,使用起来非常简便,已有的业务代码几乎不需要做什么修改,但是 C++20 最终还是选择了使用无栈协程,主要出于下面这几个方面的考虑。
  • 栈空间的限制:有栈协程的“栈”空间普遍是比较小的,在使用中有栈溢出的风险;而如果让“栈”空间变得很大,对内存空间又是很大的浪费。无栈协程则没有这些限制,既没有溢出的风险,也无需担心内存利用率的问题。
  • 性能:有栈协程在切换时确实比系统线程要轻量,但是和无栈协程相比仍然是偏重的,这一点虽然在目前的实际使用中影响没有那么大(异步系统的使用通常伴随了 IO,相比于切换开销多了几个数量级),但也决定了无栈协程可以用在一些更有意思的场景上。
  • 关于协程的储存空间:
    • C++ 的设计是无栈协程, 所有的局部状态都储存在堆上;
    • 储存协程的状态需要分配空间,分配 frame 的时候会先搜索 promise_type 有没有提供 operator new, 其次是搜索全局范围;
    • 有分配就可能会有失败,如果写了 get_return_object_on_allocation_failure() 函数,那就是失败后的办法,代替 get_return_object() 来完成工作(需要 noexcept);
    • 协程结束以后的释放空间也会先在 promise_type 里面搜索 operator delete,其次搜索全局范围;
    • 协程的储存空间只有在运行完 final_suspend 之后才会析构,或者得显式调用 coro.destroy(),否则协程的存储空间就永远不会释放,如果在 final_suspend 那里停下,那么就得在包装函数里面手动调用 coro.destroy(),不然就会漏内存;
    • 如果已经运行完毕 final_suspend,或者已经被 coro.destroy() 给析构了,那么协程的储存空间已经被释放,再次对 coro 做任何的操作都会导致 seg fault。

三、无栈协程是普通函数的泛化

  • 无栈协程是一个可以暂停和恢复的函数,是函数调用的泛化,这是为什么呢?我们知道一个函数的函数体(function body)是顺序执行的,执行完之后将结果返回给调用者,没办法挂起它并稍后恢复它,只能等待它结束。
  • 而无栈协程则允许我们把函数挂起,然后在任意需要的时刻去恢复并执行函数体,相比普通函数,协程的函数体可以挂起并在任意时刻恢复执行,因此,从这个角度来说,无栈协程是普通函数的泛化。

在这里插入图片描述

四、C++20 协程的“微言大义”

  • C++20 提供了三个新关键字(co_await、co_yield 和 co_return),如果一个函数中存在这三个关键字之一,那么它就是一个协程。
  • 编译器会为协程生成许多代码以实现协程语义,会生成什么样的代码?怎么实现协程的语义?协程的创建是怎样的?co_await 机制是怎样的?

① 协程帧(coroutine frame)

  • 当 caller 调用一个协程的时候会先创建一个协程帧,协程帧会构建 promise 对象,再通过 promise 对象产生 return object。
  • 协程帧中主要有这些内容:
    • 协程参数;
    • 局部变量;
    • promise 对象。
  • 这些内容在协程恢复运行的时候需要用到,caller 通过协程帧的句柄 std::coroutine_handle 来访问协程帧。

② promise_type

  • promise_type 是 promise 对象的类型,promise_type 用于定义一类协程的行为,包括协程创建方式、协程初始化完成和结束时的行为、发生异常时的行为、如何生成 awaiter 的行为以及 co_return 的行为等。
  • promise 对象可以用于记录/存储一个协程实例的状态,每个协程桢与每个 promise 对象以及每个协程实例是一一对应的。

③ coroutine return object

  • 它是 promise.get_return_object() 方法创建的,一种常见的实现手法会将 coroutine_handle 存储到 coroutine object 内,使得该 return object 获得访问协程的能力。

④ std::coroutine_handle

  • 协程帧的句柄,主要用于访问底层的协程帧、恢复协程和释放协程帧。
  • 可通过调用 std::coroutine_handle::resume() 唤醒协程。

⑤ co_await、awaiter、awaitable

  • co_await:一元操作符;
  • awaitable:支持 co_await 操作符的类型;
  • awaiter:定义了 await_ready、await_suspend 和 await_resume 方法的类型。
  • co_await expr 通常用于表示等待一个任务(可能是 lazy 的,也可能不是)完成。co_await expr 时,expr 的类型需要是一个 awaitable,而该 co_await表达式的具体语义取决于根据该 awaitable 生成的 awaiter。
  • 看起来和协程相关的对象还不少,这正是协程复杂又灵活的地方,可以借助这些对象来实现对协程的完全控制,实现任何想法。但是,需要先要了解这些对象是如何协作的,掌握了协程的原理,写协程应用也会游刃有余。

⑥ 协程对象的协作

  • 如下所示:
Return_t foo () { auto res = co_await awaiter; co_return res ; 
}
  • Return_t:promise return object,awaiter: 等待一个 task 完成:

在这里插入图片描述

  • 图中浅蓝色部分的方法就是 Return_t 关联的 promise 对象的函数,浅红色部分就是 co_await 等待的 awaiter。这个流程的驱动是由编译器根据协程函数生成的代码驱动的,分成三部分:
    • 协程创建;
    • co_await awaiter 等待 task 完成;
    • 获取协程返回值和释放协程帧。
  • 协程的创建:
Return_t foo () { auto res = co_await awaiter; co_return res ; 
}
  • foo() 协程会生成下面这样的模板代码(伪代码),协程的创建都会产生类似的代码:
{co_await promise.initial_suspend();try {coroutine body;}catch (...) {promise.unhandled_exception();}
FinalSuspend:co_await promise.final_suspend();
}
  • 首先需要创建协程,创建协程之后是否挂起则由调用者设置 initial_suspend 的返回类型来确定。
  • 创建协程的流程大概如下:
    • 创建一个协程帧(coroutine frame);
    • 在协程帧里构建 promise 对象;
    • 把协程的参数拷贝到协程帧里;
    • 调用 promise.get_return_object() 返回给 caller 一个对象,即代码中的 Return_t 对象。
  • 在这个模板框架里有一些可定制点:如 initial_suspend、final_suspend、unhandled_exception 和 return_value。可以通过 promise 的 initial_suspend 和 final_suspend 返回类型来控制协程是否挂起,在 unhandled_exception 里处理异常,在 return_value 里保存协程返回值。可以根据需要定制 initial_suspend 和 final_suspend 的返回对象来决定是否需要挂起协程,如果挂起协程,代码的控制权就会返回到 caller,否则继续执行协程函数体(function body)。

在这里插入图片描述

  • 值得注意的是,如果禁用异常,那么生成的代码里就不会有 try-catch,此时协程的运行效率几乎等同非协程版的普通函数,这在嵌入式场景很重要,也是协程的设计目的之一。

五、C++20 协程示例

  • 如下所示,通过 co_await 把协程调度到一个线程中打印一下线程 id:
#include <coroutine>
#include <iostream>
#include <thread>namespace Coroutine {struct task {struct promise_type {promise_type() {std::cout << "1.create promie object\\n";}task get_return_object() {std::cout << "2.create coroutine return object, and the coroutine is created now\\n";return {std::coroutine_handle<task::promise_type>::from_promise(*this)};}std::suspend_never initial_suspend() {std::cout << "3.do you want to susupend the current coroutine?\\n";std::cout << "4.don't suspend because return std::suspend_never, so continue to execute coroutine body\\n";return {};}std::suspend_never final_suspend() noexcept {std::cout << "13.coroutine body finished, do you want to susupend the current coroutine?\\n";std::cout << "14.don't suspend because return std::suspend_never, and the continue will be automatically destroyed, bye\\n";return {};}void return_void() {std::cout << "12.coroutine don't return value, so return_void is called\\n";}void unhandled_exception() {}};std::coroutine_handle<task::promise_type> handle_;};struct awaiter {bool await_ready() {std::cout << "6.do you want to suspend current coroutine?\\n";std::cout << "7.yes, suspend becase awaiter.await_ready() return false\\n";return false;}void await_suspend(std::coroutine_handle<task::promise_type> handle) {std::cout << "8.execute awaiter.await_suspend()\\n";std::thread([handle]() mutable { handle(); }).detach();std::cout << "9.a new thread lauched, and will return back to caller\\n";}void await_resume() {}};task test() {std::cout << "5.begin to execute coroutine body, the thread id=" << std::this_thread::get_id() << "\\n";//#1co_await awaiter{};std::cout << "11.coroutine resumed, continue execcute coroutine body now, the thread id=" << std::this_thread::get_id() << "\\n";//#3}
}// namespace Coroutineint main() {Coroutine::test();std::cout << "10.come back to caller becuase of co_await awaiter\\n";std::this_thread::sleep_for(std::chrono::seconds(1));return 0;
}
  • 测试输出:
1.create promie object
2.create coroutine return object, and the coroutine is created now
3.do you want to susupend the current coroutine?
4.don't suspend because return std::suspend_never, so continue to execute coroutine body
5.begin to execute coroutine body, the thread id=0x10e1c1dc0
6.do you want to suspend current coroutine?
7.yes, suspend becase awaiter.await_ready() return false
8.execute awaiter.await_suspend()
9.a new thread lauched, and will return back to caller
10.come back to caller becuase of co_await awaiter
11.coroutine resumed, continue execcute coroutine body now, the thread id=0x700001dc7000
12.coroutine don't return value, so return_void is called
13.coroutine body finished, do you want to susupend the current coroutine?
14.don't suspend because return std::suspend_never, and the continue will be automatically destroyed, bye
  • 可以清晰的看到协程是如何创建的、co_await 等待线程结束、线程结束后协程返回值以及协程销毁的整个过程。输出内容中的 1、2、3 展示了协程创建过程,先创建 promise,再通过 promise.get_return_object() 返回 task,这时协程就创建完成。协程创建完成之后是要立即执行协程函数呢?还是先挂起来?这个行为由 promise.initial_suspend() 来确定,由于它返回的是一个 std::suspend_never 的awaiter,因此不会挂起协程,于是就立即执行协程函数。
  • 执行协程到函数的 co_await awaiter 时,是否需要等待某个任务?返回 false 表明希望等待,于是接着进入到 awaiter.wait_suspend(),并挂起协程,在 await_suspend 中创建了一个线程去执行任务,之后就返回到 caller,caller 这时候可以不用阻塞等待线程结束,可以做其它事情,需要注意的是 awaiter 同时也是一个 awaitable,因为它支持 co_await。
  • 当线程开始运行的时候恢复挂起的协程,这时候代码执行会回到协程函数继续执行,这就是最终的目标:在一个新线程中去执行协程函数的打印语句。awaiter.final_suspend 决定是否要自动销毁协程,返回 std::suspend_never 就自动销毁协程,否则需要用户手动去销毁。

华夏名砚网