Linux I/O 的 Zero-Copy

Linux I/O 的 Zero-Copy

前言

最近在学习 Java Nio 以及 Linux kernel 的时候了解到了在 Linux 底层实现了零拷贝的系统调用,在前段时间研究消息队列的时候也了解到 Kafka 或者是 Netty 本身也用到了零拷贝技术,感觉有点意思,所以想对零拷贝进行进一步的研究。

关于 Zero-Copy

首先我们要了解什么是 Zero-Copy 也就是零拷贝,它又有什么用?

下面我们通过几个系统调用然后对比零拷贝我们就能知道零拷贝神奇的地方~

read and write

要知道 Linux 中网络服务器通过 I/O 将文件传输给客户端的过程。主要包括了两个系统调用

1
2
read(file, tmp_buf, len);
write(socket, tmp_buf, len);

服务器通过 read 调用读取文件的字节流,再通过 write 调用通过 socket 传输给客户端,这看起来没有问题,也很简单,但是在这过程中的开销是很大的,操作系统在底层完成很多工作。首先我们能想到系统调用要从用户态切换到内核态,其中的多次上下文切换是一件开销很大的事情。其实不仅如此,在进行系统调用的时候,数据会被拷贝至少4次。下面我们看一看时序图:(其中上半部分是上下文的切换,下半部分是数据的拷贝)

  1. read 系统调用会导致从用户态切换到内核态。操作系统的 DMAC (Direct Memory Access Controller 直接内存访问控制器) 会读取文件的内容并且存储到内核的缓冲区(kernel buffer)这里就是第一次拷贝。

  2. 数据会从内核缓冲区进一步拷贝到用户缓冲区(user buffer),此时 read 系统调用返回。此时又会从内核态切换至用户态。

  3. write 系统调用又会导致从用户态切换到内核态。数据会再一次从用户缓冲区拷贝到内核缓冲区(和第一次的内核缓冲区不是同一个缓冲区,这次是特殊的 socket 缓冲区)。

  4. write 系统调用返回,上下文的第四次切换。并且会发生第四次拷贝: DMA 会从 socket 缓冲区拷贝数据到协议引擎(protocol engine),这是一个异步独立的过程。在这里值得一提的是,异步独立的意思指的是传输并不一定是在调用返回之前,操作系统甚至没有保证调用返回的时候传输就开始了,只是仅仅意味着以太网的驱动程序中的队列接受了这个数据包,并且一般会以先进先出的方式传输数据,除非队列带有优先级或是环状的结构。

    in fact, doesn’t guarantee transmission; it doesn’t even guarantee the start of the transmission. It simply means the Ethernet driver had free descriptors in its queue and has accepted our data for transmission. There could be numerous packets queued before ours. Unless the driver/hardware implements priority rings or queues, data is transmitted on a first-in-first-out basis.
                                         -by Dragan Stancevic on January 1, 2003

mmap

通过上面对read和write,我们肯定会想,是否可以减少不必要拷贝次数来减少对操作系统的开销。这时候可以通过 mmap 系统调用来代替 read 调用。mmap是一种内存映射文件的系统调用,在 Java Nio 中也有类似的 MappedByteBuffer 实现。

1
2
tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);

  1. mmap 系统调用通过 DMA 复制到内核缓冲区,此时能够通过内存映射文件的方式将文件的内容映射到用户的进程中,此时就减少了一次拷贝。

  2. write 拷贝将内核缓冲区的内容直接复制到 socket 缓冲区。

  3. DMA 会从 socket 缓冲区拷贝数据到协议引擎。

现在我们知道,通过mmap+write能够减少一定的拷贝次数,减少操作系统的开销,但是其实使用mmap+write会有一定的隐患:当内存对某个文件进行映射的时候,而此时另外一个进程对同一个文件进行截断时,write系统调用会因为访问非法地址而被SIGBUS信号终止。SIGBUS信号默认会杀死你的进程并产生一个coredump。

解决这个问题有两种办法:

  • 第一种办法是添加一个SIGBUS的处理器,当遇到SIGBUS信号时直接返回。write调用在被中断之前返回已经写入的字节数并且将errno设置为success。但是这并不是一个好的解决方案。
  • 第二种办法是使用文件租用(也就是添加一个租赁锁)。在文件描述符上使用租赁锁,可以从内核请求读/写租约。当另一个进程试图截断正在传输的文件时,内核会向你发送一个实时信号RT_SIGNAL_LEASE信号。信号会告诉你内核正在破坏对该文件的写或读租约。在程序访问无效地址并被SIGBUS信号杀死之前,写调用被中断。写调用的返回值是中断之前写的字节数,并且errno将被设置为成功。

sendfile

在 Linux 2.1 版本,引入了 sendfile 系统调用来实现网络上的文件传输。不仅能够减少拷贝次数,还减少了上下文的切换

1
sendfile(socket, file, len);

  1. sendfile 系统调用会让 DMA 将文件内容复制到内核缓冲区,紧接着数据被内核复制到socket缓冲区。

  2. DMA 会从 socket 缓冲区拷贝数据到协议引擎。

sendfile系统调用一共拷贝了3次,这时候其实还有疑问,同样是拷贝三次,sendfile 和 mmap 有什么不同呢?通过对比我们可以知道 sendfile 是通过从内核缓冲区直接复制到 socket 缓冲区,而 mmap 是通过内存映射文件的方式。这时候又有疑问,那么 sendfile 会不会出现 mmap 的问题呢?答案是否定的,如果我们没有注册信号处理器,sendfile 系统调用会在中断前直接返回传输的字节数,并且将 errno 设为 success。而如果使用了租赁锁,我们也能收到 RT_SIGNAL_LEASE信号,并且返回的状态和没有租赁锁是一样的。

使用 DMA gather copy 的 sendfile (Zero Copy)

在 Linux 2.4 中,又通过硬件对文件的传输进行了进一步的优化。可以通过支持内存收集操作的网络接口来进一步减少拷贝的次数。简单来说,传输的数据不需要连续的内存空间,它能够收集多个内存地址。在内核 2.4 中的 socket 缓冲区的描述符便能够实现,称之为 Zero Copy 零拷贝

  1. sendfile 系统调用会让 DMA 将文件内容复制到内核缓存,紧接着数据被内核复制到socket缓冲区。

  2. 并没有数据拷贝到 socket 缓冲区,取而代之的是将包含数据的位置以及长度的描述符附加到socket 缓冲区,DMA 引擎将数据从内核缓冲区直接传递到协议引擎从而减少了最后一次拷贝,实现了零拷贝。

最后了解了零拷贝,我们知道 Zero Copy 能够通过分散/收集内存来实现对拷贝的进一步减少,但是零拷贝需要相应的硬件以及驱动的支持。学到这我心里还是杠了下,明明还是从硬件拷贝到了内核缓冲区,怎么能说是零拷贝呢?实际上,从操作系统的层面上,这就是零拷贝,因为内核缓冲区里是不会有重复数据的。使用零拷贝时,不仅能减少拷贝的次数,还能减少上下文的切换,更少的CPU数据缓存污染以及无需CPU校验和计算。

Zero Copy 以及 Java 的 transferTo

在 Java 中我们通常通过read和write方法对文件进行读写操作,但是在java.nio.channels.FileChannel中还提供了 transferTo 方法,,通过Javadoc我们知道这个方法能够将文件的通道里的字节传输到一个可写的字节通道中,这个方法能比简单的循环性能更高,很多操作系统能够在不需要多次拷贝的情况下将字节流直接地从操作系统缓存传输到目标channel。这个方法便是对应底层的零拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17


/**
* Transfers bytes from this channel's file to the given writable byte
* channel.
*
* ...
*
* <p> This method is potentially much more efficient than a simple loop
* that reads from this channel and writes to the target channel. Many
* operating systems can transfer bytes directly from the filesystem cache
* to the target channel without actually copying them. </p>
*
*/
public abstract long transferTo(long position, long count,
WritableByteChannel target)
throws IOException;

最后我们通过两个程序对Java的 transferTo 方法进行一个简单的验证。

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
62
63
//OldIOServer.java 
public class OldIOServer {

public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(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 (readCount == -1) {
break;
}

}

} catch (Exception e) {
e.printStackTrace();
}



}

}
}

//OldIOClient.java
public class OldIOClient {

public static void main(String[] args) throws Exception {
Socket socket = new Socket("localhost", 8899);

//大约4GB的文件
String fileName = "/Users/creams/Desktop/test.txt";
InputStream inputStream = new FileInputStream(fileName);

DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());

byte[] buffer = new byte[4096];
long readCount;
long total = 0;

long startTime = System.currentTimeMillis();

while ((readCount = inputStream.read(buffer)) >= 0) {
total += readCount;
dataOutputStream.write(buffer);
}

System.out.println("发送总字节数: " + total + ", 耗时: " + (System.currentTimeMillis() - startTime));

dataOutputStream.close();
socket.close();
inputStream.close();
}
}

依次启动服务器和客户端,程序结果

1
发送总字节数: 3956867683, 耗时: 4932

下面我们用 transferTo 方法来传输,代码如下:

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
//NeiIoServer.java
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 socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(true);

int readCount = 0;

while (-1 != readCount) {
try {
readCount = socketChannel.read(byteBuffer);
} catch (Exception e) {
e.printStackTrace();
}
byteBuffer.rewind();
}
}
}
}

//NeiIoClient.java
public class NewIOClient {

public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress(8899));
socketChannel.configureBlocking(true);

String fileName = "/Users/creams/Desktop/test.txt";

FileChannel fileChannel = new FileInputStream(fileName).getChannel();

long startTime = System.currentTimeMillis();

long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);

System.out.println("发送总字节数: " + transferCount + ", 耗时: " + (System.currentTimeMillis() - startTime));

}
}

依次启动服务器和客户端,结果如下

1
发送总字节数: 2147483647, 耗时: 809

感悟

学到这算是对零拷贝有一个大体的认识了,也通过层层递进的学习感受到操作系统的开发者们对OS层层递进的优化,最后通过软硬件结合优化将拷贝次数降低到极致,不禁为开发者无限的智慧感叹,并且也对 Linux Kernel 这座大山越来越感兴趣了。

参考资料