还在分不清各种 IO 模型?
IO 模型
我们的程序基本上都是对数据的 IO 操作以及基于 CPU 的运算。
基于 Java 的开发大部分是网络相关的编程,不管是基于如 Tomcat 般的 Web 容器,或是基于 Netty 开发的应用间的 RPC 服务。为了提供系统吞吐量, 降低硬件资源的开销,IO 模型也在不断适应大规模、高并发需求不断演进,今天我们就来看看这个在网络上高频出现的词汇 IO 模型
linux IO 模型
首先我们要明确,用户程序从计算机硬件读取数据(包括文件、网络数据等),会经历数据从硬件设备中读取到系统内核后,再拷贝到用户空间的过程。在 linux 系统中,针对这一操作提供了 5 种 IO 模型用于优化不同场景下的 IO 操作。
同步阻塞 IO 系统程序调用 recvfrom 阻塞等待内核将数据准备(从网卡将数据读取到内存中)。之后用户通过 recvfrom 等待内核将数据准备好,此时内核将数据从内核缓冲区复制到用户态缓冲区。
blocking I/O 发起 system call recvfrom()时,进程将一直阻塞等待另一端 Socket 的数据到来。在该模式下,会阻塞其他连接的建立,因此一般都会通过多线程处理 Socket 数据的读取。
Blocking I/O 优点是简单易用,对于本地 I/O 而言性能很高。缺点是处理网络 I/O 时,造成进程阻塞,以及创建线程的资源消耗。
同步非阻塞 IO
系统程序调用 recvfrom 时并不会阻塞等待,但是需要调用方不停的去轮询内核,获取数据准备状态。之后用户发起的(同步)recvfrom 检查到内核将数据准备好后,进行数据由内核到用户空间的复制。
相对于阻塞 I/O 的等待,非阻塞 I/O 隔一段时间就就需要发起 system call 判断数据是否就绪。如果数据就绪,就从 kernel space 复制到 user space,操作数据; 否则,kernel 会立即返回 EWOULDBLOCK 这个错误。
recvfrom 有个参数叫 flags,默认情况下阻塞。可以设置 flag 为非阻塞让 kernel 在数据未就绪时直接返回。这就是”非阻塞”主要是指数据准备阶段。
IO 多路复用
系统程序调用 select/poll/epoll 会阻塞等待至少有一个套接字就绪则返回。用户(同步)调用 recvfrom,获取这些就绪的套接字,轮询将数据由内核复制到用户态缓冲区。
I/O Multiplexing 首先向 kernel 发起 system call,传入 file descriptor 和感兴趣的事件(readable、writable 等)让 kernel 监测, 当其中一个或多个 fd 数据就绪,就会返回结果。程序再发起真正的 I/O 操作 recvfrom 读取数据。
信号驱动 IO
系统调用 sigaction 不会阻塞。当数据准备完成之后,会主动的通知用户进程数据已经准备完成,对用户进程做一个回调。用户发起的(同步)recvfrom 将就绪的数据由内核复制到用户态缓冲区。
第一次发起 system call 不会阻塞进程,kernel 的数据就绪后会发送一个 signal 给进程。进而发起真正的 IO 操作。
异步 IO
系统调用 aio_read 不会阻塞。直到 I/O 数据准备好内核会直接将数据复制到用户空间,然后内核主动会给用户进程发送通知,告诉用户进程信号表示并进行数据处理。
既然说到异步 IO,则前面的几种 IO 模型都是同步的,由上图可以看到,在数据拷贝(内核态到用户态)时,仍然是阻塞的。在异步 IO 中,请求连接到内核后,从数据准备到复制整个过程 都是在内核中完成,对应用户程序不会阻塞,直到请求数据完全准备好后,通过回调函数通知用户程序完成整个 IO 操作。
Java 中的 IO 模型
Java 中提供的 IO 相关的 API,主要是基于操作系统底层的 IO 的操作。在 Java 中的 BIO、NIO、AIO 属于 Java 对操作系统的各种 IO 模型的封装。当我们使用这些 API 时,不用关注底层 IO 的实现。
BIO
同步阻塞 IO,服务端通过阻塞输入流来监听客户端是否有数据写入,当处理输入数据时,程序会等待内核完成处理完成并返回后才会继续执行。
上图可以看到,服务端通过 ServerSocket#accept 阻塞方法监听客户端的接入,然后阻塞在通过阻塞输入流等待客户端的输入,如果一直没有输入,则其他客户端都会被阻塞在此。
我们可以通过多线程来改善,每个客户端连接时,都由独立的线程来处理,虽然通过多线程可以解决客户端间的阻塞问题,但单个线程内然是阻塞模式, 并且当客户端过多时需要足够的线程来支持,比较耗费系统资源。
NIO
同步非阻塞 IO,基于多路复用模型,依赖于服务器操作系统,通过一个 Selector 即可监听多个连接,并进行 IO 处理。但要注意,如果处理 IO 的过程较长一样会影响到其他的连接。
服务端通过 Selector#select 阻塞方法,监听 Channel 状态,一旦有 Channel 准备就绪,程序才会继续往下执行,因此需要不断轮询并监控 Channel 的状态变更。与 BIO 的多线程模式非常相似,只不过 BIO 是基于多线程技术实现,而 NIO 是基于操作系统底层提供的函数,效率更好且资源消耗更少。
AIO
异步非阻塞 IO,在 JDK1.7 之后提供了异步的相关 Channel,AIO 提供异步功能,基于回调函数实现,同样依赖于操作系统底层的异步 IO 模型,异步操作的实现是在对应的 accept、connection、read、write 等方法异步执行,完成后会主动调用回调函数。
其中 accept、read 等方法都是非阻塞的,即立即返回结果,几乎所有的异步操作都是基于回调函数实现,这种方式不管是对操作系统资源的利用以及效率上都是最佳的实现。
虽然三种 IO 模型的演进是为了提升系统处理 IO 的能力,但是开发的复杂度也同步上升:
BIO 方式适用于连接数目比较小且固定的架构,需要依赖于线程来支持多个客户端接入,但程序直观简单易理解。
NIO 方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂。
AIO 方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用 OS 参与并发操作,编程比较复杂。
同/异步与(非)阻塞
关于阻塞、非阻塞、同步、异步这些名词的解释,可以在网上找到很多解释,但是如何能够从本质上描述其含义,正如 IO 与 NIO 中说到的阻塞与非阻塞,又是怎么体现的呢?
我们一般说说的 IO 模型,其实是服务端进行 IO 操作执行与实现的形式,程序将数据从程序写入或读写时,与硬件设备(比如硬盘、网卡)间,基于操作系统提供的系统 api 实现数据由用户态与内核态交互的一种形式。
同步
程序执行需要等待返回后才会继续。
异步
与同步相反,比较直观的就是线程。
阻塞 IO
程序需要等待内核 IO 操作完成后返回到用户空间继续执行用户程序的操作指令。这里的阻塞主要是调用操作系统 api 被阻塞导致程序挂起,描述的是程序当前执行的状态。
非阻塞 IO
既然阻塞是调用操作系统 api 被阻塞,那么非阻塞则相反,得益于操作系统提供的函数支持,一般是通过轮询机制与回调函数实现。
同步与异步属于程序发起请求的方式;阻塞与非阻塞属于服务响应 IO 操作的底层实现方式。
示例
基于上面的理解,我们看下在 Java 中如何实现 BIO、NIO 以及 AIO。
BIO
Server:
Client:
NIO
省略
AIO
Server:
Client:
结束语
通过了解操作系统层面的 IO 模型可以让我们理解 IO 是如何实现,以及通过 Java 语言提供的类库实现了操作系统底层 API 调用的复杂性。
评论