C++协程

学习总结

1 快速上手

协程(coroutine)可以看作函数的广义版本,允许函数暂停和恢复。

在C++20中,协程即包含co_returnco_yieldco_await的函数。本质上,C++20协程是函数对象之上的语法糖,编译器将围绕协程生成一个代码框架,此代码依赖于return和promise类型(若不使用C++23及未来版本会提供的高级抽象,需要由用户定义,所以比较麻烦)。以C++20协程实现的一个生成器为例:

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// 打印26个大写字母
#include <coroutine>
#include <iostream>
#include <optional>

template <std::movable T>
class Generator
{
public:
struct promise_type
{
// 当前值
std::optional<T> current_value;

// 返回coroutine frame句柄
Generator<T> get_return_object()
{
return Generator{Handle::from_promise(*this)};
}

static std::suspend_always initial_suspend() noexcept
{
return {};
}

static std::suspend_always final_suspend() noexcept
{
return {};
}

std::suspend_always yield_value(T value) noexcept
{
current_value = std::move(value);
return {};
}

// 在生成器协程中不允许co_await
void await_transform() = delete;

[[noreturn]] static void unhandled_exception()
{
throw;
}
};

// coroutine frame句柄类型
using Handle = std::coroutine_handle<promise_type>;

private:
Handle m_coroutine;

public:
explicit Generator(const Handle coroutine) : m_coroutine{coroutine}
{
}

Generator() = default;
~Generator()
{
if (m_coroutine)
m_coroutine.destroy();
}

Generator(const Generator &) = delete;
Generator &operator=(const Generator &) = delete;

// 仅保留移动语义
Generator(Generator &&other) noexcept : m_coroutine{other.m_coroutine}
{
other.m_coroutine = {};
}
Generator &operator=(Generator &&other) noexcept
{
if (this != &other)
{
if (m_coroutine)
m_coroutine.destroy();
m_coroutine = other.m_coroutine;
other.m_coroutine = {};
}
return *this;
}

// 迭代器
class Iter
{
public:
void operator++()
{
m_coroutine.resume();
}
const T &operator*() const
{
return *m_coroutine.promise().current_value;
}
bool operator==(std::default_sentinel_t) const
{
return !m_coroutine || m_coroutine.done();
}
explicit Iter(const Handle coroutine) : m_coroutine{coroutine}
{
}

private:
Handle m_coroutine;
};

Iter begin()
{
if (m_coroutine)
m_coroutine.resume();
return Iter{m_coroutine};
}

std::default_sentinel_t end() { return {}; }
};

template <std::integral T>
Generator<T> range(T first, const T last)
{
while (first < last)
co_yield first++;
}

int main()
{
for (const char i : range(65, 91))
std::cout << i << ' ';
std::cout << '\n';
}

用C++23实现同样的生成器

截至gcc (GCC) 13.2.0还未支持<generator>。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 打印26个大写字母
#include <generator>
#include <iostream>
#include <ranges>

std::generator<char> letters(char first)
{
for (;; co_yield first++);
}

int main()
{
for (const char ch : letters('A') | std::views::take(26))
std::cout << ch << ' ';
std::cout << '\n';
}

python3中实现同样的生成器:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 打印26个大写字母
import asyncio

async def async_generator():
# python3中range本身就是一个生成器
for i in range(65, 91):
yield chr(i)

async def main():
async for value in async_generator():
print(value, end=" ")

asyncio.run(main())

2 Activation Frame

activation frame:保存函数调用的当前状态的内存块。此状态包括传递给函数的参数的值、局部变量的值、返回地址等。对于函数,所有activation frame都具有严格嵌套的生命周期,以允许使用高效的内存分配数据结构来为每个函数调用分配和释放activation frame。这种数据结构通常称为stack,stack上分配的activation frame称为stack frame,大多数CPU架构都有一个专用寄存器用于保存指向stack顶部的指针。

coroutine activation frame:由于协程可以在不破坏activation frame的情况下暂停,因此activation frame不能再保证具有严格嵌套的生存期,通常不能使用stack进行分配,可能需要存储在heap上。

C++协程TS(Technical Specification)中有一些规定,如果编译器可以证明协程的生命周期确实严格嵌套在调用方的生命周期内,则允许从调用方的activation frame分配协程的coroutine frame的内存。如果有足够智能的编译器,在许多情况下可以避免heap分配。

对于协程,activation frame的某些部分需要在暂停期间保留,而有些部分只需要在执行时保留。例如,范围不跨越任何协程暂停点的变量的生命周期可能会存储在stack上。可以从逻辑上认为协程的activation frame由两部分组成:coroutine framestack frame。coroutine frame保存协程activation frame的一部分,该部分在协程暂停时持续存在;stack frame部分仅在协程执行时存在,并在协程暂停并将执行转移回调用方/恢复方时释放。

普通函数有2个操作:

  1. Call:创建activation frame,暂停函数调用方的执行,将参数和返回地址写入并将执行转移到被调用函数的开头。
  2. Return:将返回值传递给调用方,销毁activation frame,在调用点恢复函数调用方的执行。

协程有5个操作:

  1. Call:创建stack frame,将参数和返回地址写入并将执行转移到协程的开头。协程首先在heap上分配coroutine frame,并将数据从stack frame复制/移动到coroutine frame中,以使其生命周期超出第一个暂停点。

  2. Return:当协程执行返回操作时,它将返回值存储在某处(具体存储位置可以由协程自定义),然后销毁任何作用域内的局部变量(但不包括参数) 。协程有机会在将执行转移回调用方/恢复方之前执行一些附加逻辑,如执行某些操作来发布返回值,或恢复另一个正在等待结果的协程,是完全可定制的。最后协程执行Suspend或Destroy操作,根据操作语义将执行转移回调用方/恢复方,将activation frame的stack frame组件从stack中弹出。

    返回语句以co_return标识。

  3. Suspend:在函数内的当前点暂停协程的执行,并将执行转移回调用方/恢复方,而不破坏activation frame。协程有机会在执行转移回之前执行一些附加逻辑。协程执行暂停后,暂停时作用域内的任何对象仍然生存。

    与普通函数的return操作一样,协程只能在协程本身内部的明确定义的暂停点处暂停。

    暂停点通过使用co_awaitco_yield关键字来标识。

  4. Resume:在协程的暂停点恢复其执行,重新激活协程的activation frame。

    在相应暂停操作提供的coroutine frame句柄上调用void resume()方法。

  5. Destroy:销毁activation frame而不恢复协程的执行。暂停点范围内的任何对象都将被销毁,用于存储activation frame的内存被释放。

    与Resume操作类似,只能在暂停的协程上执行,在相应暂停操作提供的coroutine frame句柄上调用void destroy()方法。

    它不是在最后一个暂停点将执行转移到协程,而是将执行转移到另一个代码路径,在释放coroutine frame的内存之前在暂停点调用作用域内所有局部变量的析构函数。

参考资料

Lewis Baker:

Coroutine Theory

C++ Coroutines: Understanding operator co_await

C++ Coroutines: Understanding the promise type

C++ Coroutines: Understanding Symmetric Transfer

C++ Coroutines: Understanding the Compiler Transform

Šimon Tóth:

C++20 Coroutines — Complete* Guide


C++协程
https://reddish.fun/posts/Article/CPP-Coroutine/
作者
bit704
发布于
2024年1月15日
许可协议