面试官:C++20 协程有用过吗?
共 9905字,需浏览 20分钟
·
2023-03-03 23:06
无栈协程成为 C++20 协程标准
协程分为无栈协程和有栈协程两种,无栈指可挂起/恢复的函数,有栈协程则相当于用户态线程。
有栈协程切换的成本是用户态线程切换的成本,而无栈协程切换的成本则相当于函数调用的成本。
无栈协程和线程的区别:无栈协程只能被线程调用,本身并不抢占内核调度,而线程则可抢占内核调度。
协程函数与普通函数的区别:
(1)普通函数执行完返回,则结束。协程函数可以运行到一半,返回并保留上下文;下次唤醒时恢复上下文,可以接着执行。
协程与多线程:
(1)协程适合IO密集型程序,一个线程可以调度执行成千上万的协程,IO事件不会阻塞线程
(2)多线程适合CPU密集型场景,每个线程都负责cpu计算,cpu得到充分利用
协程与异步:
(1)都是不阻塞线程的编程方式,但是协程是用同步的方式编程、实现异步的目的,比较适合代码编写、阅读和理解
(2)异步编程通常使用callback函数实现,将一个功能拆分到不同的函数,相比协程编写和理解的成本更高。
C++20 为什么选择无栈协程?
有栈(stackful)协程通常的实现手段是在堆上提前分配一块较大的内存空间(比如 64K),也就是协程所谓的“栈”,参数、return address 等都可以存放在这个“栈”空间上。
如果需要协程切换,那么通过 swapcontext 一类的形式来让系统认为这个堆上空间就是普通的栈,这就实现了上下文的切换。
有栈协程最大的优势就是侵入性小,使用起来非常简便,已有的业务代码几乎不需要做什么修改,但是 C++20 最终还是选择了使用无栈协程,主要出于下面这几个方面的考虑。
栈空间的限制
有栈协程的“栈”空间普遍是比较小的,在使用中有栈溢出的风险;而如果让“栈”空间变得很大,对内存空间又是很大的浪费。无栈协程则没有这些限制,既没有溢出的风险,也无需担心内存利用率的问题。
性能
有栈协程在切换时确实比系统线程要轻量,但是和无栈协程相比仍然是偏重的,这一点虽然在我们目前的实际使用中影响没有那么大(异步系统的使用通常伴随了 IO,相比于切换开销多了几个数量级),但也决定了无栈协程可以用在一些更有意思的场景上。
举个例子,C++20 coroutines 提案的作者 Gor Nishanov 在 CppCon 2018 上演示了无栈协程能做到纳秒级的切换,并基于这个特点实现了减少 Cache Miss 的特性。
关于协程的储存空间
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机制是怎样的?在探索这些问题之前,先来看看和 C++20 协程相关的一些基本概念。
协程相关的对象
协程帧(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。
看起来和协程相关的对象还不少,这正是协程复杂又灵活的地方,可以借助这些对象来实现对协程的完全控制,实现任何想法。但是,需要先要了解这些对象是如何协作的,把这个搞清楚了,协程的原理就掌握了,写协程应用也会游刃有余了。
一个简单的 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";//#1
co_await awaiter{};
std::cout << "11.coroutine resumed, continue execcute coroutine body now, the thread id=" << std::this_thread::get_id() << "\n";//#3
}
}// namespace Coroutine
int 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 等待线程结束、线程结束后协程返回值以及协程销毁的整个过程。
参考资料:
https://github.com/alibaba/async_simple
https://timsong-cpp.github.io/cppwp/n4868/
https://blog.panicsoftware.com/coroutines-introduction/
https://lewissbaker.github.io/
https://juejin.cn/post/6844903715099377672
https://wiki.tum.de/download/attachments/93291100/Kolb%20report%20-%20Coroutines%20in%20C%2B%2B20.pdf
作者:祁宇,Modern C++ 开源社区 purecpp.org 创始人,《深入应用 C++11》作者
许传奇,阿里巴巴开发工程师, LLVM Committer, C++ 标准委员会成员
韩垚,阿里巴巴工程师,目前从事搜索推荐引擎开发原文链接: https://blog.csdn.net/csdnnews/article/details/124123024
推荐:
很多人搞不清 C++ 中的 delete 和 delete[ ] 的区别
Java、C++ 内存模型都不知道,还敢说自己是高级工程师?
c++ thread join 和 detach 到底有什么区别?
C++ 面试八股文:list、vector、deque 比较
STL vector push_back 和 emplace_back 区别