零拷贝

本文最后更新于:2024年3月18日 晚上

零拷贝

  • 零拷贝是指计算机执行 IO 操作时,CPU 不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及 CPU 的拷贝时间,它是一种 I/O 操作优化技术。

传统 IO 的执行流程

  • 传统的 IO 流程,包括 read 和 write 的过程。
    • read:把数据从磁盘读取到内核缓冲区,再拷贝到用户缓冲区。
    • write:先把数据写入到 Socket 缓冲区,最后写入网卡设备。

Image

  • 流程
    1. 用户应用进程调用 read 函数,向操作系统发起 IO 调用,上下文从用户态转为内核态(切换 1)
    2. DMA 控制器把数据从磁盘中,读取到内核缓冲区。
    3. CPU 把内核缓冲区数据,拷贝到用户应用缓冲区,上下文从内核态转为用户态(切换 2), read 函数返回。
    4. 用户应用进程通过 write 函数,发起 IO 调用,上下文从用户态转为内核态(切换 3)
    5. CPU 将用户缓冲区中的数据,拷贝到 Socket 缓冲区。
    6. DMA 控制器把数据从 Socket 缓冲区,拷贝到网卡设备,上下文从内核态切换回用户态(切换 4), write 函数返回。
  • 从流程图可以看出,传统 IO 的读写流程,包括了 4 次上下文切换(4 次用户态和内核态的切换), 4 次数据拷贝(两次 CPU 拷贝以及两次的 DMA 拷贝)

零拷贝实现的几种方式

  • 零拷贝并不是没有拷贝数据,而是减少用户态/内核态的切换次数以及 CPU 拷贝的次数,零拷贝实现有多种方式,分别是:
    • mmap+write
    • sendfile
    • 带有 DMA 收集拷贝功能的 sendfile

mmap+write 实现的零拷贝

  • mmap 的函数原型如下:
1
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • addr:指定映射的虚拟内存地址。

  • length:映射的长度。

  • prot:映射内存的保护模式。

  • flags:指定映射的类型。

  • fd:进行映射的文件句柄。

  • offset:文件偏移量。

  • mmap 利用用了虚拟内存的特点,将内核中的读缓冲区与用户空间的缓冲区进行映射,所有的 IO 都在内核中完成,从而减少数据拷贝次数。

Image

  • mmap+write 实现的零拷贝流程如下:
    1. 用户进程通过 mmap方法 向操作系统内核发起 IO 调用,上下文从用户态切换为内核态
    2. CPU 利用 DMA 控制器,把数据从硬盘中拷贝到内核缓冲区。
    3. 上下文从内核态切换回用户态, mmap 方法返回。
    4. 用户进程通过 write 方法向操作系统内核发起 IO 调用,上下文从用户态切换为内核态
    5. CPU 将内核缓冲区的数据拷贝到的 Socket 缓冲区。
    6. CPU 利用 DMA 控制器,把数据从 Socket 缓冲区拷贝到网卡,上下文从内核态切换回用户态, write 调用返回。
  • 可以发现,mmap+write 实现的零拷贝,I/O 发生了4次用户空间与内核空间的上下文切换,以及 3 次数据拷贝,其中 3 次数据拷贝中,包括了2 次 DMA 拷贝和 1 次 CPU 拷贝
  • mmap 是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,所以节省了一次 CPU 拷贝‘’并且用户进程内存是虚拟的,只是映射到内核的读缓冲区,可以节省一半的内存空间。

sendfile 实现的零拷贝

  • sendfile 是 Linux 2.1 内核版本后引入的一个系统调用函数,API 如下:
1
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
  • out_fd:为待写入内容的文件描述符,一个 Socket 描述符。

  • in_fd:为待读出内容的文件描述符,必须是真实的文件,不能是 Socket 和管道。

  • offset:指定从读入文件的哪个位置开始读,如果为 NULL,表示文件的默认起始位置。

  • count:指定在 fdout 和 fdin 之间传输的字节数。

  • sendfile 表示在两个文件描述符之间传输数据,它是在操作系统内核中操作的,避免了数据从内核缓冲区和用户缓冲区之间的拷贝操作,因此可以使用它来实现零拷贝。

Image

  • sendfile 实现的零拷贝流程如下:
    1. 用户进程发起 sendfile 系统调用,上下文(切换 1)从用户态转向内核态
    2. DMA 控制器,把数据从硬盘中拷贝到内核缓冲区。
    3. CPU 将读缓冲区中数据拷贝到 Socket 缓冲区。
    4. DMA 控制器,异步把数据从 Socket 缓冲区拷贝到网卡。
    5. 上下文(切换 2)从内核态切换回用户态, sendfile 调用返回。
  • 可以发现,sendfile 实现的零拷贝,I/O 发生了2次用户空间与内核空间的上下文切换,以及 3 次数据拷贝,其中 3 次数据拷贝中,包括了2 次 DMA 拷贝和 1 次 CPU 拷贝

sendfile+DMA scatter/gather 实现的零拷贝

  • linux 2.4 版本之后,对 sendfile 做了优化升级,引入 SG-DMA 技术,其实就是对 DMA 拷贝加入了 scatter/gather 操作,它可以直接从内核空间缓冲区中将数据读取到网卡,使用这个特点实现零拷贝,还可以多省去一次 CPU 拷贝

Image

  • sendfile+DMA scatter/gather 实现的零拷贝流程如下:
    1. 用户进程发起 sendfile 系统调用,上下文(切换 1)从用户态转向内核态
    2. DMA 控制器,把数据从硬盘中拷贝到内核缓冲区。
    3. CPU 把内核缓冲区中的文件描述符信息(包括内核缓冲区的内存地址和偏移量)发送到 Socket 缓冲区。
    4. DMA 控制器根据文件描述符信息,直接把数据从内核缓冲区拷贝到网卡。
    5. 上下文(切换 2)从内核态切换回用户态, sendfile 调用返回。
  • 可以发现,sendfile+DMA scatter/gather 实现的零拷贝,I/O 发生了2次用户空间与内核空间的上下文切换,以及 2 次数据拷贝,其中 2 次数据拷贝都是DMA 拷贝,这就是真正的 零拷贝(Zero-copy) 技术,全程都没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。

Java 提供的零拷贝方式

  • Java NIO 对 mmap 的支持。
  • Java NIO 对 sendfile 的支持。

Java NIO 对 mmap 的支持

  • Java NIO 有一个 MappedByteBuffer 的类,可以用来实现内存映射,它的底层是调用了 Linux 内核的mmap的 API
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MmapTest {

public static void main(String[] args) {
try {
FileChannel readChannel = FileChannel.open(Paths.get("./jay.txt"), StandardOpenOption.READ);
MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY, 0, 1024 * 1024 * 40);
FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
// 数据传输。
writeChannel.write(data);
readChannel.close();
writeChannel.close();
}catch (Exception e){
System.out.println(e.getMessage());
}
}
}

Java NIO 对 sendfile 的支持

  • FileChannel 的 transferTo()/transferFrom(),底层就是 sendfile ()系统调用函数,Kafka 这个开源项目就用到它,平时面试的时候,回答面试官为什么这么快,就可以提到零拷贝 sendfile 这个点。
1
2
3
4
@Override
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
return fileChannel.transferTo(position, count, socketChannel);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SendFileTest {
public static void main(String[] args) {
try {
FileChannel readChannel = FileChannel.open(Paths.get("./jay.txt"), StandardOpenOption.READ);
long len = readChannel.size();
long position = readChannel.position();

FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
// 数据传输。
readChannel.transferTo(position, len, writeChannel);
readChannel.close();
writeChannel.close();
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!