#chiroito ’s blog

Java を中心とした趣味の技術について

日本語訳:JEP 425 : Virtual Threads (Preview) Part 3

個人的に気になっている Project Loom で Virtual Threads がプレビューされたので仕様である JEP を翻訳してみました。 ボリュームが多いため、いくつかのパートに分けて公開していきます。

原文はこちら:JEP 425: Virtual Threads (Preview)

全パートはこちら

説明(Part 2からの続き)

仮想スレッドのスケジューリング

スレッドが有用な作業を行うには、スケジューリング、つまりプロセッサコアでの実行を割り当てられる必要があります。OSスレッドとして実装されているプラットフォームスレッドについては、JDKはOS内のスケジューラに依存します。一方、仮想スレッドについては、JDKは独自のスケジューラを備えています。JDKのスケジューラは、仮想スレッドを直接プロセッサに割り当てるのではなく、仮想スレッドをプラットフォームスレッドに割り当てます(これが前述の仮想スレッドのM:Nスケジューリングです)。その後、プラットフォームスレッドは通常通りOSによってスケジューリングされます。

JDKの仮想スレッドスケジューラは、FIFOモードで動作するwork-stealing ForkJoinPoolです。スケジューラの並列度は、仮想スレッドのスケジューリングに利用できるプラットフォーム・スレッドの数です。デフォルトでは利用可能なプロセッサの数と同じですが、システムプロパティ jdk.virtualThreadScheduler.parallelism を使用して調整できます。この ForkJoinPool は、例えば並列ストリームの実装で使用され、LIFO モードで動作する共通プールとは別物であることに注意しましょう。

訳注:work-stealingとは、スレッドは自身が持つキューから仕事を取り出して処理しますが、そのキューが空になったら別のスレッドのキューから取りだす事を言います。

スケジューラが仮想スレッドを割り当てるプラットフォームスレッドを、仮想スレッドのキャリアと呼びます。仮想スレッドは、そのライフタイムを通じて異なるキャリアにスケジューリングされる可能性があります。つまり、スケジューラは仮想スレッドと特定のプラットフォームスレッドとの間の関係を維持しないのです。Javaコードから見ると、実行中の仮想スレッドはその現在のキャリアから論理的に独立しています。

  • 仮想スレッドでは、キャリアの身元は不明です。Thread.currentThread() が返す値は、常に仮想スレッド自身である。
  • キャリアと仮想スレッドのスタックトレースは別々です。仮想スレッドでスローされた例外は、キャリアのスタックフレームを含みません。スレッドダンプでは、仮想スレッドのスタックにキャリアのスタックフレームは表示されず、その逆もまた同様です。
  • キャリアのスレッドローカル変数は仮想スレッドでは使用できません。その逆もまた同様です。

また、Javaコードから見ると、仮想スレッドとそのキャリアがOSスレッドを一時的に共有していることは見えません。一方、ネイティブコードから見ると、仮想スレッドとキャリアは同じネイティブスレッド上で動作しています。そのため、同じ仮想スレッド上で複数回呼び出されたネイティブコードでは、呼び出すたびに異なるOSスレッド識別子が観測される可能性があります。

スケジューラは現在、仮想スレッドのタイムシェアリングを実装していません。タイムシェアリングとは、割り当てられた量の CPU 時間を消費したスレッドを強制的に先取りすることです。比較的少数のプラットフォームスレッドがあり、CPU使用率が100%の場合、時間共有はいくつかのタスクのレイテンシを減らすのに効果的ですが、100万の仮想スレッドで時間共有が同じように効果的であるかは明らかでありません。

仮想スレッドの実行

仮想スレッドを利用するために、プログラムを書き換える必要はありません。仮想スレッドは、アプリケーションコードが明示的にスケジューラに制御を戻すことを要求したり、期待したりしません。言い換えれば、仮想スレッドは連携しません。ユーザーコードは、仮想スレッドがいつどのようにプラットフォームスレッドに割り当てられるかについて、プラットフォームスレッドがいつどのようにプロセッサコアに割り当てられるかについて予測するのと同じように、予測してはいけません。

仮想スレッドでコードを実行するために、JDKの仮想スレッドスケジューラは、プラットフォームスレッドに仮想スレッドをマウントされ、プラットフォームスレッド上で実行するように割り当てます。これにより、プラットフォームスレッドは仮想スレッドのキャリアとなります。その後、いくつかのコードを実行した後、仮想スレッドはそのキャリアからアンマウントされます。その時点でプラットフォームスレッドは空き、スケジューラはその上に別の仮想スレッドをマウントし、再びキャリアにします。

訳注、マウント:取り付けること、アンマウント:取り外すこと

一般的に、仮想スレッドは I/O や BlockingQueue.take() などの JDK のブロック操作でブロックしたときにアンマウントされます。ブロック操作が完了する準備ができたら(たとえば、ソケットでバイトを受信した)、スケジューラに仮想スレッドを戻し、スケジューラは仮想スレッドをキャリアにマウントして実行を再開します。

仮想スレッドのマウントとアンマウントは、OSのスレッドをブロックすることなく、頻繁に、透過的に行われます。例えば、先ほどのサーバーアプリケーションでは、以下のようなコード行があり、ブロッキング操作の呼び出しが含まれています。

response.send(future1.get() + future2.get());

これらの操作により、仮想スレッドは複数回マウントとアンマウントを行うことになります。通常、get()の呼び出しごとに1回、場合によってはsend(...)でのI/O実行中に複数回行われます。

JDKのブロック操作の大部分は、仮想スレッドをアンマウントして、そのキャリアと下位のOSスレッドを解放し、新しい仕事を引き受けられるようにします。しかし、JDKの一部のブロック操作では、仮想スレッドをアンマウントしないため、そのキャリアと下位のOSスレッドの両方がブロックされます。これは、OSレベル(例:多くのファイルシステム操作)またはJDKレベル(例:Object.wait())のいずれかの制限によるものです。これらのブロッキング処理の実装は、スケジューラの並列性を一時的に拡張することで、OSスレッドの占有を埋め合わせることになります。その結果、スケジューラの ForkJoinPool 内のプラットフォームスレッド数が、一時的に利用可能なプロセッサの数を超えることがあります。スケジューラが利用できるプラットフォームスレッドの最大数は、システムプロパティ jdk.virtualThreadScheduler.maxPoolSize を使用して調整できます。

仮想スレッドがキャリアに固定されているため、ブロッキング操作中にアンマウントできない状況は、2つあります。

  1. synchronizedブロックや メソッド内のコードを実行する場合
  2. ネイティブメソッドまたは外部関数を実行する場合

固定化によってアプリケーションが不正になることはありませんが、スケーラビリティの妨げになる可能性があります。仮想スレッドが固定されている間に I/O や BlockingQueue.take() などのブロッキング処理を実行すると、そのキャリアと基盤となる OS スレッドはその処理の間ブロックされます。長い時間頻繁に固定されると、キャリアが占有され、アプリケーションの拡張性が損なわれる可能性があります。

スケジューラは、その並列度を拡大することによって固定化を補うことはしません。頻繁に実行され、長いI/O操作を守る同期ブロックやメソッドを修正することによって、頻繁で長時間の固定化を避けられるでしょう。代わりに java.util.concurrent.locks.ReentrantLock を使用するように変更してください。使用頻度が低い(たとえば、起動時のみ実行される)同期ブロックやメソッド、またはメモリ内の操作を保護する同期ブロックやメソッドは、置き換える必要はありません。いつものように、ロック・ポリシーをシンプルかつ明確に保つように努めてください。

新しい診断機能は、仮想スレッドへのコードの移行や、synchronized の特定の使用を java.util.concurrent ロックに置き換える必要があるかの評価を支援します。

  • JDK Flight Recorder (JFR) イベントは、固定化中にスレッドがブロックされると発行されます (JDK Flight Recorder を参照)。
  • システムプロパティ jdk.tracePinnedThreads は、スレッドが固定化された状態でブロックされるとスタックトレースを開始します。-Djdk.tracePinnedThreads=full を指定して実行すると、固定化中にスレッドがブロックされたときに、ネイティブフレームとモニタを保持するフレームをハイライトした完全なスタックトレースが出力されます。-Djdk.tracePinnedThreads=short で実行すると、問題のあるフレームのみの出力に制限されます。

将来のリリースでは、上記の最初の制限事項 (同期化された内部での固定化) を取り除けるかもしれません。2番目の制限は、ネイティブコードとの適切な相互作用のために必要です。

メモリの使用とガベージコレクションとの相互作用

仮想スレッドのスタックは、スタックのチャンク・オブジェクトとしてJavaのガベージコレクション・ヒープに格納されます。スタックは、メモリ効率と任意の深さのスタック(JVMの設定されたプラットフォームのスレッドスタックサイズまで)を収容するために、アプリケーションの実行中に成長したり縮小したりしています。この効率性により、多数の仮想スレッドを実現し、サーバーアプリケーションにおけるリクエスト毎のスレッド方式を存続できるのです。

上記の2番目の例では、仮定のフレームワークが新しい仮想スレッドを作成し、handleメソッドを呼び出すことで、各リクエストを処理することを思い出してください。たとえ深いコールスタック(認証、トランザクションなどの後)の最後に handle を呼び出したとしても、handle 自体は短時間のタスクだけを実行する複数の仮想スレッドを生成します。したがって、深いコールスタックを持つ各仮想スレッドに対して、わずかなメモリを消費する浅いコールスタックを持ついくつもの仮想スレッドが存在することになります。

仮想スレッドが必要とするヒープスペースとガベージコレクタの動作量は、一般に、非同期コードのそれと比較することは困難です。100万の仮想スレッドは少なくとも100万のオブジェクトを必要としますが、プラットフォームスレッドのプールを共有する100万のタスクも同じです。さらに、リクエストを処理するアプリケーションコードは、通常、I/O操作にわたってデータを維持します。リクエスト毎のスレッドでは、そのデータをローカル変数に保持できます。ローカル変数はヒープ内の仮想スレッドスタックに格納されます。これに対して、非同期コードは、パイプラインの1つのステージから次のステージに渡されるヒープオブジェクトに、同じデータを保持する必要があります。また、仮想スレッドが必要とするスタックフレームレイアウトは、コンパクトなオブジェクトのレイアウトよりも無駄が多くなります。しかし、一方で、仮想スレッドは多くの状況でスタックを変更したり再利用したりすることができます((低レベルのGC相互作用に依存します)。一方、非同期パイプラインは常に新しいオブジェクトを割り当てる必要があるため、仮想スレッドはより少ない割り当てを求めるかもしれません。全体として、リクエスト毎のスレッドと非同期コードのヒープ消費量とガベージコレクタの動作は、ほぼ同じであるべきです。時間の経過とともに、仮想スレッドスタックの内部表現がよりコンパクトになることが期待されます。

プラットフォームのスレッドスタックとは異なり、仮想スレッドスタックはGCルートではないので、それに含まれる参照は、G1などの並行ヒープ走査を行うガベージコレクタによってStop-the-World休止で走査されることはないです。これはまた、仮想スレッドが例えば BlockingQueue.take() でブロックされ、他のスレッドが仮想スレッドまたはキューへの参照を取得できない場合、そのスレッドはガベージコレクションされうることを意味します - 仮想スレッドは決して中断またはブロック解除できないので、これは良いことです。もちろん、仮想スレッドが実行中であったり、ブロックされていてブロックが解除される可能性がある場合には、仮想スレッドはガベージコレクションされることはないでしょう。

仮想スレッドの現在の限界は、G1 GC が巨大なスタックチャンクオブジェクトをサポートしないことです。もし仮想スレッドのスタックがG1 GCの領域サイズの半分(512KB ほど)に達すると、StackOverflowError がスローされるかもしれません。