優雅關閉(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:
<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的順序是相反的:
銷燬時使用相反的順序,就可以保證依賴 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