【IO】IO设计模式:TPR模式,Reactor模式、Proactor模式

1.TPR模式

传统的 Server/Client 模式会基于TPR(Thread per Request),服务器会为每个客户端请求建立一个线程,由该线程单独负责处理一个客户请求。

这种模式虽然处理起来简单方便,但是由于服务器为每个 client 的连接都采用一个线程去处理,使得资源占用非常大。当客户端多时,会创建大量的处理线程。且每个线程都要占用栈空间和一些CPU 时间;另外,阻塞可能带来频繁的上下文切换,且大部分上下文切换可能是无意义的。所以服务器会增加很多没必要的开销。

因此,为了解决这种一个线程对应一个客户端模式带来的问题,提出了采用线程池的方式,也就说创建一个固定大小的线程池,来一个客户端,就从线程池取一个空闲线程来处理,当客户端处理完读写操作之后,就交出对线程的占用。因此这样就避免为每一个客户端都要创建线程带来的资源浪费,使得线程可以重用。

但是线程池也有它的弊端,如果连接大多是长连接,因此可能会导致在一段时间内,线程池中的线程都被占用,那么当再有用户请求连接时,由于没有可用的空闲线程来处理,就会导致客户端连接失败,从而影响用户体验。假如线程池中有200个线程,而有200个用户都在进行大文件下载,会导致第201个用户的请求无法及时处理,即便第201个用户只想请求一个几KB大小的页面。。。因此,线程池比较适合大量的短连接应用。

因此便出现了下面的两种高性能 IO 设计模式:Reactor 和 Proactor。

2.Reactor模式

Reactor模式是处理并发I/O比较常见的一种模式,用于同步I/O,中心思想是将所有要处理的I/O事件注册到一个中心I/O多路复用器上,同时主线程/进程阻塞在多路复用器上;一旦有I/O事件到来或是准备就绪(文件描述符或socket可读、写),多路复用器返回并将事先注册的相应I/O事件分发到对应的处理器中。

Reactor是一种事件驱动机制,和普通函数调用的不同之处在于:应用程序不是主动的调用某个API完成处理,而是恰恰相反,Reactor逆置了事件处理流程,应用程序需要提供相应的接口并注册到Reactor上,如果相应的事件发生,Reactor将主动调用应用程序注册的接口,这些接口又称为“回调函数”。用“好莱坞原则”来形容Reactor再合适不过了:不要打电话给我们,我们会打电话通知你。

在 Reactor 模式中,会先对每个 client 注册感兴趣的事件,然后有一个线程专门去轮询每个 client 是否有事件发生,当有事件发生时(比如可读数据到达,新的套接字连接等等),便顺序处理每个事件,当所有事件处理完之后,便再转去继续轮询,如下图所示:

就好像在长途客车上,每一站都有人上车有人下车,但是车上的乘客总是希望能够在客车上得到休息(睡觉)。

  • 传统做法:每隔一段时间(或每一个站),司机或售票员对每一个乘客询问是否下车。
  • Reactor做法:汽车是乘客访问的主体(Reactor),乘客上车后,到售票员(acceptor)处登记,之后乘客便可以休息睡觉去了,当到达乘客所要到达的目的地时(指定的事件发生,乘客到了下车地点),售票员将其唤醒即可。

2.1 Reactor 的作用

某一瞬间,服务器共有10万个并发连接,此时,一次IO复用接口的调用返回了100个活跃的连接等待处理。先根据这100个连接找出其对应的对象,这并不难,epoll的返回连接数据结构里就有这样的指针可以用。接着,循环的处理每一个连接,找出这个对象此刻的上下文状态,再使用read、write这样的网络IO获取此次的操作内容,结合上下文状态查询此时应当选择哪个业务方法处理,调用相应方法完成操作后,若请求结束,则删除对象及其上下文。

这样,我们就陷入了面向过程编程方法之中了,在面向应用、快速响应为王的移动互联网时代,这样做早晚得把自己玩死。我们的主程序需要关注各种不同类型的请求,在不同状态下,对于不同的请求命令选择不同的业务处理方法。这会导致随着请求类型的增加,请求状态的增加,请求命令的增加,主程序复杂度快速膨胀,导致维护越来越困难,苦逼的程序员再也不敢轻易接新需求、重构。

Reactor是解决上述软件工程问题的一种途径,它也许并不优雅,开发效率上也不是最高的,但其执行效率与面向过程的使用IO复用却几乎是等价的,所以,无论是nginx、memcached、redis等等这些高性能组件的代名词,都义无反顾的一头扎进了反应堆的怀抱中。

Reactor模式可以在软件工程层面,将事件驱动框架分离出具体业务,将不同类型请求之间用OO的思想分离。通常,Reactor不仅使用IO复用处理网络事件驱动,还会实现定时器来处理时间事件的驱动(请求的超时处理或者定时任务的处理)。

  • 处理应用时基于OO思想,不同的类型的请求处理间是分离的。例如,A类型请求是用户注册请求,B类型请求是查询用户头像,那么当我们把用户头像新增多种分辨率图片时,更改B类型请求的代码处理逻辑时,完全不涉及A类型请求代码的修改。
  • 应用处理请求的逻辑,与事件分发框架完全分离。什么意思呢?即写应用处理时,不用去管何时调用IO复用,不用去管什么调用epoll_wait,去处理它返回的多个socket连接。应用代码中,只关心如何读取、发送socket上的数据,如何处理业务逻辑。事件分发框架有一个抽象的事件接口,所有的应用必须实现抽象的事件接口,通过这种抽象才把应用与框架进行分离。
  • Reactor上提供注册、移除事件方法,供应用代码使用,而分发事件方法,通常是循环的调用而已,是否提供给应用代码调用,还是由框架简单粗暴的直接循环使用,这是框架的自由。
  • IO多路复用也是一个抽象,它可以是具体的select,也可以是epoll,它们只必须提供采集到某一瞬间所有待监控连接中活跃的连接。
  • 定时器也是由Reactor对象使用,它必须至少提供4个方法,包括添加、删除定时器事件,这该由应用代码调用。最近超时时间是需要的,这会被反应堆对象使用,用于确认select或者epoll_wait执行时的阻塞超时时间,防止IO的等待影响了定时事件的处理。遍历也是由反应堆框架使用,用于处理定时事件。

2.2 核心组件

在Reactor模式中,有5个关键的参与者:

  • 描述符(handle):由操作系统提供的资源,用于识别每一个事件,如Socket描述符、文件描述符、信号的值等。在Linux中,它用一个整数来表示。事件可以来自外部,如来自客户端的连接请求、数据等。事件也可以来自内部,如信号、定时器事件。
  • 同步事件多路分离器(event demultiplexer):事件的到来是随机的、异步的,无法预知程序何时收到一个客户连接请求或收到一个信号。所以程序要循环等待并处理事件,这就是事件循环。在事件循环中,等待事件一般使用I/O复用技术实现。在linux系统上一般是select、poll、epoll等系统调用,用来等待一个或多个事件的发生。I/O框架库一般将各种I/O复用系统调用封装成统一的接口,称为事件多路分离器。调用者会被阻塞,直到分离器分离的描述符集上有事件发生。这点可以参考前面的 I/O多路复用模型。
  • 事件处理器(event handler):I/O框架库提供的事件处理器通常是由一个或多个模板函数组成的接口。这些模板函数描述了和应用程序相关的对某个事件的操作,用户需要继承它来实现自己的事件处理器,即具体事件处理器。因此,事件处理器中的回调函数一般声明为虚函数,以支持用户拓展。
  • 具体的事件处理器(concrete event handler):是事件处理器接口的实现。它实现了应用程序提供的某个服务。每个具体的事件处理器总和一个描述符相关。它使用描述符来识别事件、识别应用程序提供的服务。
  • Reactor 管理器(reactor):定义了一些接口,用于应用程序控制事件调度,以及应用程序注册、删除事件处理器和相关的描述符。它是事件处理器的调度核心。 Reactor管理器使用同步事件分离器来等待事件的发生。一旦事件发生,Reactor管理器先是分离每个事件,然后调度事件处理器,最后调用相关的模 板函数来处理这个事件。

所以,我们再来看一下Reactor的一般流程

1)应用程序在事件分离器注册读写就绪事件和读写就绪事件处理器
2)事件分离器等待读写就绪事件发生
3)读写就绪事件发生,激活事件分离器,分离器调用读写就绪事件处理器
4)事件处理器先从内核把数据读取到用户空间,然后再处理数据

在这里插入图片描述

特别强调:该图片及下面的四张图片出自这篇博客!!

2.3 三种模式

1.单线程模式

这是最简单的Reactor单线程模型。Reactor线程是个多面手,负责多路分离套接字,Accept新连接,并分派请求到处理器链中。

  1. 作为服务端,接收客户端的 TCP 连接
  2. 作为客户端,向服务端发起 TCP 连接
  3. 读取通信对端的请求或者应答消息
  4. 向通信对端发送消息请求或者应答消息

在这里插入图片描述
该模型适用于处理器链中业务处理组件能快速完成的场景,或者对于一些小容量应用场景,可以使用单线程模型。但是对于高负载、大并发的应用却不合适,主要原因如下:

  1. 一个线程同时处理成百上千的链路,性能上无法支撑,即便线程的CPU负荷达到 100%,也无法满足海量消息的编码、解码、读取和发送;
  2. 当单线程负载过重之后,处理速度将变慢,这会导致大量客户端连接超时,超时之后往往会进行重发,这更加重了该线程的负载,最终会导致大量消息积压和处理超时,该线程会成为系统的性能瓶颈;
  3. 可靠性问题:一旦单线程意外跑飞,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障。

另外,这种单线程模型不能充分利用多核资源,所以实际使用的不多。

2.多线程模式

该模型在事件处理器(Handler)链部分采用了多线程(线程池),也是后端程序常用的模型。

在这里插入图片描述
在绝大多数场景下,Reactor 多线程模型都可以满足性能需求;但是,在极特殊应用场景中,一个 线程负责监听和处理所有的客户端连接可能会存在性能问题。例如百万客户端并发连接,或者服务端需要对客户端的握手消息进行安全认证,认证本身非常损耗性能。在这类场景下,单独一个 Acceptor 线程可能会存在性能不足问题,为了解决性能问题,产生了第三种Reactor 线程模型-主从 Reactor 多线程模型。

3.主从模式

比起第二种模型,它是将Reactor分成两部分

  • mainReactor:负责监听并accept新连接,然后将建立的socket通过多路复用器(Acceptor)分派给subReactor
  • subReactor:负责多路分离已连接的socket,读写网络数据;业务处理功能,其交给worker线程池完成。通常,subReactor个数上可与CPU个数等同。

在这里插入图片描述

3.Proactor模式

Proactor是和异步I/O相关的。

在Reactor模式中,事件分离者等待某个事件或者可应用多个操作的状态发生(比如文件描述符可读写,或者是socket可读写),事件分离器就把这个事件传给事先注册的处理器(事件处理函数或者回调函数),由后者来做实际的读写操作。

在Proactor模式中,事件处理者(或者代由事件分离者发起)直接发起一个异步读写操作(相当于请求),而实际的工作是由操作系统来完成的。发起时,需要提供的参数包括用于存放读到数据的缓存区,读的数据大小,或者用于存放外发数据的缓存区,以及这个请求完后的回调函数等信息。事件分离者得知了这个请求,它默默等待这个请求的完成,然后转发完成事件给相应的事件处理者或者回调。

举个例子,将有助于理解Reactor与Proactor二者的差异,以读操作为例(写操作类似)。

Reactor(同步)中实现读:

  1. 注册读就绪事件和相应的事件处理器
  2. 事件分离器等待事件
  3. 事件到来,激活分离器,分离器调用事件对应的处理器
  4. 事件处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制权

Proactor(异步)中实现读:

  1. 应用程序在事件分离器注册读完成事件和读完成事件处理器,并向系统发出异步读请求(注意:操作系统必须支持异步IO)。

    PS:在这种情况下,处理器无视IO就绪事件,它关注的是完成事件。

  2. 事件分离器等待操作完成事件

  3. 在分离器等待过程中,操作系统利用并行的内核线程执行实际的读操作,并将结果数据存入用户自定义缓冲区,最后通知事件分离器读操作完成

  4. 事件分离器监听到读完成事件,激活读完成事件的处理器

  5. 读完成事件处理器直接处理用户进程缓冲区中的数据,并将控制权返回事件分离器

在这里插入图片描述

可以看出,两个模式的相同点,都是对某个I/O事件的事件通知(即告诉某个模块,这个I/O操作可以进行或已经完成)。在结构上,两者也有相同点:事件分发器负责提交IO操作(异步)、查询设备是否可操作(同步),然后当条件满足时,就回调handler;不同点在于,异步情况下(Proactor),当回调handler时,表示I/O操作已经完成;同步情况下(Reactor),回调handler时,表示I/O设备可以进行某个操作(can read 或 can write)。

总结一下 Proactor和Reactor的区别:

  • Proactor是基于异步I/O的概念,而Reactor一般则是基于多路复用I/O的概念
  • Proactor不需要把数据从内核复制到用户空间,这步由系统完成
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

A minor

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值