#chiroito ’s blog

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

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

個人的に気になっている Project Loom で Virtual Threads がプレビューされたので仕様である JEP を翻訳してみました。 ボリュームが多いため、いくつかのパートに分けて公開していきます。スターやブコメが付くとモチベーションが上がるかもしれません。(上がらないかもしれません)

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

全パートはこちら

JEP 425 : Virtual Threads (Preview)

  • 著者:Ron Pressler, Alan Bateman
  • オーナー:Alan Bateman
  • タイプ:機能
  • スコープ:SE
  • ステータス:統合済み
  • リリース:19
  • コンポーネント:core-libs
  • 議論:loom dash dev at openjdk dot java dot net
  • 労力:XL
  • レビュワ:Alex Buckley, Brian Goetz, Chris Hegarty
  • 作成日:2021/11/15 16:43
  • 更新日:2022/05/21 16:58
  • イシュー:8277131

要約

Java プラットフォームに仮想スレッドを導入します。仮想スレッドは、高スループットの並列アプリケーションを書き、維持し、観察する労力を劇的に削減する軽量なスレッドです。このAPIはプレビュー版です。

ゴール

  • シンプルなスレッド単位でリクエストするスタイルで書かれたサーバアプリケーションを、ほぼ最適なハードウェア使用率でスケーリングできるようにします。
  • java.lang.Thread API を使用する既存のコードに、最小限の変更で仮想スレッドを採用できるようにします。
  • 既存の JDK ツールを使用して、仮想スレッドのトラブルシューティング、デバッグ、およびプロファイリングを容易に行えるようにします。

非ゴール

  • 従来のスレッドの実装を削除したり、既存のアプリケーションを仮想スレッドを使用するようにユーザに明示せずに移行させることが目的ではありません。
  • Javaの基本的な並行処理モデルを変更することが目的ではありません。
  • Java言語やJavaライブラリに新しいデータ並列化構造を提供することも目的ではありません。Stream APIは、大規模なデータセットを並列に処理するための好ましい方法であることに変わりはありません。

動機

Java開発者は、30年近くにわたり、並行処理サーバーアプリケーションの構成要素としてスレッドに依存してきました。Javaはマルチスレッドなので、複数のスレッドが一度に実行され、各メソッドの全ての処理はスレッドの中で実行されます。スレッドは、Javaの並行処理の単位であり、他の単位と同時に(そしてほとんど独立して)実行される逐次実行されるコードの一部分です。各スレッドは、ローカル変数を格納し、メソッド呼び出しを調整するためのスタックと、物事がうまくいかないときのコンテキストを提供します。例外は、同じスレッドのメソッドによってスローおよびキャッチされるので、開発者はスレッドのスタックトレースを使用して、何が起こったかを見つけられます。スレッドは、ツールの中心的な概念でもあります。デバッガはスレッドのメソッド内のステートメントをステップ実行し、プロファイラは複数のスレッドの挙動を可視化し、そのパフォーマンスを理解するのに役立ちます。

リクエスト毎にスレッドを使う方式

サーバーアプリケーションは一般に、互いに独立したユーザーリクエストを同時に処理するため、アプリケーションがリクエストの全期間にわたってそのリクエストにスレッドを割り当てて処理することは理にかなっています。このスレッド毎のリクエスト方式は、アプリケーションの並行性の単位を表すためにプラットフォームの並行性の単位を使用するので、理解しやすく、プログラミングしやすく、デバッグやプロファイルも簡単です。

サーバーアプリケーションの拡張性は,待ち時間,同時実行性,スループット を関連付けるリトルの法則によって支配されています。あるリクエスト処理時間(すなわち待ち時間)において、アプリケーションが同時に処理するリクエストの数(すなわち同時実行性)は、到着率(すなわちスループット)に比例して増加しなければなりません。たとえば、平均待ち時間が50msのアプリケーションが、10リクエストを同時に処理することによって、1秒間に200リクエストのスループットを達成したとします。そのアプリケーションが 1 秒あたり 2000 リクエストのスループットに拡張するためには、100 リクエストを同時に処理する必要があります。各リクエストがリクエストの間、スレッドで処理される場合、アプリケーションが維持するために、スループットが増加するにつれてスレッドの数は増加する必要があります。

残念ながら、JDKはスレッドをオペレーティングシステム(OS)スレッドのラッパーとして実装しているため、利用可能なスレッドの数は限られています。OSのスレッドはコストが高いので、あまり多く持つことができず、リクエスト毎のスレッドというスタイルには不向きな実装になっています。各リクエストがスレッド、つまりOSのスレッドを消費する場合、CPUやネットワーク接続などの他のリソースが枯渇する前に、スレッドの数が制限要因になることが多いのです。JDKの現在のスレッド実装は、アプリケーションのスループットを、ハードウェアがサポートできるレベルよりもかなり低い水準に制限しています。これは、スレッドがプールされている場合でも発生します。プールすることで、新しいスレッドを開始する際の高いコストを回避できますが、スレッドの総数が増えるわけではありません。

非同期式でスケーラビリティを向上

ハードウェアを最大限に活用したい開発者の中には、リクエスト毎のスレッドをあきらめ、スレッドを共有するスタイルを採用する人もいます。1つのスレッドで最初から最後までリクエストを処理するのではなく、リクエスト処理コードは、I/O処理が完了するのを待ってスレッドをプールに戻し、そのスレッドが他のリクエストに対応できるようにします。このようにスレッドをきめ細かく共有することで、コードが計算を行うときだけスレッドを保持し、I/Oを待つときには保持しないことで、多くのスレッドを消費せずに多くの同時処理を行うことができるのです。OSのスレッドの不足によるスループットの制限をなくす一方で、高い代償を払うことになります。それは、いわゆる非同期プログラミングスタイルで、I/O操作の完了を待たず、後でコールバックに完了を知らせるI/Oメソッドを別に採用する必要があることです。専用のスレッドがない場合、開発者はリクエスト処理ロジックを小さな段階に分解し、通常はラムダ式で記述し、APIを使って順次パイプラインに合成しなければなりません(例えば、CompletableFutureや、いわゆる「リアクティブ」フレームワークなどを参照ください)。そのため、ループやtry/catchブロックといった、言語の基本的な逐次合成演算子を使いません。

非同期式では、リクエストの各ステージは異なるスレッドで実行され、各スレッドは異なるリクエストに属するステージを相互に切り離して実行することがあります。このことは、プログラムの動作を理解する上で深い意味を持ちます。スタックトレースは有用なコンテキストを提供せず、デバッガはリクエスト処理ロジックを段階的に処理できず、プロファイラは操作のコストをその呼び出し元と関連付けることができません。ラムダ式の記述は、JavaのストリームAPIを使って短いパイプラインでデータを処理する場合には何とかなりますが、アプリケーションのリクエスト処理コードをすべてこの方法で書かなければならない場合には問題があります。なぜなら、アプリケーションの並行性の単位である非同期パイプラインは、もはやプラットフォームの並行性の単位ではなくなっているからです。

仮想スレッドによるリクエスト毎のスレッド方式の維持

アプリケーションをプラットフォームと調和させながら拡張するためには、スレッドをより効率的に実装することで、リクエスト毎のスレッドというスタイルを維持し、より多くのスレッドを使えるようにする努力が必要です。言語やランタイムによってスレッドスタックの使い方が異なるため、OSのスレッドをより効率的に実装できません。しかし、Javaランタイムは、OSのスレッドとの一対一の対応を絶つ方法でJavaのスレッドを実装することができます。OSが限られた物理RAMに大きな仮想アドレス空間をマッピングすることでメモリが豊富にあるように見せかけるのと同じように、Javaランタイムも少数のOSスレッドに多数の仮想スレッドをマッピングすることでスレッドが豊富にあるように見せかけることができるのです。

仮想スレッドは、特定のOSスレッドに縛られないjava.lang.Threadのインスタンスです。これに対してプラットフォームスレッドは、OSのスレッドの薄いラッパーとして、伝統的な方法で実装されたjava.lang.Threadのインスタンスです。

リクエスト毎のスレッド方式のアプリケーションコードは、リクエストの全期間にわたって仮想スレッドで実行できますが、仮想スレッドは、CPUで計算を実行する間のみOSスレッドを消費します。その結果、非同期スタイルと同じスケーラビリティを、透過的に達成できます。仮想スレッドで実行されているコードがjava.* APIでブロッキングI/Oオペレーションを呼び出すと、ランタイムはノンブロッキングOSコールを実行し、後で再開できるまで仮想スレッドを自動的にサスペンドさせるのです。Java開発者にとっては、仮想スレッドは単にスレッドの作成が安上がりで、ほとんど無限にあるようなものです。ハードウェアの使用率は最適に近く、高いレベルの並行処理が可能になり、その結果高いスループットが得られる一方、アプリケーションはJavaプラットフォームとそのツールのマルチスレッド設計と調和したままです。

仮想スレッドの意味するところ

仮想スレッドは安価で豊富なため、決してプールしてはいけません。アプリケーション・タスクごとに新しい仮想スレッドを作成する必要があります。ほとんどの仮想スレッドは短命でコールスタックも浅く、1回のHTTPクライアントコールや1回のJDBCクエリを実行する程度です。対照的に、プラットフォームスレッドは重量級で高価であるため、しばしばプールする必要があります。スレッドは長寿命で、コールスタックも深く、多くのタスクで共有される傾向があります。

要約すると、仮想スレッドは、ハードウェアを最適に利用しながら、Java プラットフォームの設計と調和した信頼性の高いリクエスト毎のスレッド方式を維持します。仮想スレッドを使うのに新しい概念を学ぶ必要はありませんが、今日の高いスレッド費用に対処するために開発された習慣を学ぶ必要はあるかもしれません。仮想スレッドは、アプリケーション開発者を助けるだけでなく、フレームワーク設計者がスケーラビリティに妥協することなくプラットフォームの設計に適合した使いやすいAPIを提供するのにも役立ちます。

続きはこちら:日本語訳:JEP 425 : Virtual Threads (Preview) Part 2 - #chiroito ’s blog