java-zero-copy

本篇内容主要翻译自Efficient data transfer through zero copy,包括有些自己的思考

java zero copy

很多网页应用有大量的静态内容,针对这些静态内容主要的处理逻辑就是从磁盘读取数据,然后将数据写入到响应的socket中。这项工作应该需要较少的CPU资源。但是有时候并不是。内核从磁盘读取数据,然后将数据拷贝到应用中。之后应用将数据写入到内核,然后推送到socket中。实际上,应用程序在这里扮演了一个无效率的中间层,既将数据从磁盘写入到socket。
每一次当数据扩容user-kernel边界时,数据都会被拷贝,而这会消耗cpu cycles以及内存带宽。幸运的是,我们可以采用zero copy技术来避免内核和应用程序之间的数据拷贝。应用程序使用zero copy技术来请求内核直接将数据从磁盘文件拷贝到socket中,而不需要经过应用程序。zero copy技术能够极大的提升应用程序性能并且减少内核空间和用户空间之间的切换。
Java中使用java.nio.channels.FileChannel中的transferTo()方法在linux和Unix系统重实现zero copy。使用transferTo()方法能够直接将字节数据从一个channel写入到另一个channel中,而数据不需要经过应用程序。本篇文章首先展示使用传统的拷贝方法消耗的资源,然后展示使用zero copy获得的性能提升。

数据传输:传统方法

想想一个简单的场景,从一个文件读取数据,然后将数据通过网络写入到另一个程序中。核心操作如下所示。

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

虽然看起来很简单,但是这个操作需要再内核空间和用户空间4次上下文切换,以及4次数据拷贝。下图展示了具体过程

下图展示了上下文切换过程

主要的步骤如下:

  1. read()方法从user mode 转换到 kernel mode。在read内部是发一起了一次系统调用sys_read()从文件读取数据。第一次数据拷贝是由DMA引擎来执行,DMA从磁盘读取文件,然后将数据保存到内核缓冲区中。
  2. 请求的数据被从内核缓冲区拷贝到用户缓冲区,read()方法返回。这引起了第二次的上下文切换。现在数据是在用户空间中的缓冲区中。
  3. send()方法调用引起用户空间到内核空间的切换。这次会将数据从用户空间拷贝到内核缓冲区。这一次数据是放置到另外一个内核缓冲区中,与目的socket相关联。
  4. send()方法返回,又引起了一次上下文切换。DMA将数据从内核缓冲区拷贝到网卡缓冲区中,这是第四次数据拷贝。

我们为什么不直接讲数据拷贝到用户空间,而要经过内核空间呢?这是因为操作系统引入内核缓冲区是为了提升性能。操作系统读取数据都会预读取一些数据,这样在应用程序读取额外的数据时,可以不用发起系统调用,直接从内核缓冲区获取即可。而写入过程,可以完全实现为异步过程。既应用程序只需要将数据写入到内核缓冲区中,而不是写入到磁盘中。
但是,设想当前应用程序需要处理的数据要远远大于内核空间缓冲区的大小。而此时,数据需要在磁盘,内核缓冲区,用户空间中来回拷贝。这会严重影响性能。
Zero copy技术是解决这个问题的方法。

数据传输:zero copy方法

如果你仔细检查上面的过程,你会发现第二次和第三次数据拷贝可以省略。应用程序针对这些数据什么也不做。因此数据可以被直接从内核缓冲区拷贝到socket buffer中。transferTo()方法可以完成这个操作。下面展示了此方法

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

transferTo()方法将数据从文件channel拷贝到target channel中。这个方法依赖底层操作系统对于zero copy的支持。在UNIX或linux中,使用的是sendfile()系统调用。如下所示:
1
2
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

下图展示了transferTo()方法执行过程

下图展示了具体的上下文切换

transferTo()方法执行过程如下:

  1. DMA引擎将文件内容拷贝到内核缓冲区。然后数据从内核缓冲区拷贝到socket buffer中。(涉及到两次数据拷贝)
  2. 第三次数据拷贝发生在DMA引擎将数据从socket buffer拷贝到网卡中。

我们将上下文切换从4次降低到2次,数据拷贝从4次降低到3次(其中仅有一个数据拷贝需要CPU参与)。但是这还没有达到zero copy的目的。如果底层网卡支持gather操作,那么我们可以减少内核空间中的数据重复。在linux kernel2.4及以后版本中,socket buffer已经支持了这个操作。这个方法不仅仅减少了上下文切换并且也消除了CPU参与的数据拷贝。具体如下:

  1. transferTo()方法将文件内容拷贝到内核缓冲区,由DMA引擎执行
  2. 不需要将数据拷贝进socket buffer中,仅仅将数据的位置以及数据长度添加到kernel buffer中。DMA引擎直接将kernel buffer中的数据拷贝到网卡中。
    下图展示了包含gather操作的transferTo()

性能比较

使用java实现了文件传输,同时采用传统的IO和nio来实现。完整代码参考.其中客户端是主要的视线,服务端仅仅读取数据。

传统IO client 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 1. create socket and connect to server
try {
socket = new Socket(Common.SERVER, port);
System.out.println("Connected with server " + socket.getInetAddress() + ":" + socket.getPort());
} catch (UnknownHostException e) {
System.out.println(e);
System.exit(Common.ERROR);
} catch (IOException e) {
System.out.println(e);
System.exit(Common.ERROR);
}

// 2. send data to server
try {
inputStream = Files.newInputStream(Paths.get(fileName));
output = new DataOutputStream(socket.getOutputStream());
long start = System.currentTimeMillis();
byte[] b = new byte[4096];
long read = 0;
long total = 0;
// read function cause user mode to kernel mode,
// and DMA engine read file content from disk to kernel buffer
// then copy kernel buffer to the b array. This cause another context switch
// then when read return, cause kernel mode to user mode
// Summary: two context switch, two copy(one cpu copy)
while ((read = inputStream.read(b)) >= 0) {
total = total + read;
System.out.println("total size:" + total);
// write function cause user mode to kernel mode,
// and copy data from b array to socket buffer,
// then DMA engine copy socket buffer to nic(network interface) buffer
// then when write return, cause kernel mode to user mode,
// Summary: two context switch, two copy(one cpu copy)
output.write(b);
}
System.out.println("bytes send: " + total + " and totalTime(ms):" + (System.currentTimeMillis() - start));
} catch (IOException e) {
System.out.println(e);
}

nio client 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public void testSendfile() throws IOException  {
// 1. get file size(bytes)
Path path = Paths.get(fileName);
long fsize = Files.size(path);

SocketAddress sad = new InetSocketAddress(Common.SERVER, port);
SocketChannel sc = SocketChannel.open();
sc.connect(sad);
sc.configureBlocking(true);
FileInputStream fis = new FileInputStream(fileName);
FileChannel fc = fis.getChannel();
long start = System.currentTimeMillis();
long curnset = 0;

// in linux kernel 2.4 and later
// transferTo() function cause user mode to kernel mode
// DMA engine copy data from disk to kernel buffer
// then just copy data position and data length to kernel buffer
// then DMA engine copy kernel buffer to NIC buffer
// when transferTo return, cause another context switch
// Summary: two context switch, two copy(zero CPU copy)
curnset = fc.transferTo(0, fsize, sc);
System.out.println("total bytes transferred: " + curnset + " and time taken in MS: " +
(System.currentTimeMillis() - start) );

fc.close();
fis.close();
}

性能比较如下:

file size traditional(ms) nio(ms)
12MB 50 18
221MB 690 314
2.5G 15496 2610

评测环境:

  1. java : openjdk version “1.8.0_382”, OpenJDK Runtime Environment (build 1.8.0_382-b05), OpenJDK 64-Bit Server VM (build 25.382-b05, mixed mode)
  2. linux: CentOS Linux release 7.6 (Final)
  3. kernel: 4.14.0_1-0-0-51