※ 本記事は、Nicolai Parlogによる”Coming to Java 19: Virtual threads and platform threads“を翻訳したものです。

2022年5月21日


 

オペレーティング・システムにはプラットフォーム・スレッドの効率を上げることはできないが、JDKのスレッドとOSのスレッドとの1対1関係から脱却すれば、プラットフォーム・スレッドの効率的な利用が可能

Project LoomのJEP 425で、Java 19向けの仮想スレッドの公式プレビューが行われています。ここでは、その機能に注目してみましょう。具体的には、スケジューリングとメモリ管理、マウントとアンマウント、キャプチャと固定、可観測性、そして最適なスケーラビリティを実現するためにできることについて取り上げます。

仮想スレッドについて詳しく説明する前に、従来のスレッドについて改めて触れる必要があります。ここでは、従来のスレッドをプラットフォーム・スレッドと呼ぶことにします。

JDKのプラットフォーム・スレッドは、オペレーティング・システム(OS)スレッドの薄いラッパーとして実装されています。これは高コストなので、大量に使用することはできません。実際、スレッドの数は、CPUやネットワーク接続などの他のリソースよりもはるかに早く枯渇して、制約要素になることが多くあります。

言い換えるなら、プラットフォーム・スレッドによって、アプリケーションのスループットが本来ハードウェアでサポートできるスループットよりも大幅に低下してしまうことが多いのです。
そこで登場するのが、仮想スレッドです。

 

仮想スレッド

オペレーティング・システムにはプラットフォーム・スレッドの効率を上げることはできません。しかしJDKは、JDKのスレッドとOSのスレッドとの1対1関係から脱却することで、プラットフォーム・スレッドを効率的に使うことができます。

仮想スレッドはjava.lang.Threadのインスタンスで、OSスレッドにCPU動作を要求します。ただし、他のリソースを待つ間、OSスレッドを保持することはありません。仮想スレッドで実行されているコードがJDK APIのブロッキングI/O操作を呼び出す場合、ランタイムはノンブロッキングでOSを呼び出し、操作が完了するまで仮想スレッドを自動的に中断させます。

その間、他の仮想スレッドがそのOSスレッドを使って計算を行うことができます。実質的に、OSスレッドは複数の仮想スレッドによって共有されていることになります。

重要なのは、Javaの仮想スレッドは最低限のオーバーヘッドで動作するため、膨大な数の仮想スレッドを作成できるということです。

オペレーティング・システムは、大きな仮想アドレス空間を限られた量の物理RAMにマッピングすることで、あたかもたくさんのメモリがあるように見せかけています。JDKも同じで、大量の仮想スレッドを少数のOSスレッドにマッピングすることで、たくさんのスレッドがあるように見せかけています。

また、プログラムが仮想メモリと物理メモリを意識することはほとんどないのと同じで、Javaの並行コードも、仮想スレッドで実行されるかプラットフォーム・スレッドで実行されるかを意識しなければならないことはほとんどありません。

皆さんは単純なコードを書くことだけに集中することができます。ブロッキングを行うコードでも構いません。ランタイムが、利用できるOSスレッドをうまく共有してくれるので、ブロッキングのコストはほぼゼロになります。

仮想スレッドは、スレッド・ローカルな変数、同期ブロック、スレッドの中断をサポートします。そのため、ThreadやcurrentThreadを使うコードを変更する必要はありません。つまり、既存のJavaコードは何の変更もしなくても、仮想スレッドでそのまま動作します。再コンパイルさえも不要です。

サーバー・フレームワークがすべての着信リクエストに対して新しい仮想スレッドを始めるオプションを提供するようになれば、フレームワークとJDKをアップデートしてスイッチを入れるだけでよくなります。
 

スピードとスケールと構造

仮想スレッドが何のためのもので、何のためのものでないかを理解しておくことは重要です。

仮想スレッドは高速なスレッドではないという点は忘れないようにしましょう。仮想スレッドを使えば、1秒当たりに実行できる命令数をプラットフォーム・スレッドより増やせるわけではありません。

実は、仮想スレッドが得意なのは、待つことです。

仮想スレッドはOSスレッドを必要とせず、ブロックもしないので、たとえ数百万個の仮想スレッドがファイル・システム、データベース、Webサービスへのリクエスト終了を待機していても問題ありません。

仮想スレッドは、最大限に外部リソースを活用することで大きなスケーラビリティを実現していますが、処理が高速になるわけではありません。別の表現を用いるなら、スループットが改善します。

仮想スレッドでは、物理的な数だけでなく、コードの品質が改善する可能性もあります。

仮想スレッドは安価なので、構造化された同時実行性と呼ばれるかなり新しい並行プログラミング・パラダイムへの扉が開かれます。これについては、Inside Java Newscast #17で取り上げました。

それでは、仮想スレッドの仕組みについての説明に入りましょう。

 

スケジュールとメモリ

オペレーティング・システムはOSのスレッド、すなわちプラットフォーム・スレッドのスケジューリングを行いますが、仮想スレッドのスケジューリングはJDKが行います。JDKは、マウントと呼ばれる処理によって仮想スレッドをプラットフォーム・スレッドに割り当てることで、これを間接的に行います。その後、JDKはプラットフォーム・スレッドへの割当てを解除しますが、この処理をアンマウントと呼びます。

仮想スレッドを実行しているプラットフォーム・スレッドをキャリア・スレッドと呼びます。Javaコードからは、仮想スレッドとそのキャリア・スレッドが一時的にOSスレッドを共有しているという事実は見えません。たとえば、スタック・トレースとスレッド・ローカルな変数は完全に分かれています。

キャリア・スレッドのスケジューリングは、通常どおりOSに任されています。OSから見れば、キャリア・スレッドはただのプラットフォーム・スレッドでしかありません。

JDKは、先入れ先出し(FIFO)モードの専用ForkJoinPoolを仮想スレッドのスケジューラとして使うことで、この処理を実現しています(注:これは、並列ストリームで使われる通常のプールとは異なります)。

デフォルトで、JDKのスケジューラは利用できるプロセッサ・コア数と同じだけのプラットフォーム・スレッドを使いますが、この動作はシステム・プロパティで調整することができます。

アンマウントされた仮想スレッドのスタック・フレームは、スタック・チャンク・オブジェクトとしてヒープに格納されます。

一部の仮想スレッドには深いコール・スタックがありますが(Webフレームワークから呼ばれるリクエスト・ハンドラなど)、そういったスレッドから生成されるスレッドのコール・スタックははるかに浅い(ファイルから読み取るメソッドなど)のが通例です。

JDKが仮想スレッドをマウントするときに、すべてのフレームをヒープからスタックにコピーするという方法もあるかもしれませんが、実際には仮想スレッドがアンマウントされると、ほとんどのフレームはヒープに残り、必要に応じて遅延コピーされます。

そのため、アプリケーションの実行とともに、スタックは拡大したり縮小したりします。このことは、仮想スレッドを、大量に生成できて頻繁に交換できるほど安価にするために不可欠です。また、今後の作業でメモリ要件をさらに減らすことができる余地もあります。

 

ブロッキングとアンマウント

通常、I/Oのブロックが発生するか(ソケットからの読取りなど)、JDKの他のブロッキング操作(BlockingQueueのtakeなど)を呼び出した仮想スレッドは、アンマウントされます。

ブロッキング操作が終了できる状態になると(ソケットがバイト列を受け取ったり、キューが要素を渡せるようになったりしたとき)、仮想スレッドはスケジューラに送り返されます。すると、FIFOの順序に従ってマウントされ、実行が再開されます。

ただし、JEP 353(レガシーSocket APIの再実装)JEP 373(レガシーDatagramSocket APIの再実装)などの以前のJDK拡張提案が行われているにもかかわらず、JDKのすべてのブロッキング操作で仮想スレッドがアンマウントされるわけではありません。それどころか、中には、キャリア・スレッドとそれに対応するOSのプラットフォーム・スレッドをキャプチャし、両方をブロックする操作もあります。

この残念な動作は、OSレベルの制限(多くのファイル・システム操作に影響します)や、JDKレベルの制限(Object.wait()呼出しによるものなど)によって発生する可能性があります。

OSスレッドがキャプチャされてしまった場合、一時的にスケジューラにプラットフォーム・スレッドを追加して補填を行います。利用可能なプロセッサ数を超過する場合があるのはそのためで、最大数はシステム・プロパティで設定することができます。

今回行われている、最初の仮想スレッド提案には、もう1つの欠点があります。仮想スレッドがネイティブ・メソッドや外部関数を実行したり、同期ブロックや同期メソッドのコードを実行したりすると、その仮想スレッドはキャリア・スレッドに固定されてしまいます。固定されたスレッドは、通常であればアンマウントされる状況になっても、アンマウントされることはありません。

この状況になっても、スケジューラにプラットフォーム・スレッドは追加されません。なぜなら、固定による影響を最小限にとどめるために行えることがいくつかあるからです。この点には後ほど詳しく触れたいと思います。

キャプチャ操作やスレッドの固定が起きると、プラットフォーム・スレッドが何らかの操作の終了を待機するという状況が再登場することになります。これによって不正なアプリケーションができるわけではありませんが、スケーラビリティが低下する可能性はあります。

ありがたいことに、今後の作業によって同期による固定はなくなる可能性があります。また、java.ioパッケージの内部がリファクタリングされ、Linuxのio_uringのようなOSレベルのAPIが実装されれば、キャプチャが起きる操作数は少なくなるかもしれません。

 

仮想スレッドの可観測性

仮想スレッドは、Javaアプリケーションの観測、分析、トラブルシューティング、最適化に利用できる既存ツールに完全に対応しています。たとえば、Java Flight Recorder(JFR)を使うと、仮想スレッドの開始時、終了時、何らかの理由による起動失敗時、固定によるブロック時にイベントを発行させることができます。

スレッドの固定について詳しく調査したい場合は、システム・プロパティでランタイムを構成すると、固定によるブロックが発生したときにスタック・トレースを表示することができます。このスタック・トレースでは、固定の原因になったスタック・フレームがハイライトされています。

また、仮想スレッドは単なるスレッドなので、プラットフォーム・スレッドと同じようにデバッガでステップ実行することもできます。当然ですが、膨大なスレッドに対応できるようにするには、デバッガのユーザー・インタフェースにアップデートが必要になるでしょう。そうしなければ、とても小さなスクロール・バーを操作しなければならなくなってしまうでしょう。

通常、仮想スレッドは階層構造になります。しかし、従来のスレッド・ダンプはフラットな形式なので、このような動作や膨大な数には適しません。そのため、今後もプラットフォーム・スレッドのスレッドをダンプするだけのものになるでしょう。jcmdの新しいタイプのスレッド・ダンプでは、プラットフォーム・スレッドだけでなく、仮想スレッドも表示されます。さらに、プレーン・テキストとJSONの両方によってわかりやすい形でグループ化も行われます。

 

実用的な3つのアドバイス

最初のアドバイスは、仮想スレッドはプールしないということです。

プールが意味を成すのは高価なリソースに対して用いる場合のみですが、仮想スレッドは高価ではありません。並行処理を行いたい場合は、常に新しい仮想スレッドを作成するようにしてください。データベースへのリクエストなど、特定のリソースに対するアクセスを制限したい場合にスレッド・プールを使おうと思う方もいるかもしれませんが、これは適切ではありません。スレッド・プールではなく、セマフォを使って特定の数のスレッドのみがそのリソースにアクセスするようにします。次に例を示します。

 

// スレッド・プールを使う場合
private static final ExecutorService
	DB_POOL = Executors.newFixedThreadPool(16);

public <T> Future<T> queryDatabase(
		Callable<T> query) {
	// プールによって同時クエリーを16に制限
	return DB_POOL.submit(query);
}


// セマフォを使う場合
private static final Semaphore
	DB_SEMAPHORE = new Semaphore(16);

public <T> T queryDatabase(
		Callable<T> query) throws Exception {
	// セマフォによって同時クエリーを16に制限
	DB_SEMAPHORE.acquire();
	try {
		return query.call();
	} finally {
		DB_SEMAPHORE.release();
	}
}


次に、仮想スレッドで適切なスケーラビリティを実現するには、頻繁に固定が起きる状況や長時間固定が起きる状況を避けるようにします。そのために、I/O操作を含み頻繁に実行される同期ブロックや同期メソッド(特に、長時間実行されるもの)をリファクタリングします。この場合は、同期ではなくReentrantLockの方が適切です。次に例を示します。

 

// 同期を使う場合 ( 固定が発生する 👎🏾):
// 同期によってシーケンシャルなアクセスを保証
public synchronized String accessResource() {
	return access();
}


// ReentrantLockを使う場合 ( 固定は発生しない 👍🏾):
private static final ReentrantLock
	LOCK = new ReentrantLock();

public String accessResource() {
	// ロックによってシーケンシャルなアクセスを保証
	LOCK.lock();
	try {
		return access();
	} finally {
		LOCK.unlock();
	}
}

最後に、仮想スレッドで正しく動作するものの、スケーラビリティを向上させるために確認しておくべきもう1つの側面として、スレッド・ローカルな変数が挙げられます。この点は、通常の変数と継承する変数の両方に適用されます。仮想スレッドはスレッド・ローカルな変数の動作をプラットフォーム・スレッドと同じようにサポートしますが、仮想スレッドは膨大な数になる可能性があるので、スレッド・ローカルな変数は慎重な検討を経た後でのみ使うべきです。

実際、Project Loomでは、コードが膨大な数のスレッドを実行するときのメモリ・フットプリントを削減するため、java.baseモジュールで使われていた多くのスレッドのローカル変数が削除されました。現在、スコープ・ローカルな変数についてのドラフト版JEPでは、いくつかのユースケースについて、興味深い代替案が検討されています。

[編集注:本記事は、Nicolai ParlogのInside Java Newscast #23「仮想スレッドを掘り下げる」を抜粋要約したものです。]

 

さらに詳しく

Nicolai Parlog

 

Nicolai Parlog
Oracle、Developer Advocate
Nicolai Parlog(@nipafx):学習と共有、オンラインとオフラインに情熱を傾けるJava愛好家。オラクルのDeveloper Advocate。