协程调度概述
当你有很多协程时,如何把这些协程都消耗掉,这就是协程调度。
在前面的协程模块中,对于每个协程,都需要用户手动调用协程的resume方法将协程运行起来,然后等协程运行结束并返回,再运行下一个协程。这种运行协程的方式其实是用户自己在挑选协程执行,相当于用户在充当调度器,显然不够灵活.
引入协程调度后,则可以先创建一个协程调度器,然后把这些要调度的协程传递给调度器,由调度器负责把这些协程一个一个消耗掉。
从某种程度来看,协程调度其实非常简单,简单到用下面的代码就可以实现一个调度器,这个调度器可以添加调度任务,运行调度任务,并且还是完全公平调度的,先添加的任务先执行,后添加的任务后执行。
/**
* @file simple_fiber_scheduler.cc
* @brief 一个简单的协程调度器实现
* @version 0.1
* @date 2021-07-10
*/
#include "sylar/sylar.h"
/**
* @brief 简单协程调度类,支持添加调度任务以及运行调度任务
*/
class Scheduler {
public:
/**
* @brief 添加协程调度任务
*/
void schedule(sylar::Fiber::ptr task) {
m_tasks.push_back(task);
}
/**
* @brief 执行调度任务
*/
void run() {
sylar::Fiber::ptr task;
auto it = m_tasks.begin();
while(it != m_tasks.end()) {
task = *it;
m_tasks.erase(it++);
task->resume();
}
}
private:
/// 任务队列
std::list<sylar::Fiber::ptr> m_tasks;
};
void test_fiber(int i) {
std::cout << "hello world " << i << std::endl;
}
int main() {
/// 初始化当前线程的主协程
sylar::Fiber::GetThis();
/// 创建调度器
Scheduler sc;
/// 添加调度任务
for(auto i = 0; i < 10; i++) {
sylar::Fiber::ptr fiber(new sylar::Fiber(
std::bind(test_fiber, i)
));
sc.schedule(fiber);
}
/// 执行调度任务
sc.run();
return 0;
}
接下来将从上面这个调度器开始,来分析一些和协程调度器相关的概念。
首先是关于调度任务的定义,对于协程调度器来说,协程当然可以作为调度任务,但实际上,函数也应可以,因为函数也是可执行的对象,调度器应当支持直接调度一个函数。这在代码实现上也很简单,只需要将函数包装成协程即可,协程调度器的实现重点还是以协程为基础。
接下来是多线程,通过前面协程模块的知识我们可以知道,一个线程同一时刻只能运行一个协程,所以,作为协程调度器,势必要用到多线程来提高调度的效率,因为有多个线程就意味着有多个协程可以同时执行,这显然是要好过单线程的。
既然多线程可以提高协程调度的效率,那么,能不能把调度器所在的线程(称为caller线程)也加入进来作为调度线程呢?比如典型地,在main函数中定义的调度器,能不能把main函数所在的线程也用来执行调度任务呢?答案是肯定的,在实现相同调度能力的情况下(指能够同时调度的协程数量),线程数越小,线程切换的开销也就越小,效率就更高一些,所以,调度器所在的线程,也应该支持用来执行调度任务。甚至,调度器完全可以不创建新的线程,而只使用caller线程来进行协程调度,比如只使用main函数所在的线程来进行协程调度。
接下来是调度器如何运行,这里可以简单地认为,调度器创建后,内部首先会创建一个调度线程池,调度开始后,所有调度线程按顺序从任务队列里取任务执行,调度线程数越多,能够同时调度的任务也就越多,当所有任务都调度完后,调度线程就停下来等新的任务进来。
接下来是添加调度任务,添加调度任务的本质就是往调度器的任务队列里塞任务,但是,只添加调度任务是不够的,还应该有一种方式用于通知调度线程有新的任务加进来了,因为调度线程并不一定知道有新任务进来了。当然调度线程也可以不停地轮询有没有新任务,但是这样CPU占用率会很高。
接下来是调度器的停止。调度器应该支持停止调度的功能,以便回收调度线程的资源,只有当所有的调度线程都结束后,调度器才算真正停止。
通过上面的描述,一个协程调度器的大概设计也就出炉了:
调度器内部维护一个任务队列和一个调度线程池。开始调度后,线程池从任务队列里按顺序取任务执行。调度线程可以包含caller线程。当全部任务都执行完了,线程池停止调度,等新的任务进来。添加新任务后,通知线程池有新的任务进来了,线程池重新开始运行调度。停止调度时,各调度线程退出,调度器停止工作。
调度协程切换问题
这里分两种典型情况来讨论一下调度协程的切换情况,其他情况可以看成以下两种情况的组合,原理是一样的。
-
线程数为1,且use_caller为true,对应只使用main函数线程进行协程调度的情况。
-
线程数为1,且use_caller为false,对应额外创建一个线程进行协程调度、main函数线程不参与调度的情况。
这里先说情况2。情况2比较好理解,因为有单独的线程用于协程调度,那只需要让新线程的入口函数作为调度协程,从任务队列里取任务执行就行了,main函数与调度协程完全不相关,main函数只需要向调度器添加任务,然后在适当的时机停止调度器即可。当调度器停止时,main函数要等待调度线程结束后再退出,参考下面的图示:
情况1则比较复杂,因为没有额外的线程进行协程调度,那只能用main函数所在的线程来进行调度,而梳理一下main函数线程要运行的协程,会发现有以下三类协程:
-
main函数对应的主协程
-
调度协程
-
待调度的任务协程
在main函数线程里这三类协程运行的顺序是这样的:
-
main函数主协程运行,创建调度器
-
仍然是main函数主协程运行,向调度器添加一些调度任务
-
开始协程调度,main函数主协程让出执行权,切换到调度协程,调度协程从任务队列里按顺序执行所有的任务
-
每次执行一个任务,调度协程都要让出执行权,再切到该任务的协程里去执行,任务执行结束后,还要再切回调度协程,继续下一个任务的调度
-
所有任务都执行完后,调度协程还要让出执行权并切回main函数主协程,以保证程序能顺利结束。
上面的过程也可以总结为:main函数先攒下一波协程,然后切到调度协程里去执行,等把这些协程都消耗完后,再从调度协程切回来,像下面这样: