C++20 协程初探

前言

       C++20 终于引入了协程特性,给库作者提供了一个实现协程的机制,让用户方便使用协程来编写异步逻辑,降低了异步并发编程的难度。结合我最近协程的学习,在这里记录一下相关内容。

概念模型

       协程和普通函数相比,多了个中途随时 挂起 ,随后 恢复 的过程,当用户调用一个阻塞请求接口,从而让出控制权,当响应时,恢复之前的控制流,从而大大提高线程复用率,这也注意了协程只是并发的,并不是真正意义上的并行,在 IO 密集型场景下,协程能够很好的提高资源利用率,用少数的线程达到并发成百上万个协程的效果。
       而相对传统的线程池 + 回调模式,每发起一个请求,为了避免阻塞当前线程,需要挂一个回调函数处理后续过程,而回调函数又可能产生竞争,导致得加锁处理。而协程却能够以同步方式写实现异步,后续过程直接挂起,当响应的时候恢复执行。
       我参与的项目中,对象随时都可能起个线程干活,或者常驻于对象生命周期里,统计下来整个项目居然开了几百个线程,由于多线程编程难免导致竞争,从而需要锁这种很低级的机制做同步,而一旦引入了锁,就不可避免的扩散开来,大家看到这里加把锁,那我也加把锁,统计下来代码里面居然也有几百把锁。真是维护的噩梦。
       由于协程能够随时挂起,后续恢复,这就能实现一些延迟计算的特性,例如生成器。
       扯远了,本文主题是关于 C++20 的协程,在 C++20 还没稳定之前,先来学习一下相关知识,读完本文后你应该能利用这个机制实现一些想要的协程了。

使用场景

       C++20 的协程设计为无栈协程,相对于有栈协程,省掉了上下文切换开销[1],只能手动切换,效率更高,也不用管理复杂的寄存器状态,移植性更好,但这同时也导致了不能被非协程函数嵌套调用。
       同时引入了 3 个关键字:
       1. co_yield:挂起并返回值
       2. co_await:挂起
       3. co_return:结束协程
       当一个函数出现了上面的关键字,则该函数是个协程。

Promise

       当 caller 调用一个 callee 协程的时候,协程自身的状态信息 [2](形参,局部变量,自带数据,各个阶段点执行点)会被保存在堆上的 Promise 对象中,这也是编译器会在协程里面插入 Promise 相关代码,以及一些执行点。由于 Promise 的大小可以在编译期计算出来,从而避免了内存浪费。而 Promise 对象所有权可由coroutine_handle 句柄持有。

Future

       而 Future 对象主要是与 Promise 对象交互的桥梁,既 caller 与 callee 之间的通信:
       1. callee 挂起时,将值返回给 caller:yield 语义
       2. callee 执行结束时,将值返回给 caller:return 语义
       3. callee 恢复时,caller 将值带给 callee
       需要注意的是,这些概念和标准库的 std::promise/std::future 不是同一个东西,后者用于做同步用,std::future会阻塞等待直到 std::promise 提供值,可以看做是条件变量的封装,同样地,和其他语言的 Promise/Future 概念也不一样。

Awaitable

       如果一个对象是 Awaitable 对象,那么可以用 co_await 操作符去触发该对象的动作 ready/suspend/resume,从而转移、恢复控制权,co_await 细节留到后面在介绍。

具体机制

       了解了概念模型后,我们可以进一步探讨背后的机制了。

Promise/Future 对象

       当一个协程被调用时,会创建 Promise 对象,然后编译器会在各个阶段插入一些代码[3]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
co_await promise.initial_suspend();
try
{
<body-statements>
}
catch(...)
{
promise.unhandled_exception();
}
FinalSuspend:
co_await promise.final_suspend();
}

       可以看到一个协程函数,分为如下几个步骤:
       1. 从堆上 (operator new) 创建 Promise 对象,保存协程的状态信息
       2. initial_suspend 阶段,用于在执行协程主体 代码前做些事情
       3. 阶段,执行协程的主体代码
       4. unhandled_exception 阶段,若抛异常,处理异常
       5. final_suspend阶段,协程结束收尾动作,在这阶段的 coroutine_handle::done 方法为 true,caller 可以通过这个方法判断协程是否结束,从而不再调用 resume 恢复协程。
       而协程返回类型则是一个 Future 对象,这一步编译器通过 Promise::get_return_object() 来创建 Future 对象。而 Future 对象一般持有 Promise 的句柄:coroutine_handle,这样 caller 可以通过 Future 与 Promise 交互,从而恢复协程。
       而 Promise 对象释放的时间点有两个,避免重复执行,否则会 double free:1. final_suspend 阶段 resume 后
       1. final_suspend 阶段 resume 后
       2. 调用 coroutine_handle::destroy() 方法
       比较好的做法是在 final_suspend 阶段挂起,这时候就不可 resume 了,在 caller 通过调用 Future 持有的句柄 destroy() 方法释放 Promise 对象。综上,一个 Promise 对象需要实现如下方法:
       1. initial_suspend:返回一个 Awaitable 对象
       2. final_suspend:返回一个 Awaitable 对象
       3. get_return_object:返回一个 Future 对象给 caller
       4. unhandled_exception:处理异常
       5. return_value/return_void:co_return 时返回值给 caller
       6. yield_value:挂起时返回值给 caller
       再来看看其 coroutine_handle 句柄编译器提供了哪些主要方法:
       1. destroy:销毁 Promise 对象
       2. from_promise:静态方法,从 Promise 对象返回其 coroutine_handle 句柄
       3. done:是否处于 final_suspend 阶段
       4. promise:返回 Promise 对象引用
       5. resume/operator():恢复到协程

Awaitable 对象

       前面提到的 co_await 关键字[4],其操作的对象其实是 Awaiter 对象,若对象实现如下方法,则说明该对象是 Awaitable 的:
       1. await_ready
       2. await_suspend(coroutine_handle<>)
       3. await_resume
       那么当执行 co_await 表达式时,编译器会生成如下代码:

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
{
auto&& value = <expr>;
auto&& awaitable = get_awaitable(promise, static_cast<decltype(value)>(value));
auto&& awaiter = get_awaiter(static_cast<decltype(awaitable)>(awaitable));
if(!awaiter.await_ready())
{
<suspend-coroutine>
//if await_suspend returns void
try
{
awaiter.await_suspend(coroutine_handle);
return_to_the_caller();
}
catch(...)
{
exception = std::current_exception();
goto resume_point;
}
//endif
//if await_suspend returns bool
bool await_suspend_result;
try
{
await_suspend_result = awaiter.await_suspend(coroutine_handle);
}
catch(...)
{
exception = std::current_exception();
goto resume_point;
}
if(not await_suspend_result)
{
goto resume_point;
}
return_to_the_caller();
//endif
//if await_suspend returns another coroutine_handle
decltype(awaiter.await_suspend(std::declval<coro_handle_t>())) another_coro_handle;
try
{
another_coro_handle = awaiter.await_suspend(coroutine_handle);
}
catch(...)
{
exception = std::current_exception();
goto resume_point;
}
another_coro_handle.resume();
return_to_the_caller();
//endif
}
resume_point:
if(exception)
{
std::rethrow_exception(exception);
}
"return" awaiter.await_resume();
}

       也就是:
       1. 通过 拿到 Awaiter 对象
       2. 通过 Awaiter.await_ready()方法判断是否需要挂起,若为 true 则无需挂起
       3. 判断 Awaiter.await_suspend()的返回值类型:
       3.1 void,无返回值,直接挂起返回 caller
       3.2 bool,若为 true,则返挂起返回 caller,否则不挂起,直接 resume
       3.3 coroutine_handle<>,则挂起并将控制权转移到另一个协程上,另一个协程可以再 resume 回来,到达resume_point。
       4. Awaiter.await_resume()的返回值即为 co_await 的结果
       那么问题来了,谁来创建 Awaiter 对象呢?有两种方法:
       1. 通过 Promise 对象的 await_transform() 方法,得到 Awaiter 对象
       2. 通过重载 operator co_await 操作符,得到 Awaiter 对象
       3. 直接用 Awaitable 对象
       标准库里面实现了两种 Awaiter,分别如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct suspend_never
{
bool await_ready() const { return true; }
void await_suspend(coroutine_handle<>) const {}
void await_resume() const {}
};
struct suspend_always
{
bool await_ready() const { return false; }
void await_suspend(coroutine_handle<>) const {}
void await_resume() const {}
};

       主要在 await_ready 阶段判断是否需要挂起协程。
       最后 co_yield 其实是 co_await 的语法糖,生成如下代码:

1
co_await promise.yield_value(expression);

       而 co_return 则会调用 Promise 对象的 return_void/return_value 方法。

协程实战

       有了以上知识,应该足够实现一些协程了。
       为了简单起见,这里我实现一个 Fibonacci 的生成器。
       首先先写协程函数,并在 main caller 中调用,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
FiboFuture generate_fibo()
{
int i = 0, j = 1;
while(true)
{
co_yield j;
std::tie(i, j) = std::make_pair(j, i + j);
}
}
int main()
{
for(auto x = generate_fibo(); x < 1000; x.resume())
{
std::cout << "fibo:" << x << std::endl;
}
return 0;
}

       接着实现我们所需要的 Proimse 对象,用于将结果传给 caller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct promise_type
{
int value_; // 返回结果给 caller
auto initial_suspend() { return suspend_never{}; }
auto final_suspend() noexcept { return suspend_always{}; } // final_suspend 挂起,由 FiboFuture 释放 promise 对象
FiboFuture get_return_object()
{ return {coroutine_handle<promise_type>::from_promise(*this)}; } // 返回 FiboFuture 对象
void unhandled_exception() { std::terminate(); }
auto yield_value(int value)
{ // yield 一个值并挂起返回 caller
value_ = value;
return suspend_always{};
}
void return_void() {}
};

       还有对应的 Future:

1
2
3
4
5
6
7
8
9
10
11
12
struct FiboFuture
{
struct promise_type;
FiboFuture(coroutine_handle<promise_type> handle): handle_(handle) {}
operator int() { return handle_.promise().value_; }
void resume() { if(! handle_.done()) handle_.resume(); }
~FiboFuture() { handle_.destroy(); }
private:
coroutine_handle<promise_type> handle_;
};

       一切搞定,这就是目前生成器的实现,待后续集成到标准库中去,方便使用。
       还有一个例子是 caller/callee 相互协作,互相通信完成任务。

总结

       期待 C++20 协程的稳定成熟,这样写业务代码就简单多了,心智负担没那么重了。

文章目录
  1. 1. 前言
  2. 2. 概念模型
  3. 3. 使用场景
  4. 4. Promise
  5. 5. Future
  6. 6. Awaitable
  7. 7. 具体机制
  8. 8. Promise/Future 对象
  9. 9. Awaitable 对象
  10. 10. 协程实战
  11. 11. 总结