sylar源码阅读笔记-协程调度模块详解-有图方便理解(8000字长文)

相信大家在听sylar讲协程调度模块时,听完肯定蒙蒙的状态,本人也是。比如有下列疑问:

协程调度是干什么的?能起到什么作用?

use_caller到底指什么意思?为什么感觉那么复杂?

使用caller线程进行调度时,和其他线程调度有什么不同,为什么要区分对待?

每个线程里面有很多协程,怎么样能让协程在线程之间切换?

m_autoStop和m_stopping是什么意思?

添加的调度任务,是指定给那个协程的?

本文记录本文学习过程中的一些理解,当然也参考了很多博客,如:协程调度模块 - 类库与框架 - 程序员的自我修养 (midlane.top),大佬写的非常好,自己结合理解画了一些流程图,也记录了自己的一些理解,希望对大家有帮助,不对的地方请多指正!

个人感觉先把协程调度的大体流程讲清楚,然后在具体讲其中的细节。

协程调度想实现什么功能

之前《操作系统》学过进程调度,指的是给进程分配处理机,进程调度的策略有先来先服务、短作业优先、时间片轮转等。协程调度也类似,就是给协程分配处理机,不过目前协程调度模块分配处理机的策略是先来先服务。任务队列中有很多任务(协程),调度器将任务队列中的各个任务协程,分配给各个线程中进行执行,这就是协程调度

大体是下面这个流程(单线程情况下):
大体流程

详细说明
任务

在sylar实现的协程调度模块中,任务可以是一个协程也可以是一个函数,具体实现中是将两者其封装到FiberAndThread中。

struct FiberAndThread
{
    Fiber::ptr fiber;         // 协程
    std::function<void()> cb; // 任务要执行函数(具体使用是也是将函数封装到协程里)
    int thread;               // 线程id,可以指定将该任务运行到那个线程上,若不指定则所有线程均可以执行该任务
};
任务队列

存放多个任务,具体实现中存到链表std::list<FiberAndThread> m_fibers中,每次取任务时从链表头部开始遍历寻找可以运行的任务。每次添加任务的时候(schedule方法),将任务添加到链表尾部。(所以调度策略是先来先服务)

调度器

调度器的作用就是从任务队列中取出一个任务,然后交给线程中的任务协程处理。

线程池

因为一个线程同时只能运行一个协程,所以想要提高效率,就必须要用到多线程,sylar实现中维护了一个线程池std::vector<Thread::ptr> m_threads,多个协程在不同的线程上同时运行,线程的数量可在实例化调度器时执行。

因为用到多线程,所以必须要用到锁m_mutex

run方法

该方法的作用是循环遍历任务队列m_fibers,然后从中找到可以指定的任务,然后交给某一个线程中的协程处理。(此处省略的具体的细节,大体知道run方法的作用即可,后续会详细讨论)

关于是否使用caller线程

caller线程为调度器所在的线程,如果use_caller=true,则表示将调度器所在的线程也用于任务调度,这样在实现相同调度能力的情况下(指能够同时调度的协程数量),线程数越少,线程切换的开销也就越小,效率更高一些。(使用caller线程进行调度就会少开一个线程),是否使用caller线程,对应的处理方式也不同,这里也是比较难理解的地方。

线程创建、线程内创建协程的理解

我们知道协程是轻量化的线程,一个线程可以包含多个协程,但同时只能运行一个协程,既然线程包含协程,那么肯定是先创建线程然后在线程内创建协程,个人理解有两种方式。

例子一:先创建线程,在线程绑定的执行函数内部再创建协程。

// 新建一个线程,名称为test,执行函数为test_fiber
sylar::Thread::ptr(new sylar::Thread(&test_fiber, "test");
          
// 函数test_fiber具体如下;在test_fiber内部创建协程
void test_fiber()
{
    // 一个线程创建后,其执行函数内首先调用GetThis方法,创建一个主协程,用于协程切换
    sylar::Fiber::GetThis();
    // 创建一个子协程,执行函数为run_in_fiber
    sylar::Fiber::ptr fiber(new sylar::Fiber(run_in_fiber));
    fiber->swapIn();
    // ...
}

void run_in_fiber()
{
    // ...
}

例子二:运行main函数创建线程。假如我们有一个test.cc文件,里面有main函数,现在我们运行main函数,相当于开启的一个线程(记为main线程),则如果想在main线程里创建协程,则直接创建就好了。(main函数本身相当于是main线程的执行函数)。

// test.cc
int main(int argc, char **argv)
{
    // 调用GetThis方法,创建一个主协程,用于协程切换
    sylar::Fiber::GetThis();
	// ...
    return 0;
}

我介绍该部分,主要是想说明caller线程就相当于上述例子中运行test.cc中main函数得到的线程,非caller线程是通过new Thread的方式创建线程的。

在sylar的实现中,对于caller线程,创建协程时直接调用GetThis()方法即可,而对于非caller线程,也就是线程池里的线程,需要先创建线程(绑定run方法),然后在run方法中创建协程。在代码里你可以详细看到,明白这一点,有助于后面的理解。

任务协程、调度协程、主协程的概念

任务协程:就是我们要运行的任务,可以理解为任务队列中的单个任务。

调度协程(t_scheduler_fiber):在每一个线程中,都会有一个调度协程,调度协程负责从任务队列中取任务,然后调度协程让出执行权,运行任务协程,运行结束后再回到调度协程,如下图所示:
调度协程
主协程(t_threadFiber):在之前的协程模块中,我们已经知道,对于每一个线程都有一个主协程,主协程用于和任务协程(子协程)进行切换,因为实现的是非对称协程,只能通过主协程和任务协程(子协程)进行切换(是否使用caller协程对应主协程任务也不同,后面会讨论)。

有同学可能就有疑问了

疑问一:调度协程和主协程不是一个东西吗?为什么非要再声明一个调度协程?直接用主协程不就行了吗?

疑问二:调度协程要和任务协程(子协程)切换,另外只能通过主协程和任务协程(子协程)进行切换,所以调度协程就是主协程?

其实是这样的,刚刚说了调度协程负责从任务队列中取任务,这就意味着我必然要给调度协程设置一个执行函数啊。

但是!但是!

在协程类设计时,主协程创建时,并没有给主协程分配栈,也没有给主协程设置执行函数(具体可见Fiber()构造函数),只用于协程之间的切换,所以就导致主协程不能当作调度协程,因为调度协程需要绑定执行run方法,不断从任务队列中取任务,而主协程又不能设置执行函数。所以调度协程和主协程不是一个东西。

接着听我往下讲。记住重点:调度协程需要绑定执行run方法

caller线程和非caller线程的实现区别理解

caller线程:创建调度器的线程,我们在main函数创建了调度器,该caller线程的执行函数就是main函数(对应上边的例子二),如果想使用caller线程进行协程调度,那就需要创建一个调度协程且绑定执行run方法,才能参与协程调度。代码如下:

if (use_caller)
{
    // 如果主协程不存在,内部会创建主协程
    sylar::Fiber::GetThis();
    // 线程池可用数量-1
    --threads;

    SYLAR_ASSERT(GetThis() == nullptr);
    t_scheduler = this;
    // m_rootFiber为caller线程中的调度协程,协程绑定run方法
    m_rootFiber.reset(new Fiber(std::bind(&Scheduler::run, this), 0, true));
	// ...
}

非caller线程:在创建完调度器,调用start方法时会创建线程池,线程池里的线程就是非caller线程,每个非caller线程指定执行函数为run。(对应上边例子一),注意这里run方法绑定到了线程上了,不是协程,在线程上执行run方法不断从任务队列取任务,所以就不用再创建一个调度协程绑定执行run方法了

for (size_t i = 0; i < m_threadCount; ++i)
{
    // 线程的执行函数该Scheduler的run方法
    // new Thread()方法内部会创建一个线程执行
    m_threads[i].reset(new Thread(std::bind(&Scheduler::run, this), m_name + "_" + std::to_string(i)));
    m_threadIds.push_back(m_threads[i]->getId());
}

总结就是:

对于caller线程,run方法绑定到了一个子协程上(就是调度协程t_scheduler_fiber

对于非caller线程,run方法绑定到了线程上,不断从任务队列寻找任务的事情交给了线程,所以就不需要调度协程了,具体实现中,为了复用代码,调度协程就是的主协程。t_scheduler_fiber等于t_threadFiber,仅用于协程之间切换。代码如下:

void Scheduler::run()
{
    // ...
    // m_rootThread为caller线程的id
    if (sylar::GetThreadId() != m_rootThread)
    {
        // 当前线程如果不是caller线程,需要获取当前线程的调度线程
        SYLAR_LOG_DEBUG(g_logger) << "run(): sylar::GetThreadId() != m_rootThread";

        // 内部如果没有主协程,则会创建一个主协程,所以调度协程就是主协程
        t_scheduler_fiber = Fiber::GetThis().get();
    }
    // ...
}
idle协程

假设当线程A没有任务可以做,且整个协程调度还没结束(就是说虽然线程A没任务了,但是其他的线程还有任务正在执行,调度还没结束),此时会让线程A执行idle协程(也是一个子协程),idle协程内部循环判断协程调度是否停止。

如果未停止,则将idle协程置为HOLD状态,让出执行权,继续运行run方法内的while循环,从任务队列取任务。(属于忙等状态,CPU占用率爆炸)

如果已经停止,则idle协程执行完毕,将idle协程状态置为TERM,协程调度结束。

伪代码如下:

void Scheduler::idle()
{
    while (!stopping())
    {
    // 当stopping()为false,也就是调度还没停止,还有活跃的线程,将idle进程挂起,状态转为Hold
    sylar::Fiber::YieldToHold();
    }
    // 当stopping()为true,没有任务要执行了,执行完idle,状态置为TERM
}

void Scheduler::run()
{
    // 前置操作..
    // 没有任务时执行的协程,默认状态为INIT
    Fiber::ptr idle_fiber(new Fiber(std::bind(&Scheduler::idle, this)));

    // ... 定义必要的变量

    while (true)
    {
        // 从任务队列里寻找任务
        if (找到任务)
        {
            // 执行任务
        }
        else
        {
            // 未找到任务
			
            // idle的状态为结束TERM(当所有协程调度任务完成)
            if (idle_fiber->getState() == Fiber::TERM) 
            {
                // 执行结束,while循环的唯一退出条件
                SYLAR_LOG_INFO(g_logger) << "idle fiber term";
                break;
            }

            // 没有任务可以执行,则执行idle协程
            idle_fiber->swapIn();
        }
    }
}

整体流程图:
整理流程图

协程之间的切换问题

参考:协程调度模块 - 类库与框架 - 程序员的自我修养 (midlane.top)

考虑两种情况:

情况一:use_caller=false(调度器所在线程不参与调度),仅有一个线程。会创建一个调度协程来进行协程调度,主协程就是调度协程。

情况二:use_caller=true(调度器所在线程参与调度),仅有一个线程,则不会创建其他线程,仅有caller线程进行调度。


对于情况一,不使用caller线程,新建一个线程,将新线程执行函数绑定为run(),线程负责从任务队列中取任务,进行协程调度。caller线程只需要向任务队列中添加任务即可。当调度器停止时,caller线程等待调度线程中所有协程执行完毕在退出,过程如下图所示。

非caller协程

对于情况二,流程大致如下

创建调度器,初始化调度器时创建主协程t_threadFiber

创建调度协程t_scheduler_fiber(m_rootFiber),绑定run方法。

start()方法开启协程调度(因为只有caller线程,内部实际什么都没做)。

schedule()方法向任务队列中添加任务。

stop()方法停止调度,内部会检测到调度协程t_scheduler_fiber(m_rootFiber)不为空,执行m_rootFiber->call();,主协程让出执行权,切换到调度协程执行,从任务队列取任务执行。

每次执行任务,调度协程都要让出执行权,去执行任务协程,任务执行结束后,切换到调度协程,继续下一个任务调度。

让所有任务执行完成,调度协程要让出执行权回到主协程,以保证程序正常结束。

过程如下:

caller协程


实现细节
大体流程

调度器创建:内部首先创建一个调度线程池,调度开始后,所有调度线程按顺序从任务队列里取任务执行,调度线程数越多,能够同时调度的任务也就越多,当所有任务都调度完后,调度线程就停下来等新的任务进来。

开启调度:调用start方法后会创建线程池,调度线程一旦创建,就会从任务队列中取任务执行。

调度协程负责从调度器的任务队列中取任务执行。取出的任务即子协程,这里调度协程和子协程的切换模型即为前一章介绍的非对称模型,每个子协程执行完后都必须返回调度协程,由调度协程重新从任务队列中取新的协程并执行。如果任务队列空了,那么调度协程会切换到一个idle协程,这个idle协程什么也不做,等有新任务进来时,idle协程才会退出并回到调度协程,重新开始下一轮调度。

添加调度任务:往调度器的任务队列添加任务,可以执行任务放到那个线程上执行,但是,只添加调度任务是不够的,还应该有一种方式用于通知调度线程有新的任务加进来了(tickle()),实际该方法啥也没做,因为调度线程并不一定知道有新任务进来了。当然调度线程也可以不停地轮询有没有新任务,但是这样CPU占用率会很高(sylar实现就是不断轮询,如何实现通知调度协程任务来了,观察者模式?)。

调度器停止:停止调度,当所有的调度线程都结束后(join阻塞等待),调度器才算真正停止。

调度器停止条件
bool Scheduler::stopping()
{
    MutexType::Lock lock(m_mutex);
    return m_autoStop && m_stopping && m_fibers.empty() && m_activeThreadCount == 0;
}
// m_autoStop=true         调用stop方法后将m_autoStop置为true;感觉可以理解为是否调用了stop方法,true表示调用了
// m_stopping=true         是否正在停止
// m_fibers.empty()==true  任务队列为空
// m_activeThreadCount == 0  正在执行任务的线程数为0
// 均满足,返回true
void Scheduler::stop()
{
    m_autoStop = true;
    if (m_rootFiber && m_threadCount == 0 && (m_rootFiber->getState() == Fiber::TERM || m_rootFiber->getState() == Fiber::INIT))
    {
        SYLAR_LOG_INFO(g_logger) << this << " stopped";
        m_stopping = true;

        if (stopping())
        {
        return;
        }
	}

    if (m_rootThread != -1)
    {
        // 未使用caller线程
        SYLAR_ASSERT(GetThis() == this);
    }
    else
    {
        // 使用caller线程
        SYLAR_ASSERT(GetThis() != this);
    }

    m_stopping = true;
    for (size_t i = 0; i < m_threadCount; ++i)
    {
        // 通知还有任务,其实啥也没做
        tickle();
    }

    if (m_rootFiber)
    {
        tickle();
    }

    if (m_rootFiber)
    {
        // 如果调度器只使用了caller线程来调度,那caller调度器真正开始执行调度的位置就是这个stop方法
        // 如果使用了caller线程,需要将caller线程再执行一下
        if (!stopping())
        {
        m_rootFiber->call();
        }
    }

    std::vector<Thread::ptr> thrs;
    {
        // 遍历线程池
        MutexType::Lock lock(m_mutex);
        thrs.swap(m_threads);
    }

    for (auto &i : thrs)
    {
        // 阻塞等待所有线程执行完毕,才能停止
        i->join();
    }
}
其他注意事项

遍历任务队列时,需要判断任务的状态和线程id(如判断该任务协程自己是否能执行,任务协程是否正在运行)。

任务协程让出执行权后,需要判断任务协程的状态,若未执行完毕,需要重新假如到任务队列中(这里可能需要改进,因为协程未执行完毕,需要调度器来将未执行完的协程重新放入任务队列中,如果说协程自己把自己放入任务队列,然后再释放执行权会比较好,好的协程要学会自己管理自己)。

让出执行权前,活跃线程数+1,回来之后,活跃线程数-1;

检测是否使用了caller线程进行调度,如果使用了caller线程进行调度,那要保证stop方法是由caller线程发起的。

如果使用caller协程,即use_caller=true,对于caller协程,内部实现中绑定的是CallerMainFunc方法,执行完函数后,内部调用back切换到主线程。非caller协程,内部实现中绑定MainFunc,执行完函数后,内部调用swapOut切换回调度协程。

疑问 and 存在问题:

任务队列空闲时,调度协程进入idle协程,忙等待的问题,实际并不实用。代码中看到有方法tickle()其作用是通知线程有任务来了,感觉当一个线程空闲时,不要让其处于忙等状态,可以阻塞再idle协程上,然后有任务来时调用tickle()通知线程,该如何实现?一个协程阻塞会不会导致整个线程阻塞?

目前我的理解来看,有若干任务放到任务队列里,然后有多个线程同时从任务队列取任务,放到各自协程中执行(目前看使用的策略是先来先服务),类似于进程调度或者线程调度,我不理解的地方是协程调度相比与使用进程、线程调度有优势在那里?或者有没有一种实际情况用协程处理比较合适(之前听过IO多路复用但是还不理解)

m_stopping表达的意思是“是否正在停止”,值为true表示正在停止,值为false表示不是正在停止。sylar中部分代码true、false好像写反了。

  • 6
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值