CodeGym /课程 /JAVA 25 SELF /贯穿调用栈的任务取消与超时

贯穿调用栈的任务取消与超时

JAVA 25 SELF
第 58 级, 课程 1
可用

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();

// ... 后续
worker.interrupt();

哪些地方会自动响应中断标志?

  • 可能阻塞的方法(sleep、wait、join、阻塞数据结构的操作)在中断时会抛出 InterruptedException。
  • 在其他情况下(例如计算循环中),需要手动检查 isInterrupted()。

模式:“设置标志——尽快退出”

  1. 在调用方代码中:thread.interrupt()
  2. 在任务中:周期性检查 Thread.currentThread().isInterrupted()
  3. 必要时——正确释放资源并结束。

常见误区:期待 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("任务因取消而结束。");
});

// ... 后续
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 因取消而结束。");
});

// ... 后续
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:成组任务的取消

结构化并发与取消

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,子任务可能会“悬挂”不退出。

1
任务
JAVA 25 SELF, 第 58 级, 课程 1
已锁定
深空探测器监控:来自宇宙深处的信号 🛰️
深空探测器监控:来自宇宙深处的信号 🛰️
1
任务
JAVA 25 SELF, 第 58 级, 课程 1
已锁定
行动“关键期限”:中和网络威胁 ⏱️
行动“关键期限”:中和网络威胁 ⏱️
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION