C++20引入了协程,这被认为是自C++11以来最复杂的语言特性,甚至比模板元编程和移动语义更难掌握。协程的提案和标准化过程历时多年,经历了多次重大修改,最终版本充满了抽象概念和新的关键字。理解协程为什么被设计成这个样子,需要回顾它的目标、约束、以及与其他语言协程实现的比较。
协程的基本思想是函数可以挂起和恢复。普通的函数调用后,控制权完全交给被调函数,直到它返回;而协程可以在执行到某个点时主动让出控制权(挂起),然后在稍后的某个时间点从挂起点恢复执行。这种能力对于异步编程、生成器、流处理、以及状态机等模式至关重要。在C++20之前,实现异步代码的传统方式是回调地狱:发起一个异步操作,传入一个回调函数,操作完成后调用回调。这种模式在简单场景下可行,但随着嵌套层数增加,代码变得难以阅读和维护。Promise-Future模式在一定程度上缓解了这个问题,但仍然需要显式管理状态。协程承诺将异步代码写成同步风格,让编译器自动处理挂起和恢复的底层机制。
参考:https://oqmyh.cn/category/mingan-huli.html
C++协程的设计面临三个核心约束:
零开销原则:协程的挂起和恢复不应该比等价的回调代码慢。这意味着协程的状态不能在堆上随意分配,必须尽可能紧凑,并且上下文切换的成本必须极低。
与现有C++代码的兼容性:协程必须能够调用普通函数,普通函数也必须能够调用协程。协程不能要求特殊的运行时或垃圾回收。
灵活性:协程应该能够用于多种场景:生成器(yield值)、异步任务(返回future)、惰性求值、以及自定义的状态机。
这些约束共同塑造了C++协程的独特设计。与Python或JavaScript中协程是语言核心的一部分不同,C++协程是库设施——语言只提供了底层机制,而具体的行为由库实现者通过一组约定来定义。
参考:https://oqmyh.cn/category/kang-shuailao.html
C++协程的核心概念有三个:协程句柄、承诺对象和挂起点。
协程句柄是一个非拥有型的句柄,用于恢复协程或查询协程的状态。它类似于指针,但设计为不拥有协程帧的内存(协程帧包含协程的状态,如局部变量和挂起点)。协程句柄可以在协程外部使用,允许外部代码恢复协程或销毁协程。
承诺对象是协程的控制面。它定义了协程的行为:当协程挂起时应该发生什么?当协程返回一个值时应该发生什么?当协程抛出异常时应该发生什么?协程的返回类型决定了承诺对象的类型——编译器通过std::coroutine_traits来查找承诺类型。这种设计使得同一个协程语言机制可以用于多种场景:对于生成器,承诺对象实现yield_value方法;对于异步任务,承诺对象实现await_transform和unhandled_exception。
挂起点是协程中co_await、co_yield和co_return出现的位置。co_await是最通用的挂起机制,它等待一个可等待对象。可等待对象必须实现await_ready(是否已经就绪)、await_suspend(挂起时做什么)和await_resume(恢复时返回什么)这三个方法。这种设计允许库作者自定义挂起和恢复的行为——例如,挂起时可以将协程句柄添加到事件循环中,等待IO完成后再恢复。
co_yield是co_await的特化,用于生成器场景:它将一个值产出给调用者,然后挂起。co_return表示协程完成,类似于普通函数的return。
参考:https://oqmyh.cn/category/hufu-chengfen.html
C++协程设计中最有争议的部分是对称转移(symmetric transfer)。考虑一个协程A等待协程B的情况:当B完成时,控制权应该转移到A。在对称转移的支持下,B可以直接恢复A,而不需要通过外部调度器。C++20最初没有强制要求对称转移,这导致某些实现需要额外的内存分配和调度器调用。C++23引入了std::coroutine_handle::resume的对称版本,但编译器支持尚不普遍。
协程的内存分配是另一个性能关键点。每个协程实例需要一个帧来存储局部变量和挂起状态。编译器会计算帧的大小,然后调用operator new分配内存。对于频繁创建和销毁的协程,内存分配的成本可能很高。优化策略包括:使用自定义分配器、对帧进行池化、以及利用返回值优化(如果协程的状态可以嵌入到调用者的帧中)。C++23引入了对协程帧使用自定义分配器的标准支持。
协程与RAII的交互值得特别注意。协程的局部变量在挂起期间仍然存活,这意味着如果协程持有一个RAII资源(如锁或文件句柄),该资源会在整个挂起期间保持占用,可能导致死锁或资源耗尽。开发者在设计协程时需要注意这一点,避免在挂起点持有锁或其他独占资源。
协程的调试比普通函数困难得多。在挂起点处,协程的栈帧不是完整的——部分状态存储在协程帧中,部分在调用者的栈上。这使得传统的栈回溯工具无法显示协程的完整调用链。一些调试器(如最新版本的GDB和LLDB)已经添加了对协程的部分支持,但体验仍然远不如普通函数。
协程的标准化只是第一步。真正的变革将来自基于协程构建的库和运行时。例如:
std::generator(C++23)提供了一个标准的生成器协程类型,用于惰性序列。
异步IO库(如boost::asio、cppcoro)提供了与事件循环集成的可等待对象。
并发任务框架允许协程在不同线程上执行,并在完成时恢复。
与Rust的async/await相比,C++的协程设计更加底层和灵活。Rust的异步机制完全依赖于运行时(如Tokio),而C++协程可以在任何环境下工作,包括嵌入式系统和无分配的环境。这种灵活性是以复杂性为代价的——Rust开发者通常只需要写async fn和.await,而C++开发者需要理解承诺对象、协程句柄和可等待协议的细节。
对于大多数C++开发者来说,直接使用协程的原始机制是不明智的。更好的做法是使用已经封装好的协程库,只在必要的时候深入到底层。随着C++23和C++26对协程的完善,以及社区积累的最佳实践,协程的编程模型将逐渐变得更加友好和直观。
参考:https://oqmyh.cn