Unix 5种IO模型
Linux的IO模型
大致流程:
当用户执行read操作,会经历两个阶段:
- wait for data (等待数据就绪):等待数据到达(可以是网络数据),并拷贝到内核的缓冲区中。
- copy data from kernel to user (将数据从内核空间拷贝到用户空间):将内核缓冲区中的数据拷贝到用户缓冲区中。
可简化为下图:
网络应用需要处理的无非就是两大类问题,网络IO,数据计算。相对于后者,网络IO的延迟,给应用带来的性能瓶颈大于后者。Linux网络IO的模型大致有如下几种:
-
阻塞IO
-
非阻塞IO(仍是同步的)
-
IO多路复用
-
信号驱动IO
-
异步IO
阻塞IO
用户想要读取数据,会调用recvfrom命令,尝试从内核获取数据,若内核中的数据未准备就绪,那么用户线程会阻塞等待,一直阻塞到内核准备好了数据,并把数据拷贝到用户态,返回ok时才停止阻塞。
缺点:无效等待,且只能监听一个FD。
总结:两阶段全程阻塞。
非阻塞IO
调用recvfrom命令,若内核中的数据未准备就绪,会返回一个EWOULDBLOCK错误码,然后用户进程继续**轮询(polling)**地调用recvfrom,直到内核数据准备就绪才停止轮询,然后用户进程阻塞等待数据从内核态拷贝到用户态。
缺点:
- 第一阶段的轮询会造成CPU空转,CPU使用率暴增,不能发挥CPU的作用。
- 只能监听一个FD。
总结:第一阶段轮询,第二阶段阻塞。
IO多路复用(事件驱动模型)
FD 文件描述符
文件描述符(File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
第一阶段不在调用recvfrom,而是调用select命令,这一个线程能监听多个FD,阻塞等待FD变为可读/可写。若有FD可读/可写,则会通知用户线程,接着调用recvfrom,进行第二阶段。
总结:两阶段都阻塞,一阶段select监听多个FD,二阶段recvfrom等待拷贝。
监听FD的方式:select、poll、epoll
IO多路复用是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听FD的方式、通知的方式又有多种实现,常见的有:
-
select
- select在Linux中应用最早,而且很多平台(windows等)都有该方法,能做到跨平台。
- 将需要处理的数据封装成多个FD,然后在用户态中创建一个FD集合(其实就是包含了数组的结构体,该集合的大小是要监听的FD中的最大值+1,但是整体大小是有限制的,是1024bit),这个集合的长度大小是有限制的,同时在这个集合中,标明出来我们要监听哪些FD(fd=1)。执行select后会把这个FD集合拷贝到内核态,内核遍历该集合,若没有FD就绪,就休眠;若有FD就绪,就再遍历一遍集合,把就绪的FD标记出来(fd=1,未就绪写0),然后拷贝给用户态,用户遍历该集合,在对应的FD节点上调用recvfrom获取数据。不断循环上述过程,直至所有数据都获取完了。
- 虽然比阻塞IO和非阻塞IO好,但涉及到FD集合的频繁拷贝和频繁遍历,性能不是很好。
- FD集合只有1024位,能监听的FD有限,无法应对如今上万的高并发。
-
poll
- 在用户态中创建pollfd数组,里面添加要监听的FD信息,可自定义数组大小。调用poll命令后将pollfd数组拷贝至内核,并转成链表存储,长度无上限。后面流程几乎和select的一样,内核遍历链表,判断是否有fd就绪,若没有就休眠,若有则在pollfd数组上标记,然后拷贝至用户态,并返回就绪fd数量n,用户判断若n大于0,则遍历数组,找到就绪fd,然后进行二阶段。
- 在select基础上做了简单改进,把有限长的fd集合改为可自定义大小的pollfd数组,然后在内核中用无大小上限的链表存储,若监听fd较多,遍历耗时也会变旧,所以实际来说大小还是有上限的,拷贝和遍历操作仍存在,性能提升不明显。
-
epoll
-
主要是3个函数:
- 用户态调用epoll_create函数,在内核创建eventpoll结构体(epoll实例),结构体内部包含两个东西:
-
红黑树-> 记录要监听的FD
-
链表->记录就绪的FD
-
紧接着每个fd调用epoll_ctl函数,将该fd注册到epoll实例上,将要监听的fd添加到红黑树,并且为每个fd绑定一个监听(回调)函数,这个函数会在fd数据就绪时触发,把这个就绪的fd添加到链表list_head。
-
再调用epoll_wait函数,在用户态创建一个空的events数组,在超时时间内会检查链表中是否有就绪的fd,有的话就会添加到events数组中,并且返回对应的操作的数量,用户态的此时收到响应后,从events中拿到对应准备好的数据的节点,进行二阶段。该函数可以在等待时多次调用。
-
总结:epoll对select和poll做了改进,性能强大:
-
用eventpoll结构体代替了fd集合和pollfd数组,内部的红黑树保存要监听的fd,大小无上限,增删改查性能稳定且高效。
-
每个fd只需执行一次epoll_ctl添加到红黑树(若后续还有删改增,就不止一次了)。并靠回调函数自动添加到链表上,无需重复遍历来监听fd。
-
有fd就绪时,调用epoll_wait函数可以得到通知,获取就绪的fd,无需用户态和内核态间的数组重复拷贝。
-
-
select、poll、epoll 应用场景
很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,但它们都有各自的使用场景。
-
select
- select 的 timeout 参数精度为1ns,而 poll 和 epoll 为 1ms,因此select更加适用于实时要求更高的场景,如核反应堆的控制。
- select 可移植性更好,几乎被所有主流平台所支持。
-
poll
-
poll 没有fd监听数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。
-
若同时监控的fd小于 1000 个,没必要用epoll,因为这个应用场景下并不能体现 epoll 的优势。
-
因为 epoll 中的所有fd都存储在内核中,若监控的fd状态变化多,且都是非常短暂的,也没有必要使用 epoll,每次对某个fd的状态改变(增删改)都需要通过 epoll_ctl 函数进行系统调用,频繁系统调用会降低效率。并且epoll 的fd存储在内核,不容易调试(用户态看不到内核内部的运作进程)。
-
-
epoll
- 只需运行在 Linux 上,并且有非常大量的fd需要同时监控,而且这些连接最好是长连接(fd状态变化少)。
epoll中的事件通知模式:LT和ET
当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知模式有两种:
- LevelTriggered(epoll默认):简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。
- EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化(未就绪转为就绪)时,调用epoll_wait才会被通知。
举个栗子:
-
假设一个客户端socket对应的FD已经注册到了epoll实例中
-
客户端socket发送了2kb的数据
-
服务端调用epoll_wait,得到通知说FD就绪
-
服务端从FD读取了1kb数据回到步骤3(再次调用epoll_wait,形成循环)
-
- 如果我们采用LT模式,因为FD中仍有1kb数据,则第⑤步依然会返回结果,并且得到通知。
- 如果我们采用ET模式,因为第③步已经消费了FD可读事件,第⑤步FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时。
信号驱动IO
用户与内核建立SIGIO信号关联,并绑定回调函数,当内核有fd就绪时,内核就会发出SIGIO信号通知用户,这期间用户态不阻塞,用户收到SIGIO回调信号,就调用recvfrom进行二阶段。
缺点:当有大量IO时,信号会非常多,SIGIO处理函数来不及处理就会导致信号队列溢出,且用户态和内核态间频繁的信号交互导致性能低。
总结:一阶段建立SIGIO信号和回调函数,不阻塞,二阶段阻塞。
异步IO
用户调用aio_read,内核返回ok。准备数据和拷贝数据两个阶段都由内核完成,且用户两阶段都不存在任何阻塞。
缺点:内核要完成全部工作,在高并发的场景下内核的压力非常大,性能不好,除非在用户层面做限流,但系统复杂度就高了。
总结:全部完成后通知用户,两阶段都不阻塞。
5者对比
Java NIO
Java NIO 是从Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java IO API。NIO与原来的IO有同样的作用和目的,但是实现方式完全不同, NIO支持面向缓冲区的、基于通道的IO操作。 NIO将以更加高效的方式进行文件的读写操作。
Reactor事件驱动模型
在Reactor模型中,主要有四个角色:客户端,Reactor,Acceptor和Handler。这里Acceptor会不断地接收客户端的连接,然后将接收到的连接交由Reactor进行分发,最后有具体的Handler进行处理。
下图是NIO的模型,是对IO多路复用中的Reactor模型的实现。如果客户端也使用NIO,则客户端也会有对称的NIO模型。
Channel
-
通道 Channel 是对原 I/O 包中的流stream的模拟,可以通过它读取和写入数据。
-
流只能是单向的(一个流必须是 InputStream 或者 OutputStream 的子类);而通道是双向的,可以用于读、写或同时读写。
-
通道包括以下类型:
-
FileChannel: 从文件中读写数据;
-
DatagramChannel: 通过 UDP 读写网络中数据;
-
SocketChannel: 通过 TCP 读写网络中数据;
-
ServerSocketChannel: 可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel。
-
Buffer
-
为了保证每个通道的数据读写速度,发送给一个通道的所有数据都必须先放到缓冲区中,同样地,从通道中读取的任何数据都要先读到缓冲区中。也就是说,不会直接对通道进行读写数据,而是要先经过缓冲区。
-
buffer实质上是一个数组,但它不仅仅是一个数组,它能提供对数据的结构化访问,还可以跟踪系统的读/写进程。
-
Buffer有两种工作模式:写模式和读模式。在读模式下,应用程序只能从Buffer中读取数据,不能进行写操作。但是在写模式下,应用程序是可以进行读操作的,这就表示可能会出现脏读的情况。所以一旦决定要从Buffer中读取数据,一定要将Buffer的状态改为读模式。
-
缓冲区包括以下类型:
-
ByteBuffer
-
CharBuffer
-
ShortBuffer
-
IntBuffer
-
LongBuffer
-
FloatBuffer
-
DoubleBuffer
-
-
buffer靠3个状态变量实现读写:
- capacity:最大容量;
- position:目前这在操作(读/写)的数据块的位置;
- limit:最大可以进行操作的数据块的位置。读写模式就是靠这个变量控制。
- 当buffer为写模式时,limit = capacity,position = 0 从头开始写,写了多少,position就到哪,position最多可以写到limit(capacity)。
- 当buffer为读模式时,limit = position,position = 0 从头开始读,读了多少,position就到哪,position最多可以读到limit(也就是之前写的数据的末尾)。读完之后清空buffer,变回初始状态。
Selector
-
Java NIO能实现IO多路复用的一个线程监听多个事件的效果就是靠Selector,是非常重要的概念。
-
一个 Thread 使用一个 Selector 通过轮询的方式去监听多个 Channel 上的事件。
-
需要向Selector对象注册它感兴趣事件的Channel。selector内部会维护一个注册过的channel的数组,一个selector最多可以监听1024个channel:
// Initial capacity of the poll array private final int INIT_CAP = 8; // Maximum number of sockets for select(). // Should be INIT_CAP times a power of 2 private final static int MAX_SELECTABLE_FDS = 1024; // The list of SelectableChannels serviced by this Selector. Every mod // MAX_SELECTABLE_FDS entry is bogus, to align this array with the poll // array, where the corresponding entry is occupied by the wakeupSocket private SelectionKeyImpl[] channelArray = new SelectionKeyImpl[INIT_CAP];