#chiroito ’s blog

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

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

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

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

全パートはこちら

説明

今日、JDKのjava.lang.Threadのすべてのインスタンスは、プラットフォームスレッドです。プラットフォームスレッドは、基礎となるOSスレッド上でJavaコードを実行し、コードの全生涯にわたってOSスレッドを捕捉します。プラットフォームスレッドの数は、OSスレッドの数に制限されています。

仮想スレッドは、基盤となるOSスレッド上でJavaコードを実行するjava.lang.Threadのインスタンスですが、コードの全生涯にわたってOSスレッドを捕捉することはありません。つまり、多くの仮想スレッドが同じOSスレッド上でJavaコードを実行することができ、事実上それを共有できます。プラットフォームスレッドが貴重なOSスレッドを独占するのに対して、仮想スレッドはそうではありません。仮想スレッドの数は、OSスレッドの数よりはるかに大きくできます。

仮想スレッドは、OSではなくJDKによって提供されるスレッドの軽量な実装です。これはユーザーモードスレッドの一種で、他のマルチスレッド言語(GoのゴルーチンやErlangのプロセスなど)でも成功したものです。ユーザーモードスレッドは、OSスレッドがまだ成熟しておらず普及していなかったJavaの初期バージョンでは、いわゆる「グリーンスレッド」として機能したこともあります。しかし、Javaのグリーンスレッドはすべて1つのOSスレッドを共有し(M:1スケジューリング)、最終的にはOSスレッドのラッパーとして実装されたプラットフォームスレッドに負けました(1:1スケジューリング)。仮想スレッドはM:Nスケジューリングを採用しており、多数の(M)個の仮想スレッドが少数の(N)個のOSスレッド上で動作するようにスケジューリングされます。

仮想スレッドとプラットフォームスレッドの使い分け

開発者は、仮想スレッドとプラットフォームスレッドのどちらを使うかを選択できます。ここでは、大量の仮想スレッドを作成するサンプルプログラムを紹介します。このプログラムは、まずExecutorServiceを取得し、提出された各タスクに対して新しい仮想スレッドを作成します。そして、10,000個のタスクを投入し、すべてのタスクが完了するのを待ちます。

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}  // executor.close()は暗黙のうちに呼び出され、待機します。

この例のタスクは、1秒間スリープするという単純なコードで、最近のハードウェアは、このようなコードを同時に実行する1万個の仮想スレッドを簡単にサポートできます。裏側では、JDKは少数のOSスレッド(おそらく1スレッド程度)でコードを実行しています。

このプログラムが、Executors.newCachedThreadPool()のような、各タスクに対して新しいプラットフォームスレッドを作成するExecutorServiceを使用していたら、状況は大きく変わっていたことでしょう。ExecutorService は 10,000 個のプラットフォームスレッドを作成しようとするので、OS スレッドも 10,000 個になり、マシンとオペレーティングシステムによってはプログラムがクラッシュする可能性があります。

もしこのプログラムが、Executors.newFixedThreadPool(200)のようなプールからプラットフォームスレッドを取得するExecutorServiceを使用するなら、状況はあまり良くならないでしょう。このExecutorServiceは、1万個のタスクが共有する200個のプラットフォームスレッドを作成するため、多くのタスクが同時ではなく連続して実行され、プログラムの完了に長い時間がかかることになります。このプログラムでは、200個のプラットフォームスレッドを持つプールでは、200タスク/秒のスループットしか達成できませんが、仮想スレッドでは、(十分なウォームアップの後)約10,000タスク/秒のスループットが達成されます。さらに、このプログラムの10_000を1_000_000に変更すると、100万個のタスクを投入し、100万個の仮想スレッドを生成して同時実行し、(十分なウォームアップの後)約100万タスク/秒のスループットを達成できます。

このプログラムのタスクが、単にスリープするのではなく、1秒間計算を行う(例えば、巨大な配列をソートする)場合、プロセッサコアの数以上にスレッドの数を増やしても、それが仮想スレッドであろうとプラットフォームスレッドであろうと効果はありません。仮想スレッドは高速なスレッドではなく、プラットフォームスレッドよりも高速にコードを実行するわけではありません。仮想スレッドは高速化(低レイテンシ)ではなく、スケール(高スループット)を実現するために存在します。仮想スレッドはプラットフォームスレッドよりも多く存在するため、リトルの法則に従って、高いスループットに必要な高い並行処理が可能になります。

別の言い方をすれば、仮想スレッドは次のような場合にアプリケーションのスループットを大幅に向上させます。

  • 同時実行タスク数が多い(数千以上)。
  • 作業負荷が CPU 拘束でないこと。この場合、プロセッサコア数よりも多くのスレッドがあっても、スループットを向上させることはできないからです。

仮想スレッドは、一般的なサーバーアプリケーションのスループットを向上させるのに役立ちます。なぜなら、そのようなアプリケーションは、多くの時間を待機して過ごす多数の同時実行タスクで構成されているからです。

仮想スレッドは、プラットフォームスレッドが実行可能なあらゆるコードを実行できます。特に、仮想スレッドは、プラットフォームスレッドと同様に、スレッドローカル変数とスレッド割り込みをサポートします。つまり、リクエストを処理する既存の Java コードは、簡単に仮想スレッドで実行できるのです。多くのサーバーフレームワークはこれを自動的に行い、入ってくるリクエストごとに新しい仮想スレッドを開始し、その中でアプリケーションのビジネスロジックを実行することを選択するでしょう。

以下は、他の2つのサービスの結果を集約するサーバーアプリケーションの例です。架空のサーバーフレームワーク(示されていない)は、各リクエストに対して新しい仮想スレッドを作成し、その仮想スレッドでアプリケーションのハンドルコードを実行します。一方、アプリケーションのコードは、最初の例と同じ ExecutorService を介してリソースを同時に取得するために、2 つの新しい仮想スレッドを作成します。

void handle(Request request, Response response) {
    var url1 = ...
    var url2 = ...
 
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var future1 = executor.submit(() -> fetchURL(url1));
        var future2 = executor.submit(() -> fetchURL(url2));
        response.send(future1.get() + future2.get());
    } catch (ExecutionException | InterruptedException e) {
        response.fail(e);
    }
}
 
String fetchURL(URL url) throws IOException {
    try (var in = url.openStream()) {
        return new String(in.readAllBytes(), StandardCharsets.UTF_8);
    }
}

このようなサーバーアプリケーションでは、素直なブロッキングコードを使用することで、多数の仮想スレッドを使用できるため、スケールが良くなります。

Executor.newVirtualThreadPerTaskExecutor() は、仮想スレッドを作成する唯一の方法というわけではありません。後述する新しい java.lang.Thread.Builder API は、仮想スレッドを作成し、開始できます。さらに、構造化された同時実行は、特にこのサーバの例のようなコードにおいて、仮想スレッドを作成し管理する、より強力なAPIを提供し、それによってスレッド間の関係がプラットフォームとそのツールに知られるようになるのです。

仮想スレッドはプレビュー用APIで、デフォルトでは無効

上記のプログラムは Executors.newVirtualThreadPerTaskExecutor() メソッドを使用しているので、JDK 19 で実行するには、以下のようにプレビュー API を有効にする必要があります。

  • javac --release 19 --enable-preview Main.java でコンパイルし、java --enable-preview Main で実行します。
  • または、ソースコードランチャーを使用する場合は、java --source 19 --enable-preview Main.java でプログラムを実行します。
  • または、jshellを使用する場合は、jshell --enable-previewで起動します。

仮想スレッドをプールしない

開発者は通常、アプリケーションコードを、スレッドプールに基づく従来の ExecutorService から、リクエスト毎の仮想スレッド ExecutorService に移行することになります。スレッドプールは、他のリソースプールと同様に、高価なリソースを共有するためのものですが、仮想スレッドは高価ではないので、プールする必要はありません。

開発者は、限られたリソースへの同時アクセスを制限するために、スレッドプールを使用することがあります。例えば、あるサービスが20以上の同時リクエストを処理できない場合、サイズ20のプールに投入されたタスクを経由してサービスへのすべてのアクセスを実行することで、それを確実にできます。プラットフォームスレッドのコストが高いため、スレッドプールが一般的になり、このイディオムも一般的になりました。しかし、開発者は並行処理を制限するために仮想スレッドをプールする誘惑に駆られるべきではありません。限られたリソースへのアクセスを保護するために、セマフォのようなその目的のために特別に設計された構造を使用する必要があります。これはスレッドプールよりも効果的で便利です。また、スレッドローカルデータがあるタスクから別のタスクに誤って漏れる危険性がないため、より安全です。

仮想スレッドの監視

明確なコードを書くことが全てではありません。実行中のプログラムの状態を明確に示すことは、トラブルシューティング、メンテナンス、最適化にも不可欠であり、JDKは長い間、スレッドのデバッグ、プロファイル、監視のためのメカニズムを提供してきました。このようなツールは、おそらくは、その膨大な量に多少の配慮をした上で仮想スレッドに対しても同じことを行うべきです。おそらく、仮想スレッドは結局のところ、java.lang.Threadのインスタンスなのです。

Javaデバッガは、仮想スレッドのステップ実行、コールスタックの表示、スタックフレーム内の変数の検査が可能です。JDK Flight Recorder (JFR) は、JDK の低オーバーヘッドのプロファイリングとモニタリングのメカニズムで、アプリケーションコードからのイベント(オブジェクトの割り当てや I/O 操作など)を正しい仮想スレッドに関連付けられます。これらのツールは、非同期スタイルで書かれたアプリケーションに対してこれらのことを行えません。このスタイルでは、タスクはスレッドに関連しないため、デバッガはタスクの状態を表示したり操作したりできず、プロファイラもタスクがI/O待ちをしている時間を知れないのです。

スレッドダンプは、リクエスト毎のスレッドで書かれたアプリケーションのトラブルシューティ ングによく使われるツールのひとつです。残念ながら、JDKの従来のスレッドダンプは、jstackやjcmdで得られる、スレッドのフラットなリストを提示するものである。これは、数十または数百のプラットフォームスレッドには適していますが、数千または数百万の仮想スレッドには不向きです。したがって、従来のスレッドダンプを拡張して仮想スレッドを含めるのではなく、jcmdに新しい種類のスレッドダンプを導入して、仮想スレッドをプラットフォームスレッドと一緒に、すべて意味のある方法でグループ化して表示することにします。プログラムが構造化された並行処理を使用する場合、スレッド間のより豊かな関係を表示できます。

多くのスレッドを可視化し、分析することは、ツールの恩恵を受けるので、jcmdは、プレーンテキストに加えて、JSONフォーマットで新しいスレッドダンプを出力できます。

$ jcmd <pid> Thread.dump_to_file -format=json <file>

新しいスレッドダンプ形式は、ネットワークI/O操作でブロックされた仮想スレッドと、上に示した新しいリクエスト毎のExecutorServiceで作成された仮想スレッドを列挙します。オブジェクトアドレス、ロック、JNI統計、ヒープ統計など、従来のスレッドダンプに表示される情報は含まれません。さらに、非常に多くのスレッドをリストアップする必要があるため、新しいスレッドダンプを生成しても、アプリケーションを一時停止することはありません。

このようなスレッドダンプの例を、上記の2番目の例と同様のアプリケーションから取得し、JSONビューアでレンダリングしたものを示します。

仮想スレッドはJDKに実装され、特定のOSスレッドと関連付けられていないため、OSからは不可視であり、OSはその存在に気付きません。OSレベルの監視は、JDKプロセスが使用するOSスレッドの数が仮想スレッドの数より少ないことを観察します。

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