C++协程入门

C++协程入门
MingC++20协程入门
什么是协程?
简单来说:协程是一个可以暂停和恢复的函数。
如果从传统函数的角度来看,暂停意味着线程会停止执行。那么,协程与普通函数的区别在哪里呢?关键在于:
- 普通函数是线程紧密相关的,函数的状态依赖于线程栈;
- 协程是线程无关的,它的状态独立于任何线程,可以在不同的线程间切换。
为了更好地理解这一点,我们可以先回顾一下函数调用的机制。每当调用一个普通函数时,当前线程的栈会保存这个函数的状态(例如函数参数、局部变量等)。这一过程通过栈顶指针的移动来完成。例如,函数 Foo() 调用 Bar() 的过程如下:
在这个过程中:
- 从
地址3到地址2的内存空间分配给Foo()用来保存状态,栈顶指针指向地址2; - 当调用
Bar()时,栈顶指针移到地址1,此时从地址2到地址1的内存空间分配给Bar()用于保存状态; - 当
Bar()执行完毕,栈顶指针回到地址2,Bar()的状态被销毁,内存空间被回收。
可以看出,普通函数的状态完全依赖于线程栈,一旦线程结束或切换,函数的状态就不再存在,因此我们说普通函数是线程相关的。
而协程不一样,协程的状态是保存在堆内存中的。假设 Bar() 是一个协程,它的调用过程如下:
在这种情况下:
Bar()的状态会在堆上分配内存,这个状态独立于线程栈;- 传递给
Bar()的参数会被复制到这个堆内存中,局部变量也会直接在堆内存中创建; - 当调用
Bar()时,栈顶指针依然向下移动,给Bar()分配栈空间,并在栈上存储一个指向堆状态的引用。这样,Bar()就像普通函数一样在栈上执行,线程也可以访问到位于堆上的状态,它的状态可以在堆内存中恢复。
如果协程需要暂停,当前执行位置会保存在堆内存中,而栈上的执行状态会被销毁,栈空间被回收。下一次恢复协程时,堆内存中的暂停位置会被读取,协程从中断点继续执行。通过这种机制,实现了一个可暂停和恢复执行的函数,即协程。
总的来说,协程和普通函数一样,在执行时依赖于线程栈;但一旦暂停,协程的状态独立于线程栈,保存在堆内存中。此时,协程与任何线程都没有直接关系,调用它的线程可以继续执行其他任务,协程的恢复也可以由同一线程或完全不同的线程来完成。因此说,协程是线程无关的。
协程的优点
协程的最大优点之一是可以优化异步代码,使得代码更简洁、可读性更强。举个例子,假设我们有一个名为 IntReader 的组件,它的功能是从一个访问速度较慢的设备上读取一个整数值,因此它提供的是异步接口,代码如下:
1 | class IntReader { |
BeginRead() 方法开启了一个新的线程来生成一个随机整数,模拟异步操作。为了获取 IntReader 的结果,传统做法是使用回调函数,当操作完成时通过回调通知使用者,代码如下:
1 | class IntReader { |
如果我们需要执行多个 IntReader,把它们的结果加起来再输出,基于回调的代码会变得非常复杂:
1 | void PrintInt() { |
代码不仅需要一层套一层,还要在每层回调之间传递结果,这就是俗称的“回调地狱”。而使用协程后,这个问题迎刃而解。我们可以像下面这样使用协程调用 IntReader:
1 | Task PrintInt() { |
这段代码清晰、简洁,看起来就像是同步调用。每当协程遇到 co_await 时,它会暂停,直到 IntReader 完成操作,然后从暂停的地方恢复执行。接下来,我们将展示如何实现这一效果。
实现一个协程
在 C++ 中,只要一个函数体内使用了 co_await、co_return 或 co_yield 中的任何一个操作符,那么这个函数就会被视为协程。
我们先来关注一下 co_await 操作符。
co_await 和 Awaitable
co_await 的作用是让协程暂停,等待某个异步操作完成后再恢复执行。在前面的协程示例中,我们对 IntReader 调用了 co_await,但实际上这段代码不能正常编译,因为 IntReader 是我们自定义的类型,编译器并不知道它什么时候完成操作,也不知道如何获取操作结果。
为了让编译器理解我们的类型,C++定义了一个协议规范,要求自定义类型实现一定的函数,这样就可以在 co_await 中使用它。
这个规范被称为 Awaitable(可等待对象),它要求对象实现以下几个关键函数:
await_ready()
返回类型为bool。协程在执行co_await时,会首先调用await_ready()来检查“操作是否已经完成”。如果await_ready()返回true,则表示异步操作已完成,协程不需要暂停,可以直接继续执行。如果返回false,则协程会暂停,等待异步操作完成。
需要实现await_ready()的原因是,异步调用的时序通常是不确定的。如果异步操作在执行co_await之前已经完成,那么就应该跳过暂停步骤,直接继续执行。此时,await_ready()会返回true,协程就会立即恢复执行,避免不必要的暂停。await_suspend()
该函数接收一个类型为std::coroutine_handle<>的参数,返回类型可以是void或bool。如果
await_ready()返回false,说明协程需要暂停,那么接下来会调用await_suspend()。该函数的主要作用是接收协程的句柄 (std::coroutine_handle<>),并在异步操作完成时,通过调用这个句柄的resume()方法来恢复协程的执行。协程句柄类似于函数指针,它代表一个协程实例,我们可以通过句柄来控制该协程的恢复执行。需要注意的是,
await_suspend()的实现不仅可以启动异步操作,还可以管理协程的恢复机制。它确保在异步操作完成时,协程能够正确地恢复执行。**
await_suspend()**await_suspend()的返回类型通常是void,但是也可以是bool。当返回类型是bool时,返回值控制协程是否真的暂停。具体来说,当await_suspend()返回false时,协程将不会暂停,而是继续执行。
这提供了一个二次控制点,允许在协程暂停之前,基于某些条件决定是否阻止暂停。返回false会让协程直接继续执行,跳过暂停。await_resume()
返回类型可以是void或其他类型,它的返回值就是co_await操作符的结果。当协程恢复执行时,或者当co_await不需要暂停时,await_resume()会被调用。当协程恢复执行时,或者当co_await不需要暂停时,await_resume()会被调用。如果协程暂停了,
await_resume()会在协程恢复时提供暂停点后的数据,从而允许我们获取异步操作的结果。比如,await_resume()可以返回异步操作的结果,或者返回其他需要传递给协程后的值。
接下来,我们修改 IntReader 使其符合 Awaitable 规范。以下是完整的示例代码:
1 |
|
我们先忽略返回类型 Task ,下文会专门介绍协程的返回类型。
关键点解释:
await_ready():该函数总是返回 false,意味着协程总是需要暂停,等待异步操作完成。
await_suspend():在 await_suspend() 中,我们启动了一个子线程来执行异步操作。子线程生成随机数之后,保存在 value_ 成员变量中,然后调用协程句柄的 resume() 函数来恢复协程执行。
await_resume():当协程恢复执行时,await_resume() 会返回生成的随机数,并且这个值会成为 co_await 操作的结果返回。
执行流程:
在 main() 中,我们调用了 PrintInt(),并进入协程。
当协程执行到 co_await reader1 时,它会暂停,等待 IntReader 的异步操作完成。此时主线程返回 main(),继续等待用户输入。
reader1 中的子线程生成一个随机数,并通过协程句柄恢复协程执行。
当协程恢复时,执行到第二个 co_await reader2,然后暂停,再由 reader2 中的子线程恢复协程。
以此类推,第三个 co_await reader3 会再次暂停,并由 reader3 中的子线程恢复协程,最终输出随机数的总和。
这里的关键点是:哪个线程调用协程句柄的 resume() ,就由哪个线程恢复协程执行。可以使用打印线程id、在IDE中设置断点来观察这个程序的执行流程,以便更好地理解。
预定义的Awaitable类型
C++ 标准库中预定义了两个符合 Awaitable 规范的类型:std::suspend_never 和 std::suspend_always。顾名思义,std::suspend_never 表示协程不暂停,而 std::suspend_always 表示协程总是暂停。实际上它们的区别仅在于 await_ready() 函数的返回值:
std::suspend_never:await_ready()返回true,表示协程不需要暂停,直接继续执行。std::suspend_always:await_ready()返回false,表示协程需要暂停,等待异步操作完成后再恢复。
除此之外,这两个类型的 await_suspend() 和 await_resume() 函数实现是空的。因此,它们仅用于控制协程在某些时机是否暂停,并不涉及实际的异步操作。
这两个类型主要作为工具类,常用于 promise_type 中,用来指定协程挂起和恢复的行为。接下来,我们将详细介绍 promise_type 的作用和实现。下文会详细介绍 promise_type 。
协程的返回类型与 promise_type
C++ 对协程的返回类型只有一个基本要求:返回类型必须包含一个名为 promise_type 的嵌套类型。
与前面介绍的 Awaitable 一样, promise_type 也需要遵循 C++ 的规范,定义一系列特定的函数。promise_type 是协程的一部分,当协程被调用时,会在堆上为协程的状态分配空间,并同时创建一个对应的 promise_type 对象。通过该对象中定义的函数,我们可以与协程进行数据交互并控制协程的行为。
promise_type 要实现的第一个函数是 get_return_object() 它用于创建协程的返回值。在协程内部我们不需要显式地创建返回值,编译器会隐式地调用 get_return_object() 来创建返回值并返回给调用者。这个过程可能看起来有些奇怪——虽然 promise_type 是返回类型的嵌套类型,但编译器并不会直接创建返回值,而是先创建一个 promise_type 对象,然后通过该对象创建返回值。
那么,协程的返回值有什么用呢?这取决于协程的设计意图,具体取决于如何希望协程与调用者进行交互。
例如,在上文的示例中,PrintInt() 协程只是输出一个整数,并不需要与调用者有任何交互,因此它的返回值可以是一个空类型。 如果我们希望实现一个 GetInt() 协程,该协程需要返回一个整数给调用者,并由调用者进行输出,那么我们就需要对协程的返回类型进行一些修改,以便能够传递数据。
co_return
接下来,我们将 PrintInt() 协程修改为 GetInt(),并使用 co_return 操作符从协程中返回数据,如下所示:
1 | Task GetInt() { |
co_return total 等价于 promise_type.return_value(total),即返回的数据会通过 return_value() 函数传递给 promise_type 对象, 因此 promise_type 必须实现这个函数以接收数据。
此外,还需要确保返回类型 Task 能访问到这个数据。为了减少数据传递,我们可以在 promise_type 和 Task 之间共享同一份数据。下面是修改后的完整示例:
1 |
|
我们通过 std::shared_ptr<int> 在 promise_type 和 Task 之间共享数据。当 get_return_object() 创建 Task 时,它会将 promise_type 中的智能指针传递给 Task,这样它们就能访问到同一个数据。然后,协程内部通过 return_value() 函数将结果写入共享数据,而 Task 则通过 GetValue() 读取并返回该数据。
异步是具有传染性的,由于 GetInt() 协程内部使用了异步操作(co_await),它本身也是一个异步操作。为了等待协程执行完成,我们将 task.GetValue() 放在 while 循环中,在每次用户输入时输出当前结果。这是一个简单的示例程序,缺乏异步同步机制,所以通过通过等待用户输入来间接等待协程的执行。
在实际应用中,协程的返回类型通常需要提供更多的同步机制,以支持回调、通知等机制,使得协程的行为与传统的异步操作一致。因此,协程的优点主要体现在它的对内的代码逻辑,而不是对外的使用方式。
协程的返回类型也可以实现 Awaitable 规范,这样它就可以作为另一个协程的 co_await 对象,从而支持更复杂的协程链式调用。这样一来,调用协程的也必须是协程,这样层层往上传递,直到遇到不能改成协程的函数为止,例如 main() 函数。
与普通的 return 一样,co_return 也可以不带任何参数,在这种情况下,协程会以没有数据的方式返回,相当于调用了 promise_type.return_void(),promise_type 需要手动定义这个函数以支持不带数据的返回。。如果在协程结束时没有显式调用 co_return,编译器会隐式添加一个不带参数的 co_return 调用。
co_yield
在 C++ 协程中,co_return 用于结束协程,就像普通函数中的 return 一样。调用 co_return 后,协程实例的内存会被释放,协程也不能再继续执行。如果我们希望在协程中多次返回数据而不中止协程执行,可以使用 co_yield 操作符。
co_yield 的作用是返回数据并暂停协程,等待下一次恢复。具体来说,co_yield value 等价于 co_await promise_type.yield_value(value),传递给 yield_value() 的参数会被传给 promise_type 的 yield_value() 函数,而 yield_value() 的返回值将传给 co_await。
上面我们提到,传给 co_await 的参数要符合 Awaitable 规范,所以 yield_value() 的返回类型也要满足 Awaitable 规范,通常我们使用预定义的 std::suspend_always 让协程在每次调用 co_yield 时都暂停。
为了演示如何使用 co_yield,我们再次修改示例程序,使其在用户输入时,每次从协程中取出一个值并输出,然后继续生成下一个值。以下是修改后的完整示例代码:
1 |
|
这个示例修改的比较多,我们逐步分析。首先,IntReader::await_suspend() 中的随机数生成被改为递增整数,这样更容易观察到协程的暂停与恢复效果。
接着,Task 类增加了一个 Next() 方法,用来通过调用协程句柄恢复协程的执行:
1 | void Next() { |
这意味着 Task 需要持有协程句柄,这个句柄通过 promise_type 中的 get_return_object() 方法传递给 Task:
1 | Task get_return_object() { |
std::coroutine_handle 的 from_promise() 函数可以通过 promise_type 对象获取与之关联的协程句柄,反之,协程句柄上也有一个 promise() 函数可以获取对应的 promise_type 对象,他们是可以互相转换的。所以,在 Task 和 promise_type 之间就不需要使用 std::shared_ptr<int> 来共享数据, Task 通过协程句柄就能访问到 promise_type 对象,像下面这样直接取数据就可以了:
1 | int GetValue() const { |
需要注意的是,协程句柄 std::coroutine_handle 的模板类型。在前面的例子中,协程句柄的类型是 std::coroutine_handle<> ,不带模板参数;而在这个例子中,协程句柄的类型是 std::coroutine_handle<promise_type> ,模板参数中填入了 promise_type 类型。这个区别类似于指针 void* 和 promise_type* ,前者是无类型的,后者是强类型的。两种类型的协程句柄本质上是相同的东西,指向同一个协程实例,都可以恢复协程执行。但只有强类型的 std::coroutine_handle<promise_type> 才能调用 from_promise() 获取到 promise_type 对象。
然后,协程 GetInt() 被修改为一个无限循环,每次通过 IntReader 获取一个整数,并通过 co_yield 返回该整数:
1 | Task GetInt() { |
由于协程具有暂停和恢复的特性,GetInt() 可以在无限循环中执行,而不会像普通函数那样让线程在里面死循环。
最后,promise_type 中的 yield_value() 函数用于处理 co_yield 返回的数据。为了确保协程在每次 co_yield 后暂停,我们将 yield_value() 的返回类型设为 std::suspend_always,使协程在每次返回数据后都暂停等待恢复。
协程的生命周期
我们一开始提到,C++ 会在堆上为协程分配状态内存,并且必须确保这些内存会在适当的时机被释放,否则会造成内存泄漏。释放协程的内存可以通过自动释放或手动释放两种方式来实现。
自动释放:
当协程正常结束时,如果没有干预,C++ 会自动释放协程的内存。协程结束的标志是调用了 co_return,此时协程实例会销毁,释放状态。以下是自动释放的示例:
1 | Task GetInt() { |
PrintInt() 没有出现 co_return 语句,编译器会在末尾隐式地加上 co_return 。
自动释放的方式有时候并不是我们想要的,参考下面这个例子:
1 |
|
在这个例子中,GetInt() 协程通过 co_return 返回了 1024 给 promise_type,Task 通过协程句柄获取并访问该值。但是运行程序发现,输出的值并不是 1024,而是随机值或者会触发地址访问错误。
这个现象的原因是,协程在返回1024之后就被自动释放了, promise_type 也跟着被一起释放了,此时在 Task 内部持有的协程句柄已经变成了野指针,指向一块已经被释放的内存。所以访问这个协程句柄的任何行为都会是未定义行为。
手动释放内存
解决这个问题的方法是,将 promise_type中的 final_suspend() 返回类型从 std::suspend_never 改为 std::suspend_always 。
协程在结束的时候,会调用 final_suspend() 来决定是否暂停,如果这个函数返回了要暂停,那么协程不会自动释放,此时协程句柄还是有效的,可以安全访问它内部的数据。
1 | std::suspend_always final_suspend() noexcept { |
不过,这时候释放协程就变成我们的责任了,必须在适当的时机调用协程句柄上的 destroy() 函数来手动释放这个协程。可以在 Task 的析构函数中做这个事情:
1 | ~Task() { |
只要协程处于暂停状态(即没有被自动销毁),我们就可以调用 destroy() 来释放内存。对于像无限循环这样的协程,手动销毁内存是必须的,因为协程可能会在暂停状态下长时间存在。
initial_suspend() 的应用
与 final_suspend() 类似,initial_suspend() 用于决定协程开始执行时是否立即暂停。默认情况下,initial_suspend() 返回 std::suspend_never,即协程一开始就立即执行。
我们可以将这个函数的返回类型改成 std::suspend_always ,使协程一开始就进入暂停状态,直到我们显式恢复它。实现协程延迟执行的效果,例如批量管理协程句柄,之后需要的时候统一启动。
1 | std::suspend_always initial_suspend() { |
异常处理
协程的异常处理与普通函数有些不同,因为协程本身是在一个异步的上下文中执行的。C++编译器会自动生成一些代码来处理协程中的异常,确保协程在异常发生时能够正确地结束,并执行必要的清理工作。下面是一个伪代码示例,展示了协程的执行过程和异常处理机制:
1 | try { |
initial_suspend():协程开始执行时调用,决定协程是否暂停。通常返回std::suspend_never表示不暂停,或者std::suspend_always表示暂停。异常捕获:如果协程在执行过程中抛出异常,异常会被
catch (...)捕获,并传递给promise_type的unhandled_exception()方法。final_suspend():协程结束时调用,决定协程是否在结束前暂停。C++标准要求final_suspend()必须是noexcept,因此在final_suspend()中不能抛出异常。
举个栗子:
这是一段包含抛出异常的示例代码:
1 |
|
这是编译器处理后生成的伪代码:
1 | try { |
解释:
try-catch块:编译器会将协程体的执行过程包裹在try块中,co_await会触发initial_suspend()和final_suspend()方法的调用。- 抛出异常:当协程中抛出
std::runtime_error异常时,异常会被传递到外层的catch语句中。如果该异常未在协程体内部处理,它会被转交给promise_type::unhandled_exception()方法来进行处理。 unhandled_exception():当异常未被处理时,unhandled_exception()被调用,可以在这里执行必要的异常清理工作。final_suspend():协程结束时调用final_suspend(),这是最后的暂停点,确保协程的状态可以被正确清理。
协程主要的执行代码都被 try - catch 包裹,假如抛出了未处理的异常, promise_type 的 unhandled_exception() 函数会被调用,我们可以在这个函数里面做对应的异常处理。由于这个函数是在 catch 语句中调用的,我们可以在函数内调用 std::current_exception() 函数获取异常对象,也可以调用 throw 重新抛出异常。
调用了 unhandled_exception() 之后,协程就结束了,接下来会继续调用 final_suspend() ,与正常结束协程的流程一样。C++标准规定: final_suspend() 必须定义成 noexcept ,也就是说它不允许抛出任何异常。
后记
至此,我们介绍完了C++协程的基础内容,可以感觉到,C++20的协程仍然只提供了一些基础功能,使用起来并不友好。
要想在实际的开发中使用上C++协程,还有比较长的路。我们可以自己动手对它进行封装,或者使用第三方库的,或者期待未来的C++标准带来更高层封装的协程组件。








