IO模型
I/O是什么?
I/O其实就是 input 和 output 的缩写,即输入/输出。
I/O 就是指内存与外部设备之间的交互(数据拷贝)
磁盘 I/O 指的是硬盘和内存之间的输入输出
读取本地文件的时候,要将磁盘的数据拷贝到内存中,修改本地文件的时候,需要把修改后的数据拷贝到磁盘中
网络 I/O 指的是网卡与内存之间的输入输出
当网络上的数据到来时,网卡需要将数据拷贝到内存中。当要发送数据给网络上的其他人时,需要将数据从内存拷贝到网卡里
Linux下实现的IO模型:
Linux的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个file descriptor(fd,文件描述符)。对一个socket
的读写也会有相应的描述符,称为socket描述符。描述符就是一个数字,它执行内核中的一个结构体(文件路径,数据区等一些属性)。
因为程序运行在操作系统上,编程语言实现的IO操作API最终依赖于操作系统的IO实现。先理清阻塞、非阻塞、同步、异步这几个概念:
阻塞:调用方发起调用请求,在没有返回结果之前,调用方线程被挂起,出于一直等待状态
非阻塞:与阻塞相对,调用方发起调用请求,当前线程不会等待挂起,而会立即返回。后续可以通过轮询等手段来获取调用结果状态。
同步:所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回
异步:与同步相对,当一个异步过程调用发出后,调用者不会立刻得到结果,通过回调等措施来处理这个调用。
Linux实现了5中IO模型
1、阻塞IO模型
默认情况下,所有的文件操作都是阻塞的,在进程空间中调用recvform(recvform函数,用于从Socket套接口上接收数据),其系统调用直到数据包到达且被复制到应用进程的缓冲区中或者发生错误才返回,在此期间会一直等待,进程在从调用recvform开始到它返回的整段时间内都是被阻塞的。
2、非阻塞IO模型
recvform从应用层到内核的时候,如果该缓冲区没有数据的话,就直接返回一个EWOULDBLOCK错误,一般都是对非阻塞IO模型进行轮询来检查这个状态,看内核是不是有数据到来。
3、IO复用模型
Linux提供select/poll,进程通过将一个或多个fd传递给select或者poll系统调用,阻塞在select操作上,这样select/poll可以帮我们侦测多个fd是否处于就绪状态。
select/poll是顺序扫描fd是否就绪,而且支持的fd数量有限,因此它的使用受到了一些制约。Linux还提供了一个epoll系统调用,epoll使用基于事件驱动的方式代替顺序扫描,因此性能更高。当有fd就绪时,立即回调函数rollback。
(Java核心类库Selector就是基于epoll的多路复用技术实现)
4、信号驱动IO模型
首先开启套接口信号驱动IO功能,并通过系统调用sigaction执行一个信号处理函数(此系统调用立即返回,进程继续工作,它是非阻塞的)。
当数据准备就绪时,就为该进程生成一个SIGIO信号,通过信号回调通知应用程序调用recvform来读取数据,并通知主循环函数处理数据。(好了告诉我,我来处理,我先去忙)
5、异步IO
告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通知我们。
与信号驱动模型的主要区别是:信号驱动IO是由内核通知我们何时可以开始一个IO操作;异步IO模型由内核通知我们何时已经完成。(等处理好了再告诉我)
IO多路复用技术:在IO编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者IO多路复用技术进行处理。IO多路复用技术通过把多个IO的阻塞到同一个select的阻塞上,从而可以使系统在单线程的情况下同时处理多个客户端请求。
而且与传统的多线程模型比,最大的优势是开销小,系统不需要创建新的额外进程或这线程,也不需要维护这些进程和线程的运行,降低了系统的维护工作量,节省了系统资源。
IO多路复用的主要应用场景如下:
①:服务器需要同时处理多个出于监听状态或者多个连接状态的套接字
②:服务器需要同时处理多种网络协议的套接字
select:
select 的设计思路是唤醒模式,「通过一个 socket 列表维护所有的 socket」。
socket 对应文件列表中的 fd,select 会默认限制最大文件句柄数为 1024,间接控制 fd[] 最大为 1024
select工作原理:
①:如果列表中的 Socket 都「没有数据」,就挂起进程
②:如果有一个 Socket 收到数据,就唤醒进程,将该线程从等待队列中移除,加入到工作队列,然后准备执行 socket 任务
要获取需要执行的任务的socket(就绪的socket),需要遍历一遍select列表,得到需要执行任务的socket
select缺点:
①:每次 select 都需要将进程加入到监视 socket 的等待队列,每次唤醒都要将进程从 socket 待队列移除。这里涉及两次遍历操作,而且每次都要将 FDS 列表传递给内核,有一定的开销
②:进程被唤醒后,只能知道有 socket 接收到了数据,无法知道具体是哪一个 socket 接收到了数据,所以需要用户进程进行遍历,才能知道具体是哪个 socket 接收到了数据
poll:
poll 其实内部实现基本跟 select 一样,区别在于它们底层组织 fd[] 的数据结构不太一样,从而实现了 poll 的最大文件句柄数量限制去除了
epoll:
select需要遍历socket列表获取就绪的socket,效率很低。epoll对此做了改进:
1、拆分:epoll 将添加等待队列和阻塞进程拆分成两个独立的操作,不用每次都去重新维护等待队列
①:先用 epoll_ctl 维护等待队列 eventpoll,它通过红黑树存储 socket 对象,实现高效的查找,删除和添加
②:再调用 epoll_wait 阻塞进程,底层是一个双向链表。显而易见地,效率就能得到提升
select 的添加等待队列和阻塞进程是合并在一起的,每次调用select()操作时都得执行一遍这两个操作,从而导致每次都要将fd[]传递到内核空间,并且遍历fd[]的每个fd的等待队列,将进程放入各个fd的等待队列中
2、直接返回有数据的 fd[]:select 进程被唤醒后,是需要遍历一遍 socket 列表,手动获取有数据的 socket,而 epoll 是在唤醒时直接把有数据的 socket 返回给进程,不需要自己去进行遍历查询
直接返回有数据的socket是如何实现的?
其实就是 epoll 会先注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似 callback 的回调机制,迅速激活这个文件描述符,当进程调用 epoll_wait() 时便得到通知
epoll作为select的替代者,作了很大改进,如:
①:支持一个进程打开的socket描述符(FD)不受限制(仅受限于操作系统的最大文件句柄数,如在1G内存的机器上是10万个左右),而select是有限制的,默认是1024
②:IO效率不会随着FD数目的增加而线性下降。select/poll每次调用由于都会线性扫描全部的集合,导致效率呈线性下降。而epoll是根据每个fd上的callback函数实现的,只有活跃的socket才会去主动调用callback函数,其他idle状态的socket则不会
③:使用mmap加速内核与用户的消息传递,epoll通过内核和用户空间mmap同一块内存来实现,避免了不必要的内存复制
④:epoll的API更加简单
Java的IO演进
在Java1.4推出NIO之前,基于Java的所有Socket通信都采用了同步阻塞模式(BIO),一请求一应答,当并发访问量增大时响应时间延迟也增大,在性能和可靠性方面
存在巨大的瓶颈。因此推出了NIO(Non-Blocked IO)
但是它仍有很多不完善的地方,特别是对文件系统的处理能力仍显不足,主要问题如下:
1)、没有统一的文件属性
2)、API能力比较弱,例如目录的级联创建和递归遍历,往往需要自己实现
3)、底层存储系统的一些高级API无法使用
4)、所有的文件操作都是同步阻塞调用,不支持异步文件读写操作
BIO (阻塞 I/O) |
排队打饭模式;在窗口排队,打好饭才走 | JDK1.4 之前 |
NIO (非阻塞 I/O) |
点单、等待被叫模式;菜好了自己去端 | JDK1.4(2002 年,java.nio 包) |
AIO(异步 I/O) |
包厢模式;点单后直接被端上桌 | JDK1.7 (2011 年) |
三种IO模式
1、传统的BIO
网络编程的基本模型是CS模型,也就是两个进程之间相互通信,其中服务端提供位置信息(绑定的IP地址和监听端口),客户端通过连接操作向服务端监听的地址发起连接请求,通过三次握手建立连接,如果连接建立成功,双方就可以通过网络套接字(Socket)进行通信。
在基于传统同步阻塞模型开发中,ServerSocket负责绑定IP地址,启动监听端口,Socket负责发起连接操作。连接成功之后,双方通过输入和输出流进行同步阻塞式通信。
ServerSocket的accept方法在连接传入前会一直阻塞。不过BIO主要的问题在于由一个独立的Acceptor线程负责监听客户端的连接,每当有一个新的客户端请求接入时,服务端必须创建一个新的线程处理新接入的客户端链路,一个线程只能处理一个客户端连接。处理完成之后,通过输出流返回应答给客户端,线程销毁。在高性能服务器应用领域,往往需要成千上万个客户端的并发连接,这种模型显然无法满足高性能、高并发接入的场景。
2、NIO
与Socket类和ServerSocket类相对应,NIO提供了SocketChannel和ServerScoketChannel两种不同的套接字实现,且都支持阻塞和非阻塞两种模式。
NIO类库:
1):Buffer,缓冲区
Buffer是一个对象,它包含一些要写入或者要读出的数据。在NIO库中,所有的数据都是用缓冲区来处理的。在读取数据时,它是直接读缓冲区的数据;在写入数据时,写入到缓冲区中。任何时候访问NIO中的数据,都是通过缓冲区进行操作。
缓冲区实质上是一个数组,通常是字节数组ByteBuffer。
2):Channel,通道
网络数据通过Channel读取和写入。通道与流的不同之处在于通道是双向的,而流是单向的,只在一个方向上移动(一个流必须是InputStream或OutputStream抽象类的子类),而通道是全双工的可以同时用于读写,且读写可同时进行。
3):多路复用器Selector
多路复用器提供选择已经就绪的任务的能力。
Selecor会不断地轮询注册在其上的Channel,如果某个Channel上面发生读或者写事件,这个channel就处于就绪状态,会被selector轮询出来,然后通过SelectionKey可以获取就绪Channel的集合,进行后续的IO操作。底层使用操作系统提供的epoll函数。
3、AIO
NIO2.0引入了新的异步通道的概念,并提供了异步文件通道和异步套接字通道的实现。异步通道提供以下两种方式获取操作结果:
1):通过java.util.concurrent.Future类来表示异步操作的结果
2):在执行异步操作的时候传入一个java.nio.channels
CompletionHandler接口的实现类作为操作完成的回调。
NIO2.0的异步套接字通道是真正的异步非阻塞IO,对应于UNIX网络编程中的事件驱动IO(AIO)。它不需要通过多路复用器对注册的通道进行轮询操作即可实现异步读写,从而简化了NIO的编程模型。
为什么选择Netty
开发出高质量的NIO程序并不是一件简单的事情,除去NIO固有的复杂性和BUG不谈,作为一个NIO服务端,需要能够同时处理网络的闪断、客户端的重复接入、客户端
的安全认证、消息的解编码、半包读写等情况。
从可维护角度看,NIO采用了异步非阻塞编程模型,而且是一个IO线程处理多条链路,它的调试和跟踪非常麻烦,特别是生产环境的问题,我们无法进行有效的调试和跟
踪,往往只能靠一些日志来辅助分析,定位难度大。
不选择Java原生NIO编程的原因:
1):NIO的类库和API频繁,使用麻烦,需要熟练掌握Selector、ServerSocketChannel、SocketChannel、ByteBuffer等
2):需要熟悉Java多线程编程,因为NIO涉及到Reactor模式
3):不齐可靠性能力的话,工作量和难度都非常大。例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流等问题。
4):JDK NIO的bug,例如臭名昭著的epoll bug,会导致Selecotor空轮询,最终导致CPU 100%。
而Netty的健壮性、功能、性能、可定制性和可扩展性都首屈一指,已经得到了成百上千的商业项目验证,如业界主流的RPC框架也使用Netty来构建高性能的异步通信能力。
优点如下:
1):API使用简单,开发门槛低
2):功能强大,预置了多种编解码功能,支持多种主流协议
3):定制能力强,可以通过ChannelHandler对通信框架进行灵活地扩展
4):性能高,与其他NIO框架对比,Netty性能最优
5):成熟、稳定,Netty修复了已经发现的所有的JDK NIO 的bug
6):社区活跃,版本迭代周期短,发现的bug可以及时被修复,同时,更多的新功能会加入
7):经历了大规模的商业应用考验,质量得到保证。
END.