架构风格之事件处理风格

在一些软件系统中会存在这样的需求,当系统中的某个组件因为用户操作或内部行为发布一个事件到事件中心,该组件知道这个事件在将来的某一个时间点会被其他某个组件所消费,但是并不知道这个组件具体是谁、也不关心什么时候被消费。同样,调用该事件的组件也不一定需要知道该事件是由哪个组件所发布。满足以上场景的系统代表着一种松耦合的架构,通常被称为事件系统,这种设计风格也就是我们要讨论的事件处理风格。

1. 事件处理系统组成结构

事件处理系统的基本组成见下图,包括事件发布(Publish)、订阅(Subscribe)和消费等基本过程。系统中的某一个组件发布事件时,该组件可以广播一个或多个事件到事件中心(Event Center),而系统中每一个对该事件感兴趣的组件都可以订阅这种事件。每当事件被传播时,系统将负责自动调用那些已经订阅了该事件的组件,组件中的事件处理程序将被运行。每个事件订阅者都可以有自己一套独立的事件处理程序,事件发布者并不关心它所发布的事件被如何消费。

事件作为一种传输媒介有两个主要特点,首先事件具备异步性和并发性,事件到达的时机是系统无法提前确定的;同时,事件一般都不止一种类型,一个系统中往往需要同时处理多种事件类型。因此,对事件的处理,我们同样需要设计一种抽象的事件处理策略。

2. 事件处理策略

基于上图,事件处理根据是否包含独立的事件分派器(Dispatcher)可以分为两种主要的处理策略。

(1)无独立事件分派器的事件处理策略

无独立事件分派器的事件处理策略比较简单,即系统中任何一个组件都可以表示对某个事件感兴趣并订阅该事件。当事件发生时,这些事件仅会发布给那些已经订阅该事件的组件。从这一点上看,无独立事件分派器的事件处理与设计模式中的观察者(Observer)模式比较类似,适用于相对较小的应用系统。

(2)有独立事件分派器的事件处理策略

有独立事件分派器的事件处理策略则相对复杂,在事件发布者和订阅者之间加了一层事件分派器。在系统中添加事件分派器的主要目的在于增加控制力度,因为在大型事件系统中,存在多种不同类型的事件,订阅者对于事件的订阅可能存在一些业务规则而不是简单的通用订阅。另一方面,发布者和订阅者的数量较大的情况下,也需要由事件分派器进行统一管理,事件分派器作为中间层组件就能起到管理和协调作用。

试想这样一种场景,存在两个独立的系统,分别为系统A和系统B。系统A负责管理用户,而系统B需要在当系统A中用户被创建和更新时能够获取通知并进行响应。这是一个分布式的业务场景,我们可以通过远程方法调用等方式进行系统A和系统B之间跨进程的交互,但更好的一种方式是通过触发创建和更新事件并进行解耦。通过事件处理风格,组件不再直接调用过程,而是声明事件,系统内部或外部的组件都可以对这些事件中进行订阅,当触发一个事件时,系统会自动调用在这个事件中注册的所有过程。针对该场景,我们可以抽象出如下图所示的系统结构图,该图中声明了用户创建事件UserCreatedEvent和用户更新事件UserUpdatedEvent,而UserCreatedHandler和UserUpdatedHandler分别是针对这两个事件的处理程序,通过EventDispatcher把所有Event和Handler进行关联,这里的EventDispatcher相当于上图中传输事件的基础设施。Event和Handler提供了高层次的抽象,具体的Event和Handler实现者可以分布在不同的业务系统中从而构成分布式运行环境。

3. 事件处理系统应用

这里将讨论时间处理风格在网络通信这个常见主题中的应用。我们在前面已经看到事件处理系统的抽象可以参考分层思想,即把事件驱动系统按照事件的来源和处理过程分成三个层次:事件源(Event Source)、事件分派器和事件响应程序(Event Handler)。对于网络通信而言,套接口(Socket)即是事件产生的源头,操作系统级别的select/poll/epoll程序对应事件分离器,而业务系统中各种应用程序就是事件响应程序。通过这种抽象,我们认识到在网络通信模型中,事件源和事件分离器往往并不属于应用程序所能控制的范围之内,应用程序能做的只是对事件的响应。在具体介绍事件处理系统的应用之前,我们先来看一下IO操作和事件驱动之间的关系。

(1)IO操作与事件驱动

现代操作系统都包括内核空间(Kernel Space)和用户空间(User Space),内核空间主要存放内核代码和数据,是供系统进程使用的空间;而用户空间主要存放的是用户代码和数据,是供用户进程使用的空间。一般的IO操作都分为两个阶段,以套接口的输入操作为例,它的两个阶段包括内核空间和用户空间之间的数据传输,即首先等待网络数据到来,当数据分组到来时,将其拷贝到内核空间的临时缓冲区中,然后将内核空间临时缓冲区中的数据拷贝到用户空间缓冲区中。围绕IO操作的这两个阶段,存在几种主流的IO操作模式(见下图),每个模式对应着不同的处理方式和效果:

  • 阻塞IO

阻塞IO(Blocking IO,BIO)在默认情况下,所有套接口都是阻塞的,意味着IO的发起和结束都需等待。任何一个系统调用都会产生一个由用户态到内核态切换,再从内核态到用户态切换的过程,而进程上下文切换是通过系统中断程序来实现的,需要保存当前进程的上下文状态,这是一个成本很高的过程。

  • 非阻塞IO

如果非阻塞IO(Non-blocking IO,NIO),即当我们把套接口设置成非阻塞时,就是由用户进程不停地询问内核某种操作是否准备就绪,这就是我们常说的轮询(Polling)。这同样是一件比较浪费CPU的方式。

  • IO复用

IO复用主要依赖于操作系统提供的select和poll机制。这里同样会阻塞进程,但是这里进程是阻塞在select或者poll这两个系统调用上,而不是阻塞在真正的IO操作上。另外还有一点不同于阻塞IO的就是,尽管看起来IO复用阻塞了两次,但是第一次阻塞是在select上时,select可以监控多个套接口上是否已有IO操作准备就绪,而不是像阻塞IO那种,一次只能监控一个套接口。

  • 信号驱动IO

信号驱动IO就是说我们可以通过sigaction系统调用注册一个信号处理程序,然后主程序可以继续向下执行,当我们所监控的套接口有IO操作准备就绪时,由内核通知触发前面注册的信号处理程序执行,然后将我们所需要的数据从内核空间拷贝到用户空间。

  • 异步IO

异步IO(Asynchronous IO,AIO)与信号驱动IO最主要的区别就是信号驱动IO是由内核通知我们何时可以进行IO操作,而异步IO则是由内核告诉我们IO操作何时完成了。具体来说就是,信号驱动IO中当内核通知触发信号处理程序时,信号处理程序还需要阻塞在从内核空间缓冲区拷贝数据到用户空间缓冲区这个阶段,而异步IO直接是在第二个阶段完成后内核直接通知可以进行后续操作。

结合下图中的各个IO模型效果图,我们发现前四种IO模型的主要区别是在第一阶段,因为它们的第二阶段都是在阻塞等待数据由内核空间拷贝到用户空间;而异步IO很明显与前面四种有所不同,它在第一阶段和第二阶段都不会阻塞。

IO模型在不同操作系统中有不同的实现方式,相较Windows系统,Linux系统为我们提供了更高性能的IO模型实现机制,这也是使用Linux作为服务器的主要原因。操作系统所具备的这些非阻塞IO和异步IO模型实际上就是事件处理系统中的事件分离器,有了事件分离器,我们就可以在此基础上实现针对业务的事件响应程序,也引出了针对事件处理的Reactor模式。

(2)Reactor模式

Reactor模式[9]定义事件循环(Event Loop),利用操作系统事件分离器支持单线程在一系列事件源上同步等待事件,这里有三个词需要注意,即单线程、一系列事件源和同步。Reactor模式体现的是IO复用思想,支持多个事件源响应,而响应的方式并不是采用多个线程,而只是使用一个单线程构建事件循环,这个事件循环是一个死循环,一直阻塞等待事件的发生。当事件发生时,事件循环从操作系统提供的事件分离器中获取事件,并将事件逐个分发对应的事件响应程序,后者对它的事件作出同步处理,这里的事件响应程序位于应用系统中,而且事件处理同样也是一个同步的过程(见下图)。

Reactor模式应用广泛,是实现诸如Netty、Mina等NIO通信框架的典型模式。下图是Reactor模式的另一种更加组件化的表现形式,图中Reactor相当于IO事件的派发器(Dispatcher),事件接收器(Acceptor)接受Client连接,绑定该Client请求与实现其对应具体业务逻辑的处理程序Handler,并向Reactor注册此Handler。一般在基本的Handler基础上还会有更进一步的层次划分,用于抽象诸如read、decode、compute、encode和send等操作。当Handler处理完成时,Acceptor就会唤醒该Handler所绑定的Client,从而实现从Client到Handler再到Client的请求响应式处理过程。这里要注意的是,Acceptor使用单线程异步接受来自Client的请求,而Handler中对业务流程的处理仍然是同步的。

由于Handler的同步处理机制,Reactor模式适合于处理时间短且不阻塞IO的业务操作,有一定局限性,但使用单线程处理多任务能够避免多线程所带来的复杂性,事件串行同步处理且每次分离和分发一个事件,为开发人员提供尽量简单的编程模型。如果希望提升Handler中业务处理的效率,可以优化部分步骤。上图中,组成Handler的decode、compute、encode等抽象步骤可以引入线程池(Thread Pool)中工作线程(Working Thread)的方式进行并行化,从而把同步Handler转化成部分异步的Handler。同时,也可以采用多个Acceptor线程构成线程组来避免单个Acceptor可能出现的性能瓶颈。

如果对文章感兴趣,可以关注我的微信公众号:程序员向架构师转型,或扫描下面的二维码。

我出版了《系统架构设计:程序员向架构师转型之路》、《向技术管理者转型:软件开发人员跨越行业、技术、管理的转型思维与实践》、《微服务设计原理与架构》、《微服务架构实战》等书籍,并翻译有《深入RabbitMQ》和《Spring5响应式编程实战》,欢迎交流

发布了92 篇原创文章 · 获赞 9 · 访问量 11万+

猜你喜欢

转载自blog.csdn.net/lantian08251/article/details/99006865