零拷贝原理
一、概述
传统IO的文件传输,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
期间发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。
传输过程:
- 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
- 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
- 把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
- 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。
传统IO总结:
传统IO的文件传输期间一共发生了 4 次用户态与内核态的上下文切换和4 次数据拷贝(其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的)
二、零拷贝原理
所谓的零拷贝就是减少用户态与内核态的上下文切换和内存拷贝的次数。
零拷贝技术实现的方式通常有两种:
- mmap + write
- sendfile
2.1、mmap + write
mmap() 系统调用函数会直接把内核缓冲区里的数据映射到用户空间,这样操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
总结:mmap 减少一次数据拷贝的过程(3次内存拷贝)+ 4 次上下文切换(因为系统调用还是 2 次)
2.2、sendfile
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile():
- 第一步,替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
- 第二步,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。
从 Linux 内核 2.4 版本开始起,网卡支持 SG-DMA 技术,sendfile() 系统调用的过程发生了变化:
- 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
- 第二步,可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
总结:sendfile 只需要 2 次上下文切换和2 次数据拷贝次数,就可以完成文件的传输。
2.3、mmap 与 sendfile 区别
- mmap 适合小数据量读写,sendFile 适合大文件传输。
总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
三、 Java 中的应用
3.1、MappedByteBuffer
Java NlO 中 的 MappedByteBuffer 就是通过 FileChannel.map() 方法获得,其实就是采用了操作系统中的内存映射方式,底层就是调用 Linux mmap() 实现的。
MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "r")
.getChannel()
.map(FileChannel.MapMode.READ_ONLY, 0, len);
使用 MappedByteBuffer 类要注意的是:mmap的文件映射,在 full gc 时才会进行释放。当 close 时,需要手动清除内存映射文件,可以反射调用 sun.misc.Cleaner 方法。
3.2、sendfile
FileChannel.transferTo() 方法直接将当前通道内容传输到另一个通道,没有涉及到 Buffer 的任何操作,transferTo() 的实现方式就是通过系统调用 sendfile() 实现的。
// 使用sendfile:读取磁盘文件,并网络发送
FileChannel sourceChannel = new RandomAccessFile(source, "rw").getChannel();
SocketChannel socketChannel = SocketChannel.open(sa);
sourceChannel.transferTo(0, sourceChannel.size(), socketChannel);
以前写过的Kafka 和RocketMQ,消费消息时就是零拷贝,只不过RocketMQ 在消费消息时,使用了 mmap。Kafka 使用了 sendFile。