§了解 Play 线程池
Play Framework 从底层向上是一个异步 Web 框架。Play 中的线程池经过调整,使用比传统 Web 框架更少的线程,因为 play-core 中的 IO 从不阻塞。
因此,如果您计划编写阻塞 IO 代码或可能执行大量 CPU 密集型工作的代码,您需要准确了解哪个线程池承担了该工作负载,并且需要相应地调整它。在没有考虑这一点的情况下进行阻塞 IO 可能会导致 Play Framework 的性能非常差,例如,您可能会看到每秒仅处理几个请求,而 CPU 使用率却停留在 5%。相比之下,在典型开发硬件(例如,MacBook Pro)上的基准测试表明,Play 在正确调整的情况下,能够轻松处理每秒数百甚至数千个请求的工作负载。
§了解何时阻塞
典型的 Play 应用程序最常阻塞的地方是在与数据库通信时。不幸的是,主要的数据库都没有为 JVM 提供异步数据库驱动程序,因此对于大多数数据库来说,您唯一的选择是使用阻塞 IO。一个值得注意的例外是 ReactiveMongo,这是一个使用 Play 的 Iteratee 库与 MongoDB 通信的 MongoDB 驱动程序。
您的代码可能阻塞的其他情况包括
- 通过第三方客户端库(即,不使用 Play 的异步 WS API)使用 REST/WebService API
- 某些消息传递技术仅提供同步 API 来发送消息
- 当您自己直接打开文件或套接字时
- 占用大量 CPU 资源的操作,由于执行时间过长而导致阻塞
一般来说,如果您使用的 API 返回 Future
,则它是非阻塞的,否则它是阻塞的。
请注意,您可能会倾向于将阻塞代码包装在 Futures 中。但这并不会使其变为非阻塞,只是意味着阻塞将在不同的线程中发生。您仍然需要确保您使用的线程池有足够的线程来处理阻塞。请参阅 Play 的示例模板 https://playframework.com/download#examples,了解如何为阻塞 API 配置您的应用程序。
相反,以下类型的 IO 不会阻塞
- Play WS API
- 异步数据库驱动程序,例如 ReactiveMongo
- 向/从 Pekko 演员发送/接收消息
§Play 的线程池
Play 使用多个不同的线程池来执行不同的任务
-
内部线程池 - 这些线程池由服务器引擎内部使用,用于处理 IO。应用程序代码不应该由这些线程池中的线程执行。Play 默认配置为使用 Pekko HTTP 服务器后端,因此应使用
application.conf
中的 配置设置 来更改后端。或者,Play 还附带一个 Netty 服务器后端,如果启用,它也具有可以从application.conf
中 配置 的设置。 -
Play 默认线程池 - 这是在 Play Framework 中执行所有应用程序代码的线程池。它是一个 Pekko 调度器,由应用程序
ActorSystem
使用。它可以通过配置 Pekko 来配置,如下所述。
§使用默认线程池
Play Framework 中的所有操作都使用默认线程池。在执行某些异步操作时,例如,在 future 上调用 map
或 flatMap
,您可能需要提供一个隐式执行上下文来在其中执行给定的函数。执行上下文基本上是 ThreadPool
的另一个名称。
在大多数情况下,要使用的适当执行上下文将是 Play 默认线程池。可以通过 @Inject()(implicit ec: ExecutionContext)
访问它。这可以通过将其注入到您的 Scala 源文件中来使用
class Samples @Inject() (components: ControllerComponents)(implicit ec: ExecutionContext)
extends AbstractController(components) {
def someAsyncAction = Action.async {
someCalculation()
.map { result => Ok(s"The answer is $result") }
.recover {
case e: TimeoutException =>
InternalServerError("Calculation timed out!")
}
}
def someCalculation(): Future[Int] = {
Future.successful(42)
}
}
或在 Java 代码中使用 CompletionStage
和 ClassLoaderExecutionContext
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import javax.inject.Inject;
import play.libs.concurrent.ClassLoaderExecutionContext;
import play.mvc.*;
public class MyController extends Controller {
private ClassLoaderExecutionContext clExecutionContext;
@Inject
public MyController(ClassLoaderExecutionContext ec) {
this.clExecutionContext = ec;
}
public CompletionStage<Result> index() {
// Use a different task with explicit EC
return calculateResponse()
.thenApplyAsync(
answer -> {
return ok("answer was " + answer).flashing("info", "Response updated!");
},
clExecutionContext.current());
}
private static CompletionStage<String> calculateResponse() {
return CompletableFuture.completedFuture("42");
}
}
此执行上下文直接连接到应用程序的 ActorSystem
,并使用 Pekko 的 默认调度器.
§配置默认线程池
可以使用 application.conf
中 pekko
命名空间下的标准 Pekko 配置来配置默认线程池。
如果您想配置默认调度器,使用其他调度器,或定义一个新的调度器,请参阅 Pekko 参考文档的 调度器类型 部分以获取完整详细信息。
您可用的完整配置选项可以在 配置 部分找到。
§使用其他线程池
在某些情况下,您可能希望将工作调度到其他线程池。这可能包括 CPU 密集型工作或 IO 工作,例如数据库访问。为此,您应该首先创建一个 ThreadPool
,这在 Scala 中很容易做到
val myExecutionContext: ExecutionContext = actorSystem.dispatchers.lookup("my-context")
在这种情况下,我们使用 Pekko 来创建 ExecutionContext
,但您也可以轻松地使用 Java 执行器或 Scala fork join 线程池来创建自己的 ExecutionContext
。Play 提供了 play.libs.concurrent.CustomExecutionContext
和 play.api.libs.concurrent.CustomExecutionContext
,可用于创建您自己的执行上下文。有关更多详细信息,请参阅 ScalaAsync 或 JavaAsync。
要配置此 Pekko 执行上下文,您可以在 application.conf
中添加以下配置
my-context {
fork-join-executor {
parallelism-factor = 20.0
parallelism-max = 200
}
}
要在 Scala 中使用此执行上下文,您只需使用 scala Future
伴随对象函数
Future {
// Some blocking or expensive code here
}(myExecutionContext)
或者您可以隐式使用它
implicit val ec = myExecutionContext
Future {
// Some blocking or expensive code here
}
此外,请参阅 https://playframework.com/download#examples 上的示例模板,了解如何为阻塞 API 配置应用程序的示例。
§类加载器
类加载器在多线程环境(如 Play 程序)中需要特殊处理。
§应用程序类加载器
在 Play 应用程序中,线程上下文类加载器 可能无法始终加载应用程序类。您应该显式使用应用程序类加载器来加载类。
- Java
-
Class myClass = app.classloader().loadClass(myClassName);
- Scala
-
val myClass = app.classloader.loadClass(myClassName)
在开发模式(使用 run
)而不是生产模式下运行 Play 时,显式加载类最为重要。这是因为 Play 的开发模式使用多个类加载器,以便它可以支持自动应用程序重新加载。Play 的一些线程可能绑定到一个只知道应用程序类子集的类加载器。
在某些情况下,您可能无法显式使用应用程序类加载器。当使用第三方库时,有时会出现这种情况。在这种情况下,您可能需要在调用第三方代码之前显式设置线程上下文类加载器。如果您这样做,请记住在完成调用第三方代码后将上下文类加载器恢复到其先前值。
§切换线程
然而,类加载器的问题是,一旦控制权切换到另一个线程,您将失去对原始类加载器的访问权限。因此,如果您要使用thenApplyAsync
映射CompletionStage
,或者在与该CompletionStage
关联的Future
完成后的某个时间点使用thenApply
,然后尝试访问原始类加载器,它可能无法工作。为了解决这个问题,Play 提供了一个ClassLoaderExecutionContext
。这允许您在Executor
中捕获当前类加载器,然后将其传递给CompletionStage
的*Async
方法(例如thenApplyAsync()
),当执行器执行您的回调时,它将确保类加载器保持在范围内。
要使用ClassLoaderExecutionContext
,请将其注入您的组件,然后在与CompletionStage
交互时传递当前执行上下文。例如
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import javax.inject.Inject;
import play.libs.concurrent.ClassLoaderExecutionContext;
import play.mvc.*;
public class MyController extends Controller {
private ClassLoaderExecutionContext clExecutionContext;
@Inject
public MyController(ClassLoaderExecutionContext ec) {
this.clExecutionContext = ec;
}
public CompletionStage<Result> index() {
// Use a different task with explicit EC
return calculateResponse()
.thenApplyAsync(
answer -> {
return ok("answer was " + answer).flashing("info", "Response updated!");
},
clExecutionContext.current());
}
private static CompletionStage<String> calculateResponse() {
return CompletableFuture.completedFuture("42");
}
}
如果您有自定义执行器,您可以通过将其传递给ClassLoaderExecutionContext
的构造函数来将其包装在ClassLoaderExecutionContext
中。
§最佳实践
您应该如何在应用程序中将工作最佳地划分为不同的线程池,很大程度上取决于应用程序正在执行的工作类型以及您希望对哪些工作可以并行执行多少控制。这个问题没有一刀切的解决方案,最适合您的决定将来自了解应用程序的阻塞 I/O 要求及其对线程池的影响。对您的应用程序进行负载测试可能有助于调整和验证您的配置。
注意:在阻塞环境中,
thread-pool-executor
比fork-join
更好,因为不可能进行工作窃取,并且应该使用fixed-pool-size
大小并将其设置为底层资源的最大大小。鉴于 JDBC 是阻塞的,线程池的大小可以设置为可用于数据库池的连接数,假设线程池专门用于数据库访问。更少的线程不会消耗可用的连接数。任何超过可用连接数的线程在争用连接的情况下可能是浪费的。
下面我们概述了一些人们可能希望在 Play Framework 中使用的一些常见配置文件
§纯异步
在这种情况下,您的应用程序没有进行阻塞式 I/O 操作。由于您从未阻塞,因此每个处理器一个线程的默认配置非常适合您的用例,因此无需进行任何额外配置。Play 默认执行上下文可以在所有情况下使用。
§高度同步
此配置文件与传统同步 I/O 基于的 Web 框架(例如 Java servlet 容器)的配置文件相匹配。它使用大型线程池来处理阻塞式 I/O 操作。它适用于大多数操作都执行数据库同步 I/O 调用(例如访问数据库)的应用程序,并且您不希望或不需要控制不同类型工作的并发性。此配置文件是处理阻塞式 I/O 操作的最简单方法。
在此配置文件中,您将在所有地方使用默认执行上下文,但将其配置为在池中具有非常大量的线程。由于默认线程池用于服务 Play 请求和数据库请求,因此固定池大小应为数据库连接池的最大大小,加上核心数,再加上几个用于维护的额外线程,如下所示
pekko {
actor {
default-dispatcher {
executor = "thread-pool-executor"
throughput = 1
thread-pool-executor {
fixed-pool-size = 55 # db conn pool (50) + number of cores (4) + housekeeping (1)
}
}
}
}
此配置文件推荐用于执行同步 I/O 操作的 Java 应用程序,因为在 Java 中将工作分派到其他线程比较困难。
此外,请参阅 https://playframework.com/download#examples 上的示例模板,了解如何为阻塞 API 配置应用程序的示例。
§多个特定线程池
此配置文件适用于您希望执行大量同步 I/O 操作,但又希望精确控制应用程序一次执行多少种类型的操作的情况。在此配置文件中,您只会在默认执行上下文中执行非阻塞操作,然后将阻塞操作分派到针对这些特定操作的不同的执行上下文。
在这种情况下,您可以为不同类型的操作创建多个不同的执行上下文,如下所示
object Contexts {
implicit val simpleDbLookups: ExecutionContext = actorSystem.dispatchers.lookup("contexts.simple-db-lookups")
implicit val expensiveDbLookups: ExecutionContext =
actorSystem.dispatchers.lookup("contexts.expensive-db-lookups")
implicit val dbWriteOperations: ExecutionContext =
actorSystem.dispatchers.lookup("contexts.db-write-operations")
implicit val expensiveCpuOperations: ExecutionContext =
actorSystem.dispatchers.lookup("contexts.expensive-cpu-operations")
}
然后可以像这样配置它们
contexts {
simple-db-lookups {
executor = "thread-pool-executor"
throughput = 1
thread-pool-executor {
fixed-pool-size = 20
}
}
expensive-db-lookups {
executor = "thread-pool-executor"
throughput = 1
thread-pool-executor {
fixed-pool-size = 20
}
}
db-write-operations {
executor = "thread-pool-executor"
throughput = 1
thread-pool-executor {
fixed-pool-size = 10
}
}
expensive-cpu-operations {
fork-join-executor {
parallelism-max = 2
}
}
}
然后在您的代码中,您将创建 Future
并传递与 Future
执行的工作类型相关的 ExecutionContext
。
注意:只要配置命名空间与传递给
app.actorSystem.dispatchers.lookup
的调度程序 ID 相匹配,就可以自由选择配置命名空间。CustomExecutionContext
类将自动为您执行此操作。
§少量特定线程池
这是多个特定线程池和高度同步配置文件的组合。您将在默认执行上下文中执行大多数简单的 I/O 操作,并将那里的线程数设置为合理的高值(例如 100),然后将某些昂贵的操作分派到特定的上下文,在那里您可以限制一次执行的操作数量。
§调试线程池
调度器有很多可能的设置,很难看出哪些设置已应用以及默认设置是什么,尤其是在覆盖默认调度器时。pekko.log-config-on-start
配置选项会在应用程序加载时显示整个应用的配置。
pekko.log-config-on-start = on
请注意,您必须将 Pekko 日志设置为调试级别才能看到输出,因此您应该在 logback.xml
中添加以下内容:
<logger name="org.apache.pekko" level="DEBUG" />
一旦您看到记录的 HOCON 输出,您就可以将其复制粘贴到“example.conf”文件中,并在 IntelliJ IDEA 中查看它,IntelliJ IDEA 支持 HOCON 语法。您应该看到您的更改与 Pekko 的调度器合并,因此如果您覆盖了 thread-pool-executor
,您将看到它被合并。
{
# Elided HOCON...
"actor" : {
"default-dispatcher" : {
# application.conf @ file:/Users/wsargent/work/catapi/target/universal/stage/conf/application.conf: 19
"executor" : "thread-pool-executor"
}
}
}
还要注意,Play 在开发模式下的配置设置与生产模式不同。为了确保线程池设置正确,您应该在 生产配置 中运行 Play。
发现此文档中的错误?此页面的源代码可以在 此处 找到。在阅读 文档指南 后,请随时贡献拉取请求。有疑问或建议要分享?请访问 我们的社区论坛 与社区进行交流。