本文专门解释关于高性能IO的基层技术,关于zero-copy,mmap,direct-memory,这些都是Linux/Windows/Mac OS都支持的底层api,但我会用Java作为主语言。
打比方有需求是从文件error.txt中读取文字,然后做一些业务修改操作再回写到socket把文件传输给同伴。Java写这个逻辑是很简单的用 InputStream 流 1chunk,1chunk读入到一个buffer,然后把内容修改完,写入 OutputStream 流,写回文件,然后发到socket buffer从网卡上发出去。
虽然需求做完了,但是如果这个需求是大规模业务,这样做是显然不合适的或者说做的不够好,对于OS开销相当大,延迟会越来越高随着使用人数的增多。
我们看下为啥开销相当大:
- JVM 通过native方法到c语言读文件api再通过glibc调用系统函数read()
- OS会从用户态陷入内核态,向disk请求调用读取error.txt这个文件数据,之后DMA会把文件数据读入到内存的内核地址空间的buffer中。
- OS内核再把上面buffer的文件数据通过上下文切换copy到用户态的buffer,之后我们写的程序就可以读到了
- 我们程序读到文件内容做了某些修改后调用native方法到c语言的写文件api再通过glibc调用系统函数write()
- OS再次上下文切换到用户态把文件数据copy到内核态socket描述符的buffer,最后数据刷出到socket。
- 刷到socket后,OS上下文切换回用户态,返回JVM的程序成功。
从图和表述看,这个过程就很复杂,2次OS上下文切换,4次昂贵的文件数据拷贝。
改进版本一: sendfile()/transferTo() 实现zero-copy
zero-copy可以节省2次文件数据拷贝开销,这个技术以来各个系统自身的实现,每个都不同。一般*nix like的OS会提供 sendfile()方法来实现(PS:对应Java的api是NIO的transferTo()函数)。
sendfile() 系统调用:
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
transferTo() Java Nio函数:
public abstract long transferTo(long position,
long count,
WritableByteChannel target)
throws IOException;
transferTo() 将数据从的文件传输到指定的ByteChannel。使用此函数,相当于之前java的InputStream的read()和OutputStream的write()可以替换为一个单一的 transferTo()函数。下图展示了使用 transferTo() 方法时的数据路径:
使用了sendfile() / transferTo()函数时的OS执行步骤:
- 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard driver ——> kernel buffer)。
- 然后再将数据从内核空间缓冲区拷贝到内核中与socket相关的缓冲区中(第二次拷贝: kernel buffer ——> socket buffer)。
- sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer ——> protocol engine)。
通过sendfile实现的零拷贝I/O只使用了2次用户空间与内核空间的上下文切换,以及3次数据的拷贝(1次CPU执行的,2次DMA执行的)。 你可能会说操作系统仍然需要在内核内存空间中复制数据(kernel buffer —>socket buffer)。 是的,但从操作系统的角度来看,这已经是零拷贝,因为没有数据从内核空间复制到用户空间。 内核需要复制的原因是因为通用硬件DMA访问需要连续的内存空间(因此需要缓冲区)。
改进版本二: sendfile()+DMA scatter-and-gather实现zero-copy
- 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive ——> kernel buffer)。
- 没有数据拷贝到socket缓冲区。取而代之的是只有相应的描述符信息会被拷贝到相应的socket缓冲区当中。该描述符包含了两方面的信息:a)kernel buffer的内存地址;b)kernel buffer的偏移量。
- sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。DMA gather copy根据socket缓冲区中描述符提供的位置和偏移量信息直接将内核空间缓冲区中的数据拷贝到协议引擎上(第二次拷贝: kernel buffer ——> protocol engine),这样就避免了最后一次CPU数据拷贝。
带有DMA收集拷贝功能的sendfile实现的I/O只使用了2次用户空间与内核空间的上下文切换,以及2次数据的拷贝,而且这2次的数据拷贝都是非CPU拷贝。这样一来我们就实现了最理想的零拷贝I/O传输了,不需要任何一次的CPU拷贝,以及最少的上下文切换。
改进版本三,通过mmap实现zero-copy
传统I/O用户空间缓冲区中存有数据,因此应用程序能够对此数据进行修改等操作;而sendfile零拷贝消除了所有内核空间缓冲区与用户空间缓冲区之间的数据拷贝过程,因此sendfile零拷贝I/O的实现是完成在内核空间中完成的,这对于应用程序来说就无法对数据进行操作了。为了解决这个问题,Linux提供了mmap零拷贝来实现我们的需求。
mmap(内存映射)是一个比sendfile昂贵但优于传统I/O的方法。
我们的程序发起一次系统调用,将一个文件(或者文件的一部分)映射到虚拟地址空间的一部分,注意这时候没有分配和映射到具体的物理内存空间,而是到第一次加载这个文件的时候,通过MMU把之前虚拟地址换算成物理地址,把文件加载进物理内存。
具体分析消耗步骤如下:
- 发出mmap系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive ——> kernel buffer)。
- mmap系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。接着用户空间和内核空间共享这个缓冲区,而不需要将数据从内核空间拷贝到用户空间。因为用户空间和内核空间共享了这个缓冲区数据,所以用户空间就可以像在操作自己缓冲区中数据一般操作这个由内核空间共享的缓冲区数据。
- 发出write系统调用,导致用户空间到内核空间的上下文切换(第三次上下文切换)。将数据从内核空间缓冲区拷贝到内核空间socket相关联的缓冲区(第二次拷贝: kernel buffer ——> socket buffer)。
- write系统调用返回,导致内核空间到用户空间的上下文切换(第四次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer ——> protocol engine)
通过mmap实现的零拷贝I/O进行了4次用户空间与内核空间的上下文切换,以及3次数据拷贝。其中3次数据拷贝中包括了2次DMA拷贝和1次CPU拷贝。明显,它与传统I/O相比仅仅少了1次内核空间缓冲区和用户空间缓冲区之间的CPU拷贝。这样的好处是,我们可以将整个文件或者整个文件的一部分映射到内存当中,用户直接对内存中对文件进行操作,然后是由操作系统来进行相关的页面请求并将内存的修改写入到文件当中。我们的应用程序只需要处理内存的数据,这样可以实现非常迅速的I/O操作。
说了这么多,那么Java NIO中对零拷贝的使用有哪些呢?
NIO DirectByteBuffer
Java NIO引入了用于通道的缓冲区的ByteBuffer。 ByteBuffer有三个主要的实现:
HeapByteBuffer
在调用ByteBuffer.allocate()时使用。 它被称为堆,因为它保存在JVM的堆空间中,因此您可以获得所有优势,如GC支持和缓存优化。 但是,它不是页面对齐的,这意味着如果您需要通过JNI与本地代码交谈,JVM将不得不复制到对齐的缓冲区空间。
DirectByteBuffer
在调用ByteBuffer.allocateDirect()时使用。 JVM将使用malloc()在堆空间之外分配内存空间。 因为它不是由JVM管理的,所以你的内存空间是页面对齐的,不受GC影响,这使得它成为处理本地代码的完美选择。 然而,你要C程序员一样,自己管理这个内存,必须自己分配和释放内存来防止内存泄漏。
MappedByteBuffer
在调用FileChannel.map()时使用。 与DirectByteBuffer类似,这也是JVM堆外部的情况。 它基本上作为OS mmap()系统调用的包装函数,以便代码直接操作映射的物理内存数据。
总结
零拷贝是操作系统底层的一种实现,我们在网络编程中,利用操作系统这一特性,可以大大提高数据传输的效率。这也是目前网络编程框架中都会采用的方式,理解好零拷贝,有助于我们进一步学习Netty等网络通信框架的底层原理。