IO模型

IO指的是什么?

IO指的是输入和输出,通常指数据在内部存储器和外部存储器或其它周边设备之间的输入和输出。从硬盘中读写数据或者从网络上收发数据,都属于IO行为。

同步与异步:同步IO,是一种用户空间与内核空间的IO发起方式。同步IO是指用户空间的线程是主动发起IO请求的一方,内核空间是被动接受方。异步IO则反过来,是指系统内核是主动发起IO请求的一方,用户空间的线程是被动接受方。

同步与异步

同步/异步IO:同步IO的发起方(用户线程)会阻塞或轮询等待IO完成,而异步则是在发起IO请求后立即返回继续执行后面的代码。

阻塞/非阻塞IO:阻塞IO:用户线程发起IO请求并阻塞用户线程释放CPU执行权,等待内核态的IO处理完成;非阻塞IO:用户线程发起IO请求会立即返回并处理后面的代码,但是会有线程以轮询的方式查询内核态的IO是否处理完成,如果IO完成则立即拷贝到用户进程,这种方式对CPU资源消耗较高。阻塞与否指的时用户线程是否被挂起。

用户空间与内核空间

  • 硬件层:包括和我们熟知的和IO相关的CPU、内存、磁盘和网卡几个硬件;
  • 内核空间:计算机开机后首先会运行内核程序,内核程序占用的一块私有的空间就是内核空间,并且可支持访问CPU所有的指令集(ring0-ring3)以及所有的内存空间、IO及硬件设备;
  • 每个普通的用户进程都有一个单独的用户空间,用户空间智能访问受限的资源(CPU的“保护模式”)也就是说用户空间是无法直接操作像内存、网卡和磁盘等硬件的。

用户空间与内核空间

操作系统在内核中开辟了一块唯一且合法的系统调用,系统调用为上层用户提供了一组能够操作底层硬件的API。用户进程就可以通过系统调用访问到操作系统内核,进而就能够间接地完成对底层硬件的操作。这个访问的过程就是用户态到内核态的切换。

BIO(Blocking IO:同步阻塞IO)

需要内核IO操作彻底完成后,才返回到用户空间执行用户的操作。阻塞指的是用户空间程序的执行状态。传统的IO模型都是同步阻塞IO。在Java中,默认创建的socket都是阻塞的。

阻塞IO的优缺点:

一般情况下,会为每一个连接配备一个独立的线程;反过来说,就是一个线程维护一个连接的IO操作。在并发量小的情况下,这样做没有什么问题。但是,当在高并发的应用场景下,需要大量的线程来维护大量的网络连接,内存、线程切换开销会非常巨大。因此,基本上阻塞IO模型在高并发应用场景下是不可用的。

  • 严重依赖线程,线程还是比较耗系统资源的(一个线程大约占用1M的空间)
  • 频繁地创建和销毁代价很大,因为涉及到复杂的系统调用
  • 线程间上下文切换的成本很高,因为发生线程切换前,需要保留上一个任务的状态,以便切回来的时候,可以再次加载这个任务的状态。如果线程数量庞大,会造成线程做上下文切换的时间甚至大于线程执行的时间,CPU负载变高。

NIO(None Blocking IO:同步非阻塞IO)

  • 内核数据没有准备好的阶段,用户线程发起IO请求时,立即返回。为了读取到最终的数据,用户线程需要不断地发起IO系统调用。
  • 内核数据到达后,用户线程发起系统调用,用户线程阻塞。内核开始复制数据,它会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存),然后内核返回结果。
  • 用户线程读到数据后,才会解除阻塞状态,重新运行起来。也就是说,用户进程需要经过多次的尝试,才能保护最终真正读到数据,而后继续执行。

简单理解是NIO处理问题的方式是通过单线程或者少量线程达到处理大量客户端请求的目的。用户进程需要不断去主动询问内核数据准备好了没有。

优缺点:

  • 每次发起的IO系统调用,在内核等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好。
  • 但不断地轮训内核,将占用大量的CPU时间,效率低下。

IO Multiplexing(IO多路复用)

避免同步非阻塞IO模型中轮询等待的问题。

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

特点:

  • 和NIO模型相识,多路复用IO也需要轮询。负责select/epoll 状态查询调用的线程,需要不断地进行select/epoll 轮询,查找出达到IO操作就绪的socket连接。
  • select/epoll 的最大优势在于,一个选择器查询线程可以同时处理成千上万连接。系统不必创建大量的线程,也不必维护这些线程,从而大大减少了系统的开销。
  • 本质上,select/epoll 系统调用是阻塞式的,属于同步IO。都需要在读写事件就绪后,由系统调用本身负责进行读写,也就是说这个读写过程是阻塞的。

select

select() 允许程序监控多个fd,阻塞等待直到一个或者多个fd到达“就绪”状态。内核使用select()为用户进程提供了类似批量的接口,函数本身也会一直阻塞直到有fd为就绪状态返回。用位图算法bitmap实现的,使用了一个大小固定的数组,数组中的每个值映射fd对应位置上是否有读写事件。

poll

poll() 非常像select(),它也是阻塞等待直到一个或多个fd到达“就绪”状态。唯一的区别在于poll() 摒弃了位图算法,是用自定义的结构体pollfd,在pollfd内部封装了fd,并通过event变量注册感兴趣的可读可写事件,最后把pollfd交给内核。当有读写事件触发的时候,我们可以通过轮询pollfd,判断revent确定该fd是否发生了可读可写事件。

epoll

epoll()基本上完美地解决了poll()函数遗留的两个问题:

  • 没有了频繁的用户态到内核态的切换;
  • O(1)复杂度,返回的“nfds”是一个确定的可读写的数据,相比于之前循环n次来确认,复杂度降低了不上。

epoll()采用了“三步走”策略,分别是epoll_create()、epoll_ctl()、epoll_wait()。

  1. 用户进程通过epoll_create()函数在内核空间里创建了一块空间,并返回描述此空间的fd;
  2. 通过epoll_ctl()使用自定义的epoll_event结构体,在第一步创建的空间里注册感兴趣的事件;
  3. epoll_wait()会一直阻塞等待,直到硬盘、网卡等硬件设备数据准备完成后发起硬中断,中断CPU,CPU会立即执行数据拷贝工作,数据从磁盘缓冲传输到内核缓冲,同时将准备完成的fd放到就绪队列中供用户态进行读取。用户态阻塞停止,接收到具体数量的可读写的fds,返回用户态进行数据处理。

AIO(Asynchronous IO:异步IO)

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

特点&优缺点:

  • 在内核等待数据和复制数据的两个阶段,用户线程都不是阻塞的。用户线程需要接受内核的IO操作完成的事件,或者用户线程需要注册一个IO操作完成的回调函数。因此,异步IO有时候也被称为信号驱动IO。
  • 应用程序仅需要进行事件的注册与接收,其余的工作都留给了操作系统,也就是说,需要底层内核提供支持。异步IO是真正的异步输入输出,它的吞吐量高于IO多路复用模型的吞吐量。