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 执行。
错误处理内核机制主要解决了防御式编程中碰到的一些棘手问题。
任其崩溃
防御式编程主要通过预言可能出现的缺陷来实现容错性。使用 actor 模型并不需要使用防御式编程,而是遵循“任其崩溃”的哲学,让 actor 的上层管理者来处理这些问题。这样做的优势在于:
- 代码会变得更加简洁从而易于理解,可以清晰区分稳定代码和脆弱代码。
- 多个 actor 之间是相互独立的,并不共享状态,因此一个 actor 的崩溃不太会殃及到其他 actor。尤其重要的是一个 actor 的崩溃不会影响到其管理者,这样管理者才能正确处理此次崩溃。
- 管理者也可以选择不处理崩溃,而是记录崩溃的原因,这样我们就会得到崩溃通知并进行后续处理。
分布式
相比已经介绍过的并发模型,actor 模型的一个重大优点是它支持分布式——它可以将消息发送到另外一台计算机,就像发送到本地计算机上的 actor 一样。这被称为地理位置透明。
OTP
上面演示的代码过于底层,而 OTP 为使用 Actor 模型提供更多工具。
- 更简便的消息匹配。
- 进程管理。
- 更好的重启逻辑。
- 调试与日志。
- 代码热升级。
- 发布管理、故障切换、自动扩容等。
主要概念包括:
- 节点
- 连接节点
- 远程执行
- 远程消息
- 等等。
总结
优点
- 消息传递与封装
- 容错
- 分布式编程
缺点
Actor 除了优点,也会带来它独有的一些问题。
Actor 模型并没有直接提供并行支持,事实上可以自己构造,但由于 actor 之间不共享状态,仅通过消息传递进行交流会不太适合实施细粒度的并行。
个人认为应用 Actor 模型的最大障碍是开发者们的思维方式转变,尤其对于一个以 Java 这种命令式语言作为生产语言的团队来说。
另外,如果想要深入理解 Actor 模型,可以直接参考 Erlang/OTP,如果想要在生产中构建基于 Actor 模型的项目,推荐使用运行于 JVM 的 Akka,当然,如果同时使用 Scala 语言就更好了,因为 Java 中一贯的编程思路如共享可变状态、对象可变等不利于使用 Actor 模型。
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.