Introduction to Akka

Akka:Introduction to Akka

Akka及Actor Model介绍。

1. Akka简介

​ Akka,一组用于设计跨越处理器核心和网络的可扩展、有弹性的系统的开源库。Akka允许您专注于满足业务需求,而不是编写底层代码来保证可靠的行为、容错和高性能。

​ 通用的实践和编程模型并没有解决对于现代计算机结构而设计系统的固有挑战。分布式系统必须要应对因环境组件崩溃导致无法响应、消息丢失且没有在线路上留下痕迹,网络延迟波动等一系列问题。这些问题在精心管理的数据中心内环境中经常出现 – 在虚拟架构中更是如此。

​ 为了应对这些现实,Akka提供:

  • 多线程行为,不使用低级并发结构,如atomics 或 locks。甚至不需要考虑内存可见性问题。
  • 系统及其组件之间的透明远程通信。不需要编写或维护困难的网络通讯代码。
  • 一个有弹性的集群,高可用架构,按需扩展或缩小。

​ 所有这些功能都可以通过统一的编程模型获得:Akka利用了Actor模型来提供一个抽象模型,使得更容易编写正确的并发、并行和分布式的系统。Actor模型贯穿整个Akka库,提供了一致的理解和使用方式。因此,Akka高度集成,您无法通过选择库来解决个别问题并尝试将它们拼凑在一起。

​ 通过学习Akka及其Actor模型,您可以得到一整套广泛而深入的工具,解决困难的分布式/并行系统问题。

2. 什么是Actor模型

  当今计算环境的特点与以往编程模式的构思有很大不同。Actor几十年前被 Carl Hewitt发明。但是最近,它们对现代计算机系统的挑战的适用性已得到承认并被证明是有效的。

​ Actor模型提供了一种抽象,可以让您在交流层面考虑您的代码,与大型组织中的人相同。Actor的基本特征是,他们将世界建模成通过明确的消息传递来相互交流的有状态实体。

作为计算实体,Actor具有以下特征:

  • 它们通过消息通信异步执行,而不是方法调用。
  • 它们管理自己的状态。
  • 响应消息时,他们可以:
    • 创建其他(子)Actor
    • 发送消息给其他Actor
    • 停止(子)Actor或自己

3. Actor 模型解决什么问题?

Akka使用Actor模型来克服传统面向对象编程模型的局限性,并满足高分布式系统的独特挑战。为了充分了解Actor模型的必要性,它有助于识别传统的编程方法与并发、分布式计算的现实的不匹配。

1. 封装的错误认识

​ 面向对象编程(OOP)是一个广泛接受的编程模型。其核心思想之一就是封装。封装规定对象的内部数据不能直接从外部访问; 它只能通过调用一组辅助方法来修改。该对象负责暴露安全操作,以保护其封装数据的不变性。

​ 例如,对有序二叉树实现的操作不能改变树的次序。调用者期望排序是完整的,并且当查询树的某一数据时,他们需要能够依赖这个约束。

​ 当我们分析OOP运行行为时,我们有时会绘制一个显示方法调用交互的消息序列图。例如:

序列图

​ 不幸的是,上述图表并不能准确地表示实例在执行过程中的生命线。实际上,线程执行所有这样的调用,并且变量的执行发生在调用该方法的同一线程上。通过执行线程更新上图,看起来像这样:

线程序列图

​ 当您尝试对多线程发生的情况进行建模时,我们整齐绘制的图形变得勉强。我们可以尝试说明访问同一个实例的多个线程:

线程交互的序列图

​ 有一个执行部分,两个线程调用相同的方法。不幸的是,对象的封装模型并不能保证该部分发生什么。若在两个线程之间不进行任何的协调,两个调用的指令可以以任意方式进行交织,则无法保证封装数据的不变性。现在,这个问题由于许多线程的存在而加剧。

. 解决这个问题的常见方式是在这些方法上添加一个锁。虽然这样确保了在任何给定时间最多只有一个线程将调用该方法,但这是一个非常昂贵的策略:

  • 锁 – 严重限制并发,它们在现代CPU架构上非常昂贵,需要操作系统挂起线程并稍后恢复。
  • 调用者线程正在被阻塞,所以它不能做任何其他有意义的工作。即使在桌面应用程序中,这也是不可接受的,我们希望使用户面对的部分应用程序(其UI),即使在长时间的后台作业运行时也能够响应。在后端,阻塞是完全浪费的。人们可能认为这可以通过启动新线程来补偿,但是线程也是一种昂贵的抽象。
  • 锁定引入了新的威胁:死锁。

这些现实导致了两败俱伤的局面:

  • 没有足够的锁,状态就会被破坏。
  • 一个地方有许多锁定,性能受损,很容易导致死锁。

​ 另外,锁只在本地工作良好。当涉及跨多台机器的协调时,唯一的选择是分布式锁。不幸的是,分布式锁的比本地锁效率低几个数量级,且通常会对扩展进行硬性限制。分布式锁协议需要在多台机器上通过网络进行多次往返通信,因此延迟突破天际。

​ 在面向对象语言中,我们很少会考虑线程或线性执行路径。我们经常将系统设想为对象实例网络,对方法调用做出反应,修改其内部状态,然后通过驱动整个应用程序状态的方法调用相互通信:

互动对象网络

​ 然而,在多线程分布式环境中,实际发生的是线程通过以下方法的调用,去“遍历”此对象实例网络。因此,线程是真正的驱动执行:

由线程遍历的交互式对象的网络

总结

  • 对象只能保证面对单线程访问的封装(不变量的保护),多线程执行几乎总是导致内部状态损坏。在同一个代码段中有两个竞争的线程,则无法保证数据的不变性。
  • 虽然锁似乎是维护多线程封装的自然补救措施,但实际上它们效率低下,容易导致任何现实应用中的死锁。
  • 锁在本地工作,分布式环境下很难拓展。

2. 共享内存在现代计算机架构上的错误认识

​ 80年代至90年代的编程模型的概念中,写入变量意味着直接写入存储单元(这有点混淆了局部变量可能只存在于寄存器中)。在现代架构上 – 如果我们简化了一些事情 – CPU正在写入缓存(cache lines),而不是直接写入内存。这些高速缓存大多数是CPU核心的本地存储,也就是说,一个核心的储存写入不可见。为了使本地更改对另一个核心可见,以致对另一个线程可见,高速缓存行需要发送到另一个核心的缓存。

​ 在JVM上,我们必须通过使用volatile标记或Atomic包装来明确地表示要跨线程共享的内存位置。否则,我们只能在锁定的部分访问它们。为什么我们不把所有变量标记为volatile?因为跨内核运输缓存是一项非常昂贵的操作!这样做会隐含地阻止所涉及的内核进行额外的工作,并导致cache coherence protocol(CPU在主内存和其他CPU传输缓存)的瓶颈。结果是运行速度大幅度降低。

​ 即使开发人员意识到这种情况,弄清楚哪些内存位置应该被标记为volatile,还是使用哪种Atomic结构是一种黑暗的艺术。

总结

  • 没有真正的共享内存,CPU核心就像网络上的计算机一样,将数据块(cache lines)明确地传递给彼此。CPU间通信和网络通信有许多共同点。现在通过CPU或网络计算机传递消息是有规范的。
  • 更加规范和有原则的方法是将状态保留在并发实体中,并通过消息明确地在并发实体之间传播数据或事件,而不是通过变量标记为共享或使用atomic数据结构来代替/隐藏消息传递。

3. 调用堆栈的错误认识

​ 今天,我们经常把调用堆栈当作理所应当的。但是,它们是在并发编程并不重要的时代发明的,因为多CPU系统并不常见。调用堆栈不会跨线程,因此不要对异步调用链建模。

​ 当线程打算将任务委派给“background”时,会出现问题。实际上,这意味着委托给另一个线程。这不能成为一个简单的方法/函数调用,因为调用是线程严格在本地执行的。通常发生的情况是,“Caller“将一个对象放入由工作线程(“Cllee”)共享的存储单元,这个存储单元在一些事件循环中被选中。这允许“Caller”线程去执行其他任务。

​ 第一个问题是,如何通知“Caller”完成任务?但是当一个任务失败并出现异常时,会出现一个更严重的问题。异常传播到哪里?它将传播到工作线程的异常处理程序,完全忽略了实际的“Caller”是谁:

异常不能在不同的线程之间传播

​ 这是一个严重的问题。工作线程如何处理这种情况?它可能无法解决问题,因为它通常会忽略失败的任务的目的。“Caller”线程需要以某种方式被通知,但是没有调用堆栈来解除异常。故障通知只能通过侧面通道进行,例如将错误代码放在“Caller”线程,要不就在预期结果准备好之后。如果此通知不到位,则“Caller”不会收到失败的通知,并且任务丢失!类似于网络系统如何工作,消息/请求可以丢失/失败,没有任何通知。

​ 错误真的发生时,这种不好的情况会变得更糟,一个由线程支持的worker遇到一个错误,最终会导致一个不可恢复的情况。例如,由bug引起的内部异常会冒泡到线程的root,并使线程关闭。这就出现了一个问题,谁应该重新启动线程主持的服务的正常运行,如何恢复到已知状态?乍一看,这似乎是可以管理的,但我们突然面临着一个新的,意想不到的现象:线程当前正在工作的实际任务不在共享内存位置,而是从任务中取出(通常是一个队列)。事实上,由于异常到达顶部,丢弃所有的调用堆栈,任务状态完全丢失了!

综上所述:

  • 在当前系统上实现有意义的并发和性能,线程必须以有效的方式相互委派任务,而不会阻塞。使用这种风格的任务委托并发(更多的是通过网络/分布式计算),调用基于堆栈的错误处理分解,需要引入新的显式错误信号机制。
  • 具有工作代表权的并行系统需要处理服务故障,并具有从其中恢复的原则性手段。这些服务的客户需要注意在重新启动过程中任务/消息可能会丢失。即使没有发生损失,由于先前入队的任务(长队列),垃圾收集等引起的延迟等等,响应可能会任意延迟。面对这些,并发系统应该以超时的形式处理响应超时,就像网络/分布式系统。

4.Actor模式如何满足并发、分布式系统的需求

​ 如上述,常见的编程实践无法适应现代并发和分布式系统的需求。幸运的是,我们不需要废除我们所知道的一切。Actor模式以原则性的方式解决了这些缺点,从而使得系统的行为能够更好地匹配我们的心理模型。

特别的,我们想要去:

  • 强制执行封装,而无需使用锁。
  • 使用协作实体反应信号模型,改变状态和发送信号,推动整个应用的发展。
  • 不要担心执行机制与我们的世界观不匹配。

以下主题描述Actor模式如何完成了这些目标。

1.使用消息的传递,避免了锁和阻塞

​ 演员发送消息到彼此,而不是调用方法。发送消息不会将执行线程从发送方传送到目的地。一个Actor可以发送一个消息并继续工作,而不阻塞。因此,它可以做更多的工作,发送和接收消息。

​ 对于对象,当一个方法返回时,它释放对其执行线程的控制。在这方面,Actor表现得很像对象,它们对消息做出反应,并在处理当前消息时返回执行。以这种方式,Actor实际上实现了我们想象的对象的执行:

演员通过发送消息互相交互

​ 传递消息而不是调用方法的一个重要区别是消息没有返回值。通过发送消息,Actor将工作委托给另一个Actor。正如我们在《调用堆栈的错误认识》中看到的:如果它想要一个返回值,发送执行者将需要在同一个线程上阻止或执行其他Actor的工作。相反,接收者Actor将结果通过回复消息的方式进行传递。

​ 我们在模型中需要的第二个关键变化是恢复封装。Actor对消息做出反应,就像对象对它们调用的方法“反应”一样。Actor独立于消息的发送者执行,并且它们一次一个地响应传入的消息;而不是多个线程“延伸”到我们的Actor中,并对内部状态和不变量造成破坏。当每个Actor按顺序处理发送给它的消息时,不同的Actor彼此并发工作,所以一个Actor系统可以同时处理多个消息,同时机器上可以使用许多处理器核心。由于每个Actor总是至多处理一个消息,所以一个Actor的变量可以保持不同步。这不会自动使用锁:

消息在顺序处理时不会使不变量无效

总而言之,当Actor收到消息时,会发生什么:

  1. Actor将消息添加到队列的末尾。
  2. 如果Actor没有被安排执行,它被标记为准备执行。
  3. 一个(隐藏的)调度器实体接管Actor并开始执行它。
  4. Actor从队列的前面挑选消息。
  5. Actor修改内部状态,向其他Actor发送消息。
  6. Actor的执行是非计划的。

为了完成这个行为,Actor有:

  • An Mailbox (消息的队列)。
  • An Behavior (Actor的状态,内部变量等)。
  • Messages (表示信号的数据片段,类似于方法调用及其参数)。
  • An Execution Environment(采取具有消息的Actor的反应和调用其消息处理代码的机制)。
  • An Address (稍后再说)。

​ 消息被放入所谓的Actor邮箱中。Actor的行为描述了Actor如何响应消息(如发送更多的消息和/或改变状态)。Execution Environment编排一个线程池,完全透明地驱动所有上述操作。

这是一个非常简单的模型,它解决了之前枚举的问题:

  • 通过将执行从信令中解耦来保留封装。
  • 不需要锁。修改Actor的内部状态只能通过消息进行处理,一次只处理一条消息。
  • 任何地方都没有锁,发件人没有被阻塞。数十万的Actor可以有效地安排在十几个线程上,充分发挥现代CPU的潜力。任务授权是Actor的自然操作模式。
  • Actor状态是本地的,不共享,变化和数据通过消息传播,映射到现代内存层次结构的实际工作原理。在许多情况下,这意味着仅在包含消息中的数据的缓存中传输,同时保持本地状态和数据缓存在原始核心。相同的模型精确地映射到远程通信,其中状态保存在机器的RAM中,并且变化/数据通过网络作为数据包传播。

2.Actor优雅地处理错误

​ 由于我们不再在彼此发送消息的Actors之间存在共享的调用堆栈,所以我们需要以不同的方式处理错误情况。我们需要考虑两种错误:

  • 第一种情况是由于任务中的错误导致目标Actor上的委派任务失败(例如一些验证问题,如不存在的用户标识)。在这种情况下,由目标Actor封装的服务是完整的,只是任务本身是错误的。服务Actor应该回复给发件人一个消息,提出错误的情况。这里没有什么特别之处,错误是域的一部分,因此是普通消息。
  • 第二种情况是服务本身遇到内部故障时。Akka强制所有Actor被组织成一个树状的层次结构,即创建另一个Actor的Actor成为新Actor的Supervisor(父母)。这与操作系统如何将流程组合到树中非常相似。就像进程一样,当一个Actor失败时,它的父Actor被通知,它可以对失败做出反应。此外,如果父Actor停止,其所有子项也被递归停止。这项服务被称为监督,它是Akka的核心。

演员监督和处理小孩演员的失败

​ Supervisor (父母)可以决定在某些类型的故障情况下重新启动其子Actor,或者将他们完全停止。Children永远不会静静地死亡(除了进入无限循环的例外),而是失败,Supervisor 可以对故障做出反应,或者他们(Children)被停止(在这种情况下,有兴趣的一方会自动通知)。总是有一个责任实体来管理一个Actor:它的Supervisor 。重新启动从外部不可见:协作Actor可以继续发送消息,而目标Actor重新启动。

知识共享许可协议
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。

发表评论

电子邮件地址不会被公开。 必填项已用*标注