在 kafka、netty 这些技术中,零拷贝都是一个重要的考点。但是零拷贝与这些具体的技术无关,关键点是数据传输。就像冰糖葫芦里的山楂:冰糖葫芦里重要的组成可以有山楂。但是山楂并不是冰糖葫芦特有的,羊羹里也可以有。

深入浅出操作系统的零拷贝_数据


下面是一个 MQ 的基本流程。

深入浅出操作系统的零拷贝_零拷贝_02

如果采用传统方法进行数据传输,消息从存储系统到达消费者需要经过4次拷贝。如果使用零拷贝技术,情况会怎样呢?


传统模式下的数据拷贝过程

过程解释


传统模式下,上图红框中经过了从文件读数据和从 socket 进行数据发送两个过程。

深入浅出操作系统的零拷贝_数据_03


内部流程如下图所示:

深入浅出操作系统的零拷贝_linux_04


用户进程如 Java 程序想进行 File.read ,需要将数据进行 DMA 拷贝读取到到文件读取缓冲区。还记得​​《时刻掌握系统运行状态-深度理解top命令》​​里的 buffers/cached 空间吗?文件读取缓冲区占用的就是这个空间。


文件读取缓冲区仍然是内核空间,用户进程要使用还需要进行一次 CPU 拷贝将数据拷贝到应用进程缓冲区。这时候用户进程比如 Java 程序就可以对数据进行排序、过滤等操作了。这个过程就完成了 File.read。


如果数据想发送到网卡,也就是 Socket.send。还需要再进行一次 CPU 拷贝发送到套接字发送缓冲区进行中转,这个地方也是要占用 buffers/cached 空间的。中转这个过程很快,所以 buffers/cached 空间可以很快被释放。


数据从中转站还要进行一次 DMA 拷贝,将数据运送到网络设备缓冲区,最终发送到网络上。这个过程就完成了 Socket.send。


这个过程要进行几次上下文切换呢?File.read 这个函数需要先调用发起内核请求,进入到内核空间操作,这是一次内核切换。内核操作完成返回内核的结果,这是第二次内核切换。同理, Socket.send 也需要两次内核切换。这里的用户态到内核态的切换就是上下文切换。总共是4次。


性能测试


这种方式性能如何呢?咱们来测试一下。


写个程序从本地电脑中读取自己的一张照片,这张照片5M多大,发送到服务端。

深入浅出操作系统的零拷贝_数据_05

服务端只要能接收客户端数据就可以,我随便写了一个:

public static void server() throws Exception {
ServerSocket serverSocket = new ServerSocket(520);
int i = 1;
while (true) {
Socket socket = serverSocket.accept();
int left = 0;
while (left >= 0) {
InputStream io = socket.getInputStream();
byte[] bytes = new byte[1024];
left = io.read(bytes);
}
}
}


客户端读取数据并发送到网络:

@GetMapping(path = "hi")
public String hi() throws Exception {
client();
return "end";
}


public void client() throws Exception {
Socket socket = new Socket("127.0.0.1", 520);
//向服务器端第一次发送字符串
OutputStream netOut = socket.getOutputStream();
InputStream io = new FileInputStream("D:\\photo\\编程一生.JPG"); long begin = System.currentTimeMillis();
byte[] bytes = new byte[1024];
while (io.read(bytes) >= 0) {
netOut.write(bytes);
}
System.out.println("耗时为" + (System.currentTimeMillis() - begin) + "ms");
netOut.close();
io.close();
socket.close();
}


服务启动后:http://localhost:8080/hi 访问5次,结果如下:

耗时为450ms

耗时为437ms

耗时为424ms

耗时为423ms

耗时为420ms


结论:使用传统方式,5M多的数据读取到发送需要400多毫秒。


零拷贝过程


过程解释


linux操作系统中有个 sendFile 方法可以不通过用户进行,直接将数据从磁盘发送到网络设备缓冲区。在 linux2.1 版本的 sendFile 过程如下图:

深入浅出操作系统的零拷贝_linux_06


到了 linux2.4 ,linux 的 sendFile 进行了优化,实现了完全没有 CPU 拷贝实现数据传输。

深入浅出操作系统的零拷贝_linux_07


不管是 linux2.1 还是 linux2.4 ,都是 linux 自身实现的,函数都对应的是 sendFile 。上层比如 Java 可以使用 transferTo 和 transferFrom 使用 sendFile 方法,这两个方法是 netty 实现的重要工具,一个是发送数据用,一个是接收数据用。


性能测试


这种方式性能如何呢?咱们来测试一下。


服务端不变,客户端代码如下:

@GetMapping(path = "hi")
public String hi() throws Exception {
client();
return "end";
}


public void client() throws Exception {
SocketChannel socket = SocketChannel.open();
socket.connect(new InetSocketAddress("127.0.0.1", 520));
FileChannel io = new FileInputStream("D:\\photo\\编程一生.JPG").getChannel();
long begin = System.currentTimeMillis();
io.transferTo(0, io.size(), socket);
System.out.println("耗时为" + (System.currentTimeMillis() - begin) + "ms");
io.close();
socket.close();
}


服务启动后:http://localhost:8080/hi 访问5次,结果如下:

耗时为44ms

耗时为33ms

耗时为43ms

耗时为46ms

耗时为35ms


结论:使用零拷贝方式,5M多的数据读取到发送需要40多毫秒。与传统方式相比,性能提高10倍。


内存映射模式与零拷贝


linux 系统有零拷贝,windows 也希望减少拷贝和下上下切换,它依靠内存映射(MMAP)。当然,linux 也支持内存映射,并且在 RocketMQ 等的实现上发挥着巨大作用。



深入浅出操作系统的零拷贝_零拷贝_08


通过与上面传统方式比较,可看到由于内存映射发挥作用,在文件读取时减少了一次 CPU 拷贝。


在 Java 中可以通过下面方法进行内存映射:

RandomAccessFile raf = new RandomAccessFile(file, "rw");
MappedByteBuffer mmap = raf.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, 500);


在 MQ 的实现上,内存映射(MMAP)和 sendFile 零拷贝是提升性能的利器。下面做一个比较:


深入浅出操作系统的零拷贝_linux_09


上面可以看到 RocketMQ 由于使用了内存映射吞吐量远高于 ActiveMQ 和 RabbitMQ ,Kafka 由于使用了零拷贝又比 RocketMQ 提高了一个数量级。


实际上 RabbitMQ 的实现大量借鉴了 Kafka ,那 RabbitMQ 为什么不直接使用 Kafka 的零拷贝提高性能呢?因为 RabbitMQ 不仅仅是将数据从磁盘发送出去,还需要在内存中做一些排序、过滤等高级操作。


最后大家再来思考一个问题:零拷贝和内存映射两种模式下,各需要几次上下文切换?