并发编程中的异常处理策略与最佳实践

147 阅读7分钟

并发编程中的异常处理策略与最佳实践

关注桦说编程,提升知识水平。

本文禁止转载!

本文参考了 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. 总结

  1. 不要吞掉异常
  2. 根据需求的不同,我们可以采用不同的异常处理策略
  3. 最好有一个兜底的异常处理策略
  4. 小心 CompletableFuture 会吞掉异常