作为"线程的线程",协程能够将线程的同步任务改造成异步执行,可以实现用户级的并发。最核心的原理就是前面介绍过的ucontext,它可以记录线程执行到某时的运行状态,并随时暂存或恢复。

那么,有了ucontext后,如何管理协程的状态信息呢?如何实现协程调度,以及其中最核心的操作resume()和yield()呢?

这里先讲核心操作resume(恢复执行)和yield(暂停执行),而具体的调度逻辑等后面结合scheduler调度器讲解。

协程定义与状态管理

下面是fiber类的核心成员变量

// 正在运行的协程
static thread_local Fiber* t_fiber = nullptr;
// 主协程
static thread_local std::shared_ptr<Fiber> t_thread_fiber = nullptr;
// 调度协程
static thread_local Fiber* t_scheduler_fiber = nullptr;

// 协程id
static std::atomic<uint64_t> s_fiber_id{0};

// 协程计数器
static std::atomic<uint64_t> s_fiber_count{0};

// id
uint64_t m_id = 0;

// 栈大小
uint32_t m_stacksize = 0;

// 协程状态
State m_state = READY;

// 协程上下文
ucontext_t m_ctx;

// 协程栈指针
void* m_stack = nullptr;

// 协程函数
std::function<void()> m_cb;

// 是否让出执行权交给调度协程
bool m_runInScheduler;

我的设计思路是每一个协程都对应一个独立的fiber对象,所有协程的基本信息、运行状态,上下文都由fiber对象管理。

这里注意一个非常重要的点:this返回的是fiber.xxx()中的fiber。也就是它在哪个对象身上被调用,this就指向谁,它对理解一些核心函数很关键。

接下来分析几个比较重要的成员变量

协程指针

// 正在运行的协程
static thread_local Fiber* t_fiber = nullptr;
// 主协程
static thread_local std::shared_ptr<Fiber> t_thread_fiber = nullptr;
// 调度协程
static thread_local Fiber* t_scheduler_fiber = nullptr;

这里thread_local 关键字表明这个变量是线程本地所有的,也就是说每一个不同的线程都会有一份不同的副本。

比如t_fiber 被声明为static thread_local ,那么对于同一个线程的所有协程,t_fiber 都是同一个地址,但对于其他线程,t_fiber 就不一样了。t_fiber 指向当前线程正在运行的协程

有了上面的经验,下面两个变量就很简单了。由于这里实现的是非对称协程,一个线程存在一个唯一的主协程,t_thread_fiber 就是指向主协程的指针

不同协程之间的调度需要调度协程完成,t_scheduler_fiber就是指向调度协程的指针

协程状态与入口函数

哪怕设计再简单,协程也至少应该拥有准备(READY),正在运行(RUNNING),运行结束(TERM)三种状态,对于这三种状态

使用枚举变量表示

// 协程状态
enum State
{
	READY, 
	RUNNING, 
	TERM 
};

fiber类中额外定义了一个MainFunc,用以封装m_cb,它实际上才是真正的入口函数

void Fiber::MainFunc()
{
	//获取当前协程对象的指针
    std::shared_ptr<Fiber> curr = GetThis();
	assert(curr!=nullptr);

    //执行协程函数
	curr->m_cb();
    //运行后处理 
    //运行完毕 -> 让出执行权
	curr->m_cb = nullptr;
	curr->m_state = TERM;

	
	auto raw_ptr = curr.get();
	curr.reset(); 
	raw_ptr->yield(); 
}

可以看到,MainFunc比起直接调用m_cb,区别在于后面的运行后处理。由于我们希望协程执行结束后能由yield()来正确交换控制权给另一个协程,每个协程执行完协程函数m_cb后应该确保它调用一次yield

那为什么需要使用raw_ptr而不是智能指针呢?因为如果直接curr->yield() ,那么控制权此时会由于yield转移给另一个协程,可此时curr仍然未被释放,因为它并没有执行到MainFunc的作用域外,必须等待yield返回才会释放

但yield的返回甚至都不是一件一定发生的事,就算会发生那也是swapcontext失败了才会返回,这是因为yield中的swapcontext 在执行成功时是不返回的,直接前往目标上下文再也不会回来了。那么此时curr就无法被合理的释放。因此此处先使用curr.reset() 来重置智能指针,yield交给raw_ptr来完成

resume与yield

resume与yield是fiber类的核心函数,用于协程执行的恢复与暂停,核心依靠ucontext进行上下文转换

resume

代码如下:

void Fiber::resume()
{
	assert(m_state==READY);
	
	m_state = RUNNING;

	//这里的区别是:是一个协程调用了子协程,还是调度器自发地进行协程调度
	//yield和这里情况刚好相反
	if(m_runInScheduler)//此时需要调度器参与调度,目标协程本来运行在调度器中
	{
		SetThis(this);//此处的this是调用fiber.resume()中的fiber
		
		//m_ctx就是目标协程的上下文
		//恢复运行目标协程,并将当前ucontext(也就是scheduler的context)暂存
		//swapcontext成功时不返回,失败时返回-1
		if(swapcontext(&(t_scheduler_fiber->m_ctx), &m_ctx))
		{
			std::cerr << "resume() to t_scheduler_fiber failed\n";
			pthread_exit(NULL);
		}		
	}
	else
	{	//此时不需要调度器参与调度,目标协程本来运行在主协程上下文中
		SetThis(this);
		//和上面基本一样,但是这里暂存的是主协程上下文
		if(swapcontext(&(t_thread_fiber->m_ctx), &m_ctx))
		{
			std::cerr << "resume() to t_thread_fiber failed\n";
			pthread_exit(NULL);
		}	
	}
}

几个核心点:

  • m_runInScheduler 用来标识调用resume或yield的协程是否是调度器协程。换句话说它表明本次resume或yield是否是调度器的行为。如果是,那么它是一次对称协程调度,当前上下文应该保存在t_scheduler_fiber->m_ctx 中;如果不是,那它是一次非对称协程调度,在不存在嵌套协程的前提下,调用它的一定是主协程,当前上下文应该保存在t_thread_fiber->m_ctx

  • 以非对称协程调度为例,此时是调度器决定即将调度一个目标协程,将控制权交给它。那么调用fiber.resume()当前上下文应该是调度器协程的上下文,所以swapcontext的第一个参数是t_scheduler_fiber->m_ctx ,表明当前上下文的暂存位置;第二个参数是m_ctx,表明即将执行的上下文位置。

  • 对称协程调度中逻辑几乎一致,仅仅是当前上下文暂存的位置不同

yield

代码如下:

void Fiber::yield()
{
	assert(m_state==RUNNING || m_state==TERM);

	if(m_state!=TERM)
	{
		m_state = READY;
	}

	if(m_runInScheduler)
	{
		SetThis(t_scheduler_fiber);
		//这里是scheduler迫使协程暂停
		//要把每个协程看成一个fiber对象,m_ctx就是每个协程的上下文
		if(swapcontext(&m_ctx, &(t_scheduler_fiber->m_ctx)))
		{
			std::cerr << "yield() to to t_scheduler_fiber failed\n";
			pthread_exit(NULL);
		}		
	}
	else
	{
		SetThis(t_thread_fiber.get());
		//这个情况相当于子协程要回到主协程
		if(swapcontext(&m_ctx, &(t_thread_fiber->m_ctx)))
		{
			std::cerr << "yield() to t_thread_fiber failed\n";
			pthread_exit(NULL);
		}	
	}	
}

有了resume的经验,这里就是个完全对称的情况。调度器的情况中,此时协程原本是被调度器resume的,那么它yield之后也应该回到调度器协程,于是第二个参数是调度器上下文t_scheduler_fiber->m_ctx 。剩下的完全类似,不再赘述