优雅关闭(Graceful Shutdown/Graceful Exit),这个词好像并没有什么官方的定义,也没找到权威的来源,不过在Bing里搜索 Graceful Exit,出现的第二条却是个专门为女性处理离婚的网站……
好家伙,女性离婚一站式解决方案,这也太专业了。看来不光是程序需要优雅关闭,就连离婚也得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 信号,那么就会出现数据不一致的问题,本服务数据已经落库,但没有推送三方……
再举一个数据库的例子,存储引擎有聚集索引和非聚集索引的概念,如果一条 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
提供了两个关闭方法:
shutdown
- interrupt 空闲的 Worker线程,等待所有任务(线程)执行完成。因为空闲 Worker 线程会处于 WAITING 状态,所以interrupt 方法会直接中断 WAITING 状态,停止这些空闲线程。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,流程如下图所示:
接着 Tomcat 里注册的 ShutdownHook
线程会被执行,手动的关闭各种资源,比如 Tomcat 自己的连接,线程池等等。
当然还有最重要的一步,关闭所有的 APP:
// org.apache.catalina.core.StandardContext#stopInternal
// 关闭所有应用下的所有 Filter - filter.destroy();
filterStop();
// 关闭所有应用下的所有 Listener - listener.contextDestroyed(event);
listenerStop();
借助这俩关闭前的 Hook,应用程序就可以自行处理关闭了,比如在 XML 时代时使用的Servlet Context Listener:
org.springframework.web.context.ContextLoaderListener
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的顺序是相反的:
销毁时使用相反的顺序,就可以保证依赖 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 的加载顺序逆序的依次销毁:
由于 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。
K8S 优雅关闭
这里说的是 K8S 优雅关闭 POD 的机制,和前面介绍的 Tomcat 关闭脚本类似,都是先发送 SIGTERM Signal ,N秒后如果进程还在,就 Force Kill。
只是 Kill 的发起者变成了 K8S/Runtime,容器运行时会给 Pod 内所有容器的主进程发送 Kill(TERM) 信号:
同样的,如果在宽限期内(terminationGracePeriodSeconds
,默认30秒) ,容器内的进程没有处理完成关闭逻辑,进程会被强制杀死。
当K8S遇到 SpringBoot(Executeable Jar)
没什么特殊的,由 K8S 对 Spring Boot 进程发送 TERM 信号,然后执行 Spring Boot 的 ShutdownHook
当K8S遇到 Tomcat
和 Tomcat 的 catalina.sh
关闭方式完全一样,只是这个关闭的发起者变成了 K8S
总结
说了这么多的优雅关闭,到底怎么算优雅呢?这里简单总结 3 点:
- 作为框架/库,一定要提供正常关闭的方法,手动的关闭线程/线程池,销毁连接资源,FD资源等
- 作为应用程序,一定要处理好 InterruptedException,千万不要忽略这个异常,不然有进程无法正常退出的风险
- 在关闭时,一定要注意顺序,尤其是线程池类的资源,一定要保证线程池先关闭。最安全的做法是不要 interrupt 线程,等待线程自己执行完成,然后再关闭。
参考
- https://kubernetes.io/zh/docs/concepts/workloads/pods/pod-lifecycle/
- https://github.com/apache/tomcat
- https://whatis.techtarget.com/definition/graceful-shutdown-and-hard-shutdown
- https://www.wikiwand.com/en/Graceful_exit
- https://docs.spring.io/spring-boot/docs/2.3.0.RELEASE/reference/html/spring-boot-features.html#boot-features-graceful-shutdown