协程
协程(Coroutine)介绍及其分类
1. 什么是协程?
协程也可以叫做”轻量级的线程,用户线程“。
简单的理解协程,协程一种执行过程中能够yield(暂停)和resume(恢复)的子程序,也可以说是协程就是函数和函数运行状态的组合,怎么理解?正常的函数在执行中是直接就执行完成了中间不会有多余的步骤,更不会说我这个函数执行到一半就去执行其他函数了,但是协程不一样,我们使用协程首先要绑定一个入口函数,并且可以在函数的任意位置暂停去执行其他其他的函数,再回来执行暂停的函数,所以说协程是函数和函数运行状态的组合(协程需要绑定入口函数,协程记录了函数的运行状态)。
那么协程是如何做到让函数暂停和让函数的恢复呢?
这个是因为协程的记录会有协程上下文,协程执行yield的时候,协程上下文记录了协程暂停的位置。当resume的时候就是从暂停的地方恢复。协程上下文包含了函数在当前状态的全部cpu寄存器的值,这些寄存器记录函数的栈帧、代码执行的位置等信息,如果把这些值交给cpu去执行,那么就会实现从函数暂停的地方去恢复执行。
需要注意单线程的情况下,协程的resume和yield一定是同步的,一个协程进行yield暂停,必然对应另一个协程的resume恢复,因为线程不能没用执行主体。并且协程的yield和resume是应用程序控制的,这点和线程不一样。
线程的运行和调度是操作系统来完成的,协程的运行和调用是由应用程序来完成的,所以协程也叫做“用户态线程”。
核心特点:
- 协作式调度:协程在特定点(yield 或 await)主动挂起,交出控制权。
- 上下文切换:保存当前执行状态(如栈帧、寄存器),恢复时继续执行。
- 轻量级:比线程更低的内存和切换开销。
应用场景:
- 异步 I/O(如网络请求、文件操作)。
- 事件驱动系统(如游戏循环、Web 服务器)。
- 并发任务处理(如协程池替代线程池)。
2. 协程的分类
协程可以根据调度方式、栈管理方式等分为以下几类:
2.1 对称协程 vs 非对称协程
对称协程(Symmetric Coroutine)
- 定义:协程之间可以直接切换,调用者和被调用者的地位平等,控制权可任意传递给另一个协程。
- 机制:通过类似
yield to
的操作,协程直接指定下一个执行的协程。 - 示例:Lua 的协程(
coroutine.resume
和coroutine.yield
)。 - 优点:
- 灵活性高:协程可以自由切换,适合复杂控制流。
- 通用性:可实现多种并发模型。
- 缺点:
- 调度复杂:需要程序员显式管理协程切换,易出错。
- 调试困难:控制流分散,难以跟踪状态。
非对称协程(Asymmetric Coroutine)
- 定义:协程分为调用者和被调用者,控制权只能在调用者和被调用者之间传递(通常通过
yield
返回调用者,resume
恢复协程)。 - 机制:协程只能挂起回调用者,无法直接切换到其他协程。
- 示例:C++20 的
co_await
和co_yield
,Python 的async/await
。 - 优点:
- 简单易用:控制流清晰,调用者管理协程生命周期。
- 易于调试:挂起和恢复的点明确,适合异步编程。
- 缺点:
- 灵活性较低:无法直接在协程间切换,限制了某些并发模型。
- 依赖调用者:协程的执行依赖调用者的调度。
对称 vs 非对称对比
- 对称协程适合需要复杂控制流的场景,如状态机或协作式多任务。
- 非对称协程更适合异步编程,结构清晰,常用于现代语言(如 C++、Python)。
2.2 有栈协程 vs 无栈协程
有栈协程(Stackful Coroutine)
- 定义:每个协程拥有独立的调用栈,保存完整的上下文(函数调用链、局部变量等)。
- 机制:协程暂停时,栈帧完整保存,恢复时直接继续执行。
- 示例:Boost.Coroutine(C++)、Lua 协程。
- 优点:
- 灵活性高:可在任意函数调用深度挂起,支持复杂逻辑。
- 语言无关:无需语言级支持,可在库中实现。
- 缺点:
- 内存开销大:每个协程需要独立的栈(通常 KB 级别)。
- 实现复杂:需要管理栈分配和切换,可能涉及汇编或系统调用。
- 性能开销:上下文切换成本高于无栈协程。
无栈协程(Stackless Coroutine)
- 定义:协程不维护完整调用栈,仅保存挂起点的最小上下文(如局部变量、状态机)。
- 机制:编译器将协程转换为状态机,挂起时保存状态,恢复时跳转到对应状态。
- 示例:C++20 的
co_await
/co_yield
、Python 的async/await
。 - 优点:
- 内存开销低:仅存储必要状态,适合高并发(如百万级协程)。
- 高效切换:上下文切换成本低,接近函数调用。
- 编译器优化:编译器生成状态机,代码高效。
- 缺点:
- 灵活性有限:只能在协程函数内挂起,无法在任意函数调用深度暂停。
- 语言依赖:需要语言支持(如 C++20 的
co_await
关键字)。 - 复杂实现:用户代码简单,但编译器生成的状态机可能难以调试。
有栈 vs 无栈对比
- 有栈协程适合需要深度调用栈的场景(如复杂递归),但内存占用高。
- 无栈协程适合高并发、异步 I/O 场景,内存效率高,但受限于语言支持。
2.3 独立栈 vs 共享栈
独立栈(Separate Stack)
- 定义:每个协程拥有独立的栈空间,互不干扰。
- 机制:每个协程分配固定或动态大小的栈,上下文切换时保存/恢复栈。
- 示例:Boost.Coroutine、ucontext(Linux)。
- 优点:
- 隔离性强:协程间栈独立,互不影响,适合复杂任务。
- 支持深递归:栈大小可动态调整,适应复杂调用链。
- 缺点:
- 内存开销大:每个协程需要 KB 级别的栈空间。
- 切换开销:栈复制或切换成本较高。
- 资源管理复杂:需要显式分配和回收栈。
共享栈(Shared Stack)
- 定义:多个协程共享一个栈空间,挂起时保存上下文,恢复时重用栈。
- 机制:通过状态机或最小上下文保存,栈空间在协程间复用。
- 示例:C++20 协程(通过编译器优化实现共享栈效果)。
- 优点:
- 内存效率高:无需为每个协程分配独立栈,适合高并发。
- 切换快速:上下文切换仅涉及状态恢复,无需复制栈。
- 缺点:
- 实现复杂:需要编译器或运行时支持栈管理。
- 限制较多:不支持深递归或复杂调用链,挂起点受限。
- 调试困难:共享栈可能导致状态管理复杂化。
独立栈 vs 共享栈对比
- 独立栈适合需要隔离和复杂控制流的场景,但内存和性能开销大。
- 共享栈适合高并发、轻量级任务,内存效率高,但灵活性较低。
3. C++ 中的协程支持
C++20 引入了协程支持(co_await
、co_yield
、co_return
),主要实现非对称、无栈、共享栈协程。以下是一个简单的 C++20 协程示例:
1 |
|
输出:
1 | Start coroutine |
说明:
- C++20 协程是非对称的(只能挂起回调用者)。
- 无栈实现:编译器将协程转换为状态机,上下文存储在堆上。
- 共享栈:协程不维护独立栈,状态机共享调用者的栈空间。
4. 总结与对比
分类 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
对称协程 | 灵活,支持任意协程切换 | 调度复杂,调试困难 | 复杂控制流、状态机 |
非对称协程 | 简单易用,控制流清晰 | 灵活性低,依赖调用者 | 异步 I/O、事件驱动 |
有栈协程 | 支持深递归,语言无关 | 内存开销大,切换慢 | 复杂任务、深调用链 |
无栈协程 | 内存效率高,切换快 | 挂起点受限,语言依赖 | 高并发、异步任务 |
独立栈 | 隔离性强,支持复杂逻辑 | 内存和切换开销大 | 复杂任务、深递归 |
共享栈 | 内存效率高,切换快 | 实现复杂,限制多 | 高并发、轻量任务 |
5. 与线程池的联系
结合之前的线程池讨论,协程和线程池可以互补:
- 线程池:适合 CPU 密集型任务,线程数量受限于核心数。
- 协程:适合 I/O 密集型任务,可支持百万级并发。
- 结合方式:在线程池中运行协程,线程处理计算密集任务,协程处理异步 I/O(如 Boost.Asio 结合协程)。
如需 C++ 协程的更详细实现(如与线程池结合、异步 I/O 示例)或特定分类的深入分析,请提供进一步要求!