CH05-Actor

使用 Actor 就像租车——如果我们需要,可以很快速的租到一辆;如果车辆发生故障,也不需要自己修理,直接换一辆即可。

Actor 模型是一种适用性非常好的通用并发编程模型。它可以应用于共享内存架构和分布式内存架构,适合解决地理分布问题,同时还能提供很好的容错性。

更加面向对象

函数式编程使用可变状态,也就避免了共享可变状态带来的一系列问题。相比之下,使用 Actor 模型保留了可变状态,但不将其共享。

Actor 类似于 OOP 中的对象——其中封装了状态,并通过消息与其他 Actor 通信。两者的区别是所有 Actor 可以同时运行,而且,与 OO 式的“消息传递(实质上是方法调用)”不同,actor 之间是真实的在传递消息。

Actor 模型是一个通用的并发编程模型,几乎可以用在任何一种编程语言里,最典型的是 Erlang。而我们将使用 Elixir 来介绍 actor 模型,它是 Erlang 虚拟机(BEAM)上一种较新的编程语言。

与 Clojure 相比,Elixir 是一种不纯粹的、动态类型的函数式语言。

消息与信箱

在 Elixir 中,进程是一个轻量级的概念,比操作系统的线程还要轻,它消耗更少的资源且创建代价很低。Elixir 程序可以毫不困难的创建数千个进程,通常不需要依赖线程池技术。

对列式信箱

异步的发送消息是使用 actor 模型的重要特性之一。消息并非直接发送到一个 actor,而是发送到一个 mailbox。

这样的设计解耦了 actor 之间的关系——actor 都以自己的步调运行,发送消息时也不会被阻塞。

虽然所有 Actor 可以同时运行,但它们都按照信箱接收到消息的顺序来依次处理消息,且仅在当前消息处理完成之后才会开始处理下一条消息,因此我们只需要关心发送消息时的并发问题即可。

接收消息

def loop do 
  receive do 
    {:greet, name} -> IO.puts("Hello #{name}") 
    {:praise, name} -> IO.puts("#{name}, you're amazing") 
    {:celebrate, name, age} -> IO.puts("Here's to another #{age} years, #{name}") 
  end 
  loop 
end

通常 actor 会进行无限循环,通过 receive 等待接收消息,并进行消息处理。在 Elixir 的 actor 实现中,内部的一个函数通过递归调用自己来进行无限循环,用 receive 来等待一个消息,通过模式匹配来决定如何处理消息。这

Elixir 实现了尾调用消除,即,如果函数在最后调用了自己,那么递归调用将被替换成一个简单的跳转,这样可以避免递归引起的堆栈移除。

连接到进程

为了彻底关闭一个 actor,需要满足两个条件。第一个是需要告诉 actor 在完成消息处理后就关闭;第二个是需要知道 actor 何时完成关闭。

首先,通过接收一个显式的关闭消息来满足第一个条件:

receive do
  ...
  {:shutdown} -> exit(:normal)
  ...

然后,通过一个方法来获知 actor 是否完全关闭。下面的代码将 :trap_exit 设为 true,并用 spawn_link 替换 spawn 以连接到进程:

Process.flag(:trap_exit, true)
pid = spawn_link(&Talker.loop/0)

现在当创建的进程关闭时,就会得到一个通知(是一个系统产生的消息)。

双向通信

Actor 是以异步的方式发送消息的——发送者因此不会被阻塞。那么如何获得一个消息的回复呢?

Actor 模型没有提供直接回复消息的机制,但我们可以轻松实现:将发送进程的标示符包含在消息中,接收者接收到消息后提取其中的标识符,然后向该标识符表示的进程发送回复消息。

为进程命名

将一个消息发送给某个进程时,需要知道进程的标示符。当我们自己创建进程时没有问题,但如何向别人创建的进程发送消息呢?最简单的方式就是为进程命名。

错误处理与容错

错误检测

前面我们使用 spawn_link 建立了两个进程之间的连接,这样就可以检测到某个进程的终止。Linking 是 Elixir 编程中的一个重要概念。

  • 进程的异常终止通过连接进行传播。
  • 连接是双向的。
  • 正常终止时不影响相连接的其他进程。
  • 通过设置 trap_exit 标识可以让一个进程捕获到另一个进程的终止消息,即,将该进程转化为系统进程。

管理进程

可以创建一个系统进程来管理其他若干个进程。

错误处理内核模式

Tony Hoare 有一句名言: 软件设计有两种方式:一种是使软件过于简单,明显的没有缺陷;另一种是使软件过于复杂,没有明显的缺陷。

Actor 提供了一种容错的方式:错误处理内核模式。在两者之间找到了一种平衡。

一个软件系统如何应用了错误处理内核模式,那么使该系统正确运行的前提是其错误处理内核必须能够正确运行。程序的程序通常使用尽可能小的错误处理内核——小而简单到明显没有缺陷。

对于一个使用 actor 模型的程序,其错误处理内核是顶层的管理者,管理着子进程——对子进程进行启动、停止、重启等操作。

程序的每个模块都有自己的错误处理内核——模块正确运行的前提是其错误处理内核必须正确运行。子模块也会拥有自己的错误处理内核,依次类推。这就构成了一个错误处理的层级树,较危险的操作都会被下放给底层的 actor 执行。

NAME

错误处理内核机制主要解决了防御式编程中碰到的一些棘手问题。

任其崩溃

防御式编程主要通过预言可能出现的缺陷来实现容错性。使用 actor 模型并不需要使用防御式编程,而是遵循“任其崩溃”的哲学,让 actor 的上层管理者来处理这些问题。这样做的优势在于:

  1. 代码会变得更加简洁从而易于理解,可以清晰区分稳定代码和脆弱代码。
  2. 多个 actor 之间是相互独立的,并不共享状态,因此一个 actor 的崩溃不太会殃及到其他 actor。尤其重要的是一个 actor 的崩溃不会影响到其管理者,这样管理者才能正确处理此次崩溃。
  3. 管理者也可以选择不处理崩溃,而是记录崩溃的原因,这样我们就会得到崩溃通知并进行后续处理。

分布式

相比已经介绍过的并发模型,actor 模型的一个重大优点是它支持分布式——它可以将消息发送到另外一台计算机,就像发送到本地计算机上的 actor 一样。这被称为地理位置透明。

OTP

上面演示的代码过于底层,而 OTP 为使用 Actor 模型提供更多工具。

  • 更简便的消息匹配。
  • 进程管理。
  • 更好的重启逻辑。
  • 调试与日志。
  • 代码热升级。
  • 发布管理、故障切换、自动扩容等。

主要概念包括:

  • 节点
  • 连接节点
  • 远程执行
  • 远程消息
  • 等等。

总结

优点

  • 消息传递与封装
  • 容错
  • 分布式编程

缺点

Actor 除了优点,也会带来它独有的一些问题。

Actor 模型并没有直接提供并行支持,事实上可以自己构造,但由于 actor 之间不共享状态,仅通过消息传递进行交流会不太适合实施细粒度的并行。

个人认为应用 Actor 模型的最大障碍是开发者们的思维方式转变,尤其对于一个以 Java 这种命令式语言作为生产语言的团队来说。

另外,如果想要深入理解 Actor 模型,可以直接参考 Erlang/OTP,如果想要在生产中构建基于 Actor 模型的项目,推荐使用运行于 JVM 的 Akka,当然,如果同时使用 Scala 语言就更好了,因为 Java 中一贯的编程思路如共享可变状态、对象可变等不利于使用 Actor 模型。