很多Web应用都会提供大量的静态内容,也就意味着需要从磁盘读取数据,然后将它们写入到响应socket。这看上去似乎需要很少的CPU活动,但是它有点低效:内核从磁盘读取数据,然后穿过内核—用户边界把它送往应用,接着应用再穿过内核—用户边界把它送回,并写入socket。事实上,在把数据从磁盘发往socket的过程中,应用扮演着一个低效的中间媒介。
每次数据穿过用户—内核边界都需要被拷贝,这会消耗CPU以及内存。幸运的是,有办法可以通过某种技术来消除这些拷贝,这个技术就叫——零拷贝
。使用零拷贝的应用直接将数据从磁盘读到socket而不需要流经应用。零拷贝大大提高了应用性能并减少了内核态和用户态的上下文切换次数。
在Linux和UNIX系统中,Java类库通过java.nio.channels.FileChannel
类中的transferTo()
方法支持零拷贝。你可以使用这个方法将字节数据直接从file channel传递到另一个可写的字节通道(比如socket channel),而不需要流经应用。本文首先演示传统文件传输所带来的瓶颈,然后通过使用transferTo()
方法演示零拷贝技术如何产生更好的性能。
数据传输:使用传统方式
想一下这样的一个场景:通过网络将本地文件传送到另一个程序,这个场景再普遍不过了,很多Web应用都做这样的事情。这个过程中的主要操作就是清单1中所列的两个调用
清单1. 把字节数据从文件拷贝到socket
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
- 1
- 2
虽然清单1展现的只是一个简单的概念,但在具体操作的内部,拷贝操作需要4次用户态和核心态的上下文切换,同时数据也会被拷贝4次。图1展示了数据在从文件拷贝到socket的过程中是如何流转的。
图1. 传统的数据拷贝方式
图2展示了用户态和核心态的上下文切换过程
图2. 传统的上下文切换
注意:图2中的标识有误,应该为K→Kernel context
此过程涉及以下步骤:
read()
方法的调用引起一次用户态到内核态的上下文切换,系统内部其实是使用类似sys_read()
内核函数从文件中读取数据。DMA引擎在读取磁盘中的文件内容到内核地址空间缓冲区中的这个过程中,产生第一次拷贝。- 被请求的大量数据从read缓冲区拷贝到用户缓冲区,同时
read()
方法返回。方法返回会引起另一次的上下文切换,这次是从内核态切换回用户态。现在数据被存储在用户地址空间缓冲区中。 send()
方法的调用引起一次用户态到核心态的上下文切换,产生第三次拷贝,数据再次被放入内核地址空间缓冲区,但这个缓冲区和之前的不同,这个缓冲区是和目标socket有关联的。send()
方法返回,产生第四次的上下文切换。数据会由DMA引擎独立、异步地从内核缓冲区传送到协议引擎,从而产生第四次拷贝。
使用中间内核缓冲区(而不是直接把数据传送给用户缓冲区)可能看上去低效。但是中间内核缓冲区的引入是为了提高性能的。在读数据的时候,当需要读的数据比中间内核缓冲区容量少时,中间内核缓冲区起着预加载缓存的功能,这会显著提升性能。在写数据的时候,中间内核缓冲区的存在使得写操作可以异步进行(写到内核缓冲区立马返回到应用程序,由缓冲区异步写入网卡buffer)。
不幸的是,这种方式在需要读取的数据比中间内核缓冲区容量大得多的情况下可能会成为性能瓶颈。在这种情况下,数据需要经过多次磁盘到内核缓冲区,内核缓冲区到用户缓冲区的拷贝。
零拷贝会通过消除不必要的数据拷贝来提升性能。
数据传输:使用零拷贝方式
回顾一下传统方式,你会注意到第二次和第三次的数据拷贝实际上并不需要。应用除了缓存数据然后把它送回socket缓冲区以外,并没有做其它的事情。其实数据可以直接从read缓冲区传送到socket缓冲区。transferTo()
方法干的就是这个事情。清单2展示了该方法的方法签名:
清单2. transferTo()
方法
public void transferTo(long position, long count, WritableByteChannel target);
- 1
transferTo()
方法将数据从file channel传送到可写的字节通道(比如socket channel)。在内部,零拷贝依赖底层操作系统的支持。在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
- 2
现在,清单1中的两个方法就可以被transferTo()
一个方法替代了,如清单4所示:
清单4. 使用transferTo()
方法将数据从磁盘文件拷贝到socket
transferTo(position, count, writableChannel);
- 1
图3展示了使用transferTo()
方法时的数据流转过程:
图3. 使用transferTo()
拷贝数据
图4. 使用transferTo()
方法时的上下文切换
此过程涉及以下步骤:
transferTo()
方法的调用使得DMA引擎将数据从磁盘文件拷贝到read缓冲区,然后数据被内核拷贝到和输出socket有关的内核缓冲区(socket buffer)。- DMA引擎将数据从内核socket缓冲区传送到协议引擎,产生第三次拷贝。
改进的地方:我们已经将上下文切换次数从4次减少到了2次,将数据拷贝次数从4次减少到了3次(其中只有1次涉及了CPU,另外2次是DMA直接存取)。但这还没有达到我们零拷贝的目标。如果底层NIC(网络接口卡)支持gather操作,我们能进一步减少内核中的数据拷贝。在Linux 2.4以及更高版本的内核中,socket缓冲区描述符已被修改用来适应这个需求。这种方式不但减少多次的上下文切换,同时消除了需要CPU参与的重复的数据拷贝。用户这边的使用方式不变,而内部已经有了质的改变:
transferTo()
方法的调用使得DMA引擎将数据从磁盘文件拷贝到内核缓冲区- 没有数据被拷贝到socket缓冲区。只有包含关于数据位置和长度信息的描述符会被附加到socket缓冲区。DMA引擎直接将内核缓冲区数据传送到协议引擎,这样就消除了仅有的一次需要CPU参与的拷贝。
图5展示了调用transferTo()
方法时,支持gather操作的数据拷贝:
图5. 支持gather操作的数据拷贝
构建一个文件服务器
现在让我们在实践中使用零拷贝,TraditionalClient.java
是使用传统数据传输方式的文件上传客户端,TransferToClient.java
是使用零拷贝方式的文件上传客户端,FileServer.java
是共同的文件服务端。
注意:这一段的内容包括下面的示例程序是我自己写的,没有直译原文,因为原文的示例程序有些不规范。
- TraditionalClient.java
package cn.cjc.zerocopy;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
public class TraditionalClient {
public static void main(String[] args) throws IOException {
TraditionalClient client = new TraditionalClient();
client.testTradition();
}
public void testTradition() throws IOException {
SocketAddress sad = new InetSocketAddress("localhost", 8888);
SocketChannel sc = SocketChannel.open();
sc.configureBlocking(true);
sc.connect(sad);
FileInputStream inputStream = new FileInputStream("/Users/chenjc/test.mp4");
byte[] buf = new byte[4096];
int length, total = 0;
long start = System.currentTimeMillis();
while ((length = inputStream.read(buf)) != -1) {
total += length;
ByteBuffer buffer = ByteBuffer.wrap(buf, 0, length);
sc.write(buffer);
}
System.out.println("bytes send--" + total + " and total time--" + (System.currentTimeMillis() - start));
inputStream.close();
sc.close();
}
}
- 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
- TransferToClient.java
package cn.cjc.zerocopy;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.channels.SocketChannel;
public class TransferToClient {
public static void main(String[] args) throws IOException {
TransferToClient sfc = new TransferToClient();
sfc.testSendFile();
}
public void testSendFile() throws IOException {
SocketAddress sad = new InetSocketAddress("localhost", 8888);
SocketChannel sc = SocketChannel.open();
sc.configureBlocking(true);
sc.connect(sad);
RandomAccessFile raf = new RandomAccessFile("/Users/chenjc/test.mp4", "r");
long length = raf.length();
long start = System.currentTimeMillis();
raf.getChannel().transferTo(0, length, sc);
System.out.println("total bytes transferred--" + length + " and time taken in MS--" + (System.currentTimeMillis() - start));
raf.close();
sc.close();
}
}
- 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
- FileServer.java
package cn.cjc.zerocopy;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
public class FileServer {
private ServerSocketChannel listener;
public void mySetup() {
try {
listener = ServerSocketChannel.open();
ServerSocket ss = listener.socket();
ss.setReuseAddress(true);
ss.bind(new InetSocketAddress(8888));
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
FileServer fs = new FileServer();
fs.mySetup();
fs.readData();
}
public 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, total = 0;
while (nRead != -1) {
try {
nRead = conn.read(dst);
if (nRead != -1) total += nRead;
} catch (IOException e) {
e.printStackTrace();
nRead = -1;
}
dst.rewind();
}
System.out.println("Total received=" + total);
conn.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
- 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
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
性能比较
我们在2.6内核的Linux系统上运行了示例程序,以毫秒为单位,对比不同大小的文件在传统传输方式和零拷贝方式上的运行时间,表1展示了具体结果:
表1. 性能比较:传统方式 vs 零拷贝方式
File size | Normal file transfer (ms) | transferTo (ms) |
---|---|---|
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()
API相较于传统方式,减少了大约65%的时间消耗,这对于那些在I/O通道之间拷贝大量数据的应用(比如Web服务器)来说,对性能是一个显著的提升。
结束语
以上举例说明了使用transferTo()
方法在传输数据方面的性能优势,中间缓冲区拷贝、甚至发生在内核中的拷贝,都会产生不可忽略的额外开销。在处理大量网络数据传输的应用中,零拷贝技术能提供显著的性能提升。