JEP 428 "Structured Concurrency (Incubator)"が、JDK 19のProposed to TargetステータスからTargetedステータスに昇格した。Project Loomの傘下にあるこのJEPは、異なるスレッド上で動作する複数のタスクをアトミックなオペレーションとして扱うライブラリの導入によって、マルチスレッドプログラミングを簡略にすることを提案するものだ。結果として、エラー処理やキャンセル操作の容易化、信頼性の向上、可観測性の改善が期待できるが、現時点ではまだincubating APIである。
このライブラリでは、StructuredTaskScope
クラスを使用して並列コードを管理することができる。ここでは、サブタスクのファミリをユニット(unit)として扱う。サブタスクは、それぞれがフォーク(fork)することで独自のスレッド上に生成されるが、その後ユニットとして結合(join)することで、ユニットを単位とするキャンセルが可能になる。個々のサブタスクの例外や成功結果は、親タスクによって集約された上で処理される。例を見てみよう。
Response handle() throws ExecutionException, InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> findUser());
Future<Integer> order = scope.fork(() -> fetchOrder());
scope.join(); // Join both forks
scope.throwIfFailed(); // ... and propagate errors
// Here, both forks have succeeded, so compose their results
return new Response(user.resultNow(), order.resultNow());
}
}
上のhandle()
メソッドは、サーバアプリケーションのひとつのタスクを表すもので、着信した要求を、2つのサブタスクを生成して処理する。ExecutorService.submit()
と同じように、StructuredTaskScope.fork()
もCallable
をパラメータとして、Future
を返す。ExecuteService
と違うのは、返されたFuture
がFuture.get()
によって結合されていないことだ。このAPIは、同じくJDK 19を対象とするJEPである、JEP 425 "Virtual Threads (Preview)"上で動作する。
上の例はStructuredTaskScope
APIを使用しているので、JDK 19上で実行するには、jdk.incubator.concurrent
モジュールを追加した上で、仮想タスクを使用するプレビュー機能を有効にする必要がある。
コードのコンパイルは以下のコマンドで行う。
javac --release 19 --enable-preview --add-modules jdk.incubator.concurrent Main.java
プログラムの実行時にも同じフラグが必要だ。
java --enable-preview --add-modules jdk.incubator.concurrent Main
;
ソースコードランチャを使った直接実行も可能で、その場合のコマンドラインは次のようなものになる。
java --source 19 --enable-preview --add-modules jdk.incubator.concurrent Main.java
jshellも使用可能だが、同じくプレビュー機能を有効にする必要がある。
jshell --enable-preview --add-modules jdk.incubator.concurrent
Structured Concurrencyにはさまざまなメリットがある。そのひとつは、起動したメソッドとサブタスクの間に親子関係が作られることだ。上の例で見れば、handle()
タスクが親、サブタスクであるfindUser()
とfetchOrder()
が子で、結果的にコードブロック全体がアトミックになる。スレッドダンプ内でもタスク階層が表示されることので、可観測性が確保される。エラー処理のショートサーキットも可能だ。サブタスクのひとつがフェールすれば、完了していない他のタスクはキャンセルされる。親タスクのスレッドがjoin()
をコールする前または実行中に割り込まれた場合、スコープを出る時に、どちらのフォークも自動的にキャンセルされる。これらによって並列コードの構造が明確になるため、シングルスレッド環境で動作している時のようにコードを読み進めていけば、その内容を理解し、追跡することができる。
プログラミングの黎明期には、プログラムフローはGOTO
文の無差別な使用によってコンロトールされていた。その結果として、読むこともデバッグすることも困難な、混乱したスパゲッティコードが生み出されていたのだ。その後、プログラミングパラダイムの成熟に伴い、プログラミングコミュニティはGOTO
文が有害であることを理解するようになった。1969年、著書"The Art of Computer Programming"で高名なコンピュータ科学者のDonald Knuth氏は、GOTO
を使わなくても効率的なプログラムを書くことは可能だ、という主張をした。やがて、これらの問題をすべて解決する構造化プログラミングが現れたのだ。次の例を見てほしい。
Response handle() throws IOException {
String theUser = findUser();
int theOrder = fetchOrder();
return new Response(theUser, theOrder);
}
上のコードは構造化コードの一例だ。シングルスレッド環境であれば、handle()
メソッドがコールされると、コードが順次実行される。fetchOrder()
メソッドがfindUser()
メソッドよりも前に開始されることはない。findUser()
メソッドが失敗すれば、以降のメソッドコールはすべて実行されず、handle()
メソッドは暗黙的に失敗する。結果として、アトミックなオペレーションが成功するか、成功しないかのいずれかであることが保証されるのだ。それによって、handle()
メソッドとその子メソッド呼び出しの間には親子関係が成立し、エラーが伝搬されると同時に、実行時のコールスタックが構成されることになる。
しかしながら、このアプローチと理解は、我々の並列スレッドプログラミングモデルには通用しない。例えば上のコードをExecutorService
で記述しようとすると、コードは次のようなものになる。
Response handle() throws ExecutionException, InterruptedException {
Future<String> user = executorService.submit(() -> findUser());
Future<Integer> order = executorService.submit(() -> fetchOrder());
String theUser = user.get(); // Join findUser
int theOrder = order.get(); // Join fetchOrder
return new Response(theUser, theOrder);
}
ExecutorService
内の各タスクは別々に動作するので、成功あるいは失敗も別々に発生する。親の実行が中断してもサブタスクには伝搬しないので、そこにリークのシナリオが生まれる。つまり、親との関係が失われるのだ。スレッドダンプでは、親タスクと子タスクが無関係なスレッドのコールスタック上に表れるので、デバッグも難しくなる。コードは論理的には構造化されているかも知れないが、それは実行環境ではなく開発者の心の中にあるに過ぎない。すなわち、並列実行コードは非構造的なのだ。
非構造化並行コードにおけるこれらの問題をすべて観察した結果として、Martin Sústrik氏のブログ記事から"構造化並行性(Structured Concurrency)"なる用語が生まれ、Nathaniel J.Smith氏の記事"Notes on structured concurrency"で広く知られるようになった。構造化並行性については、Oracleテクニカルスタッフのコンサルティングメンバで、Project LoomのプロジェクトリーダのRon Pressler氏が、InfoQポッドキャストで次のように語っている。
"構造化(structured)"というのは、何かを発生(spawn)させたならば、それを待って結合する必要がある、ということです。ここで言う"構造(structure)"は、構造化プログラミングで使用されているものと同じです。考え方としては、コードのブロック構造がプログラムの実行動作に反映されている、というものです。つまり、構造化プログラミングが提供する逐次的なコントロールフローを、構造化並行性は並行処理に対して提供してくれるのです。
構造化並行性やその背景について詳しく知りたいのであれば、Ron Pressler氏のInfoQポッドキャストやYouTubeセッション、Inside Javaの記事が参考になるだろう。
編集後記
この記事は、JEP 428がJDK 19の"Targeted"ステータスになったことを反映して更新された。