协程(Coroutine)的概念由来已久,一句话来解释就是“可以被挂起(暂停执行)并恢复的函数”。

然而,直到 C++ 20 的发布,才正式引入了协程。对于开发者来说,这地毫不亚于 C++ 11 中正式引入线程(Thread)一样。但 C++ 20,仅仅是给了两个关键字(即 co_returnco_await)和一个运算符(即 co_await),还有协程相关的底层库(比如 std::coroutine_handle)。这显然没有协程在 Python 等其他编程语言中“开箱即用”。

不过首先要清楚,我们为什么需要使用协程。

目前主流语言基本上都选择了多线程作为并发设施,与线程相关的概念就是抢占式多任务,而与协程相关的是协作式多任务。

其实不管是进程还是线程,每次阻塞和切换都需要陷入系统调用,先让 CPU 运行操作系统的调度程序,然后再由调度程序决定该跑哪一个进程或线程。

而且由于抢占式调度执行顺序无法确定的特点,使用线程时需要非常小心地处理同步问题,而协程完全不存在这个问题(事件驱动和异步程序也有同样的优点)。

因为协程是用户自己来编写调度逻辑的,对于我们的 CPU 来说,协程其实是单线程,所以 CPU 不用去考虑怎么调度和切换上下文,这就省去了 CPU 的切换开销, 所以协程的性能开销在一定程度上又好于多线程。

所以综合看来, 协程的有点有以下几点:

但因此,协程也存在以下缺点:

但问题来了,C++ 中的协程,正如上文所述,非常简陋,几乎所有东西都需要程序员自己手搓,而且可能会出现各种问题。另外由于协程的特性,所有的协程函数几乎无法执行 RVO(Return Value Optimization,返回值优化)和 NRVO(Named Return Value Optimization,命名返回值优化),很多情况下需要手动调用 std::move 来降低性能开销。况且移动构造的开销也不一定低。

那么,C++ 中的协程究竟有什么应用场景呢?

在此之前,先要了解 C++ 23 加入的类模板 std::generator。顾名思义,其作用就是充当“生成器”。如果不是很理解,可以参考 cppreference 上的文档说明

如果现在需要输出 1 ~ 100 的所有数字,且输出函数内部实现使用范围 for 循环来输出,可以怎么写?

如果不使用协程, 可能会这么写:

#include <deque>
#include <print>
auto f()
{
    std::deque< int > lst;
    for ( auto i{ 1 }; i <= 100; ++i ) {
        lst.emplace_back( i );
    }
    return lst;
}
auto output( auto &&_obj )
{
    for ( const auto &i : _obj ) {
        std::print( "{} ", i );
    }
}
auto main() -> int
{
    output( f() );
    return 0;
}

如果使用协程 (这里使用 std::generator),可以这么写:

#include <generator>
#include <print>
auto f() -> std::generator< int >
{
    for ( auto i{ 1 }; i <= 100; ++i ) {
        co_yield i;
    }
}
auto output( auto &&_obj )
{
    for ( const auto &i : _obj ) {
        std::print( "{} ", i );
    }
}
auto main() -> int
{
    output( f() );
    return 0;
}

两种写法的运行结果都是一样的。但是,不妨想一下,如果需要生成 1 ~ 1e8 之间的数字,或者数字的类型是 long long,甚至改成生成字符串……这样,内存迟早会消耗完,而在此之前,根本无法输出结果。

现在再次审视下协程的定义,"可以被挂起(暂停执行)并恢复的函数",这就是协程最大的优点。

但再次重申,C++ 中的协程非常简陋,一切都需要程序员自己写。这就是 C++ 的协程的最大缺点。~当然我们不可能连夜说服标准委员会去添加一个“开箱即用”的协程库,~目前较为成熟的解决方案就是使用第三方库。

综合来看,C++ 标准中的协程支持还算不上完善,况且能采用上 C++ 20 及以上标准进行开发的项目本就不多,不过支持了总是好事。协程只是 C++ 并发编程中的沧海一粟,仅此而已。

转载请注明出处