问题

C++20 即将到来的 coroutine 能否与 Golang 的 goroutine 媲美?

回答
C++20 的协程(coroutines)和 Go 的 goroutines 都是用于实现并发和异步编程的强大工具,但它们的设计理念、工作方式以及适用的场景有显著的区别。简单地说,C++20 协程虽然强大且灵活,但与 Go 的 goroutines 在“易用性”和“轻量级”方面存在较大差距,不能完全说“媲美”。

下面我将从多个维度详细对比它们:

1. 设计理念和目标:

Go Goroutines:
目标: 简化并发编程,让开发者能够轻松地编写大量并发执行的任务,而无需担心底层的线程管理和同步问题。
核心: “不要通过通信来共享内存,而要通过共享内存来通信。” 强调使用 channels 进行通信,减少共享内存带来的锁竞争。
模型: 用户态线程(M:N 调度),由 Go 运行时管理。非常轻量级,一个程序可以轻松启动成千上万个 goroutines。
阻塞行为: 当 goroutine 调用阻塞操作(如 I/O、channel 阻塞)时,Go 运行时会将该 goroutine 挂起,并允许底层的操作系统线程去执行其他的 goroutine。这使得 goroutines 在执行阻塞操作时不会浪费 CPU 资源。

C++20 Coroutines:
目标: 提供一种更灵活、更底层的方式来编写可恢复的函数,以实现异步编程、生成器、状态机等。它更侧重于控制流的异步化和状态的封装,而不是直接提供一个轻量级的并发执行模型。
核心: 将函数执行流程分解为可暂停和可恢复的“段”,通过返回值(coroutine traits)来定义协程的行为(如如何挂起、如何恢复、如何销毁)。
模型: 不直接提供调度器。C++协程本身是语言特性,需要由用户(或库)来提供调度器(scheduler)来决定何时恢复协程,以及在哪个线程上执行。这意味着 C++协程更像是“语法糖”或一种抽象机制,可以构建在各种并发模型之上。
阻塞行为: 协程本身不直接“阻塞”线程。当协程执行到一个 `co_await` 点时,它会暂停当前函数的执行,并将控制权交还给调用者(或调度器)。调用者(或调度器)可以选择何时以及如何恢复协程。

2. 调度机制:

Go Goroutines:
内置调度器: Go 运行时拥有一个高效的、多核感知的调度器,负责将 goroutines 分配到操作系统线程上执行。它管理着 M(Machine,操作系统线程)、P(Processor,逻辑处理器,代表 Goroutine 的运行上下文)和 G(Goroutine)之间的关系。
抢占式调度(部分): Go 的调度器有一定的抢占能力,例如函数调用会检查是否需要重新调度。
特点: 非常透明和易用,开发者几乎不需要关心调度细节。

C++20 Coroutines:
无内置调度器: 这是最根本的区别之一。C++协程本身不包含任何调度逻辑。你需要自己实现或者使用第三方库(如 Boost.Asio, cppcoro, libunifex)来提供调度器。
用户控制: 调度完全由协程的返回类型和外部调度器控制。例如,你可以使用 `co_await` 来等待一个异步操作的结果,这个 `co_await` 的实现会决定是立即返回还是安排一个回调在某个时间点(或者某个线程)上执行。
灵活性: 这种灵活性允许 C++协程被集成到各种并发模型中,包括基于事件循环、线程池、或者其他自定义的调度策略。

3. 轻量级和资源消耗:

Go Goroutines:
非常轻量级: 协程栈默认很小(例如 2KB),并且可以根据需要动态增长。创建和销毁 goroutines 的开销非常低。
并发能力: 可以轻松创建数十万甚至数百万个 goroutines。

C++20 Coroutines:
比普通函数更重,但比线程轻: 协程的“状态”需要被保存在一个“承诺对象”(promise object)或类似的结构中,这比普通函数的栈帧要大。但是,它们比操作系统线程(OS threads)要轻量得多,因为它们不需要独立的内核栈和上下文切换的开销。
资源消耗取决于实现: 协程的实际资源消耗很大程度上取决于你如何实现它们的调度和状态管理。如果你使用一个高效的事件循环和线程池来调度协程,那么它们可以非常高效。
并发能力: 理论上可以支持大量协程,但具体的限制取决于调度器的实现和可用的系统资源。

4. 阻塞和同步:

Go Goroutines:
内置原语: Go 提供了 `chan`(channels)作为首选的通信和同步机制,以及 `sync` 包中的锁、原子操作等。
阻塞行为: Goroutine 阻塞(如 `ch < x` 或 ` 易用性: Channels 的设计使得并发通信变得直观且易于管理。

C++20 Coroutines:
无内置通信/同步原语: C++协程本身不提供内置的通道或同步原语。你需要依赖 C++ 标准库(如 `std::mutex`, `std::condition_variable`, `std::atomic`)或第三方库(如 Boost.Asio 的 `post`, `defer`, `awaitable` 等)来实现这些功能。
`co_await` 的角色: `co_await` 的作用是暂停协程并将其挂起,然后将控制权交给表达式的结果(通常是一个可等待对象)。这个可等待对象决定了协程何时以及如何被恢复。
异步操作的集成: C++协程非常适合与现有的异步 I/O 库(如 Boost.Asio, libuv)集成,将回调式(callbackbased)的异步 API 转换为更线性的、易于阅读的协程代码。

5. 语法和易用性:

Go Goroutines:
简单关键字: `go` 关键字非常简洁,启动一个 goroutine 只需要在函数调用前加上 `go`。
通道通信: Channel 的发送 `ch < x` 和接收 ` 易学性高: Go 在设计之初就非常重视易用性,goroutines 和 channels 的组合使得编写并发程序相对容易。

C++20 Coroutines:
复杂语法: 需要了解 `co_await`, `co_yield`, `co_return` 等关键字,以及协程的返回类型(如 `std::coroutine_handle`, `std::future`, `std::expected`, 或自定义的 awaitable 类型)。
返回类型设计: 协程的返回类型(也称为“协程特征” coroutine traits)是协程的核心,它定义了协程如何被创建、暂停、恢复和销毁。设计一个好的返回类型需要对 C++的模板元编程和异步模型有深入的理解。
需要大量辅助库: 为了真正利用 C++协程实现 Go 语言级别并发的便捷性,通常需要依赖专门的协程库(如 cppcoro, Boost.Asio),这些库提供了协程的调度器、通道、事件循环等基础组件。
学习曲线陡峭: 相比 Go 的 goroutines,C++协程的学习曲线要陡峭得多,需要更多的底层知识和库支持。

6. 适用场景和优势劣势:

Go Goroutines:
优势:
极高的易用性: 适用于快速开发高性能并发应用,如 Web 服务器、网络服务、微服务等。
优秀的生态系统: Go 提供了丰富的标准库和第三方库来支持并发编程。
内置调度器: 开发者无需操心调度细节。
轻量级: 可以轻松处理大量并发任务。
劣势:
缺乏底层控制: 对于需要对线程管理、调度策略进行精细控制的场景,Go 的抽象可能过于限制。
内存占用: 尽管 goroutines 轻量,但 Go 运行时本身也有一定的内存开销。

C++20 Coroutines:
优势:
极高的灵活性和控制力: 可以集成到任何已有的 C++项目中,与各种异步框架配合。
与现有 C++生态融合: 可以无缝使用 C++的标准库、第三方库,并且可以构建在已有的线程池、事件循环之上。
精细的资源管理: 允许开发者精细地控制协程的生命周期、内存分配和调度策略。
更低的运行时开销(潜在): 如果实现得当,可以比 Go 的运行时模型在某些方面拥有更低的开销。
状态机和生成器: 是实现复杂状态机、惰性计算(生成器)等场景的强大工具。
劣势:
高复杂性: 需要深入理解语言特性和异步编程模型,学习曲线陡峭。
需要大量第三方库支持: 要达到 Go goroutines 的易用性,需要依赖完善的协程库。
容易出错: 由于底层控制能力强,也更容易因为错误的使用方式导致潜在的问题(如内存泄漏、死锁)。
调度器是外部的: 协程的运行离不开外部调度器,如何构建高效的调度器是关键。

总结来说:

Go 的 goroutines 是一个“开箱即用”的高级并发抽象,以牺牲部分底层控制力为代价,换取了极高的易用性和效率。它是一个完整的并发解决方案。
C++20 的协程是语言层面的一个“能力”或“工具集”,它提供了一种更底层的、可组合的方式来编写可恢复的函数,但它不包含内置的调度器和并发模型。它更像是实现并发和异步编程的“构建块”,需要开发者自己或借助库来构建完整的并发系统。

因此,C++20 协程并不能直接“媲美”Go 的 goroutines,因为它们的定位和提供的抽象层级不同。如果你追求的是Go那样开箱即用的、大规模并发的简洁性,那么C++20协程本身并不能直接提供,你需要在此基础上构建。但如果你需要极高的灵活性、对底层有更多控制,或者想将异步能力集成到现有复杂的C++项目中,那么C++20协程将是一个非常强大的工具。

可以这样比喻:
Go goroutines 就像是使用一套非常方便的预制滑轨和椅子,让你能轻松快速地建造一个游乐场。
C++20 coroutines 就像是提供了一系列高质量的杆子、连接件和绳子,你可以用它们按照自己的想法建造任何游乐设施,包括滑梯,但你需要自己设计和组装。

网友意见

user avatar

最近 go 的 GC 模块的开发者提出了一个提案,叫做“非协作式的 goroutine 抢占”:golang/proposal

这个提案要实现的,如字面意思,就是强行让一个 goroutine 让出 CPU,不管该 goroutine 在做什么,不需要 goroutine 的“协作”,就能抢占该 goroutine 的 CPU 时间。go 现在的调度器,如果想从外部让一个 goroutine 让出 CPU 时间,只能在函数的入口处做一些手脚,让该 goroutine 在调用函数之前,发现它应该让出 CPU,这就是协作式的,因为需要 goroutine 执行到那一个路径,外部只能等待它执行到那里,或者其他一些触发到调度的代码路径。

这个提案的方案是,直接用信号让执行 goroutine 的系统线程切换到信号处理器,从而实现 goroutine 的打断。以前为什么不能这样做呢?因为涉及到 GC。现在需要打断 goroutine 的原因,主要是 GC 过程需要暂停所有 goroutine,而 GC 过程中对 goroutine 的状态也有要求,就是 goroutine 必须处于“安全点”,这样 GC 模块才能准确地知道哪些对象可以回收哪些不行。在 goroutine 不处于安全点时就打断,现在的 GC 模块是没法正确工作的。提案里的方案是要实现“处处都是安全点”,这样就能随时打断 goroutine 而不影响 GC 的工作(当然也有一些时机是没法成为安全点的,提案里也有解法)。

所以说,goroutine 的很多设计,其实是和 go 语言本身、和 go 运行时本身密切关联的。为什么要用 goroutine 而不是直接用系统线程?因为 GC 需要暂停所有 goroutine 的运行(时间很短,微秒以下),如果将这个交给操作系统来做,可能暂停需要等待的时间,就不太可控了,毕竟操作系统调度的时间片粒度是相对更粗的。另外还有开销的问题,使得直接使用系统线程,不如使用 goroutine。

另外也可以得出一个结论,goroutine 并不是一个简单的“协程”方案。goroutine 可以主动让出 CPU,也可以从外部强行让 goroutine 让出 CPU。这就不能叫做“协作式调度”了,自然也就不是“协程”。

所以拿 C++ 实现的各类协程,来和 goroutine 比较,是不适合的,特性都不一样。

类似的话题

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有