许多Web应用程序提供大量静态内容,这相当于从磁盘读取数据并将完全相同的数据写回到响应套接字。此活动似乎只需要相对较少的CPU活动,但效率有点低下:内核从磁盘读取数据并将其跨越内核用户边界推送到应用程序,然后应用程序将其推回到内核用户边界写出到插座。实际上,应用程序作为一个低效的媒介,将数据从磁盘文件获取到套接字。
每次数据遍历用户内核边界时,都必须复制它,这会消耗CPU周期和内存带宽。幸运的是,您可以通过一种名为 - 足够恰当 - 零拷贝的技术来消除这些副本。使用零拷贝请求的应用程序,内核将数据直接从磁盘文件复制到套接字,而不通过应用程序。零拷贝大大提高了应用程序的性能,并减少了内核和用户模式之间的上下文切换次数。
Java类库通过transferTo()
in方法在 Linux和UNIX系统上支持零拷贝java.nio.channels.FileChannel
。您可以使用该transferTo()
方法将字节从其调用的通道直接传输到另一个可写字节通道,而不需要数据流经应用程序。本文首先演示通过传统的复制语义完成简单文件传输所带来的开销,然后展示如何使用零复制技术 transferTo()
实现更好的性能。
考虑从文件读取并通过网络将数据传输到另一个程序的场景。(本场景描述了许多服务器应用程序的行为,包括提供静态内容的Web应用程序,FTP服务器,邮件服务器等等)。操作的核心在清单1中的两个调用中(请参见下载以获取指向完整的示例代码):
1
2
|
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
|
虽然清单1在概念上很简单,但在内部,复制操作需要在用户模式和内核模式之间切换四次上下文,并且在操作完成之前将数据复制四次。图1显示了数据如何从文件内部移动到套接字:
图2显示了上下文切换:
涉及的步骤是:
read()
调用导致从用户模式到内核模式的上下文切换(参见图2)。内部sys_read()
发布(或等效)以从文件中读取数据。第一个副本(见图1)由直接内存访问(DMA)引擎执行,该引擎从磁盘读取文件内容并将其存储到内核地址空间缓冲区中。read()
调用返回。调用返回导致另一个从内核切换到用户模式的上下文。现在数据存储在用户地址空间缓冲区中。send()
插座调用导致从用户模式到内核模式的上下文切换。执行第三个副本,将数据再次放入内核地址空间缓冲区。但是,这一次,数据被放入不同的缓冲区,一个与目标套接字关联的缓冲区。send()
系统调用返回,创造了第四上下文切换。独立和异步地,DMA引擎将数据从内核缓冲区传递到协议引擎时发生第四次复制。使用中间内核缓冲区(而不是直接将数据传输到用户缓冲区中)可能看起来效率低下。但是,中间内核缓冲区被引入到进程中以提高性能。在应用程序没有要求与内核缓冲区一样多的数据时,在读取端使用中间缓冲区允许内核缓冲区充当“预读缓存”。当请求的数据量小于内核缓冲区大小时,这会显着提高性能。写入侧的中间缓冲区允许写入异步完成。
不幸的是,如果所请求数据的大小远远大于内核缓冲区大小,这种方法本身可能会成为性能瓶颈。在磁盘,内核缓冲区和用户缓冲区最终传送到应用程序之前,数据被复制多次。
零拷贝通过消除这些冗余数据副本来提高性能。
如果您重新检查传统方案,您会注意到第二个和第三个数据副本实际上不是必需的。应用程序除了缓存数据并将其传回到套接字缓冲区之外别无其他。相反,数据可以直接从读缓冲区传输到套接字缓冲区。该transferTo()
方法可以让你做到这一点。清单2显示了以下方法的签名 transferTo()
:
transferTo()
方法
1
|
public void transferTo(long position, long count, WritableByteChannel target);
|
该transferTo()
方法将数据从文件通道传输到给定的可写字节通道。在内部,它取决于底层操作系统对零拷贝的支持; 在UNIX和各种Linux中,这个调用被路由到sendfile()
系统调用,如清单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()
transferTo()
将数据从磁盘文件复制到套接字
1
|
transferTo(position, count, writableChannel);
|
图3显示了transferTo()
使用该方法时的数据路径:
transferTo()
图4显示了transferTo()
使用该方法时的上下文切换:
transferTo()
transferTo()
在清单4中使用时采取的步骤如下:
transferTo()
方法使得文件内容被DMA引擎复制到读取缓冲器中。然后数据被内核复制到与输出套接字关联的内核缓冲区中。这是一个改进:我们已将上下文切换次数从四次减少到两次,并将数据副本数量从四个减少到三个(其中只有一个涉及CPU)。但是,这还没有让我们达到零拷贝的目标。如果底层网络接口卡支持收集操作,我们可以进一步减少内核所做的数据复制。在Linux内核2.4和更高版本中,套接字缓冲区描述符已被修改以适应此要求。这种方法不仅减少了多个上下文切换,还消除了需要CPU参与的重复数据副本。用户端使用率仍然保持不变,但内在因素已发生变化:
transferTo()
方法使文件内容被DMA引擎复制到内核缓冲区中。图5显示了使用transferTo()
收集操作的数据副本:
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显示了结果:
正如您所看到的,transferTo()
与传统方法相比,API将时间缩短了约65%。这对于大量将数据从一个I / O通道复制到另一个I / O通道的应用程序(如Web服务器)具有显着提高性能的潜力。
transferTo()
与从一个通道读取数据并将相同的数据写入另一个通道相比,我们已经证明了使用它的性能优势 。中间缓冲区副本 - 即使是隐藏在内核中的副本 - 可能会产生可衡量的成本。在通道间大量复制数据的应用程序中,零复制技术可以显着提高性能。