关于协程调度模块,本人看的一直是有点一知半解,但其大致思路上还是能懂得在做些什么:当有较多的协程时,如何消耗掉这些协程,按照某种思路将写协程全部消耗掉,并且可以进行添加、结束等功能,即一个协程调度的过程。如果大家实现过线程池向来对这个流程是不陌生的,当然sylar的协程调度模块还要分是通过main函数进行协程调度的情况,还是额外创建一个线程进行协程调度等问题,需要详细的探究。
首先参考路强大佬的要给简单的协程调度来探究一下协程调度模块到底在做些什么。参考:从零开始重写sylar C++高性能分布式服务器框架
/**
* @file simple_fiber_scheduler.cc
* @brief 一个简单的协程调度器实现
* @version 0.1
* @date 2021-07-10
*/
#include "../sylar/sylar.h"
sylar::Logger::ptr g_logger = SYLAR_LOG_NAME("system");
/**
* @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 -> GetThis();
std::cout << "Scheduler::run()::first call" << std::endl;
task -> call();
std::cout << "Scheduler::run()::second call" << std::endl;
task -> call();
}
}
private:
/// 任务队列
std::list m_tasks;
};
void test_fiber(int i) {
std::cout << "test_fiber()::hello world " << i << std::endl;
sylar::Fiber::ptr cur = sylar::Fiber::GetThis();
cur->back();
std::cout << "test_fiber()::back fiber " << i << std::endl;
}
int main() {
g_logger->setLevel(sylar::LogLevel::INFO);
/// 初始化当前线程的主协程
sylar::Fiber::GetThis();
/// 创建调度器
Scheduler sc;
/// 添加调度任务
for(auto i = 0; i < 10; i++) {
sylar::Fiber::ptr fiber(new sylar::Fiber(
std::bind(test_fiber, i), 0, true
));
sc.schedule(fiber);
}
/// 执行调度任务
sc.run();
return 0;
}
这里的程序和原始的是有点区别的,因为路强大佬是将协程模块做了重写的,有兴趣的同学可以相信看一下大佬的笔记,本人在学习记录的时候也很多参考了大佬的记录。
上面的协程调度理解还是很简单的,其主要就两个功能:添加协程调度任务、执行协程调度任务。而且由于上述的程序是一个单线程的程序,所以流程也很容易梳理清楚,就是初始化一个协程调度器->添加调度任务->开始调度。当然此处可以对比sylar的"scheduler_test.cc"程序:
#include "../sylar/sylar.h"
static sylar::Logger::ptr g_logger = SYLAR_LOG_ROOT();
void test_fiber() {
static int s_count = 5;
SYLAR_LOG_INFO(g_logger) << "test in fiber s_count=" << s_count;
if(--s_count >= 0) {
sylar::Scheduler::GetThis()->schedule(&test_fiber, sylar::GetThreadId());
}
}
int main(int argc, char** argv) {
g_logger->setLevel(sylar::LogLevel::INFO);
SYLAR_LOG_INFO(g_logger) << "main";
sylar::Scheduler sc(3, false, "test");
// start方法调用后,会创建调度线程池,从任务队列中取任务执行
sc.start();
SYLAR_LOG_INFO(g_logger) << "schedule";
sc.schedule(&test_fiber);
sc.stop();
SYLAR_LOG_INFO(g_logger) << "over";
return 0;
}
对比来看,sylar的协程调度模块大概流程本人这么理解:创建协程调度器->开始协程调度->若无任务,则进入idle协程->通过schedule方法添加调度任务->当有任务进来时,tickle方法通知有新协程->结束。
这里就涉及到了多线程的问题,因为在上面简单的协程调度器中只是单线程,所以流程很好理解,但是涉及到多线程提高协程调度器的效率时,就需要考虑更多(这也是本人一直在困扰的地方,感觉很难理解,尤其是是否是否使用当前调用线程)。
sylar的协程调度器实现的是一个N-M的协程调度器,即N个线程运行M个协程。这里有个问题要注意就是,一个线程在同一时刻是只能运行一个协程的,但是一个线程可以拥有多个协程。下面就大致清理一下整个协程调度模块的设计(尽量按照调度流程清理)。
这里我觉得有一个地方很有趣,假如use_caller=false的时候,其实其构造函数便是如此:
Scheduler::Scheduler(size_t threads, bool use_caller, const std::string& name)
:m_name(name) {
SYLAR_ASSERT(threads > 0);
m_rootThread = -1;
m_threadCount = threads;
}
这个时候应该是有单独的线程来进行协程调度的,而只要将新线程的入口函数作为调度协程,从任务队列中取任务即可,而main函数则是负责添加任务和停止调度器。这个时候按照顺序去看的话,应该对应的是run方法中的这里:
if(sylar::GetThreadId() != m_rootThread) {
t_scheduler_fiber = Fiber::GetThis().get();
}
这里也是本人一直在困扰的地方,此时use_caller是没有参与到调度中的,那么应该是有一个调度协程被创建用于调度,那么这个调度的协程是Fiber()无参构造时创建的fiber么?
抱着这个疑问,我在GetMainFiber处打了个日志:
// 返回当前协程调度器的调度协程
Fiber* Scheduler::GetMainFiber() {
SYLAR_LOG_INFO(g_logger) << "t_scheduler_fiber id = " << t_scheduler_fiber->getId();
return t_scheduler_fiber;
}
随后分别尝试了sylar::Scheduler sc(1, true, "test");
和sylar::Scheduler sc(1, false, "test");
,下面是输出的日志:
sylar::Scheduler sc(1, true, "test");
:
2021-11-30 16:46:43 25231 test 3 [INFO] [system] /home/yunzhi/Sylar/tests/../sylar/scheduler.cc:54 t_scheduler_fiber id = 1
sylar::Scheduler sc(1, false, "test");
:
2021-11-30 16:45:28 25043 test_0 0 [INFO] [system] /home/yunzhi/Sylar/tests/../sylar/scheduler.cc:54 t_scheduler_fiber id = 0
想来是破案了,但心里又觉得还有一层薄雾缠着,一直没有更加深入的理解,后续本人也会多尝试一些断点、日志等方法,将这里梳理得更清晰些。
接着来看如果是use_caller=true,整个构造函数是什么样的:
Scheduler::Scheduler(size_t threads, bool use_caller, const std::string& name)
:m_name(name) {
SYLAR_ASSERT(threads > 0);
sylar::Fiber::GetThis();
// 使用当前调用线程,则此时线程数应减一
--threads;
SYLAR_ASSERT(GetThis() == nullptr);
t_scheduler = this;
// 在user_caller为true的情况下,初始化caller线程的调度协程
// caller线程的调度协程不会被调度器调度,而且,caller线程的调度协程停止时,应该返回caller线程的主协程
m_rootFiber.reset(new Fiber(std::bind(&Scheduler::run, this), 0, true));
sylar::Thread::SetName(m_name);
t_scheduler_fiber = m_rootFiber.get();
m_rootThread = sylar::GetThreadId();
m_threadIds.push_back(m_rootThread);
m_threadCount = threads;
}
这里也能解释为何上面的id是1了:m_rootFiber.reset(new Fiber(std::bind(&Scheduler::run, this), 0, true));
。但此时有要注意一个问题,协程id的不同是可以看出一些问题的,但是还有一点就是是否新建了一个线程,所以此时我针对GetMainFiber又增加了日志:
Fiber* Scheduler::GetMainFiber() {
// SYLAR_LOG_INFO(g_logger) << "t_scheduler_fiber id = " << t_scheduler_fiber->getId();
SYLAR_LOG_INFO(g_logger) << "Now thread is = "<< sylar::Thread::GetName();
return t_scheduler_fiber;
}
下面是输出:
sylar::Scheduler sc(1, true, "test");
:
2021-11-30 17:06:47 26881 test 3 [INFO] [system] /home/yunzhi/Sylar/tests/../sylar/scheduler.cc:54 Now thread is = test
sylar::Scheduler sc(1, false, "test");
:
2021-11-30 17:07:38 27097 test_0 2 [INFO] [system] /home/yunzhi/Sylar/tests/../sylar/scheduler.cc:54 Now thread is = test_0
这里要注意,test_0是新建的线程,可参考m_threads[i].reset(new Thread(std::bind(&Scheduler::run, this), m_name + "_" + std::to_string(i)));
而test则是因为:sylar::Thread::SetName(m_name);
,还是原先的主线程。
还有一点,上述的分析都是本人基于threads=1,的时候进行的调试查看,当threads大于1时,会有其他的现象大家可以多调试看看,本人能力有限感觉也只能解释这么多。
那么当main函数所在的线程来进行调度时,其情况整体流程会较为复杂,大概流程为:主函数主线程运行->主函数创建调度器->主函数向调度器添加任务->开始协程调度,主函数主协程让出执行权->调度协程开始进行调度->调度结束返回主函数主协程->结束任务。
这个方法由于用到了模板,所以放在了头文件中,其大概设计可参考:
// 向调度器添加调度任务,此时调度器并不执行这些任务,而是将其保存到任务队列中
template
void schedule(FiberOrCb fc, int thread = -1) {
bool need_tickle = false;
{
MutexType::Lock lock(m_mutex);
need_tickle = scheduleNoLock(fc, thread);
}
if(need_tickle) {
tickle();
}
}
template
bool scheduleNoLock(FiberOrCb fc, int thread) {
bool need_tickle = m_fibers.empty();
FiberAndThread ft(fc, thread);
if(ft.fiber || ft.cb) {
m_fibers.push_back(ft);
}
return need_tickle;
}
其主要工作一个是向m_fibers添加任务,一个便是tickle(),tickle()在本模块中没有实际使用,其功能是通知协程调度器有任务了,在后面的IO协程调度模块会对其进行重写。
// start方法调用后,创建线程池,线程数量由初始化的线程数和use_caller确定
// 调度线程一旦创建,将立刻从任务队列中取任务执行
// 若use_caller为true,则start方法什么也不做(不需要创建新的线程用于调度)
void Scheduler::start() {
MutexType::Lock lock(m_mutex);
if(!m_stopping) {
return;
}
m_stopping = false;
SYLAR_ASSERT(m_threads.empty());
m_threads.resize(m_threadCount); // 创建线程池
for(size_t i = 0; i < m_threadCount; ++i) { // 开始创建线程
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());
}
lock.unlock();
}
这里会发现,当scheduler初始化的threads为1,且use_caller为true时,start方法只是进行了简单的初始化和判断。否则则是初始化了一个线程池,紧接着就要看run()方法。
run()方法算是一个比较负责且较为核心的方法,其在一个while循环中,不断从任务队列中取任务并且执行。当任务队列为空时,会进入idle协程,进行一个等待,若有新任务,则返回run,若检测到结束,则idle的状态为TERM,随后run方法跳出循环,结束调度。这个函数主要是一些业务逻辑感觉,并没有很难理解的地方,大家根据程序去阅读好。
void Scheduler::idle() {
SYLAR_LOG_INFO(g_logger) << "idle";
while(!stopping()) {
sylar::Fiber::YieldToHold();
}
}
在协程调度模块中的idle协程也是不断地YieldToHold(),这里其实有一点也是个问题,为什么没有使用YieldReady(),这里查看大佬的笔记是说成熟的协程要自己学会调度,而不是等待调度器再做调度,这里可以参考run方法的内容去理解。
在stop()函数中,其判断是否直接停止的前提还是满多的,if(m_rootFiber&& m_threadCount == 0 && (m_rootFiber->getState() == Fiber::TERM || m_rootFiber->getState() == Fiber::INIT))
如果这些都没有完成,最后还需要for(auto& i : thrs) i->join();
也就是要等待所有的调度任务完成才能结束,那么这个时候如果仅有一个caller线程在进行调度的话,其实际的调度也就是在stop()中实现,详细可以结合start()函数进行分析,其当时没有新建一个run的线程,所以没有进入调度任务中,而是最后在stop时,才开始消耗线程池。
u1s1,协程调度本人已经看了好多遍,但是由于自己没有多线程、协程方面的经验而且学习能力也没有很强,所以哪怕是尽可能地做了一些记录、笔记,但还是感觉有很多地方没有理解透彻,甚至是理解有误,如果大家有补充希望大家可以在评论区多交流~让我再学一学0.0。
接下来便是IOManager模块,这里算是结合协程调度有了真正的实用性(在协程调度中idle一直是忙等待,占用CPU),也是本人之前跟着写代码的时候很懵的一部分,也争取自己再进一步理解透彻后做一些笔记记录。
参考:
sylar C++ 高性能服务器(项目地址)
sylar个人主页
从零开始重写sylar C++高性能分布式服务器框架(一位大佬的笔记)