轉載自:http://www.infoq.com/cn/articles/netty-elegant-exit-mechanism-and-principles
1.進程的優雅退出
1.1.Kill -9 PID帶來的問題
在Linux上通常會通過kill -9 pid的方式強制將某個進程殺掉,這種方式簡單高效,因此很多程序的停止腳本經常會選擇使用kill -9 pid的方式。
無論是Linux的Kill -9 pid還是windows的taskkill /f /pid強制進程退出,都會帶來一些副作用:對應用軟件而言其效果等同于突然掉電,可能會導致如下一些問題:
- 緩存中的數據尚未持久化到磁盤中,導致數據丟失;
- 正在進行文件的write操作,沒有更新完成,突然退出,導致文件損壞;
- 線程的消息隊列中尚有接收到的請求消息還沒來得及處理,導致請求消息丟失;
- 數據庫操作已經完成,例如賬戶余額更新,準備返回應答消息給客戶端時,消息尚在通信線程的發送隊列中排隊等待發送,進程強制退出導致應答消息沒有返回給客戶端,客戶端發起超時重試,會帶來重復更新問題;
- 其它問題等...
1.2.JAVA優雅退出
Java的優雅停機通常通過注冊JDK的ShutdownHook來實現,當系統接收到退出指令后,首先標記系統處于退出狀態,不再接收新的消息,然后將積壓的消息處理完,最后調用資源回收接口將資源銷毀,最后各線程退出執行。
相關贊助商

QCon全球軟件開發大會上海站,2016年10月20日-22日,上海寶華萬豪酒店,精彩內容搶先看!
通常優雅退出需要有超時控制機制,例如30S,如果到達超時時間仍然沒有完成退出前的資源回收等操作,則由停機腳本直接調用kill -9 pid,強制退出。
2. 如何實現Netty的優雅退出
要實現Netty的優雅退出,首先需要了解通用Java進程的優雅退出如何實現。下面我們先講解下優雅退出的實現原理,并結合實際代碼進行講解。最后看下如何實現Netty的優雅退出。
2.0.1. 信號簡介
信號是在軟件層次上對中斷機制的一種模擬,在原理上,一個進程收到一個信號與處理器收到一個中斷請求可以說是一樣的,它是進程間一種異步通信的機制。以Linux的kill命令為例,kill -s SIGKILL pid (即kill -9 pid) 立即殺死指定pid的進程,SIGKILL就是發送給pid進程的信號。
信號具有平臺相關性,Linux平臺支持的一些終止進程信號如下所示:
信號名稱 | 用途 |
SIGKILL | 終止進程,強制殺死進程 |
SIGTERM | 終止進程,軟件終止信號 |
SIGTSTP | 停止進程,終端來的停止信號 |
SIGPROF | 終止進程,統計分布圖用計時器到時 |
SIGUSR1 | 終止進程,用戶定義信號1 |
SIGUSR2 | 終止進程,用戶定義信號2 |
SIGINT | 終止進程,中斷進程 |
SIGQUIT | 建立CORE文件終止進程,并且生成core文件 |
Windows平臺存在一些差異,它的一些信號舉例如下:SIGINT(Ctrl+C中斷)、SIGILL、SIGTERM (kill發出的軟件終止)、SIGBREAK (Ctrl+Break中斷)。
信號選擇:為了不干擾正常信號的運作,又能模擬Java異步通知,在Linux上我們需要先選定一種特殊的信號。通過查看信號列表上的描述,發現 SIGUSR1 和 SIGUSR2 是允許用戶自定義的信號,我們可以選擇SIGUSR2,為了測試方便,在Windows上我們可以選擇SIGINT。
2.0.2. Java程序的優雅退出
首先看下通用的Java進程優雅退出的流程圖:

第一步,應用進程啟動的時候,初始化Signal實例,它的代碼示例如下:
Signal sig = new Signal(getOSSignalType());
其中Signal構造函數的參數為String字符串,也就是2.1.1小節中介紹的信號量名稱。
第二步,根據操作系統的名稱來獲取對應的信號名稱,代碼如下:
private String getOSSignalType() { return System.getProperties().getProperty("os.name"). toLowerCase().startsWith("win") ? "INT" : "USR2"; }
判斷是否是windows操作系統,如果是則選擇SIGINT,接收Ctrl+C中斷的指令;否則選擇USR2信號,接收SIGUSR2(等價于kill -12 pid)指令。
第三步,將實例化之后的SignalHandler注冊到JDK的Signal,一旦Java進程接收到kill -12 或者 Ctrl+C則回調handle接口,代碼示例如下:
Signal.handle(sig, shutdownHandler);
其中shutdownHandler實現了SignalHandler接口的handle(Signal sgin)方法,代碼示例如下:

第四步,在接收到信號回調的handle接口中,初始化JDK的ShutdownHook線程,并將其注冊到Runtime中,示例代碼如下:
private void invokeShutdownHook() { Thread t = new Thread(new ShutdownHook(), "ShutdownHook-Thread"); Runtime.getRuntime().addShutdownHook(t); }
第五步,接收到進程退出信號后,在回調的handle接口中執行虛擬機的退出操作,示例代碼如下:
Runtime.getRuntime().exit(0);
虛擬機退出時,底層會自動檢測用戶是否注冊了ShutdownHook任務,如果有,則會自動將ShutdownHook線程拉起,執行它的Run方法,用戶只需要在ShutdownHook中執行資源釋放操作即可,示例代碼如下:
class ShutdownHook implements Runnable { @Override public void run() { System.out.println("ShutdownHook execute start..."); System.out.print("Netty NioEventLoopGroup shutdownGracefully..."); try { TimeUnit.SECONDS.sleep(10);//模擬應用進程退出前的處理操作 } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("ShutdownHook execute end..."); System.out.println("Sytem shutdown over, the cost time is 10000MS"); } }
下面我們在Windows環境中對通用的Java優雅退出程序進行測試,打開CMD控制臺,拉起待測試程序,如下所示:
啟動進程:

查看線程信息,發現注冊的ShutdownHook線程沒有啟動,符合預期:

在控制臺執行Ctrl+C,使進程退出,示例如下:

如上圖所示,我們定義的ShutdownHook線程在JVM退出時被執行,作為測試程序,它休眠10S之后退出,控制臺打印的相關信息如下:

下面我們總結下通用的Java程序優雅退出的技術要點:

2.0.3. Netty的優雅退出
在實際項目中,Netty作為高性能的異步NIO通信框架,往往用作基礎通信框架負責各種協議的接入、解析和調度等,例如在RPC和分布式服務框架中,往往會使用Netty作為內部私有協議的基礎通信框架。
當應用進程優雅退出時,作為通信框架的Netty也需要優雅退出,主要原因如下:
- 盡快的釋放NIO線程、句柄等資源;
- 如果使用flush做批量消息發送,需要將積攢在發送隊列中的待發送消息發送完成;
- 正在write或者read的消息,需要繼續處理;
- 設置在NioEventLoop線程調度器中的定時任務,需要執行或者清理。
下面我們看下Netty優雅退出涉及的主要操作和資源對象:

Netty的優雅退出總結起來有三大步操作:
- 把NIO線程的狀態位設置成ST_SHUTTING_DOWN狀態,不再處理新的消息(不允許再對外發送消息);
- 退出前的預處理操作:把發送隊列中尚未發送或者正在發送的消息發送完、把已經到期或者在退出超時之前到期的定時任務執行完成、把用戶注冊到NIO線程的退出Hook任務執行完成;
- 資源的釋放操作:所有Channel的釋放、多路復用器的去注冊和關閉、所有隊列和定時任務的清空取消,最后是NIO線程的退出。
下面我們具體看下如何實現Netty的優雅退出:
Netty優雅退出的接口和總入口在EventLoopGroup,調用它的shutdownGracefully方法即可,相關代碼如下:
bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully();
除了無參的shutdownGracefully方法,還可以指定退出的超時時間和周期,相關接口定義如下:

EventLoopGroup的shutdownGracefully工作原理下個章節做詳細講解,結合Java通用的優雅退出機制,即可實現Netty的優雅退出,相關偽代碼如下:
//統一定義JVM退出事件,并將JVM退出事件作為主題對進程內部發布 //所有需要優雅退出的消費者訂閱JVM退出事件主題 //監聽JVM退出的ShutdownHook被啟動之后,發布JVM退出事件 //消費者監聽到JVM退出事件,開始執行自身的優雅退出 //如果所有的非守護線程都成功完成優雅退出,進程主動退出 //如果到了退出的超時時間仍然沒正常退出,則由停機腳本通過kill -9 pid強殺進程,強制退出
總結一下:JVM的ShutdownHook被觸發之后,調用所有EventLoopGroup實例的shutdownGracefully方法進行優雅退出。由于Netty自身對優雅退出有較完善的支持,所以實現起來相對比較簡單。
2.0.4. 一些誤區
在實際工作中,由于對優雅退出和資源釋放的原理不太清楚,或者對Netty的接口不太了解,很容易把優雅退出和資源釋放混淆,導致出現各種問題。
如下案例:本意是想把某個Channel關閉,但是卻調用了Channel關聯的EventLoop的shutdownGracefully,導致把EventLoop線程和注冊在該線程持有的多路復用器上所有的Channel都關閉了,錯誤代碼如下所示:
ctx.channel().eventLoop().shutdownGracefully();
正確的做法如下所示:調用channel的close方法,關閉鏈路,釋放與該Channel相關的資源:
ctx.channel().close();
除非是整個進程優雅退出,一般情況下不會調用EventLoopGroup和EventLoop的shutdownGracefully方法,更多的是鏈路channel的關閉和資源釋放。
3. Netty優雅退出原理分析
Netty優雅退出涉及到線程組、線程、鏈路、定時任務等,底層實現細節非常復雜,下面我們就層層分解,通過源碼來剖析它的實現原理。
NioEventLoopGroup實際是NioEventLoop的線程組,它的優雅退出比較簡單,直接遍歷EventLoop數組,循環調用它們的shutdownGracefully方法,源碼如下:

調用NioEventLoop的shutdownGracefully方法,首先就是要修改線程狀態為正在關閉狀態,它的實現在父類SingleThreadEventExecutor中,它們的繼承關系如下:

SingleThreadEventExecutor的shutdownGracefully代碼比較簡單,就是修改線程的狀態位,需要注意的是修改時需要對并發調用做判斷,如果是由NioEventLoop自身調用,則不需要加鎖,否則需要加鎖,代碼如下:

解釋下為什么要加鎖,因為shutdownGracefully是public的方法,任何能夠獲取到NioEventLoop的代碼都可以調用它,在Netty中,業務代碼通常不需要直接獲取NioEventLoop并操作它,但是Netty對NioEventLoop做了比較厚的封裝,它不僅僅只能讀寫消息,還能夠執行定時任務,并作為線程池執行用戶自定義Task。因此在Channel中將獲取NioEventLoop的方法開放了出來,這就意味著用戶只要能夠獲取到Channel,理論上就會存在并發執行shutdownGracefully的可能,因此在優雅退出的時候做了并發保護。
完成狀態修改之后,剩下的操作主要在NioEventLoop中進行,代碼如下:

我們繼續看下closeAll的實現,它的原理是把注冊在selector上的所有Channel都關閉,但是有些Channel正在發送消息,暫時還不能關,需要稍后再執行,核心代碼如下:

循環調用Channel Unsafe的close方法,下面我們跳轉到Unsafe中,對close方法進行分析。
AbstractUnsafe的close方法主要做了如下幾件事:
1.判斷當前該鏈路是否有消息正在發送,如果有則將關閉操作封裝成Task放到eventLoop中稍后再執行:

2.將發送隊列清空,不再允許發送新的消息:

3.調用SocketChannel的close方法,關閉鏈路:

4.調用pipeline的fireChannelInactive,觸發鏈路關閉通知事件:

5.最后是調用deregister,從多路復用器上取消SelectionKey:

至此,優雅退出流程已經完成,這是否意味著NioEventLoop線程可以退出了,其實并非如此。
在此處,只是做了Channel的關閉和從Selector上的去注冊,總結如下:
- 通過inFlush0來判斷當前是否正在發送消息,如果是,則不執行Channel關閉動作,放入NIO線程的任務隊列中稍后再執行close()操作;
- 因為已經不允許新的發送消息加入,一旦發送操作完成,就執行鏈路關閉、觸發鏈路關閉事件和從Selector上取消注冊操作。
之前已經說了,NioEventLoop除了I/O讀寫之外,還兼具定時任務執行、關閉ShutdownHook的執行等,如果此時有到期的定時任務,即使Chanel已經關閉,但是仍然需要繼續執行,線程不能退出。下面我們具體分析下TaskQueue的處理流程。
NioEventLoop執行完closeAll()操作之后,需要調用confirmShutdown看是否真的能夠退出,它的處理邏輯如下:
1.執行TaskQueue中排隊的Task,代碼如下:

2.執行注冊到NioEventLoop中的ShutdownHook,代碼如下:

3.判斷是否到達優雅退出的指定超時時間,如果達到或者過了超時時間,則立即退出,代碼如下:

4.如果沒到達指定的超時時間,暫時不退出,每隔100MS檢測下是否有新的任務加入,有則繼續執行:

在confirmShutdown方法中,夾雜了一些對已經廢棄的shutdown()方法的處理,例如:

調用新的shutdownGracefully系列方法,該判斷條件是永遠都不會成立的,因此對于已經廢棄的shutdown相關的處理邏輯,不再詳細分析。
到此為止,confirmShutdown方法講解完畢,confirmShutdown返回true,則NioEventLoop線程正式退出,Netty的優雅退出完成,代碼如下:

3.5.1. runAllTasks重復執行問題
在NioEventLoop的run方法中,已經調用了runAllTasks方法,為何緊隨其后,在confirmShutdown中有繼續調用runAllTasks方法呢,疑問代碼如下:

原因主要有兩個:
1.為了防止定時任務Task或者用戶自定義的線程Task的執行過多占用NioEventLoop線程的調度資源,Netty對NioEventLoop線程I/O操作和非I/O操作時間做了比例限制,即限制非I/O操作的執行時間,如上圖紅框中代碼所示。有了執行時間限制,因此可能會導致已經到期的定時任務、普通任務沒有執行完,需要等待下次Selector輪詢繼續執行。在線程退出之前,需要對本該執行但是沒有執行完成的Task進行掃尾處理,所以在confirmShutdown中再次調用了runAllTasks方法;
2.在調用runAllTasks方法之后,執行confirmShutdown之前,用戶向NioEventLoop中添加了新的普通任務或者定時任務,因此需要在退出之前再次遍歷并處理一遍Task Queue。
3.5.2. 優雅退出是否能夠保證所有在通信線程排隊的消息全部發送出去
實際是無法保證的,它只能保證如果現在正在發送消息過程中,調用了優雅退出方法,此時不會關閉鏈路,繼續發送,如果發送操作完成,無論是否還有消息尚未發送出去,在下一輪Selector的輪詢中,鏈路將會關閉,沒有發送完成的消息將會被丟棄,甚至是半包消息。它的處理原理圖如下:

它的原理比較復雜,現對主要邏輯處理進行解讀:
- 調用優雅退出之后,是否關閉鏈路,判斷標準是inFlush0是否為true,如果為False,則會執行鏈路關閉操作;
- 如果用戶是類似批量發送,例如每達到N條或者定時觸發flush操作,則在此期間調用優雅退出方法,inFlush0為False,鏈路關閉,積壓的待發送消息會被丟棄掉;
- 如果優雅退出時鏈路正好在發送消息過程中,則它不會立即退出,等待發送完成之后,下次Selector輪詢的時候才退出。在這種場景下,又有兩種可能的場景:
場景A:如果一次把積壓的消息全部發送完,沒有發生寫半包,則不會發生消息丟失;
場景B:如果一次沒有把消息發送完成,此時Netty會監聽寫事件,觸發Selector的下一次輪詢并發送消息,代碼如下:

Selector輪詢時,首先處理讀寫事件,然后再處理定時任務和普通任務,因此在鏈路關閉之前,還有最后一次繼續發送的機會,代碼如下:

如果非常不幸,再次發送仍然沒有把積壓的消息全部發送完畢,再次發生了寫半包,那無論是否有積壓消息,執行AbstractUnsafe.close的Task還是會把鏈路給關閉掉,原因是只要完成一次消息發送操作,Netty就會把inFlush0置為false,代碼如下:

鏈路關閉之后,所有尚未發送的消息都將被丟棄。
可能有些讀者會有疑問,如果在第二次發送之后,執行AbstractUnsafe.close之前,業務正好又調用了flush操作,inFlush0是否會被修改成True呢?這個是不可能的,因為從Netty 4.X之后線程模型發生了變更,flush操作不是由用戶線程執行,而是由Channel對應的NioEventLoop線程執行,所以在兩者之間不會發生inFlush0被修改的情況。
Netty 4.X之后的線程模型如下所示:

另外,由于優雅退出有超時時間,如果在超時時間內沒有完成積壓消息的發送,也會發生消息丟棄的情況。
對于上述場景,需要應用層來保證相關的可靠性,或者對Netty的優雅退出機制進行優化。
4. 作者簡介
李林鋒,2007年畢業于東北大學,2008年進入華為公司從事電信軟件的設計和開發工作,有多年Java NIO、平臺中間件設計和開發經驗,精通Netty、Mina、分布式服務框架等,《Netty權威指南》、《分布式服務框架原理與實踐》作者。目前從事云平臺相關的架構和設計工作。