原创不易,禁止转载!
前言
最近排查了一个项目中的历史遗留问题,内容涉及线程池配置调优、监控、异步并发模式。对于某些低优先级或者不重要的任务,其对应的线程池常常分配少量资源,有时甚至可有可无,线程池的拒绝策略也常常设置为抛弃策略,根据不同的需求,分别为抛弃当前和抛弃最老的任务。
然而,这种配置可能存在使得程序卡死的"bug",且看下文。
问题复现
java
public class DiscardDemo {
public static void main(String[] args) {
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1,
0, TimeUnit.MILLISECONDS, new SynchronousQueue<>(), new DiscardPolicy());
pool.submit(() -> {
try {
Thread.sleep(Duration.ofDays(1));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
CompletableFuture<Void> cf = supplyAsync(() -> 1, pool)
.thenAccept(System.out::println);
cf
// .orTimeout(3, TimeUnit.SECONDS)
// .exceptionally(ex -> {
// System.out.println("exception: " + ex);
// return null;
// })
.join();
pool.shutdownNow();
}
}
代码分析:
- 配置线程池的最大线程数为1,保证同时最多仅有一个线程在运行。
- 使用同步队列保证没有任务存放在队列中(同步队列可以简单理解成队列长度为0的阻塞队列)。
- 第一个提交的任务保证长时间占用线程,第二个提交的任务必然触发拒绝策略,由于抛弃策略没有对外抛出异常或者记录日志,我们无法得知任务是未执行还是执行中或者被抛弃。这是一种典型的"黑洞"实现,缺点显而易见。
- 如果没有超时时间的控制,cf 对应的任务会一直处于未完成状态,程序卡死。
- 如果配置了超时时间(打开注释代码),由于超时时间由外部scheduler(CompletableqFuture维护)控制,可触发cf超时异常,程序顺利执行。进而整个程序可执行完成,第一个任务通过shutdownNow的中断信号触发 InterruptedException,从而线程执行结束。
使用orTimeout后,运行结果如下:
java
exception: java.util.concurrent.TimeoutException
interrupted
监控与发现过程
观察到业务系统偶发请求"卡死",表现为必然触发超时。由于业务系统中大量使用线程池,观察到IO线程池/混合线程池(偏IO)配置存在以下问题:配置了阻塞队列(LinkedBlockingQueue),而非使用同步队列,且最大线程数量配置数量较少。
一般来说,处理普通流量时,同步队列中的任务会很快被处理,其在队列中的最大等待时间 = 队列大小 * 平均IO耗时 / 活跃线程数。当峰值流量过来且未发生熔断时,平均IO耗时不变,活跃线程数仅当队列满时增加,最大等待时间随之减少。总的来说,虽然IO线程池推荐使用 CachedThreadPool + limiter 实现,以上这种实现并不会导致很严重的问题。最理想的情况是IO线程池只处理IO请求,不占用CPU资源。
当前系统的最大问题在于最大线程池配置数量较少。实际上,增加一些IO专用线程对于系统的资源占用体现在内存消耗.
还观察到某些偶发长尾流量(如网络分区、拥堵、机器GC等)的IO任务耗时增加,两种原因都导致偶发触发拒绝策略,系统使用了 CompletableFuture、ListenableFuture 异步并发模式,某些节点卡死后,导致超时异常必然触发。
最常见的中断节点见于 allOf 方法,由于 CompletableFuture 原生的任务编排能力比较有限,这个方法必须等待所有任务执行完成,即使某些任务出现异常后,allOf 仍然不会立即返回结果。所以,即使有超时时间的控制,allOf 的耗时依然是最长的那一段,由此形成级联放大效应,使得问题更加严重。
实践建议与注意事项
- 推荐使用记日志 + 抛出异常的拒绝策略,虽然增加一些代码量,但是可以保证问题被及时发现。
- 拒绝策略触发说明系统存在瓶颈,必须认真分析,可能是请求量过大,可能是线程池配置不合理,绝对不能使用 CallerRunPolicy 等方法糊弄过去。这里最重要的原则是:如果系统接近承受极限时,必须采取流量控制、熔断等策略以保证系统的正常运行,避免出现不可用状态。
- 不仅可以对线程池进行监控,还可以监控任务的执行状态,比如是否执行、执行耗时、进入阻塞队列的等待时间等信息。
- 如果真的是低优先级并且可以抛弃的任务,可以不等,也就是说不使用get、join方法。
- 使用 CompletableFuture 时,配合线程池使用 AbortPolicy 拒绝策略,抛出的异常可以捕获到 CompletableFuture 内部。线程池提交任务返回的 Future 不能直接封装拒绝异常。
- 推荐使用 CFFU 库,其为 CompletableFuture 的辅助增强库,提供了更为强大的任务编排能力、不同的并发执行策略、backport支持以及更安全的实现。笔者参与了部分特性的实现。