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 中:

desc

一种典型的应用方向是实现数据协议,从应用编写角度看,编码思路更加简单。

例如,约定一个数据分包协议进行数据传输,每一个包包含 "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中:

desc

以下是一个简单使用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()

流程图和数据拷贝过程如下图:

desc

DMA: Direct Memory Access, 直接内存访问, 计算机总线架构提供的功能,它能使数据从附加设备(如磁盘驱动器)直接发送到计算机主板的内存上。

整个过程中,发生两次系统调用,共发生了 4次用户态与内核态的 上下文切换,和4次数据拷贝:

  • 第一次拷贝 ,把磁盘上的数据拷贝到操作系统内核的缓冲区里,通过 DMA 搬运。
  • 第二次拷贝 ,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,由 CPU 完成。
  • 第三次拷贝 ,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,由 CPU 完成。
  • 第四次拷贝 ,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,通过 DMA 搬运。

很显然,这一过程中,文件数据进入用户缓存区再离开,并没有附加必不可少的操作,上下文切换也比较多,存在改进的空间。

mmap取代read

使用 mmap 取代 read 后,整个过程包含两个关键操作:

mmap()
write()

先补充一张 虚拟内存 的原理示意图,如下:

desc

使用虚拟地址取代物理地址后,多个虚拟内存可以指向同一个物理地址,虚拟内存表示的空间可以大于实际物理内存空间。

用户空间缓存区 中的部分虚拟内存 和 内核空间缓存区 中的部分虚拟内存,映射到同一物理内存区域时,可以减少不必要的拷贝。

在Linux中,mmap 将一个文件或一块设备内存(如设备寄存器)映射到进程的地址空间,实现 文件磁盘地址设备io地址 与进程虚拟地址空间中一段虚拟地址建立映射,ioremap 实现向内核空间映射 。

使用该技术后,可减少一次CPU拷贝,但上下文切换次数不变,流程图和数据流示意图如下:

desc

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方式,简化流程。

流程图和数据流示意图如下:

desc

共发生 3次 数据拷贝 ,1次 系统调用, 即2次 上下文切换

scatter/gather 优化的 sendfile方式

sendfile 中,还有CPU拷贝的过程,能不能进一步优化呢?

Linux 2.4 内核进行了优化,提供了带有 scatter/gathersendfile 操作,可以减少拷贝的内容,注意,仍然有描述信息需要拷贝。

原理为:

  • 目标:内核空间 Read Buffer 和 Socket Buffer 之间不做数据复制
  • 将 Read Buffer 的内存地址、偏移量信息等拷贝到 Socket Buffer 中。参考虚拟内存的解决思路实现目标。

Read Buffer 的内存地址、偏移量信息等,即所谓描述信息

流程图和数据流示意图如下:

desc

从内核缓冲区到网卡的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,

desc

很显然,这是操作系统中的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。
  • FutureCallback线程池 为异步程序编写提供了框架支持。

结语

至此,Java IO系列已告一段落,作为一个Android程序员,会再写一篇关于 Okio 的文章,毕竟 OKHttp 几乎是Android程序员吃饭的家伙了。

前段时间因为工作内容的变化,尚未适应过来,这篇文章的草稿攒了月余时间,期间也进行了多次思考,基础系列的文章确实相当枯燥,后面可能会靠好玩系列、三思系列进行调节。