前言

一个输入操作通常包括两个阶段:

  • 等待数据准备好
  • 从内核缓冲区拷贝到应用进程缓冲区

对于一个IO套接字上的输入操作,第一步等待数据从网络中到达,然后放到对应某个Socket缓冲区;第二步把数据从内核缓冲区复制到应用进程缓冲区。

比如应用A向网络中的应用B发消息:

当应用A向应用B发送消息时,应用A需要将消息通过Socket发送到TCP发送缓冲区(应用A缓冲区copy到内核缓冲区),TCP发送缓冲区发出消息,经过网络传输到应用B的TCP接收缓冲区,应用B通过Socket从TCP缓冲区读取自己的消息(内存缓冲区copy到应用B缓冲区)。

理解五种IO模型,我们以应用B如何从内核缓冲区里获得数据来分析讨论?

五种IO模型

多种操作系统上都支持五种IO模型,包括有:

  • 阻塞式IO
  • 非阻塞式IO
  • 多路IO复用(select/poll)
  • 信号驱动式IO(SIGIO)
  • 异步IO(AIO)

阻塞式IO

应用进程调用读取数据方法时(写入同理),应用进程被阻塞,直到数据复制到应用进程缓冲区。阻塞期间,任务被挂起,不消耗CPU时间

以recvfrom api为例,用于接收Socket的数据,并复制到应用进程的缓冲区中:

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);

几点说明

  • 所谓阻塞应用进程,如果是线程级别的程序,对应的是阻塞线程。
  • 写入时,只阻塞从应用进程缓冲区拷贝数据到内核缓冲区完成,应用进程便返回了,什么时候发是TCP/IP协议栈的事情。
  • 所谓应用进程缓冲区,在java语言中,就是程序的内存区域,如创建的字节数组或者ByteBuffer。

在阻塞+线程池的模式下,线程池资源有限无法做到高并发,且建立连接后,如何客户端发送数据很慢,会导致线程一直read数据阻塞住,把线程池就拖垮了。

非阻塞式IO

应用进程执行系统调用后,如果没数据内核会返回一个错误码。此时,应用进程(线程)可以继续执行其它任务,但是需要通过轮询的方式不断的执行系统调用来判断是否有IO数据。

几点说明

  • 通过设置Socket对应的文件描述符为非阻塞进行实现。
  • 内核数据准备好后,进行系统调用也是会阻塞的,直到应用进程完成数据报拷贝。所谓非阻塞是在内核数据未准备好之前。
  • 此方式CPU需要处理更多的系统调用,需要CPU上下文切换,从用户态到内核态再到用户态,这种模式会比较耗CPU且低效。
  • 另外,轮询也是不释放CPU的,比较耗CPU。

多路IO复用(select/poll)

假设:如果一个web应用服务器没有用IO复用技术,那么每个Socket连接都需要创建一个线程来进行处理,那么在有几万个连接的场景中,就需要创建对应数量的线程,无疑对系统资源造成很大消耗。

所以,操作系统引入了select、poll,可以对多个套接字进行同时监听,这一过程是阻塞的,当有套接字可读时返回。再由进程调用recvfrom把数据从内核拷贝到应用进程中。

Java NIO中Selector的工作机制涉及到操作系统的IO多路复用(I/O multiplexing)功能。IO多路复用是现代操作系统提供的一种机制,允许单个线程监视多个IO源(如套接字、文件等)以检测它们的IO就绪状态。

它具有单个线程处理多个IO事件的能力,又被称为事件驱动IO。

非阻塞IO和多路IO复用在JDK1.4的Java NIO中都有所体现,只是它们的关注点不通。
非阻塞IO关注立即返回、轮询检查;多路复用关注单线程管理多IO、效率。

信号驱动式IO(SIGIO)

应用进程使用sigaction系统调用,内核不会阻塞进程,而是立即返回。等到数据准备好时再向应用进程发送SIGIO信号,应用进程收到之后在信号处理程序中调用recvfrom系统调用阻塞式获取数据。

几点说明

  • 此方式相比于非阻塞的IO操作,有个明显的好处,不用一直轮询,对CPU比较友好。
  • 相对于IO多路复用模型,支持异步通知方式,不阻塞进程。但是也有其局限性,需要编写信号处理器来处理信号且跨平台可能需要小心。

异步IO(AIO)

不管是IO多路复用还是信号驱动IO,虽然效率都有所改进,但是都需要进行两次调用,一次判断数据是否准备好,一次发起recvfrom系统调用获取数据。

所以,有了异步IO,应用进程发起一次系统调用立即返回,不阻塞,等内核数据准备好且从内核空间拷贝到用户空间,再通知用户进程,此时用户进程处理数据。

异步IO:进行 aio_read 系统调用,立即返回,内核完成数据从内核空间拷贝到用户空间拷贝后,发送信号给应用进程。

几点说明

  • 异步IO与信号驱动IO的区别在于:异步IO的信号是通知应用进程 IO 完成,而信号驱动IO的信号是通知应用进程可以开始IO了。这也是异步IO和其它IO的本质区别。
  • 在Windows上有操作系统命令实现了真正的异步IO(IOCP);linux或unix通过epoll实现了伪异步IO。

异步IO扩展
JDK1.7支持了NIO2.0,它属于API层面的异步,通过事件回调的方式,允许应用程序以非阻塞的方式进行操作文件或网络IO,这样可以做到平台无关,具体可以java.nio.channels.AsynchronousChannel。但是它有别于操作系统实现的底层异步IO,见上。

同步与异步

区别:

  • 同步IO:应用进程在调用recvfrom操作时会阻塞(前四种IO模型)。
  • 异步IO:全部过程,不会阻塞。

其中:
1、前四种IO模型都是同步IO,它们在调用recvfrom将数据从内核空间拷贝到用户空间时会阻塞。
2、非阻塞IO模型、信号驱动IO、异步IO在等待数据阶段不会阻塞。

应用

关于 select、poll、epoll,它们有各自的应用场景。

  • select:select参数精确度为1ns,而poll和epoll的精确度为1ms,select适合实时性 要求更高的场景;且select的移植性更好,几乎所有的主流平台都适用;有文件描述符数量限制,内核中FD_SETSIZE和系统中ulimit大小限制,默认最大值1024个。
  • pollpoll没有最大描述符的限制,如果平台支持poll且实时性要求没那么高的话可以用poll。
  • epoll:使用红黑树记录所有待检查的文件描述符,如果只需要允许在Linux平台上,且有非常大量的描述符需要同时轮询,可以使用epoll;如果描述符不多,没有必要用epoll;epoll的描述符在内核中,不用内核空间和用户空间来回拷贝全部文件描述符,内核中还有个链表记录就绪事件,只需要将就绪事件返回到用户空间;但是内核中不易调试。

调用对应的方法都会阻塞,事件监听交由操作系统内核完成,而后进行唤醒或通知。

下图展示了epoll的各函数的作用:
image-1700277171402

总结

除了异步IO,其它四种IO的区别在于第一阶段,第二阶段都是相同的:将数据从内核空间拷贝到用户空间,此过程应用进程(线程)会阻塞。所以它们也是同步IO。

区别如下图所示:
五个IO模型比较