使用Scala开发现代应用程序:Akka的并发性和并行性

本文探讨了现代软件系统中的并发性和并行性概念,重点关注Scala中的Akka框架。通过介绍线程、反应堆、事件循环、演员模型和Akka的特性,阐述了如何利用Akka实现高效、安全的并发编程。文章还涵盖了Akka的监督、模式、远程处理和测试支持,强调了其在构建分布式系统中的作用。
摘要由CSDN通过智能技术生成

本文是我们名为“ 使用Scala开发现代应用程序 ”的学院课程的一部分。

在本课程中,我们提供一个框架和工具集,以便您可以开发现代的Scala应用程序。 我们涵盖了广泛的主题,从SBT构建和响应式应用程序到测试和数据库访问。 通过我们简单易懂的教程,您将能够在最短的时间内启动并运行自己的项目。 在这里查看

可以公平地说,如今,具有单CPU(或仅一个内核)的计算机时代已成为被人们遗忘的历史。 现在,大多数设备,无论它们有多小,都是使用旨在提高整体计算能力的多核CPU架构构建的。

硬件方面的这些进步和创新迫使我们重新思考如何开发和运行软件系统以有效利用所有可用资源的方法。

1.简介

在本教程的这一部分中,我们将讨论现代软件系统所依赖的两个核心概念:并发性和并行性。 尽管它们彼此之间非常接近,但还是有一些细微但重要的区别。 并发描述了多个任务可以随时间推移进行的过程,而并行性描述了同时执行多个任务时的过程。

2.线程

传统上,大多数并发编程模型都是围绕线程构建的,Java也不例外。 一个典型的JVM应用程序(作为一个进程运行)可能会产生许多线程,以便并发地或理想地并行地执行一些工作。

jvm过程

图1典型的JVM进程产生了几个线程

这种模型在一段时间内效果不错,但是基于线程的并发模型至少存在一个基本缺陷:状态或资源共享。 在大多数情况下,线程需要交换共享一些数据或利用另一个共享资源来完成其工作。 如果没有适当的机制,多个线程对共享资源的不受控制的访问或对共享状态的修改(称为竞争条件 )会导致数据损坏,并经常导致应用程序崩溃。

同步化

图2使用同步访问共享状态

正确使用同步原语(锁,互斥量,信号量,监视器等)可以解决访问共享状态(或资源)的问题,但是付出的代价确实很高。 它不仅使编程模型复杂化,而且多线程执行流的不确定性使问题排查过程非常耗时且困难。 而且,出现了一类新的问题:锁争用,线程饥饿,死锁,活动锁等等。

随之而来的是,线程和线程管理会消耗大量底层操作系统资源,并且从本质上讲,在某个时候创建​​更多线程实际上会对应用程序性能和可伸缩性产生负面影响。

3.反应堆和事件循环

基于线程的并发模型的缺陷使业界的注意力转向寻找更能满足现代软件系统需求的替代方案。 反应堆模式事件处理是现代异步和无阻塞编程范例的基础,是一些需要并发大规模调用的新兴编程模型。

反应堆

图3反应器模式(简化)

请注意,这是对Reactor模式的可能实现之一的非常简化的可视化,但是它很好地说明了其关键要素。 Reactor的核心是单线程事件循环。 在事件处理下,事件处理可以使用一个或多个线程(通常分组在一个池中)来进行实际工作,但是此模型中线程的用途和用法完全不同,并且与应用程序完全隔离。

4.演员和消息

消息传递 ,或者更准确地说,异步消息传递是另一个非常有趣且功能强大的并发模型,最近它获得了很大的关注。 Actor Model起源于1973年,是异步消息传递并发的最好例子之一。

演员是演员模型的核心实体。 每个参与者都有自己的消息队列(称为邮箱),单线程消息处理程序,并且仅通过异步消息与其他参与者进行通信(不忽略参与者也可以创建另一个参与者的事实)。

演员

图4阿卡族演员

这是典型的一无所有的体系结构:参与者可能具有自己的状态,但他们从未与任何其他参与者共享任何东西。 Actor可以生活在相同的JVM进程中,或者在同一物理节点上的多个JVM进程中,甚至可以散布在网络上,只要它们能够相互引用并通过消息进行通信就没有关系。

5.认识阿卡

当我们谈论反应性应用程序并了解Akka Streams时,我们已经遇到了Akka工具包 。 但是,回顾一下历史,值得一提的是, Akka工具包已经开始作为JVM平台上的Actor Model实现。 从那时起,它已经看到了许多发行版(最近的发行版是2.4.10 ),获得了许多附加的特性和功能,但是即使在今天,演员仍然是Akka的骨干。

ActorSystemAkka演员宇宙的切入点。 它是应用程序中创建和管理参与者的唯一位置。

implicit val system = ActorSystem("akka-actors")

这样,我们就可以创建新演员了! 在Akka中 ,每个actor都应该继承Actor特质(或与其混合)并实现receive功能,例如:

import akka.actor.Actor

class SampleActor extends Actor {
  def receive = {
    ...
  }
}

但是比平时更常见的是,您还可以将ActorLogging特性包含在组合中,以使用日志参考来访问专用的记录器,例如:

import akka.actor.Actor
import akka.actor.ActorLogging

class SampleActor extends Actor with ActorLogging {
  def receive = {
    case _ => log.info("Received some message!")
  }
}

尽管目前我们的SampleActor并没有做很多事情,但是我们可以实例化它并向其发送任何消息。 正如我们已经提到的,actor仅通过ActorSystem实例而不是使用new运算符创建,例如:

val sampleActor = system.actorOf(Props[SampleActor], "sample-actor")
sampleActor ! "Message!"

如果您认为sampleActor变量是SampleActor类的实例,则不太正确。 它实际上是对SampleActor actor的引用,表示为ActorRef类。 这是解决Akka中特定actor的唯一机制,无法直接访问基础actor类实例。

当我们运行应用程序时会发生什么? 除了应该在控制台中看到类似内容的事实外,其他内容不多:

[INFO] [akka-actors-akka.actor.default-dispatcher-2] [akka://akka-actors/user/sample-actor] Received some message!

6.监督

Akka中,演员有趣但非常重要的特性是他们是按等级组织的。 自然地,演员可以产生子演员,以便将作品分成较小的部分,从而形成父子层次。

这听起来像是次要的细节,但这并不是因为在阿卡族,父母演员可能会看着他们的孩子,这一过程被称为监督 。 在这种情况下,父级参与者本质上将成为主管,并且在子级参与者遇到失败的情况下(或更具体地说,例外终止),可以采用不同的策略。

官方文档详细讨论了违约和不同的监管策略,但让我们看一个快速的示例。 我们的ChildActor定义为始终在收到任何Message时引发异常。

class ChildActor extends Actor with ActorLogging {
  def receive = {
    case Message(m) => 
      throw new IllegalStateException("Something unexpected happened")
  }
}

因此,我们的ParentActor actor创建ChildActor actor的实例,并将接收到的任何消息转发给ChildActor实例。

class ParentActor extends Actor with ActorLogging {
  val child = context.actorOf(Props[ChildActor])
  
  override val supervisorStrategy = OneForOneStrategy() {
    case _: IllegalStateException => Resume
    case _: Exception => Escalate
  }

  def receive = {
    case _ => child ! Message("Message from parent")
  }
}

根据默认的监督策略,引发Exception的参与者将重新启动(在大多数情况下,这可能是所需的行为)。 但是,在我们的示例中,仅在IllegalStateException情况下,我们重写了此策略(使用ParentActor supervisorStrategy属性),以恢复对监督参与者( ChildActor )的正常消息处理。

7.模式

在其基本形式中, Akka中的参与者通过异步的单向消息进行通信。 它确实可以工作,但是许多现实世界场景需要更复杂的交互,例如使用请求/答复通信,断路器,参与者与其他参与者之间的管道消息。 幸运的是, akka.pattern包提供了一组常用的Akka模式,可以立即使用。 例如,让我们稍微修改一下SampleActor实现,以处理Message类型的Message并在收到Message后使用MessageReply回复。

case class Message(message: String)
case class MessageReply(reply: String)

class SampleActor extends Actor with ActorLogging {
  def receive = {
    case Message(m) => sender ! MessageReply(s"Reply: $m")
  }
}

现在,我们可以使用Ask模式来将消息发送给actor并等待答复,例如:

import akka.pattern.ask
import akka.util.Timeout

implicit val timeout: Timeout = 1 second
val reply = (sampleActor ? Message("Please reply!")).mapTo[MessageReply]

在这种情况下,发送方将消息发送给参与者,并等待回复(超时时间为1秒)。 请注意,典型的Akka参与者不支持有关消息的任何类型安全语义:任何内容都可以发送和接收作为响应。 发送方有责任进行适当的类型转换(例如,使用mapTo方法)。 同样,如果发件人将消息发送给不知道如何处理的参与者,则消息将以空字母结尾。

8.类型演员

如前所述, Akka的演员在接受或回复消息时不提供任何类型的安全性。 但是,在相当长的一段时间内, Akka都提供了对Typed Actors的实验性支持,其中的联系人是显式的,并且将由编译器强制执行。

Typed Actor的定义与常规的Akka actor完全不同,并且非常类似于我们用来构建RPC样式系统的方式 。 首先,我们必须首先定义接口及其实现,例如:

trait Typed {
  def send(message: String): Future[String]
}

class SampleTypedActor extends Typed {
  def send(message: String): Future[String] = Future.successful("Reply: " + message)
}

反过来, 类型Actor的实例化方式需要更多代码,尽管仍在幕后使用ActorSystem

implicit val system = ActorSystem("typed-akka-actors")
  
val sampleTypedActor: Typed = 
  TypedActor(system).typedActorOf(TypedProps[SampleTypedActor]())
  
val reply = sampleTypedActor
  .send("Hello Typed Actor!")
  .andThen { case Success(r) => println(r) }

此时,您可能会想到一个逻辑问题:不应在所有地方使用Typed Actor吗? 好点,但简短的答案是:不,可能不会。 如果您感到好奇,请花一些时间来讨论有关使用Typed Actor与常规,非Typed Actor的利弊的精彩讨论

9.计划程序

除了提供Actor Model的高级实现之外, Akka还提供了许多非常有用的实用程序。 其中之一是调度支持 ,它提供了定期或在某个时间点向特定参与者发送消息的功能。

implicit val system = ActorSystem("akka-utilities")
import system.dispatcher
  
val sampleActor = system.actorOf(Props[SampleActor], "sample-actor")  
system.scheduler.schedule(0 seconds, 100 milliseconds, sampleActor, "Wake up!")

毋庸置疑,大多数实际应用程序都要求能够安排一些任务的执行时间,因此开箱即用此功能非常方便。

10.事件总线

Akka的另一个非常有用的实用程序包含通用事件总线概念以及由ActorSystem事件流的形式提供的特定实现。

如果演员之间的通信假定消息的发送者通过事件流以某种方式知道接收者是谁,则演员可以选择向任何其他演员广播任何事件(某种类型的消息),而无需事先知道谁会收到它。 在这种情况下,有关各方必须通过按事件的类型订阅此类事件来表达其兴趣。

例如,假设我们有一条重要消息,我们仅将其命名为Event

case class Event(id: Int)

我们的SampleEventActor被指定为处理此类消息,并在每次收到消息时在控制台上打印出该消息的id

class SampleEventActor extends Actor with ActorLogging {
  def receive = {
    case Event(id) => log.info(s"Event with '$id' received")
  }
}

看起来很简单,但目前还没有什么令人兴奋的。 现在,让我们看一下事件流,并在操作中发布/订阅通信模式。

implicit val system = ActorSystem("akka-utilities")
  
val sampleEventActor = system.actorOf(Props[SampleEventActor])  
system.eventStream.subscribe(sampleEventActor, classOf[Event])
  
system.eventStream.publish(Event(1))
system.eventStream.publish(Event(2))
system.eventStream.publish(Event(3))

我们的sampleEventActor通过调用system.eventStream.subscribe()方法表示有兴趣接收Event类型的消息。 现在,每次要通过system.eventStream.publish()调用发布Event时,无论发布者是谁, sampleEventActor都将接收它。 启用日志记录后,我们将在控制台输出中看到类似的内容:

[INFO] [akka-utilities-akka.actor.default-dispatcher-2] [akka://akka-utilities/user/$a] Event with '1' received
[INFO] [akka-utilities-akka.actor.default-dispatcher-2] [akka://akka-utilities/user/$a] Event with '2' received
[INFO] [akka-utilities-akka.actor.default-dispatcher-2] [akka://akka-utilities/user/$a] Event with '3' received

11.远程处理

到目前为止,我们看到的所有示例都只涉及一个ActorSystem ,它在一个节点内的单个JVM中运行。 但是Akka的网络扩展支持多JVM /多节点部署,因此不同的ActorSystem可以在真正的分布式环境中相互通信。

为了启用ActorSystem远程功能,我们需要更改其默认的Actor参考提供程序并启用网络传输。 使用application.conf配置文件可以轻松完成所有操作:

akka {
  actor {
    provider = "akka.remote.RemoteActorRefProvider"
  }
  
  remote {
    enabled-transports = ["akka.remote.netty.tcp"]
    netty {
	    tcp {
	      hostname = "localhost"
	      port = ${port}
	    }
	}
  }
}

作为练习,我们将运行两个ActorSystem实例,一个在端口12000上名为akka-remote-1 ,另一个在端口12001上运行akka-remote-2 。 我们还将定义一个与之通信的Actor, SampleRemoteActor

class SampleRemoteActor extends Actor with ActorLogging {
  def receive = {
    case m: Any => log.info(s"Received: $m")
  }
}

在第一个ActorSystem上akka-remote-1 ,我们将创建SampleRemoteActor的实例并向其发送一条消息。

implicit val system = ActorSystem("akka-remote-1")
  
val sampleActor = system.actorOf(Props[SampleRemoteActor], "sample-actor")
sampleActor ! "Message from Actor System #1!"

但是在第二个akka-remote-2 ,我们将使用其远程引用将消息发送到SampleRemoteActor实例,其中包括ActorSystem名称( akka-remote-1 ),主机( localhost ),端口( 12000 ),并指定演员名称(在本例中为sample-actor ):

implicit val system = ActorSystem("akka-remote-2")
  
val sampleActor = system.actorSelection(
  "akka.tcp://akka-remote-1@localhost:12000/user/sample-actor")
sampleActor ! "Message from Actor System #2!"

很简单,不是吗? 并排运行两个ActorSystem实例将在akka-remote-1 JVM进程的控制台中产生以下输出:

[INFO] [main] [akka.remote.Remoting] Starting remoting
[INFO] [main] [akka.remote.Remoting] Remoting started; listening on addresses :[akka.tcp://akka-remote-1@localhost:12000]
[INFO] [main] [akka.remote.Remoting] Remoting now listens on addresses: [akka.tcp://akka-remote-1@localhost:12000]
[INFO] [akka-remote-1-akka.actor.default-dispatcher-2] [akka.tcp://akka-remote-1@localhost:12000/user/sample-actor] Received: Message from Actor System #1!
[INFO] [akka-remote-1-akka.actor.default-dispatcher-4] [akka.tcp://akka-remote-1@localhost:12000/user/sample-actor] Received: Message from Actor System #2!

到目前为止,除了我们已经看到的内容外,不仅一个actor系统可以引用另一个actor系统中的actor ,它还可以远程创建新的actor实例。

12.测试

Akka提供了出色的测试支持,以确保可以涵盖演员行为和互动的每个方面。 实际上, Akka TestKit为编写传统的单元测试和集成测试提供了适当的框架

单元测试技术围绕TestActorRef进行 ,这是常规ActorRef实现的一种简化,不涉及并发性,并且无法访问actor状态内部。 让我们从这一点开始,并使用已经熟悉的specs2框架为SampleActor进行简单的单元测试。

class SampleActorTest extends Specification with AfterAll {
  implicit val timeout: Timeout = 1 second
  implicit lazy val system = ActorSystem("test")
  
  "Sample actor" >> {
    "should reply on message" >> { implicit ee: ExecutionEnv =>
      val actorRef = TestActorRef(new SampleActor)
      actorRef ? Message("Hello") must be_==(MessageReply("Reply: Hello")).await
    }
  }
  
  def afterAll() = {
    system.terminate()
  }
}

单元测试当然是一个很好的开始,但与此同时,由于它依赖于系统的简化视图,因此通常非常受限制。 但是,再次感谢Akka TestKit ,我们可以使用更强大的测试技术。

class SampleActorIntegrationTest extends TestKit(ActorSystem("test")) 
    with ImplicitSender with WordSpecLike with BeforeAndAfterAll {
  
  "Sample actor" should {
    "should reply on message" in {
      val actorRef = system.actorOf(Props[SampleActor])
      actorRef ! Message("Hello")
      expectMsg(MessageReply("Reply: Hello"))
    }
    
    "should log an event" in {
      val actorRef = system.actorOf(Props[SampleActor])      
      EventFilter.info(
        message = "Event with '100' received", occurrences = 1) intercept {
        actorRef ! Event(100)
      }
    }
  }
  
  override def afterAll() = {
    shutdown()
  }
}

这次,我们使用了ScalaTest框架的观点,并采用了稍微不同的方法来依赖TestKit类,该类提供了一组关于消息期望的丰富断言 。 我们不仅要刺探消息的能力,我们也能够让使用过预期的日志记录断言EventFilter类,通过支持TestEventListenerapplication.conf文件。

akka {
  loggers = [
    akka.testkit.TestEventListener
  ]
}

真的很好,测试用例看起来简单,可读且可维护。 但是, Akka测试功能并不止于此,而且还在不断发展。 例如,值得一提的是实验性多节点测试支持的可用性。

13.结论

Akka是一个了不起的工具包,并为许多其他库和框架奠定了坚实的基础。 作为Actor Model的实现,它还提供了另一种使用异步消息传递并促进不变性的并发和并行性方法。

值得一提的是,这些天Akka一直在积极开发,并且已经超越了Actor模型 。 随着每个新版本的发布,它都包含越来越多的工具(稳定或/和实验性的),用于构建高度并发和分布式的系统。 它的许多高级功能(例如集群持久性有限状态机路由等)都没有涉及,但是Akka官方文档是熟悉所有这些功能的最佳场所。

14.接下来

在本教程下一部分中,我们将讨论Play! 框架 :强大,高效且功能丰富的框架,用于在Scala中构建可扩展的成熟Web应用程序。

翻译自: https://www.javacodegeeks.com/2016/10/developing-modern-applications-scala-concurrency-parallelism-akka.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值