5种网络IO模型(有图,很清楚)

同步(synchronous) IO和异步(asynchronous) IO,阻塞(blocking) IO和非阻塞(non-blocking)IO分别是什么,到底有什么区别?这个问题其实不一样的人给出的答案均可能不一样,好比wiki,就认为asynchronous IO和non-blocking IO是一个东西。这实际上是由于不一样的人的知识背景不一样,而且在讨论这个问题的时候上下文(context)也不相同。因此,为了更好的回答这个问题,我先限定一下本文的上下文。mysql

    本文讨论的背景是Linux环境下的network IO。本文最重要的参考文献是Richard Stevens的“UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking ”,6.2节“I/O Models ”,Stevens在这节中详细说明了各类IO的特色和区别,若是英文够好的话,推荐直接阅读。Stevens的文风是有名的深刻浅出,因此不用担忧看不懂。本文中的流程图也是截取自参考文献。linux

    Stevens在文章中一共比较了五种IO Model:
    * blocking IO
    * nonblocking IO
    * IO multiplexing
    * signal driven IO
    * asynchronous IO
    由signal driven IO在实际中并不经常使用,因此主要介绍其他四种IO Model。
    再说一下IO发生时涉及的对象和步骤。对于一个network IO (这里咱们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另外一个就是系统内核(kernel)。当一个read操做发生时,它会经历两个阶段:
    1)等待数据准备 (Waiting for the data to be ready)
    2)将数据从内核拷贝到进程中(Copying the data from the kernel to the process)
    记住这两点很重要,由于这些IO模型的区别就是在两个阶段上各有不一样的状况。
    一、阻塞IO(blocking IO)程序员

    在linux中,默认状况下全部的socket都是blocking,一个典型的读操做流程大概是这样:web

图1 阻塞IOsql

    当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来讲,不少时候数据在一开始尚未到达(好比,尚未收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,而后kernel返回结果,用户进程才解除block的状态,从新运行起来。
    因此,blocking IO的特色就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了。数据库

    几乎全部的程序员第一次接触到的网络编程都是从listen()、send()、recv() 等接口开始的,这些接口都是阻塞型的。使用这些接口能够很方便的构建服务器/客户机的模型。下面是一个简单地“一问一答”的服务器。编程

图 1. 简单的一问一答的服务器 / 客户机模型

图2 简单的一问一答的服务器/客户机模型缓存

    咱们注意到,大部分的socket接口都是阻塞型的。所谓阻塞型接口是指系统调用(通常是IO接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用得到结果或者超时出错时才返回。
    实际上,除非特别指定,几乎全部的IO接口 ( 包括socket接口 ) 都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用send()的同时,线程将被阻塞,在此期间,线程将没法执行任何运算或响应任何的网络请求。tomcat

    一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每一个链接都拥有独立的线程(或进程),这样任何一个链接的阻塞都不会影响其余的链接。具体使用多进程仍是多线程,并无一个特定的模式。传统意义上,进程的开销要远远大于线程,因此若是须要同时为较多的客户机提供服务,则不推荐使用多进程;若是单个服务执行体须要消耗较多的CPU资源,譬如须要进行大规模或长时间的数据运算或文件访问,则进程较为安全。一般,使用pthread_create ()建立新线程,fork()建立新进程。
    咱们假设对上述的服务器 / 客户机模型,提出更高的要求,即让服务器同时为多个客户机提供一问一答的服务。因而有了以下的模型。安全

图 2. 多线程的服务器模型

图3 多线程的服务器模型

    在上述的线程 / 时间图例中,主线程持续等待客户端的链接请求,若是有链接,则建立新线程,并在新线程中提供为前例一样的问答服务。
    不少初学者可能不明白为什么一个socket能够accept屡次。实际上socket的设计者可能特地为多客户机的状况留下了伏笔,让accept()可以返回一个新的socket。下面是 accept 接口的原型:
     int accept(int s, struct sockaddr *addr, socklen_t *addrlen); 
    输入参数s是从socket(),bind()和listen()中沿用下来的socket句柄值。执行完bind()和listen()后,操做系统已经开始在指定的端口处监听全部的链接请求,若是有请求,则将该链接请求加入请求队列。调用accept()接口正是从 socket s 的请求队列抽取第一个链接信息,建立一个与s同类的新的socket返回句柄。新的socket句柄便是后续read()和recv()的输入参数。若是请求队列当前没有请求,则accept() 将进入阻塞状态直到有请求进入队列。
    上述多线程的服务器模型彷佛完美的解决了为多个客户机提供问答服务的要求,但其实并不尽然。若是要同时响应成百上千路的链接请求,则不管多线程仍是多进程都会严重占据系统资源,下降系统对外界响应效率,而线程与进程自己也更容易进入假死状态。
    不少程序员可能会考虑使用“线程池”或“链接池”。“线程池”旨在减小建立和销毁线程的频率,其维持必定合理数量的线程,并让空闲的线程从新承担新的执行任务。“链接池”维持链接的缓存池,尽可能重用已有的链接、减小建立和关闭链接的频率。这两种技术均可以很好的下降系统开销,都被普遍应用不少大型系统,如websphere、tomcat和各类数据库等。可是,“线程池”和“链接池”技术也只是在必定程度上缓解了频繁调用IO接口带来的资源占用。并且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。因此使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。
    对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“链接池”或许能够缓解部分压力,可是不能解决全部问题。总之,多线程模型能够方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,能够用非阻塞接口来尝试解决这个问题。

    二、非阻塞IO(non-blocking IO
    Linux下,能够经过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操做时,流程是这个样子:

图4 非阻塞IO

    从图中能够看出,当用户进程发出read操做时,若是kernel中的数据尚未准备好,那么它并不会block用户进程,而是马上返回一个error。从用户进程角度讲 ,它发起一个read操做后,并不须要等待,而是立刻就获得了一个结果。用户进程判断结果是一个error时,它就知道数据尚未准备好,因而它能够再次发送read操做。一旦kernel中的数据准备好了,而且又再次收到了用户进程的system call,那么它立刻就将数据拷贝到了用户内存,而后返回。
    因此,在非阻塞式IO中,用户进程实际上是须要不断的主动询问kernel数据准备好了没有。

    非阻塞的接口相比于阻塞型接口的显著差别在于,在被调用以后当即返回。使用以下的函数能够将某句柄fd设为非阻塞状态。
    fcntl( fd, F_SETFL, O_NONBLOCK ); 
    下面将给出只用一个线程,但可以同时从多个链接中检测数据是否送达,而且接受数据的模型。

图 3. 使用非阻塞的接收数据模型

图5 使用非阻塞的接收数据模型

    在非阻塞状态下,recv() 接口在被调用后当即返回,返回值表明了不一样的含义。如在本例中,
    * recv() 返回值大于 0,表示接受数据完毕,返回值便是接受到的字节数;
    * recv() 返回 0,表示链接已经正常断开;
    * recv() 返回 -1,且 errno 等于 EAGAIN,表示 recv 操做还没执行完成;
    * recv() 返回 -1,且 errno 不等于 EAGAIN,表示 recv 操做遇到系统错误 errno。
    能够看到服务器线程能够经过循环调用recv()接口,能够在单个线程内实现对全部链接的数据接收工做。可是上述模型毫不被推荐。由于,循环调用recv()将大幅度推高CPU 占用率;此外,在这个方案中recv()更多的是起到检测“操做是否完成”的做用,实际操做系统提供了更为高效的检测“操做是否完成“做用的接口,例如select()多路复用模式,能够一次检测多个链接是否活跃。
    三、多路复用IO(IO multiplexing)
    IO multiplexing这个词可能有点陌生,可是若是我说select/epoll,大概就都能明白了。有些地方也称这种IO方式为事件驱动IO(event driven IO)。咱们都知道,select/epoll的好处就在于单个process就能够同时处理多个网络链接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的全部socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:

图6 多路复用IO

    当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”全部select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操做,将数据从kernel拷贝到用户进程。
    这个图和blocking IO的图其实并无太大的不一样,事实上还更差一些。由于这里须要使用两个系统调用(select和recvfrom),而blocking IO只调用了一个系统调用(recvfrom)。可是,用select的优点在于它能够同时处理多个connection。(多说一句:因此,若是处理的链接数不是很高的话,使用select/epoll的web server不必定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优点并非对于单个链接能处理得更快,而是在于能处理更多的链接。)
    在多路复用模型中,对于每个socket,通常都设置成为non-blocking,可是,如上图所示,整个用户的process实际上是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。所以select()与非阻塞IO相似。

    大部分Unix/Linux都支持select函数,该函数用于探测多个文件句柄的状态变化。下面给出select接口的原型:
    FD_ZERO(int fd, fd_set* fds) 
    FD_SET(int fd, fd_set* fds) 
    FD_ISSET(int fd, fd_set* fds) 
    FD_CLR(int fd, fd_set* fds) 
    int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, 
    struct timeval *timeout) 
    这里,fd_set 类型能够简单的理解为按 bit 位标记句柄的队列,例如要在某 fd_set 中标记一个值为16的句柄,则该fd_set的第16个bit位被标记为1。具体的置位、验证可以使用 FD_SET、FD_ISSET等宏实现。在select()函数中,readfds、writefds和exceptfds同时做为输入参数和输出参数。若是输入的readfds标记了16号句柄,则select()将检测16号句柄是否可读。在select()返回后,能够经过检查readfds有否标记16号句柄,来判断该“可读”事件是否发生。另外,用户能够设置timeout时间。
    下面将从新模拟上例中从多个客户端接收数据的模型。

图 4. 使用 select() 的接收数据模型

图7 使用select()的接收数据模型

    述模型只是描述了使用select()接口同时从多个客户端接收数据的过程;因为select()接口能够同时对多个句柄进行读状态、写状态和错误状态的探测,因此能够很容易构建为多个客户端提供独立问答服务的服务器系统。以下图。

图 5. 使用 select() 接口的基于事件驱动的服务器模型

图8 使用select()接口的基于事件驱动的服务器模型

    这里须要指出的是,客户端的一个 connect() 操做,将在服务器端激发一个“可读事件”,因此 select() 也能探测来自客户端的 connect() 行为。
    上述模型中,最关键的地方是如何动态维护select()的三个参数readfds、writefds和exceptfds。做为输入参数,readfds应该标记全部的须要探测的“可读事件”的句柄,其中永远包括那个探测 connect() 的那个“母”句柄;同时,writefds 和 exceptfds 应该标记全部须要探测的“可写事件”和“错误事件”的句柄 ( 使用 FD_SET() 标记 )。
    做为输出参数,readfds、writefds和exceptfds中的保存了 select() 捕捉到的全部事件的句柄值。程序员须要检查的全部的标记位 ( 使用FD_ISSET()检查 ),以肯定到底哪些句柄发生了事件。
    上述模型主要模拟的是“一问一答”的服务流程,因此若是select()发现某句柄捕捉到了“可读事件”,服务器程序应及时作recv()操做,并根据接收到的数据准备好待发送数据,并将对应的句柄值加入writefds,准备下一次的“可写事件”的select()探测。一样,若是select()发现某句柄捕捉到“可写事件”,则程序应及时作send()操做,并准备好下一次的“可读事件”探测准备。下图描述的是上述模型中的一个执行周期。

图 6. 一个执行周期

图9 多路复用模型的一个执行周期

    这种模型的特征在于每个执行周期都会探测一次或一组事件,一个特定的事件会触发某个特定的响应。咱们能够将这种模型归类为“事件驱动模型”。
    相比其余模型,使用select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时可以为多客户端提供服务。若是试图创建一个简单的事件驱动的服务器程序,这个模型有必定的参考价值。
    但这个模型依旧有着不少问题。首先select()接口并非实现“事件驱动”的最好选择。由于当须要探测的句柄值较大时,select()接口自己须要消耗大量时间去轮询各个句柄。不少操做系统提供了更为高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll,…。若是须要实现更高效的服务器程序,相似epoll这样的接口更被推荐。遗憾的是不一样的操做系统特供的epoll接口有很大差别,因此使用相似于epoll的接口实现具备较好跨平台能力的服务器会比较困难。
    其次,该模型将事件探测和事件响应夹杂在一块儿,一旦事件响应的执行体庞大,则对整个模型是灾难性的。以下例,庞大的执行体1的将直接致使响应事件2的执行体迟迟得不到执行,并在很大程度上下降了事件探测的及时性。

图 7. 庞大的执行体对使用 select() 的事件驱动模型的影响

图10 庞大的执行体对使用select()的事件驱动模型的影响

    幸运的是,有不少高效的事件驱动库能够屏蔽上述的困难,常见的事件驱动库有libevent库,还有做为libevent替代者的libev库。这些库会根据操做系统的特色选择最合适的事件探测接口,而且加入了信号(signal) 等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。下章将介绍如何使用libev库替换select或epoll接口,实现高效稳定的服务器模型。

    实际上,Linux内核从2.6开始,也引入了支持异步响应的IO操做,如aio_read, aio_write,这就是异步IO。

    四、异步IO(Asynchronous I/O)
    Linux下的asynchronous IO其实用得很少,从内核2.6版本才开始引入。先看一下它的流程:

图11 异步IO

    用户进程发起read操做以后,马上就能够开始去作其它的事。而另外一方面,从kernel的角度,当它受到一个asynchronous read以后,首先它会马上返回,因此不会对用户进程产生任何block。而后,kernel会等待数据准备完成,而后将数据拷贝到用户内存,当这一切都完成以后,kernel会给用户进程发送一个signal,告诉它read操做完成了。

    用异步IO实现的服务器这里就不举例了,之后有时间另开文章来说述。异步IO是真正非阻塞的,它不会对请求进程产生任何的阻塞,所以对高并发的网络服务器实现相当重要。
    到目前为止,已经将四个IO模型都介绍完了。如今回过头来回答最初的那几个问题:blocking和non-blocking的区别在哪,synchronous IO和asynchronous IO的区别在哪。
先回答最简单的这个:blocking与non-blocking。前面的介绍中其实已经很明确的说明了这二者的区别。调用blocking IO会一直block住对应的进程直到操做完成,而non-blocking IO在kernel还在准备数据的状况下会马上返回。
    在说明synchronous IO和asynchronous IO的区别以前,须要先给出二者的定义。Stevens给出的定义(实际上是POSIX的定义)是这样子的:
    * A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
    * An asynchronous I/O operation does not cause the requesting process to be blocked;
    二者的区别就在于synchronous IO作”IO operation”的时候会将process阻塞。按照这个定义,以前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。有人可能会说,non-blocking IO并无被block啊。这里有个很是“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操做,就是例子中的recvfrom这个系统调用。non-blocking IO在执行recvfrom这个系统调用的时候,若是kernel的数据没有准备好,这时候不会block进程。可是当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内进程是被block的。而asynchronous IO则不同,当进程发起IO操做以后,就直接返回不再理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程当中,进程彻底没有被block。

    还有一种不经常使用的signal driven IO,即信号驱动IO。总的来讲,UNP中总结的IO模型有5种之多:阻塞IO,非阻塞IO,IO复用,信号驱动IO,异步IO。前四种都属于同步IO。阻塞IO没必要说了。非阻塞IO ,IO请求时加上O_NONBLOCK一类的标志位,马上返回,IO没有就绪会返回错误,须要请求进程主动轮询不断发IO请求直到返回正确。IO复用同非阻塞IO本质同样,不过利用了新的select系统调用,由内核来负责原本是请求进程该作的轮询操做。看似比非阻塞IO还多了一个系统调用开销,不过由于能够支持多路IO,才算提升了效率。信号驱动IO,调用sigaltion系统调用,当内核中IO数据就绪时以SIGIO信号通知请求进程,请求进程再把数据从内核读入到用户空间,这一步是阻塞的。
异步IO,如定义所说,不会由于IO操做阻塞,IO操做所有完成才通知请求进程。
    各个IO Model的比较如图所示:

图12 各类IO模型的比较

    通过上面的介绍,会发现non-blocking IO和asynchronous IO的区别仍是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,可是它仍然要求进程去主动的check,而且当数据准备完成之后,也须要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则彻底不一样。它就像是用户进程将整个IO操做交给了他人(kernel)完成,而后他人作完后发信号通知。在此期间,用户进程不须要去检查IO操做的状态,也不须要主动的去拷贝数据。

 

http://blog.csdn.net/xiexievv/article/details/44976215

相关文章
相关标签/搜索