协程库fiber类的核心实现
作为"线程的线程",协程能够将线程的同步任务改造成异步执行,可以实现用户级的并发。最核心的原理就是前面介绍过的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
。剩下的完全类似,不再赘述