使用零拷贝进行高效数据传输

5 篇文章 1 订阅

学习零拷贝的时候,看到一篇写得非常好的英文文章,尝试翻译一波

-----------------------------

很多web应用提供了非常多的静态内容,这些数据从磁盘读取并写到响应socket中。这一活动可能只需要相当少的CPU活动,然而它往往是有些低效的:内核(kernel)从磁盘读取数据,经由内核-用户边界到达应用(Application),然后应用又把数据通过内核-用户边界写到socket中。结果就是,应用充当了一个非常低效的中介,只是用来把数据从磁盘传递到socket。

每次数据传输经过用户-内核边界,就会被复制,带来了CPU周期(CPU cycles)和内存带宽的消耗。幸运的是,你可以通过一种叫做零拷贝(zero copy)的技术来避免这些无意义的拷贝。零拷贝技术允许内核直接从磁盘复制数据到socket,并且不会经过应用。零拷贝极大的提高了应用性能并且减少了内核态和用户态之间的上下文切换。

Java库在Linux和UNIX系统上支持零拷贝,通过java.nio.channels.FileChannel的transferTo()去支持。使用transferTo()能够在调用数据的通道到另一个可写字节通道直接传递数据,且并不需要数据流经应用。本文首先阐释了使用传统复制方式进行简单的文件传输所带来的开销,然后展示transferTo()零拷贝技术如何实现更好的性能。

数据传输:传统方式

考虑一下读取文件并通过网络传输到另一个程序的场景(这个场景解释了很多服务器应用的行为,包括web应用提供静态内容,FTP服务器、邮箱服务器等等)。清单1展示了这一操作核心的两个方法

清单1. 从文件到Socket的字节复制

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

尽管清单1看起来非常简单,实际上在底层,复制操作需要四次用户态和内核态的上下文切换,同时数据在传输完成前复制了四次。图1展示了数据在底层是如何从文件传输到socket的。

图1. 传统的数据复制方式

Traditional data copying approach

图2展示了上下文切换

图2. 传统上下文切换

Traditional context switches

它包括以下步骤:

  1. read()调用引起用户态到内核态的切换(图2)。底层上会调用sys_read()(或者类似的方法)从文件读取数据。第一次复制(图1)是通过随机存储访问(DMA)引擎,从磁盘读取文件内容并存储到内核地址空间缓冲区(kernel address space buffer,注:我猜这里指的是图1的Read buffer吧)。
  2. 数据从读缓冲区(Read buffer)复制到用户缓冲区(注:可能指的是图1的Application buffer),然后read()调用返回结果(注:即我们读取操作拿到了文件内容)。这里又引起了另一次从内核态到用户态的上下文切换。现在数据已经存储在了用户地址空间缓存(user address space buffer,注:可能指的是图1的Application buffer)。
  3. send()调用引起用户态到内核态的切换。第三次复制是:数据被再次存放到了内核地址空间缓冲区(kernel address space buffer,注:我猜这里指的是图1的Socket buffer吧)。尽管这一次数据被放在了不同的缓存,用来和目标socket关联的缓存。
  4. send()调用返回,导致了第四次上下文切换(注:内核到用户态)。第四次复制发生在DMA引擎从内核缓冲区传递数据到协议引擎(protocol engine,注:我猜这里指的是图1的Nic buffer吧),这是独立且异步的。

使用中间内核缓冲区可能看起来低效(而不是直接传输数据到用户缓冲区),但是中间内核缓冲区被证实能够提高性能。当应用并不能请求像内核缓存区存储的那么多的数据,使用中间内核缓冲区在读端允许内核缓冲区扮演一个"readahead cache"的角色。当请求数据量小于内核缓冲区大小时,能够极大的改善性能。在写端的中间缓冲区允许写操作异步的执行((注:我的理解是,应用把数据写入写缓冲区,然后数据又从写缓冲区异步地传输到Nic buffer)。((注:这一段不好翻译,我个人理解是,我们从磁盘读取数据到应用层,需要经过读缓冲区,再从读缓冲区读取数据,而不是直接把数据从磁盘传递过来,因为我们的应用可能没有办法一次性接收那么多的数据)

不幸的是,当请求数据量大于内核缓存区大小时,这种方式就成了性能瓶颈。在数据最终传递到应用前,数据在磁盘、内核缓存区和用户缓存区之间复制多次(注:随着需要读取的数据量的增大,无意义的拷贝就越多,自然影响到性能)。

零拷贝通过减少不必要的数据拷贝来改善性能。

数据传输:零拷贝方式

如果你重新检查传统的场景,你会发现第二次和第三次数据复制实际上是没有必要的。应用层除了充当了数据的缓冲区,并把它传递回socket缓冲区,其他事并没有做。然而,数据本可以直接从读缓冲区传递到socket缓冲区的(注:请细细品味图1)。transferTo()可以让你实现这一需求。清单2展示了transferTo()的签名(signature):

清单2. TransferTo()

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

TransferTo()在文件通道到给定的可写字节通道传输数据。它在底层上依赖于操作系统对零拷贝的支持。在UNIX以及不同风格的Linux系统中,该操作在底层上会去调用sendfile()系统方法。在清单3可以看到,数据从一个文件描述符传递到另一个文件描述符。

清单3. sendfile()系统调用

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

清单1里面提到的file.read()和socket.read()调用可以由简单的transferTo()调用替代,在清单4中可以看到:

清单4. 使用transferTo()从磁盘到socket复制数据

transferTo(position, count, writableChannel);

图3展示了transferTo()调用过程中数据的传输路径

图3. 使用transferTo()复制数据

Data copy with transferTo()

图4展示了transferTo()调用时上下文的切换

图4. transferTo()的上下文切换

Context switching when using transferTo()

图4展示的transferTo方法的具体步骤如下:

  1. transferTo()方法导致文件内容从DMA引擎被复制到读缓存区中,然后内核复制数据到和输出socket关联的内核缓存区。
  2. 当DMA引擎把数据从内核socket缓冲区传递到协议引擎时,第三次复制了。

这就是一些改善的地方:我们把上下文切换次数从四次减少到两次,然后把数据复制的次数从四次减少到三次(只有一次涉及到CPU,注:我理解时读缓冲区到socket缓冲区这块操作涉及了CPU,其他是DMA操作)。然而这并不能真正的实现我们零拷贝的目的。如果底层网卡(underlying network interface card)支持收集操作(gather operation),我们就可以更近一步的减少内核数据复制。在Linux内核2.4+,socket缓冲区描述符被修改来满足这一需求。这种方法不仅减少了多次上下文切换,而且减少了需要CPU参与的拷贝。用户端仍然保持不变,只是内部做了一些改变:

  1. transferTo()导致文件通过DMA引擎被复制到内核缓冲区(注:Read buffer)
  2. 数据不再被复制到socket缓存区了,取而代之的是,带有地址和长度信息的描述符被用来添加到socket缓存区中。DMA引擎从内核缓冲区到协议引擎直接传递数据,所以减少了唯一一次的CPU复制。

图5显示了使用了收集操作的transferTo()的数据拷贝

图5. 使用transferTo()和收集操作来进行数据拷贝

Data copies when transferTo() and gather operations are used

构造一个文件服务器(非重点,有空再翻译)

Now let’s put zero copy into practice, using the same example of transferring a file between a client and a server (see Download for the sample code). TraditionalClient.java and TraditionalServer.java are based on the traditional copy semantics, using File.read() and Socket.send()TraditionalServer.java is a server program that listens on a particular port for the client to connect, and then reads 4K bytes of data at a time from the socket. TraditionalClient.java connects to the server, reads (using File.read()) 4K bytes of data from a file, and sends (using socket.send()) the contents to the server via the socket.

Similarly, TransferToServer.java and TransferToClient.java perform the same function, but instead use the transferTo() method (and in turn the sendfile() system call) to transfer the file from server to client.

性能对比

我们在2.6内核的Linux系统执行相同的应用并且计算在不同文件大小的情况下,传统方式和transferTo方式的运行毫秒数。表1是它们的结果:

表格1. 性能对比:传统方式 vs 零拷贝

文件大小正常复制 (ms)transferTo (ms)
7MB15645
21MB337128
63MB843387
98MB1320617
200MB21241150
350MB36311762
700MB134984422
1GB183998537

可以看出,transferTo方法比传统方法减少了大概65%的时间。对于从一个IO通道复制数据到另一个IO通道(比如Web服务器)的场景,使用这种方式可以很有效的提高性能。

总结

我们通过对比数据从一个通道读取到另一个通道的方式证明了transferTo()的性能优势。中间缓冲区复制,尽管被隐藏在底层内核,也会带来可见的开销。对于在通道间复制数据的应用来说,零拷贝能够提供一个非常大的性能改善。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值