一、零拷贝和NIO
(一)零拷贝综述
零拷?是?络编程的关键,很多性能优化都离不开。
零拷?(Zero-copy)技术指在计算机执?操作时,CPU 不需要先将数据从?个内存区域复制到另?个内存区域,从?可以减少上下?切换以及 CPU 的拷?时间。它的作?是在数据从?络设备到?户程序空间传递的过程中,减少数据拷?次数,减少系统调?,实现 CPU 的零参与,彻底消除 CPU 在这??的负载。
实现零拷??到的最主要技术是 DMA 数据传输技术和内存区域映射技术。
零拷?机制可以减少数据在内核缓冲区和?户进程缓冲区之间反复的 I/O 拷?操作。零拷?机制可以减少?户进程地址空间和内核地址空间之间因为上下?切换?带来的 CPU 开销。在 Java 程序中,常?的零拷?有 mmap(内存映射)和 sendFile。
(二)传统IO方式
在 Linux 系统中,传统的访问?式是通过 write() 和 read() 两个系统调?实现的,通过 read() 函数读取?件到到缓存区中,然后通过 write() ?法把缓存中的数据输出到?络端?,伪代码如下:
read(file_fd, tmp_buf, len);
write(socket_fd, tmp_buf, len);
下图分别对应传统 I/O 操作的数据读写流程,整个过程涉及 2 次 CPU 拷?、2 次 DMA 拷?总共 4 次拷?,以及 4 次上下?切换。
上下?切换:当?户程序向内核发起系统调?时,CPU 将?户进程从?户态切换到内核态;当系统调?返回时,CPU 将?户进程从内核态切换回?户态。读写两条线,总共有4次上下文切换。
CPU拷?:由 CPU 直接处理数据的传送,数据拷?时会?直占? CPU 的资源。读数据时,需要将内核缓冲区数据读取到用户缓冲区,写数据时需要把用户缓冲区的数据写入Socket缓冲区,共两次CPU拷贝。
DMA拷?:由 CPU 向DMA磁盘控制器下达指令,让 DMA 控制器来处理数据的传送,数据传送完毕再把信息反馈给 CPU,从?减轻了 CPU 资源的占有率。(DMA: direct memory access 直接内存拷?(不使? CPU )),同CPU拷贝,读写共两次DM拷贝。
(三)零拷贝方式的实现方案
在 Linux 中零拷?技术主要有 3 个实现思路:?户态直接 I/O、减少数据拷?次数以及写时复制技术。
?户态直接 I/O:应?程序可以直接访问硬件存储,操作系统内核只是辅助数据传输。这种?式依旧存在?户空间和内核空间的上下?切换,硬件上的数据直接拷??了?户空间,不经过内核空间。因此,直接 I/O 不存在内核空间缓冲区和?户空间缓冲区之间的数据拷?。
减少数据拷?次数:在数据传输过程中,避免数据在?户空间缓冲区和系统内核空间缓冲区之间的CPU拷?,以及数据在系统内核空间内的CPU拷?,这也是当前主流零拷?技术的实现思路。
写时复制技术:写时复制指的是当多个进程共享同?块数据时,如果其中?个进程需要对这份数据进?修改,那么将其拷?到??的进程地址空间中,如果只是数据读取操作则不需要进?拷?操作。
(四)零拷贝--用户态直接IO
?户态直接 I/O 使得应?进程或运?在?户态(user space)下的库函数直接访问硬件设备,数据直接跨过内核进?传输,内核在数据传输过程除了进?必要的虚拟存储配置?作之外,不参与任何其他?作,这种?式能够直接绕过内核,极?提?了性能。
?户态直接 I/O 只能适?于不需要内核缓冲区处理的应?程序,这些应?程序通常在进程地址空间有??的数据缓存机制,称为?缓存应?程序,如数据库管理系统就是?个代表。其次,这种零拷?机制会直接操作磁盘 I/O,由于 CPU 和磁盘 I/O 之间的执?时间差距,会造成?量资源的浪费,解决?案是配合异步 I/O 使?。
(五)零拷贝--mmap + write
?种零拷??式是使? mmap + write 代替原来的 read + write ?式,减少了 1 次 CPU 拷?操作。mmap 是 Linux 提供的?种内存映射?件?法,即将?个进程的地址空间中的?段虚拟地址映射到磁盘?件地址,mmap + write 的伪代码如下:
tmp_buf = mmap(file_fd, len);
write(socket_fd, tmp_buf, len);
使? mmap 的?的是将内核中读缓冲区(read buffer)的地址与?户空间的缓冲区(user buffer)进?映射,从?实现内核缓冲区与应?程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷?到?户缓冲区(user buffer)的过程,然?内核读缓冲区(read buffer)仍需将数据写到内核写缓冲区(socket buffer),?致的流程如下图所示:
基于 mmap + write 系统调?的零拷??式,整个拷?过程会发? 4 次上下?切换,1 次 CPU 拷?和 2次 DMA 拷?,?户程序读写数据的流程如下:
1. ?户进程通过 mmap() 函数向内核(kernel)发起系统调?,上下?从?户态(user space)切换为内核态(kernel space)。
2. 将?户进程的内核空间的读缓冲区(read buffer)与?户空间的缓存区(user buffer)进?内存地址映射。
3. CPU利?DMA控制器将数据从主存或硬盘拷?到内核空间(kernel space)的读缓冲区(readbuffer)。
4. 上下?从内核态(kernel space)切换回?户态(user space),mmap 系统调?执?返回。
5. ?户进程通过 write() 函数向内核(kernel)发起系统调?,上下?从?户态(user space)切换为内核态(kernel space)。
6. CPU将读缓冲区(read buffer)中的数据拷?到的?络缓冲区(socket buffer)。
7. CPU利?DMA控制器将数据从?络缓冲区(socket buffer)拷?到?卡进?数据传输。
8. 上下?从内核态(kernel space)切换回?户态(user space),write 系统调?执?返回。
mmap 主要的?处是提? I/O 性能,特别是针对??件。对于??件,内存映射?件反?会导致碎?空间的浪费,因为内存映射总是要对??边界,最?单位是 4 KB,?个 5 KB 的?件将会映射占? 8KB 内存,也就会浪费 3 KB 内存。
mmap 的拷?虽然减少了 1 次拷?,提升了效率,但也存在?些隐藏的问题。当 mmap ?个?件时,如果这个?件被另?个进程所截获,那么 write 系统调?会因为访问?法地址被 SIGBUS 信号终?,SIGBUS 默认会杀死进程并产??个 coredump,服务器可能因此被终?。
(六)零拷贝--sendFile
sendfile 系统调?在 Linux 内核版本 2.1 中被引?,?的是简化通过?络在两个通道之间进?的数据传输过程。sendfile 系统调?的引?,不仅减少了 CPU 拷?的次数,还减少了上下?切换的次数,它的伪代码如下:
sendfile(socket_fd, file_fd, len);
通过 sendfile 系统调?,数据可以直接在内核空间内部进? I/O 传输,从?省去了数据在?户空间和内核空间之间的来回拷?。与 mmap 内存映射?式不同的是, sendfile 调?中 I/O 数据对?户空间是完全不可?的。也就是说,这是?次完全意义上的数据传输过程。
基于 sendfile 系统调?的零拷??式,整个拷?过程会发? 2 次上下?切换,1 次 CPU 拷?和 2 次DMA 拷?,?户程序读写数据的流程如下:
1. ?户进程通过 sendfile() 函数向内核(kernel)发起系统调?,上下?从?户态(user space)切换为内核态(kernel space)。
2. CPU 利? DMA 控制器将数据从主存或硬盘拷?到内核空间(kernel space)的读缓冲区(readbuffer)。
3. CPU 将读缓冲区(read buffer)中的数据拷?到的?络缓冲区(socket buffer)。
4. CPU 利? DMA 控制器将数据从?络缓冲区(socket buffer)拷?到?卡进?数据传输。
5. 上下?从内核态(kernel space)切换回?户态(user space),sendfile 系统调?执?返回。
相?较于 mmap 内存映射的?式,sendfile 少了 2 次上下?切换,但是仍然有 1 次 CPU 拷?操作。sendfile 存在的问题是?户程序不能对数据进?修改,?只是单纯地完成了?次数据传输过程。
mmap 和 sendFile 的区别:
1. mmap 适合?数据量读写, sendFile 适合??件传输。
2. mmap 需要 4 次上下?切换, 3 次数据拷?; sendFile 需要 3 次上下?切换,最少 2 次数据拷?。
3. sendFile 可以利? DMA ?式,减少 CPU 拷?, mmap 则不能(必须从内核拷?到 Socket 缓冲区)。
(七)零拷贝--sendfile + DMA gather copy
Linux 2.4 版本的内核对 sendfile 系统调?进?修改,为 DMA 拷?引?了 gather 操作。它将内核空间(kernel space)的读缓冲区(read buffer)中对应的数据描述信息(内存地址、地址偏移量)记录到相应的?络缓冲区( socket buffer)中,由 DMA 根据内存地址、地址偏移量将数据批量地从读缓冲区(read buffer)拷?到?卡设备中,这样就省去了内核空间中仅剩的 1 次 CPU 拷?操作,sendfile 的伪代码如下:
sendfile(socket_fd, file_fd, len);
在硬件的?持下,sendfile 拷??式不再从内核缓冲区的数据拷?到 socket 缓冲区,取?代之的仅仅是缓冲区?件描述符和数据?度的拷?,这样 DMA 引擎直接利? gather 操作将?缓存中数据打包发送到?络中即可,本质就是和虚拟内存映射的思路类似。
基于 sendfile + DMA gather copy 系统调?的零拷??式,整个拷?过程会发? 2 次上下?切换、0 次CPU 拷?以及 2 次 DMA 拷?,?户程序读写数据的流程如下:
1. ?户进程通过 sendfile() 函数向内核(kernel)发起系统调?,上下?从?户态(user space)切换为内核态(kernel space)。
2. CPU 利? DMA 控制器将数据从主存或硬盘拷?到内核空间(kernel space)的读缓冲区(readbuffer)。
3. CPU 把读缓冲区(read buffer)的?件描述符(file descriptor)和数据?度拷?到?络缓冲区(socket buffer)。
4. 基于已拷?的?件描述符(file descriptor)和数据?度,CPU 利? DMA 控制器的gather/scatter 操作直接批量地将数据从内核的读缓冲区(read buffer)拷?到?卡进?数据传输。
5. 上下?从内核态(kernel space)切换回?户态(user space),sendfile 系统调?执?返回。
sendfile + DMA gather copy 拷??式同样存在?户程序不能对数据进?修改的问题,?且本身需要硬件的?持,它只适?于将数据从?件拷?到 socket 套接字上的传输过程。
(八)零拷贝--splice
sendfile 只适?于将数据从?件拷?到 socket 套接字上,同时需要硬件的?持,这也限定了它的使?范围。Linux 在 2.6.17 版本引? splice 系统调?,不仅不需要硬件?持,还实现了两个?件描述符之间的数据零拷?。splice 的伪代码如下:
splice(fd_in, off_in, fd_out, off_out, len, flags);
splice 系统调?可以在内核空间的读缓冲区(read buffer)和?络缓冲区(socket buffer)之间建?管道(pipeline),从?避免了两者之间的 CPU 拷?操作。
基于 splice 系统调?的零拷??式,整个拷?过程会发? 2 次上下?切换,0 次 CPU 拷?以及 2 次DMA 拷?,?户程序读写数据的流程如下:
1. ?户进程通过 splice() 函数向内核(kernel)发起系统调?,上下?从?户态(user space)切换为内核态(kernel space)。
2. CPU 利? DMA 控制器将数据从主存或硬盘拷?到内核空间(kernel space)的读缓冲区(readbuffer)。
3. CPU 在内核空间的读缓冲区(read buffer)和?络缓冲区(socket buffer)之间建?管道(pipeline)。
4. CPU 利? DMA 控制器将数据从?络缓冲区(socket buffer)拷?到?卡进?数据传输。
5. 上下?从内核态(kernel space)切换回?户态(user space),splice 系统调?执?返回。
splice 拷??式也同样存在?户程序不能对数据进?修改的问题。除此之外,它使?了 Linux 的管道缓冲机制,可以?于任意两个?件描述符中传输数据,但是它的两个?件描述符参数中有?个必须是管道设备。
(九)零拷贝--写时复制&缓冲区共享
1、写时复制:
在某些情况下,内核缓冲区可能被多个进程所共享,如果某个进程想要这个共享区进? write 操作,由于 write 不提供任何的锁操作,那么就会对共享区中的数据造成破坏,写时复制的引?就是Linux ?来保护数据的。写时复制指的是当多个进程共享同?块数据时,如果其中?个进程需要对这份数据进?修改,那么就需要将其拷?到??的进程地址空间中。这样做并不影响其他进程对这块数据的操作,每个进程要修改的时候才会进?拷?,所以叫写时拷?。这种?法在某种程度上能够降低系统开销,如果某个进程永远不会对所访问的数据进?更改,那么也就永远不需要拷?。
2、缓冲区共享
缓冲区共享?式完全改写了传统的 I/O 操作,因为传统 I/O 接?都是基于数据拷?进?的,要避免拷?就得去掉原先的那套接?并重新改写,所以这种?法是?较全?的零拷?技术,?前?较成熟的?个?案是在 Solaris 上实现的 fbuf(Fast Buffer,快速缓冲区)。
fbuf 的思想是每个进程都维护着?个缓冲区池,这个缓冲区池能被同时映射到?户空间(userspace)和内核态(kernel space),内核和?户共享这个缓冲区池,这样就避免了?系列的拷?操作。缓冲区共享的难度在于管理共享缓冲区池需要应?程序、?络软件以及设备驱动程序之间的紧密合作,?且如何改写 API ?前还处于试验阶段并不成熟。
(十)Linux零拷贝方案对比
?论是传统 I/O 拷??式还是引?零拷?的?式,2 次 DMA Copy 是都少不了的,因为两次 DMA都是依赖硬件完成的。下?从 CPU 拷?次数、DMA 拷?次数以及系统调??个??总结?下上述?种I/O 拷??式的差别。
拷??式
|
CPU拷? | DMA拷? | 系统调? | 上下?切换 |
传统?式(read + write)
|
2 | 2 |
read / write
|
4 |
内存映射(mmap + write)
|
1 | 2 | mmap + write | 4 |
sendfile
|
1 | 2 | sendfile | 2 |
sendfile + DMA gather copy
|
0 | 2 | sendfile | 2 |
splice
|
0 | 2 | splice | 2 |
二、JAVA NIO零拷贝实现方式
(一)MappedByteBuffer
MappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷??式的提供的?种实现,它继承? ByteBuffer。FileChannel 定义了?个 map() ?法,它可以把?个?件从 position 位置开始的 size??的区域映射为内存映像?件。抽象?法 map() ?法在 FileChannel 中的定义如下:
public abstract MappedByteBuffer map(MapMode mode, long position, long size) throws IOException;
参数解释:
mode:限定内存映射区域(MappedByteBuffer)对内存映像?件的访问模式,包括只可读(READ_ONLY)、可读可写(READ_WRITE)和写时拷?(PRIVATE)三种模式。
position:?件映射的起始地址,对应内存映射区域(MappedByteBuffer)的?地址。
size:?件映射的字节?度,从 position 往后的字节数,对应内存映射区域(MappedByteBuffer)的??。
MappedByteBuffer 相? ByteBuffer 新增了 fore()、load() 和 isLoad() 三个重要的?法:
fore():对于处于 READ_WRITE 模式下的缓冲区,把对缓冲区内容的修改强制刷新到本地?件。
load():将缓冲区的内容载?物理内存中,并返回这个缓冲区的引?。
isLoaded():如果缓冲区的内容在物理内存中,则返回 true,否则返回 false。
下?给出?个利? MappedByteBuffer 对?件进?读写的使?示例:
public class MappedByteBufferTest { private final static String CONTENT = "Zero copy implemented by MappedByteBuffer"; private final static String FILE_NAME = "/Users/conglongli/Desktop/mmap.txt"; private final static String CHARSET = "UTF-8"; /** * 写文件数据:打开文件通道 fileChannel 并提供读权限、写权限和数据清空权限, * 通过 fileChannel 映射到一个可写的内存缓冲区 mappedByteBuffer, * 将目标数据写入 mappedByteBuffer,通过 force() 方法把缓冲区更改的内容强制写入本地文件。 * */ public void writeToFileByMappedByteBuffer() { Path path = Paths.get(FILE_NAME); byte[] bytes = CONTENT.getBytes(Charset.forName(CHARSET)); try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, bytes.length); if (mappedByteBuffer != null) { mappedByteBuffer.put(bytes); mappedByteBuffer.force(); } } catch (IOException e) { e.printStackTrace(); } } /** * 读文件数据:打开文件通道 fileChannel 并提供只读权限, * 通过 fileChannel 映射到一个只可读的内存缓冲区 mappedByteBuffer, * 读取 mappedByteBuffer 中的字节数组即可得到文件数据 */ public void readFromFileByMappedByteBuffer() { Path path = Paths.get(FILE_NAME); int length = CONTENT.getBytes(Charset.forName(CHARSET)).length; try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ)) { MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, length); if (mappedByteBuffer != null) { byte[] bytes = new byte[length]; mappedByteBuffer.get(bytes); String content = new String(bytes, StandardCharsets.UTF_8); System.out.println(content); } } catch (IOException e) { e.printStackTrace(); } } }
(二)DirectByteBuffer
DirectByteBuffer 的对象引?位于 Java 内存模型的堆??,JVM 可以对 DirectByteBuffer 的对象进?内存分配和回收管理,?般使? DirectByteBuffer 的静态?法 allocateDirect() 创建DirectByteBuffer 实例并分配内存。
public static ByteBuffer allocateDirect(int capacity) { return new DirectByteBuffer(capacity); }
DirectByteBuffer 内部的字节缓冲区位在于堆外的(?户态)直接内存,它是通过 Unsafe 的本地?法allocateMemory() 进?内存分配,底层调?的是操作系统的 malloc() 函数。
(三)FileChannel
FileChannel 是?个?于?件读写、映射和操作的通道,同时它在并发环境下是线程安全的,基于FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel() ?法可以创建并打开?个?件通道。FileChannel 定义了 transferFrom() 和 transferTo() 两个抽象?法,它通过在通道和通道之间建?连接实现数据传输的。
transferTo():通过 FileChannel 把?件??的源数据写??个 WritableByteChannel 的?的通道。
public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException;
transferFrom():把?个源通道 ReadableByteChannel 中的数据读取到当前 FileChannel 的?件??。
public abstract long transferFrom(ReadableByteChannel src, long position, long count) throws IOException;
(四)NIO 零拷?案例
1、服务端
public class NewIOServer { public static void main(String[] args) throws Exception { InetSocketAddress address = new InetSocketAddress(7001); ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); ServerSocket serverSocket = serverSocketChannel.socket(); serverSocket.bind(address); //创建buffer ByteBuffer byteBuffer = ByteBuffer.allocate(4096); while (true) { SocketChannel socketChannel = serverSocketChannel.accept(); int readcount = 0; while (-1 != readcount) { try { readcount = socketChannel.read(byteBuffer); } catch (Exception ex) { // ex.printStackTrace(); break; } // byteBuffer.rewind(); //倒带 position = 0 mark 作废 } } } }
2、客户端
public class NewIOClient { private static String filename = "/Users/conglongli/Downloads/jdk-8u301-linux-x64.tar.gz"; /** * 使用 NIO 零拷贝方式传递(transferTo)一个大文件 * @param args * @throws Exception */ public static void main1(String[] args) throws Exception { SocketChannel socketChannel = SocketChannel.open(); socketChannel.connect(new InetSocketAddress("localhost", 7001)); //得到一个文件channel FileChannel fileChannel = new FileInputStream(filename).getChannel(); //准备发送 long startTime = System.currentTimeMillis(); //transferTo 底层使用到零拷贝 long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel); System.out.println("发送的总的字节数 = " + transferCount + " 耗时: " + (System.currentTimeMillis() - startTime)); //关闭 fileChannel.close(); } /** * 使用传统的 IO 方法传递一个大文件 * @param args * @throws Exception */ public static void main(String[] args) throws Exception { File file = new File(filename); RandomAccessFile raf = new RandomAccessFile(file, "rw"); byte[] arr = new byte[(int) file.length()]; raf.read(arr); SocketChannel socketChannel = SocketChannel.open(); socketChannel.connect(new InetSocketAddress("localhost", 7001)); //准备发送 long startTime = System.currentTimeMillis(); socketChannel.socket().getOutputStream().write(arr); System.out.println("发送的总的字节数 = " + arr.length + " 耗时: " + (System.currentTimeMillis() - startTime)); } }
文件大小是145M,在使用NIO时耗时80毫秒,在使用传统IO时耗时148毫秒。
三、其他零拷贝实现方式
1、Netty零拷贝
Netty 中的零拷?和上?提到的操作系统层?上的零拷?不太?样, 我们所说的 Netty 零拷?完全是基于(Java 层?)?户态的,它的更多的是偏向于数据操作优化这样的概念,具体表现在以下?个??:
Netty 通过 DefaultFileRegion 类对 java.nio.channels.FileChannel 的 tranferTo() ?法进?包装,在?件传输时可以将?件缓冲区的数据直接发送到?的通道(Channel)
ByteBuf 可以通过 wrap 操作把字节数组、ByteBuf、ByteBuffer 包装成?个 ByteBuf 对象, 进?避免了拷?操作
ByteBuf ?持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同?个存储区域的 ByteBuf,避免了内存的拷?
Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为?个逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷?
其中第 1 条属于操作系统层?的零拷?操作,后? 3 条只能算?户层?的数据操作优化。
2、RocketMQ和Kafka对?
RocketMQ 选择了 mmap + write 这种零拷??式,适?于业务级消息这种?块?件的数据持久化和传输;? Kafka 采?的是 sendfile 这种零拷??式,适?于系统?志消息这种?吞吐量的?块?件的数据持久化和传输。但是值得注意的?点是,Kafka 的索引?件使?的是 mmap + write ?式,数据?件使?的是 sendfile ?式。