理解Play框架线程池

来源:互联网 发布:ubuntu.vmx损坏 编辑:程序博客网 时间:2024/06/06 00:25

Play框架是一个自低向上的异步Web框架,使用Iteratee异步处理数据流。因为Play内核中的IO不会被阻塞, 所以Play中线程池比传统Web框架使用更少的线程。

因此,如果你准备写阻塞IO代码,或者潜在需要做很多CPU密集型工作的代码,你需要明确知道哪个线程池承担这个工作负担,并需要相应地优化它。如果不考虑这一点,做阻塞IO很可能会导致Play框架的性能很差。例如,你可能会看到每秒只有几个请求被处理,而CPU使用率仅有5%。通过比较,典型开发硬件(如MacBook Pro)的Benchmark已经显示Play在正确调优后能够毫不费力地每秒处理几百甚至几千个请求的负载。

知道你在什么时候阻塞

一个Play典型应用程序的最常见阻塞地方是当访问数据库的时候。不幸的是,主流数据库没有一个为JVM提供异步数据库驱动,所有对于大部分数据库,你唯一的选择就是使用阻塞IO。ReactiveMongo是一个值得注意的例外。 这个驱动使用Play Iteratee库访问MongDB。

你的代码可能阻塞的其它情况包括:

  • 通过第三方客户端库使用 REST/WebService API (例如,没有使用Play的异步WS API)
  • 一些消息传递技术仅提供同步API发送消息
  • 你自己直接打开文件或者套接字
  • CPU密集型操作,他们需要长时间运行而造成阻塞

一般来说,如果你使用的API返回Future, 那它是非阻塞的,否则它是阻塞的。

注意你可能会想把阻塞代码包装到Future中。这样不会使它变成非阻塞,它只是将阻塞在其它线程中发生。所以你仍然需要确定你正在使用的线程池具有足够的线程处理阻塞操作。如何为阻塞API配置你的应用,请参考在 http://playframework.com/download#examples 上的Play样例模板。

相反,下面IO类型不会造成阻塞:

  • Play WS API
  • 异步数据库驱动,如ReactiveMongo
  • 发送(接收)消息到(从)Akka actors

Play的线程池

Play为不同目的使用许多不同的线程池

内部线程池 - 这些线程池是服务器引擎用来处理IO的。应用程序代码不应该在这些线程池的线程中运行。Play默认使用Akka HTTP服务后端。
Play默认线程池 - 你在Play框架中所有的应用程序代码将会在这个线程池中运行。这是一个Akka Dispatcher, 并在应用程序的ActorSystem中使用。通过配置Akka可以配置它。在下面会讲到。

使用默认线程池

Play框架中所有的Action都使用默认线程池。当做某些异步操作时,例如,调用Future的map或者flatMap方法,有可能需要提供一个隐式Execution Context来执行给定的函数。Execution Context可以说是线程池的另一个名字。
在大部分情况下,Play默认线程池就是合适Execution Context。这个通过@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代码中使用带上HttpExecutionContext的CompletionStage:

import play.libs.concurrent.HttpExecutionContext;import play.mvc.*;import javax.inject.Inject;import java.util.concurrent.CompletableFuture;import java.util.concurrent.CompletionStage;public class MyController extends Controller {    private HttpExecutionContext httpExecutionContext;    @Inject    public MyController(HttpExecutionContext ec) {        this.httpExecutionContext = ec;    }    public CompletionStage<Result> index() {        // Use a different task with explicit EC        return calculateResponse().thenApplyAsync(answer -> {            // uses Http.Context            ctx().flash().put("info", "Response updated!");            return ok("answer was " + answer);        }, httpExecutionContext.current());    }    private static CompletionStage<String> calculateResponse() {        return CompletableFuture.completedFuture("42");    }}

这个Execution Context直接连接应用程序的ActorSystem,并使用默认Dispatcher。

配置默认线程池

默认线程池可以在applicaiton.conf的Akka命名空间中使用标准Akka配置来配置。下面是Play线程池的默认配置:

akka {  actor {    default-dispatcher {      fork-join-executor {        # Settings this to 1 instead of 3 seems to improve performance.        parallelism-factor = 1.0        # @richdougherty: Not sure why this is set below the Akka        # default.        parallelism-max = 24        # Setting this to LIFO changes the fork-join-executor        # to use a stack discipline for task scheduling. This usually        # improves throughput at the cost of possibly increasing        # latency and risking task starvation (which should be rare).        task-peeking-mode = LIFO      }    }  }}

这个配置指示Akka为每个有效处理器创建一个线程。线程池的最大线程数量为24。
你也可以尝试使用默认Akka配置:

akka {  actor {    default-dispatcher {      # This will be used if you have set "executor = "fork-join-executor""      fork-join-executor {        # Min number of threads to cap factor-based parallelism number to        parallelism-min = 8        # The parallelism factor is used to determine thread pool size using the        # following formula: ceil(available processors * factor). Resulting size        # is then bounded by the parallelism-min and parallelism-max values.        parallelism-factor = 3.0        # Max number of threads to cap factor-based parallelism number to        parallelism-max = 64        # Setting to "FIFO" to use queue like peeking mode which "poll" or "LIFO" to use stack        # like peeking mode which "pop".        task-peeking-mode = "FIFO"      }    }  }}

全部的配置选项在这里

使用其它线程池

在某些情况下,你可能需要分发工作给其它线程池,比如CPU密集型工作,或者如数据库访问这样的IO工作。要想做到这点,你首先应该创建一个线程池。在Scala中很容易就可以做到:

val myExecutionContext: ExecutionContext = akkaSystem.dispatchers.lookup("my-context")

上面的例子,我们使用Akka创建ExecutionContext。但你也可以很容易地使用Java Executor创建你自己的ExecutionContext,或者Scala Fork Join线程池。例如,Play提供 play.libs.concurrent.CustomExecutionContext and play.api.libs.concurrent.CustomExecutionContext.这两个类可以用来创建你自己Execution Context。更多细节请参考ScalaAsync和JavaAsync

为了配置Akka执行上下文,你可以增加下面配置给你的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)or you could just use it implicitly:implicit val ec = myExecutionContextFuture {  // Some blocking or expensive code here}

另外,请参考 http://playframework.com/download#examples中的样例模板作为如何配置应用程序阻塞API的例子。

类加载器和线程局部变量

类加载器和线程局部变量在像Play这样的多线程环境中需要特殊处理。

应用类加载器

在Play应用程序中,线程上下文类加载器可能不会总是能够加载应用类。你应该显示地使用应用类加载器来加载类。

Java代码

JavaClass myClass = app.classloader().loadClass(myClassName);

Scala代码

val myClass = app.classloader.loadClass(myClassName)

在Play的开发模式下显示加载类比在生产模式下更为重要。这是因为Play的开发模式使用不同的类加载器,这样它可以支持应用程序自动重新加载。一些Play的线程可能会绑定一个只知道一部分应用类的类加载器

在有些情况,你可能不能显示地使用应用类加载器。例如当使用第三方库时,你可能需要在调用第三方代码前显示设置线程上下文类加载器。如果你这样做了,一旦你完成第三方代码的调用,记得将上下文类加载器设置回它之前的值。

Java线程局部变量

在Play中Java代码使用线程局部变量查找上下文相关信息,例如当前HTTP请求。Scala不需要使用线程局部变量,因为它可以使用隐式参数来传递上下文。Java代码需要借助线程局部变量访问上下文相关信息来避免到处传递上下文参数。

使用线程局部变量的问题是只要切换到其它线程,你就会丢失线程局部变量信息。所以如果你在处理CompletionStage的下一阶段时候,使用了thenApplyAsync, 或者在CompletionStage关联的Future完成之后使用thenApply,那当你尝试访问HTTP上下文(例如, Session或Request),将不会工作。为了解决这个问题,Play提供了HttpExecutionContext。这个允许你在一个Executor中获得当前上下文,然后传递给CompletionStage *Async方法,例如thenApplyAsync()。并且当Executor执行你的回调函数,它将确保线程局部变量保存的上下文是可以访问到的。这样你就可以访问Request/Session/Flash/Response对象。

将HttpExecutionContext注入到你的组件,CompletionStage就可以随时访问当前上下文。例如:

import play.libs.concurrent.HttpExecutionContext;import play.mvc.*;import javax.inject.Inject;import java.util.concurrent.CompletableFuture;import java.util.concurrent.CompletionStage;public class MyController extends Controller {    private HttpExecutionContext httpExecutionContext;    @Inject    public MyController(HttpExecutionContext ec) {        this.httpExecutionContext = ec;    }    public CompletionStage<Result> index() {        // Use a different task with explicit EC        return calculateResponse().thenApplyAsync(answer -> {            // uses Http.Context            ctx().flash().put("info", "Response updated!");            return ok("answer was " + answer);        }, httpExecutionContext.current());    }    private static CompletionStage<String> calculateResponse() {        return CompletableFuture.completedFuture("42");    }}

当你有一个自定义的Executor,你可以把它包装在HttpExecutionContext。通过将它传递到HttpExecutionContext的构造器中就可以很容易做到。

最佳实践

如何在不同的线程池之间最佳分配应用程序的工作,很大程度取决于你的应用程序所做的工作类型,以及多少工作需要并行完成。

对于这个问题,没有一个统一的解决方案。你需要明确应用程序的阻塞IO需求,以及它们对线程池的影响,然后才能作出最佳决定。通过对应用程序进行负载测试可以帮助调优和验证你的配置。

注意:在阻塞环境中,thread-pool-executor要优于fork-join,因为不会发生work-stealing。并且应该使用fixed-pool-size,并设置成底层资源的最大大小。
假设一个线程池只用来数据库访问,那考虑到JDBC是阻塞的,线程池的大小需要设置成数据库连接池的有效连接数量。设置少了将不能有效消耗数据库连接;而设置多了会造成数据库连接的不必要竞争。

下面我们列出一些用户在Play框架中可能想使用的常见模式

纯异步

在这种情况,应用程序没有使用阻塞IO,因此你不会被阻塞。一个处理器一个线程的默认配置可以完美地满足你的使用情况,所以不需要额外的配置。Play默认Execution Context可以在任何这种情况下胜任。

高度同步

这种模式指的是那些基于传统同步IO的Web框架,例如Java Servlet容器。使用大线程池来处理阻塞IO。如果大部分操作是调用数据库同步IO(如访问数据库),并且你不想或者不需要对不同类型工作进行并发性控制,它是可以胜任的。这个模式对于处理阻塞IO是最简单。

在这种模式中, 你可以在每个地方都使用默认Execution Context,但是需要配置非常多的线程给线程池。因为默认线程池需要用来服务Play请求和数据库请求,所以线程池大小应该是数据库连接池的最大值,加上核数,再加上几个额外线程数量为了内部处理。

akka {  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)      }    }  }}

做同步IO的Java应用程序推荐使用这种模式,因为在Java中分发任务给其它线程更加难做。

另外,请参考 http://playframework.com/download#examples中的样例模板作为如何配置你应用程序阻塞API的例子。

很多特定线程池

这种模式是为了你想做大量同步IO,并且你也想精确地控制应用程序一次做多少类型的操作。在这种模式中,只在默认Execution Context中做非阻塞操作,并把阻塞操作分发到不同的特定Execution Context。
在这种情况,你可以为不同类型的操作创建不同的Execution Context,如下面这样:

object Contexts {  implicit val simpleDbLookups: ExecutionContext = akkaSystem.dispatchers.lookup("contexts.simple-db-lookups")  implicit val expensiveDbLookups: ExecutionContext = akkaSystem.dispatchers.lookup("contexts.expensive-db-lookups")  implicit val dbWriteOperations: ExecutionContext = akkaSystem.dispatchers.lookup("contexts.db-write-operations")  implicit val expensiveCpuOperations: ExecutionContext = akkaSystem.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,并将ExecutionContext传给它。这样这个Future就会在这个ExecutionContext中工作。

注意:配置命名空间可以自由选择,只要它匹配传入到app.actorSystem.dispatchers.lookup的Dispatcher ID。CustomExecutionContext类会自动为你匹配。

很少特定线程池

这是很多特定线程池和高度同步模式的结合体。你可以在默认Execution Context中做大部分的简单IO,并将线程数量合理地设置成更大值(例如100),而将昂贵操作分发给特定的Execution Context。在那你可以设置一次完成它们的数量。

调试线程池

Dispatcher有很多配置,并且很难确定哪些配置被应用以及这些配置的默认值。尤其是在覆盖了默认Dispatcher之后。akka.log-config-on-start配置选项能够在应用被加载时,显示完整应用配置。

akka.log-config-on-start = on

注意你必须将Akka日志配置成Debug级别,这样你才能看到输出。在logback.xml增加下面配置:

<logger name="akka" level="DEBUG" />

一旦你看到日志中HOCON输出,你可以拷贝并粘贴到一个”example.conf”文件,并在支持HOCON语法的IntelliJ IDEA中查看它。你应该会看到你的配置合并到Akka Dispatcher中。所以如果你覆盖了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。

原创粉丝点击