Unix 5种IO模型

Linux的IO模型

image-20221012100057145

大致流程:

当用户执行read操作,会经历两个阶段:

  1. wait for data (等待数据就绪)等待数据到达(可以是网络数据),并拷贝到内核的缓冲区中
  2. copy data from kernel to user (将数据从内核空间拷贝到用户空间)将内核缓冲区中的数据拷贝到用户缓冲区中

可简化为下图:

image-20221012101822838

网络应用需要处理的无非就是两大类问题,网络IO数据计算。相对于后者,网络IO的延迟,给应用带来的性能瓶颈大于后者。Linux网络IO的模型大致有如下几种:

  • 阻塞IO

  • 非阻塞IO(仍是同步的)

  • IO多路复用

  • 信号驱动IO

  • 异步IO

阻塞IO

用户想要读取数据,会调用recvfrom命令,尝试从内核获取数据,若内核中的数据未准备就绪,那么用户线程会阻塞等待一直阻塞到内核准备好了数据,并把数据拷贝到用户态,返回ok时才停止阻塞。

缺点:无效等待,且只能监听一个FD。

总结:两阶段全程阻塞

image-20221012102218668

非阻塞IO

调用recvfrom命令,若内核中的数据未准备就绪,会返回一个EWOULDBLOCK错误码,然后用户进程继续**轮询(polling)**地调用recvfrom,直到内核数据准备就绪才停止轮询,然后用户进程阻塞等待数据从内核态拷贝到用户态。

缺点:

  • 第一阶段的轮询会造成CPU空转,CPU使用率暴增,不能发挥CPU的作用。
  • 只能监听一个FD。

总结:第一阶段轮询,第二阶段阻塞

image-20221012102939979

IO多路复用(事件驱动模型)

FD 文件描述符

文件描述符(File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。

第一阶段不在调用recvfrom,而是调用select命令,这一个线程能监听多个FD,阻塞等待FD变为可读/可写。若有FD可读/可写,则会通知用户线程,接着调用recvfrom,进行第二阶段

总结:两阶段都阻塞,一阶段select监听多个FD,二阶段recvfrom等待拷贝

image-20221012103919018

监听FD的方式:select、poll、epoll

IO多路复用是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听FD的方式、通知的方式又有多种实现,常见的有:

  • select

    • select在Linux中应用最早,而且很多平台(windows等)都有该方法,能做到跨平台。
    • 将需要处理的数据封装成多个FD,然后在用户态中创建一个FD集合(其实就是包含了数组结构体,该集合的大小是要监听的FD中的最大值+1,但是整体大小是有限制的,是1024bit),这个集合的长度大小是有限制的,同时在这个集合中,标明出来我们要监听哪些FD(fd=1)。执行select后会把这个FD集合拷贝到内核态,内核遍历该集合,若没有FD就绪,就休眠;若有FD就绪,就再遍历一遍集合,把就绪的FD标记出来(fd=1,未就绪写0),然后拷贝给用户态,用户遍历该集合,在对应的FD节点上调用recvfrom获取数据。不断循环上述过程,直至所有数据都获取完了。
    • 虽然比阻塞IO和非阻塞IO好,但涉及到FD集合的频繁拷贝和频繁遍历,性能不是很好。
    • FD集合只有1024位,能监听的FD有限,无法应对如今上万的高并发。

    image-20221012111515505

  • poll

    • 在用户态中创建pollfd数组,里面添加要监听的FD信息,可自定义数组大小。调用poll命令后将pollfd数组拷贝至内核,并转成链表存储,长度无上限。后面流程几乎和select的一样,内核遍历链表,判断是否有fd就绪,若没有就休眠,若有则在pollfd数组上标记,然后拷贝至用户态,并返回就绪fd数量n,用户判断若n大于0,则遍历数组,找到就绪fd,然后进行二阶段。
    • 在select基础上做了简单改进,把有限长的fd集合改为可自定义大小的pollfd数组,然后在内核中用无大小上限的链表存储,若监听fd较多,遍历耗时也会变旧,所以实际来说大小还是有上限的,拷贝和遍历操作仍存在,性能提升不明显

    image-20221012143047200

  • epoll

    • 主要是3个函数:

      1. 用户态调用epoll_create函数,在内核创建eventpoll结构体(epoll实例),结构体内部包含两个东西:
      • 红黑树-> 记录要监听的FD

      • 链表->记录就绪的FD

      1. 紧接着每个fd调用epoll_ctl函数,将该fd注册到epoll实例上,将要监听的fd添加到红黑树,并且为每个fd绑定一个监听(回调)函数,这个函数会在fd数据就绪时触发,把这个就绪的fd添加到链表list_head。

      2. 再调用epoll_wait函数,在用户态创建一个空的events数组,在超时时间内会检查链表中是否有就绪的fd,有的话就会添加到events数组中,并且返回对应的操作的数量,用户态的此时收到响应后,从events中拿到对应准备好的数据的节点,进行二阶段。该函数可以在等待时多次调用。

      image-20221012152252623

    • 总结:epoll对select和poll做了改进,性能强大:

      • 用eventpoll结构体代替了fd集合和pollfd数组,内部的红黑树保存要监听的fd,大小无上限,增删改查性能稳定且高效

      • 每个fd只需执行一次epoll_ctl添加到红黑树(若后续还有删改增,就不止一次了)。并靠回调函数自动添加到链表上,无需重复遍历来监听fd。

      • 有fd就绪时,调用epoll_wait函数可以得到通知,获取就绪的fd,无需用户态和内核态间的数组重复拷贝

select、poll、epoll 应用场景

很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,但它们都有各自的使用场景。

  • select

    • select 的 timeout 参数精度为1ns,而 poll 和 epoll 为 1ms,因此select更加适用于实时要求更高的场景,如核反应堆的控制。
    • select 可移植性更好,几乎被所有主流平台所支持。
  • poll

    • poll 没有fd监听数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。

    • 同时监控的fd小于 1000 个,没必要用epoll,因为这个应用场景下并不能体现 epoll 的优势

    • 因为 epoll 中的所有fd都存储在内核中,若监控的fd状态变化多,且都是非常短暂的,也没有必要使用 epoll,每次对某个fd的状态改变(增删改)都需要通过 epoll_ctl 函数进行系统调用,频繁系统调用会降低效率。并且epoll 的fd存储在内核,不容易调试(用户态看不到内核内部的运作进程)。

  • epoll

    • 只需运行在 Linux 上,并且有非常大量的fd需要同时监控,而且这些连接最好是长连接(fd状态变化少)。

epoll中的事件通知模式:LT和ET

当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知模式有两种:

  • LevelTriggered(epoll默认):简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。
  • EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化(未就绪转为就绪)时,调用epoll_wait才会被通知。

举个栗子:

  1. 假设一个客户端socket对应的FD已经注册到了epoll实例中

  2. 客户端socket发送了2kb的数据

  3. 服务端调用epoll_wait,得到通知说FD就绪

  4. 服务端从FD读取了1kb数据回到步骤3(再次调用epoll_wait,形成循环)

    • 如果我们采用LT模式,因为FD中仍有1kb数据,则第⑤步依然会返回结果,并且得到通知。
    • 如果我们采用ET模式,因为第③步已经消费了FD可读事件,第⑤步FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时。

信号驱动IO

用户与内核建立SIGIO信号关联,并绑定回调函数,当内核有fd就绪时,内核就会发出SIGIO信号通知用户,这期间用户态不阻塞,用户收到SIGIO回调信号,就调用recvfrom进行二阶段。

缺点:当有大量IO时,信号会非常多,SIGIO处理函数来不及处理就会导致信号队列溢出,且用户态和内核态间频繁的信号交互导致性能低。

总结:一阶段建立SIGIO信号和回调函数,不阻塞,二阶段阻塞

image-20221012163326202

异步IO

用户调用aio_read,内核返回ok。准备数据和拷贝数据两个阶段都由内核完成,且用户两阶段都不存在任何阻塞

缺点:内核要完成全部工作,在高并发的场景下内核的压力非常大,性能不好,除非在用户层面做限流,但系统复杂度就高了。

总结:全部完成后通知用户,两阶段都不阻塞

image-20221012165456238

5者对比

image-20221012171556631

Java NIO

Java NIO 是从Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java IO API。NIO与原来的IO有同样的作用和目的,但是实现方式完全不同, NIO支持面向缓冲区的、基于通道的IO操作。 NIO将以更加高效的方式进行文件的读写操作。

Reactor事件驱动模型

img

在Reactor模型中,主要有四个角色:客户端,Reactor,Acceptor和Handler。这里Acceptor会不断地接收客户端的连接,然后将接收到的连接交由Reactor进行分发,最后有具体的Handler进行处理。

下图是NIO的模型,是对IO多路复用中的Reactor模型的实现。如果客户端也使用NIO,则客户端也会有对称的NIO模型。

image-20221102171106239

Channel

  • 通道 Channel 是对原 I/O 包中的流stream的模拟,可以通过它读取和写入数据。

  • 流只能是单向的(一个流必须是 InputStream 或者 OutputStream 的子类);而通道是双向的,可以用于读、写或同时读写

  • 通道包括以下类型:

    • FileChannel: 从文件中读写数据;

    • DatagramChannel: 通过 UDP 读写网络中数据;

    • SocketChannel: 通过 TCP 读写网络中数据;

    • ServerSocketChannel: 可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel。

Buffer

  • 为了保证每个通道的数据读写速度,发送给一个通道的所有数据都必须先放到缓冲区中,同样地,从通道中读取的任何数据都要先读到缓冲区中。也就是说,不会直接对通道进行读写数据,而是要先经过缓冲区

  • buffer实质上是一个数组,但它不仅仅是一个数组,它能提供对数据的结构化访问,还可以跟踪系统的读/写进程

  • Buffer有两种工作模式:写模式读模式。在读模式下,应用程序只能从Buffer中读取数据,不能进行写操作。但是在写模式下,应用程序是可以进行读操作的,这就表示可能会出现脏读的情况。所以一旦决定要从Buffer中读取数据,一定要将Buffer的状态改为读模式。

  • 缓冲区包括以下类型:

    • ByteBuffer

    • CharBuffer

    • ShortBuffer

    • IntBuffer

    • LongBuffer

    • FloatBuffer

    • DoubleBuffer

  • buffer靠3个状态变量实现读写:

    • capacity:最大容量;
    • position:目前这在操作(读/写)的数据块的位置;
    • limit:最大可以进行操作的数据块的位置。读写模式就是靠这个变量控制。
    • 当buffer为写模式时,limit = capacity,position = 0 从头开始写,写了多少,position就到哪,position最多可以写到limit(capacity)。
    • 当buffer为读模式时,limit = position,position = 0 从头开始读,读了多少,position就到哪,position最多可以读到limit(也就是之前写的数据的末尾)。读完之后清空buffer,变回初始状态。

    img

Selector

  • Java NIO能实现IO多路复用的一个线程监听多个事件的效果就是靠Selector,是非常重要的概念。

  • 一个 Thread 使用一个 Selector 通过轮询的方式去监听多个 Channel 上的事件

  • 需要向Selector对象注册它感兴趣事件的Channel。selector内部会维护一个注册过的channel的数组,一个selector最多可以监听1024个channel

    // Initial capacity of the poll array
    private final int INIT_CAP = 8;
    
    // Maximum number of sockets for select().
    // Should be INIT_CAP times a power of 2
    private final static int MAX_SELECTABLE_FDS = 1024;
    
    // The list of SelectableChannels serviced by this Selector. Every mod
    // MAX_SELECTABLE_FDS entry is bogus, to align this array with the poll
    // array,  where the corresponding entry is occupied by the wakeupSocket
    private SelectionKeyImpl[] channelArray = new SelectionKeyImpl[INIT_CAP];
    

Java NIO 对IO多路复用的支持

img

上一篇 下一篇