zero copy实现高效的数据传输

一.前言

许多Web应用程序提供大量静态内容,这相当于从磁盘读取数据并将完全相同的数据写回到响应套接字。此活动似乎只需要相对较少的CPU活动,但效率有点低下:内核从磁盘读取数据并将其跨越内核用户边界推送到应用程序,然后应用程序将其推回到内核用户边界写出到插座。实际上,应用程序作为一个低效的媒介,将数据从磁盘文件获取到套接字。

每次数据遍历用户内核边界时,都必须复制它,这会消耗CPU周期和内存带宽。幸运的是,您可以通过一种名为 - 足够恰当 - 零拷贝的技术来消除这些副本使用零拷贝请求的应用程序,内核将数据直接从磁盘文件复制到套接字,而不通过应用程序。零拷贝大大提高了应用程序的性能,并减少了内核和用户模式之间的上下文切换次数。

Java类库通过transferTo()in方法在 Linux和UNIX系统上支持零拷贝java.nio.channels.FileChannel您可以使用该 transferTo()方法将字节从其调用的通道直接传输到另一个可写字节通道,而不需要数据流经应用程序。本文首先演示通过传统的复制语义完成简单文件传输所带来的开销,然后展示如何使用零复制技术 transferTo()实现更好的性能。

日期转移:传统方法

考虑从文件读取并通过网络将数据传输到另一个程序的场景。(本场景描述了许多服务器应用程序的行为,包括服务静态内容的Web应用程序,FTP服务器,邮件服务器等等)。操作的核心在清单1中的两个调用中(请参阅下载以获取指向完整的示例代码):

清单1.将文件中的字节复制到套接字
1
2
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);

虽然清单1在概念上很简单,但在内部,复制操作需要在用户模式和内核模式之间切换四次上下文,并且在操作完成之前将数据复制四次。图1显示了数据如何从文件内部移动到套接字:

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

图2显示了上下文切换:

图2.传统的上下文切换
传统上下文切换

涉及的步骤是:

  1. read()调用导致从用户模式到内核模式的上下文切换(参见图2)。内部sys_read()发布(或等效)以从文件中读取数据。第一个副本(参见图1)由直接内存访问(DMA)引擎执行,该引擎从磁盘读取文件内容并将它们存储到内核地址空间缓冲区中。
  2. 所请求的数据量从读缓冲区复制到用户缓冲区中,然后read()调用返回。调用返回导致另一个从内核切换到用户模式的上下文。现在数据存储在用户地址空间缓冲区中。
  3. send()插座调用导致从用户模式到内核模式的上下文切换。执行第三个副本,将数据再次放入内核地址空间缓冲区。但是,这一次,数据被放入不同的缓冲区,一个与目标套接字关联的缓冲区。
  4. send()系统调用返回,创造了第四上下文切换。独立和异步地,当DMA引擎将数据从内核缓冲区传递到协议引擎时,会发生第四个副本。

使用中间内核缓冲区(而不是将数据直接传输到用户缓冲区)可能看起来效率低下。但是中间内核缓冲区被引入到流程中以提高性能。在应用程序没有要求与内核缓冲区一样多的数据时,在读取端使用中间缓冲区允许内核缓冲区充当“预读缓存”。当请求的数据量小于内核缓冲区大小时,这会显着提高性能。写入侧的中间缓冲区允许写入异步完成。

不幸的是,如果所请求数据的大小远远大于内核缓冲区大小,这种方法本身可能会成为性能瓶颈。在磁盘,内核缓冲区和用户缓冲区最终传送到应用程序之前,数据被复制多次。

零拷贝通过消除这些冗余数据副本来提高性能。

扫描二维码关注公众号,回复: 1841822 查看本文章

数据传输:零拷贝方法

如果您重新检查传统方案,您会注意到第二个和第三个数据副本实际上不是必需的。应用程序除了缓存数据并将其传回到套接字缓冲区之外别无其他。相反,数据可以直接从读缓冲区传输到套接字缓冲区。transferTo() 方法可以让你做到这一点。清单2显示了以下方法的签名 transferTo()

清单2. transferTo() 方法
1
public void transferTo(long position, long count, WritableByteChannel target);

transferTo()方法将数据从文件通道传输到给定的可写字节通道。在内部,它取决于底层操作系统对零拷贝的支持; 在UNIX和各种Linux中,这个调用被路由到sendfile() 系统调用,如清单3所示,它将数据从一个文件描述符传输到另一个文件描述符:

清单3. sendfile()系统调用
1
2
#include < sys /socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

清单1中的file.read()socket.send() 调用的动作可以被一个调用所取代 ,如清单4所示:transferTo()

清单4.使用 transferTo()将数据从磁盘文件复制到套接字
1
transferTo(position, count, writableChannel);

图3显示了transferTo()使用方法时的数据路径

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

图4显示了transferTo() 使用方法时的上下文切换

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

transferTo()清单4使用时采取的步骤如下

  1. transferTo()方法使得文件内容被DMA引擎复制到读取缓冲器中。然后数据被内核复制到与输出套接字关联的内核缓冲区中。
  2. 第三个副本是在DMA引擎将数据从内核套接字缓冲区传递给协议引擎时发生的。

这是一个改进:我们已将上下文切换次数从四次减少到两次,并将数据副本的数量从四个减少到三个(其中只有一个涉及CPU)。但这还没有达到我们零拷贝的目标。如果底层网络接口卡支持收集操作,我们可以进一步减少内核所做的数据复制在Linux内核2.4及更高版本中,修改了套接字缓冲区描述符以满足此要求。这种方法不仅减少了多个上下文切换,还消除了需要CPU参与的重复数据副本。用户端使用情况仍然相同,但内在函数已更改:

  1. transferTo()方法使文件内容被DMA引擎复制到内核缓冲区中。
  2. 没有数据被复制到套接字缓冲区中。相反,只有包含有关数据位置和长度信息的描述符才会附加到套接字缓冲区。DMA引擎直接将数据从内核缓冲区传递到协议引擎,从而消除了剩余的最终CPU副本。

图5显示了使用transferTo()collect操作的数据副本

图5. transferTo()使用when 和gather操作时的数据副本
使用transferTo()和收集操作时的数据副本

建立一个文件服务器

现在让我们将零拷贝付诸实践,使用在客户端和服务器之间传输文件的相同示例(请参阅下载以获取示例代码)。 TraditionalClient.java并 TraditionalServer.java基于传统的复制语义,使用File.read()Socket.send()。 TraditionalServer.java是一个服务器程序,它侦听特定端口以供客户端连接,然后从套接字一次读取4K字节的数据。TraditionalClient.java连接到服务器,File.read()从文件读取(使用)4K字节的数据,并socket.send()通过套接字将内容发送(使用)到服务器。

同样,TransferToServer.java并 TransferToClient.java执行相同的功能,而是使用transferTo()方法(并进而 sendfile()系统调用)将文件从服务器传输到客户端。

性能比较

我们在运行2.6内核的Linux系统上执行了示例程序,并测量了传统方法和transferTo()不同尺寸方法的运行时间(以毫秒为单位)表1显示了结果:

表1.性能比较:传统方法与零拷贝

如您所见,transferTo()与传统方法相比API将时间缩短了约65%。对于将数据从一个I / O通道复制到另一个I / O通道(例如Web服务器)的应用程序,这有可能显着提高性能。

概要

transferTo()与从一个通道读取和将相同数据写入另一个通道相比,我们已经证明了使用的性能优势 中间缓冲区副本 - 即使是隐藏在内核中的副本 - 可能会产生可衡量的成本。在通道间大量复制数据的应用程序中,零复制技术可以显着提高性能。

猜你喜欢

转载自blog.csdn.net/weixin_38964895/article/details/80884270