1. Thread.interrupt() 與協作式取消
在真實應用中,任務可能執行很久,有時甚至會「卡住」——例如處理網路、檔案或外部服務時。使用者可能會取消操作,伺服器可能會中止請求處理,或是共同逾時已到。如果不知道如何正確取消任務,應用程式會卡頓、白白浪費資源,且對外部事件反應不佳。
關鍵觀念:取消必須是協作式的——任務要自行檢查是否被要求結束,並妥善釋放資源。
Thread.interrupt() 的運作方式
每個執行緒都有一個「中斷」旗標。當你呼叫 thread.interrupt() 時,這個旗標會被設為 true。執行緒本身不會因此被「殺掉」,而是必須自行檢查狀態並結束:例如定期呼叫 Thread.currentThread().isInterrupted() 並妥善退出。
範例:
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 執行中...
try {
Thread.sleep(100); // 可能被中斷
} catch (InterruptedException e) {
// 中斷旗標會被清除,但我們可以再次將自己設為中斷
Thread.currentThread().interrupt();
break;
}
}
System.out.println("執行緒因中斷而結束。");
});
worker.start();
// ... later
worker.interrupt();
哪些情況會自動處理中斷旗標?
- 可能阻塞的方法(sleep、wait、join、以及阻塞資料結構的操作)在中斷時會拋出 InterruptedException。
- 其他情況(例如計算迴圈)需要手動檢查 isInterrupted()。
模式:「設定旗標——儘快退出」
- 在呼叫端:thread.interrupt()
- 在任務中:定期檢查 Thread.currentThread().isInterrupted()
- 必要時——妥善釋放資源並結束。
常見錯誤:期待 interrupt() 會立刻「殺死」執行緒。不會——它只是個訊號;任務必須自行回應。
2. Future.cancel()、CancellationException 與任務取消
Future.cancel 的運作方式
當你透過 ExecutorService.submit() 啟動任務時,會取得一個 Future 物件。它有方法 cancel(boolean mayInterruptIfRunning):
- 如果任務尚未開始——它將不會被啟動。
- 如果任務已在執行且 mayInterruptIfRunning == true——會對執行該任務的執行緒呼叫 interrupt()。
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future = executor.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 長時間工作
}
System.out.println("任務因取消而結束。");
});
// ... later
future.cancel(true); // 請求取消任務
任務實際上發生了什麼
透過 Future 的取消並不是一個「殺死執行緒」的魔法按鈕,本質上是有禮貌地呼叫 Thread.interrupt()。如果任務能正確檢查中斷旗標——它會平順地結束。否則——會繼續執行直到自然完成。
在取消之後呼叫 future.get(),你會得到 CancellationException——提醒你該任務已被取消。
3. CompletableFuture:取消、逾時與鏈結
取消 CompletableFuture
CompletableFuture 也有 cancel(boolean)。如果任務尚未完成,它會被取消,之後的所有處理器(如 thenApply、thenAccept 等)都不會被呼叫。
CompletableFuture<Void> cf = CompletableFuture.runAsync(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 執行中...
}
System.out.println("CF 因取消而結束。");
});
// ... later
cf.cancel(true);
逾時:orTimeout 與 completeOnTimeout
- orTimeout(timeout, unit)——若未在時間內完成,將以 TimeoutException 結束 CompletableFuture。
- completeOnTimeout(value, timeout, unit)——若未在時間內完成,將以指定值完成。
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(5000); } catch (InterruptedException e) {}
return "OK";
});
cf.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> "TIMEOUT")
.thenAccept(System.out::println); // 2 秒後: "TIMEOUT"
在鏈結中傳遞取消
如果取消「上層」的 CompletableFuture,後續步驟將不會被呼叫。但在使用 thenCompose 啟動內部非同步操作時,取消不會自動「向上」傳遞——必須在設計上明確處理(檢查狀態、取消子任務、使用共同截止時間)。
小心 thenCompose 搭配自訂 Executor!請確認內部任務能回應中斷/取消,且/或能取得共同逾時設定。
4. StructuredTaskScope:成組任務的取消
Structured Concurrency 與取消
StructuredTaskScope(Java 21+)能啟動一組任務,並將其生命週期當作整體來管理。如果其中一個任務發生錯誤或逾時——其餘任務會自動被取消。
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> fetchData1());
Future<String> f2 = scope.fork(() -> fetchData2());
scope.join(); // 等待所有任務完成
scope.throwIfFailed(); // 若有任一失敗——丟出例外
String result = f1.resultNow() + f2.resultNow();
System.out.println(result);
}
- 若任一任務失敗——scope 會取消所有其他任務。
- 若逾時(透過 scope.joinUntil(deadline))——scope 會取消所有任務。
終止策略
- ShutdownOnFailure——在第一個錯誤發生時取消所有任務。
- ShutdownOnSuccess——一旦有任務成功,其餘任務即被取消。
5. 實務:安全地取消長時間操作
範例:取消阻塞 IO
如果任務阻塞於檔案或網路讀取,中斷執行緒不一定有效——有些 IO 操作不會回應 interrupt。在較新的 API(如 NIO、AsynchronousFileChannel)中,中斷支援較好,但仍非處處可用。
建議:
- 如果需要取消,盡量使用非阻塞 IO。
- 對於阻塞 IO——在 API 層級設定逾時(例如 Socket.setSoTimeout)。
- 對於非同步任務——使用 Future.cancel,並正確回應中斷。
範例:取消對佇列/柵欄的等待
許多同步器(BlockingQueue.take()、CountDownLatch.await()、CyclicBarrier.await())在中斷時會拋出 InterruptedException。在處理時捕捉例外,必要時恢復旗標,並正確結束任務。
6. 模式「time‑budget」:一組操作的共同截止時間
在複雜的應用中,常需要為一組操作設定共同的逾時。例如使用者只願意等待 2 秒、但內部需要進行 3 個網路請求——它們都必須符合共同的截止時間。
如何沿著呼叫堆疊向下傳遞截止時間?
- 將截止時間物件(例如 Instant 的 deadline)傳入所有可能阻塞的方法。
- 在每個方法中計算剩餘時間:Duration.between(Instant.now(), deadline)。
- 使用該剩餘時間設定阻塞操作的逾時(await(timeout)、poll(timeout)、orTimeout(timeout))。
Instant deadline = Instant.now().plusSeconds(2);
void doWork(Instant deadline) throws TimeoutException, InterruptedException {
Duration left = Duration.between(Instant.now(), deadline);
if (left.isNegative() || left.isZero()) throw new TimeoutException();
// 使用 left 作為逾時
queue.poll(left.toMillis(), TimeUnit.MILLISECONDS);
}
Scoped Values / 上下文
在 Java 21+ 中,可以使用 Scoped Values 沿著呼叫堆疊傳遞截止時間,而不必在每個方法中顯式傳遞。
7. Structured Concurrency:在失敗/逾時時取消整個 scope
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> fetchData1());
Future<String> f2 = scope.fork(() -> fetchData2());
boolean completed = scope.joinUntil(Instant.now().plusSeconds(2));
if (!completed) {
scope.shutdown();
throw new TimeoutException("截止時間已到!");
}
scope.throwIfFailed();
// ...
}
- 若截止時間已到——scope 會取消所有任務。
- 若有任務失敗——其餘任務會自動被取消。
8. 使用取消與逾時時的常見錯誤
錯誤 1:以為 interrupt() 會立刻結束執行緒。 事實上它只是訊號——任務必須自行檢查狀態並妥善結束。
錯誤 2:未在長迴圈中檢查 isInterrupted()。 若不檢查中斷旗標,任務即使被要求結束仍會一直執行。
錯誤 3: Future.cancel() 不會帶來實際的取消,若任務不回應中斷。 如果任務「充耳不聞」,cancel() 無濟於事。
錯誤 4:未將逾時沿著呼叫堆疊向下傳遞。 若不在所有方法中傳遞截止時間,內部操作可能會「卡住」得比預期更久。
錯誤 5:在 thenCompose 鏈結中的 CompletableFuture 取消不會自動傳遞。 如果取消「上層」 future,內部任務可能仍會繼續工作——請顯式處理取消。
錯誤 6: StructuredTaskScope 未被關閉(缺少 try‑with‑resources)。 若未關閉 scope,子任務可能會持續「懸掛」。
GO TO FULL VERSION