HN 日本語サマリー

← 一覧へ戻る
プログラミング

Java 8でLoomなしのスクリプト言語のための仮想スレッド

Virtual Threads for a scripting language in Java 8 without Loom (jactl.io)

45 pointsby jaccomo4 コメント

要約

この記事では、Java 8環境でLoom(Java 21以降で導入された仮想スレッド機能)を使わずに、非同期処理を実現するスクリプト言語Jactlの設計について説明しています。Jactlは、イベント駆動型アーキテクチャにおいて、ブロッキング操作が発生した場合でも実行スレッドを解放し、スクリプトの実行状態を保存・再開する仕組みを、例外処理と継続(Continuation)オブジェクトを用いてJava 8で実現しています。

全文翻訳

Jactlの継続と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スクリプトを呼び出しているJavaコードの状態ではなく、Jactlコードの実行状態のみを保存することでした。Javaアプリケーションはイベントベースであるため、スクリプトはイベントループスレッド上の新しいイベントとして完了し、スクリプトが完了すると、スクリプトの結果をJavaアプリケーションにコールバックする完了コールバックが呼び出されます。アプリケーションによって提供されるコールバックは、アプリケーションが必要とする任意の状態を保持できます。

継続

Java 8では、もちろん、JavaでもJVMバイトコードでもコールスタックを保持する方法はありませんでしたので、同じ目的を達成するために別のメカニズムを使用する必要がありました。

長時間実行される操作を実行する関数(またはメソッド)を呼び出す必要があるスクリプトがあると想像してください。例として、その関数が他のことを行う前に、一定時間sleep()を実行する必要があると仮定します。各ネストされたメソッド呼び出しのスタックフレームを持つJavaコールスタックがあり、スタックの残りはJactlスタックフレーム、つまりネストされたJactl関数呼び出しごとに1つずつとなり、最上位のスタックフレームはsleep()関数自体のスタックフレームになります。各スタックフレームは、関数呼び出しがコードのどこで行われたか、およびそのローカル変数の値を追跡します。

[画像: sleep()がスクリプトを中断した時点でのコールスタック]

実行状態をキャプチャするために、sleep()のような長時間実行される操作の開始時に例外をスローし、各Jactlメソッド/関数にコードを生成して例外をキャッチし、状態を保存し、キャッチした例外にチェーンされた新しい例外をスローするのが最も簡単な方法だと考えました。スローされる例外のクラスをContinuationと名付けました。なぜなら、継続(continuation)はプログラムの実行状態の表現だからです。

sleep()関数の実装は、次のようになります。

public static Object sleep(long timeMs) {  Continuation continuation = new Continuation();  scheduleEvent(timeMs, () -> continuation.continueExecution());  throw continuation;}

この例外がコールスタックをアンワインドするにつれて、各Jactlスタックフレームのために生成されたコードはそれをキャッチし、自身のContinuationを作成して、どこまで実行が進んだか(待機していた呼び出しの場所)とローカル変数の値を記録し、次にキャッチしたContinuationにチェーンされた新しいContinuationをスローします。例外がJactlコールスタックの底に到達する頃には、元のコールスタックは、スクリプトの実行状態全体をキャプチャするContinuationオブジェクトのチェーン(フレームごとに1つ)に置き換えられています。

[画像: JactlコールスタックがContinuationオブジェクトのチェーンにアンワインドされる]

Javaアプリケーションのパフォーマンスチューニングを行ったことがある人なら、例外をスローするという考えを聞くとすぐにコストを連想するかもしれませんが、実際には、例外をスローするコストのほとんどは、それに付随するスタックトレースの生成にあります。スタックトレースを埋め込まない例外をスローする限り、実際には非常に効率的です。

非同期関数

Jactlコンパイラは、どのグローバル関数が長時間実行される可能性のある操作を実行でき、Continuationオブジェクトをスローできるかを知っています。これらの関数は非同期関数と呼ばれ、Jactlコンパイラはこれらの関数を呼び出すメソッドや関数を追跡し、それらも非同期としてマークします。これはコールチェーンを上にたどるため、コンパイラは、実際に最初のContinuationをスローする組み込みグローバル関数がネストされた呼び出しの多くのレベルに埋もれていたとしても、いつでも呼び出しがContinuationオブジェクトをスローする可能性があるかどうかを知ることができます。

非同期メソッド/関数の呼び出し

コンパイラが非同期としてフラグ付けされた関数を呼び出すコードを生成する場合、その呼び出しをtry/catchでラップして、スローされる可能性のあるContinuationをキャッチします。

catchブロックのコードは、新しいContinuationオブジェクトを作成し、その中にMethodHandleと場所を格納します。MethodHandleは現在の関数を指し、場所はContinuationをスローした非同期関数の呼び出しが発生した現在の関数内の場所を記録する論理的な場所です。

MethodHandleと場所だけでなく、コンパイラはスコープ内のローカル変数の値や、ローカルスタック上にある値も格納するコードを生成します。2つの別々の配列が使用されます。プリミティブであるローカル変数やスタック値に使用されるlong[]と、それ以外のすべての型に使用されるObject[]です。

すべての非同期関数には、暗黙的に最初の引数としてContinuationオブジェクトが渡されます。初回は引数はnullですが、長時間実行される操作のために関数が中断され、後で再開された場合、中断時にスローされたContinuationが渡されて再呼び出しされます。生成されたコードは、継続引数がnullでないかどうかをチェックし、そうであれば、Continuationオブジェクト内の場所を使用して、実行を続行するためにどこにジャンプするかを判断します。

ここでは、コンパイラが別の非同期関数を呼び出す関数に対して生成するコードがどのようになるかを示す擬似コードを示します。

static MethodHandle processOrderHandle = MethodHandles.lookup().findStatic("processOrder");Object processOrder(Continuation cont, ...) {  Order  order;
  Widget widget;
  int    count;
  if (cont != null) {
    // Resume from where we left off after restoring any local variables
    switch (cont.location) {
      case 0:
        // Restore locals
        order  = cont.objArr[0];
        widget = cont.objArr[1];
        count  = cont.longArr[0];
        goto LOCATION_0;
      case 1:
        ...
        goto LOCATION_1;
    }
  }
  // Code for the function
  ...
  try {
    checkInventory(widget, count);
  }
  catch (Continuation c) {
    throw new Continuation(c, processOrderHandle,                            0,   // the location
                           new long[]{ count },
                           new Object[]{ order, widget });
  }
LOCATION_0:
  ...
}

Continuationコンストラクタは、チェーンがスタックの最上位からのContinuationで始まるように、キャッチされたばかりのContinuationに自身をチェーンすることに注意してください。

実行の再開

長時間実行される操作が完了すると、チェーン内の最初のContinuationオブジェクトは、そのcontinueExecution(Object result)メソッドを呼び出すことによって再開されます。このメソッドはMethodHandleを抽出し、それを呼び出します。前述のようにContinuationを渡して、関数がローカル変数を復元し、どこから続行するかを判断できるようにします。

コールスタックは元のコールスタックと一致しなくなったため、関数が返るとき、元の親関数に戻るのではなく、Continuation.continueExecution()メソッドに戻ります。このメソッドはチェーン内の次のContinuationオブジェクトを抽出し、そのMethodHandleを呼び出します。これは、チェーン内にContinuationオブジェクトがなくなるまで続き、アプリケーションから登録された完了コールバックが最終結果とともに呼び出されます。

後続の非同期関数の呼び出し

Continuationオブジェクトのチェーンをウォークして再開している間に、別の非同期関数が呼び出され、新しい長時間実行される操作のための新しいContinuationオブジェクトがスローされる可能性があります。これは、ネストされた呼び出しのさらに多くのレベルから発生する可能性があります。この場合、新しい継続チェーンを取得し、既存のチェーンの残りをそのチェーンの末尾に追加します。

[画像: 元のチェーンを再開中に、checkInventory()が再び中断され、古いチェーンの残りが新しいチェーンの末尾に追加される]

新しい長時間実行される操作が完了し、そのContinuationチェーンが再開されると、チェーンはすべての新しい継続と、残りの古い継続で構成されます。まず新しい継続をそれぞれ再開し、次に古いチェーンの残りの継続に進みます。

実行状態のチェックポイント

Jactlが現在の実行状態を継続のチェーンに保存できるようになると、これらの継続をバイト配列にシリアライズできれば、スクリプトの状態のチェックポイントとして使用できることに気づきました。Jactlは、スクリプトが処理の重要なステップで状態をチェックポイントできるようにするcheckpoint()関数を提供します。スクリプトの状態がチェックポイントされると、その状態はディスク、データベース、または別のアプリケーションインスタンスにネットワーク経由でレプリケートできます。状態が永続化またはレプリケートされると、元のアプリケーションホストが失敗した場合などに、いつでも再開できます。

組み込み型およびユーザー定義クラスのそれぞれについて、Jactlはこれらの型のインスタンスをバイト配列に格納するコードを生成します。これには、Jactlランタイムによって内部的に使用される他の型も含まれます。Jactlは次に、アプリケーションがこれらのスクリプト状態を、アプリケーション状態の冗長化ソリューションの一部として永続化またはレプリケートするために使用できるフックを提供します。必要に応じて状態を再開するために、アプリケーションが使用できる対応するJactlメカニズムがあります。詳細とアプリケーションの冗長化のためのチェックポイントの概念実証実装の説明については、チェックポイントの概念実証を参照してください。

ベンチマーク

継続メカニズムがパフォーマンスにどのように影響するかを示すために、JMHライブラリを使用したベンチマークを作成しました。SuspendResumeBenchmarkは、イベントスケジューリングと実行にVert.xを使用し、200件の注文のバッチを処理するJactlスクリプトをベンチマークします。

スクリプト

var totals    = [:]
var itemCount = 0
var grandTotal = 0.0
var topCategory = ''
var topAmount = -1.0
var slept = 0

def checkInventory(widget, count) {
  sleep(0) if slept++ < sleepCount
  return true
}

def processOrder(order) {
  var price    = order.price
  var qty      = order.quantity
  var category = order.category

  return unless checkInventory(order.category, order.quantity)

  var discount = 0.0
  if      (qty >= 100) { discount = 0.20 }
  else if (qty >=  50) { discount = 0.10 }
  else if (qty >=  20) { discount = 0.05 }

  var lineTotal = price * qty * (1.0 - discount)

  if (totals[category] == null) {
    totals[category] = 0.0
  }
  totals[category] = totals[category] + lineTotal

  grandTotal       = grandTotal + lineTotal
  itemCount        = itemCount + 1

  if (totals[category] > topAmount) {
    topAmount   = totals[category]
    topCategory = category
  }
}

for (order in orders) {
  processOrder(order)
}

'Processed ' + itemCount + ' orders. Grand total: ' + grandTotal + '. Top category: ' + topCategory

各バッチについて、processOrder()がバッチ内の各注文に対して呼び出され、次に注文内の各アイテムに対してcheckInventory()(常にtrueを返す)が呼び出されます。checkInventory()関数は、最初のn回呼び出されたときにsleep(0)を呼び出すため、ネストされたコールスタックの内部からスクリプトのサスペンドと再開のオーバーヘッドを測定できます。sleep(0)への呼び出しはContinuationをスローしてスクリプトをサスペンドしますが、スリープ時間が0なので、スクリプトの実行を続行するためにすぐに再開イベントをスケジュールします。

ベンチマークは、スクリプトがsleep(0)を0、1、2、5、10回呼び出す場合のパフォーマンスを測定します。結果は次のとおりです。

[画像: スループット対サスペンド/再開操作の数]

チャートが示すように、このベンチマークでは、各サスペンド/再開の影響は非常に小さいです。実際のシナリオでは、相対的な影響は、スクリプトがどれだけの作業を行っているか、スタックのネストの深さ、およびスタックの各レベルにあるローカル変数の数(パラメータを含む)に基づきます。測定されたオーバーヘッドには、スクリプトと再開イベントの両方のスケジューリングと実行に関わるVert.xスケジューラオーバーヘッドも含まれていることに注意してください。

結論

古いバージョンのJavaで実行する必要があるリアクティブアプリケーションにとって、Jactlの継続ベースのメカニズムは、ブロッキング操作を処理するための便利で効率的な方法を提供し、アプリケーションがスクリプトを介してカスタマイズを提供できるようにします。スクリプトがイベントループスレッドをブロックすることを心配する必要はありません。スクリプトは、インライン化されたブロッキング操作を自然な方法で記述できます。コードをasync/awaitで汚染したり、過去にプログラミング言語が非同期動作に対処するために使用してきたFuturesやPromisesなどのメカニズムを扱ったりする必要はありません。スクリプトの観点からは、JactlはJava 21の仮想スレッドがJavaプログラムに提供するものと同等のプログラミングモデルを提供します。

最新バージョンのJavaでは、Jactlの継続ベースのアプローチを無効にし、Jactlは仮想スレッドを活用して、キャリアスレッドをブロックしないブロッキング操作のサポートを提供できます。

後記

その後、任意のJavaコードの継続を実装するために同様のメカニズムを使用する他のライブラリ(例えばApache JavaflowやJava Continuations Library - 現在はメンテナンスされていません)があることに気づきました。これらはバイトコードインストルメンテーションに依存して、適切な命令をコードベースに挿入します。これらの実装の詳細を調べて、Jactlのアプローチとどれほど密接に一致しているかはわかりませんが、例外をスローし、各スタックフレームでそれらをキャッチしてローカル状態を記録するという同じアイデアを使用しているようです。