类模版

协程

本文分析一下在TinyCoro中对于协程遇到的一些问题

父子关系建立

​ 在C++20所提供的协程中,我们如果想要实现协程的嵌套调用,默认情况下的编写会存在于一种不期望的情况,即,当协程内部co_await的另外一个协程结束的时候,其对应的接下来执行的函数可能比较混乱,其可能是main,也可能是调用协程的协程体内。这本质上是由于对于协程结束时的切换逻辑控制不到位的情况下导致的。

​ 想要理解这个,我们需要来分析一下C++的协程锁提供的多个特性中的部分,本次注意力集中到调度点(又或者”暂停点”)。

调度

​ 在协程中,存在多处控制接下来的执行逻辑的地点,理清所有的可能的调度点是我们构建起整个父子关系协程的第一步。

1. 协程初始化

​ 当在代码中编写代码调用一个协程函数时,实际上编译器会将一次简单的代码调用翻译成一系列的逻辑流,关于这部分的分析很多教程中都有解释,在此处我们关注与实际的程序执行流的流动。

协程

​ 在协程初始化时,我们的关注点在于initial_suspend的返回值中,此处的返回值决定接下来所需要执行的流程,实际上,在一个协程的initial_suspend实现中,一般只可能是suspend_always和suspend_never俩者二选一。即,考虑是先挂起协程不执行协程体还是立即执行协程体。在本次的lab中,所有的协程的initial_suspend都应该为suspend_always,这是由于我们需要由执行引擎本身去调度,以及等下会看到的另外一个用处。

  • 顶层协程首次启动,由于initial_suspend返回值始终使得协程暂停,可以使得在创建出对象之后进行一系列的操作,对于顶层协程,其需要注册到执行引擎中,由执行引擎来监控其中的一系列状态
  • 内部嵌套协程首次启动,当内部嵌套协程初始化时,如果我们不加以暂停,其可能会直接开始执行甚至于到协程体结束,如果此时我们没有绑定该协程的上下文,那么可以说该协程执行完毕之后的下一次调度将会是随机的(实际上,如果你对于自己编写的程序很了解,你可以猜到该协程结束后的栈帧是谁,但是这种需要靠”猜”的行为对于我们的框架来说就是随机的)。

​ 我们接下来可以看到这里的暂停在上下文关系建立中的必要性。

2.协程开始执行

​ 现在我们先抛弃执行引擎是如何启动的,我们现在假设顶层协程在完成一系列操作之后已经被resume。即,当对应的initial_suspend以及注册初始化之后,开始了协程体的内容执行。最简单的demo如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Task fun2(){
std::cout<<"func2 step1\n";
co_await suspend_never();
std::cout<<"func2 step1\n";
}

Task fun1(){
std::cout<<"func1 step1\n";
co_await fun2();
std::cout<<"func1 step1\n";
}

int main(){
auto task = fun1();
//...... maybe some action to save the info
task.resume();
return 0;
}

​ 初始,由于fun1的initial_suspend返回值阻塞了协程执行,所以执行流回到了main,此时main就相当于一个最基础的调度核心,我们需要去启动注册进来的协程,此处即是最基本的resume。当我们执行完task.resume();之后,fun1的协程体会开始执行,此时会打印func1 step1,接着其运行到了第一个暂停点,即co_await fun2();

​ 这里的co_await一个协程的实际语义相对有点意思。对于一个co_await关键字,其所需要的是一个await对象。一般来说,对于一个协程,其会存在一个co_await重载来提供该对象,而该重载函数一般不会是一个静态方法,否则其的局限性会很大。对于一个非静态方法的重载运算符,我们要使用的前提无论如何都是先具有对应的实际的对象,所在在这里,其也会先跑一趟对应的协程对象初始化。前面说过,由于我们的协程初始化中initial_suspend始终返回的是suspend_always,所以始终不会先执行对应的函数体。这是由于我们需要在正式执行逻辑之前绑定信息,上面已经提到,此时我们来看到内部的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
struct awaitable_base
{
awaitable_base(coroutine_handle coroutine) noexcept : m_coroutine(coroutine) {}

auto await_ready() const noexcept -> bool { return !m_coroutine || m_coroutine.done(); }

auto await_suspend(std::coroutine_handle<> awaiting_coroutine) noexcept -> std::coroutine_handle<>
{
// 绑定本身所在的协程句柄的父协程句柄
m_coroutine.promise().setFaHandle(awaiting_coroutine);
return m_coroutine;
}

std::coroutine_handle<promise_type> m_coroutine{nullptr};
};

auto operator co_await() const& noexcept
{
struct awaitable : public awaitable_base
{
auto await_resume() -> decltype(auto) { return this->m_coroutine.promise().result(); }
};
return awaitable{m_coroutine};
}

​ 此处可以看到几个比较有意思的点,其一是对应的awaitable_base实现,对应的实现中存在了一个m_coroutine句柄,该句柄实际上即为本个await对象所属的promise对象的句柄,通过该句柄,我们能够实现在await_suspend中的执行流调度,使得其能够在结束时resume一次本协程的暂停点。再结合前文中的co_await fun2();,我们可以发现其能够推迟一个co_await的协程体的执行直到await_suspend执行完毕。

​ 同时,注意点转到await_suspend的内容中,我们可以看到其中存在了一些其他的内容,如其中所示,其进行了将参数的句柄绑定到本协程句柄的属性中去,对应的作用如注释所示,绑定了本个协程的父协程(即最近一次的外部协程)。这样,我们实现了一种协程体执行的推迟,但是同时使得协程体始终在co_await这一句执行完毕之前能够开始执行一次。同时,其还能够实现一些成员属性的绑定,这是相当重要的,其是关于如何实现上下文正确调度的核心,需要着重理解。

3.协程执行结束

​ 此处我们省略大部分的场景,着重于一个特殊的场景,即,当一个子协程结束时,程序接下来的操作会是如何。

​ 在C++中,当一个协程结束时,省略其他return_的分析的话,着重点就在于final_suspend了,该函数是C++中规定的协程的另外一个调度点,其的返回值也是一个await对象,此对象也承担起了调度的作用,其中的await_suspend也能够达到跳转执行流的作用。在此次lab中,我们的期望是在一个协程执行完毕之后能够转回到对应的父协程中去进行。注意时机,是在协程执行完毕之后,以及目的,*调度回父协程(如果存在)。因此,final_suspend的目标自然就很明确,就是正确的调度回对应的父节点。而这需要通过await对象来实现。

​ 考虑前文所说的co_await时的逻辑,此时的promise中已经保存了本协程的父协程的句柄,所以此处我们所需要实现的就只是返回值的合理构造,实现起来很简单,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
auto final_suspend() noexcept 
{
struct final_awaitable{
std::coroutine_handle<> fa_handle_;

bool await_ready()noexcept{return false;}

std::coroutine_handle<> await_suspend(std::coroutine_handle<>)const noexcept{
if(fa_handle_){
return fa_handle_;
}
return std::noop_coroutine();
}

void await_resume() noexcept{}
};
return final_awaitable{fa_handle_};
}

​ 实际上可能存在多种实现,就比如不使用这里的fa_handle_成员,而是使用await_suspend的参数来进行额外的操作,不过本质上都与此处的逻辑相同。理解这里的本质逻辑即可。

-------------本文结束 感谢阅读-------------