Skip to content

Actor模型 ​

第一次看到Actor模型这个词还要追述到几年前在网易实习的时候,当时组内便是基于Actor做相关的一些开发,然后去了解了一下,后续也没什么实践的地方,只在一些书本或文章上看到过相关的一些内容,逐渐都快忘了这么一个东西,但最近无意间在github上又看到了相关内容,比较感兴趣,遂又去了解了一下,在此简单记录

关于Actor ​

Actor模型是一种用于处理并发计算和分布式系统的抽象模型。由Carl Hewitt在1973年提出,它将计算过程分解为独立的、并发的“actor”。每个actor是一个独立的计算实体,具有自己的状态和行为,并通过消息传递与其他actor进行通信。也就是说,它可以被分配,分布,调度到不同的CPU,不同的节点,乃至不同的时间片上运行,而不影响最终的结果。因此Actor在空间(分布式)和时间(异步驱动)上解耦的。我们在了解actor模型之前,首先来了解actor模型主要是为了解决什么样的问题。

  1. 在面向对象的语言中一个显著的特点是封装,然后通过对象提供的一些方法来操作其状态,但是共享内存的模型下,多线程对共享对象的并发访问会造成并发安全问题。一般会采用加锁的方式去解决,但加锁会带来一些问题:
    • 加锁的开销很大,线程上下文切换的开销大
    • 加锁导致线程block,无法去执行其他的工作,被block无法执行的线程,其实也是占据了一种系统资源
    • 加锁在编程语言层面无法防止隐藏的死锁问题
  2. 我们知道Java中并发模型是通过共享内存来实现。而cpu中会利用局部cache来加速主存的访问,为了解决多线程间缓存不一致的问题,在java中一般会通过使用volatile或者Atmoic来标记变量,通过Jmm的happens before机制来保障多线程间共享变量的可见性。因此从某种意义上来说是没有共享内存的,而是通过cpu将cache line的数据刷新到主存的方式来实现可见。因此与其去通过标记共享变量或者加锁的方式,依赖cpu缓存更新,倒不如每个并发实例之间只保存local的变量,而在不同的实例之间通过message来传递。
  3. call stack的问题:当我们编程模型异步化之后,还有一个比较大的问题是调用栈转移的问题,如下图中主线程提交了一个异步任务到队列中,Worker thread 从队列提取任务执行,调用栈就变成了workthread发起的,当任务出现异常时,处理和排查就变得困难。

那Actor模型又是如何解决这些问题的呢?

使用 Actor 允许我们:

  • 在不使用锁的情况下强制封装。
  • 利用协同实体对信号作出反应、改变状态、相互发送信号的模型来驱动整个应用程序向前发展。
  • 不要担心执行机制与我们的世界观(world view)不匹配。

Actor 的核心思想是 独立维护隔离状态,并基于消息传递实现异步通信。围绕其进行实现,actor 通常包含以下特征:

  • 每个 actor 持有一个邮箱(mailbox),本质上是一个队列,用于存储消息。
  • 每个 actor 可以发送消息至任何 actor。
  • 每个 actor 可以通过处理消息来更新内部状态,对于外部而言,actor 的状态是隔离的状态(isolated state)。

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

  • Actor 将消息添加到队列的末尾。
  • 如果 Actor 没有执行计划,则将其标记为准备执行。
  • 一个(隐藏的)调度程序实体获取 Actor 并开始执行它。
  • Actor 从队列前面选择消息。
  • Actor 修改内部状态,向其他 Actor 发送消息。
  • Actor 没有预约(unscheduled)。

为了便于通信,actor 模型使用 异步 消息传递。消息传递不使用任何中间实体,如:通道(channel)。由于 actor 模型的消息是异步传递的,中间可能会经过很长时间,甚至丢失,因此无法保证消息到达目标 actor 时的顺序。每个 actor 都完全独立于任何其他实例,actor 之间的交互完全基于异步消息,因此能够在很大程度上避免共享内存的存在问题。

任务调度 ​

Actor 模型根据任务调度的方式可以分为两种,分别是:

  • 基于线程(thread-based)的 actor 模型
  • 事件驱动(event-driven)的 actor 模型

基于线程(thread-based)的 actor 模型 ​

基于线程的 actor 模型,其本质是为每一个 actor 分配一个独立的“线程”。这里的“线程”并不是严格意义的操作系统线程,而是广泛意义的执行过程,它可以是线程、协程或虚拟机线程。

在基于线程的 actor 模型中,每个 actor 独占一个线程,如果当前 actor 的邮箱为空,actor 会阻塞当前线程,等待接收新的消息。在实现中,一般使用 receive 原语。

这种 actor 模型实现起来比较简单,但是缺点也非常明显,由于线程数量受到系统的限制,因此 actor 的数量也会受到限制。现阶段,只有少部分 actor 模型采用基于线程的实现方式,如:Erlang、Scala Actor、Cloud Haskell。

事件驱动的 actor 模型 ​

在事件驱动的 actor 模型,actor 并不直接与线程耦合,只有在事件触发(即接收消息)时,才为 actor 的任务分配线程并执行。这种方式使用续体闭包(Continuation Closure)来封装 actor 及其状态。当事件处理完毕,即退出线程。通过这种方式,我们可以使用很少的线程来执行大量 actor 产生的任务。在实现中,一般使用 react 原语。

事件驱动的 actor 模型在消息触发时,会自动创建并分配线程。在这种过程中,一般的优化是将 actor 执行建立在底层的线程池之上,这些线程可以是线程、协程或虚拟机线程。从概念上讲,这种实现与 run loop、event loop 机制非常相似。

现阶段,大部分 actor 模型采用事件驱动的调度方式。

Akka ​

Akka 是一个用 Scala 编写的库,用于在 JVM 平台上简化编写具有可容错的、高可伸缩性的 Java 和 Scala 的 Actor 模型应用,其同时提供了Java 和 Scala 的开发接口。Akka 允许我们专注于满足业务需求,而不是编写初级代码。在 Akka 中,Actor 之间通信的唯一机制就是消息传递。Akka 对 Actor 模型的使用提供了一个抽象级别,使得编写正确的并发、并行和分布式系统更加容易。Actor 模型贯穿了整个 Akka 库,为我们提供了一致的理解和使用它们的方法。

在面向对象语言中,我们通常很少考虑线程或线性执行路径。我们通常将系统设想为一个对象实例网络,这些对象实例对方法调用作出反应,修改其内部状态,然后通过方法调用相互通信,从而推动整个应用程序状态前进:

但是,在多线程分布式环境中,实际发生的情况是线程通过以下方法调用“遍历”对象实例网络。因此,线程才是真正推动执行的因素:

Actor 能够优雅地处理错误情况 ​

由于我们不再拥有在相互发送消息的 Actor 之间共享的调用栈,因此我们需要以不同的方式处理错误情况。我们需要考虑两种错误:

  • 第一种情况是,由于任务中的错误(通常是一些验证问题,如不存在的用户 ID),目标 Actor 上的委派任务失败。在这种情况下,由目标 Actor 封装的服务是完整的,只有任务本身是错误的。服务 Actor 应该用一条消息回复发送者,并显示错误情况。这里没有什么特别的,错误是域的一部分,因此错误也是普通消息。
  • 第二种情况是当服务本身遇到内部故障时。Akka 要求所有 Actor 都被组织成一个树形的结构,即一个创造另一个 Actor 的 Actor 成为新 Actor 的父节点。这与操作系统将流程组织到树中的方式非常相似。就像处理过程一样,当一个 Actor 失败时,它的父 Actor 会得到通知,并且它可以对失败做出反应。另外,如果父 Actor 被停止,那么它的所有子 Actor 也将被递归地停止。这项服务称为监督,是 Akka 的核心概念。

一个监督者(父级节点)可以决定在某些类型的失败时重新启动其子 Actor,或者在其他失败时完全停止它们。子 Actor 永远不会默不作声地死去(除了进入一个无限循环之外),相反,他们要么失败,他们的父级可以对错误作出反应,要么他们被停止(在这种情况下,相关方会被自动通知)。总是有一个负责管理 Actor 的实体:它的父节点。从外部看不到重新启动:协作 Actor 可以在目标 Actor 重新启动时继续发送消息。

小结 ​

从我个人理解角度说,akka的actor模型采用基于消息传递的机制实现并发编程,可以实现无锁异步化的编程模型,并且通过亲和性调度等方案,可以更好的利用cpu cache,对于高并发场景来说应该是一大利器。同样其中的某些思想,也适用于实时数据处理、分布式系统、IOT系统、游戏开发等场景。

Released under the MIT License.