协程
协程
概念
协程是一种特殊的函数,不像普通函数那样,一旦执行这个函数就会一直执行下去,直到函数结束或者出现异常。而协程就非常灵活,它在执行的过程中可以随时暂停,把 CPU 资源让出来,等到合适的时候再重新恢复运行。
协程的使用场景
当你正在一个大型的网络服务器,需要处理多个并发的客户端请求,但是传统的服务器是给每个请求分配一个线程来进行处理,这样会导致资源消耗过大。而使用协程,你可以在一个线程中同时处理多个客户端的请求,当某个客户端的请求需要等待 I/O 时,协程可以暂停执行,切换到处理其他客户端的请求,从而提高服务器的并发能力。
有栈协程和无栈协程
- 有栈线程:协程在执行时会使用自己的栈空间,协程的调用和返回都是通过栈来实现的。这种方式的优点是实现简单,性能较好,但缺点是栈空间有限,容易导致栈溢出。Go 语言的协程就是有栈协程的一个典型例子。
- 无栈协程: 协程在执行时不使用自己的栈空间,而是将所有的状态信息保存在堆上。这种方式的优点是可以支持更大的协程栈,但缺点是实现复杂。C#的协程就是无栈协程的一个典型例子。
C++ 协程
原理
C++ 的协程是无栈协程,是基于状态机的实现的,编译器把协程函数编译成一个特殊的函数,这个函数有很多状态,可以被挂起(让出 CPU 资源,也叫暂停执行),恢复执行等等。这样的函数也叫做可重入函数。协程的状态信息(如局部变量、返回地址等)保存在堆上,而不是栈上。

从这个图中可以看出,协程函数可以被挂起(暂停执行)和恢复执行,协程函数可以在多个状态之间切换,这些状态包括挂起、恢复和完成等。这样的设计使得协程能够在执行过程中灵活地让出控制权。
至于协程函数是怎样被挂起和恢复的,主要是通过编译器生成的状态机来实现的。当协程函数被挂起时,编译器会保存当前的执行状态(如局部变量、返回地址等),并将控制权交回给调用者。当协程函数被恢复时,编译器会恢复之前保存的执行状态,从而实现协程的继续执行。
核心概念
C++20 协程引入了几个关键概念,理解这些概念是掌握协程的基础:
Coroutine Frame (协程帧):协程帧是协程的运行时状态存储区域,包含了协程的局部变量、参数以及恢复执行所需的信息。协程帧通常分配在堆上,由编译器自动生成和管理。
Promise(承诺):
Promise是一个接口,用于定义协程的行为。它包含了协程的返回值类型、异常处理方式以及挂起/恢复逻辑。可以通过自定义Promise类型,来控制协程的行为。get_return_object():这个方法用于返回协程的返回值。返回值类型可以是void,也可以是其他类型。例如,可以返回一个Task对象。inital_suspend():这个方法用于控制协程的初始挂起行为,返回值为等待体awaiter。如果返回std::suspend_always,则协程在开始执行时会立即挂起。如果返回std::suspend_never,则协程会立即执行。final_suspend():这个方法用于控制协程的初始挂起行为。如果返回std::suspend_always,则协程在开始执行时会立即挂起。如果返回std::suspend_never,则协程会立即执行。return_void():这个方法用于处理协程的正常返回。当协程执行到co_return语句时,会调用这个方法。return_void(T value):这个方法用于处理协程的正常返回。当协程执行到co_return value语句时,会调用这个方法。T是模版参数类型。unhandle_exception():这个方法用于处理协程的异常。当协程抛出异常时,会调用这个方法。yield_value(T value):这个方法用于处理协程的co_yield语句。co_yield语句用于生成一个值,并暂停协程的执行。T是模版参数类型。
**Coroutine Handle(协程句柄):**协程句柄是一个轻量级的指针,用于操作协程。可以通过协程句柄来恢复、销毁线程。协程句柄的表现形式是
std::coroutine_handle<promise_type>,其模板参数为承诺对象promise类型。句柄有几个重要函数:resume()函数可以恢复协程。done()函数可以判断协程函数是否已经完成。返回false表示协程还没有完成,还在挂起。
协程句柄和承诺对象之间是可以相互转化的。std::coroutine_handle<promise_type>::from_promise(): 这是一个静态函数,可以从承诺对象promise得到相应句柄。std::coroutine_handle<promise_type>::promise()函数可以从协程句柄coroutine handle得到对应的承诺对象promise
Awaitable(可等待对象):
Awaitable是一个类型,用于表示一个异步操作。当协程遇到一个Awaitable对象时,可以选择挂起执行,等待异步操作完成。Awaitable对象需要提供await_ready()、await_suspend()和await_resume()三个方法,用于控制协程的挂起、恢复逻辑。bool await_ready():等待体是否准备好了,返回 false ,表示协程没有准备好,立即调用 await_suspend。返回 true,表示已经准备好了。auto await_suspend(std::coroutine_handle<> handle):如果要挂起,调用的接口。其中 handle 参数就是调用等待体的协程,其返回值有 3 种可能void同返回truebool返回true立即挂起,返回false不挂起。- 返回某个协程句柄
coroutine handle,立即恢复对应句柄的运行。
auto await_resume():协程挂起后恢复时,调用的接口。返回值作为co_wait操作的返回值。
关键字
co_await:co_await调用一个awaiter对象(可以认为是一个接口),根据其内部定义决定其操作是挂起,还是继续,以及挂起,恢复时的行为。其呈现形式为1
cw_ret = co_await awaiter;
cw_ret记录调用的返回值,其是awaiter的await_resume接口返回值。co_yield:挂起协程。其出现形式是1
co_yield cy_ret;
cy_ret会保存在promise承诺对象中(通过yield_value函数)。在协程外部可以通过promise得到。co_return:协程返回。其出现形式是1
co_return cr_ret;
cr_ret会保存在promise承诺对象中(通过return_value函数)。在协程外部可以通过promise得到。要注意,cr_ret并不是协程的返回值。这个是有区别的。
举例
这个例程展示了使用协程进行多任务调度的场景:
1 |
|
阶段一:协程的创建和初始化
在main函数中:- 创建调度器:首先创建协程调度器
Scheduler对象,开始时任务调度队列为空。 Task(printNumbers()):调用协程函数printNumbers()。由于该函数包含co_await,它是一个协程。**关键点:**协程的初始启动当printNumbers()被调用时,并非立即执行函数体内的代码。编译器会先为其创建协程状态coroutine state对象,其中包含promise_type的实例。紧接着会调用promise_type的initial_suspend()方法。在代码中,该方法返回std::suspend_never,意味着协程帧创建后不会立即挂起,而是直接开始执行函数体内的代码。CoroutineTask对象通过get_return_object()获得,它包装了管理该协程的句柄handle。- 这个
CoroutineTask最终被包装成 Task 对象,并通过schedule方法加入到调度器的任务队列中。 printLetters()的创建过程与此完全相同。
- 创建调度器:首先创建协程调度器
阶段二:调度器运行循环:
scheduler.run()启动后,进入核心循环流程:- 调用
current.coro_task.handle.resume()。这将执行权交给协程。 - 协程从它上次挂起的位置(对于首次执行,则是从函数体开头)继续运行。
printNumbers会执行std::cout << i << " ";,假设此时i=0,输出0。- 遇到
co_await std::suspend_always{};。这个表达式会使得协程挂起。std::suspend_always的await_ready()返回false,因此await_suspend被调用,协程在此暂停,控制权返回给调用者,也就是调度器的run函数。
- 调度器检查该协程句柄的
done()状态。由于printNumbers的循环还没结束,协程并未完成,所以调度器将当前任务对象重新放回任务队列的尾部。 - 调度器从队列中取出下一个任务(现在是
printLetters),并重复恢复执行——> 挂起——> 重新入队的过程。printLetters会输出a,然后挂起,并被重新加入队列。
- 调用
阶段三:循环与交替输出
调度器会持续从队列头部取任务,执行直到协程挂起,然后将未完成的任务放回队尾。这个过程形成了 轮转Round-Robin调度,使得两个协程交替执行。最终,将会看到以下的交错输出:1
0 a 1 b 2 c 3 d 4 e 5 f 6 g 7 h 8 i 9 j
阶段四:协程完成与清理
- 当一个协程
printNumbers循环结束,执行到co_return;时,它会进入结束序列。 - 编译器会调用
promise_type的final_suspend()方法。你的代码中返回std::suspend_always,这意味着协程在最终完成后仍然保持挂起状态。 - 此时,调度器检查该协程句柄的
done()方法会返回true。 - 因此,调度器不会再将这个已经完成的任务重新放入队列。
- 由于
CoroutineTask对象的析构函数没有显式地调用handle.destroy(),而协程状态又因final_suspend返回了std::suspend_always而没有被自动销毁,这可能会导致协程状态的内存泄漏。一个健壮的实现通常需要在CoroutineTask的析构函数中判断如果协程未销毁,则调用handle.destroy()来释放资源。
- 当一个协程


