2020/01/04 Zero Copy

零拷贝是网络 IO 的一个概念。在了解零拷贝之前,我们先来看一个简单的问题。我们经常会遇到需要将硬盘中的一个文件拷贝到另外的地方去,这是一个非常简单而且常见的场景,使用 JavaIO 很容易就可以实现。我们知道,Java 程序最终也是调用操作系统的 read() 和 write() 方法来实现的。虽然看起来很简单,但是这两步操作却经历了 4 次拷贝和 4 次上下文切换。

20200115132437

如上图所示,第一步,用户程序调用系统的 read() 方法,上下文从用户模式切换到内核模式,系统通过DMA拷贝,将文件内容从磁盘上读取出来,写到内核缓冲区。
第二步,数据从内核缓冲区通过 cpu 拷贝复制到用户缓冲区,然后 read() 方法返回,上下文从内核模式切换到用户模式。
第三步,调用系统的 write() 方法,上下文从用户模式切换到内核模式,同时通过 cpu 拷贝将数据放到套接字关联的缓冲区中。
第四步,系统通过 DMA 拷贝将数据写到协议栈中,write() 方法返回,上下文从内核模式切换到用户模式。

cpu 拷贝:计算机中内存的读写操作需要 cpu 来协调数据总线、地址总线和控制总线共同完成。所以在发生读写操作的时候,cpu 往往需要停下来,协助内存协调总线资源,因此叫做 cpu 拷贝。
DMA 拷贝:Direct Memory Access ,在计算机中,当需要和外设进行数据交换时,cpu 需要进行初始化,再外设和内存之间传输不需要 cpu 参与。
上下文切换:操作系统为了保护系统不被破坏,为操作系统设置了两种状态:用户状态和内核状态。当用户需要访问系统资源时,需要通过系统调用,从用户状态进入到内核状态,调用完成后再由内核状态回到用户状态,两种状态的转换就是所说的上下文切换。

可以看到,一个简单的文件复制,实际也是经历复杂 cpu 和内存操作。为了能够节省系统资源和提高性能,Linux 系统进行了 mmap 优化。

20200115132526

如上图所示,第一步,用户程序调用系统的 read() 方法,上下文从用户模式切换到内核模式,系统通过 DMA 拷贝,将文件内容从磁盘上读取出来,写到内核缓冲区。然后与用户进程共享该缓冲区,而不需要进行 CPU 拷贝,read() 方法返回,上下文从内核模式切换到用户模式。
第二步,调用系统的 write() 方法,上下文从用户模式切换到内核模式,同时将数据放到套接字关联的缓冲区中。
第三步,系统通过 DMA 拷贝将数据写到协议栈中,write() 方法返回,上下文从内核模式切换到用户模式。

通过 mmap 优化,减少了一次 cpu 拷贝,但是还是有 3 次拷贝和 4 次上下文切换。
在 Linux 内核 2.1 版本中又进一步优化,引入了 sendfile 。

20200115132644

如上图所示,第一步,用户程序调用系统的 sendfile 操作,使用 DMA 拷贝将文件内容从磁盘复制到内核缓冲区。
第二步,通过 cpu 拷贝将数据从内核缓冲区复制到套接字关联的缓冲区。
第三步,通过 DMA 拷贝将数据从套接字缓冲区放到协议栈中,然后上下文从内核模式切换到用户模式。

到目前位置,整个过程还是经历了 2 次 DMA 拷贝和 1 次 cpu 拷贝,以及 2 次上下文切换。
到了内核 2.4 版本中,Linux 对套接字缓冲区描述符进行了修改,不再将内核缓冲区中的数据拷贝到套接字缓冲区中,而只是将数据的长度等信息添加到套接字缓冲区中,这些信息大小几乎可以忽略不记,从而又减少了一次 cpu 拷贝,如下图所示。

20200115132723

第一步,用户程序调用系统的 sendfile 操作,使用 DMA 拷贝将文件内容从磁盘复制到内核缓冲区,将数据的长度等信息写到套接字缓冲区。
第二步,通过 DMA 拷贝将数据从套接字缓冲区放到协议栈中,然后上下文从内核模式切换到用户模式。

最终,整个过程只进行了 2 次 DMA 拷贝,没有 cpu 拷贝,这就是我们所说的零拷贝。因此我们看到,零拷贝并不是整个系统没有拷贝的操作,而是整个过程没有消耗 cpu 资源的拷贝操作。