Actor System
Actor 是封装了状态和行为的对象,他们之间仅通过消息交互进行通信,而消息会被投递到收件人的邮件中。在某种意义上,Actor 是 OOP 最严格的形式,但可以更好的把他们看做一个“人“:当使用 Actor 为方案建模时,想象有一组人并为其指定子任务,通过组织结构来排列他们的功能并思考如何逐步升级错误(幸亏不是和人打交道,这意味着我们无需关心他们的情绪状态和道德问题:D)。最终结果可以作为一个心理上的框架来构建软件实现。
注意:
一个
ActorSystem
是一个非常重量级的结构,它会创建多个线程,因此每个逻辑应用中仅创建一个。
层级结构
就像一个经济组织,Actor 自然形成层级。一个 Actor 负责程序中的某些功能,或许会把它的任务切分成更小的部分,更多的管理块。出于这个目的它会创建一些子 Actor 并监管他们。这里所指的监管在 这里 可以查看更多细节。这里唯一的前提是要知道每个 Actor 只有一个监管者,就是创建他们的 Actor。
Actor 系统的典型特性就是将任务切分成更小的块直至小到能够在一个地方处理。因此,不仅任务本身拥有清晰的结构,由此产生的这些 Actor 也能够推断出他们能够处理那些类型的消息,正常情况下如何做出响应以及如何处理错误。如果一个 Actor 无法处理某种情况,它会向其监管者发送对应的错误消息以寻求帮助。这种循环结构能够支持在合适的层级处理错误。
这与分层软件设计中容易陷入不漏过任何错误为目标的防御性编程相比:如果问题能与正确的人进行沟通要比把所有问题”都放在地毯上(under the carpet)“能找到更好的解决方案。
现在,设计一个系统的难点成了到底谁来监管什么。当然并没有最好的方案,但是这些指导方针或许会带来帮助:
- 如果一个 Actor 负责管理另一个 Actor 要做的工作,比如通过传递子任务,管理者则能够监起管子 Actor。原因在于管理者知道预期都有哪些类型的错误并如何处理这些错误。
- 如果一个 Actor 持有非常重要的数据(比如避免状态丢失),该 Actor 需要为所有子 Actor 找出任务可能的危险,对他们监管并处理他们的错误。根据请求的性质,最好为每个请求创建一个新的子 Actor,从而简化用户收集回复的状态管理。这也就是 Erlang 中著名的“Error Kernel Pattern”。
- 如果一个 Actor 依赖另一个 Actor 来履行其职责,则它需要监视该 Actor 的活跃性并在接收到终止提示后采取行动。这种方式与监管不同,因为监视的部分感受不到监管策略的影响,另外需要注意的是,仅基于函数依赖并不能作为决定将子 Actor 放置在层级中哪个位置的标准。
这些规则当然也会有例外,但无论你遵从还是打破这些规则,你总得又有一个合理的原因。
配置容器
Actor 系统作为 Actor 的协作组合,是用于管理共享设施的自然单元,比如调度服务、配置中心、日志服务等。拥有不同配置的多个 Actor 系统可以在用一个 JVM 共存,在 Akka 本身内也没有公共的共享状态。这与 Actor 系统之间的透明通信——即在一个节点内部或跨网络连接——可以发现 Actor 系统本身也可以作为一个层次结构的功能块。
Actor 最佳实践
- Actor 之间可以看做是合作者:能以不打扰别人或占用资源的形式有效完成各自的工作。解释到编程中这就意味着能够以事件驱动的方式处理事件并生成响应,或生成更多的请求。除非无法避免,Actor 是不能被一些外部实体阻塞的(比如被动等待线程),该实体可能是一个锁、一个网络 socket等等。
- 不能在 Actor 之间传递可变对象。为了确保这一点,请总是使用不可变消息。如果 Actor 的封装性被暴漏到外部的可变状态打破了,你则又会回到伴随所有缺点的常规 Java 并发世界。
- Actor 可以作为状态和行为的容器,因此不要经常在消息中发送行为(比如使用充满诱惑的 Scala 闭包)。一种危险是不小心在 Actor 之间共享了可变状态,而这种对 Actor 模型的违背将会不幸的破坏所有为 Actor 编程带来良好体验的特性。
- 顶层 Actor 是你错误内核(Error Kernel)的最深层部分,因此要节俭的创建并构建真正的层级化系统。这样有利于错误处理(同时需要考虑配置的粒度和性能),同时也能减少监管 Actor 的负担,以免监管 Actor 被过度使用从而成为一个单点冲突。
阻塞需要小心管理
有些场景中难以避免使用阻塞操作,比如,让一个线程休眠一段不确定的时间,等待一个外部事件触发。例如传统的 RDBMS 驱动和消息 API,而表面之下根本的原因通常是触发了网络 I/O。面对这些时你通常会忍不住使用Future
包装一下来替代之前的方式,但这种策略太简单了:当程序在不断增长的负载下运行时,你很快就可能发现一些内存或线程耗尽的瓶颈。
针对阻塞问题给出了一些建议,即便该列表也无法做到面面俱到:
- 在 Actor 中(或由 Router 管理的一组 Actor)进行阻塞调用时,确保配置一个专用的线程池或给线程池设置足够的大小。
- 在 Future 中进行阻塞调用时,确保在同一时间点该类操作的数量有一个上界。
- 在 Future 中进行阻塞调用时,提供一个带有线程数量上界的线程池,而改数量要与应用所运行的硬件属性匹配。
- 奉献一个单独的线程来管理阻塞资源(比如 NIO 选择器同时驱动多个 Channel)并通过发送 Actor 消息来分发事件。
第一种方式可能尤其适合那些本身就是单线程的资源,比如数据库中通常一次只能执行一个查询,并通过内部同步来确保这一点。通常的模式是为一组 Actor 创建一个路由,每个 Actor 中包装一个单独的 DB 连接来执行路由发来的查询。而 Actor 的数量可以逐步调整至最大吞吐,这取决与使用哪种数据库并在什么样的硬件上部署。
注意:
配置线程池的工作最好交个 Akka,通过
application.conf
进行简单的配置并由 ActorSystem[Java, Scala] 进行实例化。
你自己无需关心的
Actor 系统会管理它配置使用的资源以便运行它包含的 Actor。这样的一个系统中可以包含数百万个 Actor,尽管他们看起来如此丰富,但每个实例的消耗仅占 300 字节。当然,在这样大型的系统中消息处理的确切顺序可能无法被开发者控制,但这同样并非有意为之。退后一步然后放轻松,让 Akka 在后面处理这些繁重的工作。