§Play のスレッドプールを理解する
Play framework は、完全に非同期な web フレームワークです。ストリームは Iteratee を使って非同期に扱われます。play-core 内の IO はブロックされないので、伝統的な web フレームワークと比較して、Play のスレッドプールは低目に調整されています。
このため、ブロッキング IO や、CPU 負荷が高くなりうるコードを書く場合には、どのスレッドプールがその処理を実行しているかを知り、それに応じて調整する必要があります。これを考慮に入れずにブロッキング IO を行うと、Play framework のパフォーマンスは貧弱になり得ます。例えば、1 秒あたりほんの少数のリクエストしかない場合にも、CPU 使用率が 5% を下回らないことがあるかもしれません。それに比べて、 MacBook Pro のような典型的な開発ハードウェア上のベンチマークでは、正確に調整すれば、 Play が毎秒何百、何千リクエストの仕事量を難なく扱うことができることを示しました。
§いつブロッキングされるかを知る
データベースとの通信は Play アプリケーションがブロックされる典型的な例です。残念ながら、主要なデータベースに JVM での非同期なドライバを提供しているものはありませんので、ほとんどのデータベースではブロック IO を使わざるを得ません。注目すべき例外は ReactiveMongo で、これは Play の Iteratee ライブラリを使った MongoDB 用のドライバーです。
ブロックが発生しうるその他の例は以下の通りです。
- (Play の非同期 WS API を使わずに) サードパーティのクライアントライブラリで REST/Web サービス API を使用する場合
- メッセージを送る際に、同期 API のみ提供されているメッセージ技術を使用する場合
- ファイル、ソケットを直接自分で Open した場合
- 実行に長い時間がかかるためにブロックしてしまうような CPU 負荷の高い処理をおこなう場合
一般的に、使用している API が Future
を返す場合はノンブロッキングですが、そうでなければブロッキングです。
このため、Future でブロッキングコードをラップするという誘惑に駆られるかもしれないことに注意してください。こうすることではノンブロッキングになりませんし、ブロッキングが他のスレッド内で起こるかもしれません。使用しているスレッドプールがブロッキングを扱うための十分なスレッドを持っていることを確かめる必要があります。
これに対して、以下のタイプの IO はブロックしません。
- Play WS API
- ReactiveMongo のような非同期データベースドライバー
- Akka アクターとのメッセージの送受信
§Play のスレッドプール
Play は複数の異なる目的のためのスレッドプールを使っています。
- Netty のボス/ワーカー スレッドプール - Netty IO を扱うために Netty によって内部的に使われています。アプリケーションのコードは、このスレッドプール内のスレッドによって実行されることはありません。
- Play デフォルトスレッドプール - Play Framework のすべてのアプリケーションコードが実行されるスレッドプールです。これは Akka のディスパッチャーであり、
ActorSystem
アプリケーションで使用されます。Akka の設定で設定することができます。後述します。
Play 2.4 では、いくつかのスレッドプールが Play のデフォルトスレッドプールに統合されたことに注意してください。
§デフォルトスレッドプールを使用する
Play Framework での全てのアクションはデフォルトスレッドプールを使用します。例えば、ある非同期処理を行う場合、future での map
あるいは flatMap
を呼ぶ場合に、与えられた機能内での実行するために暗黙の実行コンテキストを提供する必要があるかもしれません。実行コンテキストは基本的に ThreadPool
の別名です。
大抵の場合は、実行コンテキストとして Play のデフォルトスレッドプール を使用するのが適切でしょう。これは Scala ソースファイルへ import することで使用できます。
import play.api.libs.concurrent.Execution.Implicits._
def someAsyncAction = Action.async {
import play.api.Play.current
WS.url("http://www.playframework.com").get().map { response =>
// This code block is executed in the imported default execution context
// which happens to be the same thread pool in which the outer block of
// code in this action will be executed.
Results.Ok("The response code was " + response.status)
}
}
§Play のデフォルトスレッドプールを設定する
デフォルトスレッドプールは application.conf
内の akka 名前空間における標準 Akka 設定を使用することで設定できます。Play のスレッドプールのデフォルト設定は以下の通りです。
akka {
fork-join-executor {
# Settings this to 1 instead of 3 seems to improve performance.
parallelism-factor = 1.0
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
}
}
この設定はプール内で 24 スレッドを最大として、有効なプロセッサーごとにスレッドを一つ作成することを Akka に指示します。
あるいは、Akka のデフォルトの設定を試してみましょう。
akka {
fork-join-executor {
# 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
# Min number of threads to cap factor-based parallelism number to
parallelism-min = 8
# Max number of threads to cap factor-based parallelism number to
parallelism-max = 64
}
}
すべての設定可能なオプションは ここ で見ることができます。
§他のスレッドプールを使う
ある状況では、他のスレッドプールに仕事を割り当てたくなることがあります。例えば、CPU 負荷の高い処理やデータベースアクセスのような IO 処理です。そのためには、まず スレッドプール
を作成するべきです。これは、 Scala 内で簡単に行うことができます。
object Contexts {
implicit val myExecutionContext: ExecutionContext = Akka.system.dispatchers.lookup("my-context")
}
この場合、ExecutionContext
を作成するために Akka を使います。しかし、例えば Java executor や Scala のフォークジョインスレッドプールを使って、自分の ExecutionContext
を作ることもできます。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
}(Contexts.myExecutionContext)
または、暗黙的に使うこともできます。
import Contexts.myExecutionContext
Future {
// Some blocking or expensive code here
}
§クラスローダとスレッドローカル
クラスローダとスレッドローカルは Play プログラムのようなマルチスレッド環境では特別に扱う必要があります。
§アプリケーションクラスローダ
Play アプリケーションでは、 スレッドコンテキストクラスローダ は必ずしも常にアプリケーションクラスをロードできるわけではありません。クラスをロードするにはアプリケーションクラスローダを明示的に使用する必要があります。
- Java
-
Class myClass = Play.application().classloader().loadClass(myClassName);
- Scala
-
val myClass = Play.current.classloader.loadClass(myClassName)
クラスのロードを明示的に行うことは、本番モードよりもむしろ、(run
を使って) Play を開発モードで動かす際に最も重要です。なぜなら、Play の開発モードは複数のクラスローダを使用することで、自動的なアプリケーションリロードをサポートしているからです。Play のスレッドのなかには、アプリケーションのクラスのサブセットについて知るただ唯一のクラスローダに紐付いているものもあります。
アプリケーションのクラスローダを明示的に使用できない場合もあります。例えばサードパーティのライブラリを使用している場合です。この場合、 スレッドコンテキストクラスローダ をサードパーティのコードを呼ぶ前に明示的に渡す必要があります。その場合、サードパーティのコードが終了した際には、コンテキストクラスローダを元の値に戻すことを忘れないでください。
§Java のスレッドローカル
Play 内の Java コードは、HTTP リクエストなどコンテキストのある情報を取得するのに ThreadLocal
を使用します。 これに対し、 Scala コードでは暗黙のパラメータとしてコンテキストを渡すので、ThreadLocal
を使用する必要はありません。 ThreadLocal
が Java で使用されるのは、 Java のコードがコンテキストのある情報にアクセスする際に、コンテキストパラメータをあちこちで引き回さなくても済むようにするためです。
Java の ThreadLocal
(正しいコンテキストに沿うと ClassLoader
) は、 HttpExecution
クラスから提供する ExecutionContextExecutor
オブジェクトが自動的に伝播します (ExecutionContextExecutor
は Scala でいう ExecutionContext
で、Java では Executor
です)。これらの特別な ExecutionContextExecutor
オブジェクトは自動的に作成され、Java のアクションや Java の Promise
メソッドで使用されます。デフォルトのオブジェクトはユーザーのデフォルトスレッドプールをラップします。独自にスレッドを運用したい場合は、 HttpExecution
クラスのヘルパーメソッドを使用して ExecutionContextExecutor
オブジェクトを自分で取得してください。
以下の例では、ユーザーのスレッドプールをラップして、スレッドローカルを正しく伝播する新しい ExecutionContext
を作成しています。
import play.libs.HttpExecution;
import scala.concurrent.ExecutionContext;
public Promise<Result> index2() {
// Wrap an existing thread pool, using the context from the current thread
ExecutionContext myEc = HttpExecution.fromThread(myThreadPool);
return Promise.promise(() -> intensiveComputation(), myEc)
.map((Integer i) -> ok("Got result: " + i), myEc);
}
§ベストプラクティス
アプリケーションにおける作業を異なるスレッドプール間でどのように割り振るべきかは、アプリケーションが実行している作業の種類、およびどれだけの作業を平行して行えるよう制御したいのかという要望に大きく依存します。万能策はありません。アプリケーションのブロッキング IO 要件と、それらのスレッドプール上における意味を理解することで、最良の決定を行うことができます。設定値の調整および検証にはアプリケーションの負荷テストが役立つでしょう。
前述のとおり JDBC はスレッドをブロックするので、スレッドプールがデータベースアクセスのためだけに使われると仮定すると、スレッドプールの数は利用可能な db プールへのコネクションの数に設定することができます。スレッドがこれ未満の数であれば、利用可能なコネクションの数を使い切ることはないでしょう。スレッドが利用可能なコネクションの数よりも多いと、コネクションの競合により無駄になる可能性があります。
以下で、 Play Framework で使用できるいくつかの一般的なプロファイルの概要を説明します。
§純粋に非同期的
この場合、アプリケーションではブロッキング IO を行いません。決してブロッキングを行わないので、プロセッサーごとにひとつのスレッドを割り当てるデフォルトの設定がこのユースケースにぴったりですし、追加の設定を行う必要はありません。Play のデフォルト実行コンテキストがあらゆる状況で使われます。
§高度に同期的
このプロファイルは、Java のサーブレットコンテナのように伝統的な同期 IO ベースの web フレームワークにマッチします。ブロッキング IO を扱うために大きなスレッドプールを使います。特に、ほとんどのアクションがデータベースアクセスを行う際に同期 IO を行い、他の種類の処理での平行性に対する制御を望まない・必要としないようなアプリケーションで有効です。このプロファイルはブロッキング IO を扱う場合にもっとも単純です。
このプロファイルでは、どこでも単純にデフォルト実行コンテキストを使いますが、以下のように、そのプールに非常に多くのスレッドを持つように設定する必要があります。
akka {
akka.loggers = ["akka.event.slf4j.Slf4jLogger"]
loglevel = WARNING
actor {
default-dispatcher = {
fork-join-executor {
parallelism-min = 300
parallelism-max = 300
}
}
}
}
このプロファイルは同期 IO を行う Java アプリケーションで推奨されます。Java では他のスレッドに作業を割り当てるのがより難しいためです。
parallelism-min
と parallelism-max
に同じ値を設定していることに着目してください。これは、スレッドの数が以下の式によって決定されるからです。
基底スレッド数 = プロセッサ数 * 平行因数
parallelism-min <= 実際のスレッド数 <= parallelism-max
したがって、使用可能なプロセッサが十分にない場合には、 parallelism-max
設定値に達することができません。
§多くの特別なスレッドプール
このプロファイルはたくさんの同期 IO を、アプリケーションがどのタイプの作業を直ちに実行するか正確にコントロールしながら実行したい場合のためのものです。このプロファイルでは、デフォルト実行コンテキストでノンブロッキングに作業を行い、特別な作業を異なる実行コンテキストでブロッキングオペレーションに割り当てます。
この場合、以下のような異なるタイプのオペレーションに対して複数の異なる実行コンテキストを作る必要があります。
object Contexts {
implicit val simpleDbLookups: ExecutionContext = Akka.system.dispatchers.lookup("contexts.simple-db-lookups")
implicit val expensiveDbLookups: ExecutionContext = Akka.system.dispatchers.lookup("contexts.expensive-db-lookups")
implicit val dbWriteOperations: ExecutionContext = Akka.system.dispatchers.lookup("contexts.db-write-operations")
implicit val expensiveCpuOperations: ExecutionContext = Akka.system.dispatchers.lookup("contexts.expensive-cpu-operations")
}
これらは以下のように設定します。
contexts {
simple-db-lookups {
fork-join-executor {
parallelism-factor = 10.0
}
}
expensive-db-lookups {
fork-join-executor {
parallelism-max = 4
}
}
db-write-operations {
fork-join-executor {
parallelism-factor = 2.0
}
}
expensive-cpu-operations {
fork-join-executor {
parallelism-max = 2
}
}
}
それからコードにて Future
を作成し、この Future
が実行していた作業と関連する ExecutionContext
を引き渡します。
Note: 設定の名前空間は、
Akka.system.dispatchers.lookup
に渡したディスパッチャー ID がマッチする限り、自由に設定できます。
§少数の特別なスレッドプール
これは、多くの特別なスレッドプールと高度に同期化されたプロファイルの組み合わせです。デフォルト実行コンテキスト中でほとんどの単純な IO を行い、(100 くらいの) 合理的な複数のスレッドを設定しますが、その後、一度に行われる数を制限することのできる特別なコンテキストに負荷の高いオペレーションを割り振ります。
Next: ログの設定
このドキュメントの翻訳は Play チームによってメンテナンスされているものではありません。 間違いを見つけた場合、このページのソースコードを ここ で確認することができます。 ドキュメントガイドライン を読んで、お気軽にプルリクエストを送ってください。