Akka学习指南(Java版)——2.初识事件驱动API

在继续学习更复杂的基于 Actor 的应用程序之前,需要了解一些事件驱动编程模型中的基本抽象概念:PromiseFuture。在前文中,我们已经了解过如何向一个 Actor 发送消息,以及如何在 Actor 内根据接收到的事件进行不同的响应行为。

但是,如果想要通过发送消息向 Actor 请求获取一些输出结果呢?比如说需要从内存键值存储中获取一条记录。

1. 常见阻塞式 IO 案例

几乎每个开发者都很熟悉阻塞式的代码。进行 IO 操作时,编写的都是阻塞式的代码。当我们调用一个同步的 API 时,调用的方法不会立即返回:应用程序会等待该调用执行完成。

例如,如果发起一个 HTTP 请求的话,只有在请求完成后,才会收到返回的响应对象。由于发起调用的线程会暂停执行并等待,因此等待 IO 操作完成的代码都是阻塞的,在 IO 操作完成之前,发起调用的线程无法进行任何其他操作。

下面是一个阻塞式代码的例子。例子使用 Java 数据库连接(Java Database Connectivity,JDBC)发起一个查询:

stmt = conn.createStatement();
String sql = "select name from users where id='123'"; 
ResultSet rs = stmt.executeQuery(sql); 
rs.next();
String name = rs.getString("name");

这里我们使用 JDBC 从数据库获取一个用户名。代码看上去非常简单,但是有一些不是很显然的运行行为降低了这段简单代码的可读性。

  • 延时:并非立即能够获得通过网络传回的结果。
  • 失败:请求可能会失败(例如远程服务有可能不可用)。有许多可能性都会导致抛出异常

调用 executeQuery 时,发起调用的线程必需等待数据库查询的完成。在一个 Web 应用程序中,有许多用户可能会同时发起很多并发请求,线程池中的线程数很可能会达到其支持的最大值。如果这些线程都在等待 IO 操作完成的话,那么即使还有可用的计算资源,也没有任何线程能够使用这些资源,因此服务器也就无法再进行任何操作了。如果读者曾经对阻塞式的基于 Servlet 的 Web 应用程序做过性能调优,那么可能接触过线程池中线程数的最大限制问题。

通常情况下,在服务器负载较大时,由于所有线程都只是在等待,服务器无法充分利用 CPU 资源。这可能是因为线程池中的线程被耗尽,也可能是因为系统把时间都花在了需要 CPU的线程之间的上下文切换上,而没有利用 CPU 进行实际的工作。同样,由于线程池中的线程数是有限的,因此如果所有线程都在等待的话,服务器在释放其中一个线程资源之前就无法处理接收到的其他请求,导致系统响应延时增加。

为什么不直接使用没有线程数量限制的线程池呢(为每一个请求都新建一个线程)?新建线程是有开销的,维护许多运行的线程也是有开销的。

在同一个 CPU 核心中运行多个线程时,操作系统需要不断切换线程上下文,保证所有的线程都能分配到 CPU 时间。CPU 需要获取并存储当前线程的状态,然后载入下一个需要使用 CPU 的线程的上下文。如果运行了 1000 个线程的话,可以想象,大把的时间会耗费在上下文切换上。

总结一下,使用多线程来处理阻塞式 IO 时会遇到一些问题:

  • 代码没有在返回类型中明确表示错误;
  • 代码没有在返回类型中明确表示延时;
  • 阻塞模型的吞吐量受到线程池大小的限制;
  • 创建并使用许多线程会耗费额外的时间用于上下文切换,影响系统性能。

2.理解消息驱动

非阻塞、异步的消息驱动系统可以只运行少量的线程,并且不阻塞这些线程,只在需要计算资源时才使用它们。这大大提高了系统的响应速度,并且能够更高效地利用系统资源。取决于具体实现的不同,异步系统还可以在返回类型中清晰地定义错误和延时等信息,我们接下来就会看到这一点带来的好处。

其缺点在于我们需要花点时间来理解如何使用消息驱动的编程模型来编写代码。

对于这两种模型,我们将各给出一个例子,帮助我们更好地理解这两种设计方法的工作原理。

首先,我们来看一个非常简单的使用阻塞 IO 调用数据库的例子。

String username = getUsernameFromDatabase(userId);
System.out.println(username);

调用了方法之后,线程会进入到被调用的方法,得到结果后再返回。如果进行调试的话,可以设置断点在该线程内进入被调用的 getUsernameFrom Database 方法,逐行查看该方法的具体执行情况。一旦开始执行真正的 IO 操作,该线程就会暂停,直到 IO 结果返回为止。然后,线程返回该方法的结果,跳出该方法,继续执行,打印出结果

基于事件驱动编写相同功能的代码时,由于需要描述事件完成时要进行的操作,而该操作在一个不同的上下文中执行,因此代码看上去会有所差别。要把上面的例子改为事件驱动,就需要在数据库返回结果之后再在代码中调用打印语句。刚开始可能需要花点时间来适应这个模型,但是一旦适应以后就会觉得很自然了。
在这里插入图片描述

要转而使用事件驱动的模型,我们需要在代码中用不同的方法来表示结果。我们需要用一个占位符来表示最终将会返回的结果:Future。然后注册事件完成时应该进行的操作:打印结果。我们注册的代码会在 Future 占位符的值真正返回可用时被调用执行。

“事件驱动”这个术语正是描述了这种方法:在发生某些特定事件时,就执行某些对应的代码。

//Java
CompletableFuture<String> usernameFuture = 
 	getUsernameFromDatabaseAsync(userId); 

usernameFuture.thenRun(username -> 
 	//executed somewhere else 
	System.out.println(username) 
);

从线程的角度来看,代码首先会调用方法,然后进入该方法内部,接着几乎立即返回一个 Future/CompletableFuture。返回的这个结果只是一个占位符,真正的值在未来某
个时刻最终会返回到这个占位符内。

我们不会过于详细地介绍这个方法调用本身,读者需要理解的是:该方法会立即返回,而数据库调用及结果的生成是在另一个线程上执行的。ExecutionContext 表示了执行这些操作的线程,我们将在本书后面的章节中对此进行介绍。(在 Akka 中,可以看到ActorSystem 中有一个 dispatcher,就是 ExecutionContext 的一种实现。)

要注意的是,调试异步代码与调试同步代码有着很大的不同:我们无法在发起调用的线程上看到数据库调用的所有细节,因此无法像调试使用阻塞模型的代码一样,使用调试器在发起调用的线程内单步执行,了解数据库调用的所有细节。同样地,如果查看某个错误信息的栈追踪信息,可能找不到最开始发起调用的代码,看到的是真正执行这段代码的栈信息。

方法返回 Future 之后,我们只得到了一个承诺,表示真正的值最终会返回到 Future 中。我们并不希望发起调用的线程等待返回结果,而是希望其在真正的结果返回后再执行特定的操作(打印到控制台)。在一个事件驱动的系统中,需要做的就是描述某个事件发生时需要执行的代码。在 Actor 中,描述接收到某个消息时进行的操作。同样地,在Future 中,我们描述 Future 的值真正可用时进行的操作。在 Java 8 中,使用 thenRun 来注册事件成功完成时需要执行的代码在这里插入图片描述
有一点必须要再次强调:打印语句并不会运行在进行事件注册的线程上。它会运行在另一个线程上,该线程信息由 ExecutionContext 维护。Future 永远是通过 Execution Context来创建的,因此我们可以选择在哪里运行 Future 中真正需要执行的代码。

在注册的匿名函数中,可以访问到作用域内的所有变量。不过由于方法并不在与闭包相同的同一词法作用域内被调用,因此在调用方法时要格外小心,或者直接不要在闭包内调用方法。我们将会在下一章中介绍这一要点。

要注意的是,Future 是有可能执行失败的,因此一定要给 Future 提供一个超时参数(在 Scala API 中是必须提供的),这样一来,Future 就不可能一直等待结果,不管是执行
成功,还是失败,可以保证 Future 一定会执行完成。接下来我们会更深入地介绍如何来处理 Future。

3.使用 Future 进行响应的 Actor

尽管接下来的测试用例确实引入了异步 API,但是测试用例仍然会因为等待结果而阻塞。这对于展示另一种在测试用例中处理 Akka 的方法很有帮助。因为如果不等待结果的话,测试用例始终会立即返回,永远会通过,所以在测试用例中需要阻塞等待。

Akka 是用 Scala 编写的。一般来说,Scala 和 Java 的 API 是一一对应的,
不过有一个重要的特例:

所有返回 Future 的异步方法返回的都是 Scala 的 scala.concurrent.Future。

所以我们需要某种方法处理Scala的Future,这里我们将Scala的Future转换成 Java 8 的 CompletableFuture

如果在构建 Play 应用程序的话,Play 的 Promise API 也是个很好的选择。

首先,我们需要加入Maven依赖,支持 Scala 和 Java 8 Future 之间的相互转换。

<dependency>
    <groupId>org.scala-lang.modules</groupId>
    <artifactId>scala-java8-compat_2.12</artifactId>
    <version>0.9.1</version>
</dependency>

3.1.测试用例

下面就是完整的测试用例。

import static scala.compat.java8.FutureConverters.*;
public class PongActorTest {
	ActorSystem system = ActorSystem.create();
	ActorRef actorRef =
			system.actorOf(Props.create(JavaPongActor.class));
	@Test
	public void shouldReplyToPingWithPong() throws Exception {
		Future sFuture = ask(actorRef, "Ping", 1000);
		final CompletionStage<String> cs = toJava(sFuture);
		final CompletableFuture<String> jFuture =
				(CompletableFuture<String>) cs;
		assert (jFuture.get(1000, TimeUnit.MILLISECONDS)
				.equals("Pong"));
	}
	@Test(expected = ExecutionException.class)
	public void shouldReplyToUnknownMessageWithFailure() throws
			Exception {
		Future sFuture = ask(actorRef, "unknown", 1000);
		final CompletionStage<String> cs = toJava(sFuture);
		final CompletableFuture<String> jFuture =
				(CompletableFuture<String>) cs;
		jFuture.get(1000, TimeUnit.MILLISECONDS);
	}
}

上面的 PongActor 测试有两个测试用例,一个针对成功的情况,一个针对失败的情况。

接下来我们就详细介绍 API 的各个部分。

3.1.1.创建 Actor

首先创建一个 ActorSystem,然后通过 actorOf 在刚创建的 Actor 系统中创建一个 Actor,前面的章节对此做过介绍:

ActorSystem system = ActorSystem.create();
ActorRef actorRef = system.actorOf(Props.create(JavaPongActor.class));

3.1.2.ask()

现在向 Actor 询问其对于某个消息的响应:

final Future sFuture = ask(actorRef, "Ping", 1000);

这一做法相当直接,我们调用 ask 方法,传入以下参数:

  • 消息发送至的 Actor 引用;
  • 想要发送给 Actor 的消息;
  • Future 的超时参数:等待结果多久以后就认为询问失败。

3.1.3.Scala Future

ask 会返回一个 Scala Future,作为响应的占位符。在 Actor 的代码中,Actor 会向 sender()发送回一条消息,这条消息就是在 ask 返回的 Scala Future 中将接收到的
响应。

虽然我们无法在 Java 8 中使用 Scala Future,但是可以通过之前导入的库将其转换为CompletableFuture:

final CompletionStage<String> cs = toJava(sFuture); 
final CompletableFuture<String> jFuture = (CompletableFuture<String>) cs;

我们首先使用 scala.compat.java8.FutureConverters.toJavaScala Future 进行转换,该方法会返回一个 CompletionStage

3.1.4.CompletionStage

CompletionStage 是 CompletableFuture 实现的接口,而且这是一个只读的接口。为了调用 get 方法,我们将结果的类型转换为 CompletableFuture

在测试用例外部,我们并不需要进行该转换。

要注意的是,我们在 Future 内存放的数据类型是 String,而 Actor 是无类型的,会返回 Object,因此读者可能会觉得这种无限制的类型转换有问题。

当然,在 ActorSystem 外部与 Actor 进行通信的时候需要在这方面多加小心。

不过在这条消息中,我们知道 Actor 一定会返回一个 String,所以认为 Future 中存放 String 是安全的。

最后,我们调用 get()方法将测试线程阻塞,并得到结果。在查询失败的例子中,get方法会抛出一个从 Actor 发送出的 akka.status.Failure 异常。

现在我们就有了一个查询成功的 Future 和一个查询失败的 Future 用来做实验了!


4.在测试中阻塞进程

我们向 Actor 请求消息会返回一个 Future 作为响应,这展示了从 Actor 系统外部与 Actor 进行通信的方法。在这个测试用例中,我们休眠/阻塞了调用 Await.result 的测试线程,这样就能同步地得到 Future 的结果。

不要在非测试代码中休眠或阻塞线程。

在测试用例中,如果要阻塞 Java 8 的 CompletableFuture,推荐使用的方法是调用Future 的 get()方法。get()方法会阻塞线程,直到返回结果。

jFuture.get().equals("Pong")

尽管在 ask 方法中已经设置了 Future 的超时时间,但是在这里仍然必须要提供超时参数。

如果 Future 返回失败,那么阻塞线程会抛出异常。Future 失败会产生一个 Throwable,Java 8 的 CompletableFuture 会对这个 Throwable 进行封装并抛出 ExecutionException.

现在我们就有了一个 Future 的例子,可以基于这个例子来创建测试用例,研究实验结果。

理解 Future API 对于编写异步代码至关重要!

猜你喜欢

转载自blog.csdn.net/monokai/article/details/107391286