多线程异常处理:挖掘页面空窗背后的原因

作为一名应用开发,大家是否有遇到以下现象,为什么一套非常优秀的兜底机制还是会出现页面空窗现象?本文将会通过实例和大家分享,作者在线程池使用过程中遇到的问题:异常处理,以及下线程池的参数设置经验。


什么现象


先解释下什么是空窗,就是数据缺失导致某块或整页出现空白的现象。

事情有点早了,刚接聚划算,还没来得及看逻辑,就被告知,压测时页面出现了空窗,像这样:

原因是什么


其实就是对应的接口超时或者数据处理异常,导致该块儿数据没有返回。

我们的代码是运行在阿拉丁容器里的,阿拉丁本身是有兜底机制的,并且有两层:

  1. 如果接口发生异常,阿拉丁会从tair里取缓存的数据返回给前端做兜底

  2. 如果阿拉丁也没有兜住,前端接收到错误的code,会自动从cdn取对应接口的数据做兜底

这套机制还是非常优秀的,但为什么还是出现了空窗了。

翻看代码发现,是我们把对应的异常给吃掉了,没有抛给阿拉丁容器,代码是这样的:

try {
    executorService.invokeAll(callableHashSet);
} catch (Exception e) {
    throw new RuntimeException(e);
}

初看,是不是以为把try catch拿掉就没问题了,然而不是,我们看看java.util.concurrent.ExecutorService#invokeAll的实现,先看我们最常用的ThreadPoolExecutor,它的invokeAll方法在父类AbstractExecutorService里实现:


这里变量ignore的命名非常漂亮,想都不用想,它被忽略了,为什么要看这个ExecutionException,是因为线程里发生的异常都被包装成了ExecutionException,我们跟着AbstractExecutorService##invokeAll看下,上图有个newTaskFor,看下实现:

protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
    return new FutureTask<T>(callable);
}

看看FutureTask#get方法:

public V get() throws InterruptedException, ExecutionException {
    int s = state;
    if (s <= COMPLETING)
        s = awaitDone(false, 0L);
    return report(s);
}

最终在report方法里实现:

private V report(int s) throws ExecutionException {
    Object x = outcome;
    if (s == NORMAL)
        return (V)x;
    if (s >= CANCELLED)
        throw new CancellationException();
    throw new ExecutionException((Throwable)x);
}

可以看到,如果线程里抛出了异常,都被包装成了ExecutionException,而ThreadPoolExecutor#invokeAll方法里忽略了这个异常,导致我们根本捕捉不到异常。

上边说的是ThreadPoolExecutor,我们再看另一个常用的ExecutorService的实现类ForkJoinPool:


看到了吧,方法命名就告诉你了,我不会抛异常给你,进去看看ForkJoinTask#quietlyJoin:


注释说得很清楚,不抛异常。

怎么解决


首先,根据上边的分析,要慎用invokeAll,解决也很简单,可以有以下几种方式:


1. 能让主线程感知到异常,并向外抛,就可以触发阿拉丁的兜底
2. 模块内部做数据缓存,捕捉到异常以后取缓存数据做兜底

  第一回合

因为对整体的逻辑没摸透,不敢直接替换掉invokeAll,会影响整个聚划算首页,时间又比较急,就先缩小改动范围,选取方法二:

  1. 在对应模块内容做数据缓存,为了兼顾时效性(聚划算商品有上团和下团时间,所以时效性很强),做了1分钟的缓存和5分钟的缓存,如果发生异常,按优先级取缓存,优先1分钟的缓存。

  2. 为了减轻写压力,只针对一定比例的请求写缓存

效果


上线之后没问题,然而第二次全链路压测,半夜又收到消息说空窗了。

第一回合失败。

  第二回合

经过分析,可能有多个原因:


1. 应该是压测状态下,下游服务持续压力大,导致缓存数据过期,
2. 写入缓存的数据也没有做好校验,可能写入不合法的数据

继续做调整:


1. 严格校验写入缓存的数据,保持缓存数据的合法性
2. 既然是兜底数据,可以直接缓存在内存,这样就不用关心写比例,直接100%缓存合法数据,并且不设置失效时间,这样保证兜底时总能取到最新的合法数据
3. 把该组件的Callable从invokeAll里拎出来,增加预案,可以触发整页兜底,作为最后的保命手段,如下:

效果


后续压测和日常没再出现过空窗,就这个模块来说,应该没问题了。


这样就好了吗


其实不应该结束,上述方案都是在时间紧张的情况下做的临时补救措施,代码里到处是特判逻辑,我们应该有更系统的设计方案:


1. 模块异常都外抛,触发阿拉丁的兜底,但阿拉丁的兜底是接口级别的,我们一个接口里边通常包含多个模块,如果因为次要模块导致用户看到的主要模块也是兜底的数据,用户体验不好
2. 针对每一个模块做独立的兜底,但像上述方法一样,一个模块一个模块来改,太累,也容易遗漏。我们应该有一个框架性设计,让以后的开发只需要关心业务逻辑,而不用关心这些非功能性问题,这点我准备在EasyWidget里边来实现,基础设施已经具备,只需要在模板方法里加几行就能实现。

总结一下


这里边遇到的主要问题是没有正确处理线程池的异常和兜底设计不完善导致,兜底的设计上边提到了思路,我们再看下处理子线程内部异常的常用方式:


▐  通过原子变量

AtomicBoolean exception = new AtomicBoolean(false);


Callable<Void> qwbkt = () -> {
    try {
        qwbktSections.add(qwbktManager.query(context, null));
    } catch (Throwable t) {
        context.getLogger().error("qwbkt exception:", t);
        exception.set(true);
    }
    return null;
};


//...


if (exception.get()) {
    throw new RuntimeException("queryError");
}

▐  以code形式返回

Callable<String> task = new Callable<String>() {
    @Override
    public String call() throws Exception {
        Result<String> result = new Result<>();
        try {
            //..
        } catch (Exception e) {
            result.setCode("500");
        }
        return result;
    }
};

▐  老老实实future#get

try {
    String s = future.get();
} catch (InterruptedException e) {
    //..
} catch (ExecutionException e) {
    //todo: 这里处理线程内部异常
}


再说说线程池的其它问题


▐  线程池设置不合理

看到很多应用里的线程池参数不合理,尤其是很多新同学,分不清前台应用和后台任务需要的线程数和拒绝策略怎么设置。

很多同学从教程里边或者某些框架源码里边看到线程池的线程数尽量跟机器核数保持一致,就一直保持这个设置。

还有看到前台应用了设置了少量的线程,队列长度是10000。这种情况在遇到突发流量的情况下很容易把自己拖垮,之所以一直没触发问题,一种原因可能是没有遇到过大流量,另一种可能是被限流保护了,一旦限流没有设置好,就可能遇到致命问题。

这里简单说下自己的经验:

  • 搞清楚核心线程数、最大线程数、任务队列的工作原理,核心线程用完了是先放任务队列,队列满了才会继续增加线程数至最大线程数

  • 前台应用队列长度一定不能太大,根据线程数、接口RT、客户端所能接受的RT来计算队列长度

  • 分清我们的应用是CPU密集型还是IO密集型,大多数情况我们的业务应用都是IO密集型的,这种情况下不必拘泥于线程数跟核数保持一致

  • 用Runtime.getRuntime().availableProcessors()设置线程数的时候,你以为取到的是虚拟机的线程数,但很可能取到的是物理机的线程数,要注意这个坑

  • 前台应用的线程数必须通过压测不断调整,才能获得合理的线程数,但一旦依赖接口的RT等情况发生变化,线程数就可能不再合理,所以合理的线程数很难保持

  • 后台应用如果不关心响应的及时性,可以设置较大的队列,但要关注机器内存,也要主要机器重启时的任务丢失问题

  线程池的关闭

任务不能丢失的时候一定要在jvm关闭的时候通过钩子关闭线程池。

Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
    @Override
    public void run()
{
        threadPool.shutdown();
    }
}));

上述方法只在jvm正常关闭的时候有效,如果强杀或断电等情况还是有问题,就要做更强有力的保障,如先发消息队列,再处理。

✿  拓展阅读

作者|朱天富(海培)

编辑|橙子君

出品|阿里巴巴新零售淘系技术