IO模型
什么是IO?
从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。
我们想要进行 IO 操作,一定是要依赖内核空间的能力。并且,用户空间的程序不能直接访问内核空间。
当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。
因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间
我们在平常开发过程中接触最多的就是 磁盘 IO(读写文件) 和 网络 IO(网络请求和响应)。
从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上本质上都是在调用OS
内核提供的函数:read()、 write()
,具体 IO 的执行是由操作系统的内核来完成的。
当应用程序发起 I/O 调用后,会经历两个步骤:
- 内核等待 I/O 设备准备好数据
- 内核将数据从内核空间拷贝到用户空间
Java IO模型
推荐阅读:彻底理解 IO 多路复用实现机制 - 掘金 (juejin.cn)
Java IO(Input/Output)模型是Java中用于处理输入和输出操作的一套API。它主要用于文件读写、网络通信等场景中的数据传输。Java IO模型包括几个核心类和接口,例如InputStream
和OutputStream
用于处理字节流,Reader
和Writer
用于处理字符流。
Java IO模型有三种常见的:
- BIO (Blocking I/O) 阻塞IO
- NIO (Non-blocking/New I/O) 非阻塞IO
- AIO (Asynchronous I/O) 异步IO
1.BIO (Blocking I/O) 阻塞IO
同步阻塞 IO 模型中,使用传统的java.io
包中的输入流和输出流进行文件读取时,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量
2.NIO (Non-blocking/New I/O) 非阻塞IO
Java 中的 NIO 于 Java 1.4 中引入,对应 java.nio
包,提供了 Channel
, Selector
,Buffer
等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO
Java 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。
同步非阻塞 IO 模型
同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。
相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。
但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。 这时候就轮到I/O 多路复用模型了
I/O 多路复用模型
在理解多路复用模型之前,我们先分析一下上述的NIO
模型到底存在什么问题呢?很简单,由于线程在不断的轮询查看数据是否准备就绪,造成CPU
开销较大。既然说是由于大量无效的轮询造成CPU
占用过高,那么等内核中的数据准备好了之后,再去询问数据是否就绪是不是就可以了?答案是Yes
。
这里 “多路” 指的是多个网络连接客户端,“复用” 指的是复用同一个线程(单进程)。I/O 多路复用其实是使用一个线程来检查多个Socket的就绪状态,在单个线程中通过记录跟踪每一个socket(I/O流)的状态来管理处理多个I/O流
IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的
目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。
- select 调用:内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
- epoll 调用:linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。
IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。
Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。
IO多路复用的三种实现:
- select
- poll
- epoll
1.select
它仅仅知道了,有I/O事件发生了,却并不知道是哪个socket发生了IO时间,它只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
select缺点:
select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:
单个进程所打开的FD是有限制的,通过
FD_SETSIZE
设置,默认1024 ;每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;
需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大
- 对 socket 扫描时是线性扫描,采用轮询的方法,效率较低(高并发)
当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。
2.poll
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.
poll缺点
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有缺点:
- 每次调用 poll ,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;
- 对 socket 扫描是线性扫描,采用轮询的方法,效率较低(高并发时)
3.epoll
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd) 的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:
1 |
|
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是logn,其中n为红黑树元素个数)。
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。
在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:
1 | struct epitem{ |
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
从上面的讲解可知:通过红黑树和双链表数据结构,并结合回调机制,造就了epoll的高效。 讲解完了Epoll的机理,我们便能很容易掌握epoll的用法了。一句话描述就是:三步曲。
- 第一步:epoll_create()系统调用。此调用返回一个句柄,之后所有的使用都依靠这个句柄来标识。
- 第二步:epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。
- 第三部:epoll_wait()系统调用。通过此调用收集在epoll监控中已经发生的事件。
epoll 通过两个方面,很好解决了 select/poll 的问题。
_第一点_,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
_第二点_, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
epoll的优点
- 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
- 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll;
- 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
epoll缺点
- epoll只能工作在 linux 下
epoll 中红黑树和双链表的作用。
一、红黑树的作用
- 高效管理文件描述符(fd)集合:
- 红黑树可以快速地插入、删除和查找节点,时间复杂度为 O (log n),其中 n 是树中节点的数量。当使用 epoll_ctl 向 epoll 对象添加、修改或删除被监控的文件描述符及其事件时,红黑树用于存储这些文件描述符及其相关信息。
- 例如,在一个高并发的服务器中,可能需要同时监控大量的客户端连接,红黑树可以高效地管理这些连接的文件描述符,使得对特定文件描述符的操作能够快速进行。
- 避免重复添加和快速定位:
- 由于红黑树的特性,重复添加相同的文件描述符时可以快速识别并避免重复操作。同时,当需要查找特定的文件描述符以进行修改或删除操作时,也可以在红黑树中快速定位。
二、双链表(rdlist)的作用
- 存储就绪事件:
- 当与文件描述符相关的 I/O 事件发生时,内核中的回调函数(ep_poll_callback)会将发生的事件添加到 rdlist 双链表中。rdlist 用于存储已经就绪(即有数据可读、可写等)的事件。
- 例如,在网络服务器中,当某个客户端发送数据到服务器,使得服务器上对应的文件描述符变为可读状态时,这个事件会被添加到 rdlist 中。
- 供 epoll_wait 快速获取:
- 当调用 epoll_wait 检查是否有事件发生时,只需检查 rdlist 双链表中是否有元素。如果 rdlist 不为空,则表示有就绪事件,可以将这些事件复制到用户态并返回给用户程序进行处理。
三、事件放入红黑树和双链表的时机
- 将事件放入红黑树:
- 当使用 epoll_ctl 添加新的文件描述符及其相关事件进行监控时,会将该文件描述符及其事件信息插入到红黑树中。红黑树中存放的是Socket的文件描述符fd,以及相关的IO事件
- 例如,服务器启动时,对于每个新建立的客户端连接,将其对应的文件描述符和关注的事件(如可读、可写等)添加到 epoll 对象中,此时就会将该文件描述符放入红黑树。
- 将事件放入双链表:
- 当与红黑树中的文件描述符相关的 I/O 事件发生时,内核的回调函数会将该事件放入 rdlist 双链表中。
- 比如,在网络通信中,当一个客户端发送数据到服务器,使得服务器上对应的文件描述符变为可读状态,内核会检测到这个事件,并将其放入 rdlist。此时,当服务器调用 epoll_wait 时,就可以从 rdlist 中获取到这个就绪事件并进行处理。
epoll 的 边缘触发和水平触发有什么区别?
epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。
这两个术语还挺抽象的,其实它们的区别还是很好理解的。
- 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
- 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。
这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。
如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换
select/poll/epoll之间的区别
select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现
select | poll | epoll | |
---|---|---|---|
操作方式 | 遍历 | 遍历 | 回调 |
数据结构 | bitmap | 数组 | 红黑树 |
最大连接数 | 1024(x86)或 2048(x64) | 无上限 | 无上限 |
最大支持文件描述符数 | 一般有最大值限制 | 65535 | 65535 |
fd拷贝 | 每次调用select,都需要把fd集合从用户态拷贝到内核态 | 每次调用poll,都需要把fd集合从用户态拷贝到内核态 | fd首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝 |
工作模式 | LT | LT | 支持ET高效模式 |
工作效率 | 每次调用都进行线性遍历,时间复杂度为O(n) | 每次调用都进行线性遍历,时间复杂度为O(n) | 事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1) |
epoll是Linux目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超select和poll。目前流行的高性能web服务器Nginx正式依赖于epoll提供的高效网络套接字轮询服务。但是,在并发连接不高的情况下,多线程+阻塞I/O方式可能性能更好。
3.AIO (Asynchronous I/O) 异步IO
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当I/O操作完成时,系统会调用应用程序提供的回调函数来处理结果
目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升
最后,来一张图,简单总结一下 Java 中的 BIO、NIO、AIO。