提到Nginx,我们都知道Nginx使用epoll的 I/O模型,但是epoll具体是怎么工作的呢?为什么Nginx要选择epoll模型呢?Linux下还有哪些 I/O模型呢?

一、同步和异步

同步和异步关注的是消息的通知机制

同步

同步是指:调用者等待被调用者返回消息

异步

异步是指:被调用者通过状态、通知、回调机制通知调用者被调用者的运行状态

二、阻塞和非阻塞

阻塞和非阻塞关注的是调用者在等待结果返回之间所处的状态

阻塞

阻塞是指:调用结果返回之前,调用者被挂起

非阻塞

非阻塞是指:调用结果返回之前,调用者不会被挂起,调用者可以做其它事情

三、 I/O模型分类

1. 阻塞I/O

阻塞I/O模式是使用的最普遍的I/O模式,大部分程序使用的都是阻塞模式的I/O,一个套接字建立后默认所处的模式就是阻塞I/O模式。
一个进程发起一个系统调用请求数据,此时内核会检查数据是否准备就绪(例如数据可能存在于磁盘上或者网卡数据缓冲区中),此时内核会去准备这些数据到内核空间,数据准备完成之后,再把数据从内核空间拷贝到用户空间(进程缓存),最后系统调用返回数据。
我们称这个进程发起系统调用到系统调用返回结果这段时间,这个进程一直处于阻塞状态。当系统调用返回时,进程才继续它的操作。
阻塞IO.png

2. 非阻塞I/O

当一个套接字设置为非阻塞模式之后,相当于告诉内核,当我请求的I/O操作不能马上完成(内核数据还没有准备好),你想让我的进程进入休眠等待的时候,不要这么做,请立刻返回一个错误给我。
非阻塞IO.png
如上图中,内核在数据还未就绪时,每次通过recvfrom返回的错误就是EWOULDBLOCK,直到内核数据准备就绪,调用recvfrom时会将数据从内核空间拷贝到用户空间(进程缓存中),最后正常返回。

3. I/O多路复用

IO多路复用中包括select,poll,epoll三种模式

我们先看看阻塞型I/O的特点:

  1. 只需要一次调用就能获得数据。
  2. 调用过程中可能会发生较长时间等待,此时程序处于睡眠态,不占用cpu资源。
  3. 处理能力有限,一次只能处理一件事情,其他的事情只能等待。

由此当我们需要处理能力更高的话(比如在高并发环境下),阻塞模型不能满足我们的需求,我们再来分析下非阻塞模型:

  1. 调用之后程序没有被阻塞,但是必须后续再来查询数据是否已经处理完成。
  2. 后续查询数据一般使用for无限循环,这样即消耗cpu,又没有做有意义的事情。

以上都是一次只能监视一个文件描述符(套接字描述符),那有人想到能否一次监视多个文件描述符呢,只要其中有一个文件描述符的数据准备好了,就可以进行读写数据,于是I/O多路复用诞生了

IO多路复用.png
如图所示,使用IO多路复用时,我们需要调用两个函数,先调用select()或者poll()函数,当内核数据还没有准备完成时,进程在select函数处阻塞,等待几个描述符中的至少一个变为可操作状态,当其内核数据准备完成时,select函数返回,进程需要再次调用recvfrom函数去读写数据。

1. 需要使用I/O多路复用的场景

  1. 当一个客户端同时处理多个输入/输出操作时,一般来说是标准输入输出和网络套接字(如ctrl+c终止正在运行的web服务器)
  2. 当程序需要同时进行多个套接字操作时
  3. 一个TCP程序处理正在侦听的套接字和已经建立好的套接字
  4. 一个服务同时使用TCP和UDP协议
  5. 一个服务器同时使用多种服务,并且每种服务都使用不同的协议

2. select

select代码.png
select优缺点.png

  • 先声明一个fd_set的数组,数组的最大长度由事先声明的FD_SETSIZE变量决定,一般情况为1024,可以修改内核来修改此值
  • select函数执行的时候,需要将rset也就是监听的文件描述符的数组从用户态拷贝到内核态,此时fd中若没有数据到来,select函数会阻塞
  • 当内核判断fd_set中有一个或一个以上的fd数据准备就绪时,select函数返回,程序开始执行for循环
  • 此时select不知道哪些fd准备好了,只能遍历fd_set中的所有元素,判断哪个元素被置位了,将被置位的fd中的数据读取出来,并且进行相应的处理

select函数的缺点如下:

  1. 数组长度默认是1024的限制。fd_set的底层数组bitmap默认大小为1024,虽然可以调整,但是大量遍历效率低下
  2. fd_set是不可重用的。因为数据到来时内核将其置位了,fd_set每次需要重新生成
  3. 数组从用户态到内核态的拷贝。select函数调用的时候会将fd_set底层数组拷贝从用户态拷贝到内核态,有复制的开销
  4. 遍历的效率是O(n)的。当需要读取数据时,得遍历文件描述符集合,判断每个描述符中数据是否就绪

3. poll

poll代码.png

  • 执行流程和select类似,但是底层没有再使用fd_set数组,而是采用底层数组,每个元素是一个结构体
  • 内核通过将结构体中的revents置位,表明该处有数据到达,读取数据之后,再重置revents字段

poll优缺点.png
改进了select的哪些缺点:

  1. 使用pollfds数组代替fd_set,突破了1024大小的限制
  2. 通过结构体中revents字段的置位与重置,pollfds是可以重用的

4. epoll

  • 解决了select的四核缺点
  • 不需要轮训,时间复杂度为O1
  • epoll_create创建一个结构体,主要是红黑树加链表组成,存放fd_events
  • epoll_ctl用于向内核注册新的描述符或是改变已注册描述符的状态,已注册的描述符会被维护在一棵红黑树上
  • epoll_wait通过callback回调函数将已准备好的描述符加入到一个链表中,调用epoll_wait会得到已完成的时间描述符

5. epoll的两种触发模式

  • LT水平触发

当epoll_wait检测到描述符事件到达时,将此事件通知进程,进程可以不用立即处理该事件,下次再调用epoll_wait时,会再次通知,是默认的一种方式,并且同时支持 Blocking 和 No-Blocking。

  • ET边缘触发
    和 LT 模式不同的是,通知之后进程必须立即处理事件。

下次再调用 epoll_wait() 时不会再得到事件到达的通知。很大程度上减少了 epoll 事件被重复触发的次数,
因此效率要比 LT 模式高。只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

4. 信号驱动I/O

信号驱动型IO.png
信号驱动型I/O可以使内核在某个文件描述符发生变化的时候发信号通知我们的程序

  1. 开启套接字信号驱动IO功能;
  2. 系统调用sigaction执行信号处理函数(非阻塞,立刻返回),告诉系统数据就绪式调用哪个函数;
  3. 数据就绪,生成sigio信号,通过信号回调通知应用来读取数据。

但是这种方式对于tcp套接字来说没有什么作用,因为对于一个tcp套接字来说,sigio信号发生的几率太高了,sigio信号并不能告诉我们发生了什么事情,下情况都会触发sigio信号,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失。

  • 在一个监听某个端口的套接字上成功的建立了一个新连接。
  • 一个断线的请求被成功的初始化。
  • 一个断线的请求成功的结束。
  • 套接字的某一个通道(发送通道或是接收通道)被关闭。
  • 套接字接收到新数据。
  • 套接字将数据发送出去。
  • 发生了一个异步 I/O 的错误。

使用信号驱动型IO的典型UDP应用就是NTP时间服务器

5. 异步I/O

异步IO.png

  • 异步模式下I/O和数据拷贝全部由内核完成,我们的程序可以继续向下执行
  • 当内核完成所有的数据处理之后,会通知我们的程序

而在 Linux 系统下,Linux 2.6才引入,异步I/O时内核需要做大量的操作,目前 AIO 并不完善因此在 Linux 下实现高并发网络编程时都是以 IO 复用模型模式为主。

最后修改:2021 年 12 月 12 日 08 : 24 PM