协程入门(基础篇)
一、协程的基本概念
什么是协程?
协程可以理解为 “可以暂停执行、后续再恢复的函数”。它像一个能 “存档” 和 “读档” 的执行单元,在执行过程中能主动挂起,将 CPU 使用权让给其他任务,之后在合适的时机恢复到挂起时的状态继续执行,整个过程无需操作系统内核参与调度。
比如我们看电影时,按下暂停键(挂起)去接电话,挂完电话再按播放键(恢复),电影会从暂停的地方继续播放,协程的执行逻辑就类似这样。
协程 vs 线程 vs 进程
要理解协程,先对比我们更熟悉的进程和线程,三者的核心差异如下:
特性 | 进程 | 线程 | 协程 |
---|---|---|---|
调度层级 | 操作系统内核调度 | 操作系统内核调度 | 用户态调度(无内核参与) |
上下文切换成本 | 高(切换地址空间、寄存器等) | 中(共享地址空间,切换寄存器) | 极低(仅保存函数局部状态) |
内存开销 | 大(独立地址空间,通常几 MB 起) | 中(共享地址空间,栈大小通常几 KB 到几 MB) | 极小(栈可动态增长,通常几 KB) |
并发数量上限 | 少(通常几百到几千) | 较多(通常几万) | 极多(通常几十万到几百万) |
控制权 | 内核抢占式(强制切换) | 内核抢占式(强制切换) | 用户主动式(需手动挂起) |
简单来说:进程是 “独立的应用程序”,线程是 “进程内的执行流”,而协程是 “线程内的轻量级执行单元”。
协程、线程、进程的关系总结
- 进程包含线程: 一个进程可以包含多个线程,线程是进程的组成部分,多个线程共享进程的资源。
- 线程包含协程: 一个线程可以包含多个协程,协程是在用户态实现的轻量级线程,可以在单个线程内部实现多任务切换。
- 协程与线程、进程的区别: 协程与线程、进程最大的区别在于调度方式。进程和线程由操作系统调度,协程由用户程序自行调度,切换更加高效。
适用场景对比
- 进程: 适用于需要高隔离度、稳定性较高的场景,比如独立服务、数据库等。
- 线程: 适用于计算密集型任务、多任务并发执行、利用多核 CPU 的场景,比如 Web 服务器、数据处理等。
- 协程: 适用于 IO 密集型任务、异步编程、轻量级多任务切换的场景,比如网络爬虫、异步框架等。
协程的核心特性:挂起与恢复
这是协程区别于普通函数的关键:
- 挂起:协程在执行到特定位置时(如遇到等待操作),主动暂停执行,保存当前的局部变量、程序计数器等状态,释放 CPU 资源。
- 恢复:当等待的条件满足(如 IO 操作完成),协程可以从挂起时的状态继续执行,无需重新初始化局部变量。
普通函数一旦执行,要么执行完成返回,要么异常退出,无法 “中途暂停再继续”,而协程弥补了这一缺陷。
二、为什么需要协程?
异步编程的痛点
在传统异步编程中(如多线程、回调函数),我们常会遇到以下问题:
- 回调地狱:为了实现顺序执行的异步操作,需要嵌套多层回调函数,代码可读性极差。例如:
// 回调地狱示例:先请求用户数据,再请求订单数据,最后打印结果
requestUserData(userId, [](User user) {requestOrderData(user.id, [](Order order) {printOrder(order); // 嵌套层级深,逻辑混乱});
});
- 线程开销过高:高并发场景下,大量线程会导致频繁的内核上下文切换,占用大量 CPU 资源和内存(每个线程栈通常需要几 KB 到几 MB)。
- 逻辑割裂:异步操作的代码分散在不同的回调函数中,业务逻辑被拆分,调试和维护难度大。
协程的优势
1. 更高效的并发模型
协程是用户态调度,无需内核参与上下文切换,切换成本仅为普通函数调用的量级(约几纳秒到几十纳秒),远低于线程切换(约几微秒到几十微秒)。在高并发场景(如百万级 IO 请求)下,协程能以极低的资源开销实现高效并发。
2. 更清晰异步代码逻辑
协程可以用 “同步代码的写法” 实现异步操作,避免回调嵌套。例如,用协程重写上面的异步逻辑:
// 协程写法:同步风格实现异步逻辑
void getAndPrintOrder(int userId) {User user = co_await requestUserData(userId); // 挂起等待用户数据Order order = co_await requestOrderData(user.id); // 挂起等待订单数据printOrder(order); // 逻辑连贯,无嵌套
}
代码逻辑和同步编程一致,可读性大幅提升。
3. 更低的内存开销
协程的栈空间可以动态分配和回收,且初始栈大小通常只有几 KB(如 C++ 协程帧通常几 KB),而线程栈默认大小通常为 1MB(Linux 系统)。相同内存下,协程的并发数量可以达到线程的数百倍甚至上千倍。
三、C++20 协程关键字初识
C++20 首次引入协程支持,通过三个关键字实现协程的核心操作,需注意:这些关键字仅能在 “协程函数” 中使用(协程函数的返回类型需满足特定接口)。
1. co_await
- 挂起协程
作用:等待一个 “可等待对象”(Awaitable)完成,期间挂起协程,释放 CPU。当 “可等待对象” 完成后,协程从挂起处恢复执行。
场景:常用于等待异步操作(如 IO、定时器)。例如:
// 等待1秒后恢复协程
co_await std::chrono::seconds(1);
// 等待网络请求完成,获取结果后恢复
auto data = co_await fetchNetworkData(url);
2. co_yield
- 产生值并挂起
作用:向协程的调用者返回一个值(类似迭代器的next()
),同时挂起协程。下次恢复协程时,从co_yield
的下一行继续执行。
场景:常用于生成序列值(如无限序列、分页数据)。例如:
// 生成1~5的序列
generator<int> generateNumbers() {for (int i = 1; i <= 5; ++i) {co_yield i; // 返回i,挂起;下次恢复从下一行继续}
}
调用时可以通过循环获取每个值:
for (auto num : generateNumbers()) {std::cout << num << " "; // 输出:1 2 3 4 5
}
3. co_return
- 返回协程结果
作用:结束协程,并返回最终结果(类似普通函数的return
)。与普通函数不同,co_return
可以返回复杂类型(如自定义对象),且会触发协程的资源清理逻辑。
示例:
// 协程函数:计算两个数的和并返回
task<int> add(int a, int b) {co_return a + b; // 结束协程,返回结果
}
四、第一个协程示例:Hello Coroutine
C++20 协程需要自定义 “协程返回类型”(如task
、generator
),以满足协程的底层接口(包含promise_type
)。下面我们实现一个简单的 “Hello Coroutine” 示例:
完整代码
#include <coroutine>
#include <iostream>
#include <string>// 1. 定义协程返回类型:用于无返回值的协程
struct HelloCoroutine {// 协程的核心:promise_type(负责协程的状态管理、结果传递)struct promise_type {// 1. 创建协程返回对象(将promise与返回对象关联)HelloCoroutine get_return_object() {return HelloCoroutine{std::coroutine_handle<promise_type>::from_promise(*this)};}// 2. 协程初始挂起点:return std::suspend_never{} 表示协程创建后立即执行std::suspend_never initial_suspend() { return {}; }// 3. 协程最终挂起点:return std::suspend_never{} 表示协程结束后立即销毁std::suspend_never final_suspend() noexcept { return {}; }// 4. 处理协程的返回(co_return时调用)void return_void() {}// 5. 处理协程异常(可选)void unhandled_exception() { std::terminate(); }};// 协程句柄:用于控制协程的执行(恢复、销毁)std::coroutine_handle<promise_type> handle;// 构造函数:接收协程句柄explicit HelloCoroutine(std::coroutine_handle<promise_type> h) : handle(h) {}// 析构函数:销毁协程句柄,释放协程资源~HelloCoroutine() {if (handle) handle.destroy();}// 禁用拷贝(避免协程句柄重复销毁)HelloCoroutine(const HelloCoroutine&) = delete;HelloCoroutine& operator=(const HelloCoroutine&) = delete;// 移动构造(允许协程对象转移)HelloCoroutine(HelloCoroutine&& other) noexcept : handle(other.handle) {other.handle = nullptr;}HelloCoroutine& operator=(HelloCoroutine&& other) noexcept {if (this != &other) {if (handle) handle.destroy();handle = other.handle;other.handle = nullptr;}return *this;}
};// 2. 定义协程函数:打印"Hello Coroutine!"
HelloCoroutine printHello() {std::cout << "Hello Coroutine!" << std::endl;co_return; // 结束协程(无返回值)
}// 3. 主函数:调用协程
int main() {// 调用协程函数:创建协程并执行(因initial_suspend是suspend_never,立即执行)auto coro = printHello();// 协程已执行完成,main函数结束return 0;
}
代码说明
- 协程返回类型
HelloCoroutine
:必须包含promise_type
结构体,这是 C++20 协程的强制要求,promise_type
负责协程的状态管理(如创建返回对象、处理挂起 / 恢复、清理资源)。 - 协程函数
printHello()
:返回类型为HelloCoroutine
,内部用co_return
结束协程,执行时会打印 “Hello Coroutine!”。 - 主函数调用:
auto coro = printHello();
会创建协程并立即执行(因为initial_suspend
返回std::suspend_never
),最终打印结果。
运行结果
Hello Coroutine!
五、协程的生命周期
协程从创建到销毁,经历以下 6 个关键阶段,我们结合上面的 “Hello Coroutine” 示例理解:
1. 协程创建
- 触发时机:调用协程函数(如
printHello()
)时,操作系统不会创建新线程,而是在当前线程中分配 “协程帧”(存储局部变量、程序计数器等状态)。 - 示例关联:
printHello()
被调用时,首先执行promise_type::get_return_object()
,创建HelloCoroutine
对象,同时分配协程帧。
2. 协程帧分配
- 协程帧:类似函数栈帧,但由用户态管理(而非内核),存储内容包括:协程的局部变量(如示例中无显式局部变量)、
promise
对象、程序计数器(记录当前执行位置)。 - 内存来源:通常从堆上分配(可自定义分配器优化),销毁时需手动释放(通过
coroutine_handle::destroy()
)。
3. 初始挂起点
- 触发逻辑:协程创建后,执行
promise_type::initial_suspend()
,根据返回值决定是否挂起:std::suspend_never
:不挂起,立即执行协程函数体(如示例)。std::suspend_always
:挂起,需通过coroutine_handle::resume()
手动恢复。
- 示例关联:示例返回
std::suspend_never
,所以创建后立即执行std::cout << "Hello Coroutine!" << std::endl;
。
4. 执行流程与挂起 / 恢复
- 正常执行:若不遇到
co_await
/co_yield
,协程会一直执行到co_return
或函数结束。 - 挂起触发:当执行到
co_await
(等待异步操作)或co_yield
(返回值)时,协程保存当前状态(程序计数器、局部变量),释放 CPU,进入挂起状态。 - 恢复触发:当挂起条件满足(如异步操作完成、调用者请求下一个值),通过
coroutine_handle::resume()
恢复协程,从挂起处继续执行。 - 示例关联:示例无
co_await
/co_yield
,所以直接执行到co_return
,无挂起过程。
5. 状态保存与恢复
- 状态保存:挂起时,协程将程序计数器(下一条要执行的指令)、局部变量、寄存器状态存入协程帧。
- 状态恢复:恢复时,从协程帧中加载保存的状态,恢复到挂起前的执行上下文,仿佛从未挂起过。
6. 最终挂起点与协程销毁
- 最终挂起点:协程执行到
co_return
或异常退出时,会执行promise_type::final_suspend()
,根据返回值决定是否挂起:std::suspend_never
:不挂起,立即销毁协程帧(如示例)。std::suspend_always
:挂起,需手动调用destroy()
销毁(用于实现 “协程完成通知” 等场景)。
- 资源清理:销毁时,首先执行协程帧中局部变量的析构函数,然后释放协程帧内存,最后销毁
promise
对象。 - 示例关联:示例返回
std::suspend_never
,执行co_return
后,调用final_suspend()
,随后HelloCoroutine
析构函数中调用handle.destroy()
,释放协程帧。
六、总结
协程的核心价值
- 轻量级并发:用户态调度,极低的切换成本和内存开销,适合高并发场景(如百万级 IO 请求)。
- 简化异步代码:用同步代码风格实现异步逻辑,避免回调地狱,提升代码可读性和可维护性。
- 灵活的状态管理:支持中途挂起与恢复,适合实现生成器、状态机、异步工作流等场景。
适用场景分析
- 高并发 IO 密集型任务:如网络服务器(处理大量 TCP 连接)、数据库查询、文件读写等(IO 等待时挂起,释放 CPU 处理其他任务)。
- 生成器场景:如无限序列生成(斐波那契数列)、分页数据加载(每次
co_yield
返回一页数据)。 - 状态机场景:如游戏角色状态(待机→移动→攻击,每个状态用协程实现,挂起切换状态)。
学习资源:
(1)管理教程
如果您对管理内容感兴趣,想要了解管理领域的精髓,掌握实战中的高效技巧与策略,不妨访问这个的页面:
技术管理教程
在这里,您将定期收获我们精心准备的深度技术管理文章与独家实战教程,助力您在管理道路上不断前行。
(2)软件工程教程
如果您对软件工程的基本原理以及它们如何支持敏捷实践感兴趣,不妨访问这个的页面:
软件工程教程
这里不仅涵盖了理论知识,如需求分析、设计模式、代码重构等,还包括了实际案例分析,帮助您更好地理解软件工程原则在现实世界中的运用。通过学习这些内容,您不仅可以提升个人技能,还能为团队带来更加高效的工作流程和质量保障。