5
头图

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

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

Windows 的优雅关闭

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

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

但一般除了死机之外,很少会有人强制关机,大多数人的操作还是通过电源选项->关机操作,让操作系统自己处理关机。比如 Windows 在关机前,会主动的关闭所有应用程序,可是很多应用会捕获进程的关闭事件,导致自己无法正常关闭,从而导致系统无法正常关机。比如 office 套件里,在关闭之前如果没保存会弹框让你保存,这个机制就会干扰操作系统的正常关机。

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

网络中的优雅关闭

网络是不可靠的!

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

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

所以,如果我们在写一个网络程序时,一定要提供关闭机制,在关闭事件中正常关闭 socket/server,从而减少因为关闭导致的更多异常问题。

怎么监听关闭事件?

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

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 线程,等待线程自己执行完成,然后再关闭。

参考


空无
3.3k 声望4.3k 粉丝

坚持原创,专注分享 JAVA、网络、IO、JVM、GC 等技术干货