在以sudoku服务器分析muduo的的multiReactor+业务线程池模型运行流程之前,我们首先要知道什么是multiReactor+业务线程池模型(这种模型又称为one loop per thread +线程池模型),请看下面这幅图。
所谓one loop per thread,就是每个loop循环对应一个IO线程,每个IO线程中有一个EventLoop对象,负责处理IO事件。一个IO线程对应一个Reactor。muduo库支持多IO线程,也即multiple reactors,这样当有突发IO时,多IO线程能够及时响应客户端。我们可以这样理解,每一个reactor对应的IO线程主要是用来处理IO事件的(当然了,当IO事件较少时,其也可以执行一些计算线程干的计算任务),当IO事件比较密集时,如果只有一个IO线程,也即只有一个mainReactor,没有subReactor,那么所有的IO事件都得mainReactor来处理,会出现响应不及时的情况。而multiple reactors模式下,其由一个mainReactor和多个subReactor,这些Reactor都是主要用来处理IO事件的,其中mainReactor主要用来处理客户端的连接请求,也就是处理监听套接字上的事件。每来一个请求,mainReactor负责accept,接受完了会得到已连接套接字,然后就交由subReactor,让他们去处理已连接套接字上的事件,即subReactor处理的是客户端和服务器之间的具体的通信,其负责读取客户端发送过来的数据和将处理后的数据结果发回给客户端。
线程池是指业务线程池,也可以称为计算线程池。在业务线程池中,一般不设计IO操作,主要用来处理业务逻辑计算。即subReactor对应的IO线程将客户端的数据读取过来之后,就交由业务线程池中的线程负责解码、计算、编码,编码好之后再交给subReactor对应的IO线程负责将结果发送给客户端。计算线程池中的线程负责计算任务,不负责IO操作。这样做有什么好处呢,我们假设没有业务线程池,那么所有的解码、计算、编码任务都得在IO线程中操作,那么如果客户端发过来的是一个计算量很大的任务的话,那么IO线程在计算时就会耗费大量时间,其要执行完计算任务后才能回到poll或者epoll_wait处继续监听IO事件,这样的话势必造成响应延迟。
所以one loop per thread + 线程池,也可以称作multiple reactors +业务线程池的模式,可以使得线程之间分工明确,各司其职,IO线程一般只负责处理IO事件,计算线程只处理与计算相关的任务。这种方案既可以适应突发IO,也可以适应突发计算的应用场合。当然了,如果是计算任务量特别大,而IO事件很少的场合,一般我们会让IO线程也执行一些计算任务,否则IO线程就一直阻塞在那边,效率较低。我相信结合上面这幅图以及我上面的分析我们应该会对该模型有着比较清晰的理解。
下面就以sudoku服务器的代码来看一下这种模型在muduo库下的具体运行流程吧,这里我先假设大家之前已经看过我的另一篇博客。https://blog.csdn.net/lovebasamessi/article/details/104611288也就是基于muduo的单IO线程的echo服务器的运行流程讲解。本篇博客重点关注多IO线程以及业务线程池的运行流程。
sudoku服务器,即数独服务器,是对数独问题进行求解的服务器,数独问题的计算量比较大,如果一个服务器处理的IO操作很多,那么就不太适合放在IO线程中进行计算求解,而应该放在计算线程池中。而如果该服务器同时有多个客户的连接请求或通信,则说明IO操作很多,那么就不太适合单IO线程,而是比较适合多IO线程,即multiple reactors模式,让mainReactor对应的IO线程去处理客户端的连接请求,subReactor对应的IO线程处理客户端与服务器间的具体通信,这样就不容易造成响应的延迟。
muduo给的示例代码中,给了一个单IO线程+业务线程池的示例以及多IO线程无业务线程池的示例,为了方便在一个代码中展示multiple reactors +业务线程池模型,我把它们整合了一下,使之成为一个工作于multiple reactors +业务线程池模式下的sudoku服务器。代码如下,这里只给了main.cc的代码,没有给sudoku的具体如何求解的代码,我认为不影响具体的分析,因为具体求解的代码其实被一个solveSudoku函数封装了。
main.cc
#include "sudoku.h"
#include "muduo/base/Atomic.h"
#include "muduo/base/Logging.h"
#include "muduo/base/Thread.h"
#include "muduo/base/ThreadPool.h"
#include "muduo/net/EventLoop.h"
#include "muduo/net/InetAddress.h"
#include "muduo/net/TcpServer.h"
#include
#include
#include
using namespace muduo;
using namespace muduo::net;
#define ComputeThreadNum 3
#define IOThreadNum 2
class SudokuServer
{
public:
SudokuServer(EventLoop* loop, const InetAddress& listenAddr, int ComputenumThreads,int IOnumThreads)
: server_(loop, listenAddr, "SudokuServer"),
ComputenumThreads_(ComputenumThreads),
startTime_(Timestamp::now())
{
server_.setConnectionCallback(
std::bind(&SudokuServer::onConnection, this, _1));
server_.setMessageCallback(
std::bind(&SudokuServer::onMessage, this, _1, _2, _3));
server_.setThreadNum(IOnumThreads);
}
void start()
{
LOG_INFO << "starting " << ComputenumThreads_ << " Computethreads.";
threadPool_.start(ComputenumThreads_);
server_.start();
}
private:
void onConnection(const TcpConnectionPtr& conn)
{
LOG_TRACE << conn->peerAddress().toIpPort() << " -> "
<< conn->localAddress().toIpPort() << " is "
<< (conn->connected() ? "UP" : "DOWN");
}
void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp)
{
LOG_DEBUG << conn->name();
LOG_INFO << "IOThreadId: " << CurrentThread::tid();
size_t len = buf->readableBytes();
while (len >= kCells + 2)
{
const char* crlf = buf->findCRLF();
if (crlf)
{
string request(buf->peek(), crlf);
buf->retrieveUntil(crlf + 2);
len = buf->readableBytes();
if (!processRequest(conn, request))
{
conn->send("Bad Request!\r\n");
conn->shutdown();
break;
}
}
else if (len > 100) // id + ":" + kCells + "\r\n"
{
conn->send("Id too long!\r\n");
conn->shutdown();
break;
}
else
{
break;
}
}
}
bool processRequest(const TcpConnectionPtr& conn, const string& request)
{
string id;
string puzzle;
bool goodRequest = true;
string::const_iterator colon = find(request.begin(), request.end(), ':');
if (colon != request.end())
{
id.assign(request.begin(), colon);
puzzle.assign(colon+1, request.end());
}
else
{
puzzle = request;
}
if (puzzle.size() == implicit_cast(kCells))
{
threadPool_.run(std::bind(&solve, conn, puzzle, id));
}
else
{
goodRequest = false;
}
return goodRequest;
}
static void solve(const TcpConnectionPtr& conn,
const string& puzzle,
const string& id)
{
LOG_DEBUG << conn->name();
LOG_INFO << "ComputeThreadId: " << CurrentThread::tid();
string result = solveSudoku(puzzle);
if (id.empty())
{
conn->send(result+"\r\n");
}
else
{
conn->send(id+":"+result+"\r\n");
}
}
TcpServer server_;
ThreadPool threadPool_;
int ComputenumThreads_;
Timestamp startTime_;
};
int main(int argc, char* argv[])
{
LOG_INFO << "pid = " << getpid() << ", tid = " << CurrentThread::tid();
int numThreadsOfCompute = ComputeThreadNum;
int numThreadsOfIO=IOThreadNum;
EventLoop loop;
InetAddress listenAddr(9981);
SudokuServer server(&loop, listenAddr, numThreadsOfCompute,numThreadsOfIO);
server.start();
loop.loop();
}
首先,我们来分析一下多IO线程模型。其实在muduo上使服务器工作于多IO线程模式,其与普通的单IO线程的服务器相比,程序只有一个地方不同,那就是在服务器的构造函数中要调用TcpServer类的成员函数setThreadNum设置一下IO线程的数量即可,另外注意,这里设置的数量是指subReactor的数量,即不包括主IO线程对应的mainReactor。
我们来看看具体它是如何一步步调用,使之能够工作在多IO线程模式下的。首先我们看到SudokuServer的构造函数体内调用了
server_.setThreadNum(IOnumThreads);
设置了IO线程数量,其实这个时候IO线程还没有被创建出来。
接下来,我们注意到在main函数中执行了server.start()函数,该函数如下所示
我们先来关注一下server_.start()这句,也就是执行了TcpServer类的start成员函数。
注意到,这里执行了IO线程池的start函数,即EventLoopThreadPool的start函数。
我们来仔细分析EventLoopThreadPool的start函数,首先我们看到,在该函数里创建了numThreads_个EventLoopThread对象,EventLoopThread类其实是对IO线程的封装。我们可以理解为一个EventLoopThread对象对应一个IO线程。但是应当注意,并不是调用了EventLoopThread的构造函数创建了EventLoopThread就相当于创建了一个IO线程。其实,我们必须要调用EventLoopThread的startLoop函数才能创建一个IO线程,这点我们接下来再说。为了深入了解,我们先来看看EventLoopThread的构造函数。
发现这个构造函数主要是提前绑定了线程运行函数,其在构造函数执行完毕时,其实际上还并没有创建出IO线程。
那我们就回到EventLoopThreadPool::start函数继续往下看,我们发现接下来其把创建的EventLoopThread的智能指针添加到了一个容器中,thread_是一个vector容器(见下图EventLoopThreadPool类的成员),专门存放EventLoopThread的智能指针,我们可以把它理解为封装的IO线程池列表。
接下来,重点来了。EventLoopThreadPool的start函数执行EventLoopThread的startLoop()函数,并将返回的结果存放到loop_容器中(实际上,它返回的是一个EventLoop的指针,可想而知,在这其中,一定有EventLoop对象被创建),从上图可以看出,EventLoopThreadPool的loop_成员存放的是EventLoop的指针,我们可以把它理解为具体的IO线程池列表。我们来看看EventLoopThread的startLoop()函数。
startLoop函数中,调用了 thread_.start()。thread_是EventLoopThread的一个Thread类对象,也就是线程类对象,我们来看看Thread::start()
发现这个里面新建了一个ThreadData类对象,其实这个类对象主要就是给新创建的线程的回调函数传一些参数,可以理解为线程回调函数的参数的封装。接下来,这里调用了pthread_create真正创建了一个线程,也就是说,这个创建出来的线程现在就可以去执行回调函数了,即detail::startThread。
这又嵌套调用了ThreadData类的runInThread函数
最终我们发现,其调用了func_。what???这个func_又是啥??别着急,慢慢来!
我们看到,func_其实是Thread的成员,在Thread的构造函数被初始化,随后在Thread::start()函数中被传递给了ThreadData对象,这才使得ThreadData类的成员函数runInThread能够执行func_。那么就要追本溯源了,看看之前的步骤哪块调用了Thread的构造函数。我们发现,在EventLoopThread类的构造函数中调用了Thread的构造函数,其Thread类型的成员thread_初始化时绑定了EventLoopThread::threadFunc函数,因而在runInThread执行的是EventLoopThread::threadFunc函数。
我们看到,在EventLoopThread::threadFunc函数中创建了一个EventLoop的栈上对象,并让EventLoopThread的指针成员loop_指向这个栈上对象,然后唤醒阻塞在条件变量cond_上的线程,接下来自己就去执行loop循环了。
也就是说,当我们在EventLoopThread的startLoop()函数执行thread_.start()时,相当于内部创建了一个新的线程,并且我们在该线程的回调函数也就是EventLoopThread::threadFunc中创建了一个栈上的EventLoop对象然后就开启事件循环了。应当注意到,此时EventLoopThread::threadFunc与EventLoopThread::startLoop的后续代码是并发执行的,因为这两个函数分别在不同的线程中执行。
让我们接着EventLoopThread的startLoop()函数继续往下看,我们发现,该线程一直阻塞在条件变量cond_上,其实也就是等待EventLoopThread::threadFunc创建EventLoop对象后发起通知,等收到通知后,说明EventLoop对象已经创建好,然后就返回了指向这个栈上的EventLoop的指针。
现在我们终于可以回到EventLoopThreadPool的start函数了,至此,我们应该发现,IO线程池已经被创建,并且IO线程池中的IO线程已经开启了事件循环。根据我们之前对EchoServer的分析,以后只要有新连接来了,就会在TcpServer::newConnection函数中调用getNextLoop函数,即Round-Robin轮询算法为每一个TcpConection分配一个IO线程。
接下来,我们来看看计算线程池的运行过程。在此之前,我们有必要了解一下计算线程池,其本质上就是一个生产者消费者模型,当客户端发送数据过来,此时IO线程把数据读到的数据以任务的方式添加到计算线程池的任务队列,IO线程充当生产者的角色,此时阻塞在任务队列上的计算线程就会被唤醒从而从任务队列中取出任务去执行,计算线程充当消费者的角色。因此我们有必要来看一下线程池类ThreadPool的数据成员。
接下来我们来分析具体的运行流程。我们注意到在main函数中执行了server.start()函数,该函数如下所示
其中,threadPool_.start(ComputenumThreads_)其实就是开启了计算线程池,其中threadPool_是一个ThreadPool类对象。我们来看看一步步是怎么运行的。先来看看上述函数中调用的ThreadPool::start()函数吧!
首先我们要知道thread_是ThreadPool类的一个数据成员,是一个存放Thread的智能指针的vector容器。可以看到,在该函数中,依次循环创建了numThreads(也就是我们设置的计算线程池的个数)个线程Thread对象,并将得到的指向Thread的指针添加到vector容器中,然后执行 threads_[i]->start()函数,其实也就是Thread::start函数,这个我们在上文已经分析过,也就是说,其实这个时候计算线程池中的线程才真正的被创建,而不是在创建Thread类对象的时候被创建,这点千万要注意。
新开的计算线程会执行detail::startThread函数,最终其实执行的是Thread类创建的时候绑定的函数(怎么执行到绑定的函数的过程,在上面的多IO线程中讲过了,这里不再重复),也就是在ThreadPool::start函数中设置绑定的ThreadPool::runInThread。我们来看看
如果每个计算线程设定了threadInitCallback_函数,就先去执行它。这个其实就是每个线程在执行任务前回调的一个函数,视具体情况可有可无。下面的关键来了,其首先调用take()函数获取了一个任务,我们来看看该函数
其实这就是前文所说的生产者消费者模型中的取任务函数。可以看到,当任务队列如果为空的话,线程就会阻塞等待,一旦有任务添加进任务队列,那么阻塞在wait上的线程就会被唤醒,从来取出了任务。
所以上文中的ThreadPool::runInThread其实就是对取出的任务做执行。至此,我们还没有看到任务什么时候被添加进来的。联想上文所说的生产者消费者模型,可想而知,一定是IO线程生产出来的。再仔细想一想,应该是在TcpConnection所在的线程的消息处理函数中被添加进来的,因为在消息处理函数中,必然会读取客户端的数据,然后交由计算线程处理。而消息处理函数就是我们在main.cc文件中设置的,即onMessage函数。
可以看到,在消息处理函数中,对Buffer中的数据进行了读取,也就是我们数独问题的字符串,然后就调用了processRequest函数。
我们看到,processRequest函数在这里先对发过来的字符串进行了解码,然后把接码后的数据封装成slove任务添加到任务队列。应当注意到,在这里,解码任务是在TcpConnection所在的IO线程中执行的,这与本文最上面的示意图中在计算线程中解码、计算、编码稍有不同,其实我们完全可以直接把数据交由计算线程进行解码、计算、编码等。
我们重点来关注一下上图中的threadPool_.run(std::bind(&solve, conn, puzzle, id))这一句,其实ThreadPool::run才是往任务队列添加任务的函数。
我们看到,在该函数中,其会把任务添加到任务队列queue_中(这里的任务就是slove函数),然后通知计算线程来执行,也就是阻塞在take函数上的计算线程。我们来看看具体的任务函数slove,在main.cc中设置。
可以看到,在slove函数中其调用了具体的求解数独问题的solveSudoku求解得到结果,然后让conn所对应的IO线程发送执行结果给客户端。特别应该注意这里各个函数运行时所在的IO线程,其中onMessage、processRequest是在分配的IO线程中执行的,也就是subReactor对应的IO线程,而slove函数是在计算线程中执行的,在slove函数中,我们发现其把结果发回给了客户端,特别需要注意,这里发结果给客户端并不是计算线程干的活,而是IO线程干的活。因为这里的send函数是支持跨线程调用的,,其实是计算线程在自己的线程中跨线程调用了send函数,通知conn所对应的IO线程来发送的。
看到了吗,send函数里判断了是否是本IO线程,并且调用了runInLoop函数,我们知道,一般内部包含runInLoop的函数都是支持跨线程调用的。好了,计算线程池的运行流程到这边就分析得差不多了。
下面贴一张图看看上面的multiReactor+业务线程池模型下的sudoku服务器的运行调度情况。
在代码中,我们设置了3个计算线程和2个IO线程,实际上还有一个主IO线程。上述测试,一共测试了4个客户端同时与服务器连接的情况。从上图中,我们可以看出主IO线程ID为8081,每次客户端向服务器发起连接请求建立新连接,都是8081对应的主IO线程进行处理的。当服务器与第1个客户端通信时,我们看到TcpConnection对应的IO线程ID为8085,计算线程为8082。与第2个客户端通信时,我们看到TcpConnection对应的IO线程ID为8086,计算线程为8083。与第3个客户端通信时,我们看到TcpConnection对应的IO线程ID为8085,计算线程为8084。与第4个客户端通信时,我们看到TcpConnection对应的IO线程ID为8086,计算线程为8082。可以看到,TcpConnection对应的IO线程采用轮询算法依次分配,两个IO线程依次被分配。计算线程池中的线程也依次得到了被调度,实际上,因为我测试的数独问题都相同,所以每个计算线程计算任务都差不多,所以被依次调度了。而在实际服务器中,有时候针对不同的客户端所发的数据所需要处理的计算量是不同的,因而计算线程也就不会像上述一样被轮着调度了。
最后再贴上一幅程序运行的大致流程图,图片太大可能不清晰,请点击图片查看,欢迎大家批评指正!!!