我們們從頭到尾說一次優雅關閉

空無發表於2021-11-22

優雅關閉(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 執行緒,等待執行緒自己執行完成,然後再關閉。

參考

相關文章