Java IO系列 | NIO-1.0拾遗、NIO-2.0 & 零拷贝必吹的牛皮
前言
上一篇系列文章中,我们已经对NIO中的 Buffer、Channel、Selector 做了较为系统的梳理,凭借其内容,Android的同学应该能跨过侃大山的门槛了。
在 NIO-1.0 中,仍有两块儿内容值得展开:
- Scatter/Gather
- 零拷贝 Zero Copy
而NIO-2.0中的内容,往底层深挖时确实量不少,但Android同学能拿来侃大山的知识相对很少,我们合并成一篇。
JDK中的Scatter&Gather
作者按:读者诸君务必注意,本章节中讨论的内容,均为JDK中体现 Scatter&Gather 特性的内容,并非是操作系统层面的内容
Scatter
译为 分散
, Gather
译为 聚集
Scatter
在NIO-1.0中的应用是 Scattering Reads
,是指数据从一个Channel读取到多个 Buffer
中:
一种典型的应用方向是实现数据协议,从应用编写角度看,编码思路更加简单。
例如,约定一个数据分包协议进行数据传输,每一个包包含 "10byte的Header" 和 "50byte的Body(不足进行填充)"
//ignore imports
public class ScatterExample {
public static void main(String[] args) {
try (SocketChannel channel = SocketChannel.open()) {
channel.connect(new InetSocketAddress("localhost", 8080));
ByteBuffer headerBuffer = ByteBuffer.allocate(10);
ByteBuffer bodyBuffer = ByteBuffer.allocate(50);
ByteBuffer[] buffers = {headerBuffer, bodyBuffer};
long bytesRead = channel.read(buffers);
headerBuffer.flip();
bodyBuffer.flip();
// Process the data in buffers, hexString代指16进制两位补齐的字符串
System.out.println("Header: " + hexString(headerBuffer.array()));
System.out.println("Body: " + hexString(bodyBuffer.array()));
} catch (IOException e) {
e.printStackTrace();
}
}
}
不难想象,我们可以比较容易地实现:Header信息识别、内容拼包。
当然,一个健壮的协议不会如此简单,仅作为示意。
值得注意的是:Scattering Reads适合 "定长" 的读取情况。
相应的,Gather
在NIO-1.0中的应用是 Gathering Writes
,指数据从多个Buffer按序写入同一个Channel中:
以下是一个简单使用Demo
//ignore imports
public class GatherExample {
public static void main(String[] args) {
try {
// 创建SocketChannel并连接到服务器
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 8080));
// 准备多个缓冲区
ByteBuffer buffer1 = ByteBuffer.wrap("Hello,".getBytes());
ByteBuffer buffer2 = ByteBuffer.wrap(" World!".getBytes());
// 将多个缓冲区的数据写入到通道中
ByteBuffer[] buffers = {buffer1, buffer2};
socketChannel.write(buffers);
// 关闭SocketChannel
socketChannel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
与 Scatter
不同的是,Gather
擅长 动态长度
OS中的零拷贝
作者按:诸君请注意,本文中讨论零拷贝、Zero-Copy时,均指操作系统中的相关内容,如与Java间存在关联,会单独说明
首先需要记住,零拷贝中并非没有拷贝,而是指新增各种机制,以减少主内存中不必要的拷贝,例如免去从内核态到用户态的拷贝。
发展历程中涉及到的技术:
- mmap
- sendfile
- splice 等
我们以“将文件系统中的文件通过网卡发出“为例,简单讨论。
传统IO
在JAVA中使用传统IO实现该需求时,即前文中所述经典IO,需要将文件系统中的文件内容,拷贝到应用内部,继而通过 Socket
从网卡发送.
包含两个关键操作:
read()
write()
流程图和数据拷贝过程如下图:
DMA: Direct Memory Access, 直接内存访问, 计算机总线架构提供的功能,它能使数据从附加设备(如磁盘驱动器)直接发送到计算机主板的内存上。
整个过程中,发生两次系统调用,共发生了 4次用户态与内核态的 上下文切换
,和4次数据拷贝:
第一次拷贝
,把磁盘上的数据拷贝到操作系统内核的缓冲区里,通过 DMA 搬运。第二次拷贝
,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,由 CPU 完成。第三次拷贝
,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,由 CPU 完成。第四次拷贝
,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,通过 DMA 搬运。
很显然,这一过程中,文件数据进入用户缓存区再离开,并没有附加必不可少的操作,上下文切换也比较多,存在改进的空间。
mmap取代read
使用 mmap
取代 read
后,整个过程包含两个关键操作:
mmap()
write()
先补充一张 虚拟内存
的原理示意图,如下:
使用虚拟地址取代物理地址后,多个虚拟内存可以指向同一个物理地址,虚拟内存表示的空间可以大于实际物理内存空间。
将 用户空间缓存区
中的部分虚拟内存 和 内核空间缓存区
中的部分虚拟内存,映射到同一物理内存区域时,可以减少不必要的拷贝。
在Linux中,mmap
将一个文件或一块设备内存(如设备寄存器)映射到进程的地址空间,实现 文件磁盘地址
或 设备io地址
与进程虚拟地址空间中一段虚拟地址建立映射,ioremap
实现向内核空间映射 。
使用该技术后,可减少一次CPU拷贝,但上下文切换次数不变,流程图和数据流示意图如下:
3次 数据拷贝
,系统调用次数不变,4次 上下文切换
。
java中使用Demo,从上层编码也能体现一二:
class Demo {
public static void main(String[] args) {
try {
// 获取文件
FileChannel readChannel = FileChannel.open(Paths.get("/..../test1.txt"), StandardOpenOption.READ);
MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY, 0, readChannel.size());
FileChannel writeChannel = FileChannel.open(Paths.get("/..../test2.txt"),
StandardOpenOption.WRITE, StandardOpenOption.CREATE);
//数据传输
writeChannel.write(data);
readChannel.close();
writeChannel.close();
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
sendfile 取代 mmap+write
上文提到,将文件数据读入用户空间内存后并没有附加必不可少的操作,那么就存在减少系统调用的优化空间。
Linux 2.1 版本开始,Linux 引入了 sendfile
替换 mmap+write
方式,简化流程。
流程图和数据流示意图如下:
共发生 3次 数据拷贝
,1次 系统调用
, 即2次 上下文切换
scatter/gather 优化的 sendfile方式
在 sendfile
中,还有CPU拷贝的过程,能不能进一步优化呢?
Linux 2.4 内核进行了优化,提供了带有 scatter/gather
的 sendfile
操作,可以减少拷贝的内容,注意,仍然有描述信息需要拷贝。
原理为:
- 目标:内核空间 Read Buffer 和 Socket Buffer 之间不做数据复制
- 将 Read Buffer 的内存地址、偏移量信息等拷贝到 Socket Buffer 中。参考虚拟内存的解决思路实现目标。
Read Buffer 的内存地址、偏移量信息等,即所谓描述信息
流程图和数据流示意图如下:
从内核缓冲区到网卡的DMA拷贝,为 Gather Copy
sendfile只适用于将数据从文件拷贝到套接字上,限定了它的使用范围。
Linux在2.6.17版本引入splice,用于在两个文件描述符中移动数据:
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <fcntl.h>
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
splice
在两个文件描述符之间移动数据,从 fd_in
拷贝长度为 len
的数据到 fd_out
,有一方必须是管道设备。
以java中的transferTo为例
在Java中,transferTo
底层使用零拷贝技术,但从上层编码并不能体现出来:
class Demo {
public static void main(String[] args) {
try {
FileChannel readChannel = FileChannel.open(Paths.get("/..../test1.txt"), StandardOpenOption.READ);
long len = readChannel.size();
long position = readChannel.position();
FileChannel writeChannel = FileChannel.open(Paths.get("/..../test2.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
readChannel.transferTo(position, len, writeChannel);
readChannel.close();
writeChannel.close();
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
在 zulu版本的实现中:
class FileChannelImpl {
public long transferTo(long position, long count,
WritableByteChannel target)
throws IOException {
ensureOpen();
//ignore
long n;
// Attempt a direct transfer, if the kernel supports it
if ((n = transferToDirectly(position, icount, target)) >= 0)
return n;
// Attempt a mapped transfer, but only to trusted channel types
if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
return n;
// Slow path for untrusted targets
return transferToArbitraryChannel(position, icount, target);
}
}
通过注释与方法名可以看出端倪,感兴趣的读者可继续追溯源码,本文不再展开。
NIO-2.0
操作系统中的AIO
还请读者诸君回忆一下 总纲 中提到的AIO,
很显然,这是操作系统中的AIO,例如,Windows 中提供了 IOCP(I/O CompletionPort,I/O完成端口)
Java中的NIO-2.0
回想一下Java中经典IO(BIO),和NIO-1.0,并没有在JDK层面提供开箱即用的异步IO编程框架。当然,这和Java的多线程编程、异步编程发展有关。
而在JDK1.7中,配套提供了异步IO的编程框架,同样置于nio包下,惯称为NIO-2.0,也有人称之为AIO。
注意,阅读其他文章时,对于 异步
、 阻塞
的讨论,要界定清楚讨论的对象和范围
在应用程序部分,发起IO调用和执行IO操作是异步的,但在JVM中,是否使用了操作系统异步IO则需要看操作系统平台,像Linux是通过 epoll
,模拟了AIO。
在 Java.nio.channels
包下增加了四个异步通道:
- AsynchronousSocketChannel
- AsynchronousServerSocketChannel
- AsynchronousFileChannel
- AsynchronousDatagramChannel
结合 Future
进行异步编程,例如:
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.ByteBuffer;
import java.util.concurrent.Future;
class Demo {
public static void main(String[] args) {
Path file = Paths.get("/path/to/file.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(file, StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(1024);
Future<Integer> operation = channel.read(buffer, 0);
while (!operation.isDone()) {
// can do other work here while reading is in progress asynchronously
}
buffer.flip();
byte[] data = new byte[buffer.limit()];
buffer.get(data);
System.out.println(new String(data));
channel.close();
}
}
当然,也可以使用 Callback
这些异步通道,通过 Future
+ Callback
+ 线程池
+ Native API
实现了 文件异步非阻塞 IO
- 其中 Native API 的部分,对应到操作系统中的AIO。
Future
、Callback
、线程池
为异步程序编写提供了框架支持。
结语
至此,Java IO系列已告一段落,作为一个Android程序员,会再写一篇关于 Okio
的文章,毕竟 OKHttp
几乎是Android程序员吃饭的家伙了。
前段时间因为工作内容的变化,尚未适应过来,这篇文章的草稿攒了月余时间,期间也进行了多次思考,基础系列的文章确实相当枯燥,后面可能会靠好玩系列、三思系列进行调节。