WEB服务与NGINX(2)-NGINX的I/O模型
阅读原文时间:2021年06月13日阅读:1

WEB服务与NGINX(2)-NGINX的I/O模型


目录


1. linux I/0模型及在NGINX中的应用

1.1 I/O模型概述

I/O在计算机中指的是INPUT和OUPUT,IOPS(INPUT/OUTPUT per second)每秒的输入输出量(或读写次数),是衡量I/O性能的主要指标之一。

IOPS是单位时间内系统能处理的I/O请求数量,一般以秒为单位。

一次完整的I/O过程通常是用户空间的进程数据和内核空间的内核数据的报文的完整交换。由于内核空间和用户空间是完全隔离的,因此每次I/O都需要从内核空间的内存数据拷贝到用户空间的内存中。

计算机中比较重要的I/O是磁盘I/O和网络I/O。

  • 网络I/O

    就是网卡接收或发送数据,根据网络协议栈的工作机制,先将数据加载到内核内存空间,然后把内核内存空间的数据复制到用户空间进程过程。

  • 磁盘I/O

    进程向内核发起系统调用,请求磁盘上的文件资源,内核通过相应的驱动程序将目标资源加载到内核空间,加载完成后把数据从内核内存中复制到进程内存中的过程。

总结一下,每次的I/O一般都有两个阶段

  1. 第一阶段:将数据从文件先加载至内核内存空间缓冲区中,等待数据准备完成,一般时间较长;
  2. 第二阶段:将数据从内核缓冲区复制到用户空间进程内存中,一般时间较短;

缓冲区的引入是为了减少频繁I/O操作而引起频繁的系统调用(你知道它很慢的),当你操作一个流时,更多的是以缓冲区为单位进行操作。

注意:CPU有两个指令集,为环0指令集和环3指令集,其中环0指令集为高级指令,例如可以控制系统硬件等,环3指令集只能运行基本指令,例如加减乘除运算。环0指令集只能有内核发起调用,环3指令集则可以由用户空间进程发起调用。因此应用程序若需要进行网络IO或磁盘IO需要向内核发起系统调用来完成。

1.2 系统I/O模型

  • 同步/异步:关注的是消息通信机制,即在等待一件事情的处理结果时,被调用者能否在完成成提供通知机制。

    • 同步:synchronous

      调用者等待被调用者返回消息后,才能继续执行。,若被调用者不提供消息返回则为同步,同步需要调用者主动询问事情是否处理完成。

      进程发出系统调用后,等待内核返回响应后才能继续下一个请求,若内核一直不返回数据,进程就一直在等待。

    • 异步:asynchronous

      调用者无需等待返回消息继续向后运行,而被调用者通过状态、通知或回调机制主动通知调用者被调用者的运行状态。

      进程发出请求调用后,不需等待内核返回响应,即可接着处理下一个请求。

  • 阻塞/非阻塞:关注调用者在等待结果返回之前所处的状态

    • 阻塞:blocking

      指I/O操作需要彻底完成后才返回到用户空间,调用结果返回之前,调用者被挂起不能执行其他操作,一般处于不可终端睡眠状态。

    • 非阻塞:nonblocking

      指I/O操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成,最终的调用结果返回之前,调用者不会被挂起。

1.3 网络I/O模型

网络I/O模型分为五种:阻塞型、非阻塞型、复用型、信号驱动型、异步。

1.3.1 同步阻塞I/O模型

同步阻塞IO模型(blocking IO)是最简单的IO模型,用户线程在内核进行IO操作时被阻塞,I/O的两个阶段都处于阻塞状态。

用户线程通过系统调用read发起IO读操作,由用户空间转到内核空间。内核等到数据包到达后,将接收的数据拷贝到用户空间,完成read操作。

用户需要等待read将数据读取到buffer后,才继续处理接收的数据。整个IO请求的过程中,用户线程是被阻塞的,这导致用户在发起IO请求时,不能做任何事情,对CPU的资源利用率不够。

同步阻塞:程序向内核发送I/O请求后就一直等待内核响应,如果内核不能立即返回结果,进程一直等待不接受新的请求,并由进程轮询查看I/O是否完成,完成后进程将I/O结果返回给进程,这种方式简单但是效率较低,较少使用。

1.3.2 同步非阻塞I/O模型

用户线程发起IO请求时立即返回。但并未读取到任何数据,用户线程需要不断地发起IO请求,直到数据到达后,才真正读取到数据,继续执行。即 “轮询”机制,轮询只发生在I/O第一阶段,第一阶段为非阻塞,第二阶段仍然处于阻塞状态。

轮询机制存在两个问题:

  1. 如果有大量文件描述符都要等待,那么就得一个一个的read,这会造成大量的上下文切换(context switch);
  2. I/O请求返回的时间是未知的,而轮询的间隔就很难控制,等待时间太长,程序响应延迟就会过大,等待时间太短,就会造成频繁的IO重试,增加CPU负担;

整个IO请求的过程中,虽然用户线程每次发起IO请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的CPU的资源。是比较浪费CPU的方式,一般很少直接使用这种模型,而是在其他IO模型中使用非阻塞IO这一特性。

同步非阻塞(nonblocking IO):程序向内核发送IO请求后就一直等待内核响应,如果内核请求不能立即返回,进程将不再等待,可以继续处理其他请求,但是进程仍然需要定时查看内核IO结果是否完成。

1.3.3 IO多路复用模型

IO多路复用模型(IO multiplexing)也称为event driven IO,需要借助select,poll或epoll函数的参与。select或epoll的好处就是单个进程就可以同时处理多个网络连接的IO(需要进程生成多个子进程或线程)。

IO多路复用模型的过程就是当用户进程需要发起IO请求时,不会直接发起系统调用,而是调用select或epoll函数,让select或epoll函数去发起IO请求,而该进程则阻塞在select或epoll函数上,而不是阻塞在真正的IO操作上;select或epoll函数最大的好处是可以同时代替多个线程或进程发起IO请求,并采用轮询方式监控这些IO请求是否完成,当某个socket的IO请求完成了,select或epoll函数会主动通知对应的用户进程,再由这个用户进程调用read操作,将数据从内核空间复制到进程内存中,完成IO。

从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视IO,以及调用select函数的额外操作,效率更差。并且阻塞了两次,但是第一次阻塞在select上时,select可以监控多个IO上是否已有IO操作准备就绪,即可达到在同一个进程内同时处理多个IO请求的目的。而不像阻塞IO那种,一次只能监控一个IO。

虽然上述方式允许单进程内处理多个IO请求,但是每个IO请求的过程还是阻塞的(在select函数上阻塞),平均时间甚至比同步阻塞IO模型还要长。如果用户线程只是注册自己需要的IO请求,然后去做自己的事情,等到数据到来时再进行处理,则可以提高CPU的利用率。

IO多路复用是最常使用的IO模型,但是其异步程度还不够“彻底”,因它使用了会阻塞线程的select系统调用。因此IO多路复用只能称为异步阻塞IO模型,而非真正的异步IO

Httpd的prefork模式使用的是select函数,worker模式使用的是poll函数。

1.3.4 信号驱动IO模型

信号驱动IO:signal-driven I/O,用户进程可以通过sigaction系统调用注册一个信号处理程序,然后主程序可以继续向下执行,当有IO操作准备就绪时,由内核通知触发一个SIGIO信号处理程序,然后将用户进程所需要的数据从内核空间拷贝到用户空间。

此模型的优势在于等待数据包到达期间进程不会被阻塞。用户主程序可以继续执行,只要等待来自信号处理函数的通知。

信号驱动IO的优点为:线程并没有在等待数据时被阻塞,内核直接返回IO调用完成的信号,不影响进程继续处理其他请求,提高资源利用率;

缺点为:信号IO在有大量的IO操作时可能会因为信号队列溢出导致无法通知进程。

异步阻塞:程序进程向内核发起IO调用后,不用等待内核响应,可以继续接受其他请求,内核收到进程请求后进行的IO如果不能立即返回,就由内核等待结果,直到IO完成后由内核通知进程进行拷贝数据。

apache的event模式使用的就是此模型。

1.3.5 异步(非阻塞)模型

异步IO(asynchronous IO),用户进程发起IO调用后,无论内核数据是否准备好,都会直接返回给用户进程,用户进程可以继续处理其他请求,等到socket数据准备好后,由内核直接复制到进程内存中,然后内核再向进程发送通知,用户直接处理自己内存中的数据即可。IO的两个阶段,进程都是非阻塞的。

异步IO与信号驱动IO最主要的区别是信号驱动IO是由内核通知何时可以进行IO操作,而异步IO则是由内核告诉用户线程IO操作何时完成。信号驱动IO当内核通知触发信号处理程序时,信号处理程序还需要阻塞在从内核空间缓冲区拷贝数据到用户空间缓冲区这个阶段,而异步IO直接是在第二个阶段完成后,内核直接通知用户线程可以进行后续操作了。

相比于IO多路复用模型,异步IO并不十分常用,不少高性能并发服务程序使用IO多路复用模型+多线程任务处理的架构基本可以满足需求。目前操作系统对异步IO的支持并非特别完善,更多的是采用IO多路复用模型模拟异步IO的方式。

1.4 nginx的I/O实现方式

nginx支持多种I/O实现方式,在不同的操作系统,不同的系统版本上实现方式不仅相同,主要实现方式如下:

  • Select

    Linux实现,对应I/O复用模型,BSD4.2最早实现,POSIX标准,适用于一般操作系统。

    优点

    POSIX所规定,目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点,本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理;

    缺点

    单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义FD_SETSIZE,再重新编译内核实现,但是这样也会造成效率的降低;

    单个进程可监视的fd数量被限制,默认是1024,修改此值需要重新编译内核。

    对socket是线性扫描,即采用轮询的方法,效率较低。

    select 采取了内存拷贝方法来实现内核将 FD 消息通知给用户空间,这样一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。

  • poll

    Linux实现,对应I/O复用模型,System V unix最早实现,windows不支持,本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态。

    优点

    没有最大连接数的限制,原因是它是基于链表来存储的。

    大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。

    poll特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

  • epoll

    在Linux 2.6内核中提出的select和poll的增强版本,Linux特有,对应I/O复用模型,具有信号驱动I/O模型的某些特性。

    支持水平触发LT和边缘触发ET,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。

    使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。

    优点:

    没有最大并发连接的限制:能打开的FD的上限远大于1024(1G的内存能监听约10万个端口),具体查看/proc/sys/fs/file-max,此值和系统内存大小相关。

    效率提升:非轮询的方式,不会随着FD数目的增加而效率下降;只有活跃可用的FD才会调用callback函数,即epoll最大的优点就在于它只管理“活跃”的连接,而跟连接总数无关。

    内存映射,利用mmap(Memory Mapping)加速与内核空间的消息传递;即epoll使用mmap减少第二阶段内存复制的开销。

    epoll较select的改进:

    如果没有I/O事件产生,我们的程序就会阻塞在select处。但是依然有个问题,我们从select那里仅仅知道了,有I/O事件发生了,但却并不知道是那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。使用select,我们有O(n)的无差别轮询复杂度,同时处理的流越多,每一次无差别轮询时间就越长。

    epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll只会把哪个流发生了怎样的I/O事件通知我们。此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

    epoll由libevent程序包实现。

  • Kqueue

    FreeBSD实现,对应I/O复用模型,具有信号驱动I/O模型某些特性,poll模型的变种。

  • /dev/poll

    SUN的Solaris实现,对应I/O复用模型,具有信号驱动I/O模型的某些特性。

  • Iocp

    Windows上的实现方式,对应第5种(异步I/O)模型。

1.5 mmap和sendfile机制

下图是从用户访问某个页面到页面的显示这几秒钟的时间当中,在后台的整个工作过程。

如上图,黑色箭头所示的过程,是传统方式的数据传输:

第一步:当用户请求www.test.com/index.html网页时,nginx服务器通过网卡接收到请求后,系统调用read导致了从用户空间到内核空间的上下文切换,内核再向磁盘发送关于Index.html的请求,DMA模块从磁盘中读取index.html(以下简称为数据)发送到内核缓冲区,完成第一次复制。

第二步:系统调用read返回,导致从内核空间到用户空间的上下文切换,此时数据已存放在用户缓冲区中,完成第二次复制。

第三步:系统调用write导致了从用户空间到内核空间的上下文切换,此时数据存放在了内核空间与socket相关的特定缓冲区中(注意这里要将第一步中的内核缓冲区区分开来),完成第三次复制。

第四步:系统再次调用返回,导致了第四次上下文切换,数据来到了相关协议引擎,完成了第四次复制。再由相关引擎返回给用户浏览器。

这里的复制操作均由DMA模块来执行完成。

  • DMA:直接内存访问,cpu收到用户请求,将指令发送给DMAC(直接内存访问控制器),由DMAC执行磁盘数据读入到内存中,cpu不参与I/O过程,提高cpu利用率。

了解了这个过程后,我们来看一下sendfile和mmap到底是什么?

  • sendfile的目的也是提升数据的传输性能

    如上图中的红色箭头,sendfile机制实际上就是节省了从内核空间到用户空间,再从用户空间到内核空间这个往返的过程,相比于传统的方式节省了一次数据的复制,提高了访问效率。它的大致步骤是:

    第一步:从磁盘到内核空间,完成第一次复制。

    第二步:从内核空间到socket buffer(注意这里的缓冲区也要和传统方式中第三部的socket区别开来,可以理解为另一块与socket相关的特定缓冲区),完成第二次复制。

    第三步:数据从socket buffer到协议相关引擎,完成第三次复制。

    自内核版本号2.1,引进了sendfile2.4之后,sendfile实现了更简单的方式,不同之处在于,文件到达内核缓冲区后,不必再将数据全部复制到socket buffer缓冲区,而只将记录数据位置和长度相关的数据保存到socket buffer,而数据实际由DMA模块直接发送给协议相关引擎,再次降低了复制操作。

  • mmap内存映射则是在用户的虚拟地址空间中寻找空闲的一段地址进行对文件的操作,不必再调用read、write系统调用,它的最终目的是将磁盘中的文件映射到用户进程的虚拟地址空间,实现用户进程对文件的直接读写,减少了文件复制的开销,提高了用户的访问效率。

sendfile和mmap的目的就是减少了数据从磁盘到用户过程中的复制操作,使数据传输更加高效,提高用户的访问速率。