IO 读写的基础原理
程序进行 IO 的读写,依赖于系统底层的 IO 读写,基本上会用到底层的 read&write 两大系统调用。在不同的操作系统中,IO 读写的系统调用的名称可能不完全一样,但是基本功能是一样的。
read 系统调用,并不是直接从物理设备把数据读取到内存中; write 系统调用,也不是直接把数据写入到物理设备。上层应用无论是调用操作系统的 read,还是调用操作系统的 write,都会涉及缓冲区。具体来说,调用操作系统的 read,是把数据从系统内核缓冲区复制到应用进程缓冲区:而 write 系统调用,是把数据从进程缓冲区复制到内核缓冲区。
也就是说,应用程序的 IO 操作,实际上不是物理设备级别的读写,而是缓存的复制。read&write 两大系统调用,都不负责数据在内核缓冲区和物理设备(如磁盘)之间的交换,这项底层的读写交换,是由操作系统内核(Kernel)来完成的。
在程序中,无论是 Socket 的 IO、还是文件 IO 操作,都属于上层应用的开发,它们的输入( Input)和输出( Output )的处理,在编程的流程上,都是一致的。
内核缓冲区与进程缓冲区
为什么设置那么多的缓冲区,为什么要那么麻烦呢?缓冲区的目的,是为了减少频繁地与设备之间的物理交换。大家都知道,外部设备的直接读写,涉及操作系统的中断。发生系统中断时,需要保存之前的进程数据和状态等信息,而结束中断之后,还需要恢复之前的进程数据和状态等信息。为了减少这种底层系统的时间损耗、性能损耗,于是出现了内核缓冲区 。
有了内核缓冲区,应用使用 read 系统调用时,仅仅把数据从内核缓冲区复制到应用的缓冲区(进程缓冲区);上层应用使用 write 系统调用时,仅仅把数据从进程缓冲区复制到内核缓冲区中。底层操作会对内核缓冲区进行监控,等待缓冲区达到一定数量的时候,再进行 IO 设备的中断处理,集中执行物理设备的实际 IO 操作,这种机制提升了系统的性能。至于什么时候中断(读中断、写中断),由操作系统的内核来决定,程序则不需要关心。
在 Linux 系统中,操作系统内核只有一个内核缓冲区。而每个用户程序(进程),有自己独立的缓冲区,叫作进程缓冲区。所以,用户程序的 IO 读写程序,在大多数情况下,并没有进行实际的 IO 操作,而是在进程缓冲区和内核缓冲区之间直接进行数据的交换。
详解系统调用流程
用户程序所使用的系统调用 read&write ,它们不等价于数据在内核缓冲区和磁盘之间的交换。read 把数据从内核缓冲区复制到进程缓冲区,write 把数据从进程缓冲区复制到内核缓冲区,具体的流程如下图:
以 read 系统调用为例,完整输入流程的两个阶段:
- 等待数据准备好
- 从内核向进程复制数据
如果是 read 一个 socket(套接字),那么以上两个阶段的具体处理流程如下:
- 第一个阶段,等待数据从网络中到达网卡。当所等待的分组到达时,它被复制到内核中的某个缓冲区。找个工作有操作系统自动完成,用户程序无感知。
- 第二个阶段,就是把数据从内核缓冲区复制到应用进程缓冲区。
如果是在 Java 服务器端,完成一次 socket 请求和响应,完整的流程如下:
- 客户端请求:通过 write 系统调用将请求数据写入进程缓冲区,然后由系统将数据复制到内核缓冲区,将内核缓冲区中的数据写入网卡,网卡通过底层通信协议将数据发给服务端。
- 服务端获取请求数据:服务端通过 read 系统调用,将内核缓冲区的数据复制到进程缓冲区。
- 服务端返回数据:完成业务处理后,构建好响应数据,将这些数据通过 write 系统调用,从用户缓冲区写入内核缓冲区中,然后由系统将内核缓冲区中的数据写入网卡,网卡通过底层通信协议将数据发给客户端。
四种主要的 IO 模型
同步阻塞 IO(Blocking IO)
最常用的 IO 模型就是阻塞 IO 模型,在默认情况下,所有 IO 操作都是阻塞的。
以套接字接口为例,在阻塞式 IO 模型中,在进程空间中调用 read,其系统调用直到数据包达到切被复制到应用进程的缓冲区中或者发生错误才返回,在此期间线程会一直等待,再从调用 read 开始到它返回的整段时间内都是被阻塞的,因此被称之为阻塞 IO 模型。
同步非阻塞 IO (None Blocking IO)
socket 默认是阻塞模式,在 Linux 系统下,可以设置成非阻塞模式。使用非阻塞模式的 IO 读写。一旦使用非阻塞模式开始 IO 系统调用,会出现以下两种情况:
- 在内核缓冲区中没有数据的情况下,系统调用会立即返回,返回一个调用失败信息
- 在内核缓冲区中有数据的情况下,是阻塞的,直到数据从内核缓冲区复制到应用进程缓冲区。复制完成后,系统调用返回成功,此时应用就可以开始处理获取到的数据了。
非阻塞 IO 的特点:应用进程的线程需要不断的进行 IO 系统调用,轮询数据是否已经准备好,如果没有准备好,就继续轮询,知道完成 IO 系统调用为止。
非阻塞 IO 的优点:每次发起的 IO 系统调用,在内核等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好。
同步非阻塞 IO 的缺点:不断地轮询内核,这将占用大量的 CPU 时间,效率低下。
总体来说,在高并发应用场景下,同步非阻塞 IO 也是不可用的。一般 Web 服务器不使用这种 IO 模型。这种 IO 模型一般很少直接使用,而是在其他 IO 模型中使用非阻塞 IO 这-特性。在 Java 的实际开发中,也不会涉及这种 IO 模型。
这里说明一下,非阻塞 IO,可以简称为 NIO,但是,它不是 Java 中的 NIO,虽然它们的英文缩写一样,希望大家不要混淆。Java 的 NIO ( New IO ),对应的是另外的一种模型,叫作 IO 多路复用模型( IO Multiplexing )。
IO 多路复用模型
在 IO 多路复用模型中,引入了一种新的系统调用,查询 IO 的就绪状态。 在 Linux 系统中,对应的系统调用为 select,epoll 系统调用。通过该系统调用,一个进程可以监视多个文件描述符,一旦某个描述符就绪( 一般是内核缓冲区可读/可写〉,内核能够将就绪的状态返回给应用程序。随后,应用程序根据就绪的状态,进行相应的 IO 系统调用。
目前支持 IO 多路复用的系统调用,有 select、epoll 等等。select 系统调用,几乎在所有的操作系统上都有支持,具有良好的跨平台特性。epoll 是在 Linux 2.6 内核中提出的,是 select 系统调用的 Linux 增强版本。
应用进程通过将一个或多个 fd 传递给 select 系统调用,阻塞在 select 操作上,这样 select 可以帮我们侦测多个 fd 是否处于就绪状态。select 是顺序扫描 fd 是否就绪,而且支持的 fd 数量有限,因此它的使用受到了一些限制。epoll 系统调用不同于 select,它是基于事件驱动方式代替顺序扫描,因此性能更高,当有 fd 就绪时,立即回调函数 rollback。
假如在 java 中发起一个多路复用 IO 的 read 读操作的系统调用,流程如下:
- 选择器注册:首先,将需要 read 操作的目标 socket 网络连接,提前注册到 select或 epoll 选择器中,Java 中对应的选择器类是 Selector 类。然后,才可以开启整个 IO 多路复用模型的轮询流程。
- 就绪状态的轮询:通过选择器的查询方法,查询注册过饿所有 socket 连接的就绪状态。通过查询的系统调用,内核会返回一个就绪的 socket 列表。当任何一个注册过的 socket 中的数据准备好了,内核缓冲区有数据了,内核就将该 socket 加入到就绪的列表中,整个查询系统调用过程是阻塞的。
- 获取到了就绪状态的 socket 列表后,发起 read 系统调用,线程阻塞,内核开始复制数据,将数据从内核缓冲区复制到用户缓冲区。
- 复制完成后,内核放回结果,用户线程才会解除阻塞状态。
IO 多路复用模型的特点 :IO 多路复用模型的 IO 涉及两种系统调用,一种是 select/epoll(就绪查询),一种是 IO 操作。IO 多路复用模型建立在操作系统的基础设施之上,即操作系统的内核必须能够提供多路分离的系统调用 select/epoll。
和 NIO 模型相似,多路复用 IO 也需要轮询。负责 select/epoll 状态查询调用的线程,需要不断地进行 select/epoll 轮询,查找出达到 IO 操作就绪的 socket 连接。
IO 多路复用模型与非阻塞 IO 模型是有密切关系的。对于注册在选择器上的每一个可以查询的 socket 连接,一般都设置成为非阻塞模型。这一点,对于用户程序而言是无感知的。
IO 多路复用模型的优点:与一个线程维护一个连接的阻塞 IO 模式相比,使用 select/epoll 的最大优势在于,一个选择器查询线程可以同时处理成千上万个连接( Connection )。系统不必创建大量的线程,也不必维护这些线程,从而大大减小了系统的开销。
Java 语言的 NIO (NewIO)技术,使用的就是 IO 多路复用模型。 在 Linux 系统上,使用的是 epoll 系统调用。
IO 多路复用模型的缺点:本质上, select/epoll 系统调用是阻塞式的,属于同步 IO。都需要在读写事件就绪后,由系统调用本身负责进行读写,也就是说这个读写过程是阻塞的。
异步 IO 模型
异步 IO 模型( Asynchronous IO, 简称为 AIO )。AIO 的基本流程是:用户线程通过系统调用,向内核注册某个 IO 操作。内核在整个 IO 操作(包括数据准备、数据复制)完成后,通知用户程序, 用户执行后续的业务操作。
在异步 IO 模型中,在整个内核的数据处理过程中,包括内核将数据从网络物理设备(网卡)读取到内核缓冲区、将内核缓冲区的数据复制到用户缓冲区,用户程序都不需要阻塞。
发起一个异步 IO 的 read 读操作系统调用,流程如下:
- 当用户线程发起了 read 系统调用会立即返回,用户线程不阻塞
- 内核就开始了 IO 的第一个阶段:准备数据。等到数据准备好了,内核就会将数据从内核缓冲区复制到用户进程缓冲区。
- 内核会给用户发送一个信号(Signal),或者回调用户线程注册的回调接口,告诉用户线程 read 操作完成了。
异步 IO 模型的特点:在内核等待数据和复制数据的两个阶段,用户线程都不是阻塞的。用户线程需要接收内核的 IO 操作完成的事件,或者用户线程需要注册一个 IO 操作完成的回调函数。正因为如此,异步 IO 有的时候也被称为信号驱动 IO。
异步 IO 异步模型的缺点: 应用程序仅需要进行事件的注册与接收,其余的工作都留给了操作系统,也就是说,需要底层内核提供支持。理论上来说,异步 IO 是真正的异步输入输出,它的吞吐量高于 IO 多路复用模型的吞吐量。
就目前而言,Windows 系统下通过 IOCP 实现了真正的异步 IO。而在 Linux 系统下,异步 IO 模型在 2.6 版本才引入,目前并不完善,其底层实现仍使用 epoll ,与 IO 多路复用相同, 因此在性能上没有明显的优势。
大多数的高并发服务器端的程序,一般都是基于Linux 系统的。因而,目前这类高并发网络应用程序的开发,大多采用 IO 多路复用模型。
Java 的 I/O 演进
在 JDK 1.4 推出 Java NIO 之前,基于 Java 的所有 Socket 通信都采用了同步阻塞模式,这种一请求一应答的通信模型简化了的应用开发,但是在性能和可靠性方面却存在着巨大的瓶颈。
正式由于 Java 传统 BIO 的拙劣表现,才使得 Java 支持非阻塞 IO 的呼声日渐高涨,终于在 JDK1.4 版本提供了新的 NIO 类库,Java 终于支持非阻塞 IO 了。
从 JDK1.0 到 JDK1.3,Java 的 IO 类库都非常原始,很多 UNIX 网络编程中的概念或者接口都没有体现,例如 Pipe,Channel,Buffer 和 Selector 等。2002 年发布 JDK1.4时, NIO 以 JSR-51 的身份正式随 JDK 发布。新增了个 java.nio 包,提供了很多进行异步 IO 开发的 API 和类库,主要类和接口如下:
- 进行异步 IO 操作的缓冲区 ByteBuffer 等。
- 进行异步 IO 操作的管道 Pipe。
- 进行各种 IO 操作(异步或者同步)的 Channel,包括 ServerSocketChannel 和 SocketChannel
- 各种字符集的编码能力和解码能力
- 实现非阻塞 IO 操作的多路复用器 selector
- 基于流行的 Perl 实现的正则表达式类库
- 文件通道 FileChannel
新的 NIO 类库的提供,极大的促进了基于 Java 的异步非阻塞编程的发展和应用,但是依然有不完善的地方,特别是对文件系统的处理能力任显不足,主要问题如下:
- 没有统一的文件属性(例如读写权限)
- API 能力比较弱,例如目录的级联创建和递归遍历,往往需要自己实现
- 底层存储系统的一些高级 API 无法使用
- 所有文件操作都是同步阻塞调用,不支持异步文件读写操作
直到 2011 年 7 月 28 日,JDK1.7 正式发布。将原来的 NIO 类库进行了升级,称之为 NIO2.0。提供了如下方面的改进:
- 提供能够批量获取文件属性的 API,这些 API 具有平台无关性,不与特性的文件系统相耦合。另外还提供了标准文件系统的 SPI,让各个服务提供商扩展实现。
- 提供 AIO 功能,支持基于文件的异步 IO 操作和针对网络套接字的异步操作。
- 完成 JSR-51 定义的通道功能,包括对配置和多播数据报的支持等。