IO那些事

IO(Input\Output): 即输入输出,通常指数据在存储器(内部和外部)或其他周边设备之间的输入和输出,是信息处理系统(例如计算机)与外部世界(可能是人类或另一信息处理系统)之间的通信。说的简单点就是与外部设备(比如磁盘)传输数据

IO大致可以分为磁盘IO网络IO内存IO。通常所说的IO指的是前两者。本文将简单介绍Linux的五大IO模型java中的IO模型,并对java的NIO做一个基本介绍。

IO基本流程

外围设备的直接读写涉及到中断,中断时需要保存进程数据、状态等信息、中断结束后需要恢复进程数据和状态,这种成本是比较高的。因此出现了一个叫内核缓冲区(位于内核空间)的东西,我们的程序并不是直接与IO设备交互的,而是与这个内核缓冲区交互

IO基本过程

io流程示意图

如图所示,读的时候,先将数据从磁盘或者网卡拷贝到内核缓冲区(这一步是操作系统内核通过读中断完成的),然后从内核缓冲区拷贝到进程缓冲区(位于用户空间)

写的时候,先将数据写到进程缓冲区,然后拷贝到内核缓冲区,然后写到网卡或者刷到磁盘(这一步是通过写中断完成的)。

读中断和写中断何时进行是内核决定的,大多数的IO操作并没有实际的IO,而是在进程缓冲区与内核缓冲区来回拷贝数据。

一个完整的读流程包括两个阶段:

  1. 准备数据:将数据从网卡拷贝到内核缓冲区
  2. 拷贝数据:将数据从内核缓冲区复制到进程缓冲区

两个重要的名词

  • 同步与异步:同步就是用户空间是发起IO的一方,异步是内核空间是发起IO的一方。也可以理解为同步就是自己要去查IO状态,异步是内核可以通知你
  • 阻塞与非阻塞:阻塞就是当你调用了一个IO读或者写时,需要等内核操作彻底(准备与拷贝数据)完成后才能返回,这一段时间用户空间程序是“卡住的状态”;非阻塞就是,调用了一个读或写时不管内核有没有操作完成,都会立即返回。

五大IO模型

同步阻塞

同步阻塞

同步阻塞IO模型

这个模型印证了上述对同步与异步、阻塞与非阻塞的解释。内核准备和拷贝数据的过程中,用户空间程序一直阻塞,所以是阻塞;用户空间是发起io的一方,所以是同步。

同步非阻塞

同步非阻塞

同步非阻塞IO模型

同步非阻塞的特点就是在数据准备阶段发起io调用会立即返回一个错误,用户空间需要轮询发起IO调用。在数据从内核缓冲区拷贝到进程缓冲区阶段的调用仍然是会被阻塞的。这种模型需要一直轮询IO状态,用的比较少。

IO多路复用

IO多路复用

IO多路复用模型

在IO多路复用模型中,引入了一种新的系统调用查询IO的就绪状态。在Linux系统中,对应的系统调用为select/epoll系统调用。通过该系统调用,一个进程可以监视多个文件描述符一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核能够将就绪的状态返回给应用程序。随后,应用程序根据就绪的状态,进行相应的IO系统调用。

————来自《Netty、Redis、Zookeeper高并发实战》

相比于同步阻塞模型,这种模型的优势在于一个线程能处理大量的IO连接,而同步阻塞只能靠开很多线程来处理多个IO连接,对于大量的IO连接无能为力。

如果连接数少的话,同步阻塞并不一定比IO多路复用性能差,因为IO多路复用有两个系统调用,同步阻塞只有一个。

信号驱动

信号驱动IO

信号驱动IO模型

这种IO模型用的不多,java里边找不到对应实现。信号驱动式模型的一个显著特点就是用户态进程不再等待内核态的数据准备好,直接可以去做别的事情。但是等待数据从内核缓冲区拷贝到进程缓冲区仍然是阻塞的。

异步IO(AIO)

异步IO

异步IO模型

上述几种IO模型本质上都是同步IO,就算是信号驱动,他在数据从内核缓冲区拷贝到进程缓冲区也是阻塞的。

AIO的基本流程是:用户线程通过系统调用,向内核注册某个IO操作。内核在整个IO操作(包括数据准备、数据复制)完成后,通知用户程序,用户执行后续的业务操作.

这种IO模型是完美的IO模型,但是据说Linux支持的不太好。大名鼎鼎的netty也是使用的多路复用IO模型,还没有使用AIO。

java中的IO

BIO

BIO就是Blocking IO, 对应上面说的同步阻塞IO模型。我们常使用的各种InputStream, 这种Reader,以及在网络编程用到的ServerSocket/Socket都是BIO。以一个Socket程序为例来直观感受一下这种模型。

BIO-server

BIO-server

![BIO-client

BIO-client

这两段代码分别展示一个tcp服务端和客户端,实现的功能就是客户端从本地读一个文件发送给服务端,服务端将收到的文件写入磁盘。

服务端的read方法的调用是阻塞的,这意味着这个服务端同一时刻只能处理一个连接,这显然不合理,为了解决这个问题,我们可以考虑多线程机制,主线程只负责接受连接,收到连接就丢进其他线程进行处理,可以每次都开一个线程,也可以考虑使用线程池。如下的代码实现了这个想法。

BIO-thread

BIO的多线程版本

NIO

NIO,可以说是java中的新IO(New IO), 也可以叫None-Blocking IO, 他对应的是前文提到的多路复用IO模型

NIO包括三个核心成员,Buffer、Channel、Selector, 后文会做详细介绍。

这里简单对比一下NIO和BIO:

NIO BIO
面向缓冲区 面向流
非阻塞 阻塞
基于通道的双向数据流 单向数据流
有Selector的概念

上边BIO的例子可以看到BIO是面向流的,NIO是面向缓冲区的,可以任务他的数据是一块一块的,通过后文的例子可以更清楚的看到这一点。

BIO都是阻塞的,也是就内核在准备数据拷贝数据阶段,用户空间发起IO的进程没法干别的事。NIO是可以是非阻塞的,他可以通过注册你感兴趣的事件(比如可读)到Selector中,然后干别的事(比如接收新的连接),当收到相应事件后再做处理。

NIO有一个通道的概念,既可以向通道里写数据也可以从里边读。但是BIO就不行,只能从输入流里边读数据,不能写;也只能往输出流写数据,而不能从里边读。

AIO

对应前文提到的异步IO模型,这种模型支持不太好,JAVA AIO框架在windows下使用windows IOCP技术,在Linux下使用epoll多路复用IO技术模拟异步IO。鼎鼎大名的netty也没有使用AIO,所以这里也不去深入探究了。

NIO基础详解

Buffer

Buffer是一个抽象类,可以认为是一个装数据的容器,底层是数组。他有很多子类:

例如:

  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

使用最多的是ByteBuffer

Buffer的基本结构如下:

Buffer的结构

Buffer的结构

这几个属性的含义是必须要搞清楚的,这里简单列举,后文讨论Buffer的基本操作会做进一步说明。

  • position: 表示当前正在读的位置
  • limit: 表示可以读取或者写入的上限位置,只有小于这个值的位置才是有效的
  • capacity: 容量,不是字节数,而是能装几个数据,与每个数据占用的字节数无关,创建时确定,不能再改变
  • mark: 一个标记位置,可以方便的回到这个位置

buffer的基本操作:

  • put(): 向缓冲区存数据
  • get(): 从缓冲区取数据
  • flip(): 切换到读取数据的模式
  • rewind():position回到起始位置,可以重复读
  • clear(): 清空缓冲区,但是数据仍然存在,limit,position回到最初状态
  • hasRemaining():判断是否还有数据可以读
  • remaining():剩余几个数据可以读
  • mark():标记当前操作的位置
  • reset(): 回到之前标记的位置

我们直接通过一个demo来说明这些操作:

测试Buffer

Buffer的基本操作

输出如下:

创建后:
position=0,capacity=10,limit=10
写入一个数据后:
position=2,capacity=10,limit=10
切换为读模式后:
position=0,capacity=10,limit=2
读取一个数据:1
position=1,capacity=10,limit=2
调用rewind:
position=0,capacity=10,limit=2
再次读一个数据:
position=1,capacity=10,limit=2
调用Buffer.clear后
position=0,capacity=10,limit=10

通过这个测试可以看出各种操作的基本使用及其对Buffer几个属性的影响。

直接缓冲区与非直接缓冲区:

  • 非直接缓冲区:通过allocate()分配的缓冲区,将缓冲区建立在jvm的内存中
  • 直接缓冲区:通过allocateDirect()分配的缓冲区,将缓冲区建立在物理内存中,zero copy
  • 可以通过isDirect()判断是否是直接缓冲区

Channel

NIO中的一个连接用一个通道表示,通道本身并不存放数据,只能与Buffer交互。

常见的通道:

  1. FileChannel: 用于读写文件的通道
  2. SocketChannel:用于Socket套接字TCP连接的数据读写
  3. ServerSocketChannel:允许我们监听TCP连接请求,为每个监听到的请求,创建一个SocketChannel套接字通道
  4. DatagramChannel:用于UDP协议的数据读写

通道的获取方法:

  1. 通过支持通道的类的getChannel方法

本地io:

  • FileInputStream
  • FileOutputStream
  • RandomAccessFile
fileInputStream.getChannel();

网络io:

  • Socket
  • ServerSocket
  • DatagramSocket
socket.getChannel();
  1. 使用各个通道的静态方法open()获取,jdk>=1.7
FileChannel fileChannel = FileChannel.open(Paths.get("a.jpg"), StandardOpenOption.READ);
  1. 使用Files的newByteChannel()获取,jdk>=1.7
SeekableByteChannel byteChannel = Files.newByteChannel(Paths.get("a.jpg"), StandardOpenOption.WRITE);

通道的基本操作

  1. 读:将通道里的数据读到buffer里,返回值表示读取到的数据个数,返回0表示没有了。此方法还有几个重载
public int read(ByteBuffer dst) throws IOException
  1. 写: 将buffer写入通道,也有几个重载
 public int write(ByteBuffer src) throws IOException
  1. 获取当前通道的大小,单位byte
public abstract long size() throws IOException
  1. 将一个通道的数据发送到另一个通道
public long transferTo(long position, long count,
                                    WritableByteChannel target)
        throws IOException;
  1. 上述反向
public long transferFrom(ReadableByteChannel src,
                                      long position, long count)
        throws IOException;
  1. 关闭通道
public final void close() throws IOException

此外还有内存映射文件、锁相关内容。限于篇幅,此处不再展开,之后可能专门写一篇探讨。

Selector

我们可以将一个通道注册到Selector中,并且指定你感兴趣的事件(可以是多个,中间用|)。通过不断调用select选择IO就绪事件,在发生相应事件时会得到一个通知,做后续处理。

选择器的使命是完成IO的多路复用。一个通道代表一条连接通路,通过选择器可以同时监控多个通道的IO(输入输出)状况。选择器和通道的关系,是监控和被监控的关系。

这里还涉及到SelectionKey的概念,SelectionKey选择键就是那些被选择器选中的IO事件。

主要方法:

  1. 打开一个Selector
public static Selector open() throws IOException
  1. 获取SelectionKey
public Set<SelectionKey> selectedKeys();
  1. 选择感兴趣的IO就绪事件
1. public int select(long timeout)
        throws IOException;
2. public int select() throws IOException;
  1. 关闭Selector
public void close() throws IOException;

NIO涉及的概念和API较多,下面通过一个具体的例子简单演示(移除了异常处理、关闭通道或连接的操作)

IO事件:

  • (1)可读:SelectionKey.OP_READ
  • (2)可写:SelectionKey.OP_WRITE
  • (3)连接:SelectionKey.OP_CONNECT
  • (4)接收:SelectionKey.OP_ACCEPT

并不是所有Channel都支持这几个事件,例如ServerSocketChannel只支持OP_ACCEPT

一个NIO传文件的例子

/**
    * 移除了一些关闭通道的代码,可能无法运行
    * 正常应该在try finally关闭, 或者使用try with resources语法自动关闭
    * @throws IOException
    */
@Test
public void server() throws IOException {
    // 获得channel
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    // 绑定端口
    serverSocketChannel.bind(new InetSocketAddress(1234));
    // 设置为非阻塞,这很重要!!!
    serverSocketChannel.configureBlocking(false);
    // 打开Selector
    Selector selector = Selector.open();
    // 将通道注册到Selector
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    int i = 0;
    while (selector.select() > 0) { // 轮询选择感兴趣的io事件
        // 拿到选择键
        Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
        while (iterator.hasNext()) { // 遍历选择键,对特定时间做处理, 可以单独去开线程处理
            SelectionKey key = iterator.next();
            if (key.isAcceptable()) { // 处理接收事件
                ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
                SelectableChannel channel = serverChannel.accept();
                channel.configureBlocking(false);
                // 将客户端连接的SocketChannel也进行注册
                channel.register(selector, SelectionKey.OP_READ);
            } else if (key.isReadable()) { // 处理读事件
                ByteBuffer buffer = ByteBuffer.allocate(1 * mb);
                SocketChannel clientChannel = (SocketChannel) key.channel();
                FileChannel fileChannel = FileChannel.open(Paths.get(path, "qrcode" + (++i) + ".png"),
                        StandardOpenOption.WRITE, StandardOpenOption.CREATE);
                int len = -1;
                while ((len = clientChannel.read(buffer)) > 0) {
                    buffer.flip(); // 切换到读模式
                    fileChannel.write(buffer);
                    buffer.clear(); // 切回写模式,别忘了!!
                }
                clientChannel.close();
                fileChannel.close();
            }
            // 处理过的事件一定要移除
            iterator.remove();
        }
    }
}

@Test
public void client() throws IOException {
    // 获取channel
    SocketChannel socketChannel = SocketChannel.open();
    // 连接
    socketChannel.connect(new InetSocketAddress(1234));
    // 设置非阻塞
    socketChannel.configureBlocking(false);
    // 开选择器
    Selector selector = Selector.open();
    // 将channel注册进选择器
    socketChannel.register(selector, SelectionKey.OP_WRITE);
    while (selector.select() > 0) { // 选择感兴趣的事件
        Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
        while (iterator.hasNext()) {
            SelectionKey key = iterator.next();
            SocketChannel channel = (SocketChannel) key.channel();
            if (key.isWritable()) { // 处理可写事件
                FileChannel fileChannel = FileChannel.open(Paths.get(path, "qrcode.png"), StandardOpenOption.READ);
                ByteBuffer byteBuffer = ByteBuffer.allocate(1 * mb);
                int len = -1;
                while ((len = fileChannel.read(byteBuffer)) > 0) {
                    byteBuffer.flip();
                    channel.write(byteBuffer);
                    byteBuffer.clear();
                }
            }
        }
    }
}

NIO使用步骤总结

  1. 获取Channel
  2. 打开Selector
  3. 将channel注册到Selector
  4. 轮询感兴趣的事件
  5. 遍历SelectionKey并最不同事件类型做相应处理

NIO的难度确实比BIO高不少,而且上述只是一个简单的例子,而且可能存在问题,实际中会比这里复杂的多,比如粘包拆包、序列化之类的问题。正因如此,才有了Netty,Netty有非常广泛的应用,比如Dubbo底层、RocketMQ等等。Netty是后边需要和大家一起研究的话题。

小结

本文介绍了5种IO模型,同步阻塞、同步非阻塞、多路复用、信号驱动、异步;然后介绍了java中的三种IO模型;最后对NIO的基础支持点做了简单介绍。期望能帮助你复习或者了解相关知识点,疏漏之处,请不吝指出。IO之路,道阻且长,加油~

IO小结

参考资料

二维码