大白话详解5种网络IO模型
1 前言
我们都知道,为了实现高性能的通信服务器,BIO在高并发的情况下会出现性能急剧下降的问题,甚至会由于创建过多线程而导致系统OOM。因此在Java业界,BIO的性能问题一直被开发者所诟病,所幸的是,JDK1.4推出了NIO,NIO基本解决了BIO的性能问题,是目前实现Java高性能服务器的基础框架。NIO官方的叫法叫做New IO,而对应于操作系统层面来说其实也是Non-Blocking IO。
大名鼎鼎的Netty就是NIO框架,而目前很多开源框架比如Dubbo,RocketMQ,Seata,Spark,Flink都是采用Netty作为基础通信组件。因此,学好Netty很重要,但是NIO作为Netty的基础,这里想说的是学好NIO也一样重要!
学好NIO,那么必须先理解操作系统层面的5种网络IO模型。
2 5种IO模型
2.1 阻塞IO模型
阻塞IO模型如下图:从上图可以看到,不管有无数据报到来,进程(线程)是阻塞于recvfrom
系统调用的。这是什么意思呢?说白了就是假如我们要用套接字读取数据,此时我们必然会调用read
方法,此时这个read
方法就会触发操作系统内核的一次recvfrom
系统调用,此时有两种情况:
-
内核还未接收到远端数据,此时数据报没有准备好,那么读取数据的线程就会一直阻塞,直到远端发来数据报,这一阻塞的过程对应上图序号1的过程;然后在数据报被从内核复制到用户空间这一过程中,该线程会再次阻塞,直到复制完成,这一过程对应上图的序号2的过程; -
内核已经接收到远端数据,此时数据报已经准备好,那么数据报就会被从内核复制到用户空间,这一过程是阻塞的,对应上图序号2的过程。
可见,阻塞IO模型的话,读一次数据会发生一次recvfrom
系统调用,整个过程都是阻塞的,即在内核的数据报还未准备好的时候,此时用户进程( 线程)阻塞;当内核的数据报准备好的时候,此时数据报要从内核拷贝到用户空间,此时用户进程(线程)也一直阻塞;直到数据报拷贝到用户空间后,此时用户进程(线程)才会醒过来,然后处理这些数据报即执行一些用户的业务逻辑。当然,如果用户进程(线程)在阻塞过程中,如果recvfrom
系统调用被信号中断,此时阻塞也是会被唤醒的。
思考: 这里的
recvfrom
系统调用被信号中断什么情况下会发生?这个信号中断指的是线程中断(Thread.interrupt()
)么?自行思考。
2.2 非阻塞IO模型
非阻塞IO模型如下图:如上图,根据内核中的数据报有无准备好,有以下两种情形:
-
当内核中的数据报还没准备好,此时 recvfrom
系统调用立即返回一个EWOULDBLOCK
错误,即不会将用户进程(线程)至于阻塞状态。我们拿Java的NIO来说,当我们配置ServerSocketChannel.configureBlocking(false);
或SocketChannel..configureBlocking(false);
时,我们调用ServerSocketChannel.accept()
的null
或SocketChannel.read(buffer)
不会阻塞的,若没有新连接接入或内核中没有数据报准备好,此时会理解返回null
或 0的返回结果,说白了这个返回结果就是对应EWOULDBLOCK
错误; -
当内核中的数据报已经准备好时,此时 recvfrom
系统调用,用户进程(线程)还是会阻塞,直到内核中的数据报已经拷贝到了用户空间,此时用户进程(线程)才会被唤醒来处理接收的数据报。
非阻塞IO在用户数据报还没准备好的时候,recvfrom
系统调用不会阻塞,接着会继续进行下一轮的recvfrom
系统调用看数据报有无准备好,周而复始,进程(线程)不断轮训,因此这是非常耗费CPU的。这种模型不是很常用,适合用在某台CPU专为某些功能准备的场合。
2.3 IO复用模型
IO复用模型如下图:初步从以上IO复用模型来看,这不是跟IO阻塞模型差不多么?当内核无数据报准备好时,select
系统调用会阻塞;当内核数据拷贝到用户空间时,此时recvfrom
系统调用依然会阻塞,实在是看不到跟IO阻塞模型有啥区别?区别就是IO复用模型还比阻塞IO模型还多一次recvfrom
系统调用,这不是明摆着多浪费一次CPU资源么?
如果我们这么想,那为什么IO复用模型得到大规模广泛应用呢?其实IO复用模型真正占优势的地方在于select
操作,这个select
操作可以选择多个文件描述符,分别对应Java NIO中的OP_CONNECT
,OP_ACCEPT
,OP_READ
和OP_WRITE
就绪事件。正是基于一次recvfrom系统调用中一个线程的select操作可以选择多个文件描述符这个功能,我们现在用一个用户线程就能监听不同channel
的OP_CONNECT
,OP_ACCEPT
,OP_READ
和OP_WRITE
这些就绪事件,然后根据某个就绪事件拿到相应的channel
来做对应的操作。而不用像阻塞IO模型或非阻塞IO模型那样,一次recvfrom系统调用中一个线程就只能选择一个文件描述符,这样就严重限制了伸缩性。这么说很抽象,就比如拿阻塞IO模型来说,由于用户进程(线程)每一次recvfrom
系统调用都是阻塞且只对应一个文件描述符,此时如果服务端线程阻塞于客户端A的读操作时,如果有另外的客户端B需要接入服务端,此时服务端线程由于阻塞于客户端A的读操作,因此无法处理客户端B的连接操作。此时,必然要一个线程一个文件描述符即服务端线程每accept
了一个客户端连接,此时就需要新建一个线程去处理这个客户端连接的读写操作。我们都知道,线程是一种很昂贵的CPU资源,当开启成千上万的线程后,线程切换的成本很高,CPU性能肯定下降,说不定高并发下还会OOM。说到这里,也许有同学会说,对于阻塞IO模型,我们不一个线程一个socket,用线程池替代,当然,这是一个优化的点,但没解决阻塞IO模型的根本。怎么说呢?当线程池的所有线程都阻塞于客户端的读或写操作时,此时其他新接入的线程将会积压在线程池的队列中阻塞等待。
2.4 信号驱动IO模型
信号驱动IO模型如下图:可见,信号驱动IO模型在等待数据报期间是不会阻塞的,即用户进程(线程)发送一个sigaction
系统调用后,此时立刻返回,并不会阻塞,然后用户进程(线程)继续执行;当数据报准备好时,此时内核就为该进程(线程)产生一个SIGIO
信号,此时该进程(线程)就发生一次recvfrom
系统调用将数据报从内核复制到用户空间,注意,这个阶段是阻塞的。
PS: 网上找了下信号驱动IO模型的java代码,没找到,会码信号驱动IO模型代码的下伙伴们可以教教我。
2.5 异步IO模型
异步IO模型如下图:异步IO模型也很好理解,即用户进程(线程)在等待数据报和数据报从内核拷贝到用户空间这两阶段都是非阻塞的,即用户进程(线程)发生一次系统调用后,立即返回,然后该用户进程(线程)继续往下执行。当内核把接收到数据报并把数据报拷贝到了用户空间后,此时再通知用户进程(线程)来处理用户空间的数据报。也就是说,这一些列IO操作都交给了内核去处理了,用户进程无须同步阻塞,因此是异步非阻塞的。
扩展: 异步IO模型跟信号驱动IO模型的区别在于当内核准备好数据报后,对于信号驱动IO模型,此时内核会通知用户进程说数据报准备好啦,你需要发起系统调用来将数据报从内核拷贝到用户空间,此过程是同步阻塞的;而对于异步IO模型,当数据报准备好时,内核不会再通知用户进程,而是自己默默将数据报从内核拷贝到用户空间后然后再通知用户进程说,数据已经拷贝到用户空间啦,你直接进行业务逻辑处理就行。
3 各种IO模型区别
通过5种IO模型的比对,可以发现,前4种IO模型都是同步阻塞IO模型,因为其第二阶段数据报从内核拷贝到用户空间都是同步阻塞的,只是第一阶段等待数据报的处理不同;最后一种IO模型(异步IO模型)才是真正的异步非阻塞IO模型,内核将一切事情都干完(内核:我真的好累)。
4 总结
好了,五种IO模型基本就已经总结完了,基本是自己基于《UNIX网络编程_卷1_套接字》的读书总结,接下来再通过java代码将这几种IO模型实现一遍。
参考:《UNIX网络编程_卷1_套接字》