跳到正文
hello world

39. orTimeout 和 completeOnTimeout:异步任务超时怎么处理

发布于阅读量 0

39. orTimeout 和 completeOnTimeout:异步任务超时怎么处理

前面学习 Future 时,用过带超时时间的 get()

future.get(30, TimeUnit.SECONDS);

它解决的是:

当前线程最多等待多久。

到了 CompletableFuture 这里,Java 还提供了两个更适合异步流程的超时方法:

orTimeout(...)
completeOnTimeout(...)

它们都能给异步任务设置超时时间,但超时后的处理方式不同:

orTimeout:超时后,让任务以异常状态完成。

completeOnTimeout:超时后,返回一个默认结果。

我现在这样记:

超时算失败,用 orTimeout。

超时给默认值,用 completeOnTimeout。

先说明版本要求

orTimeout()completeOnTimeout() 是 Java 9 加入的方法。

如果项目使用 Java 8,就不能直接调用这两个方法,需要通过其他方式实现超时控制。

这套练习如果使用 Java 17 或更高版本,可以直接使用。


orTimeout:超时后抛出异常

基本写法如下:

CompletableFuture<String> future = CompletableFuture
        .supplyAsync(() -> {
            sleep(5000);
            return "PDF 处理成功";
        })
        .orTimeout(3, TimeUnit.SECONDS);

这里异步任务需要 5 秒完成,但超时时间只设置了 3 秒。

因此,3 秒以后,这个 CompletableFuture 会以异常状态完成。

调用:

future.join();

会抛出 CompletionException,其内部原因通常是:

TimeoutException

orTimeout 完整示例

新建类:

com.succos.completablefuture.OrTimeoutDemo

代码如下:

package com.succos.completablefuture;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class OrTimeoutDemo {

    public static void main(String[] args) {

        CompletableFuture<String> future = CompletableFuture
                .supplyAsync(() -> {

                    System.out.println(Thread.currentThread().getName()
                            + " 开始处理 PDF");

                    sleep(5000);

                    System.out.println(Thread.currentThread().getName()
                            + " PDF 处理完成");

                    return "output/test-watermark.pdf";
                })
                .orTimeout(3, TimeUnit.SECONDS);

        try {
            String result = future.join();

            System.out.println("处理结果:" + result);

        } catch (CompletionException e) {

            Throwable cause = getRootCause(e);

            if (cause instanceof TimeoutException) {
                System.out.println("PDF 处理超时");
            } else {
                System.out.println("PDF 处理失败:"
                        + cause.getMessage());
            }
        }
    }

    private static Throwable getRootCause(Throwable throwable) {

        Throwable cause = throwable;

        while (cause.getCause() != null) {
            cause = cause.getCause();
        }

        return cause;
    }

    private static void sleep(long millis) {

        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("任务被中断", e);
        }
    }
}

因为任务耗时 5 秒,而超时时间是 3 秒,所以最终会进入异常处理。

可能输出:

ForkJoinPool.commonPool-worker-1 开始处理 PDF
PDF 处理超时

orTimeout 不会直接返回默认结果

调用:

.orTimeout(3, TimeUnit.SECONDS)

只是让异步任务在超过指定时间后异常完成。

它不会自动返回:

处理失败;
默认路径;
空字符串;
失败结果对象。

如果想把超时异常转换成业务结果,还需要配合:

exceptionally(...)

或者:

handle(...)

例如:

CompletableFuture<PdfTaskResult> future = CompletableFuture
        .supplyAsync(() -> {

            String targetPath = addWatermark(file);

            return PdfTaskResult.success(
                    file.getName(),
                    targetPath
            );

        }, pdfExecutor)
        .orTimeout(30, TimeUnit.SECONDS)
        .exceptionally(ex -> {

            Throwable cause = getRootCause(ex);

            if (cause instanceof TimeoutException) {
                return PdfTaskResult.fail(
                        file.getName(),
                        "PDF 处理超时"
                );
            }

            return PdfTaskResult.fail(
                    file.getName(),
                    cause.getMessage()
            );
        });

这样无论是普通异常还是超时异常,最后都能返回一个 PdfTaskResult


completeOnTimeout:超时后返回默认结果

completeOnTimeout() 的基本写法如下:

CompletableFuture<String> future = CompletableFuture
        .supplyAsync(() -> {
            sleep(5000);
            return "PDF 处理成功";
        })
        .completeOnTimeout(
                "PDF 处理超时",
                3,
                TimeUnit.SECONDS
        );

这里任务同样需要 5 秒。

但超过 3 秒以后,不会抛超时异常,而是直接使用默认结果:

PDF 处理超时

所以:

String result = future.join();

可以正常拿到字符串。


completeOnTimeout 完整示例

新建类:

com.succos.completablefuture.CompleteOnTimeoutDemo

代码如下:

package com.succos.completablefuture;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

public class CompleteOnTimeoutDemo {

    public static void main(String[] args) {

        CompletableFuture<String> future = CompletableFuture
                .supplyAsync(() -> {

                    System.out.println(Thread.currentThread().getName()
                            + " 开始处理 PDF");

                    sleep(5000);

                    System.out.println(Thread.currentThread().getName()
                            + " PDF 处理完成");

                    return "output/test-watermark.pdf";
                })
                .completeOnTimeout(
                        "output/default.pdf",
                        3,
                        TimeUnit.SECONDS
                );

        String result = future.join();

        System.out.println("最终结果:" + result);
    }

    private static void sleep(long millis) {

        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("任务被中断", e);
        }
    }
}

任务超过 3 秒后,join() 会拿到:

output/default.pdf

而不是抛出 TimeoutException


两个方法放在一起对比

方法 超时后的状态 join() 的表现 适合场景
orTimeout 异常完成 抛出 CompletionException 超时必须明确算失败
completeOnTimeout 正常完成 返回默认结果 超时可以使用降级结果

可以直接这样记:

orTimeout:超过时间,报错。

completeOnTimeout:超过时间,给默认值。

PDF 处理更适合哪一个

对于真正的 PDF 水印处理,我更倾向于使用:

orTimeout(...)

原因是 PDF 水印属于明确的业务任务。

如果处理没有在规定时间内完成,通常应该记录为:

任务超时;
处理失败;
需要重试;
不能生成正式下载地址。

我不太会给它返回一个假的默认路径:

output/default.pdf

因为用户可能把这个默认文件误认为自己处理后的 PDF。

所以更合理的写法是:

.orTimeout(30, TimeUnit.SECONDS)
.exceptionally(ex -> {

    Throwable cause = getRootCause(ex);

    if (cause instanceof TimeoutException) {
        return PdfTaskResult.fail(
                file.getName(),
                "PDF 处理超过 30 秒"
        );
    }

    return PdfTaskResult.fail(
            file.getName(),
            cause.getMessage()
    );
})

超时后明确返回失败结果。


completeOnTimeout 更适合降级场景

completeOnTimeout() 更适合有合理默认值的场景。

例如:

查询用户头像超时,返回默认头像;

查询推荐内容超时,返回空列表;

读取非核心配置超时,使用默认配置;

查询辅助说明超时,返回默认提示。

这些场景即使超时,也可以继续完成主要业务。

比如:

CompletableFuture<String> avatarFuture =
        queryUserAvatarAsync(userId)
                .completeOnTimeout(
                        "/images/default-avatar.png",
                        2,
                        TimeUnit.SECONDS
                );

如果头像服务超时,就显示默认头像。

这很合理。

但 PDF 水印超时后直接返回一个默认 PDF,往往不合理。

所以选择哪个方法,关键不是 API 本身,而是业务是否允许降级。


超时以后,原任务会自动停止吗

这是这一节最需要注意的地方。

无论使用:

orTimeout(...)

还是:

completeOnTimeout(...)

它们主要改变的是 CompletableFuture 的完成状态。

它们不等于强制停止底层任务。

也就是说:

调用方 3 秒后已经拿到超时异常或默认结果;

后台原来的任务仍然可能继续执行。

例如:

CompletableFuture<String> future = CompletableFuture
        .supplyAsync(() -> {

            System.out.println("任务开始");

            sleep(5000);

            System.out.println("任务真正执行结束");

            return "处理成功";
        })
        .completeOnTimeout(
                "默认结果",
                2,
                TimeUnit.SECONDS
        );

System.out.println(future.join());

sleep(5000);

可能输出:

任务开始
默认结果
任务真正执行结束

这说明超时结果已经返回,但原任务并没有因此立即停止。


超时控制和任务取消是两回事

我现在会这样区分:

orTimeout 和 completeOnTimeout:

控制 CompletableFuture 多久以后结束等待。

cancel(true):

尝试中断底层任务。

它们不是一回事。

即使超时后再调用:

future.cancel(true);

也不保证底层业务马上停止。

任务能不能停下来,还是取决于:

任务代码是否检查中断状态;

阻塞方法是否响应中断;

底层 PDF 库是否支持中断;

文件写入是否已经开始。

所以不能把 orTimeout() 理解成“执行超过 30 秒就杀掉线程”。

它没有这个能力。


为什么底层任务可能继续执行

CompletableFuture 管理的是任务完成状态。

而真正的任务可能已经在线程池线程里运行:

CompletableFuture.supplyAsync(() -> {
    return addWatermark(file);
}, pdfExecutor);

超时发生时,CompletableFuture 可以被标记为异常完成。

但执行 addWatermark(file) 的线程,可能仍在 PDFBox 内部处理页面。

如果底层代码没有响应中断,就会继续运行到结束。

所以超时只是告诉业务调用方:

这个结果已经来不及了,我不再正常等待它。

不代表后台工作一定停止。


PDF 超时后要防止半成品文件

PDF 水印处理通常会写输出文件。

如果任务超时,但后台还在写文件,可能出现:

接口已经返回处理超时;

输出目录里仍在生成文件;

用户重新提交后,两个任务写同一个路径;

生成的文件不完整;

半成品被当成正式文件下载。

所以输出文件设计要谨慎。

我更倾向于先写临时文件:

output/test-watermark.tmp.pdf

处理完全成功后,再改名为:

output/test-watermark.pdf

流程可以设计成:

1. 生成唯一临时文件名;

2. 把水印结果写入临时文件;

3. PDF 完整保存成功;

4. 把临时文件原子移动到正式路径;

5. 任务失败或超时时,清理临时文件。

这样至少不会轻易把半成品暴露给用户。


给 PDF 任务增加超时结果

可以定义一个结果对象:

static class PdfTaskResult {

    private boolean success;

    private boolean timeout;

    private String fileName;

    private String targetPath;

    private String message;
}

超时时返回:

PdfTaskResult.timeout(
        file.getName(),
        "PDF 处理超过 30 秒"
)

普通失败时返回:

PdfTaskResult.fail(
        file.getName(),
        "PDF 文件损坏"
)

这样超时和普通失败就能区分开。


完整的 PDF 超时示例

新建类:

com.succos.completablefuture.PdfTimeoutDemo

代码如下:

package com.succos.completablefuture;

import java.io.File;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;

public class PdfTimeoutDemo {

    public static void main(String[] args) {

        ThreadPoolExecutor pdfExecutor = new ThreadPoolExecutor(
                3,
                3,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(100),
                new NamedThreadFactory("pdf-worker"),
                new ThreadPoolExecutor.CallerRunsPolicy()
        );

        File file = new File("input/test.pdf");

        CompletableFuture<PdfTaskResult> future =
                CompletableFuture
                        .supplyAsync(() -> {

                            String targetPath =
                                    processPdf(file);

                            return PdfTaskResult.success(
                                    file.getName(),
                                    targetPath
                            );

                        }, pdfExecutor)
                        .orTimeout(
                                3,
                                TimeUnit.SECONDS
                        )
                        .exceptionally(ex -> {

                            Throwable cause =
                                    getRootCause(ex);

                            if (cause instanceof TimeoutException) {
                                return PdfTaskResult.timeout(
                                        file.getName(),
                                        "PDF 处理超过 3 秒"
                                );
                            }

                            return PdfTaskResult.fail(
                                    file.getName(),
                                    cause.getMessage()
                            );
                        });

        PdfTaskResult result = future.join();

        System.out.println("--------------------------------");
        System.out.println("文件名:" + result.getFileName());
        System.out.println("是否成功:" + result.isSuccess());
        System.out.println("是否超时:" + result.isTimeout());
        System.out.println("输出路径:" + result.getTargetPath());
        System.out.println("处理信息:" + result.getMessage());

        pdfExecutor.shutdown();
    }

    private static String processPdf(File file) {

        System.out.println(Thread.currentThread().getName()
                + " 开始处理:"
                + file.getName());

        sleep(5000);

        String targetPath = "output/"
                + file.getName()
                .replace(".pdf", "-watermark.pdf");

        System.out.println(Thread.currentThread().getName()
                + " 处理完成:"
                + targetPath);

        return targetPath;
    }

    private static Throwable getRootCause(
            Throwable throwable
    ) {
        Throwable cause = throwable;

        while (cause.getCause() != null) {
            cause = cause.getCause();
        }

        return cause;
    }

    private static void sleep(long millis) {

        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("任务被中断", e);
        }
    }

    static class PdfTaskResult {

        private boolean success;

        private boolean timeout;

        private String fileName;

        private String targetPath;

        private String message;

        public static PdfTaskResult success(
                String fileName,
                String targetPath
        ) {
            PdfTaskResult result = new PdfTaskResult();

            result.success = true;
            result.timeout = false;
            result.fileName = fileName;
            result.targetPath = targetPath;
            result.message = "处理成功";

            return result;
        }

        public static PdfTaskResult fail(
                String fileName,
                String message
        ) {
            PdfTaskResult result = new PdfTaskResult();

            result.success = false;
            result.timeout = false;
            result.fileName = fileName;
            result.message = message;

            return result;
        }

        public static PdfTaskResult timeout(
                String fileName,
                String message
        ) {
            PdfTaskResult result = new PdfTaskResult();

            result.success = false;
            result.timeout = true;
            result.fileName = fileName;
            result.message = message;

            return result;
        }

        public boolean isSuccess() {
            return success;
        }

        public boolean isTimeout() {
            return timeout;
        }

        public String getFileName() {
            return fileName;
        }

        public String getTargetPath() {
            return targetPath;
        }

        public String getMessage() {
            return message;
        }
    }

    static class NamedThreadFactory
            implements ThreadFactory {

        private final String prefix;

        private final AtomicInteger threadNumber =
                new AtomicInteger(1);

        public NamedThreadFactory(String prefix) {
            this.prefix = prefix;
        }

        @Override
        public Thread newThread(Runnable r) {

            Thread thread = new Thread(r);

            thread.setName(
                    prefix
                            + "-"
                            + threadNumber.getAndIncrement()
            );

            return thread;
        }
    }
}

这个示例中,PDF 处理模拟耗时 5 秒,但超时时间只有 3 秒。

所以最终结果会被标记为超时。

不过后台 processPdf() 是否立即停止,不能只依赖 orTimeout()


批量 PDF 怎么给每个任务设置超时

如果有一批 PDF,可以在创建每个 CompletableFuture 时分别设置超时:

List<CompletableFuture<PdfTaskResult>> futureList =
        new ArrayList<>();

for (File file : files) {

    CompletableFuture<PdfTaskResult> future =
            CompletableFuture
                    .supplyAsync(() -> {

                        String targetPath =
                                addWatermark(file);

                        return PdfTaskResult.success(
                                file.getName(),
                                targetPath
                        );

                    }, pdfExecutor)
                    .orTimeout(
                            30,
                            TimeUnit.SECONDS
                    )
                    .exceptionally(ex -> {

                        Throwable cause =
                                getRootCause(ex);

                        if (cause instanceof TimeoutException) {
                            return PdfTaskResult.timeout(
                                    file.getName(),
                                    "PDF 处理超时"
                            );
                        }

                        return PdfTaskResult.fail(
                                file.getName(),
                                cause.getMessage()
                        );
                    });

    futureList.add(future);
}

然后统一等待:

CompletableFuture.allOf(
        futureList.toArray(new CompletableFuture[0])
).join();

再收集结果:

List<PdfTaskResult> resultList = futureList
        .stream()
        .map(CompletableFuture::join)
        .collect(Collectors.toList());

因为每个任务都通过 exceptionally() 转成了结果对象,所以某个 PDF 超时不会直接让整个 allOf().join() 抛异常。


每个任务超时和整批任务超时不一样

这里还要区分两种需求。

第一种:

单个 PDF 最多处理 30 秒。

这种情况给每个任务分别加:

.orTimeout(30, TimeUnit.SECONDS)

第二种:

整批 PDF 最多等待 10 分钟。

这种情况可以给 allOf() 的结果设置超时:

CompletableFuture<Void> allFuture =
        CompletableFuture.allOf(
                futureList.toArray(
                        new CompletableFuture[0]
                )
        );

allFuture.orTimeout(
        10,
        TimeUnit.MINUTES
).join();

两者不一样:

单任务超时:限制每个文件的处理时间。

整批超时:限制整个批次最多等待多久。

真实项目里可能两种限制都需要。


整批超时也不会自动停止所有任务

即使写了:

allFuture.orTimeout(
        10,
        TimeUnit.MINUTES
).join();

超过 10 分钟后,allFuture 会异常完成。

但各个 PDF 子任务可能仍然继续执行。

如果希望超时后尝试取消未完成任务,可以在捕获超时后遍历:

for (CompletableFuture<PdfTaskResult> future
        : futureList) {

    if (!future.isDone()) {
        future.cancel(true);
    }
}

但仍然只是尝试取消。

不能保证所有底层任务立即停止。


completeOnTimeout 不要掩盖关键故障

completeOnTimeout() 使用起来很方便:

.completeOnTimeout(
        defaultResult,
        3,
        TimeUnit.SECONDS
)

但也有一个风险:它可能把真正的性能问题隐藏起来。

例如查询数据库本来应该 100 毫秒完成,现在经常超过 3 秒。

如果每次都返回默认值,用户表面上还能看到页面,但系统问题一直没有暴露。

所以使用默认结果时,仍然应该记录超时日志和监控指标。

例如:

CompletableFuture<String> future =
        queryConfigAsync()
                .completeOnTimeout(
                        "默认配置",
                        3,
                        TimeUnit.SECONDS
                )
                .whenComplete((result, ex) -> {
                    // 这里只看最终状态,未必能直接判断是否用了超时默认值
                });

如果业务需要准确知道是否发生过超时,更适合返回一个带状态的结果对象,而不是只返回普通字符串。


用结果对象表示降级

例如:

static class QueryResult<T> {

    private boolean success;

    private boolean degraded;

    private T data;

    private String message;
}

超时时返回:

QueryResult.degraded(
        defaultValue,
        "查询超时,已使用默认值"
)

这样业务能区分:

正常查询结果;

超时后的降级结果。

不会把两者混在一起。


orTimeout 和 exceptionally 的顺序

通常写成:

CompletableFuture
        .supplyAsync(...)
        .orTimeout(3, TimeUnit.SECONDS)
        .exceptionally(ex -> {
            return fallback;
        });

顺序表示:

先给前面的异步任务增加超时限制;

如果任务异常或超时,再统一兜底。

如果把 exceptionally() 放在 orTimeout() 前面:

CompletableFuture
        .supplyAsync(...)
        .exceptionally(ex -> fallback)
        .orTimeout(3, TimeUnit.SECONDS);

前面的普通业务异常可能已经被恢复了,但整条恢复后的流程仍然可能触发后面的超时。

方法顺序不同,含义也会变化。

我更常用:

.orTimeout(...)
.exceptionally(...)

先规定时间边界,再统一处理异常。


超时时间怎么定

超时时间不能随便写一个数字。

应该结合:

PDF 平均页数;

文件大小;

历史处理耗时;

机器 CPU 和内存;

磁盘读写速度;

任务排队时间;

业务能接受的等待时间。

例如,绝大多数 PDF 在 5 秒内完成,少量大文件需要 15 秒。

那超时时间可以先设置为 30 秒,再通过日志观察。

如果直接设置成 3 秒,可能大量正常文件都被误判为超时。

如果设置成 30 分钟,超时控制又失去了意义。

比较实际的做法是:

先记录真实耗时分布;

再根据 P95、P99 耗时设置边界;

对特殊大文件单独限制大小和页数。

学习项目里可以写 3 秒方便观察。

真实项目里要根据数据来定。


这一节小结

这一节我主要记住几点:

1. orTimeout 超时后让 CompletableFuture 异常完成;
2. completeOnTimeout 超时后返回一个默认结果;
3. 超时算业务失败时,更适合使用 orTimeout;
4. 业务允许降级时,可以使用 completeOnTimeout;
5. 两个方法都不等于强制停止底层任务;
6. PDF 超时后要考虑临时文件、半成品和状态清理;
7. 批量任务可以给每个 PDF 单独设置超时;
8. 单任务超时和整批任务超时是两种不同的限制;
9. 超时时间应该根据真实耗时和业务要求确定。

用一句话总结:

超时必须失败,用 orTimeout;超时允许降级,用 completeOnTimeout。

下一节开始整理 CompletableFuture 批量处理 PDF 的最终版本。

前面学习的自定义线程池、allOf、结果对象、异常处理、超时控制和日志记录,会在下一节合到一套完整代码里。