タイムアウト制御は「いつまでも待たない」ための安全装置
業務システムで一番怖いのは、「固まっているのに気づかない」状態です。
外部 API が返ってこない、DB が詰まっている、重い処理が終わらない──こういうときに、延々と待ち続けるコードは、スレッドを占有し、システム全体を巻き込んで止めてしまいます。
そこで必要になるのが「タイムアウト制御」です。
「この処理は最大でも 3 秒まで」「5 秒待っても終わらなければ諦める」といったルールをコードに埋め込むことで、システムを「止まらない設計」にしていきます。
ここでは、初心者向けに「Java でタイムアウトをどう実装するか」「実務で使えるユーティリティの形」を、例題付きで丁寧に解説します。
まず押さえるべき前提:ブロッキング処理とタイムアウト
「待つ処理」には必ずタイムアウトの設計が必要
タイムアウトを考えるべき処理は、だいたい次のようなものです。
外部 API 呼び出し(HTTP クライアントなど)
DB アクセス(接続・クエリ)
メッセージキューの受信
ロック取得
重い計算を別スレッドで実行して結果を待つ
これらはすべて「待つ」処理です。
「いつまで待つか」を決めずに書くと、「たまたま遅い」ではなく「永遠に返ってこない」ケースで詰みます。
タイムアウト制御の本質は、「待つことに上限を決める」ことです。
そして、その上限を超えたときに「どう振る舞うか」(例外を投げる・デフォルト値を返す・キャンセルする)を決めるのが設計の肝になります。
Future と ExecutorService を使った基本的なタイムアウト制御
別スレッドで処理を実行し、一定時間だけ結果を待つ
Java では、ExecutorService と Future を使うことで、「処理を別スレッドで走らせて、結果をタイムアウト付きで待つ」ことができます。
まずは素朴な例から見てみましょう。
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(何秒以内に応答するか)」を守るための設計として有効です。
大事なのは、「どのレイヤーで、どのタイムアウトを、何秒に設定するか」をチームで言語化しておくことです。
ユーティリティは、そのポリシーをコードに落とし込むための道具に過ぎません。
まとめ:タイムアウト制御で初心者が身につけるべき感覚
タイムアウト制御は、「なんとなく時間を測る」ことではなく、「どの処理に、どんな上限時間を設け、その上限を超えたらどう振る舞うか」を設計する行為です。
押さえるべきポイントは次の通りです。
ブロッキングする処理には必ず「いつまで待つか」の設計が必要。ExecutorService+Future.get(timeout, unit) で、「別スレッドで実行して、一定時間だけ結果を待つ」パターンを身につける。
タイムアウトしたら TimeoutException で呼び出し側に伝え、future.cancel(true) で中断を試みる。
タイムアウト付き実行をユーティリティ化し、例外処理・割り込み処理・ラップ処理を一箇所に集約する。
外部ライブラリのタイムアウト設定と、自前のタイムアウト制御の役割分担を意識する。
