关于reactor:带你彻底搞懂高性能网络模式Reactor-和-Proactor

42次阅读

共计 8326 个字符,预计需要花费 21 分钟才能阅读完成。

​​​​摘要:无论是 Reactor,还是 Proactor,都是一种基于「事件散发」的网络编程模式,区别在于 Reactor 模式是基于「待实现」的 I/O 事件,而 Proactor 模式则是基于「已实现」的 I/O 事件。

本文分享自华为云社区《高性能网络框架:Reactor 和 Proactor》,原文作者:小林 coding。

这次就来图解 Reactor 和 Proactor 这两个高性能网络模式。

别小看这两个货色,特地是 Reactor 模式,市面上常见的开源软件很多都采纳了这个计划,比方 Redis、Nginx、Netty 等等,所以学好这个模式设计的思维,不仅有助于咱们了解很多开源软件,而且也能在面试时吹逼。

发车!

演进

如果要让服务器服务多个客户端,那么最间接的形式就是为每一条连贯创立线程。

其实创立过程也是能够的,原理是一样的,过程和线程的区别在于线程比拟轻量级些,线程的创立和线程间切换的老本要小些,为了形容简述,前面都以线程为例。

解决完业务逻辑后,随着连贯敞开后线程也同样要销毁了,然而这样不停地创立和销毁线程,不仅会带来性能开销,也会造成浪费资源,而且如果要连贯几万条连贯,创立几万个线程去应答也是不事实的。

要这么解决这个问题呢?咱们能够应用「资源复用」的形式。

也就是不必再为每个连贯创立线程,而是创立一个「线程池」,将连贯调配给线程,而后一个线程能够解决多个连贯的业务。

不过,这样又引来一个新的问题,线程怎样才能高效地解决多个连贯的业务?

当一个连贯对应一个线程时,线程个别采纳「read -> 业务解决 -> send」的解决流程,如果以后连贯没有数据可读,那么线程会阻塞在 read 操作上(socket 默认状况是阻塞 I/O),不过这种阻塞形式并不影响其余线程。

然而引入了线程池,那么一个线程要解决多个连贯的业务,线程在解决某个连贯的 read 操作时,如果遇到没有数据可读,就会产生阻塞,那么线程就没方法持续解决其余连贯的业务。

要解决这一个问题,最简略的形式就是将 socket 改成非阻塞,而后线程一直地轮询调用 read 操作来判断是否有数据,这种形式尽管该可能解决阻塞的问题,然而解决的形式比拟粗犷,因为轮询是要耗费 CPU 的,而且随着一个 线程解决的连贯越多,轮询的效率就会越低。

下面的问题在于,线程并不知道以后连贯是否有数据可读,从而须要每次通过 read 去试探。

那有没有方法在只有当连贯上有数据的时候,线程才去发动读申请呢?答案是有的,实现这一技术的就是 I/O 多路复用。

I/O 多路复用技术会用一个零碎调用函数来监听咱们所有关怀的连贯,也就说能够在一个监控线程外面监控很多的连贯。

咱们相熟的 select/poll/epoll 就是内核提供给用户态的多路复用零碎调用,线程能够通过一个零碎调用函数从内核中获取多个事件。

PS:如果想晓得 select/poll/epoll 的区别,能够看看小林之前写的这篇文章:这次许可我,一举拿下 I/O 多路复用!

select/poll/epoll 是如何获取网络事件的呢?

在获取事件时,先把咱们要关怀的连贯传给内核,再由内核检测:

  • 如果没有事件产生,线程只需阻塞在这个零碎调用,而无需像后面的线程池计划那样轮训调用 read 操作来判断是否有数据。
  • 如果有事件产生,内核会返回产生了事件的连贯,线程就会从阻塞状态返回,而后在用户态中再解决这些连贯对应的业务即可。
    当下开源软件能做到网络高性能的起因就是 I/O 多路复用吗?

是的,根本是基于 I/O 多路复用,用过 I/O 多路复用接口写网络程序的同学,必定晓得是面向过程的形式写代码的,这样的开发的效率不高。

于是,大佬们基于面向对象的思维,对 I/O 多路复用作了一层封装,让使用者不必思考底层网络 API 的细节,只须要关注利用代码的编写。

大佬们还为这种模式取了个让人第一工夫难以了解的名字:Reactor 模式。

Reactor 翻译过去的意思是「反应堆」,可能大家会联想到物理学里的核反应堆,实际上并不是的这个意思。

这里的反馈指的是「对事件反馈」,也就是来了一个事件,Reactor 就有绝对应的反馈 / 响应。

事实上,Reactor 模式也叫 Dispatcher 模式,我感觉这个名字更贴合该模式的含意,即 I/O 多路复用监听事件,收到事件后,依据事件类型调配(Dispatch)给某个过程 / 线程。

Reactor 模式次要由 Reactor 和解决资源池这两个外围局部组成,它俩负责的事件如下:

  • Reactor 负责监听和散发事件,事件类型蕴含连贯事件、读写事件;
  • 解决资源池负责处理事件,如 read -> 业务逻辑 -> send;

Reactor 模式是灵便多变的,能够应答不同的业务场景,灵便在于:

  • Reactor 的数量能够只有一个,也能够有多个;
  • 解决资源池能够是单个过程 / 线程,也能够是多个过程 / 线程;

将下面的两个因素排列组设一下,实践上就能够有 4 种计划抉择:

  • 单 Reactor 单过程 / 线程;
  • 单 Reactor 多过程 / 线程;
  • 多 Reactor 单过程 / 线程;
  • 多 Reactor 多过程 / 线程;

其中,「多 Reactor 单过程 / 线程」实现计划相比「单 Reactor 单过程 / 线程」计划,不仅简单而且也没有性能劣势,因而理论中并没有利用。

剩下的 3 个计划都是比拟经典的,且都有利用在理论的我的项目中:

  • 单 Reactor 单过程 / 线程;
  • 单 Reactor 多线程 / 过程;
  • 多 Reactor 多过程 / 线程;

计划具体应用过程还是线程,要看应用的编程语言以及平台无关:

  • Java 语言个别应用线程,比方 Netty;
  • C 语言应用过程和线程都能够,例如 Nginx 应用的是过程,Memcache 应用的是线程。

接下来,别离介绍这三个经典的 Reactor 计划。

Reactor

单 Reactor 单过程 / 线程

一般来说,C 语言实现的是「单 Reactor 单过程」的计划,因为 C 语编写完的程序,运行后就是一个独立的过程,不须要在过程中再创立线程。

而 Java 语言实现的是「单 Reactor 单线程」的计划,因为 Java 程序是跑在 Java 虚拟机这个过程下面的,虚拟机中有很多线程,咱们写的 Java 程序只是其中的一个线程而已。

咱们来看看「单 Reactor 单过程」的计划示意图:

能够看到过程里有 Reactor、Acceptor、Handler 这三个对象:

  • Reactor 对象的作用是监听和散发事件;
  • Acceptor 对象的作用是获取连贯;
  • Handler 对象的作用是解决业务;

对象里的 select、accept、read、send 是零碎调用函数,dispatch 和「业务解决」是须要实现的操作,其中 dispatch 是散发事件操作。

接下来,介绍下「单 Reactor 单过程」这个计划:

  • Reactor 对象通过 select(IO 多路复用接口)监听事件,收到事件后通过 dispatch 进行散发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
  • 如果是连贯建设的事件,则交由 Acceptor 对象进行解决,Acceptor 对象会通过 accept 办法 获取连贯,并创立一个 Handler 对象来解决后续的响应事件;
  • 如果不是连贯建设事件,则交由以后连贯对应的 Handler 对象来进行响应;
  • Handler 对象通过 read -> 业务解决 -> send 的流程来实现残缺的业务流程。
    单 Reactor 单过程的计划因为全副工作都在同一个过程内实现,所以实现起来比较简单,不须要思考过程间通信,也不必放心多过程竞争。

然而,这种计划存在 2 个毛病:

  • 第一个毛病,因为只有一个过程,无奈充分利用 多核 CPU 的性能;
  • 第二个毛病,Handler 对象在业务解决时,整个过程是无奈解决其余连贯的事件的,如果业务解决耗时比拟长,那么就造成响应的提早;
    所以,单 Reactor 单过程的计划不实用计算机密集型的场景,只实用于业务解决十分疾速的场景。

Redis 是由 C 语言实现的,它采纳的正是「单 Reactor 单过程」的计划,因为 Redis 业务解决次要是在内存中实现,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的解决是单过程的计划。

单 Reactor 多线程 / 多过程

如果要克服「单 Reactor 单线程 / 过程」计划的毛病,那么就须要引入多线程 / 多过程,这样就产生了单 Reactor 多线程 / 多过程的计划。

闻其名不如看其图,先来看看「单 Reactor 多线程」计划的示意图如下:

具体说一下这个计划:

  • Reactor 对象通过 select(IO 多路复用接口)监听事件,收到事件后通过 dispatch 进行散发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
  • 如果是连贯建设的事件,则交由 Acceptor 对象进行解决,Acceptor 对象会通过 accept 办法 获取连贯,并创立一个 Handler 对象来解决后续的响应事件;
  • 如果不是连贯建设事件,则交由以后连贯对应的 Handler 对象来进行响应;

下面的三个步骤和单 Reactor 单线程计划是一样的,接下来的步骤就开始不一样了:

  • Handler 对象不再负责业务解决,只负责数据的接管和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务解决;
  • 子线程里的 Processor 对象就进行业务解决,解决完后,将后果发给主线程中的 Handler 对象,接着由 Handler 通过 send 办法将响应后果发送给 client;

单 Reator 多线程的计划劣势在于可能充分利用多核 CPU 的能,那既然引入多线程,那么天然就带来了多线程竞争资源的问题。

例如,子线程实现业务解决后,要把后果传递给主线程的 Reactor 进行发送,这里波及共享数据的竞争。

要防止多线程因为竞争共享资源而导致数据错乱的问题,就须要在操作共享资源前加上互斥锁,以保障任意工夫里只有一个线程在操作共享资源,待该线程操作完开释互斥锁后,其余线程才有机会操作共享数据。

聊完单 Reactor 多线程的计划,接着来看看单 Reactor 多过程的计划。

事实上,单 Reactor 多过程相比单 Reactor 多线程实现起来很麻烦,次要因为要思考子过程 <-> 父过程的双向通信,并且父过程还得晓得子过程要将数据发送给哪个客户端。

而多线程间能够共享数据,尽管要额定思考并发问题,然而这远比过程间通信的复杂度低得多,因而理论利用中也看不到单 Reactor 多过程的模式。

另外,「单 Reactor」的模式还有个问题,因为一个 Reactor 对象承当所有事件的监听和响应,而且只在主线程中运行,在面对霎时高并发的场景时,容易成为性能的瓶颈的中央。

多 Reactor 多过程 / 线程

要解决「单 Reactor」的问题,就是将「单 Reactor」实现成「多 Reactor」,这样就产生了第 多 Reactor 多过程 / 线程的计划。

老规矩,闻其名不如看其图。多 Reactor 多过程 / 线程计划的示意图如下(以线程为例):

计划具体阐明如下:

  • 主线程中的 MainReactor 对象通过 select 监控连贯建设事件,收到事件后通过 Acceptor 对象中的 accept 获取连贯,将新的连贯调配给某个子线程;
  • 子线程中的 SubReactor 对象将 MainReactor 对象调配的连贯退出 select 持续进行监听,并创立一个 Handler 用于解决连贯的响应事件。
  • 如果有新的事件产生时,SubReactor 对象会调用以后连贯对应的 Handler 对象来进行响应。
  • Handler 对象通过 read -> 业务解决 -> send 的流程来实现残缺的业务流程。

多 Reactor 多线程的计划尽管看起来简单的,然而理论实现时比单 Reactor 多线程的计划要简略的多,起因如下:

  • 主线程和子线程分工明确,主线程只负责接管新连贯,子线程负责实现后续的业务解决。
  • 主线程和子线程的交互很简略,主线程只须要把新连贯传给子线程,子线程毋庸返回数据,间接就能够在子线程将处理结果发送给客户端。

赫赫有名的两个开源软件 Netty 和 Memcache 都采纳了「多 Reactor 多线程」的计划。

采纳了「多 Reactor 多过程」计划的开源软件是 Nginx,不过计划与规范的多 Reactor 多过程有些差别。

具体差别体现在主过程中仅仅用来初始化 socket,并没有创立 mainReactor 来 accept 连贯,而是由子过程的 Reactor 来 accept 连贯,通过锁来管制一次只有一个子过程进行 accept(防止出现惊群景象),子过程 accept 新连贯后就放到本人的 Reactor 进行解决,不会再调配给其余子过程。

Proactor
后面提到的 Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式。

这里先给大家温习下阻塞、非阻塞、同步、异步 I/O 的概念。

先来看看阻塞 I/O,当用户程序执行 read,线程会被阻塞,始终等到内核数据筹备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程实现,read 才会返回。

留神,阻塞期待的是「内核数据筹备好」和「数据从内核态拷贝到用户态」这两个过程。过程如下图:

晓得了阻塞 I/O,来看看非阻塞 I/O,非阻塞的 read 申请在数据未筹备好的状况下立刻返回,能够持续往下执行,此时应用程序一直轮询内核,直到数据筹备好,内核将数据拷贝到应用程序缓冲区,read 调用才能够获取到后果。过程如下图:

留神,这里最初一次 read 调用,获取数据的过程,是一个同步的过程,是须要期待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。

举个例子,如果 socket 设置了 O_NONBLOCK 标记,那么就示意应用的是非阻塞 I/O 的形式拜访,而不做任何设置的话,默认是阻塞 I/O。

因而,无论 read 和 send 是阻塞 I/O,还是非阻塞 I/O 都是同步调用。因为在 read 调用时,内核将数据从内核空间拷贝到用户空间的过程都是须要期待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中期待比拟长的工夫。

而真正的异步 I/O 是「内核数据筹备好」和「数据从内核态拷贝到用户态」这两个过程都不必期待。

当咱们发动 aio_read(异步 I/O)之后,就立刻返回,内核主动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核主动实现的,和后面的同步操作不一样,应用程序并不需要被动发动拷贝动作。过程如下图:

举个你去饭堂吃饭的例子,你好比应用程序,饭堂好比操作系统。

阻塞 I/O 好比,你去饭堂吃饭,然而饭堂的菜还没做好,而后你就始终在那里等啊等,等了好长一段时间终于等到饭堂阿姨把菜端了进去(数据筹备的过程),然而你还得持续等阿姨把菜(内核空间)打到你的饭盒里(用户空间),经验完这两个过程,你才能够来到。

非阻塞 I/O 好比,你去了饭堂,问阿姨菜做好了没有,阿姨通知你没,你就来到了,过几十分钟,你又来饭堂问阿姨,阿姨说做好了,于是阿姨帮你把菜打到你的饭盒里,这个过程你是得期待的。

异步 I/O 好比,你让饭堂阿姨将菜做好并把菜打到饭盒里后,把饭盒送到你背后,整个过程你都不须要任何期待。

很显著,异步 I/O 比同步 I/O 性能更好,因为异步 I/O 在「内核数据筹备好」和「数据从内核空间拷贝到用户空间」这两个过程都不必期待。

Proactor 正是采纳了异步 I/O 技术,所以被称为异步网络模型。

当初咱们再来了解 Reactor 和 Proactor 的区别,就比拟清晰了。

  • Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件产生(比方可读就绪事件)后,就须要利用过程被动调用 read 办法来实现数据的读取,也就是要利用过程被动将 socket 接管缓存中的数据读到利用过程内存中,这个过程是同步的,读取完数据后利用过程能力解决数据。
  • Proactor 是异步网络模式,感知的是已实现的读写事件。在发动异步读写申请时,须要传入数据缓冲区的地址(用来寄存后果数据)等信息,这样零碎内核才能够主动帮咱们把数据的读写工作实现,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还须要利用过程被动发动 read/write 来读写数据,操作系统实现读写工作后,就会告诉利用过程间接解决数据。

因而,Reactor 能够了解为「来了事件操作系统告诉利用过程,让利用过程来解决」,而 Proactor 能够了解为「来了事件操作系统来解决,解决完再告诉利用过程」。这里的「事件」就是有新连贯、有数据可读、有数据可写的这些 I/O 事件这里的「解决」蕴含从驱动读取到内核以及从内核读取到用户空间。

举个理论生存中的例子,Reactor 模式就是快递员在楼下,给你打电话通知你快递到你家小区了,你须要本人下楼来拿快递。而在 Proactor 模式下,快递员间接将快递送到你家门口,而后告诉你。

无论是 Reactor,还是 Proactor,都是一种基于「事件散发」的网络编程模式,区别在于 Reactor 模式是基于「待实现」的 I/O 事件,而 Proactor 模式则是基于「已实现」的 I/O 事件。

接下来,一起看看 Proactor 模式的示意图:

介绍一下 Proactor 模式的工作流程:

  • Proactor Initiator 负责创立 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过
  • Asynchronous Operation Processor 注册到内核;
  • Asynchronous Operation Processor 负责解决注册申请,并解决 I/O 操作;
  • Asynchronous Operation Processor 实现 I/O 操作后告诉 Proactor;
  • Proactor 依据不同的事件类型回调不同的 Handler 进行业务解决;
  • Handler 实现业务解决;

惋惜的是,在 Linux 下的异步 I/O 是不欠缺的,aio 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别反对的,而是在用户空间模仿进去的异步,并且仅仅反对基于本地文件的 aio 异步操作,网络编程中的 socket 是不反对的,这也使得基于 Linux 的高性能网络程序都是应用 Reactor 计划。

而 Windows 里实现了一套残缺的反对 socket 的异步编程接口,这套接口就是 IOCP,是由操作系统级别实现的异步 I/O,真正意义上异步 I/O,因而在 Windows 里实现高性能网络程序能够应用效率更高的 Proactor 计划。

总结

常见的 Reactor 实现计划有三种。

第一种计划单 Reactor 单过程 / 线程,不必思考过程间通信以及数据同步的问题,因而实现起来比较简单,这种计划的缺点在于无奈充分利用多核 CPU,而且解决业务逻辑的工夫不能太长,否则会提早响应,所以不适用于计算机密集型的场景,实用于业务解决疾速的场景,比方 Redis 采纳的是单 Reactor 单过程的计划。

第二种计划单 Reactor 多线程,通过多线程的形式解决了计划一的缺点,但它离高并发还差一点间隔,差在只有一个 Reactor 对象来承当所有事件的监听和响应,而且只在主线程中运行,在面对霎时高并发的场景时,容易成为性能的瓶颈的中央。

第三种计划多 Reactor 多过程 / 线程,通过多个 Reactor 来解决了计划二的缺点,主 Reactor 只负责监听事件,响应事件的工作交给了从 Reactor,Netty 和 Memcache 都采纳了「多 Reactor 多线程」的计划,Nginx 则采纳了相似于「多 Reactor 多过程」的计划。

Reactor 能够了解为「来了事件操作系统告诉利用过程,让利用过程来解决」,而 Proactor 能够了解为「来了事件操作系统来解决,解决完再告诉利用过程」。

因而,真正的大杀器还是 Proactor,它是采纳异步 I/O 实现的异步网络模型,感知的是已实现的读写事件,而不须要像 Reactor 感知到事件后,还须要调用 read 来从内核中获取数据。

不过,无论是 Reactor,还是 Proactor,都是一种基于「事件散发」的网络编程模式,区别在于 Reactor 模式是基于「待实现」的 I/O 事件,而 Proactor 模式则是基于「已实现」的 I/O 事件。

参考资料

https://cloud.tencent.com/developer/article/1373468

https://blog.csdn.net/qq_27788177/article/details/98108466

https://time.geekbang.org/column/article/8805

https://www.cnblogs.com/crazymakercircle/p/9833847.html

点击关注,第一工夫理解华为云陈腐技术~

正文完
 0