muduo网络库学习—跨线程调用及EventLoopThread(4)
文章目录
前言
在本章中主要介绍在muduo网络库中如何实现跨线程调用以及对IO线程的封装类EventLoopThread。
一、如何跨线程调用
不进行跨线程调用会怎么样:
由上一篇文章我们可以知道一个EventLoop对象有一个timerQueue队列,如果多个线程访问这个对象,那么这个对象属于临界资源,如果多个线程不加锁同时访问的话就会出现问题,但是加锁的话又会影响服务器的性能。所以在muduo中作者在runInLoop的方法实现了成员函数既可以跨线程调用又不用加锁,但是本质是上但是在一个loop所在的线程中执行的。
1.进程线程之间的通信
通信有四种方法:单向管道,双向管道,eventfd,条件变量
单向管道:pipe,pipe[0]只能用于读不能用于写,pipe[1]反之;
双向管道:soketpair既可以用于读又可以用于写
eventfd:是一个比pipe更高效的事件通知机制,一方面他比pipe少用一个文件描述符,另一方面他只有八个字节的固定缓冲区,不像pipe一样具有不定长的真实缓冲区,缓冲区管理简单多了。
条件变量:pthread_cond_signal,pthread_cond_wait等!(不适用于进程)
2.muduo中如何实现跨线程通信
思维导图:
在构造函数中实现wakeupfd(使用eventfd创建)的创建以及wakeupChannel的创建并加入到Poller中进行监听,设置成员函数handleRead为读回调函数。
代码如下:
Eventloop::Eventloop():looping_(false),
quit_(false),
eventHandling_(false),
thread_(CurrentThread::getTid()),
poller_(Poller::newDefaultPoller(this)),
currentChannel(NULL),
timerQueue_(new TimerQueue(this)),
wakeupFd_(creatEventFd()),
wakeupChannel_(new Channel(this,wakeupFd_))
{
LOG_TRACE << "EventLoop created " << this << " in thread " << thread_;
// 如果当前线程已经创建了EventLoop对象,终止(LOG_FATAL)
if (t_loopInThisThread)
{
LOG_FATAL << "Another EventLoop " << t_loopInThisThread
<< " exists in this thread " << thread_;
}
else
{
t_loopInThisThread = this;
}
wakeupChannel_->setReadCallBack(std::bind(&Eventloop::handleRead,this));
wakeupChannel_->enableReading();
}
设置唤醒的函数以及回调读函数,分别只是往文件描述符中写入一个size_t的变量以及将其读出来
代码如下:
// 该函数可以跨线程调用
void Eventloop::wakeup(){
size_t n=1;
size_t k=::write(wakeupFd_,&n,sizeof(n));
assert(sizeof(n)==k);
}
void Eventloop::handleRead()
{
uint64_t one = 1;
//ssize_t n = sockets::read(wakeupFd_, &one, sizeof one);
ssize_t n = ::read(wakeupFd_, &one, sizeof one);
if (n != sizeof one)
{
LOG_ERROR << "EventLoop::handleRead() reads " << n << " bytes instead of 8";
}
}
跨线程调用函数以及往目标线程中添加计算任务:runInLoop函数如果当前线程就是loop的所属线程则则直接调用函数,否则加入到计算任务队列中。添加的计算任务队列后,是否唤醒目标进程分三种情况:1.当前执行的线程不是目标线程则需要唤醒目标线程;2.当前线程就是目标线程但是正处于处理计算任务中则需要唤醒进程;3.在当前线程中且正在处理回调函数,不需要唤醒线程,因为处理完回调函数后紧接着就处理计算任务队列了(具体见思维导图中的loop函数进行理解)。
另外值得一提的是在前一章提到的EventLoop的成员函数runAt,runAfter等都是需要调用runinloop函数保证不加锁下的线程安全。EventLoop只把pendingFunctors暴露给其他线程,因此我们在向计算队列中加任务的时候要上锁。
代码如下:
// 在I/O线程中执行某个回调函数,该函数可以跨线程调用
void Eventloop::runInLoop(const Func&cb){
if(isInLoopThread()){
// 如果是当前IO线程调用runInLoop,则同步调用cb
cb();
}
else{
//如果不在创建loop的线程中则往该贤臣数组添加工作并且进行wakeup处理
queueInLoop(cb);
}
}
void Eventloop::queueInLoop(const Func&cb){
//添加任务
{
MUtexLockGuard guard(mutex_);
FuncVector.push_back(cb);
}
// 调用queueInLoop的线程不是IO线程需要唤醒
// 或者调用queueInLoop的线程是IO线程,并且此时正在调用pending functor,需要唤醒
// 只有IO线程的事件回调中调用queueInLoop才不需要唤醒
if(!isInLoopThread()||callingPendingFunctors_){
wakeup();
}
}
对计算任务的处理,关键在于我们是直接将计算任务队列拷贝出来,这样减少的临界区,同时因为计算任务很可能再次调用了queueInLoop,则会造成死锁,还有一个原因是防止一直有源源不断的任务添加进来,这样IO线程陷入死循环,无法处理IO事件。
顺便解答一个作者在muduo书中提出的问题:为什么不在headEvent直接处理计算任务队列?这是因为如果我们是处于事件回调中调用queueInLoop的话,则省去了wakeup->poll->handread三次系统调用,可以直接处理任务队列。
还有Eventloop对象掌管来wakeupChannel的生命周期,这里我们使用unique_ptr智能指针来控制其生命周期。另外提一句上一节课timerQueue同时也管理着一个Channel对象的生命周期,但是不是用指针进行管理而是直接使用对象实体。
二、EventLoopThread类
思维导图:
1.简介
通过创建一个EventLoopdThread对象并且调用该对象的startloop函数(返回EventLoop指针)这样就创建了一个IO线程。startLoop内部创建一个线程,该线程创建一个Eventloop对象并执行事件循环。这里的回调函数cb是在执行事件循环前的初始化,如果没有设置则为空。
代码如下:
#ifndef EVENTLOOPTHREAD_H
#define EVENTLOOPTHREAD_H
#include "Eventloop.h"
#include "../base/Mutex.h"
#include "../base/Condition.h"
#include "../base/Thread.h"
#include <boost/function.hpp>
#include <boost/bind.hpp>
namespace muduo{
namespace net{
//该类主要用来封装一个IO线程
class EventLoopThread{
public:
typedef std::function<void(Eventloop*)> ThreadInitCallback;
EventLoopThread(const ThreadInitCallback &cb=ThreadInitCallback());
~EventLoopThread();
Eventloop*startLoop();//启动线程,该线程就变成了IO线程
private:
void threadFunc();//线程函数
Eventloop*loop_;//指向线程中创建的eventloop对象
bool exiting_;//线程是否退出
Thread thread_;//IO线程
Mutex mutex_;//对应的锁以及条件变量
Condition condition_;
ThreadInitCallback callback_;
};
}
}
#endif
2.开启IO线程
代码如下(示例):
#include "EventLoopThread.h"
#include "../base/Logging.h"
namespace muduo{
namespace net{
EventLoopThread::EventLoopThread(const ThreadInitCallback&cb):
exiting_(false),
thread_(boost::bind(&EventLoopThread::threadFunc,this)),
mutex_(),
condition_(mutex_),
callback_(cb),
loop_(nullptr)
{
}
EventLoopThread::~EventLoopThread(){
assert(!exiting_);
exiting_=true;
loop_->quit();//退出循环
thread_.join();//线程回收
}
Eventloop*EventLoopThread::startLoop(){
thread_.start();
{
MUtexLockGuard guard(mutex_);
while(!loop_){
condition_.wait();
}
}
return loop_;
}
void EventLoopThread::threadFunc(){
Eventloop loop;
if(callback_){
callback_(&loop);
}
{
// loop_指针指向了一个栈上的对象,threadFunc函数退出之后,这个指针就失效了
// threadFunc函数退出,就意味着线程退出了,EventLoopThread对象也就没有存在的价值了。
// 因而不会有什么大的问题
MUtexLockGuard guard(mutex_);
loop_=&loop;
condition_.notify();
}
loop_->loop();
}
}
}
在这里具体来说说条件变量的使用,首先条件变量一定是要配合着互斥量来使用的,总共有两次加锁两次解锁操作,以及配合着while的使用:
第一次加锁:目的是queue.empty()到cond.wait()期间,条件变量不发生变化,如果不加锁,那么在queue.empty()到cond.wait()期间发生了cond.signal()但是cond.wait()是无法收到的,因此wait端只能等下一次signal到来。
第一次解锁:在进入cond.wait()后会解锁,这样的目的是使得其他线程改变条件变量来唤醒当前进程。
第二次加锁:在cond.wait()后解锁目的是在cond.wait()到while()判断期间条件量不会发生变化,因为跳出while循环后在解锁前默认条件变量是不变的,如果不加锁临界量被其他线程改变的话是容易出现错误的。
第二次解锁:释放锁使得其他进程得以推进
配合while使用:假设当前有A,B两个线程等待条件变量,C进行条件变量的唤醒,B首先被cond.wait()但是还没有抢占锁,A先抢占了锁然后判断条件符合进行出队操作,然后B等A释放锁之后在进行判断不符合条件只能解锁继续阻塞。
具体见两位大佬博客:
两次加锁两次解锁操作:见博客:https://blog.csdn.net/shichao1470/article/details/89856443?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522165226010516781685385300%2522%252C%2522scm%2522%253A%252220140713.130102334…%2522%257D&request_id=165226010516781685385300&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2allbaidu_landing_v2~default-1-89856443-null-null.142v9control,157v4control&utm_term=pthread_cond_wait%E5%8A%A0%E9%94%81&spm=1018.2226.3001.4187
配合着while:https://www.cnblogs.com/tqyysm/articles/9765667.html
配合while使用:https://www.cnblogs.com/tqyysm/articles/9765667.html
总结
本章在于在不加锁的情况下进行跨线程调用保证线程安全,同时条件变量的两次加锁解锁以及while的配合使用也是关键。