当涉及到网络编程和IO操作时,数据拷贝是一个常见的性能瓶颈。传统的数据拷贝过程中,数据需要从内核缓冲区复制到用户空间缓冲区,然后再从用户空间缓冲区复制到内核缓冲区,这个过程会耗费大量的CPU时间和内存带宽,降低系统的性能和吞吐量。
为了解决这个问题,零拷贝技术应运而生。零拷贝技术是指在数据传输过程中,避免将数据从一块内存拷贝到另一块内存,从而减少了CPU的开销和内存带宽的消耗,提高了系统的性能。
在Java后端开发中,使用零拷贝技术可以有效提升系统的性能和吞吐量。本文将介绍零拷贝技术的概念、实现原理以及在Java后端开发中的应用,希望能够为读者提供有价值的参考和帮助。
一、传统I/O
在展开说零拷贝之前,我们先来回顾一下传统IO的方式是怎么样的。
早期的数据IO,由用户进程向CPU发起,应用程序与磁盘之间的 I/O 操作都是通过 CPU 的中断完成的。CPU还要负责将磁盘缓冲区拷贝到内核缓冲区(pageCache),再从内核缓冲区拷贝到用户缓冲区。为了减少CPU占用,产生了DMA技术,大大解放了CPU。
DMA 的全称叫直接内存存取(Direct Memory Access),是一种允许外围设备(硬件子系统)直接访问系统主内存的机制。目前大多数的硬件设备,包括磁盘控制器、网卡、显卡以及声卡等都支持 DMA 技术。
1.1传统I/O的问题
我们以读取一张图片数据的过程为例来分析传统IO有哪些问题。传统的访问方式是通过 write() 和 read() 两个系统调用实现的,通过 read() 函数读取图片到到缓存区中,然后通过 write() 方法把缓存中的图片输出到网络端口。
read操作:
当应用程序执行 read 系统调用读取一块数据的时候,如果这块数据已经存在于用户进程的页内存中,就直接从内存中读取数据。
如果数据不存在,则先将数据从磁盘加载数据到内核空间的读缓存(read buffer)中,再从读缓存拷贝到用户进程的页内存中。
write操作:
当应用程序准备好数据,执行 write 系统调用发送网络数据时,先将数据从用户空间的页缓存拷贝到内核空间的网络缓冲区(socket buffer)中,然后再将写缓存中的数据拷贝到网卡设备完成数据发送。
从上图中可以看出,整个IO的过程需要进行两次DMA拷贝,两次CPU拷贝,四次上下文切换。总共四次拷贝,四次切换。这个代价确实有些大。
说完传统IO,接下来我们看下零拷贝都做了哪些优化。
二、零拷贝
2.1什么是零拷贝
零拷贝这个词,在很多地方都出现过,比如Kafka、Nginx、Tomcat等等这些技术的底层都有用到零拷贝技术,那么究竟什么是零拷贝呢?
零拷贝是指在数据传输过程中,避免了数据的多次拷贝,从而提高了数据传输的效率。在传统的IO模型中,数据从磁盘中读取到内核缓冲区,然后再从内核缓冲区拷贝到用户缓冲区,最后再从用户缓冲区拷贝到应用程序中。而在零拷贝模型中,数据可以直接从内核缓冲区拷贝到应用程序中,避免了数据的多次拷贝,提高了数据传输的效率。零拷贝技术可以通过mmap和sendfile等系统调用实现。
所以说零拷贝并不是说不拷贝,而是减少拷贝的次数,因为从磁盘中拷贝数据到内存,或者从内存中的一块儿区域拷贝到另一块儿区域都是一个耗费性能的操作。零拷贝技术的目的就是减少这种行为的发生次数以此来提高性能。
2.2零拷贝实现的几种方式
对比开头说到的传统IO,我们可以在以下几个方面进行优化
1. 用户态可以直接操作读写,不需要在用户态和内核态之间反复横跳。
2. 尽量减少拷贝次数,尽量减少上下文切换次数。
3. 写时复制,需要写操作的时候再拷贝,只是读操作没必要拷贝
用户态直接IO
用户态直接 I/O 使得应用进程或运行在用户态(user space)下的库函数直接访问硬件设备。
用户态直接 I/O 只能适用于不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,称为自缓存应用程序,如数据库管理系统 就是一个代表。
其次,这种零拷贝机制会直接操作磁盘 I/O,由于 CPU 和磁盘 I/O 之间的执行时间差距,会造成大量资源的浪费,解决方案是配合异步 I/O 使用。
写时复制
写时复制指的是当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么就需要将其拷贝到自己的进程地址空间中。
这样做并不影响其他进程对这块数据的操作,每个进程要修改的时候才会进行拷贝,所以叫写时拷贝。
减少拷贝次数
1. mmap+write零拷贝技术
以mmap+write的方式替代传统的read+write的方式,减少了一次拷贝。
mmap 是 Linux 提供的一种内存映射文件方法,即将一个进程的地址空间中的一段虚拟地址映射到磁盘文件地址使用 mmap 的目的是将内核中读缓冲区(read buffer)的地址与用户空间的缓冲区(user buffer)进行映射。从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷贝到用户缓冲区(user buffer)的过程。
整个拷贝过程会发生 4 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。mmap 主要的用处是提高 I/O 性能,特别是针对大文件。对于小文件,内存映射文件反而会导致碎片空间的浪费。
2. Sendfile零拷贝技术
通过 Sendfile 系统调用,数据可以直接在内核空间内部进行 I/O 传输,从而省去了数据在用户空间和内核空间之间的来回拷贝。
将要读取的文件缓冲区的文件 fd 和要发送的Socket缓冲区的Socket fd 传给sendfile函数,Sendfile 调用中 I/O 数据对用户空间是完全不可见的。也就是说,这是一次完全意义上的数据传输过程。也就是说用户程序不能对数据进行修改,而只是单纯地完成了一次数据传输过程。整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。
3. Sendfile+DMA gather copy
它只适用于将数据从文件拷贝到 socket 套接字上的传输过程。
它将内核空间的读缓冲区(read buffer)中对应的数据描述信息(内存地址、地址偏移量)记录到相应的网络缓冲区( socket buffer)中,由 DMA 根据内存地址、地址偏移量将数据批量地从读缓冲区(read buffer)拷贝到网卡设备中。
这样 DMA 引擎直接利用 gather 操作将页缓存中数据打包发送到网络中即可,本质就是和虚拟内存映射的思路类似。
整个拷贝过程会发生 2 次上下文切换、0 次 CPU 拷贝以及 2 次 DMA 拷贝。
4.Splice零拷贝技术
Splice相当于在Sendfile+DMA gather copy上的提升,Splice 系统调用可以在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline),从而避免了两者之间的 CPU 拷贝操作。
基于 Splice 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝。
2.3总结
无论是传统I/O拷贝方式,还是引入了零拷贝的方式,2次DMA Copy都是必要的步骤,因为两次DMA都是依赖硬件完成的。
三、零拷贝的实际应用
3.1JavaNIO基于零拷贝的实现
Java-NIO:主要有三个方面用到了零拷贝技术:
MappedByteBuffer.map():底层调用了操作系统的mmap()内核函数。
DirectByteBuffer.allocateDirect():可以直接创建基于本地内存的缓冲区。
FileChannel.transferFrom()/transferTo():底层调用了sendfile()内核函数。
3.2主流技术中零拷贝的应用
1. Netty中零拷贝的应用
Netty中的零拷贝是一种用户进程级别的零拷贝体现,主要也包含三方面:
1) Netty的发送、接收数据的ByteBuf缓冲区,默认会使用堆外本地内存创建,采用直接内存进行Socket读写,数据传输时无需经过二次拷贝。如果使用传统的堆内存进行Socket网络数据读写,JVM需要先将堆内存中的数据拷贝一份到直接内存,然后才写入Socket缓冲区中,相较于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
2)Netty的文件传输采用了transferTo()/transferFrom()方法,它可以直接将文件缓冲区的数据发送到目标Channel(Socket),底层就是调用了sendfile()内核函数,避免了文件数据的CPU拷贝过程。
3)Netty提供了组合、拆解ByteBuf对象的API,咱们可以基于一个ByteBuf对象,对数据进行拆解,也可以基于多个ByteBuf对象进行数据合并,这个过程中不会出现数据拷贝,这个是程序级别的零拷贝,实际上就是在原数据的基础上用不同的引用表示而已。
2. 其他技术中的零拷贝技术应用
Kafka底层基于java.nio包下的FileChannel.transferTo()实现零拷贝。Kafka Server基于FileChannel将文件中的消息数据发送到SocketChannel。
RocketMQ基于mmap + write的方式实现零拷贝。内部实现基于nio提供的java.nio.MappedByteBuffer,基于FileChannel的map方法得到mmap的缓冲区。