一、Linux 网络I/O模型
Linux的内核秉承一切皆文件的理念,普通文件、目录、字符设备、块设备和网络设备(套接字)等在Unix/Linux都被当做文件来对待。虽然他们的类型不同,但是linux系统为它们提供了一套统一的操作接口。对一个文件的读写操作会调用内核提供的系统命令,返回一个文件描述符(简称:fd),而对于一个socket的读写也有响应的描述符,简称socketfd,描述符就是一个数字,它指向内核中的一个结构体(文件路径,数据区等一些属性)。
根据UNIX网络编程对I/O模型的分类,UNIX提供了5种I/O模型,分别如下:
1. 阻塞I/O模型(Blocking IO):
最常用的I/O模型就是阻塞I/O模型,默认情况下,所有的文件操作都是阻塞的。应用进程向内核发起 I/O 请求,发起调用recvfrom的线程一直等待内核返回结果。一次完整的 I/O 请求称为BIO(Blocking IO,阻塞 I/O),所以 BIO 在实现异步操作时,只能使用多线程模型,一个请求对应一个线程。但是,线程的资源是有限且宝贵的,创建过多的线程会增加线程切换的开销。
2、非阻塞I/O模型(non-blocking IO):
recvfrom从应用层到内核,如果该缓冲区没有数据的话,直接返回一个EWOULDBLOCK错误,一般都对非阻塞I/O模型进行轮询的时候就是检查这个状态,看内核是否有数据到来。NIO 相比 BIO 虽然大幅提升了性能,但是轮询过程中大量的系统调用导致上下文切换开销很大。所以,单独使用非阻塞 I/O 时效率并不高,而且随着并发量的提升,非阻塞 I/O 会存在严重的性能浪费。
3、多路复用I/O模型
多路复用实现了一个线程处理多个 fd的操作。多路指的是多个数据通道,复用指的是使用一个或多个固定线程来处理每一个 Socket。select/poll是顺序扫描fd是否就绪,而且支持的fd数量有限。linux还提供了epoll的实现方式,基于事件驱动方式代替顺序扫描,因此性能更高。当有fd就绪时,立即回调函数rollback。
select、poll、epoll 都是 I/O 多路复用的具体实现,线程一次 select 调用可以获取内核态中多个数据通道的数据状态。多路复用解决了同步阻塞 I/O 和同步非阻塞 I/O 的问题,是一种非常高效的 I/O 模型。
3.1、epoll的特点
- 支持一个进程打开的scoket描述符(fd)不受限制(仅受限于操作系统的最大文件句柄数)
- I/O效率不会随着fd数目的增加而线性下降
- 使用mmap加速内核与用户空间的消息传递
- epoll的API更加简单
4、信号驱动I/O模型
信号驱动 I/O 并不常用,它是一种半异步的 I/O 模型。在使用信号驱动 I/O 时,通过系统调用sigaction执行一个信号处理函数。当数据准备就绪后,内核通过发送一个 SIGIO 信号通知应用进程,应用进程就可以开始读取数据了。
5、异步I/O模型
异步 I/O 最重要的一点是从内核缓冲区拷贝数据到用户态缓冲区的过程也是由系统异步完成,应用进程只需要在指定的数组中引用数据即可。异步 I/O 与信号驱动 I/O 这种半异步模式的主要区别:信号驱动 I/O 由内核通知何时可以开始一个 I/O 操作,而异步 I/O 由内核通知 I/O 操作何时已经完成。
二、JAVA的I/O模型
在JDK1.4时,新增了java.nio包,提供了很多进行异步I/O开发的API和类库,主要的类和接口如下:
- 进行异步I/O操作的缓冲区ByteBuffer等
- 进行异步I/O操作的管道Pipe
- 进行各种I/O操作(异步或同步)的Channel,包括ServerSocketChannel和SocketChannel
- 多种字符集的编码能力和解码能力
- 实现非阻塞I/O操作的多路复用器Selector
- 基于流行的Perl实现的正则表达式类库
- 文件通道FileChannel
虽然提供的NIO类库极大的促进了JAVA的异步非阻塞变成的发展和应用,但依然存在对文件系统的处理能力不足,主要有: - 没有统一的文件属性(例如读写权限)
- API能力比较弱,例如目录的级联创建和递归遍历,往往需要自己实现
- 底层存储系统的一些高级API无法使用
- 所有文件操作都是同步阻塞调用,不支持异步文件读写操作
在JDK1.7中,对NIO类库进行了升级,被称为NIO2.0它主要提供了三个方面的改进: - 提供能够批量获取文件属性的API,这些API具有平台无关性,不与特性的文件系统相耦合。另外它还提供了标准文件系统的SPI,供各个服务提供商扩展实现
- 提供AIO功能,支持基于文件的异步I/O操作和针对网络套接字的异步操作
- 完成JSR-51定义的通道功能,包括对配置和多播数据报的支持等
三、Netty的I/O模型
Netty 的 I/O 模型是基于非阻塞 I/O 实现的,底层依赖的是 JDK NIO 框架的多路复用器 Selector。一个多路复用器 Selector 可以同时轮询多个 Channel,采用 epoll 模式后,只需要一个线程负责 Selector 的轮询,就可以接入成千上万的客户端。
在 I/O 多路复用的场景下,当有数据处于就绪状态后,需要一个事件分发器(Event Dispather),它负责将读写事件分发给对应的读写事件处理器(Event Handler)。事件分发器有两种设计模式:Reactor 和 Proactor,Reactor 采用同步 I/O, Proactor 采用异步 I/O。
Reactor 实现相对简单,适合处理耗时短的场景,对于耗时长的 I/O 操作容易造成阻塞。Proactor 性能更高,但是实现逻辑非常复杂,目前主流的事件驱动模型还是依赖 select 或 epoll 来实现。
(摘自 Lea D. Scalable IO in Java )
上图所描述的便是 Netty 所采用的主从 Reactor 多线程模型,所有的 I/O 事件都注册到一个 I/O 多路复用器上,当有 I/O 事件准备就绪后,I/O 多路复用器会将该 I/O 事件通过事件分发器分发到对应的事件处理器中。该线程模型避免了同步问题以及多线程切换带来的资源开销,真正做到高性能、低延迟。
完美弥补 Java NIO 的缺陷
在 JDK 1.4 投入使用之前,只有 BIO 一种模式。开发过程相对简单。新来一个连接就会创建一个新的线程处理。随着请求并发度的提升,BIO 很快遇到了性能瓶颈。JDK 1.4 以后开始引入了 NIO 技术,支持 select 和 poll;JDK 1.5 支持了 epoll;JDK 1.7 发布了 NIO2,支持 AIO 模型。Java 在网络领域取得了长足的进步。
Netty 相比 JDK NIO 突出的优势:
易用性。 我们使用 JDK NIO 编程需要了解很多复杂的概念,比如 Channels、Selectors、Sockets、Buffers 等,编码复杂程度非常高。相反,Netty 在 NIO 基础上进行了更高层次的封装,屏蔽了 NIO 的复杂性;Netty 封装了更加人性化的 API,统一的 API(阻塞/非阻塞) 大大降低了开发者的上手难度;与此同时,Netty 提供了很多开箱即用的工具,例如常用的行解码器、长度域解码器等,而这些在 JDK NIO 中都需要自己实现。
稳定性。 Netty 更加可靠稳定,修复和完善了 JDK NIO 较多已知问题,例如臭名昭著的 select 空转导致 CPU 消耗 100%,TCP 断线重连,keep-alive 检测等问题。
可扩展性。 Netty 的可扩展性在很多地方都有体现,这里主要列举其中的两点:一个是可定制化的线程模型,用户可以通过启动的配置参数选择 Reactor 线程模型;另一个是可扩展的事件驱动模型,将框架层和业务层的关注点分离。大部分情况下,开发者只需要关注 ChannelHandler 的业务逻辑实现。
更低的资源消耗
作为网络通信框架,需要处理海量的网络数据,那么必然面临有大量的网络对象需要创建和销毁的问题,对于 JVM GC 并不友好。为了降低 JVM 垃圾回收的压力,Netty 主要采用了两种优化手段:
对象池复用技术。 Netty 通过复用对象,避免频繁创建和销毁带来的开销。
零拷贝技术。 除了操作系统级别的零拷贝技术外,Netty 提供了更多面向用户态的零拷贝技术,例如 Netty 在 I/O 读写时直接使用 DirectBuffer,从而避免了数据在堆内存和堆外内存之间的拷贝。