目录
许多Web应用程序提供大量的静态内容,这相当于从磁盘上读取数据并将完全相同的数据写回响应套接字。该活动似乎需要较少的CPU活动,但效率较低:内核从磁盘读取数据并将其跨内核用户边界推送到应用程序,然后应用程序将其跨内核用户边界推送回写入插座。实际上,该应用程序充当了效率低下的中介,将数据从磁盘文件获取到套接字。
每次数据越过用户内核边界时,都必须将其复制,这会消耗CPU周期和内存带宽。幸运的是,您可以通过一种称为零副本的技术来消除这些副本。使用零复制的应用程序要求内核直接将数据从磁盘文件复制到套接字,而无需通过应用程序。零复制极大地提高了应用程序性能,并减少了内核和用户模式之间的上下文切换次数。
Java类库通过中的transferTo()
方法在Linux和UNIX系统上支持零拷贝java.nio.channels.FileChannel
。您可以使用该transferTo()
方法将字节直接从调用它的通道传输到另一个可写字节通道,而无需数据流经应用程序。本文首先演示了通过传统的复制语义进行简单文件传输所产生的开销,然后说明了使用零复制技术如何transferTo()
实现更好的性能。
日期转移:传统方法
考虑从文件读取并将数据通过网络传输到另一个程序的场景。(此场景描述了许多服务器应用程序的行为,包括服务于静态内容的Web应用程序,FTP服务器,邮件服务器等。)操作的核心是清单1中的两个调用(下载完整的示例代码):
清单1.将字节从文件复制到套接字
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
尽管清单1在概念上很简单,但是在内部,复制操作需要在用户模式和内核模式之间进行四个上下文切换,并且在操作完成之前将数据复制四次。图1显示了如何将数据从文件内部移动到套接字:
图1.传统的数据复制方法
图2显示了上下文切换:
图2.传统上下文切换
涉及的步骤是:
- 该
read()
调用导致从用户模式到内核模式的上下文切换(参见图2)。内部发出sys_read()
(或等效命令)以从文件中读取数据。第一个副本(请参见图1)由直接内存访问(DMA)引擎执行,该引擎从磁盘读取文件内容并将其存储到内核地址空间缓冲区中。 - 将请求的数据量从读取缓冲区复制到用户缓冲区,然后
read()
调用返回。调用返回将导致另一个上下文从内核切换回用户模式。现在,数据存储在用户地址空间缓冲区中。 - 该
send()
插座调用导致从用户模式到内核模式的上下文切换。执行第三次复制以再次将数据放入内核地址空间缓冲区。但是,这次将数据放入另一个缓冲区中,该缓冲区与目标套接字关联。 - 该
send()
系统调用返回,创造了第四上下文切换。独立且异步地,当DMA引擎将数据从内核缓冲区传递到协议引擎时,发生第四次复制。
使用中间内核缓冲区(而不是将数据直接传输到用户缓冲区中)似乎无效。但是将中间内核缓冲区引入了该过程以提高性能。在读取端使用中间缓冲区可以在应用程序未要求内核缓冲区容纳的数据量时,将内核缓冲区用作“预读缓存”。当请求的数据量小于内核缓冲区大小时,这将显着提高性能。写侧的中间缓冲区允许写异步完成。
不幸的是,如果请求的数据大小比内核缓冲区的大小大得多,则此方法本身可能会成为性能瓶颈。数据在最终交付给应用程序之前,已在磁盘,内核缓冲区和用户缓冲区之间多次复制。
零复制通过消除这些冗余数据副本来提高性能。
数据传输:零复制方法
如果您重新检查传统方案,您会发现实际上并不需要第二和第三数据副本。除了缓存数据并将其传输回套接字缓冲区外,该应用程序什么也不做。相反,数据可以直接从读取缓冲区传输到套接字缓冲区。该transferTo()
方法使您可以精确地做到这一点。清单2显示了方法签名transferTo()
:
清单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()
and socket.send()
调用的动作可以由一个调用代替,如清单4所示:transferTo()
清单4.使用transferTo()将数据从磁盘文件复制到套接字
transferTo(position, count, writableChannel);
图3显示了transferTo()
使用该方法时的数据路径:
图3.用transferTo()复制数据
图4显示了transferTo()
使用该方法时的上下文切换:
图4.使用transferTo()进行上下文切换
transferTo()
如清单4所示使用时所采取的步骤是:
- 该
transferTo()
方法使文件内容由DMA引擎复制到读取缓冲区中。然后,数据被内核复制到与输出套接字关联的内核缓冲区中。 - 第三份副本发生在DMA引擎将数据从内核套接字缓冲区传递到协议引擎时。
这是一个改进:我们将上下文切换的数量从四个减少到了两个,并将数据副本的数量从四个减少到了三个(其中只有一个涉及CPU)。但这还不能使我们达到零拷贝的目标。如果基础网络接口卡支持收集操作,则可以进一步减少内核完成的数据重复。在Linux内核2.4及更高版本中,已修改套接字缓冲区描述符以适应此要求。这种方法不仅减少了多个上下文切换,而且消除了需要CPU参与的重复数据副本。用户端的用法仍然保持不变,但内在函数已更改:
- 该
transferTo()
方法使文件内容被DMA引擎复制到内核缓冲区中。 - 没有数据复制到套接字缓冲区。相反,只有带有有关数据的位置和长度的信息的描述符才附加到套接字缓冲区。DMA引擎将数据直接从内核缓冲区传递到协议引擎,从而消除了剩余的最终CPU副本。
图5显示了transferTo()
与gather操作一起使用的数据副本:
图5.使用transferTo()和gather操作时的数据副本
构建文件服务器
现在,使用在客户端和服务器之间传输文件的相同示例,将零复制实践到实践中(示例代码请参见下载)。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(毫秒) |
---|---|---|
7MB | 156 | 45 |
21MB | 337 | 128 |
63MB | 843 | 387 |
98兆字节 | 1320 | 617 |
200MB | 2124 | 1150 |
350MB | 3631 | 1762 |
700MB | 13498 | 4422 |
1GB | 18399 | 8537 |
如您所见,transferTo()
与传统方法相比,API将时间减少了大约65%。对于将大量数据从一个I / O通道复制到另一个I / O通道的应用程序(例如Web服务器),这可能会显着提高性能。
概要
transferTo()
与从一个通道读取并将相同数据写入另一个通道相比,我们已经展示了使用的性能优势。中间缓冲区副本(甚至是隐藏在内核中的缓冲区副本)的成本也可以衡量。在通道之间执行大量数据复制的应用程序中,零复制技术可以显着提高性能。
相关阅读
搞懂Linux零拷贝,DMA:https://rtoax.blog.csdn.net/article/details/108825666