一些Web应用提供大量的静态资源,这相当于从磁盘读取大量数据,然后通过Socket将这些数据回写。
这种活动似乎看起来只需要一点CPU,但是非常低效: 内核从磁盘读取数据,并将其穿过内核-用户边界给应用,然后应用再将其穿过内核-用户边界推送过来,写入Socket。事实上,应用程序充当了从磁盘到Socket的低效媒介。
每次数据穿越用户空间和内核空间的边界时,都必须进行复制,这会消耗CPU周期和内存带宽。
幸运的是,你可以通过一种被称为"零拷贝"的技术来消除这些拷贝。
应用发起零拷贝请求,内核将直接将数据从磁盘复制到sockt, 不在需要通过应用程序。
零拷贝极大的改善了应用性能,减少了内核态到用户态之间的上下文切换次数。
Java 标准库在Linux和UNIX系统上通过Java.nio.channels.FileChannel的transferTo方法来支持零拷贝。
你可以通过transferTo方法直接将字节从调用该方法的通道传输到另一个可写字节的通道,而不需要数据流经应用程序。
本文首先演示通过传统方式拷贝进行简单文件传输所产生的开销,然后展示使用transferTo带来更好的性能实现。
数据传输的传统方法
考虑从文件中读取数据并通过网络传输给另外一个程序的状况(这个场景是许多应用服务器都会有的行为,包括Web 应用提供静态字段,FTP 服务器,邮件服务器等等)。操作核心是下面的两个调用,或者下载完整的代码[1]
lua
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
尽管上面的代码在概念上很简单,但是内部的实现上要求复制操作在内核态和用户态上下文切换4次,数据要复制四次才能完成。
Figure 1. 传统数据拷贝
Figure 2. 传统上下文切换
-
read()调用将会导致用户态到内核态上下文切换。内部会发出sys_read()(或类似命令)来读取文件中的数据。第一次复制(见Figure 1)由直接内存访问引擎执行,它从磁盘读 取文件内筒并将其存储到内核地址空间的缓冲区。
-
被请求的数据会从读缓冲区进去到用户缓冲区,然后read()调用被返回。 调用返回将会从内核态切换到用户态, 现在数据被存储到用户地址区空间缓冲区。
-
send() socket 调用将会导致用户态从内核态切换。第三次复制将再次把数据放入到内核地址空间。不过,这一次数据被放入了不同缓冲区,一个目标相关Socket的缓冲区。
-
send调用返回,将会产生第四次上下文切换。与此同时,独立且异步地, 当DMA引擎将数据从内核缓冲区复制到协议引擎,发生了第四次数据复制。
使用内核缓冲区当做媒介,而不是直接将数据传输到用户缓冲区,看起来比较低效。
但是为了改善性能,在程序中引入了中间内核缓冲区。
在读取端使用中间缓冲区,可以让内核缓冲区充当"超前读取缓存",当应用程序要求的数据还没有达到内核缓冲区的容量时。
当请求数据的数量小于内核缓冲区的大小时,将会大大的改善性能。写入侧的缓冲区允许异步导入。不幸的是,如果请求的数据量大大超过了内核缓冲区,这种方式将会成为性能瓶颈。数据在磁盘、内核缓冲区、用户缓冲区之间多次复制,最后才来到应用程序。
零拷贝消除了这些重复的拷贝来提升性能。
数据传输,零拷贝
如果你再次检查传统的拷贝方式,你就会发现第二次到第三次的复制事实上不是必须的。应用程序除了缓存数据将其传送给Socket的缓冲区之外,什么都不做。相反数据可以直接从读缓冲区传输到Socket缓冲区。transferTo方法允许你执行这个操作。下面展示了transferTo的签名。
Listing 2. The transferTo() method
arduino
public void transferTo(long position, long count, WritableByteChannel target);
trtransferTo()
方法将数据从文件通道传输到给定的可写字节通道。
在内部,它依赖于操作系统对零拷贝的支持,在UNIX和Linux的各种版本中,这个方法会被路由到 sendFile调用,如下面的调用所示,该调用将一个文件描述符传输到另外一个文件传输符。
The sendfile() system call
arduino
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
file.read()调用和socket.send调用被transferTo调用所取代。
使用 transferTo() 从磁盘复制数据到Socket。
scss
transferTo(position, count, writableChannel);
transferTo 复制数据到Socket。当使用上面的代码进行所采取的步骤如下:
-
transferTo方法导致文件内容被DMA引擎复制到读缓冲区。然后数据由内核直接复制到与socket输出关联的内核缓冲区中。
-
第三次复制发生在DMA引擎将数据从Socket 内核缓冲区传递到协议引擎中。
这是一个改进: 我们将上下文切换次数从四次减少到了两次,复制数据从四次减少到了三次(只有一次涉及到CPU)
但这还没有到达零拷贝。如果网卡支持收集操作,我们可以进一步减少内核执行的复制请求。在Linux 的2.4版本或者更新的版本,Socket缓冲区描述符已经满足此要求。
这种方法不仅减少了上下文切换次数,还消除了需要CPU参与的重复数据复制。开发者的用法保持不变,但内部机制已经发生了改变。
-
transferTo方法通过DMA引擎将文件内容直接复制到内核缓冲区。
-
没有数据被复制进入Socket缓冲区,相反只有数据长度位置的描述符的信息被附加到Socket缓冲区。
DMA引擎将数据直接从内核缓冲区传递到协议引擎,从而消除了剩余的最终的CPU复制。
构建一个文件服务器
现在让我们完成的实操一把,完整的代码见译者注。 TraditionalClient和TraditionalServer基于传统复制方式,用File.read()和Socket.send()方法。
TraditionalServer是一个在特定端口监听等待客户端连接的应用程序。每次从socket中读取4kb的数据。TraditionalClient连接到服务端,使用Filer.read方法每次从文件中读取4kb数据,使用Socket.send()将文件从socket发送到服务端。
类似地,TransferToServer.java
和 TransferToClient.java
执行相同的功能,但使用 transferTo()
方法(进而使用 sendfile()
系统调用)将文件从服务器传输到客户端。
性能比较
我们在Linux 2.6版本执行了相同程序,测量了传统方法和transferTo方法在在不同文件大小上下的运行时间:
正如你所看的transferTo API相对于传统方式快了百分之六十五。
这极大了增强了一些应用程序将大量的数据从一个I/O通道复制到另外一个I/O通道的性能,比如web服务器。
Summary
我们演示了与从一个通道读取并写入数据到另外一个通道相比,transferTo的性能优势。
中间缓冲区复制,即使隐藏在内核里面,也可能会产生大量的成本。在大量在通道之间复制数据的应用程序中,零拷贝技术可以提供显著的性能提升。性能提升。
译者注
[1] 提到的代码
java
public class TraditionalClient {
public static void main(String[] args) {
int port = 2000;
String server = "localhost";
Socket socket = null;
String lineToBeSent;
DataOutputStream output = null;
FileInputStream inputStream = null;
int ERROR = 1;
// connect to server
try {
socket = new Socket(server, port);
System.out.println("Connected with server " +
socket.getInetAddress() +
":" + socket.getPort());
}
catch (UnknownHostException e) {
System.out.println(e);
System.exit(ERROR);
}
catch (IOException e) {
System.out.println(e);
System.exit(ERROR);
}
try {
String fname = "sendfile/NetworkInterfaces.c";
inputStream = new FileInputStream(fname);
output = new DataOutputStream(socket.getOutputStream());
long start = System.currentTimeMillis();
byte[] b = new byte[4096];
long read = 0, total = 0;
while((read = inputStream.read(b))>=0) {
total = total + read;
output.write(b);
}
System.out.println("bytes send--"+total+" and totaltime--"+(System.currentTimeMillis() - start));
}
catch (IOException e) {
System.out.println(e);
}
try {
output.close();
socket.close();
inputStream.close();
}
catch (IOException e) {
System.out.println(e);
}
}
}
java
import java.net.*;
import java.io.*;
public class TraditionalServer {
public static void main(String args[]) {
int port = 2000;
ServerSocket server_socket;
DataInputStream input;
try {
server_socket = new ServerSocket(port);
System.out.println("Server waiting for client on port " +
server_socket.getLocalPort());
// server infinite loop
while(true) {
Socket socket = server_socket.accept();
System.out.println("New connection accepted " +
socket.getInetAddress() +
":" + socket.getPort());
input = new DataInputStream(socket.getInputStream());
// print received data
try {
byte[] byteArray = new byte[4096];
while(true) {
int nread = input.read(byteArray , 0, 4096);
if (0==nread)
break;
}
}
catch (IOException e) {
System.out.println(e);
}
// connection closed by client
try {
socket.close();
System.out.println("Connection closed by client");
}
catch (IOException e) {
System.out.println(e);
}
}
}
catch (IOException e) {
System.out.println(e);
}
}
}
java
public class TransferToClient {
public static void main(String[] args) throws IOException{
TransferToClient sfc = new TransferToClient();
sfc.testSendfile();
}
public void testSendfile() throws IOException {
String host = "localhost";
int port = 9026;
SocketAddress sad = new InetSocketAddress(host, port);
SocketChannel sc = SocketChannel.open();
sc.connect(sad);
sc.configureBlocking(true);
String fname = "sendfile/NetworkInterfaces.c";
long fsize = 183678375L, sendzise = 4094;
// FileProposerExample.stuffFile(fname, fsize);
FileChannel fc = new FileInputStream(fname).getChannel();
long start = System.currentTimeMillis();
long nsent = 0, curnset = 0;
curnset = fc.transferTo(0, fsize, sc);
System.out.println("total bytes transferred--"+curnset+" and time taken in MS--"+(System.currentTimeMillis() - start));
//fc.close();
}
}
java
public class TransferToServer {
ServerSocketChannel listener = null;
protected void mySetup()
{
InetSocketAddress listenAddr = new InetSocketAddress(9026);
try {
listener = ServerSocketChannel.open();
ServerSocket ss = listener.socket();
ss.setReuseAddress(true);
ss.bind(listenAddr);
System.out.println("Listening on port : "+ listenAddr.toString());
} catch (IOException e) {
System.out.println("Failed to bind, is port : "+ listenAddr.toString()
+ " already in use ? Error Msg : "+e.getMessage());
e.printStackTrace();
}
}
public static void main(String[] args)
{
TransferToServer dns = new TransferToServer();
dns.mySetup();
dns.readData();
}
private void readData() {
ByteBuffer dst = ByteBuffer.allocate(4096);
try {
while(true) {
SocketChannel conn = listener.accept();
System.out.println("Accepted : "+conn);
conn.configureBlocking(true);
int nread = 0;
while (nread != -1) {
try {
nread = conn.read(dst);
} catch (IOException e) {
e.printStackTrace();
nread = -1;
}
dst.rewind();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}