c++协程控制流深入剖析
本篇文章将就最近作者对c++20协程的研究进行回顾与总结,分析c++协程控制流程以及各种资源的构造与释放过程,以求更全面的理解c++协程的机制并应用实践。
关于本文出现的大多数代码,都是作者通过 Benny Huo 专栏学习而来。我们将具体探讨下面代码(仅有大概印象即可)的运行过程,本文不会具体到代码的完整实现,只就代码理解c++协程的控制流:
class SleepAwaiter : public Awaiter<int>
{
public:
SleepAwaiter(uint64_t time)
{
m_time = time;
}
protected:
void on_suspend() override
{
m_sylar::TimeManager* tim = m_sylar::TimeManager::getInstance(); // 这是一个定时器封装
tim->addTimer(m_time * 1000000, false, [this](){
std::cout << "SleepAwaiter call back begin " << this << std::endl;
resume(m_time);
std::cout << "SleepAwaiter call back end" << std::endl;
});
std::cout << "on_suspend" << std::endl;
}
void before_resume() override
{
std::cout << "before_resume" << std::endl;
}
private:
uint64_t m_time;
};
Task<int> msleep(uint64_t time)
{
std::cout << "[msleep sleep 10s begin]" << std::endl;
co_await SleepAwaiter(time);
std::cout << "[msleep sleep 10s end]" << std::endl;
co_return 1;
}
Task<void, TaskBeginExecuter> coro() // 这是协程任务开始
{
std::cout << "coroutine task running(with a async sleep task)..." << std::endl;
co_await msleep(3); // 不直接co_await SleepAwaiter是为了展示协程调用逻辑
std::cout << "coroutine task end, after 3s" << std::endl;
co_return;
}
int main(void)
{
auto t = coro();
t.getHandle().resume();
sleep(10); // 等待协程执行完成
return 0;
}
代码实例中,各个位置代码运行顺序如何?
1.main函数到coro协程
所有的代码,都将从coro函数开始,coro函数被调用时,协程栈帧就被创建,promise_type生成,随后生成了Task对象。在这一步中,最重要的有两个函数:
Task<ResultType, Executer> get_return_object();
InitialAwaiter<Executer> initial_suspend();
我们都知道这两个函数的作用,重要的是上面代码中的Executer以及InitialAwaiter的内容,他俩决定了这个协程创建时将会发生什么。
// InitialAwaiter部分实现
//...
bool await_ready(){ return false; }
void await_suspend(std::coroutine_handle<> handle)
{
m_executer->initialExecute([handle](){
handle.resume();
});
}
//...
// TaskBeginExecuter部分实现:
virtual void initialExecute(std::function<void()> &&func)
{
}
如代码,InitialAwaiter会开始时就挂起整个任务,await_suspend中会由m_executer->initialExecute进行协程的恢复,TaskBeginExecuter.initialExecute根本不会运行func这个函数,也就是协程开始就被挂起了。当协程运行到await_suspend最后一行后,控制流回到main函数中并且t获得了get_return_object()生成的Task,main函数重新调用resume()后,协程继续运行。
2.co_await msleep() 的控制流展开与子协程生命周期管理
Task<void, TaskBeginExecuter> coro() // 这是协程任务开始
{
std::cout << "coroutine task running(with a async sleep task)..." << std::endl;
co_await msleep(3); // 不直接co_await SleepAwaiter是为了展示协程调用逻辑
std::cout << "coroutine task end, after 3s" << std::endl;
co_return;
}
coro函数运行后,遇到了第一个协程关键字,通过await_transform函数,co_await会获得一个awaiter:
template<typename _ResultType, typename _Executer> // co_await协程返回值类型,与ResultType 有所区分
TaskAwaiter<_ResultType, _Executer> await_transform (Task<_ResultType, _Executer>&& task)
{
return TaskAwaiter<_ResultType, _Executer>(task);
}
// TaskAwaiter
bool await_ready()
{
return false;
}
void await_suspend(std::coroutine_handle<> handle)
{ // 控制子协程是否完成后恢复自己
m_task->finally([handle](Result<_ResultType> result){ // m_task完成时会调用这个传入的函数
handle.resume();
});
}
_ResultType await_resume()
{
return m_task->getResult();
}
await_transform中并没有什么,重要的地方在TaskAwaiter, TaskAwaiter会先挂起自己,随后TaskAwaiter.await_suspend会把协程的resume权交m_task的完成回调,此处的m_task就是msleep(3)协程函数,也就是说,当msleep协程运行完毕后,会调用这个注册的函数来恢复自己执行。
协程嵌套分析
这里遇到了一个很重要的生命周期控制问题,也就是子协程生命周期问题。深入分析原代码,msleep(3)会先运行后在挂起的时候返回,然后通过transform生成awaiter供co_await使用:
co_await msleep(3);-------msleep(3)开始运行(不考虑initial_suspend)
|
*** msleep遇到挂起或结束
|
返回get_return_object生成的Task
|
co_await执行awaiter-await_transform转换获取awaiter
|
await_suspend
|
m_task->finally(... )
| \
msleep完成 msleep挂起
| \
回调直接执行 coro挂起,回到***并重复到其调用者
|
coro恢复
看似最后两条结果都没有问题,但msleep挂起时,协程调用链就已经被破坏了,因为如果msleep挂起,co_await执行完毕后,会销毁产生的Task临时变量,此时Task管理的协程也就会被销毁,导致子协程永远无法恢复主协程。因此我们需要保护msleep传回的Task,对await_transform进行修改,如下:
template<typename _ResultType, typename _Executer> // co_await协程返回值类型,与ResultType 有所区分
TaskAwaiter<_ResultType, _Executer> await_transform (Task<_ResultType, _Executer>&& task)
{ // 需要控制子任务生命周期
Task<_ResultType, _Executer>* task_ptr = new Task<_ResultType, _Executer>(std::move(task));
m_child_task_ptr.reset(task_ptr);
TaskAwaiter<_ResultType, _Executer> child_task (task_ptr);
return child_task;
}
由于主协程需要由子协程唤醒,因此子协程必须在主协程被销毁后销毁因此需要一个智能指针,存放在主协程promise_type中来进行管理子协程生命周期,由于promise_type生命周期与handle相同,因此在主协程没被销毁前,子协程将一直存在。
3.sleepAwaiter对整个协程调用链产生的影响
void on_suspend() override
{
m_sylar::TimeManager* tim = m_sylar::TimeManager::getInstance(); // 这是一个定时器封装
tim->addTimer(m_time * 1000000, false, [this](){
std::cout << "SleepAwaiter call back begin " << this << std::endl;
resume(m_time); // 封装后的resume,会恢复当前协程。
std::cout << "SleepAwaiter call back end" << std::endl;
});
std::cout << "on_suspend" << std::endl;
}
上面的代码是sleepAwaiter最核心的函数,在await_suspend中,会执行on_suspend的代码。
msleep的挂起
观察到on_suspend中并没有立刻resume,,因此,对于msleep函数,他就会挂起,回到了协程嵌套分析中所说的过程。
执行权经过层层传递,最终又回到了main函数的位置,main函数随后可以执行任意的操作。整个协程调用挂起。如下:
SleepAwaiter.await_suspend()执行完成
|
msleep挂起
|
返回Task
|
coro接受Task,运行await_transform后挂起
|
coro返回Task
|
main
msleep的恢复
2s后,定时器通过回调执行设置的代码,也就是说一个函数A(假设)会调用resume(m_time), 此时msleep函数会恢复并执行await_resume(),完成了协程的恢复。流程如下:
定时器运行resume,调用SleepAwaiter.await_resume()
|
msleep获取await_resume返回值并运行
|
msleep调用co_return
|
msleep通过回调函数resume coro
|
coro执行调用co_return
|
coro运行其回调
|
msleep 回调resume完成,继续运行回调函数
|
定时器resume完成
控制流会从最内层挂起点,一层层向外,直到最外层协程,最后再一层层向里回到外部函数resume的地方。
4.当前恢复挂起的缺陷
协程嵌套分析中,子协程会由主协程管理,那最外层的协程只会由调用者函数管理,类似上述的任务,此处为了防止崩溃使用了sleep等待定时器回调:
int main(void)
{
{
auto t = coro();
t.getHandle().resume();
}
sleep(10); // 等待协程执行完成
return 0;
}
如代码所示,若t被销毁,那定时器回调执行时,一定会导致错误。
为了避免这样的错误,我们需要控制final_suspend的行为,也就是:
- 外部存在Task管理协程--->协程运行完毕后挂起,让Task进行销毁操作
- 外部Task先被销毁--------->协程运行完毕后直接销毁
这一步代码很简单但很分散,因篇幅限制,不在此处详述。
5.其他问题
在协程的控制中,应明确各部分的语义,如外界获取的Task必须独一份,不能进行拷贝(无特殊的控制情况下),否则很容易出现多次销毁等问题。
小结
本文重点分析了协程控制流,参考了Benny Huo 专栏实现。通过对其整个流程的具体分析,我们才能更好的使用它并更快的找出协程运行时产生的错误。涉及到的代码均有在m_sylar中实现,如果你也在研究协程,欢迎共同讨论。