プログラミング
Java 8でLoomなしのスクリプト言語のための仮想スレッド
Virtual Threads for a scripting language in Java 8 without Loom (jactl.io)
要約
この記事では、Java 8環境でLoom(Java 21以降で導入された仮想スレッド機能)を使わずに、非同期処理を実現するスクリプト言語Jactlの設計について説明しています。Jactlは、イベント駆動型アーキテクチャにおいて、ブロッキング操作が発生した場合でも実行スレッドを解放し、スクリプトの実行状態を保存・再開する仕組みを、例外処理と継続(Continuation)オブジェクトを用いてJava 8で実現しています。
全文翻訳
はじめに
Jactlは、Javaアプリケーションに組み込めるセキュアなスクリプト言語です。私がJactlを開発し始めたとき、バイトコードにコンパイルされて最適なパフォーマンスを発揮し、アプリケーションがスクリプトの実行できることとできないことを正確に制御できるほどセキュアで、そして何よりも、長時間実行されるブロッキング操作が行われたときに実行スレッドをブロックしないスクリプト言語を求めていました。
私がJactlを開発し始めたとき、Java 21と仮想スレッドはまだ存在しておらず、イベント駆動型、リアクティブなアプリケーション(Vert.xなどに基づくもの)が、高スループットのJavaアプリケーションの書き方でした。また、Java 8やJava 11にまだ留まっているアプリケーションでも動作するスクリプト言語が必要でした。
注: 後続のJavaバージョンでは、イベントベースのアーキテクチャを採用する代わりに仮想スレッドを使用したいJavaアプリケーションは、JactlContext.async(false)フラグを設定することで、ここで説明されている組み込みのJactl非同期メカニズムを無効にすることを選択できます。
リアクティブアプリケーションとは、イベントループスレッドのプールがキューからイベントを処理するアプリケーションのことです。ゴールデンルールは、イベントがブロッキングしないことです。なぜなら、ブロッキングはイベントループスレッドの1つを一時停止させ、そのブロッキング操作が完了するまで、それ以上のイベントを処理できなくするからです。ブロッキング操作とは、スレッドがコードを積極的に処理せず、データベースリクエストやリモートプロシージャコールなどの操作の結果を待っている状態のことです。ブロッキング操作がイベントループスレッドで発生すると、最終的にはすべてのスレッドが長時間実行される操作を待っており、どのイベントも処理されていない状況が発生します。
私は、イベントループスレッドから呼び出せるスクリプト言語を求めていましたが、ブロッキング操作を実行した際には、その状態を保存して戻り、スレッドを解放してさらなるイベントを処理できるようにしたいと考えていました。長時間実行される操作の結果が利用可能になったら、スクリプトは中断した時点から再開され、処理を続行します。
Java 21以降では、仮想スレッドが同じ機能を提供します。それらは、ローカル変数を含むコールスタックを保持し、スレッドが他の作業を実行できるようにし、ブロッキング操作が完了するとコールスタックが復元され、プログラムは中断した時点から続行します。
目標は、Jactlスクリプトの実行状態のみを保存することであり、Jactlスクリプトを呼び出しているJavaコードの状態は保存しないことでした。Javaアプリケーションはイベントベースであるため、スクリプトはイベントループスレッド上の新しいイベントとして完了し、スクリプトが完了してスクリプトの結果をJavaアプリケーションにコールバックする際に、完了コールバックが呼び出されます。アプリケーションによって提供されるコールバックは、アプリケーションが必要とする状態を保持できます。
継続(Continuations)
Java 8では、もちろん、JavaでもJVMバイトコードでもコールスタックを保持する方法はありません。そのため、同じ目的を達成するために別のメカニズムを使用する必要がありました。スクリプトが長時間実行される操作を実行する関数(またはメソッド)を呼び出す必要があると想像してみてください。例として、その関数が他のことをする前にsleep()を実行する必要があると仮定します。Javaのコールスタックがあり、ネストされた各メソッド呼び出しのスタックフレームがあり、その後、残りのスタックはJactlスタックフレームとなり、ネストされた各Jactl関数呼び出しに対して1つずつ、最上位のスタックフレームはsleep()関数自体のスタックフレームになります。各スタックフレームは、関数呼び出しがコードのどこで行われたか、およびそのローカル変数の値を追跡します。
実行状態をキャプチャするために、最も簡単な方法は、sleep()のような長時間実行される操作の開始時に例外をスローし、各Jactlメソッド/関数で例外をキャッチし、状態を保存し、キャッチした例外にチェーンされた新しい例外をスローするようにコードを生成することだと考えました。例外クラスをContinuationと呼びました。なぜなら、継続はプログラムの実行状態の表現だからです。
sleep()関数の実装は、次のようになります。
public static Object sleep(long timeMs) {
Continuation continuation = new Continuation();
scheduleEvent(timeMs, () -> continuation.continueExecution());
throw continuation;
}
この例外がコールスタックをアンワインドするにつれて、各Jactlスタックフレームの生成コードはそれをキャッチし、自身のContinuationを作成して、どこまで実行が進んだか(待機していた呼び出しの位置)とローカル変数の値を記録し、キャッチした例外にチェーンされた新しいContinuationをスローします。例外がJactlコールスタックの最下部に到達する頃には、元のコールスタックは、スクリプト全体の実行状態をまとめてキャプチャするContinuationオブジェクトのチェーン(フレームごとに1つ)に置き換えられています。
注: Javaアプリケーションのパフォーマンスチューニングを行ったことがある人なら誰でも、例外をスローするという考えを聞くとすぐにコストを連想しますが、実際には、例外をスローするコストのほとんどは、それに付随するスタックトレースの生成にあります。スタックトレースを埋め込まない例外をスローする限り、実際には非常に効率的です。
非同期関数(Async Functions)
Jactlコンパイラは、Continuationオブジェクトをスローできる長時間実行操作を実行できるグローバル関数を認識しています。これらの関数は非同期関数と呼ばれ、Jactlコンパイラはこれらの非同期関数を呼び出すメソッドや関数を追跡し、それらも非同期としてマークします。これは呼び出しチェーンを上にたどり、コンパイラは、実際に最初のContinuationをスローする組み込みグローバル関数がネストされた呼び出しの多くのレベルに埋もれていたとしても、いつでも呼び出しがContinuationオブジェクトをスローする可能性があるかどうかを知ることができます。
非同期メソッド/関数の呼び出し
コンパイラが非同期としてフラグ付けされた関数を呼び出すコードを生成する場合、それは呼び出しをContinuationをスローする可能性のあるものをキャッチするtry/catchブロックでラップします。catchブロックのコードは新しいContinuationオブジェクトを作成し、その中にMethodHandleと場所を保存します。MethodHandleは現在の関数を指し、場所はContinuationをスローした非同期関数への呼び出しが発生した現在の関数内のどこかを示す論理的な場所です。MethodHandleと場所だけでなく、コンパイラは、非同期関数がContinuationをスローした際にスコープ内にあったローカル変数の値や、ローカルスタック上にある値を保存するコードも生成します。使用される配列は2つあります。プリミティブであるローカル変数とスタック値に使用されるlong[]と、それ以外のすべての型に使用されるObject[]です。
すべての非同期関数には、暗黙的にContinuationオブジェクトが最初の引数として渡されます。初回は引数はnullですが、長時間実行操作のために一時停止され、その後再開された場合、一時停止時に元のContinuationで再呼び出しされます。生成されたコードは、continuation引数がnullでないかどうかをチェックし、nullでない場合は、`Continuation`オブジェクト内の場所を使用して、関数内のどこにジャンプして実行を続行するかを判断します。
以下は、コンパイラが別の非同期関数を呼び出す関数に対して生成するコードの疑似コードです。
static MethodHandle processOrderHandle = MethodHandles.lookup().findStatic("processOrder");
Object processOrder(Continuation cont, ...) {
Order order;
Widget widget;
int count;
if (cont != null) {
// ローカル変数を復元した後、中断した場所から再開
switch (cont.location) {
case 0:
// localsを復元
order = cont.objArr[0];
widget = cont.objArr[1];
count = cont.longArr[0];
goto LOCATION_0;
case 1:
... goto LOCATION_1;
}
}
// 関数のコード...
try {
checkInventory(widget, count);
} catch (Continuation c) {
throw new Continuation(c, processOrderHandle, 0, // 場所
new long[]{ count },
new Object[]