并发编程中的异常处理策略与最佳实践
关注桦说编程,提升知识水平。
本文禁止转载!
本文参考了 Reactor文档 总结的异常处理模式,主要讨论了 Java 异常处理、Future(线程池返回结果类型)、CompletableFuture 等异常处理模式和方法。对于分布式事务、异常重试、单元测试等涉及异常的内容,由于其问题限于某一领域,故不讨论。
异常处理的核心思想就是对于异常不能视而不见,其最终是会在某个位置进行处理的。
1. Java 异常处理分析
单线程情况:Java 中代码执行依赖于线程和栈帧,发生异常时,我们可以记录此时的调用栈(StackTrace, 栈帧列表),方便排查问题。线程提供了异常处理的回调,如果没有设置异常处理器,线程会终止,同时会默认将异常StackTrace 输出到 System.err;有异常处理器的情况下,会执行异常处理器。
public class Demo {
public static void main(String[] args) {
throw new IllegalStateException("程序错误");
}
}
以上代码抛出异常后,主线程
Exception in thread "main" java.lang.IllegalStateException: 程序错误
at com.example.blogdemo.errorhandle.Demo.main(Demo.java:5)
以下为设置ExceptionHandler来进行兜底的代码:
class ExceptionHandlerDemo {
public static void main(String[] args) throws InterruptedException {
class MyUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler {
@Override
public void uncaughtException(Thread t, Throwable e) {
// Handle the uncaught exception
System.err.println("Uncaught exception in thread '" + t.getName() + "': " + e.getMessage());
}
}
Thread t = new Thread(() -> {
throw new RuntimeException("Test exception");
});
t.setUncaughtExceptionHandler(new MyUncaughtExceptionHandler());
t.start();
t.join();
}
}
运行打印异常如下:
Uncaught exception in thread 'Thread-0': Test exception
多线程情况下,就和单线程一样,StackTrace 会记录任务(Runable) 的执行,列表头为run方法的调用,至于任务是如何提交的,谁提交的就无法直接获取到了。通常我们在业务中会进行打点(使用 InheritableThreadLocal、TransmittableThreadLocal等记录上下文信息),记录当前线程中执行的任务原信息,比如任务名称、父任务、执行耗时等情况。对于执行的任务进行异常兜底,可以通过设置线程池中 线程的ExceptionHandler、封装提交任务(Runable)等形式实现。
Future 会封装执行过程中的异常,所以其异常不会继续抛出到线程中,以至于导致线程退出。但是这时需要代码编写人员自己实现异常的处理,很多时候我们会忽略这个异常,这是错误的做法。正如Tim Peters 在 Pyhton之禅中说: Errors should never pass silently。忽略了异常并不会使异常消失,反而可能会使结果变得更差,程序可能会出现错误甚至崩溃。本文我们就来详细看看这些异常应该如何处理。
2. 异常处理模式
不同编程模式下对于异常的处理模式各不相同:最常见的异常处理归结为 try-catch 处理模式,异常包含调用栈,异常中过深的调用栈被很多人所诟病;函数式编程中将异常处理为值,导致很多方法的返回值总是带着异常,要求程序员进行处理。
2.1 记录
很多时候我们对于异常无法及时处理,它可能是运行中的bug,可能是网络问题,我们通常都会把异常进行记录。比如日志、metrics、告警,其会有不同的实现形式。
try-catch 模式中我们在 catch 块中进行记录,异常作为值时我们在回调方法中进行记录。当然,默认异常处理也应该进行最后的兜底。
2.2 默认值
我们有时希望异常出现后继续运行代码,或者出现异常的代码不会影响其他代码的执行,此时可以使用默认值进行处理,这种默认值通常是 NullObject。
try {
return doSomethingDangerous(10);
}
catch (Throwable error) {
return "";
}
CompletableFuture.failedFuture(new RuntimeException("Exception!"))
.exceptionally(e -> "");
2.3 终止流程
Flux.just(10,20,30)
.map(this::doSomethingDangerousOn30)
.onErrorComplete();
响应式流可以将异常情况视为终止信号,终止流的计算。
单线程情况下,我们会抛出异常来终止后续代码的执行。
异常作为值的情况可以进行相关代码编写:
class FuncErrDemo {
public static void main(String[] args) {
List<Integer> list = Stream.of("1", "2", "end")
.map(x -> Try.of(() -> Integer.parseInt(x)))
.takeWhile(Try::isSuccess)
.map(Try::get)
.toList();
System.out.println(list);
// 输出结果为 [1, 2]
}
}
多线程情况下可以手动调用 cancel 方法,使用 ListenableFuture 支持从源头取消任务(fast-fail)。
2.4 回退方法(fallback) / 动态回退方法
这里的动态回退方法指的是回退方法以异常为参数,可以进行一些复杂的逻辑处理。比如爬虫时根据提示信息进行回退:提示IP封锁时,切换IP。异常也是值。
String v1;
try {
v1 = callExternalService("key1");
}
catch (Throwable error) {
v1 = getFromCache("key1");
}
CompletableFuture 支持 exceptionally, exceptionallyCompose,ListenableFuture 支持 catching。
ListenableFuture<Integer> fetchCounterFuture = fetchCounterV2();
ListenableFuture<Integer> faultTolerantFuture = Futures.catching(fetchCounterFuture, FetchException.class, x -> fetchCounterV1(), directExecutor());
2.5 封装异常
常见的封装就是将异常包装成业务异常。
try {
return callExternalService(k);
}
catch (Throwable error) {
throw new BusinessException("oops, SLA exceeded", error);
}
CompletableFuture 可以使用 exceptionallyCompose 方法实现:
public static void main(String[] args) {
callExternalServiceAsync("serviceA")
.exceptionallyCompose(e -> failedFuture(new BusinessException("服务异常", er)))
.join();
}
static CompletableFuture<String> callExternalServiceAsync(String serviceName) {
return failedFuture(new ServerException("服务异常: " + serviceName));
}
2.6 资源释放与收尾工作
try-catch-finally 是常见的异常处理模式,finally 块中的代码通常为资源清理和收尾,不包括具体业务。比如无论显式锁中同步块是否抛出异常,都要释放锁。无论代码执行成功与否,计时器都要进行计时和关闭。try with reource 是 try-catch-finally 的一种语法糖实现。
在多线程进行处理时,关闭资源是一个比较困难的问题,常常涉及到信息的同步。结构化并发提供了一种并发异常处理+取消机制的实现,详见 JEP453。
Response handle() throws ExecutionException, InterruptedException {
// 出现异常 -> 关闭资源
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<String> user = scope.fork(() -> findUser());
Supplier<Integer> order = scope.fork(() -> fetchOrder());
scope.join() // Join both subtasks
.throwIfFailed(); // ... and propagate errors
// Here, both subtasks have succeeded, so compose their results
return new Response(user.get(), order.get());
}
}
2.7 吞掉异常
**只有一种情况下我们可以吞掉异常,就是没有这个异常。**在 Java 中,存在受检异常,其要求我们必须进行异常处理,try-catch 或者标记在方法上,强制后续处理。涉及多线程情况下,我们需要处理 InterruptException, 如果确定不会中断某个线程,这种中断的处理机制让人哭笑不得,好处是受检异常提醒了我们需要进行异常处理,坏处是我们不得不处理根本不存在的异常。我们可以使用 @SneakyThrow 或者一些其他技巧跳过检查,或者在异常处理时抛出 IllegalStageException,因为我们确定其不会抛出,但是万一呢。
3. 如何处理 List<Future> 的异常
这里的 List 是支持协变的,List<CompletableFuture> 也应该具有相似的处理模式。
我们知道 Future 封装了异常,通常处理List<Future>,其异常还没有被处理过。
常见的做法(不是最好的)是短路操作,如果出现异常,则视为一个异常,最终处理为Future<List>。笔者建议读者仔细思考代码真正想实现的效果,参考上一节的内容,做出正确的处理。比如可以赋予默认值,提升代码容错,保证后续代码继续执行。最起码我们需要做好异常的记录,避免给自己挖坑。
4. CompletableFuture 会吞掉异常
由于超时、取消、竞争设置值等原因,CompletableFuture 可能吞掉异常,使用者需要特别注意。笔者会在后续文章中进行详细分析。
5. 总结
- 不要吞掉异常
- 根据需求的不同,我们可以采用不同的异常处理策略
- 最好有一个兜底的异常处理策略
- 小心 CompletableFuture 会吞掉异常