一個執行緒罷工的詭異事件

咖啡拿鐵發表於2019-03-13

背景

事情(事故)是這樣的,突然收到報警,線上某個應用裡業務邏輯沒有執行,導致的結果是資料庫裡的某些資料沒有更新。

雖然是前人寫的程式碼,但作為 Bug maker&killer 只能咬著牙上了。

因為之前沒有接觸過出問題這塊的邏輯,所以簡單理了下如圖:

一個執行緒罷工的詭異事件

  1. 有一個生產執行緒一直源源不斷的往佇列寫資料。
  2. 消費執行緒也一直不停的取出資料後寫入後續的業務執行緒池。
  3. 業務執行緒池裡的執行緒會對每個任務進行入庫操作。

整個過程還是比較清晰的,就是一個典型的生產者消費者模型。

嘗試定位

接下來便是嘗試定位這個問題,首先例行檢查了以下幾項:

  • 是否記憶體有記憶體溢位?
  • 應用 GC 是否有異常?

通過日誌以及監控發現以上兩項都是正常的。

緊接著便 dump 了執行緒快照檢視業務執行緒池中的執行緒都在幹啥。

一個執行緒罷工的詭異事件

結果發現所有業務執行緒池都處於 waiting 狀態,佇列也是空的。

同時生產者使用的佇列卻已經滿了,沒有任何消費跡象。

結合上面的流程圖不難發現應該是消費佇列的 Consumer 出問題了,導致上游的佇列不能消費,下有的業務執行緒池沒事可做。

review 程式碼

於是檢視了消費程式碼的業務邏輯,同時也發現消費執行緒是一個單執行緒

一個執行緒罷工的詭異事件

結合之前的執行緒快照,我發現這個消費執行緒也是處於 waiting 狀態,和後面的業務執行緒池一模一樣。

他做的事情基本上就是對訊息解析,之後丟到後面的業務執行緒池中,沒有發現什麼特別的地方。

但是由於裡面的分支特別多(switch case),看著有點頭疼;所以我與寫這個業務程式碼的同學溝通後他告訴我確實也只是入口處解析了一下資料,後續所有的業務邏輯都是丟到執行緒池中處理的,於是我便帶著這個前提去排查了(埋下了伏筆)。

因為這裡消費的佇列其實是一個 disruptor 佇列;它和我們常用的 BlockQueue 不太一樣,不是由開發者自定義一個消費邏輯進行處理的;而是在初始化佇列時直接丟一個執行緒池進去,它會在內部使用這個執行緒池進行消費,同時回撥一個方法,在這個方法裡我們寫自己的消費邏輯。

所以對於開發者而言,這個消費邏輯其實是一個黑盒。

於是在我反覆 review 了消費程式碼中的資料解析邏輯發現不太可能出現問題後,便開始瘋狂懷疑是不是 disruptor 自身的問題導致這個消費執行緒罷工了。

再翻了一陣 disruptor 的原始碼後依舊沒發現什麼問題後我諮詢對 disruptor 較熟的@咖啡拿鐵,在他的幫助下在本地模擬出來和生產一樣的情況。

本地模擬

一個執行緒罷工的詭異事件
一個執行緒罷工的詭異事件

本地也是建立了一個單執行緒的執行緒池,分別執行了兩個任務。

  • 第一個任務沒啥好說的,就是簡單的列印。
  • 第二個任務會對一個數進行累加,加到 10 之後就丟擲一個未捕獲的異常。

接著我們來執行一下。

一個執行緒罷工的詭異事件
一個執行緒罷工的詭異事件

發現當任務中丟擲一個沒有捕獲的異常時,執行緒池中的執行緒就會處於 waiting 狀態,同時所有的堆疊都和生產相符。

細心的朋友會發現正常執行的執行緒名稱和異常後處於 waiting 狀態的執行緒名稱是不一樣的,這個後續分析。

解決問題

一個執行緒罷工的詭異事件

當加入異常捕獲後又如何呢?

一個執行緒罷工的詭異事件

程式肯定會正常執行。

同時會發現所有的任務都是由一個執行緒完成的。

雖說就是加了一行程式碼,但我們還是要搞清楚這裡面的門門道道。

原始碼分析

於是只有直接 debug 執行緒池的原始碼最快了;


一個執行緒罷工的詭異事件

一個執行緒罷工的詭異事件

通過剛才的異常堆疊我們進入到 ThreadPoolExecutor.java:1142 處。

  • 發現執行緒池已經幫我們做了異常捕獲,但依然會往上拋。
  • finally 塊中會執行 processWorkerExit(w, completedAbruptly) 方法。

一個執行緒罷工的詭異事件

看過之前《如何優雅的使用和理解執行緒池》的朋友應該還會有印象。

執行緒池中的任務都會被包裝為一個內部 Worker 物件執行。

processWorkerExit 可以簡單的理解為是把當前執行的執行緒銷燬(workers.remove(w))、同時新增(addWorker())一個 Worker 物件接著處理;

就像是哪個零件壞掉後重新換了一個新的接著工作,但是舊零件負責的任務就沒有了。

接下來看看 addWorker() 做了什麼事情:

一個執行緒罷工的詭異事件

只看這次比較關心的部分;新增成功後會直接執行他的 start() 的方法。

一個執行緒罷工的詭異事件

由於 Worker 實現了 Runnable 介面,所以本質上就是呼叫了 runWorker() 方法。


runWorker() 其實就是上文 ThreadPoolExecutor 丟擲異常時的那個方法。

一個執行緒罷工的詭異事件
一個執行緒罷工的詭異事件

它會從佇列裡一直不停的獲取待執行的任務,也就是 getTask();在 getTask 也能看出它會一直從內建的佇列取出任務。

而一旦佇列是空的,它就會 waitingworkQueue.take(),也就是我們從堆疊中發現的 1067 行程式碼。

執行緒名字的變化

一個執行緒罷工的詭異事件
一個執行緒罷工的詭異事件
一個執行緒罷工的詭異事件

上文還提到了異常後的執行緒名稱發生了改變,其實在 addWorker() 方法中可以看到 new Worker()時就會重新命名執行緒的名稱,預設就是把字尾的計數+1。

這樣一切都能解釋得通了,真相只有一個:

在單個執行緒的執行緒池中一但丟擲了未被捕獲的異常時,執行緒池會回收當前的執行緒並建立一個新的 Worker; 它也會一直不斷的從佇列裡獲取任務來執行,但由於這是一個消費執行緒,根本沒有生產者往裡邊丟任務,所以它會一直 waiting 在從佇列裡獲取任務處,所以也就造成了線上的佇列沒有消費,業務執行緒池沒有執行的問題。

總結

所以之後線上的那個問題加上異常捕獲之後也變得正常了,但我還是有點納悶的是:

既然後續所有的任務都是線上程池中執行的,也就是純非同步了,那即便是出現異常也不會拋到消費執行緒中啊。

這不是把我之前儲備的知識點推翻了嘛?不信邪!之後我讓運維給了加上異常捕獲後的線上錯誤日誌。

結果發現在上文提到的眾多 switch case 中,最後一個竟然是直接操作的資料庫,導致一個非空欄位報錯了?!!

這事也給我個教訓,還是得眼見為實啊。

雖然這個問題改動很小解決了,但覆盤整個過程還是有許多需要改進的:

  1. 消費佇列的執行緒名稱竟然和業務執行緒的字首一樣,導致我光找它就花了許多時間,命名必須得調整。
  2. 開發規範,防禦式程式設計大家需要養成習慣。
  3. 未知的技術棧需要謹慎,比如 disruptor,之前的團隊應該只是看了個高效能的介紹就直接使用,並沒有深究其原理;導致出現問題後對它拿不準。

例項程式碼:

github.com/crossoverJi…

你的點贊與分享是對我最大的支援

一個執行緒罷工的詭異事件

相關文章