前言:
3.sendFile优化(Linux2.1版本)
上文我们介绍了下FileChannel的基本API使用。本文中,我们就一起看下FileChannel中的高阶API。
说是高阶,还真的就是,这些知识点大量利用了操作系统的对文件传输映射的高级玩法,极大的提高了我们操作文件的效率。我们熟知的kafka、rocketMQ等也是用了这些高阶API,才有如此的高效率。
我们提出一个需求,描述如下:提供一个对外的socket服务,该服务就是获取指定文件目录下的文件,并写出到socket中,最终展现在client端。
1.传统的文件网络传输过程按照此需求,常规方式,我们使用如下代码来完成:
File file = new File("D:\\test.txt");
Long size = file.length();
byte[] arr = new byte[size.intValue()];
try {
// 1.将test.txt文件内容读取到arr中
FileInputStream fileInputStream = new FileInputStream(file);
fileInputStream.read(arr);
// 2.提供对外服务
Socket socket = new ServerSocket(9999).accept();
// 3.传输到客户端
socket.getOutputStream().write(arr);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
以上是一个最简单版本的实现。
那么从操作系统的角度,以上传输经历了哪些过程呢?
这中间的过程我们可以分为以下几步:
fileInputStream.read方法对应于:
1)第一次复制:read方法调用,用户态切换到内核态。数据从硬盘拷贝到内核缓冲区,基于DMA自动操作,不需要CPU支持
2)第二次复制:从内核缓冲区拷贝到用户缓冲区(也就是byte[] arr中)。read方法返回,用内核态到用户态的转换。
socket.getOutputStream().write(arr)对应于:
3)第三次复制:从用户缓冲区拷贝数据到socket的内核缓冲区。write方法调用,用户态切换到内核态。
4)数据从socket内核缓冲区,使用DMA拷贝到网络协议引擎。write方法返回,内核态切换到用户态。
从上面的过程我们可以发现,数据发生了四次拷贝,四次上下文切换。
那么还有没有优化方式呢?答案是肯定的,我们接着往下看。
2.mmap优化
mmap通过内存映射,将文件直接映射到内存中。此时,用户空间和内核空间可以共享这段内存空间的内容。用户对内存内容的修改可以直接反馈到磁盘文件上。
FileChannel提供了map方法来实现mmap功能
File file = new File("D:\\test.txt");
Long size = file.length();
byte[] arr = new byte[size.intValue()];
try {
// 1.将test.txt文件内容读取到arr中
RandomAccessFile raFile = new RandomAccessFile(file, "rwd");
FileChannel channel = raFile.getChannel();
MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, size);
// 2.提供对外服务
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999));
serverSocketChannel.configureBlocking(false);
while(true){
SocketChannel socketChannel =
serverSocketChannel.accept();
if(socketChannel != null){
// 3.传输到客户端
socketChannel.write(mappedByteBuffer);
}
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
我们直接将file的内容映射到mappedByteBuffer,然后直接将mappedByteBuffer的内容传递出去。
那么从操作系统的角度,以上传输经历了哪些过程呢?

参考1中的四个步骤,少了一次内存拷贝,就是将文件从内核缓冲区拷贝到用户进程缓冲区这一步;但是上下文切换并没有减少。
Linux2.1版本提供了sendFile函数,该函数对本例有哪些优化呢?
就是可以将数据不经过用户态,直接从内核文件缓冲区传输到Socket缓冲区
FileChannel提供transferTo(和transferFrom)方法来实现sendFile功能
File file = new File("D:\\test.txt");
Long size = file.length();
try {
// 1.将test.txt文件内容读取到arr中
RandomAccessFile raFile = new RandomAccessFile(file, "rwd");
FileChannel channel = raFile.getChannel();
// 2.提供对外服务
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999));
serverSocketChannel.configureBlocking(false);
while(true){
SocketChannel socketChannel =
serverSocketChannel.accept();
if(socketChannel != null){
// 3.使用transferTo方法将文件数据传输到客户端
channel.transferTo(0, size, socketChannel);
}
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
同2中的代码,只是在最后一步将文件内容传输到socket时,使用了不一样的方法,本例中使用了FileChannel.transferTo方法来传递数据。
那么从操作系统的角度,以上传输经历了哪些过程呢?

参照1中的4个过程,少了用户空间的参与,那么就不存在用户态与内核态的切换。
所以,总结下来,就是减少了两次上下文切换,同时,减少了一次数据拷贝。
注意:剩下的是哪两次上下文切换呢?用户进程调用transferTo方法,用户态切换到内核态;调用方法返回,内核态切换到用户态。
4.sendFile优化(Linux2.4版本)
在Linux2.4版本,sendFile做了一些优化,避免了从内核文件缓冲区拷贝到Socket缓冲区的操作,直接拷贝到网卡,再次减少了一次拷贝。
代码同3,只是具体实现时的操作系统不太一样而已。
那么从操作系统的角度,其传输经历了哪些过程呢?

参照1中的4个操作过程,同样少了用户空间的参与,也不存在用户态与内核态的切换。
所以总结下来,就是两次数据拷贝,两次上下文切换(相比较3就是减少了内核文件缓冲区到内核socket缓冲区的拷贝)
总结:下面我们通过一个图表来展示下以上四种传输方式的异同
传输方式上下文切换次数数据拷贝次数传统IO方式44mmap方式43sendFile(Linux2.1)23sendFile(Linux2.4)22
实际,以上sendFile的数据传输方式就是我们常说的零拷贝。
可能会有些疑问,哪怕Linux2.4版本的sendFile函数不也是有两次数据拷贝嘛,为什么会说是零拷贝呢?
笔者拷贝了一段话,解释的蛮有意思的:
首先我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据,
sendFile 2.1 版本实际上有 2 份数据,算不上零拷贝)。例如我们刚开始的例子,内核缓存区和 Socket 缓冲区的数据就是重复的。
而零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。
再稍微讲讲 mmap 和 sendFile 的区别。
参考:
linux下的mmap和零拷贝技术 - 简书
mmap与sendfile() - 简书