【C++ 异步编程】C++ 20 的协程起手指南
文章目录
推荐一个零声教育学习教程,个人觉得老师讲得不错,分享给大家:[Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,点击立即学习: https://github.com/0voice 链接。
前言
从 【网络编程】NtyCo协程服务器的框架(轻量级的协程方案,人称 “小线程”) 到 【C++ 异步编程】没有 C++20 的协程特性之前,我们是怎样完成异步编程的。搞懂这个就能理解 “协程可以像同步编程那样完成异步编程”,当我 get 到协程是为了解决多个不同 ”异步耗时任务“ 的按流程序执行时产生的 ”回调地狱问题“ (对开发效率极为不利,尽管运行速度很快,不是性能的问题),我就知道我离学会 C++20 协程已经不远了。
本篇文章就先对 C++20 的协程做一个可以运行的小案例测试。我更大的愿望其实是,写一个是用协程完成百万并发的服务器程序。这是有点难的,但如果完成了,这说明我对做业务复杂的高性能服务器又往前进了一大步。
C++20 协程的介绍
C++20 的协程是广义上的 ”函数”,我们都知道普通的 C++ 函数只能够 “被线程调用”、“一字不落全部执行才返回”。而协程是可以主动让出协程的控制权,不必等协程的内容全部执行完再返回。
关于协程的八股文:协程是用户态轻量级线程,由程序自身控制调度(非抢占式),在单线程内实现多任务协作式并发。
有两个特别的关键字 co_await 和 co_yield,这意味着任何一个 C++20 的协程,其协程的控制权有两个让出对象,第一个是让出给调用它的主协程,即 co_yield,另一个让出对象是其他线程 co_await。这种设计其实很巧妙,我们的协程可以通过线程间通信来实现异步任务处理,同时也可以主动让出给主协程,供其调度差遣,有相当大的灵活性。
协程真的很像一个函数,他不过是多了 恢复、异步执行、让出协程 、协程返回 四个动作而已。线程里的主协程就是一整个程序的主线,而子协程是支线任务,就算分叉再多,弯弯绕绕再多,所有的协程执行完之后都得回到主协程里面。
书写一个协程
C++20 认为协程得要自己给自己定立规矩,协程要主动的区域其他协程进行协调协作,不可以做抢占线程的事情。所谓的规矩就是,异步等待、让出协程、协程返回、协程恢复的时候要做什么动作。只有订立了规矩,才有协作的基础,正如有了协议才有了网络通信,不然怎么叫“协程”呢?
而在 C++ 20 的协程库里面,有一种数据结构叫做 promise_type,他就是我们所要订立的 “规矩”,不仅要有规矩,还得要有异步调用链。定义一个写成所需要的大部分接口,都在下图显示。我们需要定义接口,来让这些关键字的使用有具体的语义。
还需要说明自己的协程运行到什么地方会让出协程、主协程什么时候会恢复协程、协程什么时候返回、主协程什么时候执行异步任务。这就是具体协程的实现了,这些实现都依赖于对协程接口的定义,完成了协程的接口定义,才可以书写协程,因为协程的挂起、让出、返回、恢复,这四个关键动作都是依赖于它们。
promise_type 是协程的规矩承诺
我的这份代码,来自于 B 站夏曹俊 C++ 基础实战课程的一段代码。我只是把它的代码都改了一下,把终端输出信息补充得更加详细,读者根据终端输出可以很快的理解代码的执行顺序。
自定义一个类型,只要其 “内嵌 / 组合” 一个 promise_type 类型接口,再组合一个 coroutine_handle 的句柄,用来获取promise_type 内部的数据 (我们恢复协程、迭代器输出都是要使用这个句柄的),那么我们就可以定义出协程的一个蓝本。
#include<iostream>#include<coroutine>#include<string>#include<optional>#include<thread>#include<queue>usingnamespace std;template<typenameT>classTask{public:structpromise_type{promise_type(){ cout <<"1 promise_type 构造函数,定立规矩"<< endl;}~promise_type(){ cout <<"8 promise_type 析构函数,协程死亡,规矩作废"<< endl;}autoget_return_object(){ cout <<"2 get_return_object 获取协程的句柄"<< endl;return Task{coroutine_handle<promise_type>::from_promise(*this)};} std::suspend_never initial_suspend(){ cout <<"4 initial_suspend 协程创建之初,此处不挂起"<< endl;return{};} std::suspend_always final_suspend()noexcept{ cout <<"6 final_suspend 协程返回,此处挂起"<< endl;return{};}voidunhandled_exception(){ cout <<"unhandled_exception 协程内部处理"<< endl;}voidreturn_void(){ cout <<"5 return_void 这个协程并不返回任何值"<< endl;} std::suspend_always yield_value(T value){ cout <<"yield_value(让出值、生成值): "<< value << endl; value_ =move(value);return{};}// 协程不能直接让出 T value_;}; coroutine_handle<promise_type>handle(){return handle_;} std::optional<T>Next(){if(!handle_ || handle_.done())return std::nullopt; handle_.resume();if(handle_.done())return std::nullopt;return handle_.promise().value_;}Task(coroutine_handle<promise_type> handle ):handle_(handle){ cout <<"3 Task 协程构造函数,协程可以看作是一个任务函数"<< endl;}~Task(){ cout <<"7 Task 析构函数,某种意义上是函数的析构"<< endl;if(handle_) handle_.destroy();}private: coroutine_handle<promise_type> handle_;};promise_type 与协程的关系图
Awaitable 异步等待对象
需要定义三个接口,名字不能写错,接口方法的名字是固定的。
// 异步等待时期的处理链structXEventAwait{boolawait_ready(){ cout <<"await_ready:本次协程被设定成一定挂起\n";returnfalse;//挂起}voidawait_suspend(coroutine_handle<> handle){ cout <<"XEvent await_suspend:协程投放异步任务,此处模拟异步任务,做出休眠"<< endl; this_thread::sleep_for(3s); handle.resume();} string await_resume(){ cout <<"await_resume:异步任务已经完成,现在正在对异步任务返回值做数据处理,随后把值传递给协程"<< endl;return"testresume";}};根据前两个类型定义一个协程
Task<string>TestCoroutine(){ cout <<"TestCoroutine 开始运行"<< endl; cout <<"准备调用异步任务链"<< endl; string res =co_await XEventAwait{};// 可以使用 std::suspend_always{} cout <<"after co_wait:异步等待 async-wait 完成,获取异步任务的处理结果 res ="<< res << endl;for(int i =0; i <4; i++){ string str ="test co_yield "+ std::to_string(i +1);co_yield str;} cout <<"协程即将返回"<< endl;co_return; cout <<"TestCoroutine 结束 不会被调用,因为协程不会往 co_return 下方运行"<< endl;}综合前面的代码,我结合接口和对象,来描述主协程、协程、异步等待、异步任务之间的关系图,搞清楚这些接口的调用关系,我们就会很好的完成协程的定义。
再对比开头的这张图,我们就能清晰的 get 到 C++20 协程的架构使用逻辑了。
测试协程
intmain(){{auto task =TestCoroutine(); cout <<"这是协程第一次让出给主协程"<< endl; cout <<"接下来,协程进入生成器模式:resume 接口守在生成函数里面"<< endl;// task.handle().resume();while(auto line = task.Next()){ cout <<"主协程获取来自生成器的数据: "<<*line << endl;} cout <<"生成器任务结束"<< endl; cout <<"即将离开作用域,即使协程最后是被挂起且没有被恢复执行,也会自动析构对象"<< endl;}}运行之后,我们大概就清楚了它的语法,各个语句之间的调用顺序。大家比对终端输出和代码的文本输出流语句就会明白是 C++20 协程的接口怎么一回事了。
qiming@k8s-master1:~/share/mycpp_work/coroutine-network$ g++ -std=c++20 -o test coroutine_test.cpp -g qiming@k8s-master1:~/share/mycpp_work/coroutine-network$ ./test 1 promise_type 构造函数,定立规矩 2 get_return_object 获取协程的句柄 3 Task 协程构造函数,协程可以看作是一个任务函数 4 initial_suspend 协程创建之初,此处不挂起 TestCoroutine 开始运行 准备调用异步任务链 await_ready:本次协程被设定成一定挂起 XEvent await_suspend:协程投放异步任务,此处模拟异步任务,做出休眠 await_resume:异步任务已经完成,现在正在对异步任务返回值做数据处理,随后把值传递给协程 after co_wait:异步等待 async-wait 完成,获取异步任务的处理结果 res =testresume yield_value(让出值、生成值): test co_yield 1 这是协程第一次让出给主协程 接下来,协程进入生成器模式:resume 接口守在生成函数里面 yield_value(让出值、生成值): test co_yield 2 主协程获取来自生成器的数据: test co_yield 2 yield_value(让出值、生成值): test co_yield 3 主协程获取来自生成器的数据: test co_yield 3 yield_value(让出值、生成值): test co_yield 4 主协程获取来自生成器的数据: test co_yield 4 协程即将返回 5 return_void 这个协程并不返回任何值 6 final_suspend 协程返回,此处挂起 生成器任务结束 即将离开作用域,即使协程最后是被挂起且没有被恢复执行,也会自动析构对象 7 Task 析构函数,某种意义上是函数的析构 8 promise_type 析构函数,协程死亡,规矩作废 总结
C++20 协程的架构就说到这里了。我们还是对比一下它跟 NtyCo 开源框架的区别。
NtyCo 协程框架的流水线式的逻辑图如下,他是跟 C++20 的协程架构不一样的。C++20 的协程从未离开过本线程!他只是给异步线程派发任务。