join() が投げる例外が FailedException から ExecutionException に変わったことStructuredTaskScope と Joiner に第3の型パラメータが追加され、型安全性が強化されたopen() の新しい overload が追加されたJavaのStructured Concurrencyが、またじわっと前進しました。InfoQが伝えているのは、JEP 533「Structured Concurrency」 が JDK 27でintegrated status に入った、というニュースです。
ここでいう Structured Concurrency は、ざっくり言うと「関連する複数の処理を、ひとつのまとまりとして扱う」ための仕組みです。
たとえば、1つのリクエストを処理するために「ユーザー情報を取りに行く」「注文一覧を取る」「プロフィールを取る」といった複数の処理を同時に走らせる場面がありますよね。そういうとき、バラバラにスレッドを管理するのではなく、親のスコープの中で子タスクをまとめて管理するのがStructured Concurrencyです。
個人的には、これはかなり筋のいい方向だと思っています。
並行処理って、便利な反面、放っておくと「いつ終わるの?」「失敗したら誰が止めるの?」「どこで起きてる処理なの?」と一気にカオスになるんですよね。Structured Concurrencyは、そのカオスを「スコープ」という形で整理しようとしている。発想としてかなり気持ちいいです。
今回のJEP 533で目立つのは、例外処理の整理 です。
前のpreviewでは、Joiner.allSuccessfulOrThrow() や anySuccessfulOrThrow()、awaitAllSuccessfulOrThrow() が、サブタスクの失敗時に FailedException を投げていました。
それがPreview 7では、**ExecutionException** を投げるように変わりました。
ExecutionException は、Future.get() でもおなじみの「子タスクが失敗したときに、その失敗を包んで伝える」例外です。つまり、Structured Concurrencyの例外の流れが、既存のJavaの並行処理APIとかなり近づいたわけです。
これは地味に重要です。
新しいAPIって、どうしても「専用のクセ」が出がちなんですが、今回の変更はそのクセを減らしている。学習コストが下がるし、既存コードとの行き来もしやすい。こういう“馴染ませ方”はうまいな、と思います。
記事内の例では、こんな感じです。
try (var scope = StructuredTaskScope.open()) {
Subtask<String> user = scope.fork(() -> findUser(userId));
Subtask<List<Order>> o = scope.fork(() -> fetchOrders(userId));
scope.join();
return new Response(user.get(), o.get());
} catch (ExecutionException e) {
switch (e.getCause()) {
case IOException ioe -> handleIo(ioe);
case TimeoutException te -> handleTimeout(te);
default -> throw e;
}
}
ポイントは、join() で例外を捕まえたあと、getCause() で本当の原因を見るところです。
ExecutionException 自体は「包み紙」で、中身に IOException や TimeoutException が入っている、というイメージですね。
これ、既存のJava開発者にはかなり理解しやすいと思います。
一方で、以前のpreviewで FailedException を使っていた人は、JDK 27へ移行するときに catchを書き換える必要がある ので、そこは注意点です。
次の変更は、少しだけマニアックですが大事です。
StructuredTaskScope と Joiner に、第3の型パラメータ R_X が追加されました。
これは「join() が何の例外を投げうるか」を型として表すためのものです。
以前は Joiner<T, R> だったものが、Joiner<T, R, R_X> になる、という話です。
これを聞くと「型パラメータが増えただけ?」と思うかもしれませんが、ここにはちゃんと意味があります。
Javaの型システムに、**“このjoinはこの種類の例外を投げる”** と明示できるようになるので、ライブラリ作者にとってはかなり誠実な設計です。
個人的には、この変更は「APIをきれいに整えている途中なんだな」という印象を受けました。
最初は多少荒削りでも、previewを重ねながら、例外の扱いを型に落とし込んでいく。これはJavaらしい慎重さでもありますし、かなり好感が持てます。
open() の新しい overload も便利そう3つ目の変更は、使い勝手に効いてきそうです。
StructuredTaskScopeには、設定を受け取れる新しい open() の overload が追加されました。
これにより、デフォルトのjoin policy に対して、timeout や name、thread factory などの設定をまとめて足しやすくなりました。
記事の例では、こんな書き方が紹介されています。
try (var scope = StructuredTaskScope.open(
cfg -> cfg.withTimeout(Duration.ofSeconds(2)).withName("checkout"))) {
scope.fork(() -> fetchCart(userId));
scope.fork(() -> fetchProfile(userId));
scope.join();
}
以前は、デフォルトのfail-fastな挙動に設定を足すために、Joiner を渡したりする必要がありました。
それが少し簡潔になった、というわけです。
こういう改善って、派手ではないけれど実務ではかなり効きます。
フレームワークやAPIって、「できること」より「書きやすさ」 が採用率を左右することが多いんですよね。open() のこの変更は、まさにその方向の改善だと思います。
今回のJEP 533は、記事でもはっきり「再設計ではない」と書かれています。
つまり、APIの骨格はすでに固まりつつあって、今は主に ergonomics(使いやすさ) と typing(型の整合性) を詰めている段階です。
構造的な保証は、引き続き維持されています。たとえば:
ScopedValue のbindingは子タスクに継承されるStructureViolationException は、try-with-resources の外で使ったり、所有者でないスレッドからforkしたりしたときに発生するここは安心材料です。
つまり「便利になったけど、根本の安全装置はそのまま」ということですね。
Structured Concurrencyの狙いは、単に「スレッドを増やす」ことではありません。
むしろ逆で、並行処理を“雑に増やさない”ための枠組み です。
従来の並行処理は、処理ごとにスレッドを飛ばして、結果をあとから集めて、失敗したら別経路で止めて……というふうに、コードがすぐ分散しがちでした。
Structured Concurrencyは、それを「この親処理のための子タスク群です」と明示して、ライフサイクルをきれいに揃えようとします。
これがうまくはまると、
という、かなり実務的なメリットがあります。
正直、並行処理のコードは「動く」だけなら何とかなるけれど、「保守できる」状態にするのが本当に難しい。
その意味で、Structured ConcurrencyはJavaにとってかなり重要な一歩だと思います。
JEP 533の今回の更新は、見た目には地味です。
でも中身はかなり本質的で、例外の流れを既存のJavaの感覚に寄せつつ、型安全性を強め、設定の書き味も改善している。かなり堅実な進化です。
派手な新機能というより、「ちゃんと使えるAPIに育ってきた」 というニュースですね。
こういう成熟の仕方、私はかなり好きです。新しさで驚かせるのではなく、あとで振り返ったときに「結局これが一番使いやすかった」と言われるタイプの進化だからです。
参考: JEP 533 Tightens Exception Handling in Java's Structured Concurrency for JDK 27