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

前言

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);

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

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

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

  1. shutdown

    • 拒绝新任务提交

    • 待执行的任务不会取消

    • 正在执行的任务也不会取消,将继续执行

  2. 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);
    });
}
  1. 自定义线程池,用于异步任务的执行。此处为演示方便使用Executors.newFixedThreadPool(1)生成了只有一个线程的线程池
  2. 高并发请求/incr接口,每次请求该接口,都会往线程池中添加一个任务,任务异步执行的过程中依赖Redis

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

  1. 切断上游流量入口,确保不再有流量进入到当前节点
  2. 向应用发送kill 命令,在设定的时间内待应用正常关闭,若超时后应用仍然存活,则使用kill -9命令强制关闭
  3. 当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连接池先行被回收

image

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

image
image
场景2

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

例如:

ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);

scheduledExecutorService.scheduleWithFixedDelay(() -> {
    // load from db and put into cache
    // ...
    
}, 100, 100, TimeUnit.MILLISECONDS);
  1. 每100ms向线程池里扔一个任务
  2. 任务是:从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 执行过程,具体原理本篇按下不表,留待下一篇进行分析

image

从上图中可以看出,只要在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,且在定义线程池时就将优雅关闭行为一同定义完毕,实现了高内聚的目的

image
image

在Spring中使用ThreadPoolTaskExecutor,更便捷:

  • 不用再自行管理线程池,获取的时候也很方便,直接注入即可

  • 在需要关闭的时候,直接调用destroy方法即可实现优雅关闭

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

总结

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

思考

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

题外话

做为一名合格的工程师,我认为起码得有工匠精神,有义务让自己的应用更健壮,对应用的整个执行过程如数家珍,尽可能消灭潜在的隐患,努力做到0 error(s), 0 warning(s),如此才对的起我们的称呼,否则,和闲鱼有什么分别?

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268