Java Tips | 基本ユーティリティ:タイムアウト制御

Java Java
スポンサーリンク

タイムアウト制御は「いつまでも待たない」ための安全装置

業務システムで一番怖いのは、「固まっているのに気づかない」状態です。
外部 API が返ってこない、DB が詰まっている、重い処理が終わらない──こういうときに、延々と待ち続けるコードは、スレッドを占有し、システム全体を巻き込んで止めてしまいます。

そこで必要になるのが「タイムアウト制御」です。
「この処理は最大でも 3 秒まで」「5 秒待っても終わらなければ諦める」といったルールをコードに埋め込むことで、システムを「止まらない設計」にしていきます。

ここでは、初心者向けに「Java でタイムアウトをどう実装するか」「実務で使えるユーティリティの形」を、例題付きで丁寧に解説します。


まず押さえるべき前提:ブロッキング処理とタイムアウト

「待つ処理」には必ずタイムアウトの設計が必要

タイムアウトを考えるべき処理は、だいたい次のようなものです。

外部 API 呼び出し(HTTP クライアントなど)
DB アクセス(接続・クエリ)
メッセージキューの受信
ロック取得
重い計算を別スレッドで実行して結果を待つ

これらはすべて「待つ」処理です。
「いつまで待つか」を決めずに書くと、「たまたま遅い」ではなく「永遠に返ってこない」ケースで詰みます。

タイムアウト制御の本質は、「待つことに上限を決める」ことです。
そして、その上限を超えたときに「どう振る舞うか」(例外を投げる・デフォルト値を返す・キャンセルする)を決めるのが設計の肝になります。


Future と ExecutorService を使った基本的なタイムアウト制御

別スレッドで処理を実行し、一定時間だけ結果を待つ

Java では、ExecutorServiceFuture を使うことで、「処理を別スレッドで走らせて、結果をタイムアウト付きで待つ」ことができます。

まずは素朴な例から見てみましょう。

import java.util.concurrent.*;

public class TimeoutBasic {

    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newSingleThreadExecutor();

        Callable<String> task = () -> {
            Thread.sleep(5000); // 5秒かかる重い処理
            return "done";
        };

        Future<String> future = executor.submit(task);

        try {
            String result = future.get(3, TimeUnit.SECONDS); // 最大3秒だけ待つ
            System.out.println("結果: " + result);
        } catch (TimeoutException e) {
            System.out.println("タイムアウトしました");
            future.cancel(true); // 処理を中断しようと試みる
        } finally {
            executor.shutdownNow();
        }
    }
}
Java

ここで重要なポイントは三つあります。

一つ目は、future.get(timeout, unit) を使うと、「指定時間だけ結果を待つ」ことができるということです。
時間内に処理が終われば結果が返り、終わらなければ TimeoutException が投げられます。

二つ目は、タイムアウトしたときに future.cancel(true) を呼んでいることです。
これは「その処理を実行しているスレッドに割り込みをかける」操作で、処理側が割り込みをきちんと扱っていれば、中断されます。

三つ目は、executor.shutdownNow() でスレッドプールを終了していることです。
サンプルでは単純化していますが、実務ではアプリ全体で共有する Executor を使うことが多く、終了タイミングも設計の一部になります。


実務で使える「タイムアウト付き実行ユーティリティ」の形

戻り値ありの処理をタイムアウト付きで実行する

先ほどのパターンをユーティリティ化してみます。

import java.util.concurrent.*;
import java.util.function.Supplier;

public final class TimeoutExecutor {

    private final ExecutorService executor;

    public TimeoutExecutor(ExecutorService executor) {
        this.executor = executor;
    }

    public <T> T runWithTimeout(
            Supplier<T> action,
            long timeout,
            TimeUnit unit
    ) throws TimeoutException {

        Future<T> future = executor.submit(action::get);
        try {
            return future.get(timeout, unit);
        } catch (TimeoutException e) {
            future.cancel(true); // タイムアウト時に中断を試みる
            throw e;
        } catch (ExecutionException e) {
            // 中で投げられた例外をそのまま投げ直す
            Throwable cause = e.getCause();
            if (cause instanceof RuntimeException re) {
                throw re;
            }
            throw new RuntimeException(cause);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Interrupted while waiting", e);
        }
    }
}
Java

使う側はこうなります。

ExecutorService executor = Executors.newCachedThreadPool();
TimeoutExecutor timeoutExecutor = new TimeoutExecutor(executor);

try {
    String result = timeoutExecutor.runWithTimeout(
            () -> callExternalApi(),
            3,
            TimeUnit.SECONDS
    );
    System.out.println("成功: " + result);
} catch (TimeoutException e) {
    System.out.println("API が3秒以内に返ってこなかった");
}
Java

ここで深掘りしたいのは、次の点です。

Supplier<T> を受け取ることで、「任意の処理」をタイムアウト付きで実行できる汎用ユーティリティになっている。
TimeoutException はそのまま呼び出し側に投げ、タイムアウトしたことを明示的に扱えるようにしている。
ExecutionException の中に包まれた本来の例外(cause)を取り出して投げ直すことで、「中で何が起きたか」が分かりやすくなっている。
InterruptedException をキャッチしたときに、割り込みフラグを復元している。

このユーティリティを一つ用意しておくだけで、「タイムアウト付きで実行したい処理」をどこからでも同じ書き方で扱えるようになります。


戻り値がない処理(void)をタイムアウト付きで実行する

Runnable を使ったバージョン

戻り値が不要な処理(例: 「通知を送るだけ」「ログを吐くだけ」)をタイムアウト付きで実行したい場合は、Runnable を使ったオーバーロードを用意しておくと便利です。

public void runWithTimeout(
        Runnable action,
        long timeout,
        TimeUnit unit
) throws TimeoutException {

    runWithTimeout(() -> {
        action.run();
        return null;
    }, timeout, unit);
}
Java

使う側はこうなります。

try {
    timeoutExecutor.runWithTimeout(
            () -> sendNotification(),
            2,
            TimeUnit.SECONDS
    );
} catch (TimeoutException e) {
    System.out.println("通知送信が2秒以内に終わらなかった");
}
Java

こうしておくと、「戻り値あり」「戻り値なし」の両方を同じユーティリティで扱えます。
呼び出し側のコードも、「この処理はタイムアウト付きで実行される」という意図がメソッド名からはっきり読み取れます。


「タイムアウトしたらどうするか」を設計する

タイムアウトは「例外」で伝えるのが基本

タイムアウトは、「普通の失敗」とは少し性質が違います。
「処理が遅すぎた」という意味であり、「処理の中身が間違っていた」とは限りません。

そのため、タイムアウトは専用の例外(TimeoutException など)で呼び出し側に伝えるのが基本です。
呼び出し側は、「タイムアウトなら再試行する」「タイムアウトならデフォルト値を返す」「タイムアウトならユーザーに『混み合っています』と表示する」といった分岐を書けます。

タイムアウト後に「処理をどう扱うか」

タイムアウトしたとき、内部の処理はまだ動いている可能性があります。
future.cancel(true) で割り込みをかけても、処理側が割り込みを無視していれば止まりません。

実務では、次のような方針を組み合わせて設計します。

処理側は、Thread.interrupted()InterruptedException をきちんと扱い、「割り込みが来たら早めに終了する」ように書く。
タイムアウトしたら、呼び出し側は「結果は信用しない」と決める(たとえ後から成功しても無視する)。
必要に応じて、「タイムアウトした処理が後から成功した場合の後始末」(例えば重複登録の防止)を別途設計する。

ここが「タイムアウト制御の難所」ですが、まずは「タイムアウトしたら結果は使わない」というシンプルな前提から始めるとよいです。


外部ライブラリやクライアントの「タイムアウト設定」との違い

HTTP クライアントや JDBC には「専用のタイムアウト」がある

例えば、HTTP クライアント(HttpClient, OkHttp, RestTemplate など)や JDBC ドライバには、すでに「接続タイムアウト」「読み取りタイムアウト」「クエリタイムアウト」といった設定が用意されています。

これらは「そのライブラリ内部での待ち時間の上限」を制御するもので、Future ベースのタイムアウトとは別レイヤーです。

実務では、まずは「ライブラリが提供しているタイムアウト設定」を正しく使うのが優先です。
そのうえで、「アプリ全体として、この処理は最大何秒まで許容するか」という観点で、今回のようなユーティリティを重ねることがあります。

二重のタイムアウトをどう考えるか

例えば、HTTP クライアントに「読み取りタイムアウト 5 秒」を設定しつつ、アプリ側で「全体として 3 秒で諦める」タイムアウトをかける、という構成もあり得ます。

この場合、アプリ側の 3 秒タイムアウトが先に発動し、HTTP クライアントは「まだ待てる」と思っていても、結果は捨てられます。
これは「アプリ全体の SLA(何秒以内に応答するか)」を守るための設計として有効です。

大事なのは、「どのレイヤーで、どのタイムアウトを、何秒に設定するか」をチームで言語化しておくことです。
ユーティリティは、そのポリシーをコードに落とし込むための道具に過ぎません。


まとめ:タイムアウト制御で初心者が身につけるべき感覚

タイムアウト制御は、「なんとなく時間を測る」ことではなく、「どの処理に、どんな上限時間を設け、その上限を超えたらどう振る舞うか」を設計する行為です。

押さえるべきポイントは次の通りです。

ブロッキングする処理には必ず「いつまで待つか」の設計が必要。
ExecutorServiceFuture.get(timeout, unit) で、「別スレッドで実行して、一定時間だけ結果を待つ」パターンを身につける。
タイムアウトしたら TimeoutException で呼び出し側に伝え、future.cancel(true) で中断を試みる。
タイムアウト付き実行をユーティリティ化し、例外処理・割り込み処理・ラップ処理を一箇所に集約する。
外部ライブラリのタイムアウト設定と、自前のタイムアウト制御の役割分担を意識する。

タイトルとURLをコピーしました