通过zero-copy进行高效的数据传输(译)

Posted by AlstonWilliams on February 17, 2019

这篇文章翻译自Efficient data transfer through zero copy。由于译者水平有限,有的地方翻译的可能不正确,所以读者应当先查看原文。

Web应用程序为了提供静态内容,需要先从磁盘中读出数据,然后将这些数据写入到Socket中。虽然这个过程不会占用太多CPU资源,但是它确实效率不高。内核从磁盘读取数据,需要在内核态和用户态之间切换,然后将它写入到Socket时,又需要从用户态切换到内核态。

当数据在用户态和内核态之间切换时,会消耗CPU资源以及内存带宽。我们可以通过一种叫做Zero-copy的技术来消除这些开销。zero-copy会将数据直接从磁盘拷贝到Socket,这就避免在内核态和用户态之间的切换。在Java中,java.nio.channels.FileChanneltransferTo()函数就是通过zero-copy来实现的。你可以通过transferTo()方法,直接将数据从一个Channel传输到另一个Channel。

在这篇文章中,首先通过一个使用传统方式实现的文件传输的例子,来查看其开销。然后通过一个用zero-copy实现的例子,来验证其性能优势。

使用传统的方式实现的文件传输例子

假设我们要开发一个用于从特定文件中读取数据,然后通过网络将它传输给其他的程序的例子。这个程序的核心,就是这么两个调用:

File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);

这很容易理解。但是,在其内部,却有着四次用户态和内核态之间的切换,并且,数据被拷贝了四次。下面这张图,展示了数据传输的过程:

而下面这张图,则展示了内核态和用户态之间切换的过程:

这四次用户态和内核态之间切换分别是:

  1. 当调用read()时,会导致一次从用户态到内核态的切换。read()的内部是通过sys_read()来实现的。第一次数据复制,是数据通过DMA被从文件读出并且放在了内核的buffer中。
  2. 数据从内核的读缓冲区被复制到应用程序的缓冲区,然后read()调用返回。这就会导致一次从内核态到用户态的切换。现在,数据被存储到了应用程序的缓冲区中。
  3. send()调用又会导致一次从用户态到内核态的切换。这里发生了第三次数据复制,数据从应用程序的缓冲区被复制到内核的写缓冲区。
  4. send()调用返回,这导致了第四次上下文切换。并且数据通过DMA从内核写缓冲区被发送到了协议栈。

即使中间的内核缓冲区(而不是直接将数据传输到应用程序的缓冲区中)似乎看起来效率很低。但是实际上,中间的内核缓冲区是为了提升性能而存在的。对于内核读缓冲区来说,它可以实现预先读,即,缓冲区中预先加载一些磁盘中的数据,下次应用程序在读数据的时候,就不需要再去磁盘读取,而是直接从内核的读缓冲区读取就好了,这是因为,有研究表明,应用程序总是倾向于读取连续的数据,即如果磁盘上的一块数据被访问到了,那么,在磁盘上,与这块数据相邻的其他数据,也很快就会被访问到。这就提高了读性能。而对于写缓冲区来说,它可以实现异步的写操作。即,对于写操作,将数据复制到内核写缓冲区,基本上就可以认为写入成功,而不需要一直阻塞到TCP协议栈真的发送了数据并收到了响应。

然而,内核缓冲区也有一些缺点。想象这样一种场景,应用程序请求10KB的数据,而内核缓冲区仅有4KB,那么,就需要分三次来读取磁盘数据,并复制到应用程序的缓冲区。对于内核的缓冲区也是这样。

通过zero-copy,就可以规避这些问题。

使用zero-copy实现的文件传输的例子

我们可以发现,在上面的例子中,实际上,第二次和第三次数据复制并不是必须的。应用程序仅仅是作为一个桥梁,将数据从内核读缓冲区传输到内核写缓冲区。实际上,数据可以直接从内核的读缓冲区写入到socket的缓冲区。Java中的transferTo()就实现了这个操作。

transferTo()方法的声明如下:

public void transferTo(long position, long count, WritableByteChannel target);

transferTo方法会将数据从FileChannel传输到WritableByteChannel。但是,这个方法依赖于操作系统的底层实现。只有当操作系统支持zero-copy时,这个方法才有用。在UNIX以及Linux的不同发行版中,这个方法最终会调用sendfile()系统调用。

sendfile()的声明如下:

#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

下图展示了transferTo()方法的流程:

下图展示了使用transferTo()方法时,上下文切换的情况:

两次上下文切换分别发生在:

  1. 在调用transferTo()方法时,需要从用户态转换到内核态。然后数据通过DMA被从文件读到内核读缓冲区中。然后数据被从内核读缓冲区复制到Socket的缓冲区中。最后,数据通过DMA被放到了NIC buffer中.
  2. tranferTo()方法返回时,需要从内核态切换到用户态。

即使这相对于用传统方式实现的例子有了一些提升,比如,上下文切换次数从四次降到了两次,数据需要被复制三次,而不是四次了。但是,这还不是zero-copy。要进一步降低开销,需要网络接口直接一些特定的操作。从Linux内核2.4之后,socket buffer descriptor就被修改了,来适应这种需求。这个修改,不仅降低了上下文切换的次数,而且消除了数据在被复制时,涉及到的CPU消耗:

  1. transferTo()方法让数据通过DMA被复制内核读缓冲区
  2. 没有数据会被复制到socket buffer。socket buffer只会收到一个descriptor,它包含了数据的位置和长度信息。DMA直接将数据从内核读缓冲区复制到NIC Buffer中,因此,消除掉了CPU消耗。

性能比较

我们在一个内核为2.6的Linux操作系统上,运行了一些测试,最终得出了下面的结果:

我们可以看到,使用transferTo()实现的这种方式,相对于用传统方式实现,只需要大约65%的时间。