在Spring环境中正确关闭线程池的姿势

2023-05-14 09:05:09 线程 正确 姿势

前言

在Java System#exit 无法退出程序的问题一文末尾提到优雅停机的一种实现方案,要借助Shutdown Hook进行实现,本文,将继续探索优雅停机中遇到的一些问题:应用中线程池的优雅关闭

线程池正确关闭的姿势

在这一节,先不讨论应用中线程池该如何优雅关闭以达到优雅停机的效果,只是简单介绍一下线程池正确关闭的姿势

为简化讨论的复杂性,本文的线程池均是指jdk中的java.util.concurrent.ThreadPoolExecutor

正确关闭线程池的关键是 shutdown + awaitTermination或者 shutdownNow + awaitTermination

一种可能的使用姿势如下:

ExecutorService executorService = Executors.newFixedThreadPool(1);
executorService.execute(() -> {
    // do task
});

// 执行shutdown,将会拒绝新任务提交到线程池;待执行的任务不会取消,正在执行的任务也不会取消,将会继续执行直到结束
executorService.shutdown();

// 执行shutdownNow,将会拒绝新任务提交到线程池;取消待执行的任务,尝试取消执行中的任务
// executorService.shutdownNow();

// 超时等待线程池完毕
executorService.awaitTermination(3, TimeUnit.SECONDS);

一个任务会有如下几个状态:

  • 未提交,此时可以将任务提交到线程池
  • 已提交未执行,此时任务已在线程池的队列中,等待着执行
  • 执行中,此时任务正在执行
  • 执行完毕

那么,执行shutdown方法或shutdownNow方法之后,将会影响任务的状态

shutdown

  • 拒绝新任务提交
  • 待执行的任务不会取消
  • 正在执行的任务也不会取消,将继续执行

shutdownNow

  • 拒绝新任务提交
  • 取消待执行的任务
  • 尝试取消执行中的任务(仅仅是做尝试,成功与否取决于是否响应InterruptedException,以及对其做出的反应)

接下来看一下java doc对这两个方法的描述:

shutdown: Initiates an orderly shutdown in which previously submitted tasks are executed, but no new tasks will be accepted. Invocation has no additional effect if already shut down.
This method does not wait for previously submitted tasks to complete execution. Use awaitTermination to do that.

shutdownNow: Attempts to stop all actively executing tasks, halts the processing of waiting tasks, and returns a list of the tasks that were awaiting execution.
This method does not wait for actively executing tasks to terminate. Use awaitTermination to do that.
There are no guarantees beyond best-effort attempts to stop processing actively executing tasks. For example, typical implementations will cancel via Thread.interrupt, so any task that fails to respond to interrupts may never terminate.

Java doc 提到,这两个方法都不会等执任务执行完毕,如果需要等待,请使用awaitTermination。该方法带有超时参数:如果超时后任务仍然未执行完毕,也不再等待。毕竟应用总归要停机重启,而不可能无限等待下去,因此超时机制是提供给用户的最后一道底线

综上,shutdown(Now) + awaitTermination 确实是实现线程池优雅关闭的关键

应用中如何正确关闭线程池

这一节内容其实才是本文要介绍的重心。上一小节内容我们知道了如何优雅关闭线程池,但那是一般意义上方法论指导,如果将线程池运用于我们的应用中,譬如Spring Boot环境中,复杂度将会变得不一样

本一节,将会介绍线程池在spring (Boot)环境中优雅关闭遇到的一个问题跟挑战,以及解决方案

注:本节使用Spring Boot举例,仅仅是因为它的应用面广,受众多,大家容易理解,并不代表只在该环境下才会出问题。在纯Spring、甚至非Spring环境,都有可能出现问题

场景1

我们来假设一个场景,有了场景的铺垫,对问题的理解会简单一些

@Resource
private RedisTemplate<String, Integer> redisTemplate;

// 自定义线程池
public static ExecutorService executorService = Executors.newFixedThreadPool(1);

@GetMapping("/incr")
public void incr() {
    executorService.execute(() -> {
        // 依赖Redis进行计数
        redisTemplate.opsForValue().increment("demo", 1L);
    });
}
  • 自定义线程池,用于异步任务的执行。此处为演示方便使用Executors.newFixedThreadPool(1)生成了只有一个线程的线程池
  • 高并发请求/incr接口,每次请求该接口,都会往线程池中添加一个任务,任务异步执行的过程中依赖Redis

此时,要求停机发布新版本,按照Java System#exit 无法退出程序的问题文章,我们知道了优雅停机的一般步骤:

  • 切断上游流量入口,确保不再有流量进入到当前节点
  • 向应用发送kill 命令,在设定的时间内待应用正常关闭,若超时后应用仍然存活,则使用kill -9命令强制关闭
  • JVM接收到kill命令,会唤起应用中所有的Shutdown Hooks,等待Shutdown Hooks执行完毕便可以正常关机;与此同时,应用会接着处理在途请求,以确保不会向客户端抛出连接中断异常,实现无感知发布

一切看起来很美好,然而…

当JVM收到kill指令后,便会唤醒所有的Shutdown Hook,而其中有一个Shutdown Hook是Spring应用在启动之初注册的,它的作用是对Spring管理的Bean进行回收,并销毁ioc容器

那么问题就产生了:以我们的场景为例,线程池里的任务与Spring Shutdhwon Hook正在并发地执行着,一旦任务执行期依赖的资源先行被释放,那任务执行时必然会报错

在我们的场景中,就很有可能因为Redis连接被回收,从而导致redisTemplate.opsForValue().increment("demo", 1L);抛出异常,执行失败

如图示:

Jedis连接池先行被回收

下一刻,线程池里的任务尝试获取Jedis连接,失败并抛出异常

场景2

除了上述场景外,还有一个场景或许大家也经常会碰到:本地启动一个定时任务,按一定频率将数据从DB加载到Cache中

例如:

ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);

scheduledExecutorService.scheduleWithFixedDelay(() -> {
    // load from db and put into cache
    // ...
    
}, 100, 100, TimeUnit.MILLISECONDS);
  • 每100ms向线程池里扔一个任务
  • 任务是:从DB中取出数据,放入缓存(例如Local Cache,Redis)

在Spring Shutdown Hook执行期间,新的任务仍然会产生,又或者旧的任务未执行完毕,一旦尝试获取DB资源,就可能由于资源被回收而获取失败,抛出异常

此时的系统关闭已经不优雅—任务执行有异常,这种异常可能对业务有损,我们应尽量避免类似问题的产生,而不是抱着"算了吧,反正产生这个问题的概率很低",或者"算了吧,反正异常对我目前业务影响也不大"的态度,这是技术人的基本修养,也是对自我提高的要求—目前业务影响不大,允许不优先解决,但是期望掌握一种解决方案,将来有一天如果碰到了对业务损伤比较大的场景,可以很有底气地说:我能行

解决方案

这个问题产生的根因,是Spring Shutdown Hook与线程池里的任务并发执行,有可能使任务依赖的资源被提前回收导致的。那么一个很直白的思路即是:在切断流量之后,能否让线程池先关闭,再执行Spring 的Shutdown Hook,避免依赖资源被提前回收?

顺着这个思路,有三个问题需要解决:

  • 线程池如何关闭
  • 线程池如何感知Spring Shutdown Hook将要被执行
  • 如何让线程池先于Spring Shutdown Hook关闭

对于第一个问题,本文的上一个小节线程池正确关闭的姿势已经给出了解决方案:即shutdown(Now) + awaitTermination

对于第二个问题,Spring Shutdown Hook被触发的时候,会主动发出一些事件,我们只要监听这些的事件,就能够做出相应的反应

对于第三个问题,我们只要在这些事件的监听器中先行将线程池关闭,再让程序走接下来的关闭流程即可

二、三涉及到Spring 的Shutdown Hook 执行过程,具体原理本篇按下不表,留待下一篇进行分析

从上图中可以看出,只要在destroyBeans之前关闭线程池即可,因此,有两种解决方案:

  • 监听Spring的ContextClosedEvent事件,在事件被触发时关闭线程池
  • 实现Lifecycle接口,并在其stop方法中关闭线程池

此处以监听ContextClosedEvent为例:

@Component
public class ContextClosedHandler implements ApplicationListener<ContextClosedEvent> {

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
    	  // 获取线程池
    	  // ...
    	  
    	  // 关闭线程池,并等待一段时间
        myExecutorService.shutdown();
        myExecutorService.awaitTermination(3, TimeUnit.SECONDS);
    }
}

此处大家或许能看出一些小问题:需要自行管理线程池。在Spring环境中,我们其实有更多的选择:使用Spring提供的org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor,并将实例交给Spring管理

代码如下:

// 将ThreadPoolTaskExecutor实例交给Spring管理
@Bean
public ThreadPoolTaskExecutor threadPoolTaskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(1);
    executor.setMaxPoolSize(1);
    
    // 告诉线程池,在销毁之前执行shutdown方法
    executor.setWaitForTasksToCompleteOnShutdown(true);
    // shutdown\shutdownNow 之后等待3秒
    executor.setAwaitTerminationSeconds(3);
    
    return executor;
}
@Component
public class ContextClosedHandler implements ApplicationListener<ContextClosedEvent> {
    // 直接注入
    @Resource
    private ThreadPoolTaskExecutor executor;

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
    		// 关闭线程池
        executor.destroy();
    }
}

注: ThreadPoolTaskExecutor的waitForTasksToCompleteOnShutdown + awaitTerminationSeconds等于ThreadPoolExecutor的shutdown + awaitTermination,且在定义线程池时就将优雅关闭行为一同定义完毕,实现了高内聚的目的

在Spring中使用ThreadPoolTaskExecutor,更便捷:

  • 不用再自行管理线程池,获取的时候也很方便,直接注入即可
  • 在需要关闭的时候,直接调用destroy方法即可实现优雅关闭

这样,Spring就会等到线程池关闭(超时)后,才会接着往下执行Bean的销毁、资源回收、应用上下文关闭的逻辑,确保被依赖资源不会被提前回收掉

总结

本篇以两种实际场景为例,抛出了一个很切合实际项目的问题:在Spring应用中如何正确地关闭线程池。文中指出,如果非正常关闭将可能会产生异常的问题,同时也分析了问题产生的原因并给出了相应的解决方案。下一篇,将会具体分析Spring Shutdown Hook执行过程,与诸君共同探索其中的奥秘

思考

本文虽以"Spring环境中正确关闭线程池"为背景进行讨论,然而实际上思维还可以更发散一些,可以不限于Spring环境,也不限于"关闭线程池"这个行为。更一般化地,在一个应用上下文环境中,许多的Bean有相互依赖的关系,这种依赖关系在应用启动及应用关闭之时需要格外地注意:在启动时,被依赖的Bean需要先行构造完毕;在关闭时,被依赖的Bean需要靠后销毁。依托这个思想,只要找到应用上下文提供给我们的扩展点,就可以达到目的

以上为个人经验,希望能给大家一个参考,也希望大家多多支持。

相关文章