编辑推荐: |
文章深入探索了 C++ 20 协程的原理,并通过详实的代码示例,展现它在提升编程效率上的独特魅力。
希望能为大家提供一些参考或帮助。
文章来自于微信公众号深度Linux,由火龙果Linda编辑推荐。 |
|
传统的多线程编程虽能实现并发任务,但资源开销大、线程同步复杂等问题常让开发者头疼不已。而
C++ 20 引入的协程,宛如一股清新的风,为异步编程带来了全新思路。它轻量级且高效,无需像多线程那样耗费大量资源。
接下来,本文将带你深入探索 C++ 20 协程的原理,并通过详实的代码示例,展现它在提升编程效率上的独特魅力。
一、C++ 20协程概述
在 C++ 编程的世界里,大家是不是常常会遇到这样的困扰:程序需要执行一些耗时的操作,像是等待网络响应、读取文件,这时候整个线程就像被施了定身咒,干等着啥也干不了,效率极其低下。而且,多线程编程虽然能解决一部分问题,但随之而来的是复杂的同步、互斥,稍不留意就会陷入死锁的泥潭,让人头疼不已。
别急,C++ 20 带来的协程(Coroutine)特性,就像是一把神奇的钥匙,为咱们打开了高效异步编程的新大门。协程,简单来说,它就像是一个超级灵活的函数,可以在执行过程中随时暂停,把宝贵的
CPU 时间让给其他任务,等条件满足了,又能无缝恢复,继续之前的工作。这意味着,咱们可以用一种看似同步的简洁代码风格,轻松驾驭异步操作,让程序的性能蹭蹭上涨。
想象一下,在开发一个网络应用时,协程能让你轻松应对海量的并发请求,而不必为线程的频繁切换和资源竞争而烦恼;又或者在处理大数据流时,它能够巧妙地分段处理数据,既节省内存,又能快速响应。无论是构建高性能的服务器,还是优化复杂的算法逻辑,协程都有用武之地。接下来,咱们就深入探索一下
C++ 20 协程的奥秘,看看它究竟是如何施展 “魔法”,让编程变得更加高效、优雅的。
二、协程初印象:特殊的函数
咱们先来揭开协程的神秘面纱,看看它究竟是何方神圣。从本质上讲,协程是一种特殊的函数,与咱们日常编写的普通函数相比,它有着独特的
“超能力”。普通函数一旦被调用,就像上了发条的闹钟,会按照既定的代码逻辑 “哒哒哒” 地一路执行下去,直到返回结果或者遇到异常才会停下脚步。而协程呢,就灵活多了,它在执行过程中能够随时暂停,把
CPU 资源拱手让出,去干别的更紧急的事儿,等时机成熟了,又能无缝衔接,从上次暂停的地方继续开工。
举个生活中的例子,想象你正在家里做饭,突然门铃响了,这时候你就相当于一个协程,暂停做饭这个 “任务”,去开门处理访客的事儿,处理完回来接着做饭,而锅里饭菜的状态(类比协程的内部状态)还保持着你离开时的样子。
用专业点的术语来说,协程函数内部可以使用 co_await、co_yield 这两个关键字来实现挂起操作,而
co_return 关键字则用于协程的返回。当协程执行到 co_await 时,它会检查后面跟着的等待体(awaiter)对象,如果条件不满足(比如等待的网络数据还没准备好,或者某个异步操作还未完成),协程就会暂停执行,进入等待状态,并且保存当前的执行上下文,包括局部变量的值、代码执行到的位置等信息。直到等待的条件满足,协程又会被唤醒,恢复到之前挂起的位置,继续向下执行。co_yield
的作用类似,不过它除了挂起协程,还会向调用者返回一个值,就好像你暂停做饭去开门时,还顺便给访客递了瓶水(这个
“水” 就是返回值)。
首先再次强调,C++ 20的协程是一个特殊函数。只是这个函数具有挂起和恢复的能力,可以被挂起(挂起后调用代码继续向后执行),而后可以继续恢复其执行。如下图:

如图所示,协程并没有一次执行完成,可以被反复挂起,挂起后可以恢复到挂起的点继续运行。为了更直观地感受协程的执行流程,咱们来看下面这张简单的示意图:

通过这个表格,咱们能清晰地看到协程在执行流程上相较于普通函数的优势,它能够在面对耗时操作时灵活应变,避免线程长时间阻塞,大大提高程序的运行效率。特别是在处理高并发场景,比如网络服务器同时处理成千上万的请求,或者在执行大量
I/O 密集型任务(如读写文件、数据库查询等)时,协程的这种特性就如同给程序装上了涡轮增压引擎,让性能得到质的飞跃。
三、C++ 20 协程的关键组件
了解了协程的基本概念后,咱们再来深入认识一下协程的几个关键组件,它们就像是协程这部 “精密仪器” 的零部件,相互协作,让协程得以顺畅运行。
首先是协程函数,前面咱们提到它可以用 co_await、co_yield、co_return 来标记。这三个关键字各有神通,co_await
用于暂停协程的执行,直到某个异步操作完成,就好像你在等快递上门,没等到的时候就先去干别的事儿,等快递员打电话了,你再回来签收;co_yield
用于从协程中产生一个值,并暂停协程的执行,它常用于生成器模式,想象一个生产数字序列的协程,每产生一个数字就暂停一下,等待外界取用;co_return
则用于从协程中返回一个值,并结束协程的执行,类似普通函数的 return 语句,只不过协程的返回还有一些额外的
“善后工作” 要做。
3.1 co_await
co_await调用一个awaiter对象(可以认为是一个接口),根据其内部定义决定其操作是挂起,还是继续,以及挂起,恢复时的行为。其呈现形式为
cw_ret
= co_await awaiter; |
cw_ret 记录调用的返回值,其是awaiter的await_resume 接口返回值。
3.2 co_yield
挂起协程。其出现形式是
cy_ret会保存在promise承诺对象中(通过yield_value函数)。在协程外部可以通过promise得到。
3.3 co_return
协程返回。其出现形式是
cr_ret会保存在promise承诺对象中(通过return_value函数)。在协程外部可以通过promise得到。要注意,cr_ret并不是协程的返回值。这个是有区别的。
咱们来看一个简单的示例代码:
#include <coroutine> #include <iostream>
struct Generator { struct promise_type { int current_value; Generator get_return_object() { return Generator{std::coroutine_handle<promise_type>::from_promise(*this)}; } std::suspend_always initial_suspend() { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void unhandled_exception() { std::terminate(); } std::suspend_always yield_value(int value) { current_value = value; return {}; } void return_void() {} }; std::coroutine_handle<promise_type> coro_handle; explicit Generator(std::coroutine_handle<promise_type> handle) : coro_handle(handle) {} ~Generator() { if (coro_handle) coro_handle.destroy(); } Generator(const Generator&) = delete; Generator& operator=(const Generator&) = delete; Generator(Generator&& other) noexcept : coro_handle(other.coro_handle) { other.coro_handle = nullptr; } Generator& operator=(Generator&& other) noexcept { if (this!= &other) { coro_handle = other.coro_handle; other.coro_handle = nullptr; } return *this; } bool move_next() { if (!coro_handle.done()) { coro_handle.resume(); return true; } return false; } int current_value() const { return coro_handle.promise().current_value; } };
Generator counter() { for (int i = 0; i < 5; ++i) { co_yield i; } co_return; }
int main() { Generator gen = counter(); while (gen.move_next()) { std::cout << gen.current_value() << std::endl; } return 0; }
|
在这段代码中,counter 函数就是一个协程函数,它使用 co_yield 生成了一个从 0 到
4 的整数序列。每次执行到 co_yield 时,协程就会暂停,把当前的 i 值返回给调用者(也就是
main 函数中的 gen.move_next 操作),然后等待下一次被唤醒。当 i 达到 5 时,协程执行
co_return,结束运行。
接着是协程柄(Coroutine Handle),它就像是协程的 “遥控器”,用于启动协程、恢复协程的执行以及销毁协程框架。咱们可以通过
std::coroutine_handle 来获取协程柄,例如在上面的代码中,Generator 结构体里就保存了协程柄,通过它的
resume 函数可以唤醒暂停的协程,让协程继续执行,就像按下遥控器的播放键一样;而 destroy
函数则用于在协程不再使用时,释放其占用的资源,相当于关掉电器后拔掉插头,避免资源浪费。
还有一个重要组件是 awaiter,它是实现了特定接口的类,用于控制协程的挂起和恢复。一个典型的
awaiter 需要实现 await_ready、await_suspend 和 await_resume
这三个函数。await_ready 函数返回 bool 类型,如果返回 true,表示协程已经就绪,无需挂起,可以直接继续执行;否则表示需要挂起。
当 await_ready 返回 false 时,await_suspend 函数就会被调用,它的返回值类型有多种选择,返回
void 类型或者 true,表示当前协程挂起之后将执行权还给当初调用或者恢复当前协程的函数;返回
false,则恢复执行当前协程,就好像本来暂停的电影又突然播放了;返回其他协程的 coroutine_handle
对象,这时候返回的 coroutine_handle 对应的协程被恢复执行,相当于切换到另一个 “频道”;抛出异常时,当前协程恢复执行,并在当前协程当中抛出异常。最后,await_resume
函数在协程恢复执行后被调用,它的返回值将作为 co_await 表达式的返回值,比如协程等待某个网络请求完成,await_resume
就可以返回请求到的数据。
通过这几个关键组件的紧密配合,C++ 20 协程才能在异步编程的舞台上大放异彩,帮助咱们轻松应对各种复杂的任务场景。
四、协程的底层运行机制
咱们深入到 C++ 20 协程的底层,瞧瞧它究竟是怎么运作的,这就好比揭开一台精密仪器的外壳,看看里面的齿轮是如何咬合、运转,驱动整个系统工作的。
编译器在识别协程函数时,主要通过函数的返回值来判断。协程函数的返回类型有着特殊要求,必须包含一个子类型
—— 承诺对象(promise),通常呈现为 Result::promise_type 这种形式。这个承诺对象就像是协程的
“管家”,掌控着协程运行过程中的诸多关键环节,它实现了诸如 get_return_object 等一系列接口。通过
std::coroutine_handle<promise_type>::from_promise(
promise& p ) 这个静态函数,编译器就能拿到协程句柄(coroutine handle),这可是操控协程的
“遥控器”,有了它,咱们才能在协程外部对协程进行启动、恢复和销毁等操作。
当协程启动时,系统会在堆上开辟一块空间,用来存放协程状态(coroutine state)。这个协程状态就像是协程的
“日记本”,详细记录着协程函数的参数、运行状态、变量以及挂起时的断点等重要信息。打个比方,协程在执行过程中暂停了,等下次恢复执行时,靠什么知道从哪儿接着干呢?靠的就是协程状态里记录的断点信息,它能让协程丝毫不差地从上次停下的地方继续前行,就像你看视频暂停后再播放,依然能接着之前的情节往下看一样。
而且,协程状态的生命周期和协程紧密相连,协程创建时它诞生,协程结束(无论是正常执行到 co_return,还是中途遇到异常终止)时,它也随之消亡,释放占用的资源。好了上面的描述,我们可以看出协程的几个重要概念:
⑴协程状态(coroutine state)
协程状态(coroutine state)是协程启动开始时,new空间存放协程状态,协程状态记录协程函数的参数,协程的运行状态,变量。挂起时的断点。
注意,协程状态 (coroutine state)并不是就是协程函数的返回值RET。虽然我们设计的RET一般里面也有promise和coroutine
handle,大家一般也是通过RET去操作协程的恢复,获取返回值。但coroutine state理论上还应该包含协程运行参数,断点等信息。而协程状态
(coroutine state)应该是协程句柄(coroutine handle)对应的一个数据,而由系统管理的。
⑵承诺对象promise
承诺对象的表现形式必须是result::promise_type,result为协程函数的返回值。
承诺对象是一个实现若干接口,用于辅助协程,构造协程函数返回值;提交传递co_yield,co_return的返回值。明确协程启动阶段是否立即挂起;以及协程内部发生异常时的处理方式。其接口包括:
auto get_return_object() :用于生成协程函数的返回对象。
auto initial_suspend():用于明确初始化后,协程函数的执行行为,返回值为等待体(awaiter),用co_wait调用其返回值。返回值为std::suspend_always
表示协程启动后立即挂起(不执行第一行协程函数的代码),返回std::suspend_never 表示协程启动后不立即挂起。(当然既然是返回等待体,你可以自己在这儿选择进行什么等待操作)
void return_value(T v):调用co_return v后会调用这个函数,可以保存co_return的结果
auto yield_value(T v):调用co_yield后会调用这个函数,可以保存co_yield的结果,其返回其返回值为std::suspend_always表示协程会挂起,如果返回std::suspend_never表示不挂起。
auto final_suspend() noexcept:在协程退出是调用的接口,返回std::suspend_never
,自动销毁 coroutine state 对象。若 final_suspend 返回 std::suspend_always
则需要用户自行调用 handle.destroy() 进行销毁。但值得注意的是返回std::suspend_always并不会挂起协程。
前面我们提到在协程创建的时候,会new协程状态(coroutine state)。你可以通过可以在
promise_type 中重载 operator new 和 operator delete,使用自己的内存分配接口。
⑶协程句柄coroutine handle
协程句柄(coroutine handle)是一个协程的标示,用于操作协程恢复,销毁的句柄。
协程句柄的表现形式是std::coroutine_handle<promise_type>,其模板参数为承诺对象(promise)类型。句柄有几个重要函数:
resume()函数可以恢复协程。
done()函数可以判断协程是否已经完成。返回false标示协程还没有完成,还在挂起。
协程句柄和承诺对象之间是可以相互转化的。
std::coroutine_handle<promise_type>::from_promise
: 这是一个静态函数,可以从承诺对象(promise)得到相应句柄。
std::coroutine_handle<promise_type>::promise()
函数可以从协程句柄coroutine handle得到对应的承诺对象(promise)
⑷等待体(awaiter)
co_wait 关键字会调用一个等待体对象(awaiter)。这个对象内部也有3个接口。根据接口co_wait
决定进行什么操作。
bool await_ready():等待体是否准备好了,返回 false ,表示协程没有准备好,立即调用await_suspend。返回true,表示已经准备好了。
auto await_suspend(std::coroutine_handle<>
handle)如果要挂起,调用的接口。其中handle参数就是调用等待体的协程,其返回值有3种可能
void 同返回true
bool 返回true 立即挂起,返回false 不挂起。
返回某个协程句柄(coroutine handle),立即恢复对应句柄的运行。
auto await_resume() :协程挂起后恢复时,调用的接口。返回值作为co_wait
操作的返回值。
等待体(awaiter)值得用更加详细的笔墨书写一章,我们就放一下,先了解其有2个特化类型。
std::suspend_never类,不挂起的的特化等待体类型。
std::suspend_always类,挂起的特化等待体类型。
前面不少接口已经用了这2个特化的类,同时也可以明白其实协程内部不少地方其实也在使用co_wait
关键字。
例子,“七进七出”的协程。
好了。所有概念我们介绍基本完成了。先来段代码吧。否则实在憋屈。这个例子主要展现的是协程函数和主线程之间的切换。协程反复中断,然后在main函数内部又恢复其运行。直至最后co_return。
这个例子虽然简单,但如果你对异步编程有所了解也能明白如何利用C++20完成一段异步编程了。源代码获取地址请点击下面例子中:
coro_ret<int> coroutine_7in7out() 就是协程函数。
coro_ret<int> c_r 就是协程的返回值。在后续,都是通过c_r和协程进行交互。
coro_ret<int>::promise_type 就是承诺对象
std::coroutine_handle<promise_type> 就是句柄。
#include <coroutine> #include <iostream> #include <stdexcept> #include <thread>
//!coro_ret 协程函数的返回值,内部定义promise_type,承诺对象 template <typename T> struct coro_ret { struct promise_type; using handle_type = std::coroutine_handle<promise_type>; //! 协程句柄 handle_type coro_handle_;
coro_ret(handle_type h) : coro_handle_(h) { } coro_ret(const coro_ret&) = delete; coro_ret(coro_ret&& s) : coro_handle_(s.coro_) { s.coro_handle_ = nullptr; } ~coro_ret() { //!自行销毁 if (coro_handle_) coro_handle_.destroy(); } coro_ret& operator=(const coro_ret&) = delete; coro_ret& operator=(coro_ret&& s) { coro_handle_ = s.coro_handle_; s.coro_handle_ = nullptr; return *this; }
//!恢复协程,返回是否结束 bool move_next() { coro_handle_.resume(); return coro_handle_.done(); } //!通过promise获取数据,返回值 T get() { return coro_handle_.promise().return_data_; } //!promise_type就是承诺对象,承诺对象用于协程内外交流 struct promise_type { promise_type() = default; ~promise_type() = default;
//!生成协程返回值 auto get_return_object() { return coro_ret<T>{handle_type::from_promise(*this)}; }
//! 注意这个函数,返回的就是awaiter //! 如果返回std::suspend_never{},就不挂起, //! 返回std::suspend_always{} 挂起 //! 当然你也可以返回其他awaiter auto initial_suspend() { //return std::suspend_never{}; return std::suspend_always{}; } //!co_return 后这个函数会被调用 void return_value(T v) { return_data_ = v; return; } //! auto yield_value(T v) { std::cout << "yield_value invoked." << std::endl; return_data_ = v; return std::suspend_always{}; } //! 在协程最后退出后调用的接口。 //! 若 final_suspend 返回 std::suspend_always 则需要用户自行调用 //! handle.destroy() 进行销毁,但注意final_suspend被调用时协程已经结束 //! 返回std::suspend_always并不会挂起协程(实测 VSC++ 2022) auto final_suspend() noexcept { std::cout << "final_suspend invoked." << std::endl; return std::suspend_always{}; } // void unhandled_exception() { std::exit(1); } //返回值 T return_data_; }; };
//这就是一个协程函数 coro_ret<int> coroutine_7in7out() { //进入协程看initial_suspend,返回std::suspend_always{};会有一次挂起
std::cout << "Coroutine co_await std::suspend_never" << std::endl; //co_await std::suspend_never{} 不会挂起 co_await std::suspend_never{}; std::cout << "Coroutine co_await std::suspend_always" << std::endl; co_await std::suspend_always{};
std::cout << "Coroutine stage 1 ,co_yield" << std::endl; co_yield 101; std::cout << "Coroutine stage 2 ,co_yield" << std::endl; co_yield 202; std::cout << "Coroutine stage 3 ,co_yield" << std::endl; co_yield 303; std::cout << "Coroutine stage end, co_return" << std::endl; co_return 808; }
int main(int argc, char* argv[]) { bool done = false; std::cout << "Start coroutine_7in7out ()\n"; //调用协程,得到返回值c_r,后面使用这个返回值来管理协程。 auto c_r = coroutine_7in7out(); //第一次停止因为initial_suspend 返回的是suspend_always //此时没有进入Stage 1 std::cout << "Coroutine " << (done ? "is done " : "isn't done ") << "ret =" << c_r.get() << std::endl; done = c_r.move_next(); //此时是,co_await std::suspend_always{} std::cout << "Coroutine " << (done ? "is done " : "isn't done ") << "ret =" << c_r.get() << std::endl; done = c_r.move_next(); //此时打印Stage 1 std::cout << "Coroutine " << (done ? "is done " : "isn't done ") << "ret =" << c_r.get() << std::endl; done = c_r.move_next(); std::cout << "Coroutine " << (done ? "is done " : "isn't done ") << "ret =" << c_r.get() << std::endl; done = c_r.move_next(); std::cout << "Coroutine " << (done ? "is done " : "isn't done ") << "ret =" << c_r.get() << std::endl; done = c_r.move_next(); std::cout << "Coroutine " << (done ? "is done " : "isn't done ") << "ret =" << c_r.get() << std::endl; return 0; }
|
通过编译器对协程函数返回值的识别,以及协程状态和承诺对象的紧密协作,C++ 20 协程才能在底层有条不紊地运行,为上层的高效编程提供坚实的支撑。了解这些底层机制,有助于咱们在编写协程代码时,更加得心应手,遇到问题也能迅速定位根源,轻松解决。
五、实战演练:协程的应用场景
理论知识学了不少,下面咱们就通过几个具体的实战案例,来看看协程在实际编程中是如何大显身手的。
5.1异步 IO 操作
在网络编程领域,协程的异步 IO 能力简直是 “神来之笔”。以往咱们使用传统的同步方式发送网络请求,代码大概是下面这个样子:
#include <iostream> #include <thread> #include <chrono> #include <cstring> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h>
int main() { // 创建套接字 int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd == -1) { std::cerr << "创建套接字失败" << std::endl; return -1; }
// 配置服务器地址 struct sockaddr_in server_addr; server_addr.sin_family = AF_INET; server_addr.sin_port = htons(8080); inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
// 连接服务器 if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) { std::cerr << "连接服务器失败" << std::endl; close(sockfd); return -1; }
// 发送数据 const char* message = "Hello, Server!"; if (send(sockfd, message, strlen(message), 0) == -1) { std::cerr << "发送数据失败" << std::endl; close(sockfd); return -1; }
// 接收服务器响应 char buffer[1024]; memset(buffer, 0, sizeof(buffer)); if (recv(sockfd, buffer, sizeof(buffer), 0) == -1) { std::cerr << "接收数据失败" << std::endl; close(sockfd); return -1; }
std::cout << "服务器响应: " << buffer << std::endl;
// 关闭套接字 close(sockfd);
return 0; }
|
这段代码在执行 connect、send 和 recv 这些网络操作时,线程会被阻塞,干巴巴地等着操作完成,啥别的事儿都干不了,就像在超市结账时,收银员只顾着给一个顾客结账,后面排着长队的顾客只能干着急。要是在一个需要处理大量并发请求的网络服务器里,这种阻塞会让整个系统的性能变得极差,响应速度慢得让人抓狂。
现在,咱们改用协程来实现同样的功能,代码瞬间 “高大上” 了起来:
#include <iostream> #include <coroutine> #include <thread> #include <chrono> #include <cstring> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h>
// 用于表示异步操作结果的结构体 struct AsyncResult { bool ready; char buffer[1024]; };
// 实现awaitable接口的类,用于控制协程在网络操作时的挂起和恢复 struct NetworkAwaiter { AsyncResult& result; int sockfd; NetworkAwaiter(AsyncResult& res, int fd) : result(res), sockfd(fd) {} bool await_ready() const noexcept { return result.ready; } void await_suspend(std::coroutine_handle<> handle) const noexcept { // 启动一个新线程模拟异步网络操作 std::thread([this, handle]() { // 这里模拟接收服务器响应,实际应用中会替换为真实的网络操作 std::this_thread::sleep_for(std::chrono::seconds(2)); if (recv(sockfd, result.buffer, sizeof(result.buffer), 0) == -1) { std::cerr << "接收数据失败" << std::endl; } else { result.ready = true; } handle.resume(); }).detach(); } void await_resume() const noexcept {} };
// 协程函数,用于发送网络请求并异步等待响应 std::coroutine_handle<> networkRequest() { // 创建套接字 int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd == -1) { std::cerr << "创建套接字失败" << std::endl; co_return; }
// 配置服务器地址 struct sockaddr_in server_addr; server_addr.sin_family = AF_INET; server_addr.sin_port = htons(8080); inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
// 连接服务器 if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) { std::cerr << "连接服务器失败" << std::endl; close(sockfd); co_return; }
// 发送数据 const char* message = "Hello, Server!"; if (send(sockfd, message, strlen(message), 0) == -1) { std::cerr << "发送数据失败" << std::endl; close(sockfd); co_return; }
AsyncResult result = { false }; // 使用co_await等待网络响应,协程在此处挂起,不阻塞线程 co_await NetworkAwaiter(result, sockfd);
std::cout << "服务器响应: " << result.buffer << std::endl;
// 关闭套接字 close(sockfd); co_return; }
int main() { auto handle = networkRequest(); // 这里可以继续执行其他任务,而不必等待网络请求完成 // 假设其他任务耗时 1 秒 std::this_thread::sleep_for(std::chrono::seconds(1)); handle.resume(); return 0; }
|
在这个协程版本的代码里,当执行到 co_await NetworkAwaiter(result, sockfd)
时,协程就会暂停执行,把宝贵的 CPU 时间让给其他任务,就好像超市收银员在给顾客扫码结账时,发现需要等顾客找钱包,就先去帮旁边顾客装袋一样。同时,一个新线程会被启动,去模拟执行网络操作(实际应用中会替换为真实的异步网络库函数)。等网络数据接收完成,协程又会被唤醒,继续执行后续的代码,打印出服务器的响应。在
main 函数里,咱们可以看到,在协程等待网络响应的这段时间,程序还能去干点别的事儿,大大提高了程序的并发性能,让整个系统的响应速度如闪电般迅速。
文件读写操作也是类似的道理。传统的同步文件读取代码可能是这样:
#include <iostream> #include <fstream> #include <string>
int main() { std::ifstream file("example.txt"); if (file.is_open()) { std::string line; while (std::getline(file, line)) { std::cout << line << std::endl; } file.close(); } else { std::cerr << "无法打开文件" << std::endl; } return 0; }
|
这种方式在读取大文件时,线程会一直卡在文件读取操作上,程序就像被冻住了一样,动弹不得。要是用协程来改写,咱们可以实现一边读取文件,一边处理其他数据的效果,让程序的运行效率大幅提升。具体的协程实现代码如下:
#include <iostream> #include <coroutine> #include <fstream> #include <string>
// 用于表示文件读取结果的结构体 struct FileReadResult { bool ready; std::string line; };
// 实现awaitable接口的类,用于控制协程在文件读取时的挂起和恢复 struct FileReadAwaiter { FileReadResult& result; std::ifstream& file; FileReadAwaiter(FileReadResult& res, std::ifstream& f) : result(res), file(f) {} bool await_ready() const noexcept { return result.ready; } void await_suspend(std::coroutine_handle<> handle) const noexcept { // 启动一个新线程模拟异步文件读取操作 std::thread([this, handle]() { if (std::getline(file, result.line)) { result.ready = true; } handle.resume(); }).detach(); } void await_resume() const noexcept {} };
// 协程函数,用于异步读取文件 std::coroutine_handle<> readFile() { std::ifstream file("example.txt"); if (file.is_open()) { FileReadResult result = { false }; while (true) { // 使用co_await等待文件读取完成,协程在此处挂起,不阻塞线程 co_await FileReadAwaiter(result, file); if (result.ready) { std::cout << result.line << std::endl; } else { break; } } file.close(); } else { std::cerr << "无法打开文件" << std::endl; } co_return; }
int main() { auto handle = readFile(); // 这里可以继续执行其他任务,而不必等待文件读取完成 // 假设其他任务耗时 1 秒 std::this_thread::sleep_for(std::chrono::seconds(1)); handle.resume(); return 0; }
|
在这个代码中,协程遇到 co_await FileReadAwaiter(result, file)
时就会暂停,等待文件读取线程完成任务,一旦读取到一行数据,协程就会恢复,打印出这行内容,实现了高效的异步文件读取。
5.2生成器模式
生成器模式在很多场景下都非常有用,比如咱们需要生成一个大型的数据集,但又不想一次性把所有数据都生成出来占用大量内存,这时候协程就能派上用场。
就拿生成斐波那契数列来说,传统的递归方式实现代码如下:
#include <iostream>
int fibonacci(int n) { if (n <= 1) return n; return fibonacci(n - 1) + fibonacci(n - 2); }
int main() { for (int i = 0; i < 10; ++i) { std::cout << fibonacci(i) << " "; } std::cout << std::endl; return 0; }
|
这种方式计算较大的项数时,效率极其低下,因为会有大量的重复计算,就好像你为了找一件东西,在同一个房间里反复翻找各个角落,浪费了大量时间。而且,如果要生成一个很长的数列,会占用大量的栈空间,很容易导致栈溢出。现在用协程来实现,就巧妙多了:
#include <iostream> #include <coroutine>
// 用于保存协程状态和数据的结构体 struct FibonacciGenerator { struct promise_type { int current; int next; FibonacciGenerator get_return_object() { return FibonacciGenerator{ std::coroutine_handle<promise_type>::from_promise(*this) }; } std::suspend_always initial_suspend() { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void unhandled_exception() { std::terminate(); } std::suspend_always yield_value(int value) { current = value; return {}; } void return_void() {} }; std::coroutine_handle<promise_type> coro; explicit FibonacciGenerator(std::coroutine_handle<promise_type> handle) : coro(handle) {} ~FibonacciGenerator() { if (coro) coro.destroy(); } FibonacciGenerator(const FibonacciGenerator&) = delete; FibonacciGenerator& operator=(const FibonacciGenerator&) = delete; FibonacciGenerator(FibonacciGenerator&& other) noexcept : coro(other.coro) { other.coro = nullptr; } FibonacciGenerator& operator=(FibonacciGenerator&& other) noexcept { if (this!= &other) { coro = other.coro; other.coro = nullptr; } return *this; } bool move_next() { if (!coro.done()) { coro.resume(); return true; } return false; } int current_value() const { return coro.promise().current; } };
FibonacciGenerator fibonacci() { int a = 0; int b = 1; while (true) { co_yield a; int next = a + b; a = b; b = next; } co_return; }
int main() { FibonacciGenerator gen = fibonacci(); for (int i = 0; i < 10; ++i) { if (gen.move_next()) { std::cout << gen.current_value() << " "; } } std::cout << std::endl; return 0; }
|
在这个协程实现的版本里,fibonacci 函数是一个协程函数,每次执行到 co_yield a 时,就会暂停协程,把当前的斐波那契数
a 返回给调用者,并且保存当前的状态,包括 a 和 b 的值。等下次调用 move_next 恢复协程时,又能接着上次的状态继续计算下一个数,就像你暂停看视频去干了别的事儿,回来还能接着之前的情节往下看。这样,咱们可以按需一个一个地获取斐波那契数,而不是一次性生成所有的数,大大节省了内存空间,提高了程序的运行效率。
5.3协同多任务
在一些需要同时处理多个任务,但又不想使用传统多线程带来的复杂同步和上下文切换开销的场景下,协程可以作为一种轻量级的解决方案。
假设咱们要模拟一个简单的任务调度系统,有两个任务:一个是打印数字,一个是打印字母。传统的多线程实现方式大概是这样:
#include <iostream> #include <thread> #include <mutex>
std::mutex mutex;
void printNumbers() { for (int i = 0; i < 10; ++i) { std::lock_guard<std::mutex> guard(mutex); std::cout << i << " "; } }
void printLetters() { for (char c = 'a'; c < 'k'; ++c) { std::lock_guard<std::mutex> guard(mutex); std::cout << c << " "; } }
int main() { std::thread t1(printNumbers); std::thread t2(printLetters); t1.join(); t2.join(); return 0; }
|
这里为了防止两个线程同时输出导致混乱,使用了互斥锁 mutex 来进行同步,但这样就引入了额外的开销,代码也变得复杂,就好像为了让两个人有序地通过一扇门,专门安排了一个门卫来指挥,虽然达到了目的,但耗费了人力物力。现在用协程来改写,代码就简洁明了多了:
#include <iostream> #include <coroutine> #include <queue>
// 任务结构体,包含协程句柄和任务函数 struct Task { std::coroutine_handle<> coro; void (*func)(); Task(std::coroutine_handle<> h, void (*f)()) : coro(h), func(f) {} };
// 简单的协程调度器 class Scheduler { public: void schedule(Task task) { tasks.push(task); } void run() { while (!tasks.empty()) { Task current = tasks.front(); tasks.pop(); current.coro.resume(); if (!current.coro.done()) { tasks.push(current); } } } private: std::queue<Task> tasks; };
// 打印数字的协程函数 std::coroutine_handle<> printNumbers() { for (int i = 0; i < 10; ++i) { std::cout << i << " "; co_await std::suspend_always{}; } co_return; }
// 打印字母的协程函数 std::coroutine_handle<> printLetters() { for (char c = 'a'; c < 'k'; ++c) { std::cout << c << " "; co_await std::suspend_always{}; } co_return; }
int main() { Scheduler scheduler; scheduler.schedule(Task(printNumbers(), nullptr)); scheduler.schedule(Task(printLetters(), nullptr)); scheduler.run(); return 0; }
|
六、避坑指南:常见问题与解决策略
在使用 C++ 20 协程的过程中,就像走在一条新开辟的道路上,难免会遇到一些 “坑洼”。了解这些常见问题并掌握应对策略,能让咱们的协程编程之旅更加顺畅。
首先,一个容易混淆的点是协程与线程的概念。有些小伙伴误以为协程就是线程的一种简单替代,于是在资源管理和同步机制上照搬多线程的那一套,结果程序要么效率低下,要么出现莫名其妙的错误。其实啊,协程是在单线程内运行的,它通过协作式的挂起和恢复来实现异步操作,共享栈空间,切换成本极低,主要用于处理非阻塞的异步任务,而不是像线程那样并发执行。举个例子,如果把线程比作高速公路上并行行驶的汽车,各自有着独立的车道,那么协程就像是一辆能随时在路边停靠、又能快速启动的智能汽车,它利用单车道的空闲时间,巧妙地完成各种任务,而不会造成交通拥堵(资源竞争)。
再来说说 co_await 的不当使用。有些开发者只要遇到稍微耗时的操作,就不假思索地加上 co_await,想着让协程挂起等待,肯定能提高性能。殊不知,这样可能会导致协程频繁地不必要挂起和恢复,反而增加了开销。比如说,对于一些简单的计算任务,本身在
CPU 上瞬间就能完成,根本不需要等待其他条件,这时候用 co_await 就是画蛇添足了。正确的做法是,只在确实需要等待异步操作完成时,比如网络数据传输、文件读取这类耗时的
I/O 操作,才使用 co_await,让协程暂时 “歇一歇”,把 CPU 时间让给更急需的任务。
还有一个比较隐蔽但危害不小的问题,那就是资源泄漏。协程的生命周期管理要是没做好,协程柄没有及时销毁,就如同家里的水龙头没关紧,水资源(系统资源)会一点点流失。这通常发生在协程提前结束,或者执行路径出现异常,导致本该执行的清理代码被跳过的情况。比如下面这段错误示例:
#include <coroutine> #include <iostream>
struct BadCoroutine { struct promise_type { BadCoroutine get_return_object() { return BadCoroutine{std::coroutine_handle<promise_type>::from_promise(*this)}; } std::suspend_always initial_suspend() { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void unhandled_exception() { std::terminate(); } void return_void() {} }; std::coroutine_handle<promise_type> coro; explicit BadCoroutine(std::coroutine_handle<promise_type> handle) : coro(handle) {} ~BadCoroutine() { // 这里忘记销毁协程柄,导致资源泄漏 } };
BadCoroutine leakyCoroutine() { co_return; }
int main() { BadCoroutine bad = leakyCoroutine(); // 没有正确处理协程资源 return 0; }
|
在这个例子中,BadCoroutine 的析构函数没有销毁协程柄,随着程序的运行,越来越多的协程资源被占用,最终可能导致系统崩溃。解决这个问题的关键在于,要确保每个协程在不再使用时,都能正确关闭其协程柄。一种推荐的做法是使用智能指针来管理协程柄,利用智能指针的自动析构特性,就像给协程资源请了一个
“管家”,确保资源在合适的时候被释放。比如将上面的代码修改为:
#include <coroutine> #include <iostream> #include <memory>
struct GoodCoroutine { struct promise_type { GoodCoroutine get_return_object() { return GoodCoroutine{std::coroutine_handle<promise_type>::from_promise(*this)}; } std::suspend_always initial_suspend() { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void unhandled_exception() { std::terminate(); } void return_void() {} }; std::coroutine_handle<promise_type> coro; explicit GoodCoroutine(std::coroutine_handle<promise_type> handle) : coro(handle) {} ~GoodCoroutine() { if (coro) coro.destroy(); } };
std::unique_ptr<GoodCoroutine> safeCoroutine() { auto coro = std::make_unique<GoodCoroutine>(std::coroutine_handle<GoodCoroutine::promise_type>::from_promise(GoodCoroutine::promise_type{})); co_return; return coro; }
int main() { auto good = safeCoroutine(); // 智能指针会自动管理协程资源,无需手动销毁 return 0; }
|
在这个修正后的代码中,使用 std::unique_ptr 来管理 GoodCoroutine 的实例,当
unique_ptr 生命周期结束时,会自动调用 GoodCoroutine 的析构函数,确保协程柄被正确销毁,避免了资源泄漏的问题。
七、全文总结
通过前面的学习,咱们领略了 C++ 20 协程的强大魅力。它就像是一把瑞士军刀,在异步编程、资源管理、任务调度等诸多领域都展现出了非凡的能力,让咱们能够用简洁、高效的代码实现复杂的功能。协程的优势显而易见,它避免了线程上下文切换的高额开销,减少了资源竞争带来的隐患,还能以同步代码的简洁风格驾驭异步操作,极大地提升了程序的性能与可读性。
然而,“工欲善其事,必先利其器”,要想用好 C++ 20 协程这把利器,深入理解其原理是关键。咱们要时刻牢记协程与线程的区别,精准把握
co_await 等关键字的使用时机,严谨管理协程的生命周期,避免陷入资源泄漏等陷阱。只有这样,才能真正发挥协程的优势,让代码如虎添翼。
展望未来,随着 C++ 语言的不断发展以及社区的持续努力,协程必然会在更多的领域大放异彩。无论是构建高性能的网络服务器、优化大数据处理流程,还是打造响应迅速的桌面应用,协程都将扮演不可或缺的角色。咱们作为开发者,应当紧跟技术潮流,持续深入学习
C++ 协程相关知识,不断探索其在实际项目中的应用,让咱们的程序更加高效、智能,为用户带来更卓越的体验。相信在不久的将来,C++
协程将成为咱们编程工具箱中的必备法宝,助力咱们攻克一个又一个技术难题,创造出更多令人惊叹的软件佳作。
|