c++协程控制流深入剖析

7 阅读7分钟

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中实现,如果你也在研究协程,欢迎共同讨论。