文章目录
1、从I / O开始说起
我个人理解的IO就是计算机和磁盘、网卡等设备进行交互、信息传输的过程。
从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。
从应用程序方面来看,I/O涉及了用户态和内核态
像我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。
用户空间的程序不能直接访问内核空间。
因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间
这里就出现了以下的拷贝
当用户发起I/O请求,会发生如下动作:
- 用户请求读取数据
- read 方法调用后,要从 java 程序的用户态切换至内核态,去调用操作系统(Kernel)的读能力,将数据读入内核缓冲区。这期间用户线程阻塞,操作系统使用 DMA(Direct Memory Access)来实现文件读,其间也不会使用 cpu
- 从内核态切换回用户态,将数据从内核缓冲区读入用户缓冲区(即 byte[] buf),这期间 cpu 会参与拷贝,无法利用 DMA
- 调用write()方法,这时将数据从用户缓冲区(byte[] buf)写入 socket 缓冲区,cpu 会参与拷贝
- 向网卡写数据,又得从用户态切换至内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu
可以看到,用户态与内核态的切换发生了 3 次,数据交换了四次。
2、IO模型
2.1 分类
在广义上,IO模型分为五类,分别是:阻塞IO,非阻塞IO,多路复用IO,信号驱动IO,异步IO
2.2 关于阻塞非阻塞、同步和异步
以Java的NIO为例,当调用一次 channel.read 或 stream.read 后,会切换至操作系统内核态来完成真正数据读取,而读取又分为两个阶段,分别为:
- 等待数据阶段
- 复制数据阶段
对于阻塞而言,如果没有read到数据,就一直等到这里,这就是阻塞的
如果没有read到数据,但是程序可以返回,就是非阻塞的
阻塞调用是指调用结果返回之前,当前线程会被挂起,一直处于等待消息通知,不能够执行其他业务。函数只有在得到结果之后才会返回
而对于同步来说,发出read后,当有数据了,我再去复制数据
但是异步来说,我发出了read,有其他的线程得到数据,并别将数据返回给发出read的线程,发出read的线程只需要接收就可以了,不用管等待和发出数据,这就是异步
异步的概念和同步相对。当一个同步调用发出后,调用者要一直等待返回消息(结果)通知后,才能进行后续的执行;当一个异步过程调用发出后,调用者不能立刻得到返回消息(结果)。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者
2.3 关于这五个IO模型的图式
阻塞IO
从发出read请求后,直到数据读到用户线程,在这个过程中,用户线程一直处于阻塞状态
非阻塞IO
- 用户线程在一个循环中一直调用read方法,若内核空间中还没有数据可读,立即返回
只是在等待阶段非阻塞 - 用户线程发现内核空间中有数据后,等待内核空间执行复制数据,待复制结束后返回结果
多路复用
Java用selector监听事件,当有有事件发生时,select从阻塞状态返回,接着处理发生的多个事件
异步 IO
发出read请求,用户线程继续执行,当复制数据的步骤完成,线程2将数据带回给用户线程。
阻塞 IO vs 多路复用
- 阻塞IO模式下,若线程因accept事件被阻塞,发生read事件后,仍需等待accept事件执行完成后,才能去处理read事件
- 多路复用模式下,一个事件发生后,若另一个事件处于阻塞状态,不会影响该事件的执行
3、零拷贝
零拷贝指的是数据无需拷贝到 JVM 内存中,同时具有以下三个优点
- 更少的用户态与内核态的切换
- 不利用 cpu 计算,减少 cpu 缓存伪共享
- 零拷贝适合小文件传输
3.1 传统IO问题
传统的 IO 将一个文件通过 socket 写出
File f = new File("data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");
byte[] buf = new byte[(int)f.length()];
file.read(buf);
Socket socket = ...;
socket.getOutputStream().write(buf);
内部工作流程是这样的:
就如 上面说的一样:
内核态和用户态直接出现了三次切换,被数据拷贝了四次
3.2 NIO优化
通过 DirectByteBuf
- ByteBuffer.allocate(10) HeapByteBuffer 使用的还是 java 内存
- ByteBuffer.allocateDirect(10) DirectByteBuffer 使用的是操作系统内存
大部分步骤与优化前相同,唯有一点:Java 可以使用 DirectByteBuffer 将堆外内存映射到 JVM 内存中来直接访问使用: - 这块内存不受 jvm 垃圾回收的影响,因此内存地址固定,有助于 IO 读写
- java 中的 DirectByteBuf 对象仅维护了此内存的虚引用,内存回收分成两步
- DirectByteBuf 对象被垃圾回收,将虚引用加入引用队列
- 通过专门线程访问引用队列,根据虚引用释放堆外内存
- 减少了一次数据拷贝,用户态与内核态的切换次数没有减少
3.3 进一步优化
进一步优化(底层采用了 linux 2.1 后提供的 sendFile 方法),java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据
- java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 cpu
- 数据从内核缓冲区传输到 socket 缓冲区,cpu 会参与拷贝
- 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu
可以看到
- 只发生了一次用户态与内核态的切换
- 数据拷贝了 3 次
3.4 零拷贝
- java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 cpu
- 只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
- 使用 DMA 将 内核缓冲区的数据写入网卡,不会使用 cpu
整个过程仅只发生了一次用户态与内核态的切换,数据拷贝了 2 次。所谓的【零拷贝】,并不是真正无拷贝,而是在不会拷贝重复数据到 jvm 内存中,零拷贝的优点有
- 更少的用户态与内核态的切换
- 不利用 cpu 计算,减少 cpu 缓存伪共享
- 零拷贝适合小文件传输