在学习Netty之前,我们首先对UNIX系统常用的I/O模型进行介绍,然后对Java的I/O历史演进进行简单说明,再对JDK的BIO、NIO和NIO2.0的使用进行详细的说明,让大家体会Java网络编程的简单与强大。
没有数据缓冲区,I/O性能出现问题
没有c或者c++中的Channcl概念,只有输入和输出流
同步阻塞式I/O通讯(BIO),通常会导致通讯线程被长时间阻塞
支持的字符集有限,硬件的可移植性不好
Linux的内核将所有的外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核的系统命令,返回一个file descriptor(fd,文件描述)。而对一个socket的读写返回socketfd(socket 描述符),描述符就是一个数字,它指向核中的一个结构体(文件路径,数据区等一些属性)。
缺省情况下,所有的文件操作都是阻塞的。以套接字接口为例:在进程空间中调用recvfrom,其系统调用直到数据包到达且被复制到应用的进程的缓冲区中或者发生错误时才被返回,在此期间一直会被等待,进程在被调用recvfrom开始到它返回的整段时间内都是被阻塞的.
recvfrom从应用层到内核的时候,如果该缓冲区没有数据的话,就直接返回一个EWOULDBLOCK错误,一般都对非阻塞I/O模型进行轮询检查这个状态,看内核是不是有数据返回。
Linux提供select/poll,进程通过将一个或多个fd传递给poll系统调用,应用进程阻塞在select操作上,这样select/poll可以帮我们侦查多个fd是否处于就绪状态。select/poll是顺序扫描fd是否就绪,而且支持的fd数量有限,因此它的使用受到了一些制约。Linux还提供了一个epoll系统调用,epoll使用基于事件驱动方式代替顺序扫描,因此性能更高。当有fd就绪时,立即回调函数rollback。
首先开启套接口信号驱动I/O功能,并通过系统调用sigaction执行一个信号处理函数(此系统调用立即返回,进程继续工作,它是非阻塞的)。当数据准备就绪时,就位该进程生成一个SIGIO信号,通过信号回调通知应用程序调用recvfrom来读取数据,并通知主循环函数处理数据。
告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通知我们。这种模型与信号驱动模型的主要区别是:信号驱动I/O由内核通知我们何时可以开始一个I/O操作;异步I/O模型由内核通知我们I/O操作何时完成。
服务器需要同时处理多个处于监听状态或者多个连接状态的套接字
服务器需要同时处理多种网络协议的套接字
注意:源IP地址和目的IP地址以及源端口号和目的端口号的组合称为套接字
支持一个进程打开socket描述符(FD)不受限制(仅受限于操作系统的最大文件句柄数)。select的size默认值为1024,大型服务修改较为复杂,epoll支持的句柄数跟系统相关。
I/O效率不会随着FD数量的增加而线性下降。select/poll每次调用都会线性扫描全部集合,epoll是根据活跃的socket才会主动调用callback函数。
使用mmap加速内核与用户空间的消息传递。epoll是通过内核和用户空间mmap同一块内存来实现的。
epoll的API更加简单。包括创建一个epoll描述符、添加监听事件、阻塞等待所监听的事件发生、关闭epoll描述符等。
网络编程的基本模型是Client/Server模型,也就是两个进程之间进行相互通信,其中服务端提供位置信息(绑定的IP地址和监听端口),客户端通过连接操作向服务端监听的地址发起连接请求,通过三次握手建立连接,如果连接成功,双方就可以通过网络套接字(socket)进行通信。
一个独立Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。一请求一应答通信模型,如下图:
为了解决同步阻塞I/O面临的一个链路需要一个线程处理的问题,对线程模式进行优化——后端通过一个线程池来处理多个客户端的请求接入。
当有新的客户端接入时,将客户端的socket封装成一个Task投递到后端的线程池中进行处理,jdk的线程池维护一个消息队列和N个活跃线程,对消息队列中的任务进行处理。由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。如下图:
伪异步I/O实际上仅仅是对之前I/O线程模型的一个简单优化,它无法从根本上解决同步I/O导致的通信线程阻塞问题,简单分析如下:
服务端处理缓慢,返回应答消息消耗 60s,平时只需要10ms.
采用伪异步I/O的线程正在读取故障服务点的响应,由于读取输入流是阻塞的,它将会被同步阻塞60s.
假设所有的可用线程都被故障服务阻塞,那后续所有的I/O消息都将在队列中排队。
由于线程池采用阻塞队列实现,当队列积满之后,后续入队列的操作将被阻塞。
由于前端只有一个Accptor线程接收客户端接入,它被阻塞在线程池的同步阻塞队列之后,新的客户端请求信息将被拒绝,客户端会发生大量的连接超时。
由于几乎所有的连接都超时,调用者会认为系统已经奔溃,无法接收新的请求消息。
NIO提供了SocketChannel和ServerSocketChannel两种不同的套接字通道实现。都支持阻塞和非阻塞两种模式。阻塞模式非常简单,但性能和可靠性都不好,非阻塞模式则正好相反。开发人员可以根据自己的需要来选择合适的模式。一般来说,低负载、低并发的应用程序可以选择同步阻塞I/O以降低编程复杂度;对于高负载、高并发的网络应用,需要使用NIO的非阻塞模式进行开发。
在NIO库中,所有数据都是用缓冲区处理的。在读写数据是,它是直接读写到缓冲区中的。任何时候访问NIO中的数据,都是通过缓冲区进行操作。
缓冲区实质就是一个数组。通常它是一个字符数组(ByteBuffer),也可以使用其他类型的数组。但是一个缓冲区不仅仅是一个数组,缓冲区提供了对数组的结构化访问以及维护读写位置(limit)等信息。
Java的基本类型(除了Boolean类型)都对应一种缓冲区,MappedByteBuffer是专门用于内存映射的一种ByteBuffer。
缓冲区的继承关系如下图:
方法 | 描述 |
---|---|
int capacity() | 返回此缓冲区的容量。 |
Buffer clear() | 清除此缓冲区。 |
Buffer flip() | 反转此缓冲区。 |
boolean hasRemaining() | 判断在当前位置和限制之间是否有任何元素。 |
abstract boolean isReadOnly() | 判断此缓冲区是否为只读缓冲区。 |
int limit() | 返回此缓冲区的限制。 |
Buffer limit(int newLimit) | 设置此缓冲区的限制。 |
Buffer mark() | 在此缓冲区的位置设置其标记。 |
int position() | 返回此缓冲区的位置。 |
Buffer position(int newPosition) | 设置此缓冲区的位置。 |
int remaining() | 返回当前位置与限制之间的元素数量。 |
Buffer reset() | 将此缓冲区的位置重新设置成以前标记的位置。 |
Buffer rewind() | 重绕此缓冲区。 |
网络数据通过Channel同时进行读取和写入,通道与流的不同之处在于通道是双向的。
Channel的类图继承关系如下图:
自顶向下看,前三层主要是Channel接口,用于定义它的功能,后面是一些具体的功能类(抽象类)。实际上Channel可以分为两个大类:用于网络读写的SelectableChannel和用于文件操作的FileChannel。
Selector会不断地轮询注册在其上的Channel,如果某个Channel上面发生读或者写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以获取就绪Channel的集合,进行后续的I/O操作。
打开ServerSocketChannel,用于监听客户端的连接,它是所有客户端连接的父管道
ServerSocketChannel acceptorSvr=ServerSocketChannel.open();
绑定监听端口,设置连接为非阻塞模式
acceptorSvr.configureBlocking(false);
acceptorSvr.socket().bind(new InetSocketAddress(port), 1024);
创建Reactor线程,创建多路复用器并启动线程
Selector selector=Selector.open();
New Thread(new ReactorTask()).start();
将ServerSocketChannel注册到Reactor线程的多路复用器Selector,监听ACCEPT事件
SelectionKey key=acceptorSvr.register(selector,SelectionKey.OP_ACCEPT,ioHandler);
多路复用器在线程run方法的无限循环体内轮询准备就绪的key
int num=selector.select();
Set selectedKeys=selector.selectedKeys();
Iterator it=selectedKeys.iterator();
while (it.hasNext()){
SelectionKey key=(SelectionKey)it.next();
//...deal with I/O event ...
}
多路复用器监听到新的客户端接入,处理新的接入请求,完成TCP三次握手,建立物理链路
SocketChannel channel=svrChannel.accept();
设置客户端链路为非阻塞模式
channel.configureBlocking(false);
channel.socket.setReuseAddress(true);
......
将新接入的客户端连接注册到Reactor线程的多路复用器上,监听读操作,读取客户端发送的网络消息
SelectionKey key=socketChannel.register(selector,SelectionKey.OP_READ,ioHandler);
异步读取客户端请求消息到缓冲区
int readNumber = channel.read(receivedBuffer);
对ButeBuffer进行编解码,如果有半包消息指针reset,继续读取后续的报文,将解码成功的消息封装成task,投递到业务线程池中,进行业务逻辑编排
Object message = null;
while(buffer.hasRemain())
{
byteBuffer.mark();
Object message = decode(byteBuffer);
if (message == null)
{
byteBuffer.reset();
break;
}
messageList.add(message );
}
if (!byteBuffer.hasRemain())
byteBuffer.clear();
else
byteBuffer.compact();
if (messageList != null & !messageList.isEmpty())
{
for(Object messageE : messageList)
handlerTask(messageE);
}
将POJO对象encode成ByteBuffer,调用SocKetChannel的异步write接口,将消息异步发送给客户端
socketChannel.write(buffer);
注意:如果发送区TCP缓冲区满,会导致写半包,此时,需要注册监听写操作位,循环写,直到整个包消息写入TCP缓冲区。对于这些内容此次暂不赘述,后续Netty源码分析章节会详细分析Netty的处理策略。
打开SocketChannel,绑定客户端本地地址(可选,默认系统会随机分配一个可用的本地地址)
SocketChannel clientChannel = SocketChannel.open();
设置SocketChannel为非阻塞模式,同时设置客户端连接的TCP参数
clientChannel.configureBlocking(false);
socket.setReuseAddress(true);
socket.setReceiveBufferSize(BUFFER_SIZE);
socket.setSendBufferSize(BUFFER_SIZE);
异步连接服务端
boolean connected=clientChannel.connect(new InetSocketAddress(“ip”,port));
判断是否连接成功,如果连接成功,则直接注册读状态位到多路复用器中,如果当前没有连接成功(异步连接,返回false,说明客户端已经发送sync包,服务端没有返回ack包,物理链路还没有建立)
if (connected)
{
clientChannel.register( selector, SelectionKey.OP_READ, ioHandler);
}
else
{
clientChannel.register( selector, SelectionKey.OP_CONNECT, ioHandler);
}
向Reactor线程的多路复用器注册OP_CONNECT状态位,监听服务端的TCP ACK应答
clientChannel.register( selector, SelectionKey.OP_CONNECT, ioHandler);
创建Reactor线程,创建多路复用器并启动线程
Selector selector = Selector.open();
New Thread(new ReactorTask()).start();
多路复用器在线程run方法的无限循环体内轮询准备就绪的Key
int num = selector.select();
Set selectedKeys = selector.selectedKeys();
Iterator it = selectedKeys.iterator();
while (it.hasNext()) {
SelectionKey key = (SelectionKey)it.next();
// ... deal with I/O event ...
}
接收connect事件进行处理
if (key.isConnectable())
//handlerConnect();
判断连接结果,如果连接成功,注册读事件到多路复用器
if (channel.finishConnect())
registerRead();
注册读事件到多路复用器
clientChannel.register( selector, SelectionKey.OP_READ, ioHandler);
异步读客户端请求消息到缓冲区
int readNumber = channel.read(receivedBuffer);
对ByteBuffer进行编解码,如果有半包消息接收缓冲区Reset,继续读取后续的报文,将解码成功的消息封装成Task,投递到业务线程池中,进行业务逻辑编排
Object message = null;
while(buffer.hasRemain())
{
byteBuffer.mark();
Object message = decode(byteBuffer);
if (message == null)
{
byteBuffer.reset();
break;
}
messageList.add(message );
}
if (!byteBuffer.hasRemain())
byteBuffer.clear();
else
byteBuffer.compact();
if (messageList != null & !messageList.isEmpty())
{
for(Object messageE : messageList)
handlerTask(messageE);
}
将POJO对象encode成ByteBuffer,调用SocketChannel的异步write接口,将消息异步发送给客户端
socketChannel.write(buffer);
通过序列图和关键代码的解说,相信大家对创建NIO客户端程序已经有了一个初步的了解,下面就跟随着我们的脚步,继续看看如何使用NIO改造之前的时间服务器客户端TimeClient吧。
NIO的类库和API繁杂,使用麻烦,你需要熟练掌握Selector、ServerSocketChannel、SocketChannel、ByteBuffer等。
需要具备其他的额外技能做铺垫,例如熟悉Java多线程编程。这是因为NIO编程涉及到Reactor模式,你必须对多线程和网路编程非常熟悉,才能编写出高质量的NIO程序。
可靠性能力补齐,工作量和难度都非常大。例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等问题,NIO编程的特点是功能开发相对容易,但是可靠性能力补齐的工作量和难度都非常大。
JDK NIO的BUG,例如臭名昭著的epoll bug,它会导致Selector空轮询,最终导致CPU 100%。官方声称在JDK1.6版本的update18修复了该问题,但是直到JDK1.7版本该问题仍旧存在,只不过该BUG发生概率降低了一些而已,它并没有被根本解决。
由于上述原因,在大多数场景下,我不建议大家直接使用JDK的NIO类库,除非你精通NIO编程或者有特殊的需求,在绝大多数的业务场景中,我们可以使用NIO框架Netty来进行NIO编程,它既可以作为客户端也可以作为服务端,同时支持UDP和异步文件传输,功能非常强大。
Netty是业界最流行的NIO框架之一,它的健壮性、功能、性能、可定制性和可扩展性在同类框架中都是首屈一指的,它已经得到成百上千的商用项目验证,例如Hadoop的RPC框架avro使用Netty作为底层通信框架。很多其它业界主流的RPC框架,也使用Netty来构建高性能的异步通信能力。
API使用简单,开发门槛低;
功能强大,预置了多种编解码功能,支持多种主流协议;
定制能力强,可以通过ChannelHandler对通信框架进行灵活的扩展;
性能高,通过与其它业界主流的NIO框架对比,Netty的综合性能最优;
成熟、稳定,Netty修复了已经发现的所有JDK NIO BUG,业务开发人员不需要再为NIO的BUG而烦恼;
社区活跃,版本迭代周期短,发现的BUG可以被及时修复,同时,更多的新功能会被加入;
经历了大规模的商业应用考验,质量已经得到验证。在互联网、大数据、网络游戏、企业应用、电信软件等众多行业得到成功商用,证明了它可以完全满足不同行业的商业应用。