异步 阻塞 多路复用

利用web服务器实例简单讲解一下事件驱动模型与异步IO。

web服务器和客户端是一对多的关系,web服务器必须有能力同时为多个客户端提供服务。一般来说,完成并行处理请求工作有三种方式可以选择:多进程方式,

多线程方式和异步方式。

  • 多进程

多进程方式是指,服务器每当接受一个客户端时,就由服务器主进程生成一个子进程出来和该客户端建立连接进行交互,直到连接断开,该子进程就结束了。

优点:

  设计和实现相对简单,各个子进程之间相互独立,处理客户单请求的过程彼此不受干扰,并且当一个子进程产生问题时,不容易将影响蔓延到其他进程中,这保证了提供服务的稳定性。当子进程退出时,其占用的资源会被操作系统回收,也不会留下任何垃圾。

缺点:

  操作系统中生成一个子进程需要进行内存复制等操作,在资源和时间上会产生一定的额外开销,因此,如果web服务器接收大量并发请求,就会对系统资源造成压力,导致系统性能下降。

  • 多线程

服务器每当接收到一个客户端时,会由服务器主进程派生一个线程处理和该客户端进行交互。

优点:

  由于操作系统产生一个线程的开销远远小于一个进程的开销,所以多线程方式在很大程度上减轻了web服务器对系统资源的要求。该方式使用线程进行任务调度,开发方面可以遵循一定的标准。

缺点:

  多个线程位于同一个进程内,可以访问同样的内存空间,彼此之间相互影响;同事,在开发过程中不可避免地要由开发者自己对内存进行管理,其增加了出错的风险。服务器系统需要长时间连续不停地运转,错误的积累可能最终对整个服务器产生重大影响、

  • 异步方式

  异步方式和多进程方式以及多进程方式完全不同的一种处理客户端请求的方式。在说明异步方式之前,先说一下网络通信的基本概念。

网络通信中的同步机制和异步机制是描述通信模式的概念。

  • 同步机制,是指发送方发送请求后需要等待接收到接收方发回相应后,才接着发送下一个请求。
  • 异步机制:发送方发出一个请求后,不等的接收方相应这个请求,就继续发送下一个请求。
  • 在同步机制中,所有的请求在服务器端得到同步,发送方和接收方对请求的处理步调是一致的;在异步机制中,所有来自发送方的请求形成一个队列,接收方处理完成后通知发送方。

阻塞和非阻塞用来描述进程处理调用的方式。

  在网络通信中,主要指网络套接字socket的阻塞和非阻塞方式,而socket的实质也就是io操作。socket的阻塞调用方式认为,调用结果返回之前,当前线程从运行状态被挂起,一直等待调用结果返回之后,才进入就绪状态,获取cpu后继续执行。socket的非阻塞,如果调用结果不能马上返回,当前线程就不会被挂起,而是立即返回,执行下一个调用。
  这两对概念,有四个新的组合:同步阻塞,异步阻塞,同步非阻塞,异步非阻塞。

  •   同步阻塞: 发送方向接收方发送请求后,一直等待响应;接收方处理请求时运行的IO操作如果不能马上得道结果,就一直等到结果返回,才响应发送方,期间不能进行其他工作。超市排队付账时,客户(发送方)向收款员(接收方)付款(发送请求)后需要等待收款员找零,期间不能做其他的事情;而收款员要等待收款机返回结果(IO操作),后才能把零钱取出来交给客户(响应请求),期间也只能等待,不能做其他事情。这种方式实现简单,但是效率不高。
  •        同步非阻塞:发送方向接受方发送请求后,一直等待响应;接受方处理请求时进行的IO操作如果不能马上得到结果,就立即返回,去做其他的事情,但由于没有得道请求处理结果,不响应发送方。一直到IO操作完成后,接收方获得结果响应发送方后,接收方才进入下一次请求过程。在实际中不使用这种方式。
  •       异步阻塞:发送方向接受方发送请求后,不用等待响应,可以接着进行其他工作;接收方处理请求时进行的IO操作如果不能马上得到结果,就一直等到返回结果后,才响应发送方,期间不能进行其他工作。这种方式在实际中也不使用。
  •       异步非阻塞:发送方向接收方发送请求后,不用等待响应,可以继续其他工作;接收方处理请求时进行的IO操作如果不能马上得到结果,也不等待,而是马上返回去做其他事情。当IO操作完成之后,将完成状态和结果通知接收方,接收方再响应发送方。例如,客户(发送方)向收款员(接收方)付款(发送请求)后再等待收款员找零的过程中,可以做其他的事情,比如打电话,聊天等;而收款员在等待收款机处理交易(IO操作)的过程中可以帮助客户讲商品打包,当收款机产生结果后,收款员给客户结账(响应请求)。这种方式是发送方和接收方通信效率最高的一种。

在上面的描述中有一个问题?

  在使用异步非阻塞时,服务端在工作进程调用IO后,就去进行其他工作了;当IO调用返回后,会通知工作进程。那么IO调用是如何把自己的状态通知给工作进程的。

解决这个问题有两种方案:

  1. 让工作进程在进行其他工作的过程中间隔一段时间就去检查一下IO的运行状态,如果完成,就去响应客户端,如果未完成,就继续正在进行的工作。
  2. IO调用在完成之后能主动去通知工作进程。
  • 对于第一种,虽然工作进程在IO调用过程中没有等待,但不断的检查仍然在时间和资源上导致了不小的开销,最理想的解决方案是第二种。
  • 对于第二种方案的IO操作,在linux系统中有5种模型方案:
    • - 阻塞 I/O(blocking IO)
      - 非阻塞 I/O(nonblocking IO)
      - I/O 多路复用( IO multiplexing)
      - 信号驱动 I/O( signal driven IO)  # 不常使用
      - 异步 I/O(asynchronous IO)

五种IO操作的简单介绍:

参考的博客: https://segmentfault.com/a/1190000003063859

                     https://www.cnblogs.com/George1994/p/6702084.html

概念说明:

 在进行解释之前,首先要说明几个概念:
  - 用户空间和内核空间
  - 进程切换
  - 进程的阻塞
  - 文件描述符
  - 缓存 I/O

用户空间和内核空间:

  现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

进程切换: 

  为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
1. 保存处理机上下文,包括程序计数器和其他寄存器。
2. 更新PCB信息。

3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
4. 选择另一个进程执行,并更新其PCB。
5. 更新内存管理的数据结构。
6. 恢复处理机上下文。

注:进程控制块(Processing Control Block),是操作系统核心中一种数据结构,主要表示进程状态。其作用是使一个在多道程序环境下不能独立运行的程序(含数据),成为一个能独立运行的基本单位或与其它进程并发执行的进程。或者说,OS是根据PCB来对并发执行的进程进行控制和管理的。 PCB通常是系统内存占用区中的一个连续存区,它存放着操作系统用于描述进程情况及控制进程运行所需的全部信息

进程的阻塞:

  正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的

文件描述法fd:

  文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

缓存IO:

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间

缓存 I/O 的缺点
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
1. 等待数据准备 (Waiting for the data to be ready)
2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

正是这两个解答产生了上面所提到的5种IO操作模型。

五种IO模型的详解,不想再搬运了,等什么时候自己总结了,会贴上。

但现在提供链接 https://segmentfault.com/a/1190000003063859  这个博文没有第四种信号驱动模型,

信号驱动模型可以查看着篇博文: https://www.cnblogs.com/George1994/p/6702084.html

五种模式的总结:

blocking和non-blocking的区别

调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。

synchronous IO和asynchronous IO的区别

在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。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这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。

而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

各个IO Model的比较如图所示:

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

 事件驱动模型:

上面我们提到了一个问题,提到了问题的解决办法,给出了5种基本IO模型,nginx服务器采用的IO多路复用的方式。具体的就是采用select/poll/epoll的方式来处理这种问题,像这种系统调用的方式称为事件驱动模型。

事件驱动模型:
  事件驱动就是在持续事务管理过程中,由当前时间点上出现的事件引发的调动可用资源执行相关任务,解决不断出现的问题,防止事务堆积的一种策略。在计算机编程领域,事件驱动模型对应一种程序设计方式,Event-driven-programming即事件驱动程序设计。

事件驱动模型的组成:

  事件驱动模型一般是由事件收集器,事件发送器和事件处理器三部分基本单元组成。

  事件收集器专门负载收集所有的事件,包括来自用户的(如鼠标点击事件,键盘输入事件等),来自硬件的(如时钟事件)和来自软件(如操作系统和应用程序本身等)的。
  事件发送器负责将收集到的事件分发到目标对象中。

  目标对象就是事件处理器所在的位置。事件处理器主要负责具体事件的响应工作,它往往要到实现阶段才万完全确定。

事件驱动模型的编写:
  事件驱动可以又任何编程语言来实现,只是难易程度有别。如果一个系统是以事件驱动程序模型作为编程基础的,那么,它的架构基本上是这样的:预先设计一个事件循环所形成的程序,这个事件循环程序构成了事件收集器,它不断地检查目前要处理的事件信息,然后使用事件发送器传递给事件处理器。事件处理器一般用虚函数机制来实现。

  windows系统就是基于事件驱动程序设计的典型实例。windows操作系统中的视图(窗口),使我们所说的事件发送器的目标对象。视图接收事件并能够对其进行相应的处理。当我们将事件发送到具体的某一个视图的时候。实际上就完成了从传统的流线型程序结构到事件触发方式的转变。

事件驱动模型的实现方法:

通常在编写事件驱动模型时,其中的事件处理器有以下几种实现方法。

  1. 事件发送器每传递过来一个请求,目标对象就创建一个新的进程,调用事件处理器来处理该请求。
  2. 事件发送器每传递过来一个请求,目标对象就创建一个新的线程,调用事件处理器来处理该请求。
  3. 事件发送器每传递过来一个请求,目标对象就将其放入一个待处理的事件列表,使用非阻塞I/O方式调用事件处理器来处理该请求。
  • 第一种方式:由于创建新的进程开销比较大,会导致服务器性能比较差,但其实现相对比较简单。
  • 第二种方式:由于要涉及到线程的同步,可能会面临死锁,同步等一系列问题,编码比较复杂、
  • 第三种方式:在编写程序代码时,逻辑比前两种都复杂。

事件驱动处理库又被称为多路IO复用,最常见包含以下三种:select模型,poll模型,epoll模型。

 select库:

select库是各个版本的linux和windows平台都支持的基本事件驱动模型库,并且在接口的定义上也基本相同,只是部分参数的含义略有差异。使用select的一般步骤是:首先,创建所关注的事件描述符的集合。对于一个描述符,可以关注上面的读,写事件以及异常发生事件。所以要创建三类事件描述法集合,分别用来收集读事件描述符,写事件描述符和异常事件描述符。其次,调用底层提供的select函数,等待事件发生。这里需要注意一点是,select的阻塞与是否设置非阻塞IO是没有关系的。然后,轮询所有事件描述符集合中的每一个事件描述符,检查是否有相应的事件发生,如果有,就进行处理。

 poll库:

不支持windows平台,支持linux平台。
poll与select的基本工作方式是相同的,都是先创建一个关注事件描述符集合,再去等待这些事件发生,然后再去轮询描述符集合,检查有没有事件发送,如果有,就进行处理。poll库与select库的主要区别在于。select库需要为读,写和异常事件分别创建一个描述符集合,因此在最后轮询的时候,需要分别轮询这三个集合。而poll库只需要创建一个集合,在每个描述符对应的结构上分别设置读,写,异常事件,最后轮询的时刻,可以同时检查这三种事件是否发送,可以说poll库是select库的优化实现。

 epoll库:

epoll库是nginx服务器支持高性能事件驱动库之一,是公认的非常优秀的事件驱动模型。

上面两种模型都是创建一个待处理的事件列表,然后把这个列表发给内核,返回的时候,再去轮询检查这个列表,以判断事件是否发生。这样在描述符比较多的应用中,效率就显得比较底下。一种比较好的做法是,把描述符列表的管理交由内核负载,一旦某种事件发生,内核就把发生事件的描述符列表通知进程,这样就避免了轮询整个描述符列表。epoll库就是这样一种模型。

首先,epool库通过相关调用通知内核建立一个有N个描述符的事件列表;然后,给这些描述符设置所关注的事件,并把它添加到内核事件列表中去,在具体的编码过程中也可以通过相关调用对事件列表中的描述符进行修改和删除。
完成设置后,epoll库就开始等待内核通知事件的发生了。某一事件发生之后,内核将发生事件的描述符列表上报给epoll库。得到事件列表的epoll库,就可以进行事件处理了。
epoll库在linux平台上是高效的。它支持一个进程打开大数目的事件描述符,上限是系统可以打开的文件的最大数目;同时,epoll库的IO效率不随描述符数目增加而线性下降,因为条只会对内核上很“活跃”的描述符进行操作。

猜你喜欢

转载自www.cnblogs.com/wxzhe/p/9145423.html