1. 介绍

函数式编程不使用可变状态,也就避免了共享可变状态带来的问题。相比之下,使用actor模型保留了可变状态,只是不进行共享。

actor类似于面向对象编程中的对象——其分封装了状态,并通过消息与其他actor通信。两者的区别是所有actor可以同时运行、actor之间的消息传递是真实地在传递消息,这与OO式的消息传递(实质是调用了一个方法)不同。

2. Actor中的消息和信箱

我们例子使用Elixir语言来写,这种语言基于erlang虚拟机。erlang vm原声对actor并发模型有良好的支持。

2.1 收发消息

如下代码定义了一个actor用来接受三种不同的消息,即greet,praise和celebrate。我们不做具体的语言讲解,了解思想就够了。elixir的语法不是很抽象,大家自己可以脑补代码的含义。

defmodule Talker do
  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
end

然后我们创建Actor实例,然后向其发送消息:

发送消息

pid = spawn(&Talker.loop/0) # 使用spawn函数来创建Talker这个Actor
send(pid, {:greet, "Huey"})
send(pid, {:praise, "Dewey"})
send(pid, {:celebrate, "Louie", 16})
sleep(1000)

接下来我们来分析下actor模型消息发送的原理。actor模型的重要特性之一,就是发送消息给actor的时候是异步地发送消息到信箱。上面例子发送消息的过程如下图所示:


这样的设计解耦了actor之间的关系——actor都以自己的步调运行,且发送消息时不会被阻塞。虽然所有actor可以同时运行,但他们都按照信箱接受消息的顺序来依次处理消息,且仅在当前消息处理完成后才会处理下一个消息,因此我们只需要关心发送消息时的并发问题即可。

接受消息

defmodule Talker do
  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}")
      {:shutdown} -> exit(:normal)  # 显示的定义关闭
    end
    loop    # 递归调用自己继续接受,但是elixir实现了尾调用消除,所以这个不用担心栈溢出
  end
end

通过以上函数,elixir不断递归调用自己来对接受到的消息进行匹配。与clojure没有实现尾调用消除不同,elixir实现了尾调用消除,即如果函数在最后调用了自己,那么队规调用将被替换成一个简单的跳转

关闭actor
关闭actor的过程通过上述代码的exit方法来显式的关闭actor。要知道actor是何时关闭的则可以通过。完整代码如下:

defmodule Talker do
  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}")
      {:shutdown} -> exit(:normal)
    end
    loop
  end
end

Process.flag(:trap_exit, true)
pid = spawn_link(&Talker.loop/0)  # 使用spwan_link在结束的时候会返回一个三元组消息 {:EXIT,pid,reason}

send(pid, {:greet, "Huey"})
send(pid, {:praise, "Dewey"})
send(pid, {:celebrate, "Louie", 16})
send(pid, {:shutdown})

receive do
  {:EXIT, ^pid, reason} -> IO.puts("Talker has exited (#{reason})") # ^符号表示不绑定消息到第二个数据,用当前的pid值进行模式匹配
end

有状态的actor
actor也可以是有状态的,如下:

defmodule Counter do
  def start(count) do
    spawn(__MODULE__, :loop, [count])
  end
  def next(counter) do
    send(counter, {:next})
  end
  def loop(count) do    # count参数的值时不断递增的,但是由于actor是串行处理消息的,所以这个actor可以安全地访问其状态
    receive do
      {:next} ->
        IO.puts("Current count: #{count}")
        loop(count + 1)
    end
  end
end

此外actor还支持双向的同步通信、进程命名操作。这个具体可以再回顾书本,加深actor模型的理解。

3. Actor模型的错误处理和容错性

并发很重要的一个特性就是并发代码具有容错性。

3.1 错误检测

作者实现了一个例子代码,由于其中没有包含参数检查而导致运行失败。大多数语言的处理方法是增加一些检查参数的代码,当检查到非法参数时报错。Elixir提供了另外一种方法——将错误处理隔离到一个管理进程中。这个方法是一个很大的改进,是代码更简洁、更具有维护性。
连接进程进行消息传播是Elixir编程中的重要概念之一。

Elixir中确保容错性的规则:

  1. 如果没有异常发生,消息一定能被送达并被处理
  2. 如果某个环节出现异常,异常一定会通知到使用者(假设使用者已经连接到或正在管理发生异常的进程)

3.2 错误处理内核(error-Kernel)模式

actor提供了一种容错的方式:错误处理内核模式
对于使用actor模型的程序来说,其错误处理内核是顶层的管理者,管理着子进程——对子进程进行启动、停止和重启等操作。

每个模块都有自己的错误处理内核从而构成错误处理内核层级树。错误处理内核往往小而简单,以至于没有缺陷。

3.3 任其崩溃策略和防御式编程

  • 防御式编程: 通过语言可能出现的额缺陷来实现容错性。例如类型检查中加很多if判断避免不合理的参数输入,但是总归不能把所有异常输入的处理都写在代码中
  • 任其崩溃策略:actor模型就采用这种哲学,让actor的管理者来处理这些问题。

任其崩溃策略的好处:

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

4. 分布式与OTP

4.1 OTP概览

在Elixir当中,其实每次函数调用都是在进行模式匹配。OPT库当中有一个GenServer组件。GenServer是一个行为(behaviour),可以用来自动创建一个有状态的actor。

OPT带来的好处主要有:

  1. 重启: 用OTP创建一个管理者,可以支持不同的重启策略,拥有更好的重启逻辑。
  2. 调试与日志: 通过调整OTP服务器的参数,可以开启调试和日志功能
  3. 热代码升级: OTP服务器不需要停止整个系统就可以进行升级
  4. 发布管理
  5. 故障切换
  6. 自动扩容 等等。。。

4.2 节点

每创建一个Erlang虚拟机实例,就相当创建了一个节点。区别之前进程的link,此处我们的连接 connect指的是两个节点之间的连接。可以使用Node.connect()方法。

OTP体现了强大的管理集群的能力。很容易将不同节点连接起来。一个节点可以在另一个节点上执行代码,并且执行的结果还会返回给第一个节点。不过强大的功能也有很大的危险性。在设计集群管理策略时尤其需要考虑安全性。

4.3 分布式词频统计

分别设计计数器、累加器、解析器与容错三个actor。在master节点上启动一个parser和accumulator然后在其他的一台或几台计算机上启动几个计数器。如果一个计数器挂了,其他的计数器会继续运行并接管那些运行在故障机上的页面。

5. 总结

Smalltalk的设计者、面向对象编程之父Alan Kay曾经这样描述面向对象的本质:

很久以前,我在描述”面向对象编程“时使用了对象这个概念。很抱歉这个概念让许多人误入歧途,他们将学习的中心放在了“对象”这个次要的方面。

真正主要的方面是“消息”。创建一个规模宏大且可生长的系统的关键在于其模块之间应该如何交流,而不在于其内部的属性和行为应该如何表现。

这段话概括了使用actor模型进行编程的精髓——我们可以认为actor模型是面向对象模型在并发编程领域的扩展。actor模型精心设计了消息传输和封装的机制,强调了面向对象的精髓,可以说actor模型非常“面向对象”。

5.1 actor模型的优点

  1. 消息传输和封装:虽然多个actor可以同时运行,但他们并不共享状态,而且在单个actor中所有事件都是串行执行的。所以关于并发,只需要关注多个actor之间的消息流即可。每个actor可以被单独测试,而且当测试覆盖了某个actor的消息类型消息顺序时,就可以确定这个actor非常可靠。如果发现了一个与并发相关的bug,也就知道重点应该放在actor之间的消息流上。
  2. 容错:使用actor模型的程序天生具有容错性。这不仅会让程序更加强壮,而且通过任其崩溃的哲学可以让代码更加简洁明了。
  3. 分布式编程:actor模型支持共享内存模型,也支持分布式内存模型。分布式是软件具有容错能力的基石。actor模型可以方便解决任何规模的问题。

5.2 actor模型的缺点

  1. actor模型仍然会碰到死锁问题,比如之前某个actor崩溃重启了,另外个actor会一直等待(如果没有设定超时的话)
  2. actor模型对并行没有提供直接支持。通过并发技术构造的并行方案会引入不确定性。由于多个actor并不共享状态,仅仅通过消息传递来交流,所以不太适合实施细粒度的并行