图片和资料来自自己购买的电子书 <Netty, Redis, Zookeeper 高并发实战>

四种 IO 模型

同步阻塞 IO

Blocking IO

阻塞 IO : 需要内核 IO 操作彻底完成后, 才返回到用户空间执行用户的操作. 阻塞指的是用户空间程序的执行状态.

同步 IO : 是一种用户空间与内核空间的 IO 发起方式. 同步 IO 是指用户空间的线程是主动发起 IO 请求的一方, 内核空间是被动接受方.

异步 IO : 是指系统内核是主动发起 IO 请求的一方, 用户空间的线程是被动接受方.

image-20191031153209097

优点

  • 开发简单
  • 阻塞期间, 用户线程基本不会占用 CPU 资源

缺点

  • 一般一条线程维护一个连接的 IO 操作
  • 高并发下需要大量线程, 内存, 线程切换开销大

同步非阻塞 IO

Non-blocking IO

非阻塞 IO : 是用户空间的程序不需要等待内核 IO 操作彻底完成, 可以立即返回用户空间执行用户操作, 即处于非阻塞状态. 与此同时, 内核会立即返回给用户一个状态值.

Socket 连接默认是阻塞模式的, 在 Linux 系统下可以将 socket 设置为非阻塞模式. 这时就是同步非阻塞 IO.

image-20191031153747925

特点:

  • 在内核缓冲区没有数据的情况下, 系统调用会立即返回一个调用失败信息
  • 有数据的情况下, 是阻塞的.直到数据从内核缓冲区复制到用户进程缓存区. 完成后调用返回成功, 用户线程才会执行.

优点

  • 每次发起 IO 系统调用, 在内核等待数据过程中, 可以立即返回. 用户线程不会阻塞, 实时性好.

缺点

  • 要不断地轮询内核, 这将占用大量的 CPU 时间, 效率低

IO 多路复用

IO Multiplexing. 对应 Java 的 NIO 包

即 Reactor 反应器设计模式, 有时也称为异步阻塞 IO. 例如 Java中的 Selector 和 Linux 的 Epoll

这种避免了同步非阻塞中不断要轮询的问题

对应的系统调用为 select/epoll . 这种系统调用中, 一个进程可以监视多个文件描述符, 一旦某个描述符就绪(一般是内核缓冲区可读/写), 内核能够将就绪的状态返回给应用程序. 然后应用程序根据就绪的状态, 进行相应的 IO 系统调用.

image-20191031154636481

特点 : 涉及两种系统调用. 一种是 select/epoll 操作, 一种是 IO 操作. 它与同步非阻塞 IO 有密切关系. 对于注册在选择器上的每一个可查询的 socket 连接, 一般都设置为同步非阻塞模型.

优点

  • 一个 selector 线程可以同时处理成千上万的连接
  • 系统不必创建大量线程
  • 减小系统资源开销

缺点

  • 本质上, select/epoll 系统调用是阻塞式的, 属于同步 IO.
  • 都需要在读写事件就绪后, 由系统调用本身负责进行读写, 这个过程是阻塞的

异步 IO

Asynchronous IO

异步 IO : 是指系统内核是主动发起 IO 请求的一方, 用户空间的线程是被动接受方. 类似回调.

基本流程: 用户线程通过系统调用, 向内核注册某个 IO 操作. 内核在整个 IO 操作完成后, 通知用户程序, 用户执行后续的业务操作.

image-20191031155416993

有时也被称为 信号驱动 IO

Windows 通过 IOCP 实现了真正的异步 IO.

Linux 2.6 才引入异步IO, 但底层还是通过 epoll 实现, 所以目前 Linux 基本都是 epoll

Java NIO

核心组件

  • Channel
  • Buffer
  • Selector

NIO (New IO) 与 OIO (old IO) 对比

  • OIO : 是面向流(顺序操作). NIO 是面向缓冲区(Buffer 任意位置)
  • OIO : 阻塞(一直阻塞, 直到有数据 read 后返回). NIO 是非阻塞(有数据, 则读取返回. 没有则直接返回)
  • OIO : 没有 selector 概念. 而 NIO 有.

Channel

在 OIO , 同一个网络连接会关联到两个流: Input Stream 和 Output Stream.

在 NIO , 同 一个网络连接使用 Channel 表示. 代表 Linux 中的底层文件描述符.

主要类型

FileChannel : 文件

SocketChannel : TCP 客户端
  
ServerSocketChannel : TCP 服务端
  
DatagramChannel : UDP  

操作

FileChannel fileChannel = new FileInputStream("mtp/").getChannel();

SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("127.0.0.1", 8080));
while(!socketChannel.finishConnect()) {
  //一直判断
}
//到这里表示连接成功

Selector

它是一个 IO 事件查询器. 通过它, 可以一条线程查询多个 Channel 的 IO 事件就绪状态.

开发时, 将 Channel 注册到 Selector 中, 然后选择内部机制, selector 查询就绪事件就调用相应处理.

与 Channel 的关系

一个 channel 表示一条连接. 而 selector 可以同时监控多个 channel 的 IO. 所以 selector 和 channel 是监控和被监控的关系.

channel 和 selector 之间的关系, 通过 register (注册器) 的方式完成. 调用 Channel.register(Selector sel, in ops) 方法, 可以将 channel 实例注册到一个 selector 中. Ops 表示事件类型. 事件类型有

SelectionKey.OP_ACCEPT
SelectionKey.OP_CONNECT
SelectionKey.OP_READ
SelectionKey.OP_WRITE

要监控多个事件, 则用位或运算符来实现:
SelectionKey.OP_ACCEPT | SelectionKey.OP_CONNECT

注意, 并不是所有的 channel 都是可以被 selector 监控的. 能被监控的 channel 都继承了 SelectableChannel 类.

使用流程

Channel 的 validOps() 返回的是可支持的事件类型.

import org.junit.Test;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

public class TestNIO {
    @Test
    public void test() throws IOException {
        Selector selector = Selector.open();
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        //配置TCP 服务器信息
        serverSocketChannel.configureBlocking(false);
        serverSocketChannel.bind(new InetSocketAddress("127.0.0.1", 8080));
        //注册到selector
        serverSocketChannel.validOps();
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        while (selector.select() > 0) {
            Set<SelectionKey> selectKey = selector.selectedKeys();
            System.out.println(selectKey.size());
            Iterator<SelectionKey> iter = selectKey.iterator();
            while (iter.hasNext()) {
                SelectionKey key = iter.next();
                if (key.isAcceptable()) {
                    //有新连接进来, 这时可用 serverSocketChannel.accept() 获取客户端 channel
                    System.out.println("有新连接进来 " + key.channel());
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    socketChannel.configureBlocking(false);
                    socketChannel.register(selector, SelectionKey.OP_READ);
                }
                iter.remove();
            }
        }
    }
}

注意, 每处理完一个事件, 后, 要更新 channel 为不同的状态. 否则会一直重复处理

Buffer

应用程序与 Channel 的主要交互操作, 利用 Buffer 进行数据的读写. 这是非阻塞的重要前提和基础之一.

它是非线程安全的

直接继承的类有

ByteBuffer (java.nio)
CharBuffer (java.nio)
DoubleBuffer (java.nio)
FloatBuffer (java.nio)
IntBuffer (java.nio)
LongBuffer (java.nio)
ShortBuffer (java.nio)

重要属性

  • capacity : 容量大小.一旦写入的对象数量超过了它, 缓冲区就表示满了.初始化后就不能改变.
  • position : 表示当前位置. 它与 读写模式 有关. 在不同模式下, 它的值是不同的.
    • 写模式 : 进入写模式时(默认分配后是进入这个模式), position 为 0. 每写一个对象数据, 则会移到下一个可写位置.
    • 读模式 : 进入读模式时, position 为 0. 每读一个对象数据, 则会移到下一个可读位置.
    • 切换模式: 调用 flip 方法
  • limit : 在不同的模式下有不同的含义.
    • 在写入模式中, 它表示可以写入的最大上限.
    • 在读模式中, 表示最多能读多少数据.
    • 刚进入写模式时, limit 与 capacity 的值一样.
    • 从写模式切换为读模式时, limit 值为写模式时的 position 值. 即之前写入的最大数量, 作为可以读取的上限值.

重要方法

  • allocate() : 创建缓冲区. 调用子类的该方法来分配, 而不是 new

  • put() 写入缓冲区

  • flip() 切换模式

  • get() 读取缓冲区数据

  • rewind() 倒带. 如果已经读写一次数据, 想再读一遍, 这时可调用该方法

    • position 重置为 0

    • limit 保持不变

    • mark 标记被清理

      public final Buffer rewind() {
          position = 0;
          mark = -1;
          return this;
      }
      
  • mark()和 reset() : mark 表示将当前 position 保存起来. reset 表示将 mark 的值恢复到 position 中

  • clear() 在读模式下调用的话会切换为写入模式. 并会将 position 清 0, limit 为 capacity.

Reactor

定义

Reactor也可以称作反应器模式,它有以下几个特点:

  • Reactor模式中会通过分配适当的handler(处理程序)来响应IO事件,类似与AWT 事件处理线程

  • 每个handler执行非阻塞的操作,类似于AWT ActionListeners 事件监听

  • 通过将handler绑定到事件进行管理,类似与AWT addActionListener 添加事件监听

演化

传统方式

while (true) {
  socket = accept();
  handle(socket);
}
  • 如果 handle 方法没处理完, 则后面的连接没法接收处理
  • 于是出现了 Connection Per Thread

reactor 方式

Reactor 类似事件驱动的 dispatcher 分发器. 它有两个重要组件

  • Reactor : 负责查询 IO 事件. 然后发送到相应的 Handler 处理器处理.
  • Acceptor : 负责连接
  • IOHandler : IO 事件处理器. 完成真正的连接建立, 通道的读取, 业务的处理等.

单线程版本

SelectionKey 中的方法

  • attach(Object o) : 将 Handler 处理器, 设置到 SelectionKey 实例
  • Object attacment() : 获取 attach(Object o) 设置的 Handler

Reactor 与 Handler 在同一条线程中. 一般极少使用

多线程版本

  • 升级Handler , 改为用线程池执行
  • 如果是多核CPU, 将 reactor 也拆分为多个

资料参考