编程开源技术交流,分享技术与知识

网站首页 > 开源技术 正文

AKKA--初始Actor(akka 教程)

wxchong 2024-08-07 01:29:53 开源技术 49 ℃ 0 评论

背景

Actor 模型是一种并发计算的理论模型,而 Akka 的核心其实是 Actor 模型的一种实现。在本章中,我们将通过了解 Akka 和 Actor 模型的历史来介绍 Akka 的核心概念。这会帮助读者更好地理解 Akka 到底是什么,以及 Akka 试图要解决什么样的问题。其次,本章中将重复使用同一个例子来阐述本书的目的。

一.什么是AKKA

Akka 一词据说来源于瑞典的一座山,我们说到 Akka 时,通常是指一个分布式工具集,用于协调远程计算资源来进行一些工作。Akka 是 Actor 并发模型的一种现代化实现。现在的 Akka 可以认为是从许多其他技术发展演化而来的, 它借鉴了 Erlang 的 Actor模型实现,同时又引入了许多新特性,帮助构建能够处理如今 大规模问题的应用程序。

1.1 Actor模型的起源

为了更好地理解 Akka 的含义及其使用方法,我们将快速地了解 Actor 模型的历史, 理解 Actor 模型的含义,以及它是如何一步一步发展到如今的 Akka 这样一个用于构建高 容错性分布式系统的框架。

Actor 并发模型最早出现于一篇叫作《A Universal Modular Actor Formalism for Artificial Intelligence》的论文,该论文发表于 1973 年,提出了一种并发计算的理论模型, Actor 就源于该模型。我们将在本节中学习 Actor 模型的特性,理解它的优点,能够在并 发计算中帮助我们解决共享状态带来的常见问题。

1.2 什么是Actor

首先,让我们来定义什么是 Actor。在 Actor 模型中,Actor 是一个并发原语;更简单 地说,可以把一个 Actor 看作是一个工人,就像能够工作或是处理任务的进程和线程一样。 把 Actor 看成是某个机构中拥有特定职位及职责的员工可能会对理解有所帮助。比如说一 个寿司餐馆。餐馆的职员需要做各种各样不同的工作,给客人准备餐盘就是其中之一。

1.3 Actor 和消息传递

在面向对象编程语言中,对象的特点之一就是能够被直接调用:一个对象可以访问 或修改另一个对象的属性,也可以直接调用另一个对象的方法。这在只有一个线程进行 这些操作时是没有问题的,但是如果多个线程同时读取并修改同一个值,那么可能就需 要进行同步并加锁。

Actor 和对象的不同之处在于其不能被直接读取、修改或是调用。反之,Actor 只能 通过消息传递的方式与外界进行通信。简单来说,消息传递指的是一个 Actor 可以接收 消息(在我们的例子中该消息是一个对象),本身可以发送消息,也可以对接收到的消息 作出回复。尽管我们可以将这种方式与向某个方法传递参数并接收返回值进行类比,但 是消息传递与方法调用在本质上是不同的:消息传递是异步的。无论是处理消息还是回 复消息,Actor 对外界都没有依赖。

Actor 每次只同步处理一个消息。邮箱本质上是等待 Actor 处理的一个工作队列,如 图 1-1 所示。处理一个消息时,为了能够做出响应,Actor 可以修改内部状态,创建更多 Actor 或是将消息发送给其他 Actor。

在具体实现中,我们通常使用 Actor 系统这个术语来表示多个 Actor 的集合以及所 有与该 Actor 集合相关的东西,包括地址、邮箱以及配置。

下面再重申一下这几个重要的概念:

  1. Actor:一个表示工作节点的并发原语,同步处理接收到的消息。Actor 可以保存并修改内部状态。
  2. 消息:用于跨进程(比如多个 Actor 之间)通信的数据。
  3. 消息传递:一种软件开发范式,通过传递消息来触发各种行为,而不是直接触发行为。
  4. 邮箱地址:消息传递的目标地址,当 Actor 空闲时会从该地址获取消息进行处理。
  5. 邮箱:在 Actor 处理消息前具体存储消息的地方。可以将其看作是一个消息队列。
  6. Actor 系统:多个 Actor 的集合以及这些 Actor 的邮箱地址、邮箱和配置等。

虽然现在看来可能还不是太明显,但是 Actor 模型要比命令式的面向对象并发应用程序容易理解多了。我们可以举一个现实世界中的例子来比喻使用 Actor 模型来建模的 过程,这会帮助我们理解它带来的好处。比如有一个寿司餐馆,其中有 3 个 Actor:客人 、 服务员以及厨师。

首先,客人向服务员点单。服务员将客人点的菜品写在一张纸条上,然后将这张纸 条放在厨师的邮箱中(将纸条贴在厨房的窗户上)。当厨师有空闲的时候,就会获取这条 消息(客人点的菜品),然后就开始制作寿司(处理消息),直至寿司制作完成。寿司准 备好以后,厨师会发送一条消息(盛放寿司的盘子)到服务员的邮箱(厨房的窗户),等 待服务员来获取这条消息。此时厨师可以去处理其他客人的订单。

当服务员有空闲时,就可以从厨房的窗户获取食物的消息(盛放寿司的盘子),然后将其送到客人的邮箱(比如餐桌)。当客人准备好的时候,他们就会处理消息(吃寿司),

运用餐厅的运作来理解 Actor 模型是很容易的。随着越来越多的客人来到餐厅,我们 可以想象服务员每次接收一位客人的下单,并将订单交给厨房,接着厨师处理订单制作寿 司,最后服务员将寿司交给客人。每个任务都可以并发进行。这就是 Actor 模型提供的最 大好处之一:当每个人各司其职时,使用 Actor 模型分析并发事件非常容易。而使用 Actor 模型对真实应用程序的建模过程和本例中对寿司餐厅的建模过程并没有太大差异。

Actor 模型的另一个好处就是可以消除共享状态。因为一个 Actor 每次只处理一条消 息,所以可以在 Actor 内部安全地保存状态。如果读者此前没有接触过并发系统,那么 可能不是很容易马上理解这一点。不过我们可以用一种简单的方式来进行说明。假设我 们尝试执行两个操作,同时读取、修改并保存一个变量,那么如果我们不进行同步操作 并加锁的话,其中的一个操作结果将丢失。这是一个非常容易犯的错误。

在下面的例子中,有两个线程同时执行一个非原子的自增操作。让我们来看看在线程 间共享状态会带来什么结果。会有多个线程从内存中读取一个变量值,将该变量自增,然 后将结果写回内存。这就是竞态条件(Race Condition),可以通过保证某一时刻只有一个 线程访问内存中的值来解决其带来的一部分问题。下面用一个 Scala 的例子进行说明。如果我们尝试使用多个线程并发地对一个整型变量执行 100 000 次自增操作,那么 极有可能会丢失掉其中一些自增操作的结果。

import concurrent.Future

import concurrent.ExecutionContext.Implicits.global var i, j = 0

(1 to 100000).foreach(_ => Future{i = i + 1})

(1 to 100000).foreach(_ => j = j + 1) Thread.sleep(1000)

println(s"${i} ${j}")

我们使用x = x + 1这个简单的函数将i和j都自增了100 000次,其中i的自增操作通 过多个线程并发执行,而 j 的自增操作则只通过一个线程来执行。等待 1 秒钟后,我们再 打印运行结果,确保所有更新都已经完成。读者可能会认为运行结果是 100000 100000, 然而结果却并非如此。

共享状态是不安全的。如果两个线程同时读取一个值,将该值自增,然后写回内存, 那么由于该值同时被多个线程读取,其中的某些操作结果将会丢失。这就是竞态条件, 也是使用共享状态的并发模型存在的最基本的问题之一。

通过推导每一步读取和写入操作的结果,我们能够更清晰地展示出竞态条件发生时的具体情况:

[...]
Thread 2 reads value in memory - value read as 9
Thread 2 writes value in memory - value set to 10 (9 + 1) Thread 1 reads value in memory - value read as 10
Thread 2 reads value in memory - value read as 10
Thread 1 writes value in memory - value set to 11 (10 + 1) !! LOST INCREMENT !!
Thread 2 writes value in memory - value set to 11 (10 + 1) Thread 1 reads value in memory - value read as 11
[...]


为了保证内存中的共享状态值不出现错误,我们可以使用锁和同步机制,防止多个 线程同时读取并写入同一个值。这就会导致问题变得更为复杂,更难理解,也更难确保 结果的正确。

使用共享状态带来的最大威胁就是代码在测试中看上去经常是正确的,但是一旦有多 个线程并发运行时,就会时不时地出现一些错误。由于测试时通常都不会出现多线程并发的 情况,因此这些 Bug 很容易被忽略。Dion Almaer 曾经在博客中写到过,大多数 Java 应用程 序都存在大量的并发 Bug,因此有时能正确运行,有时却运行失败。Actor 通过减少共享状 态来解决这一问题。如果我们把状态移到 Actor 内部,那么只有该 Actor 才能访问其内部的 状态(实际上只有一个线程能够访问这些内部状态)。如果把所有消息都看做是不可变的, 那么我们实际上可以去除 Actor 系统中的所有共享状态,构建更安全的应用程序。

Erlang 语言中监督和容错机制的发展演化

自从在前面提到的论文中第一次出现以来,Actor 模型随着时间的推移不断地发展,它对程序语言设计也产生了显著影响(比如 Scheme)。

20 世纪 80 年代,爱立信在 Erlang 语言中实现了 Actor 模型,用于嵌入式电信应用 程序。这一实现绝对值得一提。该实现中引入了通过监督机制(Supervision)提供的容 错性概念。爱立信使用 Erlang 和 Actor 模型实现了一款日后经常被提及的应用,叫作 AXD301。AXD301 能够提供 99.9999999%的可用性,这一点令人惊叹。相当于在 100 年 中,AXD301 只有 3.1 秒的时间会宕机。AXD 的开发团队表示,他们通过消除共享状态(正如我们之前介绍的一样)并引入 Erlang 中的监督容错机制来达到如此高的可用性。

Actor 模型也是通过监督机制来提供容错性的。监督机制基本上是指把处理响应错误 的责任交给出错对象以外的实体。这意味着一个 Actor 可以负责监督它的子 Actor,它会 监控子 Actor 的运行错误,并且根据子 Actor 生命周期中的运行表现执行相应的操作。当 一个正在运行的 Actor 发生错误时,监督机制提供的默认处理方式是重新启动发生错误 的 Actor(实际上是重新创建)。这种重新创建出错 Actor 的处理方式基于一种假设:意 外发生的错误是由错误状态导致的,因此移除并重新创建应用程序中出错的部分可以将 其修复,并恢复正常工作。我们也可以编写自定义的错误处理方式作为监督策略,这样一来基本上就可以采取任何操作将应用程序恢复至工作状态,如图所示:

分布式与位置透明性的发展演化

如今的业务需求要求工程师能够设计同时响应成千上万个并发用户请求的系统,而 用一台机子来运行这样的系统是绝对不够的。除此之外,多核处理器变得越来越流行, 因此将任务分布到多个核上以确保有效地利用硬件资源也变得越来越重要。

Akka 采用了 Actor 模型,并且继续对其发展演化,引入了对如今的工程师来说最重要 的一个特性:网络环境下的分布式。Akka 将其自己视作是一个支持容错性的分布式工具 集。也就是说,Akka 是一个提供了在多个服务器的物理边界之间工作的工具集。在支持 高可用性的同时,几乎可以无限扩展。在最近的几个 Akka 发布版本中,大多数新增的特 性都和解决网络系统的问题有关。最近引入的Akka Cluster(集群)允许将一个Actor系统部署到多台机器上,并且这一点对用户不可见。Akka IO 和 Akka HTTP 也已经进入了核 心库,使得系统之间的交互变得更简单。Akka 对于 Actor 模型的重要贡献之一就是位置透 明性的概念:就是说一个 Actor 的邮箱地址实际上可以是一个远程地址,但是这个地址对 开发者来说基本上是透明的,所以无论是否是远程地址,编写的代码也基本上是相同的。

Akka 沿用了 Erlang 的一些 Actor 实现,并且打破了 Actor 系统的物理边界。Akka 添加了远程处理以及位置透明性,使得一个 Actor 的邮箱可以在远程机器上,而 Akka 会对网络上的消息传输进行抽象封装。

最近,Akka 又引入了集群。读者可能知道一些基于 Amazon 那篇 Dynamo 论文的分 布式系统,比如 Dynamo、Cassandra 和 Riak。Akka Cluster 也采用了类似的现代化方法。 有了 Cluster 以后,一个 Actor 系统就可以运行在多台机器之上,而不同的节点之间也可 以就各自的状态互相通信交互,这就实现了一个可伸缩的 Akka 集群,并且没有单点故 障。这一机制和 Dynamo 风格的数据库类似,比如 Riak 和 Cassandra。这个特性非常好, 使得创建可伸缩并且具备优良容错性的系统变得相当容易。

Typesafe(提供Scala和Akka等技术的公司)1不断在通过提供许多网络工具(比如Akka IO和Akka HTTP)来推广分布式计算。除此之外,Typesafe已经参与了Reactive Streams提案, 而Akka也实现了第一个版本,支持用于异步处理的非阻塞背压(Back-Pressure)。


Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表