五种IO模型透彻分析

1.基础

在引入IO模型前,先对io等待时某一段数据的"经历"作一番解释。如图:html

当某个程序或已存在的进程/线程(后文将不加区分的只认为是进程)须要某段数据时,它只能在用户空间中属于它本身的内存中访问、修改,这段内存暂且称之为app buffer。假设须要的数据在磁盘上,那么进程首先得发起相关系统调用,通知内核去加载磁盘上的文件。但正常状况下,数据只能加载到内核的缓冲区,暂且称之为kernel buffer。数据加载到kernel buffer以后,还需将数据复制到app buffer。到了这里,进程就能够对数据进行访问、修改了。web

如今有几个须要说明的问题。安全

(1).为何不能直接将数据加载到app buffer呢网络

其实是能够的,有些程序或者硬件为了提升效率和性能,能够实现内核旁路的功能,避过内核的参与,直接在存储设备和app buffer之间进行数据传输,例如RDMA技术就须要实现这样的内核旁路功能。数据结构

可是,最普通也是绝大多数的状况下,为了安全和稳定性,数据必须先拷入内核空间的kernel buffer,再复制到app buffer,以防止进程串进内核空间进行破坏。并发

(2).上面提到的数据几回拷贝过程,拷贝方式是同样的吗app

不同。如今的存储设备(包括网卡)基本上都支持DMA操做。什么是DMA(direct memory access,直接内存访问)?简单地说,就是内存和设备之间的数据交互能够直接传输,再也不须要计算机的CPU参与,而是经过硬件上的芯片(能够简单地认为是一个小cpu)进行控制。异步

假设,存储设备不支持DMA,那么数据在内存和存储设备之间的传输,必须经过计算机的CPU计算从哪一个地址中获取数据、拷入到对方的哪些地址、拷入多少数据(多少个数据块、数据块在哪里)等等,仅仅完成一次数据传输,CPU都要作不少事情。而DMA就释放了计算机的CPU,让它能够去处理其余任务。socket

再说kernel buffer和app buffer之间的复制方式,这是两段内存空间的数据传输,只能由CPU来控制。async

因此,在加载硬盘数据到kernel buffer的过程是DMA拷贝方式,而从kernel buffer到app buffer的过程是CPU参与的拷贝方式。

(3).若是数据要经过TCP链接传输出去要怎么办

例如,web服务对客户端的响应数据,须要经过TCP链接传输给客户端。

TCP/IP协议栈维护着两个缓冲区:send buffer和recv buffer,它们合称为socket buffer。须要经过TCP链接传输出去的数据,须要先复制到send buffer,再复制给网卡经过网络传输出去。若是经过TCP链接接收到数据,数据首先经过网卡进入recv buffer,再被复制到用户空间的app buffer。

一样,在数据复制到send buffer或从recv buffer复制到app buffer时,是CPU参与的拷贝。从send buffer复制到网卡或从网卡复制到recv buffer时,是DMA操做方式的拷贝。

以下图所示,是经过TCP链接传输数据时的过程。

(4).网络数据必定要从kernel buffer复制到app buffer再复制到send buffer吗

不是。若是进程不须要修改数据,就直接发送给TCP链接的另外一端,能够不用从kernel buffer复制到app buffer,而是直接复制到send buffer。这就是零复制技术。

例如httpd不须要访问和修改任何信息时,将数据原本来本地复制到app buffer再原本来本地复制到send buffer而后传输出去,但实际上复制到app buffer的过程是能够省略的。使用零复制技术,就能够减小一次拷贝过程,提高效率。

固然,实现零复制技术的方法有多种,见个人另外一篇结束零复制的文章:零复制(zero copy)技术

如下是以httpd进程处理文件类请求时比较完整的数据操做流程。

大体解释下:客户端发起对某个文件的请求,经过TCP链接,请求数据进入TCP 的recv buffer,再经过recv()函数将数据读入到app buffer,此时httpd工做进程对数据进行一番解析,知道请求的是某个文件,因而发起某个系统调用(例如要读取这个文件,发起read()),因而内核加载该文件,数据从磁盘复制到kernel buffer再复制到app buffer,此时httpd就要开始构建响应数据了,可能会对数据进行一番修改,例如在响应首部中加一个字段,最后将修改或未修改的数据复制(例如send()函数)到send buffer中,再经过TCP链接传输给客户端。

2. I/O模型

所谓的IO模型,描述的是出现I/O等待时进程的状态以及处理数据的方式。围绕着进程的状态、数据准备到kernel buffer再到app buffer的两个阶段展开。其中数据复制到kernel buffer的过程称为数据准备阶段,数据从kernel buffer复制到app buffer的过程称为数据复制阶段。请记住这两个概念,后面描述I/O模型时会一直用这两个概念。

本文以httpd进程的TCP链接方式处理本地文件为例,请无视httpd是否真的实现了如此、那般的功能,也请无视TCP链接处理数据的细节,这里仅仅只是做为方便解释的示例而已。另外,本文用本地文件做为I/O模型的对象不是很适合,它的重头戏是在套接字上,若是想要看处理TCP/UDP过程当中套接字的I/O模型,请看完此文后,再结合个人另外一篇文章"不可不知的socket和TCP链接过程"以从新认识I/O模型。

再次说明,从硬件设备到内存的数据传输过程是不须要CPU参与的,而内存间传输数据是须要CPU参与的。

2.1 Blocking I/O模型

如图:

假设客户端发起index.html的文件请求,httpd须要将index.html的数据从磁盘中加载到本身的httpd app buffer中,而后复制到send buffer中发送出去。

可是在httpd想要加载index.html时,它首先检查本身的app buffer中是否有index.html对应的数据,没有就发起系统调用让内核去加载数据,例如read(),内核会先检查本身的kernel buffer中是否有index.html对应的数据,若是没有,则从磁盘中加载,而后将数据准备到kernel buffer,再复制到app buffer中,最后被httpd进程处理。

若是使用Blocking I/O模型:

(1).当设置为blocking i/o模型,httpd从都是被阻塞的。
(2).只有当数据复制到app buffer完成后,或者发生了错误,httpd才被唤醒处理它app buffer中的数据。
(3).cpu会通过两次上下文切换:用户空间到内核空间再到用户空间。
(4).因为阶段的拷贝是不须要CPU参与的,因此在阶段准备数据的过程当中,cpu能够去处理其它进程的任务。
(5).阶段的数据复制须要CPU参与,将httpd阻塞,在某种程度上来讲,有助于提高它的拷贝速度。
(6).这是最省事、最简单的IO模式。

以下图:

2.1 Non-Blocking I/O模型

(1).当设置为non-blocking时,httpd第一次发起系统调用(如read())后,当即返回一个错误值EWOULDBLOCK(至于read()读取一个普通文件时可否返回EWOULDBLOCK请无视,毕竟I/O模型主要是针对套接字文件的,就当read()是recv()好了),而不是让httpd进入睡眠状态。UNP中也正是这么描述的。

When we set a socket to be nonblocking, we are telling the kernel "when an I/O operation that I request cannot be completed without putting the process to sleep, do not put the process to sleep, but return an error instead.

(2).虽然read()当即返回了,但httpd还要不断地去发送read()检查内核:数据是否已经成功拷贝到kernel buffer了?这称为轮询(polling)。每次轮询时,只要内核没有把数据准备好,read()就返回错误信息EWOULDBLOCK。
(3).直到kernel buffer中数据准备完成,再去轮询时再也不返回EWOULDBLOCK,而是将httpd阻塞,以等待数据复制到app buffer。
(4).httpd在阶段不被阻塞,可是会不断去发送read()轮询。在被阻塞,将cpu交给内核把数据copy到app buffer。

以下图:

2.3 I/O Multiplexing模型

称为多路IO模型或IO复用,意思是能够检查多个IO等待的状态。有三种IO复用模型:select、poll和epoll。其实它们都是一种函数,用于监控指定文件描述符的数据是否就绪,就绪指的是对某个系统调用再也不阻塞了,例如对于read()来讲,就是数据准备好了就是就绪状态。就绪种类包括是否可读、是否可写以及是否异常,其中可读条件中就包括了数据是否准备好。当就绪以后,将通知进程,进程再发送对数据操做的系统调用,如read()。因此,这三个函数仅仅只是处理了数据是否准备好以及如何通知进程的问题。能够将这几个函数结合阻塞和非阻塞IO模式使用,例如设置为非阻塞时,select()/poll()/epoll将不会阻塞在对应的描述符上,调用函数的进程/线程也就不会被阻塞。

select()和poll()差很少,它们的监控和通知手段是同样的,只不过poll()要更聪明一点,因此此处仅以select()监控单个文件请求为例简单介绍IO复用,至于更具体的、监控多个文件以及epoll的方式,在本文的最后专门解释。

(1).当想要加载某个文件时,假如httpd要发起read()系统调用,若是是阻塞或者非阻塞情形,那么read()会根据数据是否准备好而决定是否返回,是否能够主动去监控这个数据是否准备到了kernel buffer中呢,亦或者是否能够监控send buffer中是否有新数据进入呢?这就是select()/poll()/epoll的做用。
(2).当使用select()时,httpd发起一个select调用,而后httpd进程被select()"阻塞"。因为此处假设只监控了一个请求文件,因此select()会在数据准备到kernel buffer中时直接唤醒httpd进程。之因此阻塞要加上双引号,是由于select()有时间间隔选项可用控制阻塞时长,若是该选项设置为0,则select不阻塞,此时表示当即返回但一直轮询检查是否就绪,还能够设置为永久阻塞。
(3).当select()的监控对象就绪时,将通知(轮询状况)或唤醒(阻塞状况)httpd进程,httpd再发起read()系统调用,此时数据会从kernel buffer复制到app buffer中并read()成功。
(4).httpd发起第二个系统调用(即read())后被阻塞,CPU所有交给内核用来复制数据到app buffer。

(5).对于httpd只处理一个链接的状况下,IO复用模型还不如blocking I/O模型,由于它先后发起了两个系统调用(即select()和read()),甚至在轮询的状况下会不断消耗CPU。可是IO复用的优点就在于能同时监控多个文件描述符。

如图:

更详细的说明,见本文末。

2.4 Signal-driven I/O模型

即信号驱动IO模型。当开启了信号驱动功能时,首先发起一个信号处理的系统调用,如sigaction(),这个系统调用会当即返回。但数据在准备好时,会发送SIGIO信号,进程收到这个信号就知道数据准备好了,因而发起操做数据的系统调用,如read()。

在发起信号处理的系统调用后,进程不会被阻塞,可是在read()将数据从kernel buffer复制到app buffer时,进程是被阻塞的。如图:

2.5 Asynchronous I/O模型

即异步IO模型。当设置为异步IO模型时,httpd首先发起异步系统调用(如aio_read(),aio_write()等),并当即返回。这个异步系统调用告诉内核,不只要准备好数据,还要把数据复制到app buffer中。

httpd从返回开始,直到数据复制到app buffer结束都不会被阻塞。当数据复制到app buffer结束,将发送一个信号通知httpd进程。

如图:

看上去异步很好,可是注意,在复制kernel buffer数据到app buffer中时是须要CPU参与的,这意味着不受阻的httpd会和异步调用函数争用CPU。若是并发量比较大,httpd接入的链接数可能就越多,CPU争用状况就越严重,异步函数返回成功信号的速度就越慢。若是不能很好地处理这个问题,异步IO模型也不必定就好。

2.6 同步IO和异步IO、阻塞和非阻塞的区分

阻塞、非阻塞、IO复用、信号驱动都是同步IO模型。由于在发起操做数据的系统调用(如本文的read())过程当中是被阻塞的。这里要注意,虽然在加载数据到kernel buffer的数据准备过程当中可能阻塞、可能不阻塞,但kernel buffer才是read()函数的操做对象,同步的意思是让kernel buffer和app buffer数据同步。显然,在保持kernel buffer和app buffer同步的过程当中,进程必须被阻塞,不然read()就变成异步的read()。

只有异步IO模型才是异步的,由于发起的异步类的系统调用(如aio_read())已经无论kernel buffer什么时候准备好数据了,就像后台同样read同样,aio_read()能够一直等待kernel buffer中的数据,在准备好了以后,aio_read()天然就能够将其复制到app buffer。

如图:

3.select()、poll()和epoll

前面说了,这三个函数是文件描述符状态监控的函数,它们能够监控一系列文件的一系列事件,当出现知足条件的事件后,就认为是就绪或者错误。事件大体分为3类:可读事件、可写事件和异常事件。它们一般都放在循环结构中进行循环监控。

select()和poll()函数处理方式的本质相似,只不过poll()稍微先进一点,而epoll处理方式就比这两个函数先进多了。固然,就算是先进分子,在某些状况下性能也不必定就比老家伙们强。

3.1 select() & poll()

首先,经过FD_SET宏函数建立待监控的描述符集合,并将此描述符集合做为select()函数的参数,能够在指定select()函数阻塞时间间隔,因而select()就建立了一个监控对象。

除了普通文件描述符,还能够监控套接字,由于套接字也是文件,因此select()也能够监控套接字文件描述符,例如recv buffer中是否收到了数据,也即监控套接字的可读性,send buffer中是否满了,也即监控套接字的可写性。select()默认最大可监控1024个文件描述符。而poll()则没有此限制。

select()的时间间隔参数分3种:
(1).设置为指定时间间隔内阻塞,除非以前有就绪事件发生。
(2).设置为永久阻塞,除非有就绪事件发生。
(3).设置为彻底不阻塞,即当即返回。但由于select()一般在循环结构中,因此这是轮询监控的方式。

当建立了监控对象后,由内核监控这些描述符集合,于此同时调用select()的进程被阻塞(或轮询)。当监控到知足就绪条件时(监控事件发生),select()将被唤醒(或暂停轮询),因而select()返回知足就绪条件的描述符数量,之因此是数量而不只仅是一个,是由于多个文件描述符可能在同一时间知足就绪条件。因为只是返回数量,并无返回哪个或哪几个文件描述符,因此一般在使用select()以后,还会在循环结构中的if语句中使用宏函数FD_ISSET进行遍历,直到找出全部的知足就绪条件的描述符。最后将描述符集合经过指定函数拷贝回用户空间,以便被进程处理。

监听描述符集合的大体过程以下图所示,其中select()只是其中的一个环节:

大概描述下这个循环监控的过程:

(1).首先经过FD_ZERO宏函数初始化描述符集合。图中每一个小方格表示一个文件描述符。
(2).经过FD_SET宏函数建立描述符集合,此时集合中的文件描述符都被打开,也就是稍后要被select()监控的对象。
(3).使用select()函数监控描述符集合。当某个文件描述符知足就绪条件时,select()函数返回集合中知足条件的数量。图中标黄色的小方块表示知足就绪条件的描述符。
(4).经过FD_ISSET宏函数遍历整个描述符集合,并将知足就绪条件的描述符发送给进程。同时,使用FD_CLR宏函数将知足就绪条件的描述符从集合中移除。
(5).进入下一个循环,继续使用FD_SET宏函数向描述符集合中添加新的待监控描述符。而后重复(3)、(4)两个步骤。

若是使用简单的伪代码来描述:

FD_ZERO
for() {
    FD_SET()
    select()
    if(){
        FD_ISSET()
        FD_CLR()
    }
    writen()
}

以上所说只是一种须要循环监控的示例,具体如何作倒是不必定的。不过从中也能看出这一系列的流程。

3.2 epoll

epoll比poll()、select()先进,考虑如下几点,天然能看出它的优点所在:

(1).epoll_create()建立的epoll实例能够随时经过epoll_ctl()来新增和删除感兴趣的文件描述符,不用再和select()每一个循环后都要使用FD_SET更新描述符集合的数据结构。
(2).在epoll_create()建立epoll实例时,还建立了一个epoll就绪链表list。而epoll_ctl()每次向epoll实例添加描述符时,还会注册该描述符的回调函数。当epoll实例中的描述符知足就绪条件时将触发回调函数,被移入到就绪链表list中。
(3).当调用epoll_wait()进行监控时,它只需肯定就绪链表中是否有数据便可,若是有,将复制到用户空间以被进程处理,若是没有,它将被阻塞。固然,若是监控的对象设置为非阻塞模式,它将不会被阻塞,而是不断地去检查。

也就是说,epoll的处理方式中,根本就无需遍历描述符集合。

相关文章
相关标签/搜索