Java并发之actor模型

Java并发之actor模型

Actor模型是一个概念模型,用于处理并发计算。它定义了一系列系统组件应该如何动作和交互的通用规则。

线程-对象模型的问题

锁和效率

OOP的核心特性是封装,封装规定对象的内部数据不能直接从外部访问,它只能通过调用一组方法来修改。当我们分析OOP运行时行为时,我们有时会绘制一个消息序列图,显示方法调用的交互。例如:

时序图

很显然,对应单线程来说,程序就像串行一样执行,下面考虑多线程情况:

多线程时序

显然,在一个执行部分中,两个线程进入相同的方法。对象的封装模型并不保证该部分中的线程安全,解决此问题的常用方法是围绕这些方法添加锁定,确保了在任何给定时间最多只有一个线程将进入该方法,但这显然是一个非常昂贵的策略:

  • 锁严重限制了并发性,在现代CPU架构上开销巨大。
  • 调用者线程现在被阻塞,无法执行任何其他工作。
  • Locks引入了一个新的威胁:死锁。

这些现实导致了一个两难的局面:

  1. 没有足够的锁,状态就会被破坏。
  2. 由于存在许多锁定,性能会受到影响并且很容易导致死锁。

此外,锁只能在本地很好地工作。在协调跨多台机器时,唯一的选择是分布式锁。但是分布式锁的效率比本地锁要低得多。分布式锁定协议需要在多台计算机上通过网络进行多次通信往返,因此导致延迟。

我们经常将系统设想为对象实例的网络,它们对方法调用作出反应,修改其内部状态,然后通过方法调用相互通信,在多线程分布式环境中,实际是线程通过方法调用“遍历”对象实例网络。

object network

总结:

  1. 对象只能在单线程访问时保护内部状态,多线程时,总是会导致内部转态损坏。
  2. 为了处理多线程问题必须要引入锁,但是实际上,锁的效率低下,且有死锁的风险性。
  3. 分布式锁的效率更低。

共享内存

80-90年代的编程模型表示,写入变量意味着直接写入存储位置(局部变量可能仅存在于寄存器中).在现代架构上,CPU正在写入缓存行而不是直接写入内存。这些高速缓存中的大多数都是CPU内核的本地高速缓存,也就是说,一个内核的写入不会被另一个内核看到。为了使本地更改对另一个核心可见,从而对另一个线程可见,需要将缓存行传送到另一个核心的缓存。

在JVM上,我们必须使用volatile标记或Atomic包装器明确表示要跨线程共享的内存位置。否则,我们只能在锁内部访问它们。为什么我们不将所有变量标记为volatile?因为跨核心运送缓存行是一项非常昂贵的操作!这样做会隐含地使所涉及的核心停止执行额外的工作,并导致高速缓存一致性协议(协议CPU用于在主存储器和其他CPU之间传输高速缓存线)的瓶颈。

volatile

总结:

  1. 现代架构中不再有真正的共享内存,cpu核心彼此间显式地传递数据。跨CPU核心通信已成为趋势。
  2. 除了使用易失性标记或是原子变量,更普遍的方法是将状态保存在并发实体本地,通过消息在并发实体之间传播数据。

回调

如何通知”调用者”完成任务?当任务因异常而失败时会出现更严重的问题。异常传播到哪里?它将传播到工作线程的异常处理程序,完全忽略了实际的”调用者”是谁。这种类似于网络系统的工作方式,其中消息/请求可能会丢失/失败而没有任何通知。

回调

总结:

  1. 为了在当前系统上实现任何有意义的并发性和性能,线程必须以有效的方式在彼此之间委派任务而不会阻塞。使用这种类型的任务委派并发,基于调用堆栈的错误处理会崩溃,需要引入新的显式错误信令机制。使失败成为领域模型的一部分
  2. 具有工作委派的并发系统需要处理服务故障,并具有从故障中恢复的原则方法。此类服务的客户端需要注意,任务/消息可能会在重新启动期间丢失。即使没有发生损失,由于先前排队的任务(较长的队列),垃圾回收所导致的延迟等,响应可能会被任意延迟。面对这些情况,并发系统应以超时的形式处理响应,例如联网/分布式系统

actor system

下面来关注下actor模型如何处理这些问题。

使用消息来避免锁和阻塞

actor之间不是互相调用方法,而是彼此发送消息。发送消息不会使得执行线程从发送者转移到接收者。actor可以发送消息并继续进行而不会受到阻碍。因此,它可以在相同的时间内完成更多工作。

对于对象,当方法返回时,它释放对其执行线程的控制。在这方面,参与者的行为很像对象,它们对消息做出反应并在完成对当前消息的处理后返回执行。通过这种方式,参与者实际上实现了我们为对象设想的执行:

传递消息和调用方法之间的重要区别是消息没有返回值。通过发送消息,actor将工作委托给另一位actor。

我们在模型中需要进行的第二个关键更改是恢复封装。参与者对消息做出反应,就像对象“响应”对其上调用的方法一样。区别在于,actor独立于消息发件人执行,一次对一个传入消息进行响应,而不是多个线程“突入”我们的对象并破坏内部状态。当每个actor都按顺序处理发送给它的消息时,不同的参与者会同时并发工作,以便参与者系统可以同时处理尽可能多的消息。

由于每个actor最多只能处理一条消息,因此可以保持状态的不变性而无需同步。从而无需使用锁

当actor收到消息时,会发生以下情况:

  1. actor将消息添加到队列的末尾。
  2. 如果调度器未启动actor,则将其标记为准备执行。
  3. 调度器接受该actor并开始执行它。
  4. Actor从队列的前面选择消息。
  5. Actor修改内部状态,将消息发送给其他Actor。
  6. actor解除调度。

为实现上述行为,actor具有:

  1. 邮箱(消息队列)。
  2. 行为(演员的状态,内部变量等)。
  3. 消息(代表信号的数据片段,类似于方法调用及其参数)。
  4. 执行环境(一种使收到消息的参与者响应并调用其消息处理代码的机制)。
  5. 地址。

上述模型解决了前面列举的问题:

  1. 通过将执行与信号分离(方法调用转移执行,消息传递不这样做)来保留封装。
  2. 不需要锁。只能通过消息修改actor的内部状态,消息一次处理一次,以保持不变性。
  3. 在任何地方都没有使用锁,发送者也不会被阻止。可以在十几个线程上有效地调度数百万个参与者,从而充分发挥现代CPU的潜力。任务委派是参与者的自然操作方式。
  4. 参与者的状态是本地的,而不是共享的,更改和数据通过消息传播,这些消息映射到现代内存层次结构的实际工作方式。在许多情况下,这意味着仅传递包含消息中数据的高速缓存行,同时将本地状态和数据保留在原始核心中。相同的模型准确地映射到远程通信,其中状态保存在机器的RAM中,更改/数据作为数据包在网络上传播.

优雅处理错误

由于不存在向彼此发送消息的参与者之间的共享调用堆栈,因此我们需要以不同的方式处理错误情况。我们需要考虑两种错误:

  1. 第一种情况是目标actor上的委派任务由于任务中的错误而失败(例如一些验证问题)。在这种情况下,目标actor的服务是完整的,只是任务本身是错误的。服务actor应该向发送方回复一条消息,说明错误情况。
  2. 第二种情况是服务本身遇到内部故障。Akka强制将所有actor组织成树状层次结构,这与操作系统将进程组织到树中的方式非常相似。就像流程一样,当一个actor失败时,其父actor可以决定如何对失败做出反应。同样,如果父actor停止,则其所有子代也将递归停止。这项服务称为监督,它是Akka的核心。

调度

Actor模型的任务调度方式分为“基于线程(thread-based)的调度”以及“基于事件(event-based)的调度”两种。

基于线程的调度为每个Actor分配一个线程,在接受一个消息时,如果当前Actor的“邮箱(mail box)”为空,则会阻塞当前线程直到获得消息为止。基于线程的调度实现起来较为简单,不过基于线程的调度缺点也是非常明显的,由于线程数量受到操作系统的限制,把线程和Actor捆绑起来势必影响到系统中可以同时的Actor数量。而线程数量一多也会影响到系统资源占用以及调度,而在某些情况下大部分的Actor会处于空闲状态,而大量阻塞线程既是系统的负担,也是资源的浪费。因此基于线程的调度是一个拥有重大缺陷的实现,现有的Actor Model大都不会采取这种方式。

于是另一种Actor模型的任务调度方式便是基于事件的调度。“事件”在这里可以简单理解为“消息到达”事件,而此时才会为Actor的任务分配线程并执行。很容易理解,我们现在便可以使用少量的线程来执行大量Actor产生的任务,既保证了运算资源的充分占用,也不会让系统在同时进行的太多任务中“疲惫不堪”,这样系统便可以得到很好的伸缩性。

现有的Actor Model一般都会使用基于事件的调度方式。不过某些实现,如MS CCR、Retlang、Jetlang等类库还需要客户指定资源分配方式,显式地指定Actor与资源池(即线程池)之间的对应关系。而如Erlang或Scala则隐藏了这方面的分配逻辑,由系统整体进行统一管理。前者与后者相比,由于进行了更多的人工干涉,其资源分配可以更加合理,执行效率也会更高——不过其缺点也很明显:会由此带来额外的复杂度。

-------------本文结束感谢您的阅读-------------
坚持分享,您的支持将鼓励我继续创作!
0%