不同業務使用同一個執行緒池發生死鎖

2024.09.06

在我們進行程式碼開發時,我也看過很多全域註冊一個自訂線程池(也有可能不是自訂的,直接使用更不推薦Executors 創建的線程池),也許是業務量不高、也許是其他原因,反正全域可這一線程池使勁造。

一、看個代碼

業務邏輯代碼:

自訂執行緒池BizThreadPool 程式碼如下:

透過上方的程式碼範例,如果你還沒看出問題,那你可以停留幾秒鐘思考一下。

自訂線程池創建,使用的這個隊列,嗯......,大家工作中一定不要這麼用,此處只是為了做演示使用。

如果你已經看出來了問題所在,也希望你能繼續看下去,驗證一下咱們是不是想的相同。

二、有啥問題

經過短暫幾秒鐘的思考之後,決定還是執行Demo 看看現象。

封裝一個controller 直接啟動Springboot 程序,Java 啟動。

啟動成功之後呼叫GET http://localhost:8080/test/test,輸出結果如下。

按照我們的預期,日誌中應該也要輸出子任務才對啊,怎麼建立的子任務沒有輸出呢,看現象應該是沒有執行。

那我們先執行一下jstack 指令看一下線程相關的訊息,輸出訊息中其中一段如下所示。

透過上面的堆疊資訊可以看出,主執行緒在將父任務執行完成之後,開啟了一個CountDownLatch並等待3個子任務執行完成。

問題就在這,一直等待,一直等不到結果,所以就是我們剛開始看到的結果,只有父任務執行了,子任務並沒有執行。

一次呼叫沒有回應,多次呼叫之後,達到伺服器資源瓶頸時系統就該發生崩潰了。

那麼子任務為何沒有執行到呢?

三、小試牛刀

首先我們從頭開始捋一下,先看下線程池的配置。

我們在建立自訂執行緒池時,核心執行緒與最大執行緒都設定的1,那我們直接修改最大執行緒數量,讓執行緒池有執行緒可以執行子任務不就行了嗎?

對於生產中,核心線程與最大線程一般也不會設定為1,但是即使你設定為10、100、1000,極端情況下也會出現本文後面將要講述的問題。

說乾就乾,創建自訂線程池的程式碼變成如下形式。

非常有自信的你重啟程序,然後呼叫接口,最終傻眼了,怎麼沒變化?

如果你修改完最大執行緒數就去重啟程式的話,表示執行緒池的工作原理你已經忘了!

好吧原諒你了,這次不准再忘了,下面跟我一起來看看這究竟是什麼原因。

四、執行緒池工作流程

這裡放一下執行緒池的工作流程。

面試官:執行緒池核心執行緒設定為0時任務執行流程怎麼樣的

在知道了執行緒池的工作流程之後,在上述程式碼中,即使增加了最大執行緒池的數量,最終子任務也不會執行到,我們可以列印一下目前執行緒池的狀態進行輔助觀察。 (上述程式碼的printThreadPoolStatus()方法會進行執行緒池目前狀態的列印)

呼叫GET http://localhost:8080/test/info方法查看執行緒池目前的狀態。

可以看到佇列中存在3個任務在排隊,等待執行緒池分配執行緒執行任務。這也就是修改了最大執行緒池數量未生效的原因,因為還有一個無界佇列。

當然如果任務一直增加,佇列中任務數量越來越多,達到伺服器的瓶頸,就會發生OOM了。 (阿里開發規範中不建議使用無界隊列的原因)

五、修改核心執行緒數量

那我們直接修改核心執行緒數量吧,核心執行緒超過任務數量?

回答:不行。

對於我們上面的例子來說,增加核心線程數量,擁有可以執行子任務的線程,確實可以解決當下場景。

但是當並發量上來之後,或者說線程池的線程都被父線程所佔用時,依舊會發現子任務無法獲得線程執行。

此處我們修改核心執行緒為10執行看一下輸出結果。

透過修改核心執行緒數量,解決了子任務在隊列中堆積的問題。

所以透過上述程式碼,大家應該知道死鎖是怎麼發生的了吧,這裡我總結一下。

六、小結

當核心執行緒為1,最大執行緒為1,使用無界隊列。父任務在執行緒等待子任務完成的通知,子任務在執行緒池的任務佇列中等待執行緒池調度執行緒資源。

當核心執行緒為1,最大執行緒為n,使用無界隊列。最大執行緒設定n與設定1沒有差別,除非使用的佇列不同,只要是使用的無界佇列,當資源耗盡之時,就是服務崩潰的時候。此時後面新的父任務到來時,也只會在任務隊列中繼續累積。

當核心執行緒為n,最大執行緒為n,使用無界隊列。核心執行緒設定為n,表示父執行緒大機率是可以執行的,建立的子任務在任務佇列中排隊執行。

當並發量上來,或是核心執行緒都被父任務所佔據之後,執行緒池呼叫就變成瞭如下場景,所有的任務都被堆積在任務佇列當中:

核心執行緒全是父任務,後面建立的任務也都在任務佇列堆積,最終達到伺服器瓶頸系統OOM。

七、最終解決方案

透過上述程式碼範例,死鎖的根本原因在於,父任務會建立多個子任務,並等待子任務執行結束,而父子任務都是使用的同一個執行緒池,當執行緒池中執行緒都是父任務時,所有的子任務又都在任務佇列中等待執行,所以這樣就會發生死鎖。

核心執行緒永遠不會釋放,從而造成任務隊列不斷堆積,直到OOM。

所以解決方案就是,隔離線程池。

不同的業務使用不同的執行緒池,使用一個新的執行緒池處理子任務,這樣就可以避免死鎖的發生了。

修改之後的程式碼如下。

透過查看日誌輸出可以發現,執行緒池隔離之後,即使核心執行緒設定為1,也是可以正常執行業務邏輯的,任務佇列中也沒有堆積任務。

八、總結

透過上面的Demo 復現以及解決方案,在工作中優化建議如下:

  • 禁止使用Executors建立自訂線程池。使用ThreadPoolExecutor建立執行緒池時,注意每個參數的意義,並規避資源耗盡的風險。
  • 執行緒池使用有界隊列,避免使用無界隊列。
  • 對於父子任務的場景,可以使用執行緒池或MQ。使用有界隊列之後,制定合理的拒絕策略,拒絕策略可以考慮MQ 做重試。
  • 不同業務使用不同的執行緒池,禁止父子任務使用相同的執行緒池。