IO 模型与 NIO 基础

110 阅读16分钟

IO 的过程

  1. 首先在网络的网卡上或本地存储设备中准备数据,然后调用 read() 函数。
  2. 调用 read() 函数厚,由内核将网络/本地数据读取到内核缓冲区中。
  3. 读取完成后向 CPU 发送一个中断信号,通知 CPU 对数据进行后续处理。
  4. CPU 将内核中的数据写入到对应的程序缓冲区或网络 Socket 接收缓冲区中。
  5. 数据全部写入到缓冲区后,应用程序开始对数据开始实际的处理。

原文链接:blog.csdn.net/JHIII/artic…

当调用一次 channel.read 或 stream.read 后,会切换至操作系统内核来完成真正的数据读取,而读取又分为两个阶段,分别为:

  • 等待数据阶段:指数据从网络网卡或本地存储器读取到内核的过程
  • 复制数据阶段:数据准备好到内核缓冲区,需要从内核缓冲区拷贝数据到用户空间

同步阻塞、同步非阻塞、多路复用、信号驱动模型、异步非阻塞 IO 模型详解

  • 同步阻塞:如果内核的数据还没准备好的话,那么用户程序就一直阻塞等待
  • 同步非阻塞:通过轮训的方式来处理,如果轮训的时候发现来请求了才会进行内核空间到用户空间的复制
  • 多路复用:一个线程通过 select 函数同时监控多个 fd,在 select 函数监控的 fd 中 有任何一个数据状态准备就绪了,select 函数就会告诉用户线程,用户线程然后再去读取数据。多路是指网络连接,复用一个线程。
    • select 只是返回一个可读状态,用户线程只知道有事件发生了,但是需要循环判断哪些流有事件,然后进行处理
    • epoll 是返回可读事件,用户线程会知道哪个流有事件,并且知道是什么事件,然后用户线程根据指定事件来进行处理
    • select、poll、epoll之间的区别(搜狗面试),系统层面的 select 的实现其实是更底层的实现了刚才我们写的循环遍历查找思路,只是直接在系统层面性能更高。
  • 信号驱动模型:Java 对信号驱动模型并没有原生的支持,这是因为 Java 运行时环境在 Java SE 8 之前使用的是单线程模型,直到 Java SE 8 才增加了对并发的大量改进。信号驱动模型需要操作系统内核支持,而不同的操作系统对信号驱动模型的实现各不相同,需要在不同的系统上进行定制和配置,这就会导致 Java 跨平台移植的困难性增加。另外,Java 的线程模型相对来说更容易被 Java 开发人员理解和使用,也能够满足大部分的编程需求,因此 Java 没有特别强调对信号驱动模型的支持

  • 异步非阻塞

    • 同步:线程是自己获取结果
    • 异步:线程自己不去获取结果,而是其他线程送结果,有的说是内核做复制数据操作,然后通知用户线程,用户线程直接使用数据
      • linux 系统异步 IO 在 2.6 版本引入,但是底层实现还是用多路复用模拟了异步 IO,性能没有优势
      • windows 系统通过 IOCP 实现了真正的异步 IO

BIO

同步阻塞 IO ,数据的写入和读取都必须阻塞在一个线程中执行,在写入和读取完成之前线程阻塞额

  • 特点:程序简单,在阻塞等待数据期间,用户线程挂起,用户线程基本上不会占用 CPU 资源
  • 缺点:一般情况下,服务端会为每个客户端连接配套一条独立线程用来读写,在并发大的情况下,开销会很大

NIO

提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的同步非阻塞 IO 程序,同时提供了更接近操作系统高性能的数据操作方式

Java中 的 NIO 主要是通过 ServerSocketChannel 和 SocketChannel 实现的。当我们使用 NIO 的方式向ServerSocketChannel 注册一个 I/O 操作时,调用者一般是通过 ServerSocketChannel.register 方法,该方法实际上是调用了 Selector.register 方法来注册事件。

NIO epoll 存在空轮询导致 CPU 100% BUG

  • 特点:每次发起 IO 系统调用,在内核的等待数据过程中可以立即返回,用户线程不会阻塞,可以去干其他活。
  • 缺点:需要不断间隔一定时间重复发起 IO 系统调用,不断轮询,占用大量 CPU,任务响应延迟增大,因为任务可能在两次轮询之间的任意时间发生,Java NIO 并不是只能实现 IO 模型中的 NIO,还能实现 IO 多路复用

AIO

是 jdk7 引入的包,提供了异步非阻塞的 IO 操作方式,所以叫它 AIO(Asynchronous IO),异步 IO 是基于事件和回调机制实现的,也就是应用操作后会直接返回,不会阻塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作

NIO 基础

非阻塞 IO

三大组件

Channel & Buffer

就是读写数据的双向通道,而之前的 stream 要么是输入,要么是输出, channel 比 stream 更底层。

常见的 Channel

  • 文件传输通道
    • FileChannel
  • UDP 时需要的传输通道
    • DatagramChannel
  • TCP 传输通道
    • SocketChannel
    • ServerSocketChannel
FileChannel 常用方法
  • channel.read(buffer):读满当前 buffer position 以后的数据
// 读满当前 buffer position 以后的数据
channel.read(buffer)

// 比如文本: 1234567890abc
ByteBuffer buffer = ByteBuffer.allocate(10);
int readLen = channel.read(buffer);
// read 之后 position 是 10,
buffer.position(8);
// 这里再读会读两个字节到第 9 和 10 两个位置,如果输出会打印
// 12345678ab而不是1234567890
readLen = channel.read(buffer);

Buffer

用来缓冲读写数据 常见的 buffer 有

  • ByteBuffer
    • MappedByteBuffer
    • DirectByteBuffer
    • HeapByteBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer
  • CharBuffer

Selector

多线程版设计

一个客户端建立连接,代码的角度就是创建一个 socket,读写操作就是通过 socket 来完成,所以一个客户端来了就是给他开一个线程,如果多个客户端就是要开多个线程,每个线程管一个客户端连接

缺点:

  1. 内存占用率高
  2. 线程上下文切换成本高
  3. 只适合连接数少的场景

线程池版设计

用线程池合理控制线程总数,一个线程连接建立后,期间所有的 read、write 等处理完之后再处理下一个 socket 请求,依次循环处理,所以只适用于短连接场景。

缺点:

  1. 处理第一个客户端时占用了其中一个线程,哪怕什么都不做,那这个线程也会一直被占用,比如客人就餐的话可能有很多的步骤,他要翻菜单、点菜、吃饭,要最后买单结账,但是在他执行所有操作的这个同时,这个服务员都必须跟着陪着。就比如说他在这儿光翻菜单不点菜,都在这个线程内。直到最后这个客人结账完了,他把这个连接断开了,这个服务员才得到自由,才能去服务下一个

selector 版设计

可以被称为多路复用器(可以对多个 Channe 可读写事件监控),可以把多个 channel 注册到一个 selector 上,每个线程可以通过 selector 管理多个 channel,当其中的一个 channel 发生了读写事件,selector 就会通知线程去处理这个事件,避免了一个客户端连接占用一个线程的情况出现,线程的利用率得到提升。

适合连接数特别多、事件处理快的场景。

如果一个 channel 发送了很大的事件,那么 thread 就一直在处理这个事件,其他的 channel 就只能等待了。

  • 仅针对网络 IO、普通文件 IO 没法利用多路复用
  • 如果不用 Selector 的非阻塞模式,线程大部分时间都在做无用功,而 Selector 可以保证
    • 有可连接事件时才去连接
    • 有可读事件才去读取
    • 有可写事件才去写入
      • 限于网络传输能力,Channel 未必时时可写,一旦 Channel 可写,会触发 Selector 的可写事件

SelectionKey

表示 SelectableChannel 在 selector 中注册的标记/句柄,会记录你关心的 Channel,可以告知 SelectionKey 只关注哪些事件。

SocketChannel.register 方法会返回一个 SelectionKey

selector.selectedKeys() 用完需要 remove。 原因原因
selector.selectedKeys()与selecot.keys区别

  • selectorImpl 在内部存放 selection key 时,用 keys、selectedKeys、publicKeys、publicSelectedKeys 四个字段,前两个是实际创建的 HashSet,后两者是 unmodifiableSet、ungrowableSet,前两者不能暴露给用户,这样防止用户对其错误修改
  • selectedKeys 会在 select() 有事件发生时进行 add,是不会进行 remove 的,所以用完不 remove ,用完的 key 还是在集合里,那么再有别的 Channel 来的话,会出现以下情况
    • accept 是会报错的,因为非阻塞模式下 accept() 返回的是 Null
    • read 会读到空数据,read 方法返回的是 0

服务端就算用完后 remove ,客户端异常断开链接后还是需要 catch 并 cancel

  • 服务端 remove 后只是从 publicSelectedKeys 里删除了,但是
  • sun.nio.ch.SelectorImpl#processDeregisterQueue 会判断 cancelledKeys 如果有的话会从 sun.nio.ch.WindowsSelectorImpl#fdMap、selectedKeys 和 cancelledKeys 删除数据,那么再取的时候,是从 fdMap 中获取的 SelectorImpl,那么就会获取不到了

客户端正常 close 断开,怎么判断是正常断开还是正常读数据,read 返回的数据是 -1

处理消息边界
  • 固定长度边界:每次发送消息时指定每个消息的固定长度,不满固定长度时,用固定字符串填充,比如空格
    1. 缺点:需要提前得知消息的范围,设置的小的话接收大消息会有问题,如果设置大的话,小消息又需要用大量的数据填充
  • 分隔符:以固定符号表示结尾
    1. 优点:简单,不会浪费空间
    2. 缺点:
      1. 需要对内容本身做处理,防止内容出现分隔符,所以需扫描一遍传输的数据将其转义
      2. 需要每个字节比较,比较耗时
  • 固定长度 + 内容:比如协议规定固定 4 位存放内容长度
    1. 优点:可以根据固定长度精准定位,也不用扫描转义字符
    2. 缺点:设计比较困难,大了浪费空间,毕竟每个报文都需要带长度,小了可能不够用
    3. TLV 格式,即 Type 类型、Length 长度、Value 数据,类型和长度已知的情况下,就可以方便分配合适的 buffer,缺点是 buffer 需要提前分配,如果内容过大,则影响 server 吞吐量
      1. Http1.1 是 TLV 模式
      2. Http2.0 是 LTV 模式
分隔符读数据简单 util
@Slf4j
public class SocketUtils {

    public static void readData(SelectionKey key) {
        try {
            SocketChannel clientChannel = (SocketChannel)key.channel();
            // 获取附件
            ByteBuffer buffer = (ByteBuffer)key.attachment();

            // 正常断开返回值是 -1
            final int readSize = clientChannel.read(buffer);
            if (readSize == -1) {
                key.cancel();
                log.info("客户端正常断开:{}", clientChannel);
            } else {
                split(buffer);

                // 还相同说明一个字节都没被读,需要被扩容
                if (buffer.position() == buffer.limit()) {
                    ByteBuffer newByteBuffer = ByteBuffer.allocate(buffer.capacity() * 2);
                    buffer.flip();
                    newByteBuffer.put(buffer);
                    // 替换附件
                    key.attach(newByteBuffer);
                }
            }
        } catch (Exception e) {
            // 因为客户端断开了,这里需要取消连接
            key.cancel();
            e.printStackTrace();
        }
    }

    private static void split(ByteBuffer source) {
        // 读
        source.flip();
        // 循环到最后需要读取的一个位置
        for (int i = 0; i < source.limit(); i++) {
            // 找到分隔符,用get(i)是因为不会改变position,一次性初始化够ByteBuffer的空间
            if (source.get(i) == '\n') {
                // 完整的一条信息的长度,+1是因为 \n 也要取到
                int length = i + 1 - source.position();
                // -1 是因为\n不读出来,直接略过
                ByteBuffer target = ByteBuffer.allocate(length - 1);

                for (int j = 0; j < length; j++) {
                    final byte b = source.get();
                    // 不取 \n
                    if (j != length - 1) {
                        target.put(b);
                    }
                }
                printString(target);
            }
        }

        // 从未读的地方开始重新写
        source.compact();
    }
    public static void printString(ByteBuffer byteBuffer) {
        System.out.println(new String(byteBuffer.array(), 0, byteBuffer.limit(), StandardCharsets.UTF_8));;
    }
}
单线程 selector 实现
@Slf4j
public class SelectorReadServerDemo {
    public static void main(String[] args) throws IOException {
        // 1. selector,管理多个channel
        final Selector selector = Selector.open();

        // 2. 创建服务器
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        // 非阻塞模式
        serverSocketChannel.configureBlocking(false);

        // 3. 建立 selector 和 channel 的联系(注册),0表示不关注任何事件
        // 就是将来发送事件后,通过它可以知道事件和哪个 channel 的事件
        final SelectionKey selectionKey = serverSocketChannel.register(selector, 0, null);
        // 告诉 selectionKey 关注哪种事件
        selectionKey.interestOps(SelectionKey.OP_ACCEPT);
        
        // 绑定监听端口号
        serverSocketChannel.bind(new InetSocketAddress(8080));

        // accept 
        while (true) {
            // 4. 等待事件
            // 没有事件发生则会阻塞,有事件会恢复运行
            // 如果下面取到任务未处理,select 方法会一直能取到任务,必须处理 accept 获取取消 cancel
            selector.select();

            // 5. 处理事件,selectionKeys 内部包含了所有发生的事件
            final Set<SelectionKey> selectionKeys = selector.selectedKeys();
            final Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                final SelectionKey key = iterator.next();
                log.info("key:{}", key);

                // 6. 区分事件类型
                if (key.isAcceptable()) {
                    // 获取发生事件的 serverChannel
                    final ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
                    log.info("accept....{}", serverChannel);
                    final SocketChannel client = serverChannel.accept();

                    client.configureBlocking(false);
                    // 设置一个 bytebuffer 作为附件关联到 selectionKey上
                    ByteBuffer byteBuffer = ByteBuffer.allocate(16);
                    SelectionKey clientSectionKey = client.register(selector, 0, byteBuffer);
                    clientSectionKey.interestOps(SelectionKey.OP_READ);
                } else if (key.isReadable()) {
                    	SocketUtils.readData(key);ackTrace();
                    }
                }

                // 必须要删除掉  https://blog.csdn.net/weixin_65349299/article/details/122301441
                iterator.remove();
            }

        }
    }
    public static void printString(ByteBuffer byteBuffer) {
        System.out.println(new String(byteBuffer.array(), 0, byteBuffer.limit(), StandardCharsets.UTF_8));;
    }

    private static void split(ByteBuffer source) {
        // 读
        source.flip();
        // 循环到最后需要读取的一个位置
        for (int i = 0; i < source.limit(); i++) {
            // 找到分隔符,用get(i)是因为不会改变position,一次性初始化够ByteBuffer的空间
            if (source.get(i) == '\n') {
                // 完整的一条信息的长度,+1是因为 \n 也要取到
                int length = i + 1 - source.position();
                // -1 是因为\n不读出来,直接略过
                ByteBuffer target = ByteBuffer.allocate(length - 1);

                for (int j = 0; j < length; j++) {
                    final byte b = source.get();
                    // 不取 \n
                    if (j != length - 1) {
                        target.put(b);
                    }
                }
                printString(target);
            }
        }

        // 从未读的地方开始重新写
        source.compact();
    }
}
多线程 selector 实现

@Slf4j
public class ThreadServerDemo {
    public static void main(String[] args) throws IOException {
        // 1. selector,管理多个channel
        final Selector selector = Selector.open();

        // 2. 创建服务器
        ServerSocketChannel server = ServerSocketChannel.open();
        // 非阻塞模式
        server.configureBlocking(false);
        // 绑定监听端口号
        server.bind(new InetSocketAddress(8080));

        // 3. 建立 selector 和 channel 的联系(注册),0表示不关注任何事件
        // 只关注 accept 事件
        final SelectionKey acceptSelectionKey = server.register(selector, 0, null);
        // 告诉 selectionKey 关注哪种事件
        acceptSelectionKey.interestOps(SelectionKey.OP_ACCEPT);

        // 创建固定数量的 worker,并初始化
        Worker[] workers = new Worker[2];
        for (int i = 0; i < workers.length; i++) {
            workers[i] = new Worker("worker-0");
        }

        AtomicInteger index = new AtomicInteger();

        while (true) {
            // 等待事件
            selector.select();

            // 5、处理事件
            final Set<SelectionKey> acceptSelectionKeys = selector.selectedKeys();
            final Iterator<SelectionKey> iterator = acceptSelectionKeys.iterator();
            while (iterator.hasNext()) {
                final SelectionKey acceptKey = iterator.next();
                if (acceptKey.isAcceptable()) {
                    final SocketChannel clientChannel = server.accept();
                    clientChannel.configureBlocking(false);

                    // 注册 channel 到 worker 的 selector
                    workers[index.getAndIncrement() % workers.length].register(clientChannel);
                }

                iterator.remove();
            }
        }
    }

    static class Worker implements Runnable {
        private Thread thread;
        private Selector selector;
        private String name;
        // 是否初始化 selector
        private volatile boolean start = false;
        private ConcurrentLinkedQueue<Runnable> queue = new ConcurrentLinkedQueue<>();

        public Worker(String name) {
            this.name = name;
        }

        /**
         * 注册 channel 到 worker 的 selector
         * 初始化线程和 selector
         */
        public void register(SocketChannel clientChannel) throws IOException {
            log.info("register====={}", this);
            if (!start) {
                thread = new Thread(this);
                selector = Selector.open();

                // 先执行 select 方法,再关联 SocketChannel 则会一直阻塞
                thread.start();

                start = true;
            }

            // 这块如果关注事件比 run 方法里的 select 方法晚,那么就会一直阻塞
//             Thread.sleep(100);

//            selector.wakeup();
            log.info("wakeup===============");
            // 设置一个 bytebuffer 作为附件关联到 selectionKey上
            ByteBuffer byteBuffer = ByteBuffer.allocate(16);
            clientChannel.register(this.selector, SelectionKey.OP_READ, byteBuffer);

            // 唤醒 selector 类似于 LockSupport 先拿到和后拿到无所谓,不过一定要放在注册事件后面
            selector.wakeup();
        }


        @Override
        public void run() {
            // 先执行 select 方法,再关联 SocketChannel 则会一直阻塞,睡眠一会,最 low 的解决方式
//            Thread.sleep(5000);
            log.info("工作线程:{}初始化了", name);
            while (true) {
                try {
                    selector.select();
                    final Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();

                    while (iterator.hasNext()) {
                        final SelectionKey key = iterator.next();

                        if (key.isReadable()) {
                            log.info("read==========:{}", ((SocketChannel)key.channel()).getRemoteAddress());
                            SocketUtils.readData(key);
                        }

                        iterator.remove();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

零拷贝

零拷贝详细描述-小林coding

传统 IO

传统 IO 将一个文件通过 socket 写出流程如下:

  1. Java 本身不具备 IO 能力,因此 read 方法调用后,要从 Java 程序的用户态切换到内核态,去调用操作系统(Kernel)的读能力,将数据读入内核缓冲区。这期间用户线程阻塞,操作系统使用 DMA(Direct Memory Access)来实现文件读,期间也不会使用 CPU

DMA 也可以理解为硬件单元,用来解放 CPU 完成文件 IO

  1. 内核态切换到用户态将数据从内核缓冲区读入用户缓冲区(即 byte[] buf),这期间 CPU 会参与拷贝,无法利用 DMA
  2. 调用 write 方法,这时数据从用户缓冲区(byte[] buf)写入 socket 缓冲区,CPU 会参与拷贝
  3. 接下来向网卡写数据,这项能力 Java 又不具备,因此又需要从用户态切换到内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 CPU

可以看到中间环节较多,Java 的 IO 实际并不是物理设备级别的读写,而是缓存的复制,底层真正读写是操作系统来完成的

  • 用户态与内核态切换发生了三次(没有算上最后切换回用户态的一次),操作比较重量级
  • 数据拷贝供 4 次

NIO 优化

通过 DirectByteBuffer,大部分步骤与优化前相同,只有一点区别:Java 可以使用 DirectByteBuffer(MappedByteBuffer 子类) 将堆外内存映射到 jvm 内存中来直接访问,因为已经将文件映射到内存,所以就减少了一次 cpu 拷贝

  1. 这块内存不受 jvm 垃圾回收影响,因此内存地址固定,有助于 IO 读写
  2. Java 中的 DirectByteBuffer 对象仅维护了此内存的虚引用,内存回收分为两步
    1. DirectByteBuffer 对象被垃圾回收,将虚引用加入引用队列
    2. 通过专门线程访问引用队列,根据虚引用释放堆外内存
  3. 减少了一次数据拷贝,用户态与内核态切换的次数没有减少

进一步优化 linux2.1

底层采用了 linux2.1 后提供的 sendFile 方法,Java 中对应两个 channel 调用 transferTo/transfrFrom 方法拷贝数据

  1. Java 调用 transferTo 方法后,要从 Java 程序的用户态切换至内核态,使用 DMA 将数据读入内核缓冲区,不会使用 CPU
  2. 数据从内核缓冲区传输到 socket 缓冲区,CPU 会参与拷贝
  3. 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 CPU

可以看到

  • 只发生了一次用户态到内核态的切换
  • 数据拷贝 3 次

进一步优化 linux2.4

  1. Java 调用 transferTo 方法后,要从 Java 程序的用户态切换至内核态, 使用 DMA 将数据读入内核缓冲区,不会使用 CPU
  2. 只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
  3. 使用 DMA 将内核缓冲区的数据写入网卡,不会使用 CPU

整个过程仅发生了一次用户态与内核态的切换,数据拷贝了 2 次,所谓的【零拷贝】并不是真正无拷贝,而是不会拷贝重复数据到 jvm 内存中,零拷贝的优点有

  • 更少的用户态与内核态的切换
  • 不利用 CPU 计算,减少 CPU 缓存伪共享
  • 零拷贝适合小文件传输

mmap 和 sendFile

MappedByteBuffer 便是 MMAP 的操作类。