§了解 Play 執行緒池
Play Framework 從底層開始,就是一個非同步網路框架。Play 中的執行緒池經過調整,比傳統網路框架使用更少的執行緒,因為 play-core 中的 IO 永不封鎖。
因此,如果您計畫撰寫阻擋式 IO 程式碼,或可能執行大量 CPU 密集工作的程式碼,您需要確切知道哪個執行緒池承擔該工作負載,並且您需要相應地調整它。不考慮這一點而執行阻擋式 IO 可能會導致 Play Framework 效能非常差,例如,您可能只看到每秒處理幾個要求,而 CPU 使用率卻停留在 5%。相比之下,在典型的開發硬體(例如 MacBook Pro)上的基準測試顯示,Play 在正確調整後,可以毫不費力地處理每秒數百甚至數千個要求的工作負載。
§知道何時會阻擋
典型的 Play 應用程式最常在與資料庫通訊時阻擋。不幸的是,沒有任何主要資料庫為 JVM 提供非同步資料庫驅動程式,因此對於大多數資料庫,您唯一的方法是使用阻擋式 IO。一個值得注意的例外是 ReactiveMongo,這是 MongoDB 的驅動程式,它使用 Play 的 Iteratee 函式庫與 MongoDB 通訊。
您的程式碼可能阻擋的其他情況包括
- 透過第三方用戶端函式庫使用 REST/WebService API(即,不使用 Play 的非同步 WS API)
- 某些訊息傳遞技術僅提供同步 API 來傳送訊息
- 當您自己直接開啟檔案或 socket
- 由於執行時間過長而阻擋的 CPU 密集型作業
一般來說,如果您使用的 API 傳回 Future
,它是非阻擋式的,否則它是阻擋式的。
請注意,您可能會因此受到誘惑,將您的封鎖程式碼包裝在 Futures 中。這不會讓它變成非封鎖,它只表示封鎖會發生在不同的執行緒中。您仍需要確保您使用的執行緒池有足夠的執行緒來處理封鎖。請參閱 Play 的範例範本,網址為 https://playframework.com/download#examples,了解如何為封鎖 API 設定您的應用程式。
相反地,下列 IO 類型不會封鎖
- Play WS API
- 非同步資料庫驅動程式,例如 ReactiveMongo
- 傳送/接收訊息至/自 Pekko actors
§Play 的執行緒池
Play 使用許多不同的執行緒池,用於不同的目的
-
內部執行緒池 - 這些由伺服器引擎內部用於處理 IO。應用程式的程式碼絕不應由這些執行緒池中的執行緒執行。Play 預設設定為 Pekko HTTP 伺服器後端,因此應使用
application.conf
中的 設定設定 來變更後端。或者,Play 也附帶 Netty 伺服器後端,如果啟用,也有一些設定可以從application.conf
設定。 -
Play 預設執行緒池 - 這是 Play Framework 中所有應用程式程式碼執行的執行緒池。它是一個 Pekko 分派器,並由應用程式
ActorSystem
使用。它可以透過設定 Pekko 來設定,如下所述。
§使用預設執行緒池
Play Framework 中的所有動作都使用預設執行緒池。在執行某些非同步作業時,例如在未來呼叫 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 的 預設調度器。
§設定預設執行緒池
預設執行緒池可使用在 pekko
名稱空間下的 application.conf
中的標準 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
關聯的 Future
完成後的某個時間點使用 thenApply
來對應 CompletionStage
,然後您嘗試存取原始類別載入器,它可能無法正常運作。為了解決這個問題,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 基礎網路架構(例如 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)
}
}
}
}
此設定檔建議用於執行同步 IO 的 Java 應用程式,因為在 Java 中較難將工作分配到其他執行緒。
此外,請參閱 https://playframework.com/download#examples 上的範例範本,以取得如何為封鎖 API 設定應用程式的範例。
§許多特定執行緒池
此設定檔適用於您想要執行大量同步 IO 的情況,但您也想要精確控制應用程式一次執行多少特定類型的作業。在此設定檔中,您只會在預設執行內容中執行非封鎖作業,然後將封鎖作業分配到不同執行內容以執行這些特定作業。
在這種情況下,您可以為不同類型的作業建立多個不同的執行內容,如下所示
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
類別會自動為您執行此操作。
§幾個特定執行緒池
這是許多特定執行緒池與高度同步設定檔之間的組合。您會在預設執行緒內容中執行最簡單的 IO,並將執行緒數目設定為合理的高值(例如 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 中檢視,它支援 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。
在此文件檔中發現錯誤?此頁面的原始程式碼可以在 這裡 找到。在閱讀 文件檔指引 後,請隨時提交拉取請求。有問題或建議要分享嗎?前往 我們的社群論壇 與社群展開對話。