テックブログ

仮想スレッドのタイムアウトとキャンセルを設計する

仮想スレッドのタイムアウトとキャンセルを設計する

タイムアウトは「呼び出し側が待つ時間を終えること」、キャンセルは「実行中または未開始のタスクを取り消そうとすること」です。Future.get(timeout, unit)がTimeoutExceptionを返しても、タスクが自動的に停止するわけではありません。実務では、時間切れを検出し、cancel(true)で割り込みを要求し、タスクと利用中のI/Oが割り込みや個別の期限に応じられる構成を組み合わせます。

掲載コードはJava SE 25のAPIを基準にし、プレビュー機能を使いません。待機には動作を再現しやすいThread.sleepを使いますが、本番のHTTPやDBでは、それぞれのクライアントやドライバが提供する接続・読取り・クエリのタイムアウトも設定してください。

1. タイムアウトとキャンセルを分けて考える

1.1 待機終了とタスク終了の違い

Future.get(300, TimeUnit.MILLISECONDS)は、結果を最大300ミリ秒待つ操作です。時間内に結果を取得できなければTimeoutExceptionを送出し、呼び出し側は次の処理へ進めます。ただし、この例外が表すのは結果待ちの期限切れであり、タスクそのものの終了ではありません。

呼び出し側だけが戻り、仮想スレッド上のタスクが外部APIやDBを待ち続けると、利用者には失敗を返した後も接続やメモリが残ります。要求が重なると、古い処理が新しい要求と同じ資源を使い続ける可能性があります。仮想スレッドが軽量でも、タスクが保持する入力データや下流の接続まで無制限に軽くなるわけではありません。

設計時は、少なくとも次の三つを別々に確認します。

境界決めること期限を超えたときの代表例
結果待ち呼び出し側が何秒待つかFuture.get(timeout, unit)を終了する
タスク未完了の処理へ停止をどう伝えるかFuture.cancel(true)で割り込みを要求する
下流I/O接続・読取り・クエリを何秒で諦めるかHTTPクライアントやJDBCのタイムアウトを設定する

この三層を一つの「タイムアウト設定」として扱うと、画面は時間切れになったのにDBクエリだけが残る、といったずれが起きます。待機を終える期限と、処理を片付ける仕組みの両方が必要です。

時間切れを利用者へ返す位置も確認します。Web APIのリクエスト処理でFutureを待つ場合、HTTPサーバやプロキシが先に接続を閉じると、アプリが後から正常結果を得ても利用者へ届けられません。逆に、アプリが先に時間切れを返しても、プロキシの長い期限に頼っているだけではタスクの後片付けになりません。入口から下流までの期限を一覧にし、どこが最初に期限を判定するかを把握します。

また、時間切れは障害原因ではなく、遅延を一定範囲へ閉じ込める制御です。なぜ遅くなったかは別途調査します。接続取得待ち、外部API応答、ロック待ち、CPU処理のどこで時間を使ったかを区間ごとに計測しなければ、FutureのTimeoutExceptionだけを数えても改善箇所は分かりません。

1.2 一つの期限を複数箇所へ配分する

利用者への応答期限が3秒なら、外部APIにも一律3秒を与えればよいとは限りません。アプリ内の変換、接続取得、外部通信、結果の組み立てにも時間が必要です。下流I/Oの期限を応答期限と同じにすると、I/Oが期限いっぱい使った後に後処理を行う余裕がなくなります。

たとえば全体で3秒なら、接続取得500ミリ秒、外部APIの応答2秒、残りを後処理と余裕時間にする考え方があります。実際の値は相手のSLA、通常時の遅延分布、再試行の有無、利用者へ返せる代替結果から決めます。掲載値をそのまま本番へ移すのではなく、計測結果に基づいて調整してください。

複数の下流処理を順番に呼ぶ場合は、各処理が固定の最大時間を使うと全体期限を超えることがあります。処理開始時刻から残り時間を計算し、次の処理へ渡せる時間を減らす方法もあります。期限の伝播は便利ですが、本記事ではリトライや冪等性までは扱わず、Futureと割り込みによる一つのタスクの終了に範囲を限定します。

期限を扱う計測では、時刻の差ではなく経過時間を測る時計を使うことも大切です。System.currentTimeMillis()は端末時刻の補正で値が動く可能性があります。単一JVM内で残り時間を計算するなら、単調増加を目的としたSystem.nanoTime()を使い、開始値との差から経過時間を求めます。ログへ表示する日時と、期限判定に使う経過時間を分けてください。

要件上「最大3秒」と書かれていても、すべての層へ3秒を設定するのではなく、入口の締切時刻から各処理の開始時点で残りを求めると整合を取りやすくなります。残りがすでにゼロなら下流を新たに呼び出さず、時間切れとして戻します。期限後に不要な仕事を増やさないことも、キャンセル設計の一部です。

2. Future.getで結果待ちに期限を設ける

2.1 TimeoutExceptionを結果の種類として扱う

時間付きgetは、正常結果、タスク内の失敗、呼び出し側の割り込み、待機の時間切れを別の例外で通知します。TimeoutExceptionを一般的なExceptionでまとめて捕捉すると、何が起きたかを利用者や運用者へ説明しにくくなります。

次のTimedGet.javaは、2秒かかるタスクを200ミリ秒だけ待つ例です。時間切れを検出した後にcancel(true)を呼び、タスクへ割り込みを要求します。cancelの戻り値と、最終的な取消し状態も表示します。

import java.time.Duration;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class TimedGet {
    public static void main(String[] args) throws Exception {
        try (ExecutorService executor =
                Executors.newVirtualThreadPerTaskExecutor()) {
            Future<String> future = executor.submit(() -> {
                Thread.sleep(Duration.ofSeconds(2));
                return "completed";
            });

            try {
                System.out.println(future.get(200, TimeUnit.MILLISECONDS));
            } catch (TimeoutException e) {
                boolean requested = future.cancel(true);
                System.out.println("timed out, cancel requested=" + requested);
            }

            System.out.println("cancelled=" + future.isCancelled()
                    + ", done=" + future.isDone());
            try {
                future.get();
            } catch (CancellationException e) {
                System.out.println("result is cancelled");
            }
        }
    }
}

Thread.sleepは割り込みに応じてInterruptedExceptionを送出するため、この例のタスクは終了します。Futureは取消し済みになり、取消し後のget()はCancellationExceptionを送出します。isDone()は正常終了だけを表すメソッドではなく、失敗や取消しを含めてタスクが完了状態にある場合もtrueです。

cancelの戻り値も、業務処理が確実に停止した証明ではありません。すでに完了していれば取消しに失敗し、実行中なら取消し状態へ遷移して割り込みを試みます。判定直後に状態が変わる競合もあるため、「cancelがfalseならエラー」「trueなら副作用なし」と単純化しないでください。最終的な業務結果は、対象処理の記録や下流の状態から確認します。

時間切れをcatchした後に同じFutureへ再び無期限のget()を呼ぶと、せっかくの期限が失われます。掲載例の最後のget()は取消し後の例外を説明するためのものです。本番コードでは、取消し結果を観測する目的がない限り、期限切れ後に無期限待機へ戻さないようにします。

2.2 タイムアウト後に残る処理の確認

時間切れのcatch節でエラーを返すだけでは、タスクは実行を続けます。タスクが最終的に完了すれば問題が見えにくい場合もありますが、結果を受け取る相手がいないまま、外部更新や重い計算を続ける可能性があります。

一方で、すべての時間切れに無条件でcancel(true)を使えば安全とも限りません。送金や登録処理の途中で取消しを要求しても、外部サービス側で処理が完了していることがあります。呼び出し側が結果を受け取れなかった状態と、業務処理が行われなかった状態は同じではありません。更新系処理では、処理IDによる結果照会や冪等性など別の設計が必要です。

まず、読取り処理のように中断後の副作用が少ないタスクで、期限切れ時の動作を確認してください。開始・時間切れ・取消し要求・タスク終了を同じ要求IDで追跡し、利用者へ応答した後に処理が残っていないかを観測します。

複数Futureを待つ場合は、一件ずつ同じ長さを待つ実装にも注意します。10件それぞれを最大1秒待てば、最悪10秒になる可能性があります。全体の締切を一つ決め、各getの前に残り時間を再計算するか、完了済みの結果から処理する仕組みを使います。個別期限と全体期限のどちらを保証するのかをコードレビューで説明できるようにします。

部分成功を許す処理では、時間切れになった対象だけを取り消し、すでに成功した結果まで捨てるのかを要件から決めます。全件が揃わなければ意味がない照会と、成功したものだけ表示できる一覧では判断が異なります。FutureのAPIはこの業務判断を行わないため、結果の型に成功、失敗、時間切れ、取消しを表せる状態を持たせる方法もあります。

3. cancel(true)とinterruptの関係を理解する

3.1 取消しは強制終了ではなく停止の要求

Future.cancel(true)は、タスクが未開始なら実行しないようにし、すでに実行中ならそのスレッドへ割り込みを送って停止を試みます。Javaには任意の処理を安全な位置で即座に強制終了する一般的な仕組みはありません。cancel(true)が呼ばれた瞬間に、どのコードでも必ず止まるわけではありません。

タスクがThread.sleep、割り込み可能な待機、または割り込み状態の確認を行う場合は、停止要求に応じられます。反対に、長いCPUループが割り込みを一度も確認しなければ、Futureが取消し状態でも計算を続ける場合があります。利用ライブラリが割り込みをどのように扱うかも確認が必要です。

cancel(false)は、すでに実行中のタスクへ割り込みを送りません。未開始タスクの取消しには使えますが、時間切れになった実行中処理の終了を期待する用途とは性質が異なります。引数名のmayInterruptIfRunningが示すように、trueも「割り込んでよい」という停止の試行です。

3.2 割り込みに応じるタスクの作り方

CPU処理を長く続けるタスクでは、区切りごとにThread.currentThread().isInterrupted()を確認し、作業を終了できるようにします。確認頻度が低すぎると停止が遅れ、高すぎるとループの負荷になります。業務上の処理単位や一定件数ごとの境界で確認する方法が分かりやすくなります。

static long countUntilInterrupted(long upperLimit) {
    long count = 0;
    for (long i = 0; i < upperLimit; i++) {
        if (Thread.currentThread().isInterrupted()) {
            System.out.println("interrupted at " + i);
            return count;
        }
        count += i % 7;
    }
    return count;
}

途中終了が可能な処理では、返り値だけで正常完了と中断を区別できるようにします。上の簡略例のように部分値をそのまま正常結果として返すと、呼び出し側が完全な計算結果と誤認する可能性があります。実務では中断を例外や状態付き結果として上位へ通知し、途中生成物を公開しない、または不完全であることを明示します。

ファイルや接続などを開いている場合は、finallyやtry-with-resourcesで解放します。割り込みに応じて途中で戻る経路でも、正常終了時と同じように後片付けが行われるかを試験してください。

割り込み状態の確認にはisInterrupted()とThread.interrupted()があります。前者は状態を確認してもクリアしませんが、後者は現在のスレッドの状態を返した後にクリアします。単に停止要求を調べたいループでThread.interrupted()を使うと、上位が確認する前に情報を消す可能性があります。状態を消す意図がなければisInterrupted()を選びます。

外部ライブラリが割り込みを捕捉して別例外へ変換する場合もあります。ライブラリの仕様、例外のcause、実際のタスク終了時間を確認してください。API名に「timeout」が含まれていても、下位処理の取消しまで行うとは限りません。Future側、タスク側、ライブラリ側を一続きで試験する必要があります。

4. InterruptedExceptionを正しく扱う

4.1 業務エラーと停止要求の区別

InterruptedExceptionは、入力不正や通信先の業務エラーとは異なり、そのスレッドの待機を中断する要求を表します。これを捕捉してログだけ出し、同じ処理を続けると、cancel(true)を呼んでもタスクが停止しない構成になります。

たとえば次のコードは避けます。

try {
    Thread.sleep(1_000);
} catch (InterruptedException e) {
    System.out.println("ignored");
}
doNextStep(); // 停止要求を無視して続行してしまう

InterruptedExceptionが送出されると、通常は割り込み状態がクリアされます。下位メソッドで例外を捕捉し、チェック例外を宣言できないなどの理由でそのまま再送出しない場合は、Thread.currentThread().interrupt()で状態を復元して処理を終了します。

4.2 上位へ伝えるか状態を復元するか

最も分かりやすい方法は、可能であればInterruptedExceptionを上位へ送出することです。上位のタスク境界で中断として扱い、通常の業務結果へ変換しません。

static String loadCustomer() throws InterruptedException {
    Thread.sleep(1_000);
    return "customer-101";
}

インターフェースの制約などでチェック例外を外へ出せない場合は、割り込み状態を復元して終了します。

static String loadCustomerWithoutCheckedException() {
    try {
        Thread.sleep(1_000);
        return "customer-101";
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new IllegalStateException("customer load was interrupted", e);
    }
}

どの例外型へ包むかはアプリの契約によります。重要なのは、正常な「顧客なし」へ置き換えず、停止要求だったことを失わないことです。ログには要求ID、期限、取消し要求の有無を残し、同じ例外を複数階層で重複出力しない方針も決めます。

呼び出し側がFuture.getの待機中にInterruptedExceptionを受けた場合も、対象タスクが自動的に取り消されたとは限りません。呼び出し側の停止処理で、そのFutureを取り消す責任があるか、Executor全体の所有者へ委ねるかを決めます。共有Executorを閉じてほかの要求まで止めるのではなく、自分が投入したタスクの参照を使って処理します。

複数タスクを所有する処理なら、途中で呼び出し側が割り込まれたときに未完了Futureを列挙し、必要なものへ取消しを要求する方法があります。その際も、取消し後の片付けを無期限に待たず、停止処理自体の期限を定めます。通常処理の期限だけでなく、障害時の後片付けに使える時間も運用設計へ含めてください。

5. 下流I/Oにも個別のタイムアウトを設定する

5.1 Futureの期限だけでは接続待ちを止められない

Futureの時間付き待機は、呼び出し側の待ち時間を制限します。しかし、利用しているHTTPクライアント、JDBCドライバ、ファイルシステムの操作が割り込みへどのように応じるかは別です。割り込みだけで速やかに戻ると仮定せず、各APIが提供するタイムアウトを設定します。

HTTPでは、接続確立までの期限と、応答を待つ期限が分かれていることがあります。DBでは、接続プールから接続を得る期限、ソケットの期限、SQLの実行期限が別に存在します。一つだけ設定して「DBは3秒で必ず戻る」と考えないでください。

待機箇所確認する設定例設定がない場合の問題
接続プール接続取得タイムアウト空きが出るまで要求が滞留する
HTTP接続connect timeout到達不能な接続先を長く待つ
HTTP応答request/read timeout接続後に応答が来ない状態が残る
SQL実行query timeout重いクエリやロック待ちが残る
Future結果get(timeout, unit)呼び出し側が無期限に待つ

名称と厳密な動作は利用製品によって異なります。設定名が似ていても対象範囲が違うため、公式ドキュメントと実際の障害試験で確認します。

JDBCのStatement.setQueryTimeoutのように秒単位の設定もあれば、HTTPクライアントのようにDurationで指定する設定もあります。ゼロが「無期限」を表す製品もあるため、単位と初期値を必ず確認します。設定ファイルの値だけでなく、実際に生成されたクライアントへ反映されているかを起動ログや試験で確かめます。

接続プールの取得タイムアウトが1秒、クエリタイムアウトが2秒なら、単純には最大3秒近く使う可能性があります。さらにDNS、TLS、トランザクション終了、接続返却の時間もあります。設定項目を一つずつ見るのではなく、一要求の時系列に並べると、全体期限との矛盾を見つけやすくなります。

5.2 全体期限より短い下流期限の設計

下流I/Oの期限は、通常、処理全体の期限より短くします。時間切れを受け取った後に、接続の解放、結果の記録、利用者への応答を行う余裕を残すためです。複数の下流を順番に呼ぶ場合は、単純な合計が全体期限を超えないかも確認します。

一方、短すぎる期限は通常の揺らぎまで障害として扱い、再試行や負荷増加を招きます。平均値だけでなく、p95やp99など遅い側の分布、ピーク時間、接続取得待ちを観測して決めます。値を変えたときは、時間切れ件数だけでなく、下流側の継続処理や接続使用数も確認してください。

期限切れ後に再試行する場合は、最初の処理が本当に終わったか不明なことがあります。本記事のcancel(true)を「実行されなかった証明」として使わず、更新系の再試行では業務IDや重複防止の設計を別途行います。

6. 正常・時間切れ・割り込みを分けて試験する

6.1 再現可能な三つのケース

最初に、同じタスクで待機時間だけを変え、正常完了と時間切れを再現します。次に、時間切れ時にcancel(true)を呼ぶ場合と呼ばない場合を比較し、タスク終了ログの時刻を確認します。最後に、呼び出し側自身が割り込まれた場合を試し、InterruptedExceptionが業務エラーと別に扱われることを確認します。

試験では、future.isDone()だけで成功を判定しません。取消し後もisDone()はtrueになるため、isCancelled()、get()の結果または例外、タスク側の終了記録を組み合わせます。外部I/Oを使う試験では、クライアント側の応答だけでなく、接続数やサーバ側の処理が残っていないかも見ます。

1. タスク開始を要求ID付きで記録する
2. 正常完了なら結果と所要時間を記録する
3. 時間切れなら待機期限と取消し要求を記録する
4. タスク側で割り込み検出と資源解放を記録する
5. 応答後に同じ要求IDの処理が残っていないか確認する

この順序にすると、呼び出し側が戻った時刻と、タスクが実際に終わった時刻の差を確認できます。

試験用タスクに、割り込みを正しく扱う版と意図的に無視する版を用意すると、cancel(true)が強制停止ではないことを確認できます。無視する版は本番へ残さず、テスト専用にします。取消し要求後もカウンタが増え続けることを観測すれば、Futureの状態だけでは実処理の停止を証明できない理由が分かります。

また、期限の境界値も試します。通常は200ミリ秒で終わるタスクに200ミリ秒の期限を設定すると、端末負荷やスケジューリングの揺らぎで結果が変わります。本番では通常値と期限の間に余裕を持たせ、試験では期限直前、期限超過、即時取消しを別ケースにします。

6.2 本番投入前の確認表

本番に近い試験では、正常時だけでなく、接続先が応答しない、接続プールが枯渇する、ロック待ちになる、停止処理中に期限を迎えるケースを用意します。ネットワーク断を模擬する場合は、単に存在しないURLへ向けるだけでなく、接続はできるが応答しないケースも分けます。

確認項目合格条件
結果待ち指定期限で呼び出し側が戻り、時間切れを識別できる
取消し要求時間切れ時に必要なタスクへだけcancel(true)を呼ぶ
タスク終了割り込み可能な待機と計算ループが停止要求に応じる
例外処理InterruptedExceptionを正常値へ変換せず、上位へ伝える
資源解放接続、ファイル、permitなどが途中終了でも解放される
下流期限接続取得、通信、クエリの期限が全体期限と整合する
観測要求IDから開始、時間切れ、取消し、実終了を追跡できる
更新処理結果不明を未実行と断定せず、重複実行を防ぐ別設計がある

仮想スレッドに変えたことで、待機を含む同期コードは書きやすくなります。しかし、期限の責任までは自動化されません。どの層がいつ待機を終え、誰が残った処理へ停止を伝え、どの資源を解放するかをコードと運用試験の両方で確認してください。

まとめ

Future.get(timeout, unit)は呼び出し側の結果待ちを制限しますが、TimeoutExceptionだけではタスクは停止しません。時間切れ後に処理を残さないためには、業務上の安全性を確認したうえでcancel(true)により割り込みを要求し、タスクがInterruptedExceptionや割り込み状態に応じて終了できるようにします。

また、Futureの期限と、HTTP・DB・接続プールなど下流I/Oの期限は別に設定します。正常、時間切れ、取消し、呼び出し側の割り込みを分けて試験し、利用者への応答後にタスクや接続が残っていないかを観測することが、実務でのタイムアウト設計の基本です。

参考リンク