操作系统的IO模型
什么是IO
IO,英文全称是Input/Output,翻译过来就是输入/输出。平时我们听得挺多,就是什么磁盘IO,网络IO。那IO到底是什么呢?是不是有种懵懵懂懂的感觉呀,好像大概知道它是什么,又好像说不清楚。
IO,即输入/输出,到底谁是输入?谁是输出呢?IO如果脱离了主体,就会让人疑惑。
计算机角度的IO
我们常说的输入输出,比较直观的意思就是计算机的输入输出,计算机就是主体。大家是否还记得,大学学计算机组成原理的时候,有个冯.诺依曼结构,它将计算机分成分为5个部分:运算器、控制器、存储器、输入设备、输出设备。

输入设备是向计算机输入数据和信息的设备,键盘,鼠标都属于输入设备;输出设备是计算机硬件系统的终端设备,用于接收计算机数据的输出显示,一般显示器、打印机属于输出设备。
例如你在鼠标键盘敲几下,它就会把你的指令数据,传给主机,主机通过运算后,把返回的数据信息,输出到显示器。
鼠标、显示器这只是直观表面的输入输出,回到计算机架构来说,涉及计算机核心与其他设备间数据迁移的过程,就是IO。如磁盘IO,就是从磁盘读取数据到内存,这算一次输入,对应的,将内存中的数据写入磁盘,就算输出。这就是IO的本质。
操作系统的IO
我们要将内存中的数据写入到磁盘的话,主体会是什么呢?主体可能是一个应用程序,比如一个Java进程(假设网络传来二进制流,一个Java进程可以把它写入到磁盘)。
操作系统负责计算机的资源管理和进程的调度。我们电脑上跑着的应用程序,其实是需要经过操作系统,才能做一些特殊操作,如磁盘文件读写、内存的读写等等。因为这些都是比较危险的操作,不可以由应用程序乱来,只能交给底层操作系统来。也就是说,你的应用程序要把数据写入磁盘,只能通过调用操作系统开放出来的API来操作。
什么是用户空间?什么是内核空间?
以32位操作系统为例,它为每一个进程都分配了4G(2的32次方)的内存空间。这4G可访问的内存空间分为二部分,一部分是用户空间,一部分是内核空间。内核空间是操作系统内核访问的区域,是受保护的内存空间,而用户空间是用户应用程序访问的内存区域。
我们应用程序是跑在用户空间的,它不存在实质的IO过程,真正的IO是在操作系统执行的。即应用程序的IO操作分为两种动作:IO调用和IO执行。IO调用是由进程(应用程序的运行态)发起,而IO执行是操作系统内核的工作。此时所说的IO是应用程序对操作系统IO功能的一次触发,即IO调用。
内核缓冲区与进程缓冲区
缓冲区的目的,是为了减少频繁的系统IO调用。大家都知道,系统调用需要保存之前的进程数据和状态等信息,而结束调用之后回来还需要恢复之前的信息,为了减少这种损耗时间、也损耗性能的系统调用,于是出现了缓冲区。
有了缓冲区,操作系统使用read函数把数据从内核缓冲区复制到进程缓冲区,write把数据从进程缓冲区复制到内核缓冲区中。等待缓冲区达到一定数量的时候,再进行IO的调用,提升性能。至于什么时候读取和存储则由内核来决定,用户程序不需要关心。
在linux系统中,系统内核也有个缓冲区叫做内核缓冲区。每个进程有自己独立的缓冲区,叫做进程缓冲区。
所以,用户程序的IO读写程序,大多数情况下,并没有进行实际的IO操作,而是在读写自己的进程缓冲区。
零拷贝
数据会存在两块内存:
- 磁盘:文件原始数据
- 内核缓冲区(内核态)
- 应用程序缓冲区(用户态)
传统 IO 会发生多次数据复制 + 状态切换,这就是性能瓶颈。
传统 BIO 普通文件复制(2 次拷贝 + 2 次切换)
场景:读取本地文件 → 程序 → 写出到磁盘 / 网络
流程
- 磁盘数据 → 内核缓冲区(DMA 拷贝,无 CPU 参与)
- 内核缓冲区 → 用户进程缓冲区(CPU 拷贝)
- 程序业务处理
- 用户缓冲区 → 内核 socket 缓冲区(CPU 拷贝)
- 内核 socket 缓冲区 → 网卡 / 磁盘(DMA)
总结
- 2 次 CPU 数据拷贝
- 多次 内核态 ↔ 用户态 切换
- 小文件无所谓,大文件、高并发传输巨慢、消耗 CPU
零拷贝
避免数据在「内核缓冲区」和「用户缓冲区」之间来回拷贝
数据全程在内核内存流转,应用程序只负责指令调度,不搬运数据。
关键点
- 不是完全没有拷贝
- 没有 CPU 参与的内存拷贝
- 只剩 DMA 硬件拷贝(极低消耗)
- 减少内核态 / 用户态切换
Java 里两种主流零拷贝实现
1. NIO 文件传输:transferTo /transferFrom(最常用)
FileChannel
1 | |
底层流程
- 磁盘 → 内核缓冲区(DMA)
- 直接从内核缓冲区 → 目标通道(Socket / 文件)
- 完全不经过应用程序的用户内存
拷贝次数
- 只发生 1 次 DMA 拷贝
- 无 CPU 拷贝
- 态切换大幅减少
适用:文件上传、下载、大文件复制、服务器静态资源下发
2. mmap 内存映射(NIO MappedByteBuffer)
- 把磁盘文件直接映射到内核虚拟内存
- 程序操作内存 = 直接操作文件
- 读写不用频繁系统调用
- 适合:大文件随机读写(日志、分片文件)
短板:不适合网络传输、内存占用高
DMA
DMA 全称:Direct Memory Access 直接内存访问
让硬件自己搬运数据,不占用 CPU。
没有 DMA 会发生什么?(老式 IO)
以「读磁盘文件」举例:
- CPU 发指令:去磁盘读数据
- 磁盘每读一小块数据,就要通知 CPU
- CPU 亲自把数据,一点点搬运到内存
- 全程 CPU 被卡死,干不了别的事
缺点:
CPU 沦为「搬运工」,IO 量大时,CPU 爆满、性能炸裂。
有 DMA 之后(现代计算机标准)
CPU 只做下发指令:
「DMA 控制器,你去把磁盘数据搬到内核缓冲区」
后续数据搬运,全是 DMA 硬件自己干
磁盘 ⇋ 内存 的数据传输,完全绕过 CPU
搬运完成后,DMA 发一个中断,告诉 CPU:完事了
核心结论
- DMA 负责:硬件 ↔ 内存 的大块数据搬运
- CPU 只负责:调度、指令、逻辑计算
操作系统的一次IO过程
应用程序发起的一次IO操作包含两个阶段:
IO调用:应用程序进程向操作系统内核发起调用。
IO执行:操作系统内核完成IO操作。
操作系统内核完成IO操作还包括两个过程:
准备数据阶段:内核等待I/O设备准备好数据
拷贝数据阶段:将数据从内核缓冲区拷贝到用户进程缓冲区

其实IO就是把进程的内部数据转移到外部设备,或者把外部设备的数据迁移到进程内部。外部设备一般指硬盘、socket通讯的网卡。一个完整的IO过程包括以下几个步骤:
应用程序进程向操作系统发起IO调用请求
操作系统准备数据,把IO外部设备的数据,加载到内核缓冲区
操作系统拷贝数据,即将内核缓冲区的数据,拷贝到用户进程缓冲区
阻塞和非阻塞的区别
阻塞和非阻塞的区别就在于第一个阶段,如果数据没有就绪,在查看数据是否就绪的过程中是一直等待,还是直接返回一个标志信息。
同步与异步的区别
同步和异步的区别是否会导致发起IO请求的线程暂停。
同步需要通过用户线程或者内核不断地去轮询数据是否就绪。
异步是IO操作的两个阶段都是由内核自动完成,然后发送通知告知用户线程IO操作已经完成。
阻塞IO模型
我们已经知道IO是什么啦,那什么是阻塞IO呢?
假设应用程序的进程发起IO调用,但是如果内核的数据还没准备好的话,那应用程序进程就一直在阻塞等待,一直等到内核数据准备好了,从内核拷贝到用户空间,才返回成功提示,此次IO操作,称之为阻塞IO。

阻塞IO比较经典的应用就是阻塞socket、Java BIO。
阻塞IO的缺点就是:如果内核数据一直没准备好,那用户进程将一直阻塞,浪费性能,可以使用非阻塞IO优化。
非阻塞IO模型
如果内核数据还没准备好,可以先返回错误信息给用户进程,让它不需要等待,而是通过轮询的方式再来请求。这就是非阻塞IO,流程图如下:

非阻塞IO的流程如下:
应用进程向操作系统内核,发起
recvfrom读取数据。操作系统内核数据没有准备好,立即返回
EWOULDBLOCK错误码。应用程序进程轮询调用,继续向操作系统内核发起
recvfrom读取数据。操作系统内核数据准备好了,从内核缓冲区拷贝到用户空间。
完成调用,返回成功提示。
非阻塞IO模型,简称NIO,Non-Blocking IO。它相对于阻塞IO,虽然大幅提升了性能,但是它依然存在性能问题,即频繁的轮询,导致频繁的系统调用,同样会消耗大量的CPU资源。可以考虑IO复用模型,去解决这个问题。
IO多路复用模型
既然NIO无效的轮询会导致CPU资源消耗,我们等到内核数据准备好了,主动通知应用进程再去进行系统调用,那不就好了嘛?
当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
IO复用模型核心思路:系统给我们提供一类函数(如我们耳濡目染的select、poll、epoll函数),它们可以同时监控多个fd的操作,任何一个返回内核数据就绪,应用进程再发起recvfrom系统调用。
IO多路复用之select
应用进程通过调用select函数,可以同时监控多个fd,在select函数监控的fd中,只要有任何一个数据状态准备就绪了,select函数就会返回可读状态,这时应用进程再发起recvfrom请求去读取数据。

非阻塞IO模型(NIO)中,需要N(N>=1)次轮询系统调用,然而借助select的IO多路复用模型,只需要发起一次询问就够了,大大优化了性能。
但是呢,select有几个缺点:
监听的IO最大连接数有限,在Linux系统上一般为1024。
select函数返回后,是通过遍历
fdset,找到就绪的描述符fd。(仅知道有I/O事件发生,却不知是哪几个流,所以遍历所有流)
因为存在连接数限制,所以后来又提出了poll。与select相比,poll解决了连接数限制问题。但是呢,select和poll一样,还是需要通过遍历文件描述符来获取已经就绪的socket。如果同时连接的大量客户端,在一时刻可能只有极少处于就绪状态,伴随着监视的描述符数量的增长,效率也会线性下降。
因此经典的多路复用模型epoll诞生。
IO多路复用之epoll
为了解决select/poll存在的问题,多路复用模型epoll诞生,它采用事件驱动来实现,流程图如下:

epoll先通过epoll_ctl()来注册一个fd(文件描述符),一旦基于某个fd就绪时,内核会采用回调机制,迅速激活这个fd,当进程调用epoll_wait()时便得到通知。这里去掉了遍历文件描述符的坑爹操作,而是采用监听事件回调的机制。这就是epoll的亮点。
我们一起来总结一下select、poll、epoll的区别
| select | poll | epoll | |
|---|---|---|---|
| 底层数据结构 | 数组 | 链表 | 红黑树和双链表 |
| 获取就绪的fd | 遍历 | 遍历 | 事件回调 |
| 事件复杂度 | O(n) | O(n) | O(1) |
| 最大连接数 | 1024 | 无限制 | 无限制 |
| fd数据拷贝 | 每次调用select | 需要将fd数据从用户空间拷贝到内核空间 每次调用poll,需要将fd数据从用户空间拷贝到内核空间 | 使用内存映射(mmap),不需要从用户空间频繁拷贝fd数据到内核空间 |
epoll明显优化了IO的执行效率,但在进程调用epoll_wait()时,仍然可能被阻塞。能不能酱紫:不用我老是去问你数据是否准备就绪,等我发出请求后,你数据准备好了通知我就行了,这就诞生了信号驱动IO模型。
IO模型之信号驱动模型
信号驱动IO不再用主动询问的方式去确认数据是否就绪,而是向内核发送一个信号(调用sigaction的时候建立一个SIGIO的信号),然后应用用户进程可以去做别的事,不用阻塞。当内核数据准备好后,再通过SIGIO信号通知应用进程,数据准备好后的可读状态。应用用户进程收到信号之后,立即调用recvfrom,去读取数据。

信号驱动IO模型,在应用进程发出信号后,是立即返回的,不会阻塞进程。它已经有异步操作的感觉了。但是你细看上面的流程图,发现数据复制到应用缓冲的时候,应用进程还是阻塞的。回过头来看下,不管是BIO,还是NIO,还是信号驱动,在数据从内核复制到应用缓冲的时候,都是阻塞的。还有没有优化方案呢?AIO(真正的异步IO)!
IO 模型之异步IO(AIO)
前面讲的BIO,NIO和信号驱动,在数据从内核复制到应用缓冲的时候,都是阻塞的,因此都不算是真正的异步。AIO实现了IO全流程的非阻塞,就是应用进程发出系统调用后,是立即返回的,但是立即返回的不是处理结果,而是表示提交成功类似的意思。等内核数据准备好,将数据拷贝到用户进程缓冲区,发送信号通知用户进程IO操作执行完毕。
流程如下:
.png)
异步IO的优化思路很简单,只需要向内核发送一次请求,就可以完成数据状态询问和数据拷贝的所有操作,并且不用阻塞等待结果。日常开发中,有类似思想的业务场景:
比如发起一笔批量转账,但是批量转账处理比较耗时,这时候后端可以先告知前端转账提交成功,等到结果处理完,再通知前端结果即可。
阻塞、非阻塞、同步、异步IO总结
同步阻塞(blocking-IO)简称BIO
同步非阻塞(non-blocking-IO)简称NIO
异步非阻塞(asynchronous-non-blocking-IO)简称AIO
BIO:同步阻塞,在服务器中实现的模式为一个连接一个线程。也就是说,客户端有连接请求的时候,服务器就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然这也可以通过线程池机制改善。BIO一般适用于连接数目小且固定的架构,这种方式对于服务器资源要求比较高,而且并发局限于应用中,是JDK1.4之前的唯一选择,但好在程序直观简单,易理解。
NIO:同步非阻塞,在服务器中实现的模式为一个请求一个线程,也就是说,客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到有连接IO请求时才会启动一个线程进行处理。NIO一般适用于连接数目多且连接比较短(轻操作)的架构,并发局限于应用中,编程比较复杂,从JDK1.4开始支持。
AIO:异步并非阻塞,在服务器中实现的模式为一个有效请求一个线程,也就是说,客户端的IO请求都是通过操作系统先完成之后,再通知服务器应用去启动线程进行处理。AIO一般适用于连接数目多且连接比较长(重操作)的架构,充分调用操作系统参与并发操作,编程比较复杂,从JDK1.7开始支持。