Post

I/O 模型

深入操作系统,理解 I/O 模型

I/O 模型

前置知识

(服务器大多都采用Linux系统,这里我们以Linux为例来介绍)

为了避免 用户应用 导致 冲突甚至内核崩溃,用户应用与内核是分离的。

进程的寻址空间会划分为两部分:内核空间、用户空间

  • 用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问
  • 内核空间可以执行特权命令(Ring0),调用一切系统资源

Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区:

  • 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
  • 读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区

image.png

提高IO效率?

  • 减少无效等待
  • 减少用户态和内核态之间的这种缓冲区的数据拷贝

两个核心阶段

  • 等待数据准备好:(对于读取来说是)数据从外部设备(如网卡)到达内核缓冲区
  • 将数据从内核空间复制到用户空间

阻塞与非阻塞:

  • 阻塞是指调用方一直在等待而且别的事情什么都不做;
  • 非阻塞是指调用方先去忙别的事情。

同步处理与异步处理:

  • 同步处理是指被调用方得到最终结果之后才返回给调用方;
  • 异步处理是指被调用方先返回应答,然后再计算调用结果,计算完最终结果后再通知并返回给调用方。

阻塞、非阻塞和同步、异步的区别:

  • 阻塞、非阻塞的讨论对象是调用者;
  • 同步、异步的讨论对象是被调用者。

阻塞、非阻塞和同步、异步其实针对的对象是不一样的。

在《UNIX网络编程》一书中,总结归纳了5种IO模型:

  • 阻塞IO(Blokcking IO)
  • 非阻塞IO(Nonblock IO)
  • IO多路复用(IO Multiplexing)
  • 信号驱动IO(Signal Driven IO)
  • 异步IO(Asynchronous IO)

image.png

阻塞IO

顾名思义,阻塞IO就是两个阶段都必须阻塞等待。

image.png

等待期间,应用程序会让出CPU,进入等待队列。

比喻:

一个人在钓鱼,当没鱼上钩时,就坐在岸边一直等。

优点:

程序简单,在阻塞等待数据期间进程/线程挂起,基本不会占用 CPU 资源。

缺点:

每个连接需要独立的进程/线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大,这种模型在实际生产中很少使用。

非阻塞IO

顾名思义,非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。

  • 用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。
  • 虽然是非阻塞,但是性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增
    • 忙轮询(忙等)

image.png

比喻:

边钓鱼边玩手机,隔会再看看有没有鱼上钩,有的话就迅速拉杆。

优点:

不会阻塞在内核的等待数据过程,每次发起的 I/O 请求可以立即返回,不用阻塞等待,实时性较好。

缺点:

轮询将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低,所以一般 Web 服务器不使用这种 I/O 模型。

IO多路复用

无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:

  • 如果调用recvfrom时,恰好没有数据,阻塞IO会使进程阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用
  • 如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据

在单线程情况下,服务端处理客户端Socket请求时只能依次处理每个socket,如果正在处理的socket恰好未就绪(数据不可读或不可写),线程就会被阻塞,其他客户端socket都必须等待。

要提高效率有几种办法?

  1. 多线程
  2. 不排队,数据就绪了,用户应用就去读取数据

文件描述符(File Descriptor):

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

用户进程如何知道内核中数据是否就绪呢?

IO多路复用:利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。

image.png

image.png

比喻:

放了一堆鱼竿,在岸边一直守着这堆鱼竿,没鱼上钩就玩手机。

优点:

可以基于一个阻塞对象,同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程),这样可以大大节省系统资源。

缺点:

当连接数较少时效率相比多线程+阻塞 I/O 模型效率较低,可能延迟更大,因为单个连接处理需要 2 次系统调用,占用时间会有增加。

众所周之,Nginx这样的高性能互联网反向代理服务器大获成功的关键就是得益于Epoll。

IO多路复用可以用更少的线程处理更多的任务,在高并发场景下有优势

监听FD、通知的方式有多种实现,常见的有:select、poll、epoll

差异:

  • select和poll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认
  • epoll则会在通知用户进程FD就绪的同时,把已就绪的FD写入用户空间

select是Linux中最早的I/O多路复用实现方案:

  • 工作原理:
    1. 应用程序创建一个fd_set(一个位图,每一位对应一个文件描述符)。
    2. 将需要监视的fd对应的位设置为1。
    3. 调用select函数,将这个fd_set从用户空间拷贝到内核空间,并告诉内核:“请帮我监视这些fd,有任何一个准备好了就告诉我”。
    4. 内核会遍历所有传入的fd,检查它们的状态。如果没有一个就绪,select调用就会阻塞
    5. 当某个fd就绪(比如,有数据可读),或者超时时间到了,内核会修改fd_set,标记出哪些fd是就绪的,然后select调用返回。
    6. 应用程序拿到返回的fd_set后,需要自己遍历一遍,找出是哪个fd就绪了,然后进行相应的读写操作。
  • select模式存在的问题
    • 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
    • select无法得知具体是哪个fd就绪,需要遍历整个fd_set
    • fd_set监听的fd数量不能超过1024

poll模式对select做了简单改进,但性能提升不明显。

  • 工作流程:
    • poll不再使用 fd_set 位图,而是使用一个pollfd结构体数组,向其中添加关注的fd信息,数组大小自定义。
    • 调用poll函数将这个数组拷贝到内核空间,转链表存储,无上限。由内核检查状态。
  • 与select对比:
    • select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限
    • 监听FD越多,每次遍历消耗时间也越久,性能反而会下降

epoll模式是对select和poll的改进。它提供了三个函数:

  • epoll_create:在内核中创建eventpoll结构体,返回指向该实例的文件描述符epfd
  • epoll_ctl:用于向epoll实例中添加、修改或删除需要监视的fd。会将一个fd添加到epoll的红黑树中,并设置回调函数。当该fd就绪时,内核调用callback函数,把对应的fd加入就绪列表
  • epoll_wait:这是主循环中调用的函数。它会阻塞,直到就绪链表中有fd存在,或者超时。它检查就绪链表是否为空,不为空则返回就绪的FD的数量

image.png

select模式存在的三个问题:

  • 能监听的FD数量有限,不能超过1024个
  • 每次select都需要把所有要监听的FD都拷贝到内核空间
  • 每次都要遍历所有FD来判断就绪状态(哪个文件描述符可以读写)

poll相对于select的优化仅仅在于:

  • poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听过多,性能会下降

因此 select 和 epoll 不适合高并发场景

针对拷贝问题:epoll使用的策略是各个击破与共享内存。

实际上,文件描述符集合的变化频率比较低,但select和poll频繁的拷贝整个集合。

epoll通过引入epoll_ctl很体贴的做到了只操作那些有变化的文件描述符。

同时epoll和内核还成为了好朋友,共享了同一块内存,这块内存中保存的就是那些已经可读或者可写的的文件描述符集合,这样就减少了内核和程序的拷贝开销。

针对需要遍历文件描述符才能知道哪个可读可写这一问题,epoll使用的策略是“当小弟”。

在select和poll机制下:

进程要去各个文件描述符上等待,任何一个文件描述可读或者可写就唤醒进程,进程被唤醒后还要从头到尾检查一遍是哪个文件描述符可读或可写。

在epoll机制下:

进程只要等待在epoll上,epoll代替进程去各个文件描述符上等待,当哪个文件描述符可读或者可写的时候就告诉epoll,epoll记录下来然后唤醒进程,这样进程被唤醒后就无需自己从头到尾检查一遍了。

因此我们可以看到:在epoll这种机制下,实际上利用的就是大名鼎鼎的事件驱动——Event-driven策略。

  • 不要打电话给我,有需要我会打给你)

epoll模式如何解决这些问题?

  • 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高,性能不会随监听的FD数量增多而下降
  • 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epoll_wait无需传递任何参数,无需重复拷贝FD到内核空间
  • 内核会将就绪的FD直接拷贝到用户空间的指定位置,用户进程无需遍历所有FD就能知道就绪的FD是谁

实际上,在Linux平台,epoll基本上就是高并发的代名词。

以epoll为代表的I/O多路复用(基于事件驱动)技术使用非常广泛。

实际上你会发现但凡涉及到高并发、高性能的场景基本上都能见到事件驱动的编程方法。

IO多路复用-事件通知机制

当FD有数据可读时,我们调用epoll_wait就可以得到通知。但是事件通知的模式有两种:

  • LevelTriggered:简称LT。当FD有数据可读时,会被重复通知多次,直至数据处理完成。是epoll的默认模式。
  • EdgeTriggered:简称ET。当FD有数据可读时,只会被通知一次,不管数据是否处理完成。

结论:

  • ET模式避免了LT模式可能出现的惊群现象
  • ET模式最好结合非阻塞IO读取FD数据(FD可读时循环读取数据),相比LT会复杂一些

IO多路复用-web服务流程

基于epoll模式的web服务的基本流程如图:

image.png

信号驱动IO

信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。

image.png

当有大量IO操作时,信号较多,SIGIO处理的函数不能及时处理可能导致信号队列溢出

而且内核空间与用户空间的频繁信号交互性能也较低。

比喻:

鱼竿上系了个铃铛,当铃铛响,就知道鱼上钩,然后可以专心玩手机。

优点:

线程并没有在等待数据时被阻塞,可以提高资源的利用率。

缺点:

信号 I/O 在大量 IO 操作时可能会因为信号队列溢出导致没法通知。

信号驱动 I/O 尽管对于处理 UDP 套接字来说有用,即这种信号通知意味着到达一个数据报,或者返回一个异步错误。

但是,对于 TCP 而言,信号驱动的 I/O 方式近乎无用,因为导致这种通知的条件为数众多,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失。

与非阻塞IO的区别在于它提供了消息通知机制,不需要用户进程不断的轮询检查

异步IO

异步IO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其他事情,内核等待数据就绪并拷贝到用户空间才会递交信号,通知用户进程。

image.png

可以看到,异步IO模型中,用户进程在两个阶段都是非阻塞状态。

  • 高并发情况下内核里可能会积累过多IO读写任务,而每增加一次任务可能有大量的内存消耗,可能导致系统因为内核占用过多而崩溃
  • 需要做好并发访问的限流。限流的工作+回调函数的机制 -> 实现起来代码比较复杂

优点:

异步 I/O 能够充分利用 DMA 特性,让 I/O 操作与计算重叠。

缺点:

要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O。

而在 Linux 系统下,Linux 2.6才引入,目前 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 IO 多路复用模型为主。

小结

image.png

阻塞IO:两个阶段都阻塞等待 → 等待数据就绪、等待数据从内核拷贝到用户空间

非阻塞IO:第一个阶段非阻塞(其实是忙等待)

IO多路复用:利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知

信号驱动IO:与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务

异步IO:整个过程都是非阻塞的。用户进程调用完异步API后就可以去做其他事情,内核等待数据就绪并拷贝到用户空间才会递交信号,通知用户进程

IO多路复用是操作系统层面的的实现

This post is licensed under CC BY 4.0 by the author.

Trending Tags