前言
上一篇文章讲述了传统数据传输的过程,以及为什么传统数据传输的性能很低的原因。这一排就来具体的说一下零拷贝的方式和原理。
什么是零拷贝?
简单一点来说,零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。在数据拷贝进行的同时,允许 CPU 执行其他的任务从来提升应用程序的性能。零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率。
DMA
DMA(Direct Memory Access,直接存储器访问)。DMA是指外部设备不通过CPU而直接与系统内存交换数据的接口技术。通常系统总线是由CPU管理的,在DMA方式时,就希望CPU把这些总线让出来,即CPU连到这些总线上的线处于第三态(高阻状态),而由DMA控制器接管,控制传送的字节数,判断DMA是否结束,以及发出DMA结束信号。
但是DMA只是解决了内核态从磁盘或外部取文件数据的CPU占用时间,并没有彻底解决那繁琐的4次上下文切换和2次内存数据拷贝。
常见的零拷贝
一.mmap内存映射
在这之前先了解几个概念:
进程中的虚拟内存
进程对内存的读写不是直接使用物理内存地址,而是基于虚拟地址。每个进程运行时,操作系统都会为其创建一个私有的虚拟内存,存放进程运行时代码和数据。操作系统通过内存管理机制,将虚拟内存映射到物理内存。
虚拟内存使得操作系统可以同时支持多个运行进程安全共享物理内存,防止进程之间的不安全读写。
虚拟内存分为两部分:用户空间(User space)和内核空间(kernel space)。用户空间存放用户代码和用户数据;内核空间存放操作系统代码。
前面说过,每个进程有自己私有的虚拟内存,不同进程的虚拟内存中的相同的地址,被映射到物理内存中的不同位置。但是内核空间是个例外,所有进程是共享内核空间的,也就是对不同进程来说,它们内核空间内的内容、地址映射实际上都是相同的。
缺页中断(page fault)
操作系统为每个进程的虚拟内存和物理内存之间建立了一张映射表,需要注意的是,虚拟内存中的内容只会一部分被装载到物理内存中。
当进程访问的虚拟地址对应的内容不在物理内存时,操作系统会触发一个缺页中断,将物理内存中不用的内容暂时置换到磁盘,将需要的内容读取道物理内存。通过这种管理模式,我们可以在同时运行多个进程的情况下,让每个进程觉得自己在独享整个内存空间。
mmap主要也是依靠缺页中断来获取磁盘文件。
对于内存映射,其实是文件到内存空间的映射,对于用户应用程序来说,和文件建立映射关系的是虚拟地址空间,而不是物理内存或Heap。
当我们建立一个2g大小的映射时,并不是在heap,更不是在物理内存中分配了这么大的空间,仅仅是在虚拟地址空间中划出了这么大一个区域而已,好比是做个记号。
应用访问内存映射区域时,操作系统会把虚拟的地址映射成真正的物理内存地址和底层文件的偏移量。如果应用访问的虚拟地址对应的文件内容尚未被装入内存,操作系统通过缺页中断,将内存中的部分内容交换出去,腾出空间将文件的内容读取到内存。
mmap基本概念
mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了用户程序对文件的操作而不必再调用read,write等系统调用函数(read,write等操作是对用户空间来说)。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。从而可以看出mmap其实是映射在用户空间的。
这里的内存并不是实际的物理内存,而是指进程的虚拟内存地址。
mmap的原理
- 进程启动映射过程,进程在用户空间调用mmap函数,在虚拟地址空间中为映射创建虚拟映射区域。
- 调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
- 进程访问分配的虚拟的地址区间中的某个地址,引发缺页异常,实现文件内容到物理内存的拷贝。
应用程序通过虚拟地址查询页表,发现这一段地址并不在物理内存中,所以使用缺页中断把磁盘中的数据读入到物理内存中(也就是读入到页缓存,按虚拟分区是在内核空间中),而且用户程序已经有了虚拟映射地址,可以通过这个映射地址访问到页缓存中的数据。
所以mmap的零拷贝关键在于不再需要把数据从内核内存空间拷贝到用户内存空间。
mmap+write
再说下程序从磁盘中读取然后发送到网络中的过程。
基于 mmap + write 系统调用的零拷贝方式,整个拷贝过程会发生 4 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝,用户程序读写数据的流程如下:
- 程序启动,调用mmap,创建好虚拟映射区域,并且文件物理地址和进程虚拟地址的一一映射关系。
- 用户程序读取文件数据时,通过页表查询,发现物理内存上没有该数据,那么就要系统调用,从用户态切换到内核态(第一次上下文切换)
- 通过DMA把磁盘中的数据读取到页缓存中(内核缓存区),然后切换到用户态(第二次上下文切换),因为用户空间已经有了虚拟映射地址,所以他是可以找到缓存在页缓存中的数据的,也就不需要再拷贝到用户空间去了。
- 接下来用户程序就要调用write方法,把数据写入网卡中。用户态再次切换到内核态(第三次上下文切换),然后使用CPU拷贝,把内核空间中的数据拷贝到Socket缓冲区。
- 再利用DMA技术把数据拷贝到网卡进行数据传输。
- 最后再切换回用户态(第四次上下文切换)
mmap 主要的用处是提高 I/O 性能,特别是针对大文件。对于小文件,内存映射文件反而会导致碎片空间的浪费,因为内存映射总是要对齐页边界,最小单位是 4 KB,一个 5 KB 的文件将会映射占用 8 KB 内存,也就会浪费 3 KB 内存。并且创建mmap时需要创建虚拟地址映射区域,需要创建映射关系也需要花费时间。
sendfile
sendfile 系统调用在 Linux 内核版本 2.1 中被引入,目的是简化通过网络在两个通道之间进行的数据传输过程。sendfile 系统调用的引入,不仅减少了 CPU 拷贝的次数,还减少了上下文切换的次数,它的伪代码如下:
sendfile(socket_fd, file_fd, len);
基于 sendfile 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝,用户程序读写数据的流程如下:
- 用户进程通过 sendfile() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
- CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。
- CPU 将读缓冲区(read buffer)中的数据拷贝到的网络缓冲区(socket buffer)。
- CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
- 上下文从内核态(kernel space)切换回用户态(user space),sendfile 系统调用执行返回。
相比较于 mmap 内存映射的方式,sendfile 少了 2 次上下文切换,但是仍然有 1 次 CPU 拷贝操作,也就是不再需要用户进行调用write操作了。sendfile 存在的问题是用户程序不能对数据进行修改,而只是单纯地完成了一次数据传输过程。
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 + DMA gather copy 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换、0 次 CPU 拷贝以及 2 次 DMA 拷贝。
比起sendfile少了一次CPU拷贝。不再拷贝到Socket缓冲区。而且sendfile + DMA gather copy 拷贝方式同样存在用户程序不能对数据进行修改的问题,而且本身需要硬件的支持,它只适用于将数据从文件拷贝到 socket 套接字上的传输过程。
splice
sendfile + DMA gather copy 只适用于将数据从文件拷贝到 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 拷贝,用户程序读写数据的流程如下:
- 用户进程通过 splice() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
- CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。
- CPU 在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline)。
- CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
- 上下文从内核态(kernel space)切换回用户态(user space),splice 系统调用执行返回。
splice 拷贝方式也同样存在用户程序不能对数据进行修改的问题。除此之外,它使用了 Linux 的管道缓冲机制,可以用于任意两个文件描述符中传输数据,但是它的两个文件描述符参数中有一个必须是管道设备。
小结
通过两篇文章的描述,我们可以看到零拷贝的目标就是为了减少上下文切换和占用CPU拷贝所消耗的时间,但是零拷贝还是有无法修改数据的问题存在,在Linux系统中虽然mmap可以实现修改数据,但是他还存在一次CPU拷贝,所以现如今比较好的零拷贝方式就是splice。
参考资料:
https://blog.csdn.net/wypblog/article/details/101731752
https://www.cnblogs.com/huxiao-tee/p/4660352.html