浅析NIO与零拷贝

传统IO拷贝

传统IO的数据拷贝流程如下图:

  1. 数据需要从磁盘拷贝到内核空间,再从内核空间拷到用户空间(JVM)。
  2. 程序可能进行数据修改等操作。
  3. 再将数据拷贝到内核空间,内核空间再拷贝到网卡内存,通过网络发送出去(或拷贝到磁盘)。

即数据的读写(这里用户空间发到网络也算作写),都至少需要两次拷贝。

当然磁盘到内核空间属于DMA拷贝(DMA即直接内存存取,原理是外部设备不通过CPU而直接与系统内存交换数据)。而内核空间到用户空间则需要CPU的参与进行拷贝,既然需要CPU参与,也就涉及到了内核态和用户态的相互切换,如下图:

NIO零拷贝

sendfilemmap本质上都是基于零拷贝技术实现的,在Java NIO中,其分别通过FileChanneltransferTo()map()提供支持。

sendfile

sendfile的数据拷贝如下图:

内核态与用户态切换如下图:

改进的地方:

  1. 我们已经将上下文切换次数从4次减少到了2次。
  2. 将数据拷贝次数从4次减少到了3次(其中只有1次涉及了CPU,另外2次是DMA直接存取)。

但这还没有达到我们零拷贝的目标。如果底层NIC(网络接口卡)支持gather操作,我们能进一步减少内核中的数据拷贝。在Linux 2.4以及更高版本的内核中,Socket缓冲区描述符已被修改用来适应这个需求。简单来说,传输的数据不需要连续的内存空间,整个过程中并没有实际内容数据从内核缓冲区拷贝到Socket缓冲区,取而代之的是将包含数据的位置以及长度的描述符附加到Socket缓冲区,之后直接从内核缓冲区进行收集然后传输即可。这种方式不但减少多次的上下文切换,同时消除了需要CPU参与的重复的数据拷贝。用户这边的使用方式不变,而内部已经有了质的改变:

mmap

以上都建立在整个过程不需要进行额外的数据文件操作的情况下,如果既需要这样的速度,也需要进行数据操作就可以使用mmap(内存映射文件)。mmap将文件的内容从内核缓冲区映射到用户的进程中,这样就不需要拷贝了,但是mmap有个缺点是如果其他进程在向这个文件write,那么会被认为是一个错误的存储访问。除此之外,mmap最后向网卡传输时必须将数据从内核缓冲区拷贝到Socket缓冲区。

实验

传统IO拷贝:

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
// Server
public class OldIOServer {
public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(8899));

while (true) {
Socket socket = serverSocket.accept();
DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());
try {
byte[] byteArray = new byte[4096];
while (true) {
int readCount = dataInputStream.read(byteArray, 0, byteArray.length);
if (-1 == readCount) {
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

// Client
public class OldIOClient {
public static void main(String[] args) throws Exception {
Socket socket = new Socket("localhost", 8899);
String fileName = "/Users/hecenjie/Downloads/joker.mp4";
InputStream inputStream = new FileInputStream(fileName);
DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());
byte[] buffer = new byte[4096];
long readCount;
long total = 0L;

long startTime = System.currentTimeMillis();
while((readCount = inputStream.read(buffer) )>= 0) {
total += readCount;
dataOutputStream.write(buffer);
}
System.out.println("[old] 发送总字节数: " + total + " , 耗时: " + (System.currentTimeMillis() - startTime) + "毫秒");
dataOutputStream.close();
socket.close();
inputStream.close();
}
}

NIO零拷贝:

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
59
60
61
// Server
public class NewIOServer {
public static void main(String[] args) throws Exception {
InetSocketAddress address = new InetSocketAddress(8899);
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.setReuseAddress(true);
serverSocket.bind(address);

ByteBuffer byteBuffer = ByteBuffer.allocate(4096);

while (true) {
SocketChannel channel = serverSocketChannel.accept();
channel.configureBlocking(true);
int readCount = 0;
while(-1 != readCount) {
try{
readCount = channel.read(byteBuffer);
byteBuffer.rewind();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}

// Client
public class NewIOClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
String fileName = "/Users/hecenjie/Downloads/joker.mp4";
FileChannel fileChannel = new FileInputStream(fileName).getChannel();
socketChannel.connect(new InetSocketAddress("localhost", 8899));
socketChannel.configureBlocking(true);
// begin
sendfile(fileChannel, socketChannel);
mmap(fileChannel, socketChannel);
}

public static void sendfile(FileChannel fileChannel, SocketChannel socketChannel) throws IOException {
long position = 0L;
long startTime = System.currentTimeMillis();
while(position < fileChannel.size()) {
// 如果socketChannel是非阻塞的,可能就读取不到指定数量的字节
position += fileChannel.transferTo(position, Math.min(2147483647L, fileChannel.size()-position), socketChannel);
}
System.out.println("[sendfile] 发送总字节数: " + position + " , 耗时: " + (System.currentTimeMillis() - startTime) + "毫秒");
}

public static void mmap(FileChannel fileChannel, SocketChannel socketChannel) throws IOException {
MappedByteBuffer mbb;
long position = 0L; // 同时也充当position的作用
long startTime = System.currentTimeMillis();
while(position < fileChannel.size()){
mbb = fileChannel.map(FileChannel.MapMode.READ_ONLY, position, Math.min(fileChannel.size()-position, Integer.MAX_VALUE));
position += socketChannel.write(mbb);
}
System.out.println("[mmap] 发送总字节数: " + position + " , 耗时: " + (System.currentTimeMillis() - startTime) + "毫秒");
}
}

实验结果:

1
2
3
[old] 发送总字节数: 2300047434 , 耗时: 2572毫秒
[sendfile] 发送总字节数: 2300047434 , 耗时: 474毫秒
[mmap] 发送总字节数: 2300047434 , 耗时: 1271毫秒

这里有几点需要特别注意的地方。首先,transferTo()是有处理长度限制的,也就是2147483647L个字节(2GB - 1)。所以如果处理超过2GB的文件就要循环执行transferTo()方法,直至处理长度和文件长度一样为止。其次,如果将SocketChannel设置成非阻塞的,可能就读取不到指定数量的字节,因此如果使用之前没有循环的方式,会发现就算是小于2GB的文件也传输不完,而使用循环的方式则天然的解决了此问题。

参考资料