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。