阻塞、非阻塞、同步、异步的概念
阻塞与非阻塞
简单来说,阻塞、非阻塞都是对于操作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性能更好,但是编程逻辑变得更加复杂,出现了问题也更难排查。
五种IO模型
阻塞IO blocking
非阻塞IO non-blocking
IO多路复用(IO multiplexing)
信号驱动IO(signal-driven)
异步IO(asynchronous)
muduo库的框架设计
现代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,读取过程中可能会阻塞当前执行流,导致其余连接迟迟未响应。
muduo的LT模式
Epoll的ET模式通常是高效的代名词,使用ET模式事件就绪只会通知一次,即使一次未将数据读取完毕之后也不再通知,减少了Epoll底层回调机制的触发次数,提高了性能。
ET模式的高效是相对的,为什么muduo使用LT模式?我认为有以下好处:
- 低延迟:每次读取/写入只调用一次系统调用,兼顾每个连接,不会因为某个连接需要进行大量读写影响其余连接。
- 数据完整性:LT模式只要数据没读完就会一直上报。
Reactor 模型
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事件处理器
muduo库的Multiple Reactors模型
图中的每一个Reactor对应一个EventLoop,一个线程运行一个EventLoop,每个Loop上的客户端连接都只能由其所在的Loop进行读写。
以图上一个MianReactor,2个SubReactor 为例:
muduo库中,MianReactor和SubReactor的交互并没有使用简单的同步队列(如生产者消费者模型),而是使用了高效的eventfd系统调用实现通知/唤醒机制,每个Loop都持有一个eventfd句柄,要通知某个Loop只要对其所拥有的eventfd写数据即可。
如果使用了同步队列,在瞬时高并发的场景,此同步队列很可能成为性能瓶颈,同步队列简化了逻辑,使代码更容易实现,但维持同步的性能损耗在高并发场景可能成为性能杀手,而使用eventfd 不涉及任何的 race condttion
性能杀手:锁竞争、上下文切换、数据拷贝、内存申请
muduo库核心组件
Channel
将已连接的客户端连接 connfd,打包成了一个Channel,其内部封装了connfd,loop_ (此Channel所在的EventLoop)、event (EPOLLIN/OUT),revent(响应的事件)和对应事件发生时的一系列回调。
muduo库中只有两种channel, 用来监听新连接的 listenfd-acceptor Channel 和客户端的 connChannel ,acceptorChannel 注册在MainLoop的Poller上。
Poller
Poller对应Reactor模型中的事件分发器,向EventLoop返回就绪事件集合,是具体某个IO多路复用的对象,muduo库中的Poller为一个抽象类,muduo提供两个具体的IO多路复用实现,EpollPoller 和 PollPoller 继承Poller类,重写其虚函数,实现其跨平台性,默认使用的是Epoll。
EventLoop
EventLoop为muduo的核心,一个线程对应一个EventLoop,EventLoop对应上图Reactor模型中的Reactor反应堆,一个EventLoop可以管理大量的Channel 和 一个Poller 。 Channel无法直接与Poller交互,Channel想要修改自己所关注的事件必须通过EventLoop调用Poller的接口。
Socket和Acceptor
socket :对socket 封装,bind 、listen、accept 和一系列如设置端口复用的setsockopt函数。
Acceptor:
主要封装了对 listenfd 相关的操作 socket bind listen ,运行在baseLoop
Thread和EventLoopThread
Thread: 对C++11线程类的进一步封装
EventLoopThread :one loop per thread 的体现,封装了一个线程和一个EventLoop,此线程运行此EventLoop
EventLoopThreadPool
事件循环线程池,用户调用SetThreadNum 设置SubLoop个数,不包括BaseLoop,如未设置线程数,默认muduo库只有一个BaseLoop同时处理新连到来和已有连接的读写事件。
Buffer
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
一个连接成功的客户端对应一个TcpConnection,内部封装了 Socket、Channel 、InputBuffer、OutputBuffer 和对应事件的回调。
TcpServer
TcpServer 统领全局,使用muduo库进行服务端编程TcpServer必不可少。
TcpServer只对外暴露出了友好、易用的接口,底层复杂的网络细节已经全部被封装好了。
TcpServer主要封装了以下重要组件:
Acceptor: 用于接收新连接,运行在baseloop上
EventLoopThreadPool:创建SubLoop
还封装了一系列用户设置的回调
从echoserver剖析muduo
muduo库的调用链很长,大量的使用到function/bind进行回调绑定,下面以一个ehcoserver的运行过程,从一开始的初始化操作,新连接到来的处理、数据的读写,和连接的断开,来剖析moduo库的核心运行逻辑。
#include "TcpServer.h"
#include "logging.h"
#include <functional>
#include <iostream>
using namespace calm;
using namespace calm::net;
class EchoServer
{
public:
EchoServer(EventLoop* loop,InetAddress& listenAddr)
:loop_(loop),
server_(loop,listenAddr,"EchoServer")
{
//给服务器注册用户建立断开连接的回调
server_.setConnectionCallback(std::bind(&EchoServer::onConnection,this,std::placeholders::_1));
//给服务器注册用户可读写事件回调
server_.setMessageCallback(std::bind(&EchoServer::onMessage,this,std::placeholders::_1,std::placeholders::_2,std::placeholders::_3));
server_.setThreadNum(3);
}
void start()
{
server_.start();
}
private:
void onConnection(const TcpConnectionPtr& conn)
{
LOG_INFO << conn->peerAddress().toIpPort() << " -> "
<< conn->localAddress().toIpPort() << " is "
<< (conn->connected() ? "UP" : "DOWN");
conn->send("hello calm\n");
}
void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp time)
{
string msg(buf->retrieveAllAsString());
//LOG_INFO << conn->name() << " recv " << msg.size() << " bytes at " << time.toString();
std::cout << "receive: " << msg << "\n";
if (msg == "exit\n")
{
conn->send("bye\n");
conn->shutdown();//关闭写端
}
if (msg == "quit\n")
{
loop_->quit();
}
conn->send(msg);
}
EventLoop* loop_;
TcpServer server_;
};//end EchoServer
int main()
{
LOG_INFO << "ThreadId = " << getCurrentThreadId();
EventLoop loop;
InetAddress listenAddr("127.0.0.1", 8080);
EchoServer server(&loop, listenAddr);
server.start();
loop.loop();
return 0;
}
以实现一个echoserver为例,用户只需要绑定几个事件处理回调,再调用Start就实现了一个简单的echoserver。
初始化
创建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库的学习深刻理解到基于对象的程序设计,和多线程下如何妙用智能指针正确管理资源的生命周期,如何封装底层复杂的实现细节,只对外暴露易用性强的接口? 实际项目中各个组件的设计如何做到不冗余 、弱耦合