2024-04-04
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 本文链接:https://www.skjava.com/mianshi/baodian/detail/7030053551

回答

I/O 模型决定了数据如何从输入源(如网络)传输到应用程序,或者如何从应用程序传输到输出目的地。在《UNIX网络编程》中,有 5 种 I/O 模型:

  • 阻塞I/O模型
    1. 应用程序发起一个 I/O 操作后,将一直等待直到操作完成。在这个过程中,应用程序被阻塞,不能执行其他任务。
    2. 在简单应用中易于理解和实现,但是在高并发场景下效率较低,不推荐。
  • 非阻塞I/O模型
    • 应用程序发起一个 I/O 操作后不会被阻塞,而是立即返回一个状态。应用程序需要不断地检查这个 I/O 操作是否完成(通常通过轮询实现)。
    • 需要应用程序不断轮询检查状态,增加 CPU 的负担。
  • I/O复用模型
    • 利用 select、poll、epoll 等机制,允许单个线程同时监视多个文件描述符(FD)。当某个 FD 准备就绪(可读、可写、异常等),相应的操作可以进行,无需阻塞等待其他 FD。
    • 目前高并发网络编程中最常用的模型,可以有效地支持高并发场景。
  • 信号驱动I/O模型
    • 应用程序告诉内核启动一个操作,并让内核在 I/O 操作真正可执行时通过信号通知它。应用程序在等待信号时不被阻塞。
  • 异步I/O模型
    • 应用程序发起一个 I/O 操作后立即返回,继续执行其他任务。当 I/O 操作真正完成时,应用程序会收到一个通知。
    • 理论上提高最高的性能,但是实现的复杂性比较高,而且并不是所有平台都能够有效地支持异步 I/O。

详解

操作系统为了能够以一种安全的方式持续运行,它是不允许应用程序随意地访问计算机硬件部分的,比如内存、硬盘、网卡等等。如果应用程序要访问硬件部分,不如读取磁盘上面的文件,就必须要通过操作系统提供的API来访问,以达到安全的访问控制。所以,I/O 对应用程序而言,强调的则是通过向内核发起系统调用完成对I/O的间接访问。图例如下:

所以,应用程序发起一次IO访问是分为两个阶段的:

  1. IO 调用阶段:应用程序向内核发起系统调用。
  2. IO执行阶段:内核执行IO操作并返回。
    1. 数据准备阶段:内核等待IO设备准备好数据
    2. 数据拷贝阶段:将数据从内核缓冲区拷贝到用户空间缓冲区

在后面描述的阻塞与非阻塞都是基于这两个阶段

阻塞I/O模型

阻塞 I/O 是最基本、最传统的 I/O 模型。在该模型中,当应用程序发起一个 I/O 操作时,它将会一直等待操作系统完成这个操作。在这个等待过程中,应用程序被“阻塞”,即它不能执行其他任何任务或处理其他 I/O 请求,只能在这里干等着。模型图例:

应用程序发起一个系统调用(recvform),这个时候应用程序会一直阻塞下去,直到内核把数据准备好,并将其从内核复制到用户空间,复制完成后返回成功提示,这个时候应用程序才会继续处理数据。

所以,阻塞IO模型在IO两个阶段都会阻塞

在 Java 中,传统的 I/O 操作(如 java.io.InputStreamjava.io.OutputStream)是基于阻塞 I/O 模型的,当我们从 InputStream 读取数据或向 OutputStream 写入数据时,相关的方法调用会一直阻塞,直到操作完成。如下代码:

InputStream in = new FileInputStream("example.txt");
byte[] buffer = new byte[1024];
int bytesRead;

// 以下循环会在每次 read() 调用时阻塞,直到有数据可读
while ((bytesRead = in.read(buffer)) != -1) {
    // 处理读取到的数据
}

in.close();

它的优点是简单且容易实现,但是它整个过程都是阻塞的,进程一直挂起,程序性能较为低,不适用并发大的应用。

场景:某天,你跟你女朋友(假如你有女朋友)去饭店吃饭,点完餐后,你就做坐那里一直等菜做好后,吃饱喝足才离开。这期间你和你女朋友由于担心不知道菜什么时候才能做好,所以这个期间你们就只能一直在座位上面等着,什么时候也不能干。

非阻塞 IO模型

非阻塞 I/O 模型,I/O 请求的调用会立即返回一个状态,而不是等到操作完全完成。程序继续运行,同时不断地“轮询”检查 I/O 操作是否已完成。图例如下:

应用程序发起recvform系统调用,如果数据报没有准备会则会立即返回一个EWOULDBLOCK错误码,进程并不需要进行等待。进程收到该错误后,判断内核数据还没有准备好,它还可以继续发送 recvform,如果数据报已经准备好了,待数据从内核拷贝到用户空间返回成功指示后,进程则可以处理数据报了。

非阻塞 I/O 模型不会阻塞程序的执行,但是它需要程序通过轮询的方式显示地检查 I/O 操作是否已完成,这个轮询的过程是需要消耗 CPU 资源的。该模型与阻塞 I/O 一样,实现稍微简单些,也不适用于高并发的业务场景。

在 Java NIO 中,它提供了非阻塞 I/O 的支持,如下:

ServerSocketChannel serverChannel = ServerSocketChannel.open();
// 非阻塞
serverChannel.configureBlocking(false);

场景:一个星期后,你跟你女朋友还是去那家餐厅吃饭,点完菜后,你女朋友吸取上次教训,知道要在这里干等,所以还不如去逛逛,买点香水口红啥的。但是呢,由于你们担心会错过上菜,所以你们就每隔一段时间就来问下服务员,你们的菜准备好了没有,来来回回好多回,若干次后,终于问到菜已经准备好了,然后你们就开心的吃起来。

I/O复用模型

在 I/O 复用模型中,应用程序使用一种机制(select、poll或epoll)来监测多个I/O通道,当至少一个I/O通道准备好进行I/O操作(如读取或写入)时,它就通知应用程序。图例如下:

多个进程的I/O注册到一个复用器(select)上,然后用一个进程监听该 select,select 会监听所有注册进来的I/O。如果内核的数据报没有准备好,select 调用进程会被阻塞,而当任一I/O在内核缓冲区中有数据,select调用就会返回可读条件,然后进程再进行recvform系统调用,内核将数据拷贝到用户空间,注意这个过程是阻塞的。

Java NIO 中的 Selector 是 Java NIO 的核心,它允许一个单独的线程监控多个通道的I/O事件。一个Selector实例可以同时检测多个通道上的数据可读、可写、连接就绪等事件。这就是I/O用的体现。

在实际的Java NIO 开发中,我们通常会创建一个Selector,并将多个SelectableChannel(例如SocketChannel)注册到这个选择器上。每个通道都会指定感兴趣的事件(如读、写、连接等)。然后,程序通过循环调用select()方法来检测哪些通道准备好了对应的事件。当select()方法返回后,程序会迭代已选择的键集(selected key set),并根据每个键(Key)的状态执行相应的IO操作。如下:

public class NioServerExample {
    public static void main(String[] args) throws Exception {
        // 创建Selector和ServerSocketChannel
        Selector selector = Selector.open();
        ServerSocketChannel serverSocket = ServerSocketChannel.open();

        // 绑定端口,并设置为非阻塞模式
        serverSocket.bind(new InetSocketAddress(8080));
        serverSocket.configureBlocking(false);

        // 将ServerSocketChannel注册到Selector,关注OP_ACCEPT事件
        serverSocket.register(selector, SelectionKey.OP_ACCEPT);

        // 创建一个Buffer用于数据读取
        ByteBuffer buffer = ByteBuffer.allocate(256);

        while (true) {
            // 阻塞等待需要处理的事件
            selector.select();

            // 获取所有接收事件的SelectionKey实例
            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> keyIterator = selectedKeys.iterator();

            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();

                if (key.isAcceptable()) {
                    // 接受客户端连接
                    SocketChannel client = serverSocket.accept();
                    client.configureBlocking(false);

                    // 注册读事件
                    client.register(selector, SelectionKey.OP_READ);
                } else if (key.isReadable()) {
                    // 读取数据
                    //....
                }
                keyIterator.remove();
            }
        }
    }
}

场景:还是那家餐厅,开始的时候,大家都是在那里等,服务员只需要等菜做好,端上来就可以了,某天有些小伙伴发现你跟你女朋友竟然利用等菜的空闲时间去逛街(虽然累,但好歹也买了几件东西对吧),然后他们也采用了这种方式,这个时候服务员就受不了了,你们隔一段时间就来问,隔一段时间就来问,烦都烦死了,于是他想了一个办法,说,你们派一个人来问就可以了,我这边做了由他来告诉你们菜是否已经做了好。

关于 I/O 多路复用,可以阅读这篇文章:请说下你对 Netty 中Reactor 模式的理解

信号驱动IO模型

模型图例如下:

在信号驱动IO模型中,应用程序首先会设置一个信号处理器。这就是在告诉操作系统,当 I/O 操作准备好执行时,操作系统应该向应用程序发送一个特定的信号。应用程序发起一个非阻塞的IO操作(调用 sigaction),非阻塞意味着立刻返回,所以应用程序发送完请求后就可以继续执行其他任务。

一旦 I/O 操作准备好后,操作就会向应用程序发送一个信号(SIGIO),应用程序预先定义的信号处理函数随后被调用。这个处理函数通常会执行实际的IO操作。

该模型在等待数据阶段是不会阻塞的,适用于高并发场景。但是它的模型比较复杂,实现起来有困难,同时不同操作系统对信号的处理可能有所不同,这可能会导致应用程序在不同系统间移植时遇到问题。

场景:有人帮你问,其实也不是那么好,因为你还是要等他来告诉你,而且他是只要你们当中有一个人的菜做好了就告诉你们所有人。于是,你们又想了一种方案,我点完菜后,我告诉服务员,我留我的微信在你这里,菜做好后,你告诉我就可以了。这样你女票就可以利用这个空余时间逛更久了。

异步IO模型

异步IO模型是一个完完全全的异步操作了。当应用程序发起一个 I/O 请求后,可以立即继续执行其他任务,而不需要等待IO操作的完成。当IO操作实际完成时,应用程序会收到通知。图例如下:

该模型整个过程完全异步,不阻塞,能够更好地利用系统资源,提供了程序的整体效率,但是它的模型复杂,实现难度大。

场景:虽然留电话的方式不错,你只需要留一个微信就可以了,瞬间解放了,后面你又发现了一个问题,你到了餐厅后,还不能立刻吃饭,因为他们还要上菜,这个过程你还是要等,如果你到店后立刻就可以吃难道不是更爽么?所以你服务员沟通说,你做好菜后,直接上,完成后再微信通知你,你到店后就直接吃了。这样你是不是更加爽了?

最后

五种IO模型,层层递进,一个比一个性能高,当然模型的复杂度也一个比一个复杂。最后用一张图来总结下:

阅读全文