协程

概念

协程是一种特殊的函数,不像普通函数那样,一旦执行这个函数就会一直执行下去,直到函数结束或者出现异常。而协程就非常灵活,它在执行的过程中可以随时暂停,把 CPU 资源让出来,等到合适的时候再重新恢复运行。

协程的使用场景

当你正在一个大型的网络服务器,需要处理多个并发的客户端请求,但是传统的服务器是给每个请求分配一个线程来进行处理,这样会导致资源消耗过大。而使用协程,你可以在一个线程中同时处理多个客户端的请求,当某个客户端的请求需要等待 I/O 时,协程可以暂停执行,切换到处理其他客户端的请求,从而提高服务器的并发能力。

有栈协程和无栈协程

  • 有栈线程:协程在执行时会使用自己的栈空间,协程的调用和返回都是通过栈来实现的。这种方式的优点是实现简单,性能较好,但缺点是栈空间有限,容易导致栈溢出。Go 语言的协程就是有栈协程的一个典型例子。
  • 无栈协程: 协程在执行时不使用自己的栈空间,而是将所有的状态信息保存在堆上。这种方式的优点是可以支持更大的协程栈,但缺点是实现复杂。C#的协程就是无栈协程的一个典型例子。

C++ 协程

原理

C++ 的协程是无栈协程,是基于状态机的实现的,编译器把协程函数编译成一个特殊的函数,这个函数有很多状态,可以被挂起(让出 CPU 资源,也叫暂停执行),恢复执行等等。这样的函数也叫做可重入函数。协程的状态信息(如局部变量、返回地址等)保存在堆上,而不是栈上。

alt text

从这个图中可以看出,协程函数可以被挂起(暂停执行)和恢复执行,协程函数可以在多个状态之间切换,这些状态包括挂起、恢复和完成等。这样的设计使得协程能够在执行过程中灵活地让出控制权。

至于协程函数是怎样被挂起和恢复的,主要是通过编译器生成的状态机来实现的。当协程函数被挂起时,编译器会保存当前的执行状态(如局部变量、返回地址等),并将控制权交回给调用者。当协程函数被恢复时,编译器会恢复之前保存的执行状态,从而实现协程的继续执行。

核心概念

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 同返回 true
      • bool 返回 true 立即挂起,返回 false 不挂起。
      • 返回某个协程句柄 coroutine handle,立即恢复对应句柄的运行。
    • auto await_resume():协程挂起后恢复时,调用的接口。返回值作为 co_wait 操作的返回值。

关键字

  • co_awaitco_await 调用一个 awaiter 对象(可以认为是一个接口),根据其内部定义决定其操作是挂起,还是继续,以及挂起,恢复时的行为。其呈现形式为

    1
    cw_ret = co_await awaiter;

    cw_ret 记录调用的返回值,其是awaiterawait_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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <iostream>
#include <coroutine>
#include <queue>

struct CoroutineTask {
struct promise_type {
CoroutineTask get_return_object() {
return CoroutineTask{ std::coroutine_handle<promise_type>::from_promise(*this) };
}
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void unhandled_exception() { std::terminate(); }
void return_void() {}
};

std::coroutine_handle<promise_type> handle;
CoroutineTask(std::coroutine_handle<promise_type> h) : handle(h) {}
};

struct Task {
CoroutineTask coro_task;

Task(CoroutineTask&& task) : coro_task(std::move(task)) {}
};

class Scheduler {
public:
void schedule(Task task) {
tasks.push(std::move(task));
}

void run() {
while (!tasks.empty()) {
Task current = std::move(tasks.front());
tasks.pop();

current.coro_task.handle.resume();

if (!current.coro_task.handle.done()) {
tasks.push(std::move(current));
}
}
}

private:
std::queue<Task> tasks;
};

CoroutineTask printNumbers() {
for (int i = 0; i < 10; ++i) {
std::cout << i << " ";
co_await std::suspend_always{};
}
co_return;
}

CoroutineTask printLetters() {
for (char c = 'a'; c < 'k'; ++c) {
std::cout << c << " ";
co_await std::suspend_always{};
}
co_return;
}

int main() {
Scheduler scheduler;
scheduler.schedule(Task(printNumbers()));
scheduler.schedule(Task(printLetters()));
scheduler.run();
return 0;
}
  • 阶段一:协程的创建和初始化
    main 函数中:

    1. 创建调度器:首先创建协程调度器 Scheduler 对象,开始时任务调度队列为空。
    2. Task(printNumbers()):调用协程函数 printNumbers()。由于该函数包含 co_await,它是一个协程。**关键点:**协程的初始启动当 printNumbers() 被调用时,并非立即执行函数体内的代码。编译器会先为其创建协程状态 coroutine state 对象,其中包含 promise_type 的实例。紧接着会调用 promise_typeinitial_suspend() 方法。在代码中,该方法返回 std::suspend_never,意味着协程帧创建后不会立即挂起,而是直接开始执行函数体内的代码。
    3. CoroutineTask 对象通过 get_return_object() 获得,它包装了管理该协程的句柄 handle
    4. 这个 CoroutineTask 最终被包装成 Task 对象,并通过 schedule 方法加入到调度器的任务队列中。
    5. printLetters() 的创建过程与此完全相同。
  • 阶段二:调度器运行循环:
    scheduler.run() 启动后,进入核心循环流程:

    1. 调用 current.coro_task.handle.resume()。这将执行权交给协程。
    2. 协程从它上次挂起的位置(对于首次执行,则是从函数体开头)继续运行。
      • printNumbers 会执行 std::cout << i << " ";,假设此时 i=0,输出 0
      • 遇到 co_await std::suspend_always{};。这个表达式会使得协程挂起。std::suspend_alwaysawait_ready() 返回 false,因此 await_suspend 被调用,协程在此暂停,控制权返回给调用者,也就是调度器的 run 函数。
    3. 调度器检查该协程句柄的 done() 状态。由于 printNumbers 的循环还没结束,协程并未完成,所以调度器将当前任务对象重新放回任务队列的尾部。
    4. 调度器从队列中取出下一个任务(现在是 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
  • 阶段四:协程完成与清理

    1. 当一个协程printNumbers循环结束,执行到co_return;时,它会进入结束序列。
    2. 编译器会调用promise_typefinal_suspend()方法。你的代码中返回std::suspend_always,这意味着协程在最终完成后仍然保持挂起状态。
    3. 此时,调度器检查该协程句柄的done()方法会返回true
    4. 因此,调度器不会再将这个已经完成的任务重新放入队列。
    5. 由于CoroutineTask对象的析构函数没有显式地调用handle.destroy(),而协程状态又因final_suspend返回了std::suspend_always而没有被自动销毁,这可能会导致协程状态的内存泄漏。一个健壮的实现通常需要在CoroutineTask的析构函数中判断如果协程未销毁,则调用handle.destroy()来释放资源。