I/O 模型

I/O 是什么?

从字面意思上来看,它就是输入/输出的意思,但从计算机和应用程序的角度它有不一样的含义

根据冯.诺依曼结构,计算机结构分为五部分:运算器、控制器、存储器、输入设备、输出设备。如下图所示:

11

输入设备向计算机输入数据,如:鼠标、键盘等;输出设备接收计算机输出数据,如:显示器等。从计算机结构的角度来看:I/O 描述了计算机系统和外部设备之间通信的过程

操作系统为了稳定性和安全性,将进程的地址空间划分为:用户空间内核空间。运行的应用程序都是在用户空间,而和系统资源相关的操作都在内核空间,由操作系统统一管理

应用程序不能直接访问内核空间,当要执行 I/O 操作时,只能发起系统调用让操作系统帮助完成。每次系统调用都会从用户态切换成内核态,当调用完成后,再切换回用户态,需要一定的开销

平时开发程序中接触最多的就是:磁盘 I/O (读写文件)网络 I/O (网络请求和响应)

所以从应用程序的角度来看:应用程序对系统内核发起 I/O 调用 (系统调用),操作系统负责执行这些系统调用。也就是应用程序只是发起了 I/O 调用的请求,具体的 I/O 执行是由操作系统内核完成

换个层面思考,可以把两个角度合二为一。根据应用程序的需求,需要建立计算机系统和外部设备的通信,比如:应用程序从设备读,后续写回设备,这里的设备并不局限于磁盘,包含一切 I/O 设备

而应用程序无法自己操作 I/O 设备,需要使用操作系统提供的系统调用接口,让操作系统完成,系统调用会触发陷阱指令转换系统状态 (用户态 -> 内核态),当操作系统完成系统调用后,会陷出回应用程序

更具体的,应用程序一次完整的 I/O 调用包括:

对于准备阶段,关于读请求的具体含义为:等待系统调用的完整请求数据,并将外围设备的数据读入内核缓冲区;关于写请求的具体含义为:等待系统调用的完成请求数据,并将用户缓冲区数据写入内核缓冲区

2

Socket 服务端模式

一般而言,I/O 模型更多的是讨论网络 I/O,也就是客户端/服务端模型。在单进程/单线程的 Socket 服务端模式下,所有客户端的请求都是一个进程/线程去处理:

10

但在多核 CPU 中,无法完全利用 CPU 资源,所以就有了多进程的 Socket 服务端模式,每次有客户端去连接服务端,就会fork()一个子进程去负责这个客户端连接:

11

但由于每次创建子进程的开销很大,当连接的客户端数量变多后,系统的负荷也会变大,所以就有了多线程的 Socket 服务端模式,每次有客户端去连接服务端,就创建去负责这个客户端连接:

12

虽然创建线程的开销比进程低很多,但如果创建线程过多也会导致系统负载过大,就算使用线程池技术,也只能缓解。所以就有了 I/O 多路复用,它使用单线程同步非阻塞方式监听多个 socket,当任意一个 socket 有事件发生就会返回

UNIX 系统中的 I/O 模型

写在前面,关于「请求」的同步/异步

关于阻塞/非阻塞

先分享一个很有意思的小故事!!!从前有个人叫老张,他喜欢喝开水,一言不合就煮开水喝

情况一:老张把水壶放到火上,站在旁边啥也不干等着水烧开 -> 同步阻塞

情况二:老张把水壶放到火上,不站在旁边干等着,但会时不时来看水有么有烧开 -> 同步非阻塞

情况三:老张买了个新的水壶,水开了就会叫,老张把水壶放到火上,站在旁边啥也不干等着水烧开 -> 异步阻塞

情况四:老张买了个新的水壶,水开了就会叫,老张把水壶放到火上,就可以去干其它事情,也不用时不时来看,当水壶叫了就知道水开了 -> 异步非阻塞

注意:「异步」和「阻塞」放一起有些相互矛盾,异步就已经代表非阻塞了,所以基本没有这种说法

说了这么多,下面不说废话了,直接开门见山!!在 UNIX 系统下,常见的 I/O 模型有五种:阻塞 I/O (BIO)非阻塞 I/O (NIO)I/O 多路复用信号驱动 I/O异步 I/O

BIO

下图中的recvfrom函数视为系统调用 (下同)。应用程序调用recvfrom,直到数据报准备好且被复制到应用进程的缓冲区中或者发生错误才返回。用户进程从发起系统调用到结果返回均处于阻塞状态

假设在单线程下,客户端 A 已经连接服务端,服务端等待接收客户端 A 准备发送的数据,如果客户端 A 长时间不发送数据,那么服务端只能阻塞在read()系统调用处,也无法处理其它客户端的连接请求

缺点:如果使用多线程来提高效率,每个线程会对应一个 socket,对于长连接会造成大量的资源占用,可能后续来了更多连接后造成性能上的瓶颈

3

NIO

进程发起 I/O 调用,无论结果如何都会直接返回,如果返回值是一个错误EWOULDBLOCK,表示数据报还未准备好。进程将继续不断轮询,直到数据报准备好以及完成复制

和 BIO 唯一的不同在于,如果服务端调用read()发送数据没有准备好,就会立刻返回,然后接着进行下一次检查。虽然 NIO 可以避免服务端长时间阻塞等待,但不断轮询会消耗 CPU,类似于自旋

缺点:进程在完成 I/O 调用前会不断轮询操作是否就绪,会消耗大量 CPU 时间

4

 

I/O 多路复用

应用进程首先会系统调用select,然后进入阻塞状态,当有新事件发生,就会从select返回,应用进程会再系统调用recvfrom完成数据报的复制工作

I/O 多路复用的特点:在单线程里同时监控多个 socket,而这些 socket 任一个进入读就绪状态,select函数就可以返回。I/O 多路复用类似于多线程下的 BIO

关于 I/O 多路复用更详细介绍可见 I/O 多路复用

5

信号驱动 I/O

应用进程通过sigaction系统调用安装一个信号处理函数,该系统调用将立即返回,应用进程继续工作,并没有阻塞

当数据报准备好读取时,内核就为该进程产生一个 SIGIO 信号。我们既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已经准备好待处理,也可以立即通知主循环,让它读取数据报

优点:等待数据报准备好期间应用进程不被阻塞,主循环可以继续执行,只要等待来自信号处理函数的通知

6

异步 I/O

工作机制:告知内核启动某个操作,并让内核在整个操作 (包括将数据从内核复制到应用缓冲区中) 完成后通知应用进程

和信号驱动 I/O 的区别:信号驱动 I/O 是由内核通知应用进程何时可以启动一个 I/O 操作,而异步 I/O 是由内核通知应用进程 I/O 操作何时完成

7

五种模型比较

8

Java 中三种常见的 I/O 模型

BIO

Java 中的 BIO 模型是同步且阻塞的,服务器是多线程的 Socket 模式,会为每一个客户端的连接请求都会分配一个线程去处理,如下图所示:

1

Java BIO 的缺陷在于需要为每个连接分配一个线程,而线程的创建、管理、销毁都需要一定的开销,虽然可以使用线程池减少这种开销,但如果连接量过大,使用线程池也难顶~~更具体地:

适用场景:连接数目比较小且固定的架构,对服务器资源要求较高,但程序简单

NIO

由于 Java NIO 内容较多,这里重开一篇文章专门总结:Java NIO

Java 中的 NIO 就是非阻塞的 I/O 多路复用。服务端实现一个线程就可以管理多个客户端连接,客户端的连接都会注册到多路复用器 selector 中,多路复用器轮询到连接有 I/O 请求就进行处理

在 Java 多线程的服务端模式下,BIO 是一旦有客户端连接就在服务端为它分配一个线程;而 NIO 是将所有客户端连接注册到 selector 中,一个线程可以管理多个连接

9

NIO 中所有 I/O 都是从 channel 开始:

适用场景:高负载、高并发的 (网络) 应用

AIO

AIO 基于事件和回调机制实现的,应用程序 I/O 调用后会立马返回,不会堵塞,当后台处理完成,操作系统会通知线程继续后续的操作

适用场景:连接数目多且连接比较长 (重操作) 的架构

注意:目前 AIO 的应用不广泛,因为性能并没有很大提高

参考文章