我们从头至尾说一次优雅关闭

2021年11月22日 阅读数:7
这篇文章主要向大家介绍我们从头至尾说一次优雅关闭,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

优雅关闭(Graceful Shutdown/Graceful Exit),这个词好像并无什么官方的定义,也没找到权威的来源,不过在Bing里搜索 Graceful Exit,出现的第二条倒是个专门为女性处理离婚的网站……image.png
好家伙,女性离婚一站式解决方案,这也太专业了。看来不光是程序须要优雅关闭,就连离婚也得Graceful!html

在计算机里呢,优雅关闭指的其实就是程序的一种关闭方案。那既然有优雅关闭,确定也有不优雅的关闭了。java

Windows 的优雅关闭

就拿 Windows 电脑开关机这事来讲,长按电源键强制关机,或者直接断电关机,这种就属于硬关闭(hard shutdown),操做系统接收不到任何信号就直接没了,多不优雅!react

此时系统内,或者一些软件尚未进行关闭前的处理,好比你加班写了4个小时的PPT来没来得及保存……git

但通常除了死机以外,不多会有人强制关机,大多数人的操做仍是经过电源选项->关机操做,让操做系统本身处理关机。好比 Windows 在关机前,会主动的关闭全部应用程序,但是不少应用会捕获进程的关闭事件,致使本身没法正常关闭,从而致使系统没法正常关机。好比 office 套件里,在关闭以前若是没保存会弹框让你保存,这个机制就会干扰操做系统的正常关机。github

或者你用的是 Win10,动不动就本身更新系统的那种,若是你在更新系统的时候断电强制关机,再次开机的时候可能就会有惊喜了……更新文件写了一半,你猜猜会出现什么问题?web

网络中的优雅关闭

网络是不可靠的!

TCP 的八股文相信你们都背过,四次挥手后才能断开链接,但四次挥手也是创建在正常关闭的前提下。若是你强行拔网线,或者强制断电,对端不可能及时的检测到你的断开,此时对端若是继续发送报文,就会收到错误了。redis

你看除了优雅的四次挥手,还有 TCP KeepAlive 作心跳,光有这个还不够,应用层还得再作一层心跳,同时还得正确优雅的处理链接断开,Connection Reset 之类的错误。spring

因此,若是咱们在写一个网络程序时,必定要提供关闭机制,在关闭事件中正常关闭 socket/server,从而减小由于关闭致使的更多异常问题。数据库

怎么监听关闭事件?

各类语言都会提供这个关闭事件的监听机制,只是用法不一样。借助这个关闭监听,实现优雅关闭就很轻松了。apache

JAVA 监听关闭

JAVA 提供了一个简单的关闭事件的监听机制,能够接收到正常关闭信号的事件,好比命令行程序下的 Ctrl+C 退出信号。

Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("Before shutdown...");
    }
}));

在这段配置完成后,正常关闭前,ShutdownHook的线程就会被启动执行,输出 Before shutdown。固然你要是直接强制关闭,好比Windows下的结束进程,Linux 下的 Kill -9……神仙都监听不到

C++ 里监听关闭

C++ 里也有相似的实现,只要将函数注册到atexit函数中,在程序正常关闭前就能够执行注册的fnExit函数。

void fnExit1 (void)
{
  puts ("Exit function 1.");
}

void fnExit2 (void)
{
  puts ("Exit function 2.");
}

int main ()
{
  atexit (fnExit1);
  atexit (fnExit2);
  puts ("Main function.");
  return 0;
}

关闭过程当中可能会遇到的问题

设想这么一个场景,一个消息消费逻辑,事务提交成功后推送周边系统。早就收到了关闭信号,可是因为有大量消息堆积,一部分已经堆积在内存队列了,但是并行消费处理的逻辑一直没执行完。

此时有部分消费线程提交事务,尚未推送周边系统时,就收到了 Force Kill 信号,那么就会出现数据不一致的问题,本服务数据已经落库,但没有推送三方……
graceful_shutdown_04.drawio.png
再举一个数据库的例子,存储引擎有汇集索引和非汇集索引的概念,若是一条 Insert 语句执行后,刚写了汇集索引,还没来得及写非汇集索引,进程就被干掉了,那么这俩索引数据直接就不一致了!

不过做为存储引擎,必定会处理这个不一致的问题。但若是能够正常关闭,让存储引擎安全的执行完,这种不一致的风险就会大大下降。

进程中止

JAVA 进程中止的机制是,全部非守护线程都已经中止后,进程才会退出。那么直接给JAVA进程发一个关闭信号,进程就能关闭吗?确定不行!

JAVA 里的线程默认都是非阻塞线程,非守护线程会只要不停,JVM 进程是不会中止的。因此收到关闭信号后,得自行关闭全部的线程,好比线程池……

线程中断

线程怎么主动关闭?抱歉,这个真关不了(stop 方法从JAVA 1.1就被废弃了),只能等线程本身执行完成,或者经过软状态加 interrupt 来实现:

private volatile boolean stopped = false;

@Override
public void run() {
    while (!stopped && Thread.interrupted()){
        // do sth...
    }
}

public void stop(){
    stopped = true;
    interrupt();
}

当线程处于 WAITTING 状态时,interrupt 方法会中断这个 WAITTING 的状态,强制返回并抛出 InterruptedException 。好比咱们的线程正在卡在 Socket Read 操做上,或者 Object.wait/JUC 下的一些锁等待状态时,调用 interrupt 方法就会中断这个等待状态,直接抛出异常。

但若是线程没卡在 WAITING 状态,并且仍是在线程池中建立的,没有软状态,那上面这个关闭策略可就不太适用了。

线程池的关闭策略

ThreadPoolExecutor 提供了两个关闭方法:

  1. shutdown - interrupt 空闲的 Worker线程,等待全部任务(线程)执行完成。由于空闲 Worker 线程会处于 WAITING 状态,因此interrupt 方法会直接中断 WAITING 状态,中止这些空闲线程。
  2. shutdownNow - interrupt 全部的 Worker 线程,不论是不是空闲。对于空闲线程来讲,和 shutdown 方法同样,直接就被中止了,能够对于正在工做中的 Worker 线程,不必定处于 WAITING状态,因此 interrupt 就不能保证关闭了。

注意:大多数的线程池,或者调用线程池的框架,他们的默认关闭策略是调用 shutdown,而不是 shutdownNow,因此正在执行的线程并不必定会被 Interrupt

但做为业务线程,必定要处理 **InterruptedException**。否则万一有shutdownAll,或者是手动建立线程的中断,业务线程没有及时响应,可能就会致使线程完全没法关闭了

三方框架的关闭策略

除了 JDK 的线程池以外,一些三方框架/库,也会提供一些正常关闭的方法。

  • Netty 里的 EventLoopGroup.shutdownGracefully/shutdown - 关闭线程池等资源
  • Reddsion 里的 Redisson.shutdown - 关闭链接池的链接,销毁各类资源
  • Apache HTTPClient 里的 CloseableHttpClient.close - 关闭链接池的链接,关闭 Evictor 线程等

这些主流的成熟框架,都会给你提供一个优雅关闭的方法,保证你在调用关闭以后,它能够销毁资源,关闭它本身建立的线程/池。

尤为是这种涉及到建立线程的三方框架,必需要提供正常关闭的方法,否则可能会出现线程没法关闭,致使最终 JVM 进程不能正常退出的状况。

Tomcat 里的优雅关闭

Tomcat 的关闭脚本(sh 版本)设计的很不错,直接手摸手的告诉你应该怎么关:

commands:
    stop              Stop Catalina, waiting up to 5 seconds for the process to end
    stop n            Stop Catalina, waiting up to n seconds for the process to end
    stop -force       Stop Catalina, wait up to 5 seconds and then use kill -KILL if still running
    stop n -force     Stop Catalina, wait up to n seconds and then use kill -KILL if still running

这个设计很灵活,直接提供 4 种关闭方式,任你随便选择。

force 模式下,会给进程发送一个 SIGTERM Signal(kill -15),这个信号是能够被 JVM 捕获到的,会执行注册的 ShutdownHook 线程。等待5秒后若是进程还在,就 Force Kill,流程以下图所示:
graceful_shutdown_02.drawio.png

接着 Tomcat 里注册的 ShutdownHook 线程会被执行,手动的关闭各类资源,好比 Tomcat 本身的链接,线程池等等。

固然还有最重要的一步,关闭全部的 APP:

// org.apache.catalina.core.StandardContext#stopInternal

// 关闭全部应用下的全部 Filter - filter.destroy();
filterStop();
// 关闭全部应用下的全部 Listener - listener.contextDestroyed(event);
listenerStop();

借助这俩关闭前的 Hook,应用程序就能够自行处理关闭了,好比在 XML 时代时使用的Servlet Context Listener:

<listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>

Spring 在这个 Listener 内,自行调用 Application Context 的关闭方法:

public void contextDestroyed(ServletContextEvent event) {
    // 关闭 Spring Application Context
    this.closeWebApplicationContext(event.getServletContext());
    ContextCleanupListener.cleanupAttributes(event.getServletContext());
}

Spring 的优雅关闭

在 Spring ApplicationContext 执行 close 后,Spring 会对全部的 Bean 执行销毁动做,只要你的 Bean 配置了 destroy 策略,或者实现了 AutoCloseable 接口 ,那么 Spring 在销毁 Bean 时就能够调用 destroy 了,好比 Spring 包装的线程池 - ThreadPoolTaskExecutor,它就实现了 DisposableBean 接口:

// ThreadPoolTaskExecutor
public void destroy() {
    shutdown();
}

在 destroy Bean 时,这个线程池就会执行 shutdown,不须要你手动控制线程池的 shutdown。

这里须要注意一下,Spring 建立 Bean 和销毁 Bean的顺序是相反的:

spring_bean_priority.drawio.png
销毁时使用相反的顺序,就能够保证依赖 Bean 能够正常被销毁,而不会提早销毁。好比 A->B->C这个依赖关系中,咱们必定会保证C先加载;那么在若是先销毁 C 的话 ,B可能还在运行,此时B可能就报错了。

因此在处理复杂依赖关系的 Bean 时,应该让前置 Bean 先加载,线程池等基础 Bean 最后加载,销毁时就会先销毁线程池这种基础 Bean了。


大多数须要正常关闭的框架/库在集成 Spring 时,都会集成 Spring Bean 的销毁入口。

好比 Redis 客户端 - Lettuce,spring-data-redis 里提供了 lettuce 的集成,集成类 LettuceConnectionFactory 是直接实现 DisposableBean 接口的,在 destroy 方法内部进行关闭

// LettuceConnectionFactory 

public void destroy() {
    this.resetConnection();
    this.dispose(this.connectionProvider);
    this.dispose(this.reactiveConnectionProvider);

    try {
        Duration quietPeriod = this.clientConfiguration.getShutdownQuietPeriod();
        Duration timeout = this.clientConfiguration.getShutdownTimeout();
        this.client.shutdown(quietPeriod.toMillis(), timeout.toMillis(), TimeUnit.MILLISECONDS);
    } catch (Exception var4) {
        if (this.log.isWarnEnabled()) {
            this.log.warn((this.client != null ? ClassUtils.getShortName(this.client.getClass()) : "LettuceClient") + " did not shut down gracefully.", var4);
        }
    }

    if (this.clusterCommandExecutor != null) {
        try {
            this.clusterCommandExecutor.destroy();
        } catch (Exception var3) {
            this.log.warn("Cannot properly close cluster command executor", var3);
        }
    }

    this.destroyed = true;
}

其余框架也是同样,集成 Spring 时,都会基于Spring 的 destroy 机制来进行资源的销毁。

Spring 销毁机制的问题

如今有这样一个场景,咱们建立了某个 MQ 消费的客户端对象,就叫 XMQConsumer 吧。在这个消费客户端中,内置了一个线程池,当 pull 到消息时会丢到线程池中执行。

在消息 MQ 消费的代码中,须要数据库链接池 - DataSource,还须要发送 HTTP 请求 - HttpClient,这俩对象都是被 Spring 托管的。不过 DataSource 和 HttpClient 这俩 Bean 的加载顺序比较靠前,在 XMQConsumer 启动时,这俩 Bean 必定时初始化完成可使用的。

不过这里没有给这个 XMQConsumer 指定 destroy-method,因此 Spring 容器在关闭时,并不会关闭这个消费客户端,消费客户端会继续 pull 消息,消费消息。

此时当 Tomcat 收到关闭信号后,按照上面的关闭流程,Spring 会按照 Bean 的加载顺序逆序的依次销毁:

spring_bean_destroy_order.drawio.png

因为 XMQConsumer 没有指定 destroy ,因此 Spring 只会销毁 #2 和 #3 两个 Bean。但 XMQConsumer 线程池里的线程和主线程但是异步的,在销毁前两个对象时,消费线程仍然在运行,运行过程里须要操做数据库,还须要经过 HttpClient 发送请求,此时就会出现:XXX is Closed 之类的错误。

Spring Boot 优雅关闭

到了 Spring Boot 以后,这个关闭机制发生了一点点变化。由于以前是 Spring 项目部署在 Tomcat 里运行,由Tomcat 来启动 Spring。

可在 Spring Boot(Executeable Jar 方式)中,顺序反过来了,由于是直接启动 Spring ,而后在 Spring 中来启动 Tomcat(Embedded)。启动方式变了,那么关闭方式确定也变了,shutdownHook 由 Spring 来负责,最后 Spring 去关闭 Tomcat。

以下图所示,这是两种方式的启动/中止顺序:
Untitled Diagram.drawio.png

K8S 优雅关闭

这里说的是 K8S 优雅关闭 POD 的机制,和前面介绍的 Tomcat 关闭脚本相似,都是先发送 SIGTERM Signal ,N秒后若是进程还在,就 Force Kill。

只是 Kill 的发起者变成了 K8S/Runtime,容器运行时会给 Pod 内全部容器的主进程发送 Kill(TERM) 信号:
graceful_shutdown_03.drawio.png
一样的,若是在宽限期内(terminationGracePeriodSeconds,默认30秒) ,容器内的进程没有处理完成关闭逻辑,进程会被强制杀死。

当K8S遇到 SpringBoot(Executeable Jar)

没什么特殊的,由 K8S 对 Spring Boot 进程发送 TERM 信号,而后执行 Spring Boot 的 ShutdownHook

当K8S遇到 Tomcat

和 Tomcat 的 catalina.sh 关闭方式彻底同样,只是这个关闭的发起者变成了 K8S

总结

说了这么多的优雅关闭,到底怎么算优雅呢?这里简单总结 3 点:

  1. 做为框架/库,必定要提供正常关闭的方法,手动的关闭线程/线程池,销毁链接资源,FD资源等
  2. 做为应用程序,必定要处理好 InterruptedException,千万不要忽略这个异常,否则有进程没法正常退出的风险
  3. 在关闭时,必定要注意顺序,尤为是线程池类的资源,必定要保证线程池先关闭。最安全的作法是不要 interrupt 线程,等待线程本身执行完成,而后再关闭。

参考