零拷贝原理讲解
我们以用户通过网络读取一个本地磁盘上文件为例,在说零拷贝之前,我们先要说说一个普通的IO操作是怎样做的
- 系统接收到网络用户读取文件的请求
- 应用程序发起系统调用, 从用户态切换到内核态(第一次上下文切换)
- 内核态中把数据从硬盘文件读取到内核中间缓冲区(kernel buf)
- 数据从内核中间缓冲区(kernel buf)复制到(用户态)应用程序缓冲区(app buf),从内核态切换回到用户态(第二次上下文切换),Read操作完毕。
- 应用程序开始发送数据到网络上
- 应用程序发起系统调用,从用户态切换到内核态(第三次上下文切换)
- 内核中再把数据从socket的缓冲区(socket buf)发送的网卡的缓冲区(NIC buf)上
- 从内核态切换回到用户态(第四次上下文切换),Write操作完毕
由上诉流程得知, 一次read-send涉及到了四次拷贝:
- 硬盘拷贝到内核缓冲区(DMA COPY)
- 内核缓冲区拷贝到应用程序缓冲区(CPU COPY)
- 应用程序缓冲区拷贝到socket缓冲区(CPU COPY)
- socket buf拷贝到网卡的buf(DMA COPY)
其中涉及到2次cpu中断, 还有4次的上下文切换
很明显,第2次和第3次的的copy只是把数据复制到app buffer又原封不动的复制回来, 为此带来了两次的cpu copy和两次上下文切换, 是完全没有必要的
linux的零拷贝技术就是为了优化掉这两次不必要的拷贝
sendFile
linux内核2.1开始引入一个叫sendFile系统调用,这个系统调用可以在内核态内把数据从内核缓冲区直接复制到套接字(SOCKET)缓冲区内, 从而可以减少上下文的切换和不必要数据的复制
有了sendFile这个系统调用后, 我们read-send模型就可以简化为:
- 应用程序开始读文件的操作
- 应用程序发起系统调用, 从用户态切换到内核态(第一次上下文切换)
- 内核态中把数据从硬盘文件读取到内核中间缓冲区
- 通过sendFile,在内核态中把数据从内核缓冲区复制到socket的缓冲区
- 内核中再把数据从socket的缓冲区发送的网卡的buf上
- 从内核态切换到用户态(第二次上下文切换)
涉及到数据拷贝变成:
- 硬盘拷贝到内核缓冲区(DMA COPY)
- 内核缓冲区拷贝到socket缓冲区(CPU COPY)
- socket缓冲区拷贝到网卡的buf(DMA COPY)
可以看到,一次read-send模型中, 利用sendFile系统调用后, 可以将4次数据拷贝减少到3次, 4次上下文切换减少到2次, 2次CPU中断减少到1次
相对传统I/O, 这种零拷贝技术通过减少两次上下文切换, 1次cpu copy, 可以将I/O性能提高50%以上(网络数据, 未亲测)
开始的术语中说到, 所谓的零拷贝的"零", 是指用户态和内核态之间的拷贝次数为0, 从这个定义上来说, 现在的这个零拷贝技术已经是真正的"零"了
然而, 对性能追求极致的伟大的科学家和工程师们并不满足于此. 精益求精的他们对中间第2次的cpu copy依旧耿耿于怀, 想尽千方百计要去掉这一次没有必要的数据拷贝和CPU中断
支持scatter-gather特性的sendFile
在内核2.4以后的版本中, linux内核对socket缓冲区描述符做了优化. 通过这次优化, sendFile系统调用可以在只复制kernel buffer的少量元信息的基础上, 把数据直接从kernel buffer 复制到网卡的buffer中去.从而避免了从"内核缓冲区"拷贝到"socket缓冲区"的这一次拷贝.
这个优化后的sendFile, 我们称之为支持scatter-gather特性的sendFile
在支持scatter-gather特性的sendFile的支撑下, 我们的read-send模型可以优化为:
- 应用程序开始读文件的操作
- 应用程序发起系统调用, 从用户态进入到内核态(第一次上下文切换)
- 内核态中把数据从硬盘文件读取到内核中间缓冲区
- 内核态中把数据在内核缓冲区的位置(offset)和数据大小(size)两个信息追加(append)到socket的缓冲区中去
- 网卡的buf上根据socekt缓冲区的offset和size从内核缓冲区中直接拷贝数据
- 从内核态返回到用户态(第二次上下文切换)
最后数据拷贝变成只有两次DMA COPY:
- 硬盘拷贝到内核缓冲区(DMA COPY)
- 内核缓冲区拷贝到网卡的buf(DMA COPY)
JAVA代码实现零拷贝
代码语言:txt复制File file = new File("test.zip");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
FileChannel fileChannel = raf.getChannel();
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("", 1234));
// 直接使用了transferTo()进行通道间的数据传输
fileChannel.transferTo(0, fileChannel.size(), socketChannel);
NIO的零拷贝由transferTo()方法实现。transferTo()方法将数据从FileChannel对象传送到可写的字节通道(如Socket Channel等)。在内部实现中,由native方法transferTo0()来实现,它依赖底层操作系统的支持。在UNIX和Linux系统中,调用这个方法将会引起sendfile()系统调用
Java中的mmap(内存映射)的原理
看到这里,你可能会觉得零拷贝真完美,性能如此之高。零拷贝实际上是建立在不需要进行数据文件操作的情况下,我们知道,零拷贝应用程序只是向内核态发送一个指令,接下来的操作都是在内核中执行的,执行完毕了之后应用程序才会收到一个应答,他不像普通的BIO有把文件拷贝到应用程序中这一过程,所以,在零拷贝的过程中,我们是无法干预。那么如果我即需要零拷贝的速度,又需要程序能够进行一定的干预,我们应该怎么做呢?这就要说到MMAP(内存映射)
MMAP(内存映射文件), 是指将文件映射到进程的地址空间去, 实现硬盘上的物理地址跟进程空间的虚拟地址的一一对应关系.
MMAP是另外一个用于实现零拷贝的系统调用.跟sendFile不一样的地方是, 它是利用共享内存空间的方式, 避免app buf和kernel buf之间的数据拷贝(两个buf共享同一段内存)
在Java中,我们把他们共享的内存叫做直接内存或者堆外内存,他不是虚拟机运行时数据区的一部分,也不是<Java虚拟机规范>中定义的内存区域。
在java中使用mmap技术的代码如下
代码语言:txt复制File file = new File("test.zip");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
FileChannel fileChannel = raf.getChannel();
MappedByteBuffer buffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());
直接内存(mmap技术)将文件直接映射到内核空间的内存,返回一个操作地址(address),它解决了文件数据需要拷贝到JVM才能进行操作的窘境。而是直接在内核空间直接进行操作,省去了内核空间拷贝到用户空间这一步操作。
NIO的直接内存是由MappedByteBuffer实现的。核心即是map()方法,该方法把文件映射到内存中,获得内存地址addr,然后通过这个addr构造MappedByteBuffer类,以暴露各种文件操作API。
由于MappedByteBuffer申请的是堆外内存,因此不受Minor GC控制,只能在发生Full GC时才能被回收。而DirectByteBuffer改善了这一情况,它是MappedByteBuffer类的子类,同时它实现了DirectBuffer接口,维护一个Cleaner对象来完成内存回收。因此它既可以通过Full GC来回收内存,也可以调用clean()方法来进行回收。
另外,直接内存的大小可通过jvm参数来设置:-XX:MaxDirectMemorySize。
NIO的MappedByteBuffer还有一个兄弟叫做HeapByteBuffer。顾名思义,它用来在堆中申请内存,本质是一个数组。由于它位于堆中,因此可受GC管控,易于回收。
详细的原理看这篇文章
总结
关于零拷贝,我们主要要解决的是问题是几次没有意义的copy,而使得数据能够直接从内核缓冲copy到网卡的buf中,为了解决这个问题,我们需要操作系统的支持。Linux内核从2.4开始,就支持这一特性。
参考:
https://www.cnblogs.com/lhh-north/p/11031821.html#commentform
https://www.cnblogs.com/eryun/p/12088001.html