muduo是陈硕大神在Linux平台下基于C/C++开发的高性能网络库,在此基础上可以很方便的扩展,进行二次开发编写如http服务器。muduo网络库的核心框架,one thread per thread + Reactor模式。这也是大多数Linux端高性能网络编程框架应用的主要架构。
其本人写的《Linux多线程服务器端编程》对muduo库整个框架和设计细节进行了非常详尽的介绍和分析,非常推荐大家阅读,不仅仅对网络编程有帮助。
此文使用muduo编写了一个简单的echoserver服务器,从用户使用的角度来进行讲解muduo的核心框架、各个组件的交互、优秀的代码设计和实现细节。从源码角度辅助理解整个muduo的实现,希望对网络编程有更深的理解。
从muduo源码中提取出了其核心逻辑,提供一个包含详细注释的muduo库,可以配合源码阅读,删除了定时器事件、大量断言判错、和客户端只保留了muduo库服务端编程的核心框架,代码行数大概2000+,便于剖析其核心。
删减版muduo C++11
简单来说,阻塞、非阻塞都是对于操作I/O产生的行为,I/O 无非就是读(read)和写(write)。
以一个sockfd举例,无论是读还是写都有两个阶段,1 等待内核中的接收/发送缓冲区就绪(可读/可写),2 将内核中接收缓冲区数据拷贝至用户区 或 将用户区数据拷贝至发送缓冲区。
linux下默认对sockfd读/写操作,如果未就绪就默认会一直阻塞到就绪事件发生,程序才会继续往下执行,而如果调用了如 fcntl 系统调用对fd设置为了非阻塞,即使数据未就绪也直接返回。
同步与异步
陈硕大神的原话:“在处理 I/O 的时候,阻塞和非阻塞都是同步 I/O。只有使用了特殊的 API 才是异步I/O。”
我的理解是 无论是阻塞I/O、非阻塞I/O、I/O多路复用,都是同步I/O,它们都只是一种就绪事件通知方案,无论是sockfd可读还是可写了,返回的都是就绪事件,用户程序依旧要承担 从用户区将数据拷贝至内核发送缓冲区 或 从内核接收缓冲区将数据拷贝至用户区的时间,而异步I/O通知的是事件完成,用户程序不用承担数据拷贝所花费的时间,拷贝操作已经由内核帮你完成,一返回就可以进行下一步处理。
异步IO相对于同步IO性能更好,但是编程逻辑变得更加复杂,出现了问题也更难排查。
现代CPU都有许多个核心,服务器这种程序也必须充分利用多核CPU的优势,如何设计多线程服务器?
引用 libevent 网络库作者的观点:one loop per thread is usually a good model
此时问题就转化为了如何设计一个高效的EventLoop,一个线程运行一个EventLoop。
EventLoop是muduo库的核心,muduo库使用的是 IO multiplexing + non-blocking IO + LT模式。
IO多路复用一般和非阻塞IO搭配使用,它们都无法单独使用,不会有人对一个非阻塞的fd一直轮询读取,浪费CPU资源。也不会有人在IO多路复用中使用阻塞IO,读取过程中可能会阻塞当前执行流,导致其余连接迟迟未响应。
Epoll的ET模式通常是高效的代名词,使用ET模式事件就绪只会通知一次,即使一次未将数据读取完毕之后也不再通知,减少了Epoll底层回调机制的触发次数,提高了性能。
ET模式的高效是相对的,为什么muduo使用LT模式?我认为有以下好处:
Reactor模型封装了 对IO的读写、对应事件的回调 、事件操作。
The reactor design pattern is an event handling pattern for handling service requests
delivered concurrently to a service handler by one or more inputs. The service handler
then demultiplexes the incoming requests and dispatches them synchronously to the
associated request handlers.
单个Reactor
Reactor组件:Event事件、Reactor反应堆、Demultiplex事件分发器、EvantHandler事件处理器
图中的每一个Reactor对应一个EventLoop,一个线程运行一个EventLoop,每个Loop上的客户端连接都只能由其所在的Loop进行读写。
以图上一个MianReactor,2个SubReactor 为例:
muduo库中,MianReactor和SubReactor的交互并没有使用简单的同步队列(如生产者消费者模型),而是使用了高效的eventfd系统调用实现通知/唤醒机制,每个Loop都持有一个eventfd句柄,要通知某个Loop只要对其所拥有的eventfd写数据即可。
如果使用了同步队列,在瞬时高并发的场景,此同步队列很可能成为性能瓶颈,同步队列简化了逻辑,使代码更容易实现,但维持同步的性能损耗在高并发场景可能成为性能杀手,而使用eventfd 不涉及任何的 race condttion
性能杀手:锁竞争、上下文切换、数据拷贝、内存申请
将已连接的客户端连接 connfd,打包成了一个Cannel,其内部封装了connfd,loop_ (此Channel所在的EventLoop)、event (EPOLLIN/OUT),revent(响应的事件)和对应事件发生时的一系列回调。
muduo库中只有两种channel, 用来监听新连接的 listenfd-acceptorChannel 和客户端的 connChannel ,acceptorChannel 注册在MainLoop的Poller上。
Poller对应Reactor模型中的事件分发器,向EventLoop返回就绪事件集合,是具体某个IO多路复用的对象,muduo库中的Poller为一个抽象类,muduo提供两个具体的IO多路复用实现,EpollPoller 和 PollPoller 继承Poller类,重写其虚函数,实现其跨平台性,默认使用的是Epoll。
ChannelList activeChannels_;
std::unique_ptr poller_;
int wakeupFd; -> loop
std::unique_ptr wakeupChannel ;
EventLoop为muduo的核心,一个线程对应一个EventLoop,EventLoop对应上图Reactor模型中的Reactor反应堆,一个EventLoop可以管理大量的Channel 和 一个Poller 。 Channel无法直接与Poller交互,Channel想要修改自己所关注的事件必须通过EventLoop调用Poller的接口。
socket :对socket 封装,bind 、listen、accept 和一系列如设置端口复用的setsockopt函数。
Acceptor:
主要封装了对 listenfd 相关的操作 socket bind listen ,运行在baseLoop
Thread: 对C++11线程类的进一步封装
EventLoopThread :one loop per thread 的体现,封装了一个线程和一个EventLoop,此线程运行此EventLoop
事件循环线程池,用户调用SetThreadNum 设置SubLoop个数,不包括BaseLoop,如未设置线程数,默认muduo库只有一个BaseLoop同时处理新连到来和已有连接的读写事件。
GetNextLoop() //通过Round Robin轮询算法获取下一个SubLoop
muduo库设置了应用层的缓冲区,此缓冲区是模仿Netty网络库的设计
/// A buffer class modeled after org.jboss.netty.buffer.ChannelBuffer
/// @code
/// +-------------------+------------------+------------------+
/// | prependable bytes | readable bytes | writable bytes |
/// | | (CONTENT) | |
/// +-------------------+------------------+------------------+
/// | | | |
/// 0 <= readerIndex <= writerIndex <= size
/// @endcode
应用读数据: 调用read Tcp接收缓冲区 -> InputBuffer
应用写数据: 调用send OutputBuffer -> Tcp发送缓冲区(如果一次未完全发送完毕,需要将剩余数据保存到OutputBuffer中,并让Poller关注此连接的EPOLLOUT事件,等待下回通知再继续发送)
non-blocking 网络编程中必须要有应用层的 buffer。
Non-blocking IO 的核心思想是避免阻塞在 read() 或 write() 或其他 IO 系统调用上,这样可以最大限度地复用 thread-of-control,让一个线程能服务于多个 socket 连接。IO 线程只能阻塞在如 select()/poll()/epoll_wait()。这样一来,应用层的缓冲是必须的,每个 TCP socket 都要有 input buffer 和 output buffer。
为什么必须要有 output buffer?
如果程序此时要调用send()发送20k 数据,可此时TcpServer发送缓冲区只能接收10k,而剩下的10k数据不可能直接丢弃,也不能让执行流再此阻塞等待到可写,此时剩余的数据因该由网络库接管,保存到TcpConnection的 OutputBuffer 中,并关注EPOLLOUT事件,一旦 socket 变得可写就立刻发送数据,直到发送完毕。
为什么必须要有 input buffer?
客户端向服务器发送20k数据,服务器不会直接收到20k的完整数据,可能是 5k …10k 15k… 20k… 在数据没有接收完整的数据时,必须将数据缓存到InputBuffer 中,等待接收到一个完整的报文再通知应用程序调用对应的业务处理进行解析。。
一个连接成功的客户端对应一个TcpConnection,内部封装了 Socket、Channel 、InputBuffer、OutputBuffer 和对应事件的回调。
TcpServer 统领全局,使用muduo库进行服务端编程TcpServer必不可少。
TcpServer只对外暴露出了友好、易用的接口,底层复杂的网络细节已经全部被封装好了。
TcpServer主要封装了以下重要组件:
Acceptor: 用于接收新连接,运行在baseloop上
EventLoopThreadPool:创建SubLoop
还封装了一系列用户设置的回调
std::unordered_map<std::string, TcpConnectionPtr> connections_;//保存所有的连接
muduo库的调用链很长,大量的使用到function/bind进行回调绑定,下面以一个ehcoserver的运行过程,从一开始的初始化操作,新连接到来的处理、数据的读写,和连接的断开,来剖析moduo库的核心运行逻辑。
#include
#include
#include
#include
#include
using namespace std::placeholders;
class CharServer
{
public:
CharServer(muduo::net::EventLoop *loop,
const muduo::net::InetAddress &addr,
const std::string &name) : server_(loop, addr, name), loop_(loop)
{
//连接建立/连接断开都会触发此回调
server_.setConnectionCallback(std::bind(&CharServer::OnConnection, this, _1));
//读事件发生触发此回调
server_.setMessageCallback(std::bind(&CharServer::OnMessage, this, _1, _2, _3));
}
void SetThreadNum(int num)
{
//设置底层创建的EventLoop个数,不包括base_loop
server_.setThreadNum(num);
}
void Start()
{
server_.start();
}
void OnConnection(const muduo::net::TcpConnectionPtr &conn)
{
if (conn->connected())
{
std::cout << conn->peerAddress().toIpPort() << std::endl;
std::cout << "new conn ... " << std::endl;
}
else
{
std::cout << conn->peerAddress().toIpPort() << std::endl;
std::cout << "conn close ... " << std::endl;
}
}
void OnMessage(const muduo::net::TcpConnectionPtr &conn,
muduo::net::Buffer *buf,
muduo::Timestamp ts)
{
std::string recv = buf->retrieveAllAsString();
conn->send(recv);
conn->shutdown();
}
private:
muduo::net::TcpServer server_;
muduo::net::EventLoop *loop_;
};
int main()
{
muduo::net::EventLoop loop;
CharServer server(&loop, {"127.0.0.1", 8080}, "EchoServer");
server.setThreadNum(3); //设置SubLoop个数
server.Start(); // epoll_ctl 添加listen_fd
loop.loop(); // epoll wait 阻塞等待
}
以实现一个echoserver为例,用户只需要绑定几个事件处理回调,再调用Start就实现了一个简单的回声服务器。
创建BaseLoop对象
创建TcpServer对象
用户传入OnConnection/Onmessage -> Tcpserver -> TcpConnection -> Channel 最终绑定到Channel上。
echoserver内封装了一个TcpServer对象,TcpServer构造需要传入BaseLoop。TcpServer内封装了两个重要组件 Acceptor EventThreadPool
假设此时已经完成初始化操作,用户调用setThreadNum(3) 创建了三个SubLoop,此时listenfd已经被封装成了Channel并注册到了BaseLoop的Poller上,每个SubLoop的Poller也已经注册了wakeupChannel,每个Loop都都阻塞在epoll_wait 上。
在TcpConnection构造函数中已经设置了Channel 事件发生时的一系列回调
//给Channel设置相对应的回调
channel_->SetReadCallBack(
std::bind(&TcpConnection::HandleRead, this, std::placeholders::_1));
channel_->SetWriteCallBack(
std::bind(&TcpConnection::HandleWrite, this));
channel_->SetCloseCallBack(
std::bind(&TcpConnection::HandleClose, this));
channel_->SetErrorCallBack(
std::bind(&TcpConnection::HandleError, this));
在CharServer::OnMassage中直接调用send() 将数据原封不动回传。
调用TcpConnection::shutdown() 并不会直接断开连接,而是会等待剩余数据发送完毕了才真正调用ShutdownWrite()关闭写端,并且muduo库的这种关闭连接方式必须让对端read到0,也调用close() 关闭连接,否则此连接会长时间保持在半关闭状态。
主动关闭连接
剩下的逻辑就和被动关闭连接相同。
只是简单地以一个例子讲解了muduo库服务端的核心调用链,还有许多重要的细节和优秀代码设计不好使用文字表达,其实是太懒了。。要真正吃透muduo库还请去多啃源码。。
通过muduo库的学习深刻理解到基于对象的程序设计,和多线程下如何妙用智能指针正确管理资源的生命周期,如何封装底层复杂的实现细节,只对外暴露易用性强的接口? 实际项目中各个组件的设计如何做到不冗余 、弱耦合