muduo网络框架分析

概要

muduo是一个纯异步、多线程、多路复用网络框架,主线程负责监听网络连接事件,子线程负责处理已建立的连接的网络读写事件。采用eventfd实现父、子线程之间的通信。子线程之间是不能直接进行通信的,原因在于,子线程之间互相不知道对方是否已经析构,也就是一个子线程很有可能向另外一个已经退出的子线程递交任务,这样就会造成程序崩溃。

muduo网络框架是基于生产者消费者模型的。每一个线程都维护了自己的未决队列,当另外的线程或者线程自身递交一个任务到未决队列后,会向未决队列所在线程关联的eventfd写入任意的8字节数据。这样就激活了这个线程从未决队列中取出任务进行消费。

muduo网络框架默认是采用epoll多路复用,基于水平触发, 利用struct epoll_event结构中的data数据成员ptr指针存储Channel对象。

muduo网络框架是基于对象的,利用boost::function实现多态,用boost::shared_ptr,boost::scoped_ptr,boost::weak_ptr智能指针包装裸指针,尽可能的降低内存泄露风险。以安全牺牲性能。

线程池初始化

利用条件变量+互斥体实现线程池的初始化。EventLoopThreadPool对象维护了EventLoopThread线程对象池和EventLoop对象池。每一个EventLoop对象属于确切的某个线程。主线程通过调用EventLoopThread::startLoop()方法,利用条件变量和互斥体同步等待子线程创建,并初始化EventLoop对象。当子线程初始化EventLoop对象后,向条件变量发送唤醒信号。在该条件变量上睡眠的主线程被唤醒。接着主线程将得到的EventLoop对象,返回给EventLoopThreadPool::start()函数。最终在EventLoopThreadPool::start()函数中,EventLoop对象被存入vector向量。这样EventLoop对象池就创建好了。而子线程在向条件变量发送唤醒信号后,调用了EventLoop::loop()函数,进入主循环。一旦主循环退出,整个子线程就退出了。

muduo网络框架使用原子操作来避免EventLoopThreadPool对象被多次初始化。从这点可以看出,muduo网络框架的线程池是静态的,不可伸缩的。还有一点,这些子线程是无差别的,都将作为网络i/o线程。muduo网络框架使用了__thread 关键字用于标识线程独立的全局变量,也就是每个线程一个副本。

接收器acceptor

负责监听网络连接事件,从网络协议栈的全链接队列中取出完成三次握手的连接,并得到socket文件描述符。因为采用的是水平触发,所以muduo网络框架一次只从全连接队列中取出一个连接不会出问题。只要全连接队列不为空,那么监听文件描述符的可读事件将会一直响应。这样的设计并不符合高性能网络框架的要点。

Acceptor对象隶属于TcpServer。一个TcpServer拥有一个Acceptor对象。一个Acceptor对象只负责监听一个网络接口。muduo并不支持多个网络接口的监听。当Acceptor对象获取到一个已连接的文件描述符后,就会将这个连接设置成非阻塞和修改文件状态标识为closeonexec。接着调用TcpServer注册的newConnection回调函数。

框架核心TcpServer

TcpServer对象负责muduo网络框架的初始化,包括创建线程池和连接池,并且维护连接池。一个muduo网络框架只拥有一个TcpServer对象。TcpServer对象由主线程维护。TcpServer::newConnection()函数由acceptor调用,在TcpServer::newConnection函数中,首先调用EventLoopThreadPool::getNextLoop()函数,获取一个EventLoop对象,在多线程模式下,该EventLoop对象属于某个子线程。紧接着为新获取的socket文件描述符构造一个TcpConnection对象,并将该TcpConnection对象与获取的EventLoop对象关联。这个TcpConnection对象就属于某个子线程了。接着将TcpConnection对象插入到TcpServer维护的连接池中。muduo网络框架使用了智能指针,TcpServer对象维护的连接池中的成员是boost::shared_ptr对象,这样TcpConnection只要存在于连接池中,那么对象就不会被释放。

程序自此,TcpConnection关联的socket文件描述符还没有加入epoll。通过调用TcpConnection::connectEstablished()函数,将socket文件描述符加入epoll事件监听器。执行该函数的线程一定与该TcpConnection关联的EventLoop对象所属的线程保持一致。为了做到这一点,程序中使用EventLoop::runInLoop()函数包装TcpConnection::connectEstablished()函数。在多线程模式下,这将会发生线程切换,主线程对子线程关联的EventLoop对象的未决队列上锁。主线程上所成功,将会把TcpConnection::connectEstablished()函数对象投递到子线程的未决队列中,并且向该EventLoop对象关联的eventfd写入8字节数据,用于唤醒与该EventLoop对象关联的子线程,尽快处理未决队列中的任务。之所以要唤醒,是因为子线程很有可能阻塞在epoll_wait函数处。只有从epoll_wait返回,要么是超时时间到了,要么是接收到新的事件时,子线程才有机会处理在未决队列中的任务。那样的话,将会大大的影响,子线程处理未决队列中的任务的速率。通过向eventfd写入8字节数据,就是为了让子线程从epoll_wait中返回。因为子线程的epoll监听的eventfd产生了读事件,则epoll_wait就会返回。

TcpServer对象也控制TcpConnection的析构,TcpServer::removeConnection()函数由TcpConnection::handleClose()函数调用,执行TcpConnection::handleClose()函数的线程一定是TcpConnection对象关联的EventLoop对象所属的线程。这里将会发生一次线程切换,子线程对主线程的EventLoop对象的未决队列进行上锁,并且把函数对象TcpServer::removeConnectionInLoop()递交给主线程的EventLoop对象的未决队列中。子线程向主线程的EventLoop对象关联的eventfd写入8字节数据,唤醒主线程执行未决队列中的任务。在TcpServer::removeConnectionInLoop()函数中,主线程把boost::shared_ptr对象从连接池中移除,为TcpConnection对象的析构做好准备。紧接着主线程调用了EventLoop::queueInLoop()函数,该函数包装了TcpConnection::connectDestroyed()函数。不知道是否有读者仔细斟酌过这行代码,为什么不用EventLoop::runInLoop()函数来包装呢?在多线程模式下,能够很容易理解的。在单线程模式下,笔者花了好一阵工夫,才理解大牛陈硕的用意。这行代码是为了避免TcpConnection对象的析构发生在Channel::handleEvent的内部。因为一个Tcpconnection对象拥有一个Channel对象,如果在Channel::handleEvent内部造成了TcpConnection对象的析构,那么channel对象也一并被析构。也就会出现访问一个已经被析构的Channel对象的可能。

连接TcpConnection

一个socket文件描述符对应一个TcpConnection对象。一个TcpConnection对象包含一个Channel对象。Channel对象封装了与epoll或者poll的处理细节。在TcpConnection对象中,封装了读、写、关闭等事件响应的处理函数。一个TcpConnection对象拥有两个缓冲区,一个是输入缓冲区,一个是输出缓冲区。输入缓冲区负责接收网络协议栈中的数据,输出缓冲区负责接收待发送的应用层中的数据。输入、输出缓冲区是动态扩张的,直到内存耗尽。

默认情况下,socket文件描述符只监听读事件。当应用层数据不能够一次性的写入网络协议栈时,就会为该socket文件描述符添加写事件。应用层未写入的数据将会存入输出缓冲区。当可写事件产生时,框架自动从输出缓冲区中取出数据,写入网络协议栈。如果输出缓冲区没有更多数据可写时,需要取消写事件,否则cpu会出现100%现象。当发送数据的时候,需要首先检查输出缓冲区是否有数据,如果有数据,则不能立即发送到网络协议栈,只能讲这些数据追加到输出缓冲区后面,否则可以立即发送。

应用层从Tcpconnection对象的输入缓冲区中获取数据,进行解析。笔者曾经在使用muduo的时候,出现过问题。每次只从输入缓冲区中提取一个完整的数据包,而没有连续处理,直到输入缓冲区中的剩余数据不足以构成一个完整数据包。当时笔者认为只要输入缓冲区中有数据,muduo网络框架就会不停的通知应用层处理,实则不然。

渠道Channel

在muduo网络框架中,Channel对象直接与epoll或者poll进行交互。一个Channel对象拥有一个文件描述符。这些文件描述符包括listenfd、connectfd、eventfd和timerfd。每一个线程拥有一个EventLoop对象,每个EventLoop对象包含一个Poller对象。每个Poller对象维护了一个Channel池。笔者看来Channel池对于poll有用,对epoll没有意义。以epoll为例,在为文件描述符添加事件的时候,把文件描述符关联的Channel对象的地址赋给了struct epoll_event结构的data成员的ptr。当特定的文件描述符产生事件时,就可以通过该ptr指针获取到Channel对象。网络框架必须保证Channel对象的生命周期,也就是析构Channel对象之前,文件描述符一定要从epoll的监听队列中移除。

框架核心EventLoop

一个线程拥有一个EventLoop对象。一个EventLoop对象包含一个eventfd,一个事件未决队列,一个Poller对象和一个定时器对象。eventfd用于线程间通信或者线程内部通信,主要确保监听该eventfd的线程不阻塞于epoll_wait函数,而立即处理未决队列中的事件。事件未决队列,把每个线程的任务和职责分的很清楚。这里用互斥体来保护临界资源,事件未决队列。muduo网络框架,处理十分巧妙,把临界区域限定得很小。在某些场景下,可以使用更加高效的自旋锁替代。Poller对象是对poll和epoll的抽象,对上层透明。系统默认使用更加高效的epoll,作为事件监听器。一个定时器对象拥有一个timerfd文件描述符,定时器相关信息见下文。整个线程将会一直在EventLoop::loop()函数中运行,直到退出。muduo网络框架并没有充分利用epoll_wait超时特点,而是固定的睡眠10毫秒。如果充分利用epoll_wait的超时特点,可以实现更加高效的定时器机制。整个线程除了epoll_wait这个阻塞点之外,不能有其他阻塞点。这也是为什么在muduo框架内部所有的文件描述都是以非阻塞模式运行的。

定时器timer

muduo网络框架利用linux kernel 2.6.29+的新特性timerfd来接收定时信号。当定时器事件出触发时,内核向timerfd写入8字节数据,以通知监听了timerfd可读事件的线程,处理定时任务。muduo网络框架并不是为每一个定时任务分配一个timerfd,而是利用了std::set有序集合,muduo网络框架总是为std::set集合中的第一个定时任务注册定时器。当定时器触发时,总是查找std::set集合中大于当前时间的最小值作为临界值。所有小于当前临界值的定时任务都可以认为已经到期了,则可以处理这些定时任务。处理完这些定时任务后,还要检查这些定时任务是否是重复执行的,如果是重复执行的,则将这些定时任务重新加入定时队列,并为std::set中的第一个定时任务设置定时器。采用timerfd写定时任务比用setitimer或者alrm函数写起来要简单,清晰。高效的网络框架应该利用epoll_wait+红黑树或者最小堆来实现定时器,这样可以减少系统调用。

缓冲Buffer

muduo网络框架利用std::vector容器作为动态伸缩的缓冲区。该缓冲区头部腾出固定大小的空间用于前向追加,这样当需要前向追加数据时,就省去移动整个缓冲区的代价。缓冲区可以自动复位。当缓冲区扩张很大时,就算调用clear()函数也不能释放被vector占用的内存,只能手动调用shrink函数,才能将缓冲区占用的内存释放掉。muduo网络框架也利用了分散读特性,将大量的数据分散读到多个缓冲区,这样可以充分利用内存碎片。不过,muduo网络框架并没有很好利用readv分散读特性,因为出现了重复内存拷贝。主流网络框架都是用固定大小的chunk串联成链表,形成自动扩张的缓冲区。采用这种设计的缓冲区,再结合readv分散读,不但会减少内存拷贝,还能避免std::vector以2倍空间增长的代价。

转自:https://m.2cto.com/net/201609/544547.html

发布了47 篇原创文章 · 获赞 20 · 访问量 10万+

猜你喜欢

转载自blog.csdn.net/boiled_water123/article/details/104135540