原文件地址
https://www.ibm.com/developerworks/linux/library/j-zerocopy/ 在看kafka时读到这篇文章,感觉不错就翻译了一下,由于E文不好忘海涵。
本文向你介绍一种有效改进I/O密集型JAVA应用的技术,这种技术被叫做zero copy,适用于linux或者unix平台。Zero copy 可以避免buffers间冗余的数据拷贝,减少用户空间和内核空间的context交互。
许多的应用提供大量的静态内容服务,及从硬盘中读取数据然后将同样的数据写入socket响应。这种类型的服务需要很少的cpu性能,但是会响应低下:内核从硬盘中读取数据然后通过内核-用户通道传输到相关应用,接着应用把这些数据又通过内核-用户通道写到socket中。实际上,在数据从硬盘到socket的传输中应用扮演了一个低效的中间者角色。
每次数据穿越用户-内核管道,它都会被copy,都需要消耗cpu周期和内存空间。幸运的是你通过一种叫做zero copy的技术来减少这些copy。应用通过这项技术将将数据直接从硬盘文件拷贝到socket中,而不再绕行应用。Zero copy提高了应用的表现并且减少了内核模型和用户模式之间的上下文交互。
Java的类库支持在Linux和Unix系统中通过java.nio.channels.FileChannel类的transferTo()方法来实现zero copy。transferTo()方法通过它所在的Channel将数据传入另一个可写入的通道而不将数据流绕行应用。本文首先讲述一般文件传输方法,然后再展示zero-copy技术是如何使用transferTo()获得更好的表现的。
数据传输:传统的方式
考虑这样一种场景,从一个文件中读取数据然后通过网络传输到其他系统中去(这个场景代表了很多应用服务,包括Web应用中提供静态资源,FTP服务,邮件服务等等)。操作的核心体现在list1代码中的两次调用。
Listing 1. 从文件向socket拷贝
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
虽然listing1逻辑上非常简单,在整个过程中用户模式和内核模式之间上下文切换共有四次。Figure 1 展示了数据是如何从文件到socket的。
Figure 1. 传统的数据copy方式
Figure 2. 传统的上下文切换
步骤是:
- read()调用触发了从用户模式到内核模式的上下文交换(见Figure2)。在内核空间内一个sys_read()调用完成了从文件中读取数据。这第一次copy(见Figure1)是通过DMA引擎完成的,DMA引擎从硬盘中读取数据然后将他们存到内核地址空间的buffer中。
- 被请求的数据从内核的read buffer 拷贝到 用户buffer,并且read()调用返回。这个调用返回又引发了一次从内核到用户模式的交互。现在数据已经被加载在用户空间的buffer中了。
- send()socket请求将会引起从用户模式向内核空间的切换。第三次拷贝又将数据传到内核地址空间。但是这次数据是呗写入不同的buffer,目标为socket。
- send()系统调用,创建第四次上下文交换。第四次复制为异步的,DMA引擎将内核buffer中的数据写入协议引擎。
使用了中间层的内核buffer(而不是直接将数据写入用户buffer)可能看起来不直接。但是内核buffer的引入确实提高了系统表现。在读取端使用中间层buffer,使得内核buffer扮演了一个“预读高速缓存”的角色,当应用还没有请求如此多的数据。这极大提高了当请求数据量小于内核buffer时的性能。在写端的中间buffer则允许异步的完成写入。
不幸的是,当被请求的数据量大于内核buffer大小的时候,他本身成为了性能的瓶颈。数据被传到application前,在硬盘、内核buffer和用户buffer间的多次拷贝。
Zero copy 通过减少冗余的数据的拷贝提高性能。
数据传输:zero copy 方式
如果再次审视传统场景,你可能会注意到其实第二次和第三次数据拷贝不是必须的。应用除了缓存数据再将它写回socket buffer之外没有其他操作。其实,数据可以直接从read buffer直接写入socket buffer。这个transferTo()方法正是帮你做到了这一点。listing 2 向我们展示了transferTo()的细节:
listing 2. The transferTo() 方法
public void transferTo(long position, long count, WritableByteChannel target);
transferTo()将数据从file channel 写入给定的可写入channel 。实际上它依赖于系统对zero copy的支持,在unix和linux系统中,这个调用被叫做sendfile()系统调用。在listing3中,将具体展示:
listing 3. The sendfile() 系统调用
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
listing1中的file.read()和socket.send()方法被简单的transferTo()调用取代,详见listing4
listing 4. Using transferTo() to copy data from a disk file to a socket
transferTo(position, count, writableChannel);
Figure 3 展示了transferTo()的方法是如何被使用的
Figure 3. 数据拷贝通过 transferTo()
Figure 4 展示了在transferTo()中如何完成上下文切换的
Figure 4. 上下文切换在 transferTo()
以下步骤将展示在transferTo()中具体是如何完成上下文切换的大帅府
- transferTo()中文件内容是被DMA引擎拷贝到一个可读的buffer。然后数据被内核直接写入与输出socket关联的内核buffer中。
- 第三次拷贝发生在DMA引擎将数据从socket buffer中拷贝到协议引擎。
这里有一个改进:我们将上下文切换从4次减少到2次,将copy数从4降低到3(只有一次用到了cpu)。但是仍然未达到我们的zero copy的目标。我们可以通过内核来减少数据重复,如果网卡支持进一步操作。在linux内核2.4及以后版本,socket buffer的descriptor被修改以来适应这种需求。这一修改无法减少上下文切换但是可以减少降低复制数据。在用户端使用是无区别的,但是内部已经发生改变:
- transferTo() 中文件内容被DMA引擎加载入内核buffer。
- 数据不再被拷入socket buffer。被取代的,只有descriptor关于位置和长度的信息被附加到socket buffer。这DMA引擎将数据直接从内核buffer拷入协议引擎,从而减少了cpu拷贝。
Figure 5. Data copies when transferTo()
and gather operations are used
Building a file server
现在用zero copy做个联系,运用同样的例子。TraditionalClinet.java和TraditionServer.java是基于传统方法的,使用File.read()和Socket.send()。TraditionServer.java是一个服务程序在特定的端口监听客户端的请求,然后读取4KB的数据。TraditionalClinet.java连接到服务器端,从文件中读取4KB的数据,然后传输内容到服务器端。
同样,TransferToServer.java和TransferToClient.java完成同样的功能,但是代替的使用transferTo()方法(系统中使用sendfile()调用)来讲数据从服务器写入客户端。
性能测试
测试使用2.6的内核的linux,每次测试时间不少于百万秒级并且transferTo(),下表将展示结果:
Table 1. Performance comparison: Traditional approach vs. zero copy
7MB | 156 | 45 |
21MB | 337 | 128 |
63MB | 843 | 387 |
98MB | 1320 | 617 |
200MB | 2124 | 1150 |
350MB | 3631 | 1762 |
700MB | 13498 | 4422 |
1GB | 18399 | 8537 |
如你所见,transferTo()比较传统观方法大约节省65%的时间。
总结
我们展示了使用transferTo()的性能和传统的方式比较。中间buffer的拷贝(隐藏于内核)带来了可观的消耗。在应用中2个channel之间数据传输,zero-copy拷贝技术可以得到显著的性能提升。