可扩展的实时事物处理
写出正确、具有容错性和可扩展性的并发程序太困难了。Akka 的创始者们认为这是因为使用了错误的工具和错误的抽象。 Akka 就是为了改变这种状况而生的。通过使用 Actor 模型提升了抽象级别,为构建可扩展的,有弹性的响应式并发提供了一个更好的平台——详见《响应式宣言》。
Actors
Actor 为你提供:
- 对并发/并行程序简单的、高级别的抽象。
- 异步、非阻塞、高性能的时间驱动模型。
- 非常轻量级的时间驱动模型。
容错性
- 使用“let-it-crash”语义的监控层次体系。
- 监控层次体系可以跨越多个JVM,从而提供真正的容错系统。
- 非常适合编写永不停机、自愈合的高容错系统。
位置透明性
Akka 的所有元素都为分布式环境而设计:所有 Actor 只通过发送消息进行交互,所有操作都是异步的。
持久性
Actor 接收到的消息可以选择性的被持久化,并在 Actor 启动或重启的时候重放。这使得 Actor 能够恢复其状态,即使是在 JVM 崩溃或正在迁移到另外节点的情况下。
Akka平台提供哪些有竞争力的特性?
Akka为以下目标提供了一致的运行时与编程模型:
- 垂直扩展(并发)
- 水平扩展(远程调用)
- 高容错
Actor 最佳实践:
写在前面
对于 Actor 模型我不想作太多的说明,网上有的是资料,这是学习 Akka 必须要了解的背景知识。关于事件驱动和异步也不想说太多,这些都算是编程领域一些通识的知识了。
我觉得 Actor 模型最大的改变可能是对传统的编程方式,虽然看起来又我是理所当然。和其他工具或框架一样,工具学习的难度并不大,关键是如何将问题如何分解成各个模块,编写正交性强、可扩张性强和低耦合的代码。
通常我们要构建一个系统或完成一个需要,需要将问题抽象,将问题分类,然后分成各个小的模块。分析变与不变,将复杂的问题在分位若干层次,分析各个层级和模块的相互关系。
这些源于对问题的分析,然后当我们要实现某个功能,可能要各个模块的相互依赖。当代码的执行序列需要某些功能时,不需要一个万能的类或者函数可以完成一切,我们可以通过引用的方式,调用它的实现。没错通过组合,来构建一个复杂的系统。将问题宏观的看待,如果我们要解决一个问题,需要持有一些东西,让他去解决。如果把执行序列的代码比作一个人的话,他要完成很复杂的任务,他需要不断持有他们的能力,最终内化,好像是自己完成的一样。问题是,如果现实生活中,我们把任务分给其他人做了,我们是调用了他们某方面的能力,为何不直接交代他,而是持有他的这种能力呢?没错,将分解的任务,分配给各个人去做,并不需要用真正雇佣他,他可以是外包的。
当你写代码克服“本能”的引用,然后调用。而是通过持有引用,通过消息交流。而引用只是用来交流,并不涉及具体的实现,只是通讯的工具。这将进一步抽象和讲问题分解,屏蔽底层细节,通过事件来驱动。将代码运行在多个核心或多台计算机上,而不需要理会具体的状态,因为没有了直接的调用。也不需要当心锁,带来的并发和死锁带来的各种问题。
专注于将问题分解,分配到各个模块,只需关注消息的处理,而无需理会各种提成的总总细节,将你从各种繁琐的并发问题中解放出来,装置与具体的业务实现。
一些建议
- Actor 应该被视为友好的同事。
- 不要在 Actor 之间传递可变对象。
- Actor 是行为和状态的封装。
阻塞需要仔细管理
在某些情况下,阻塞操作是不可避免的,即必须不定期地休眠一个线程,等待外部事件唤醒。例如传统的关系型数据库的驱动程序或消息传递API,而深层的原因通常是出现幕后的(网络)I/O。面对这一点,你可能受到诱惑,只是将阻塞调用包装在 Future 中来替之工作,但这个策略太简单了:当应用的负载增加,你很可能会发现性能瓶颈,或者出现内存或线程耗尽的情况。
对“阻塞问题”的充分解决方案的清单是不会穷尽的,但肯定会有下面的建议:
- 在一个 Actor(或由一个路由器[Java, Scala]管理的一组 Actor内)内进行阻塞调用,并确保配置一个线程池,它要足够大或者专门用于这一目的。
- 在一个 Future 内进行阻塞调用,确保任意时间点内这种调用的数量都在一个上限内(无限制提交这类任务会耗尽你的内存或线程)。
- 在一个 Future 内进行阻塞调用,使用一个线程池,该线程池的线程数上限对应用程序运行的硬件是合适的。 奉献一个单独的线程来管理一组阻塞资源(如一个NIO选择器驱动多个频道),并在事件发生时把它们作为actor消息发送。
第一个建议对本质上是单线程的资源特别适合,如传统数据库句柄一次只能执行一个未完成的查询,并使用内部同步保证这一点。一个常见的模式是对 N 个 Actor 创建一个router,每个 Actor 包装一个数据库连接,并处理发送给这个 router 的查询。数目 N 必须被调整为最大吞吐量,这个数字取决于什么数据库管理系统部署在什么硬件上。
注意</br> 配置线程池的任务最好代理给Akka来做,只要在application.conf中配置,并由ActorSystem [Java, Scala] 实例化即可。
什么是 Actor
一个 Actor 是容器,它包含了状态、行为,一个邮箱,子 Actor 和一个监管策略。所有这些封装在一个 Actor 引用里。最终在 Actor 终止时,会发生这些。
监管与控制
监管描述的是 Actor 之间的依赖关系:监管者将任务委托给下属,并相应的对下属的失败情况进行响应。
做基于监管的工作性质和失败的性质,监管可以有 4 种基本选择:
- 恢复下属,保持下属累积的内部状态。
- 重启下属,清除下属的内部状态。
- 永久地停止下属。
- 升级失败(沿监管树向上传递失败),由此失败自己。
Actor 引用,路径与地址
树形监管结构和跨多个网络节点的 Actor 之间的透明通讯
Actor 引用
Actor 引用是 ActorRef 的子类。通过 Actor 引用可以方便的在多个 Actor 之间传递消息。ActorRef 引用的获得可以通过 akka.actor.ActorRefFactory 获得。这是一个接口,ActorSystem 和 akka.actor.ActorContext 实现了该接口。
根据 Actor 系统的配置,支持几种常见类型 Actor 引用。
- 纯本地actor引用,在配置为不使用网络功能的actor系统中使用。这些actor引用如果通过网络连接传给远程的JVM,将不能正常工作。
- 本地actor引用,在配置为使用远程功能的actor系统中使用,来代表同一个JVM的actor。为了能够在被发送到其它节点时仍然可达,这些引用包含了协议和远程地址信息。
- 本地actor引用的一个子类,用在路由器中(routers,即混入 了 Router trait的actor)。它的逻辑结构与之前的本地引用是一样的,但是向它们发送的消息会被直接重定向到它的子actor。
- 远程actor引用,代表可以通过远程通讯访问的actor,即向他们发送消息时会透明地对消息进行序列化,并发送到别的JVM。
Actor 路径
Actor 是严格按照树形结构来创建的,如果文件系统一样,每个 Actor 到根节点之间有一条唯一的路径。
Actor 引用和路径之间有什么区别?
Actor 引用标明了一个 Actor,其生命周期和 Actor 的生命周期保持匹配;Actor 路径表示一个名称,其背后可能有也可能没有真实的 Actor,而且路径本身不具有生命周期,它永远不会失效。你可以创建一个 Actor 路径,而无需创建一个 Actor,但你不能在创建 Actor 引用时不创建相应的 Actor。
你可以创建一个 Actor,终止它,然后创建一个具有相同路径的新 Actor。新创建的实例是 Actor 的一个新的化身。它并不是一样的 Actor。一个指向老的化身的 Actor 引用不适用于新的化身。发送给老的 Actor 引用的消息不会被传递到新的化身,即使它们拥有相同的路径。
Actor 路径锚点
"akka://my-sys/user/service-a/worker1" // 纯本地
"akka.tcp://my-sys@host.example.com:5678/user/service-b" // 远程
Actor 逻辑路径
跟路径沿着监管链到达某个 Actor 的唯一路径就是逻辑路径,描述的是整个 Actor 之间的层次关系,可能在同一节点,也可能跨越多个节点。
Actor 物理路径
物理路径和 URL 类似,通过唯一的地址,描述一个 Actor 所在节点的具体位置,这是为节点之间远程通讯服务的。所以物理路径的一个重要性质是它绝不会跨越多个 Actor 系统或者 JVM 虚拟机。
如何获得 Actor 引用
actor引用的获取方法分为两类:通过创建actor,或者通过查找actor。后一种功能又分两种:通过具体的actor路径来创建actor引用,和查询actor逻辑树。
创建 Actor
上面说过,可以通过 ActorSystem 和 ActorContext 来创建相应的 Actor 引用。
通过具体的路径来查找 Actor
另外,可以使用 ActorSystem.actorSelection 来查找 Actor 引用。“选择”可在已有 Actor 与被选择的 Actor 进行通讯的时候用到,在投递每条消息的时候都会用到查找。
为了获得一个绑定到指定 Actor 生命周期的 ActorRef,你需要发送一个消息,如内置的 Identify 信息,向指定的 Actor,所获得的 sender() 即为所求。
Actor 的引用和路径相等性
ActorRef 的相等性与 ActorRef 的目的匹配,即一个 ActorRef 对应一个目标 Actor 化身。两个 Actor引用进行比较时,如果它们有相同的路径且指向同一个 Actor 化身,则两者相等。指向一个已终止的 Actor的引用,与指向具有相同路径但却是另一个(重新创建)Actor 的引用是不相等的。需要注意的是,由于失败造导致的 Actor 重启,仍意味着它是同一个 Actor 化身,即重新启动对 ActorRef 消费者是不可见的。
与远程部署之间的互操作
当一个 Actor 创建一个子 Actor,Actor 系统的部署者会决定新的 Actor 是在同一个 JVM 中还是在其它节点上。如果是后者,Actor 的创建会通过网络连接引到另一个 JVM 中进行,因而在另一个 Actor 系统中。远程系统会将新的 Actor 放在一个专为这种场景所保留的特殊路径下,新的 Actor 的监管者将会是一个远程 Actor 引用(代表触发它创建动作的 Actor )。这时,context.parent(监管者引用)和context.path.parent(Actor路径上的父 Actor)表示的 Actor 是不同的。然而,在其监管者中查找这个 Actor 的名称将会在远程节点上找到它,保持其逻辑结构,例如向另一个未确定(unresolved)的 Actor 引用发送消息。
路径中的地址部分用来做什么?
在网络上传送 Actor 引用时,是用它的路径来表示的。因此,它的路径必须包括能够用来向它所代表的 Actor 发送消息的完整信息。这一点是通过将协议、主机名和端口编码在路径字符串的地址部分做到的。当 Actor 系统从远程节点接收到一个 Actor 路径,会检查它的地址部分是否与自己的地址相同,如果相同,那么会将这条路径解析为本地 Actor 引用,否则解析为一个远程 Actor 引用。
Actor 路径的顶级作用域
在路径树的根上是根监管者,所有其他actor都可以从通过它找到;它的名字是”/”。在第二个层次上是以下这些:
- “/user” 是所有由用户创建的顶级actor的监管者;用 ActorSystem.actorOf创建的actor在其下。
- “/system” 是所有由系统创建的顶级actor的监管者,如日志监听器,或由配置指定在actor系统启动时自动部署的actor。
- “/deadLetters” 是死信actor,所有发往已经终止或不存在的actor的消息会被重定向到这里(以尽最大努力为基础:即使在本地JVM,消息也可能丢失)
- “/temp”是所有系统创建的短时actor的监管者,例如那些在ActorRef.ask的实现中用到的actor。
- “/remote” 是一个人造虚拟路径,用来存放所有其监管者是远程actor引用的actor。
需要为actor构建这样的名称空间源于一个核心的非常简单的设计目标:在层次结构中的一切都是一个actor,以及所有的actor都以相同方式工作。因此,你不仅可以查找你所创建的actor,你也可以查找系统守护者并发送消息(在这种情况下它会忠实地丢弃之)。这个强大的原则意味着不需要记住额外的怪异模式,它使整个系统更加统一和一致。
透明性
天生的分布式
Akka 中所有的东西都是被设计为在分布式环境下工作的: Actor 之间所有的互操作都是使用纯粹的消息传递机制,所有的操作都是异步的。付出这些努力是为了保证其所有功能,无论是在单一的 JVM 上还是在拥有很多机器的集群里都能同样有效。实现这一点的关键是从远程到本地进行优化,而不是从本地到远程进行一般化。参阅这篇经典论文来了解关于为什么第二种方式注定要失败的详细讨论。
破坏透明性的方式
由于设计分布式执行过程对可以做的事情添加了一些限制,Akka 所满足的约束并不一定在使用 Akka 的应用程序中也满足。最明显的一条是网络上发送的所有消息都必须是可序列化的。而不那么明显的是,这也包括在远程节点上创建 Actor 时,用作 Actor工厂的闭包(即在 Props 里)。
另一个结果是所有元素都需要知道所有交互是完全异步的,在一个计算机网络中这意味着一个消息可能需要好几分钟才能到达接收方(跟配置有关)。还意味着消息丢失的概率比在单一的 JVM 中(接近 0,但仍不能完全保证!)高得多。
远程调用如何使用?
我们把透明性的想法限制在“ Akka 中几乎没有为远程调用层设计的 API ”:而完全由配置来驱动。你只需要按照之前的章节概括的那些原则来编写你的应用,然后在配置文件里指定远程部署的 Actor 子树。这样你的应用可以不用通过修改代码来实现扩展。API 中唯一允许编程来影响远程部署的部分是 Props 包含的一个属性,这个属性可能被设为一个特定的 Deploy 实例;这与在配置文件中进行部署设置具有相同的效果(如果两者都有,那么配置文件优先)。
Peer-to-Peer vs. Client-Server
Akka Remoting 是以对等网络方式进行 Actor 系统连接的通信模块,它是 Akka 集群的基础。远程处理的设计取决于两个(相关)的设计决策:
涉及系统之间的通信是对称的:如果系统 A 可连接到系统 B,那么 B 系统必须也能够独立连接到系统 A。 通信系统中的角色按照连接的模式是对称的:不存在系统只接受连接,也没有系统只发起连接。 这些决定的结果是,它不可能按照预定义的角色安全地创建纯粹的“客户端-服务器”设置(违反假设2 ),并使用网络地址转换(NAT)或负载平衡器(违反假设1)。
对于“客户端-服务器”的设置,最好是使用 HTTP 或 Akka I/O 。
使用路由来进行垂直扩展的标记点
一个 Actor 系统除了可以在集群中的不同节点上运行不同的部分,还可以通过并行增加 Actor 子树的方法来垂直扩展到多个cpu 核上(设想例如搜索引擎并行处理不同的检索词)。新增出来的子树可以使用不同的方法来进行路由,例如循环(round-robin)。要达到这种效果,开发者只需要声明一个“withRouter”的 Actor,这样系统会创建一个路由 Actor 取而代之,该路由 ?Actor 会按照期望类型和配置的数目生成子 Actor,并使用所配置的方式来对这些子 Actor 进行路由。一旦声明了这样的路由,它的配置可以自由地被配置文件里的配置进行重写,包括把它与其(某些)子 Actor 的远程部署进行混合。
Akka 与 Java 内存模型
Java 内存模型
在Java 5之前,Java内存模型(JMM)定义是有问题的。当多个线程访问共享内存时很可能得到各种奇怪的结果,例如:
- 一个线程看不到其它线程所写入的值:可见性问题。
- 由于指令没有按期望的顺序执行,一个线程观察到其它线程的 ‘不可能’ 行为:指令重排序问题。
随着 Java 5中JSR 133 的实现,很多这种问题都被解决了。 JMM 是一组基于 “发生在先” 关系的规则, 限制了一个内存访问行为何时必须在另一个内存访问行为之前发生,以及反过来,它们何时能够不按顺序发生。这些规则的两个例子包括:
- 监视器锁规则: 对一个锁的释放先于所有后续对同一个锁的获取。
- volatile 变量规则:对一个 volatile 变量的写操作先于所有对同一 volatile 变量的后续读操作。
虽然 JMM 看起来很复杂,但是其规范试图在易用性和编写高性能、可扩展的并发数据结构的能力之间寻找一个平衡。
Actors 与 Java 内存模型
使用 Akka 中的 Actor 实现,有两种方法让多个线程对共享的内存进行操作:
- 如果一条消息被(例如,从另一个 Actor)发送到一个 Actor,大多数情况下消息是不可变的,但是如果这条消息不是一个正确创建的不可变对象,如果没有 “发生先于” 规则, 有可能接收方会看到部分初始化的数据,甚至可能看到无中生有的数据(long/double)。
- 如果一个 Actor 在处理某条消息时改变了自己的内部状态,而之后又在处理其它消息时又访问了这个状态。一条很重要的需要了解的规则是,在使用 Actor 模型时你无法保证,同一个线程会在处理不同的消息时使用同一个 Actor。
为了避免 Actor 中的可见性和重排序问题,Akka 保证以下两条 “发生在先” 规则:
- Actor 发送规则 : 一条消息的发送动作先于同一个 Actor 对同一条消息的接收。
- Actor 后续处理规则: 一条消息的处理先于同一个 Actor 的下一条消息处理。
注意:
通俗地说,这意味着当这个 Actor 处理下一个消息的时候,对 Actor 的内部字段的改变是可见的。因此,在你的 Actor 中的域不需要是 volitale 或是同等可见性的。
这两条规则都只应用于同一个 Actor 实例,对不同的 Actor 则无效。
Future 与 Java 内存模型
一个 Future 的完成 “先于” 任何注册到它的回调函数的执行。
我们建议不要在回调中捕捉(close over)非 final 的值 (Java 中称 final,Scala 中称 val), 如果你一定要捕捉非 final 的域,则它们必须被标记为 volatile 来让它的当前值对回调代码可见。
如果你捕捉一个引用,你还必须保证它所指代的实例是线程安全的。我们强烈建议远离使用锁的对象,因为它们会引入性能问题,甚至最坏可能造成死锁。这些是使用 synchronized 的风险。
STM 与 Java 内存模型
Akka 中的软件事务性内存 (STM) 也提供了一条 “发生在先” 规则:
事务性引用规则: 对一个事务性引用,在提交过程中一次成功的写操作,先于所有对同一事务性引用的后续读操作发生。 这条规则非常象 JMM中 的“volatile 变量”规则。目前 Akka STM 只支持延迟写,所以对共享内存的实际写操作会被延迟到事务提交之时。在事务中发生的写操作会被存放在一个本地缓冲区内 (事务的写操作集) ,并且对其它事务是不可见的。这就是为什么脏读是不可能的。
这些规则在 Akka 中的实现会随时间而变化,精确的细节甚至可能依赖于所使用的配置。但是它们是建立在其它 JMM 规则之上的,如监视器锁规则、volatile 变量规则。 这意味着 Akka 用户不需要操心为了提供“发生先于”关系而增加同步,因为这是 Akka 的工作。这样你可以腾出手来处理业务逻辑,让 Akka 框架来保证这些规则的满足。
Actor 与共享的可变状态
因为 Akka 运行在 JVM 上,所以还有一些其它的规则需要遵守。
- 捕捉Actor内部状态并暴露给其它线程
class MyActor extends Actor { var state = ... def receive = { case _ => // 错误的做法 // 非常错误,共享可变状态, // 会让应用莫名其妙地崩溃 Future { state = NewState } anotherActor ? message onSuccess { r => state = r } // 非常错误, 共享可变状态 bug // "发送者"是一个可变变量,随每个消息改变 Future { expensiveCalculation(sender) } //正确的做法 // 非常安全, "self" 被闭包捕捉是安全的 // 并且它是一个Actor引用, 是线程安全的 Future { expensiveCalculation() } onComplete { f => self ! f.value.get } // 非常安全,我们捕捉了一个固定值 // 并且它是一个Actor引用,是线程安全的 val currentSender = sender Future { expensiveCalculation(currentSender) } } }
- 消息应当是不可变的, 这是为了避开共享可变状态的陷阱。
消息发送
Akka 作为一个在多核的单机或在分布式计算机网络中构建可靠应用程序的工具系统。关键在于你的代码单元 —— Actor 之间的所有交互都是通过消息来传递完成。
由于 Akka 的透明性机制,无论是单机还是分布式的计算机网络,通信的基本机制都是一样的。
一般规则
- 至多投递一次, 即不保证投递。
- 对每个“发送者-接受者” 对,有消息排序。
第一条规则是典型的,并在其他 Actor 框架中有出现,而第二个则是 Akka 独有的。
配置
具体配置,请看官方文档(Configuration)。