來自:crossoverJie(微訊號:crossoverJie)
背景
事情(事故)是這樣的,突然收到報警,線上某個應用裡業務邏輯沒有執行,導致的結果是資料庫裡的某些資料沒有更新。
雖然是前人寫的程式碼,但作為 Bugmaker&killer;
只能咬著牙上了。
因為之前沒有接觸過出問題這塊的邏輯,所以簡單理了下如圖:
- 有一個生產執行緒一直源源不斷的往佇列寫資料。
- 消費執行緒也一直不停的取出資料後寫入後續的業務執行緒池。
- 業務執行緒池裡的執行緒會對每個任務進行入庫操作。
整個過程還是比較清晰的,就是一個典型的生產者消費者模型。
嘗試定位
接下來便是嘗試定位這個問題,首先例行檢查了以下幾項:
- 是否記憶體有記憶體上限溢位?
- 應用 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
也能看出它會一直從內建的佇列取出任務。
而一旦佇列是空的,它就會 waiting
在 workQueue.take()
,也就是我們從堆疊中發現的 1067 行程式碼。
執行緒名字的變化
上文還提到了異常後的執行緒名稱發生了改變,其實在 addWorker()
方法中可以看到 newWorker()
時就會重新命名執行緒的名稱,預設就是把字尾的計數+1。
這樣一切都能解釋得通了,真相只有一個:
在單個執行緒的執行緒池中一但丟擲了未被捕獲的異常時,執行緒池會回收當前的執行緒並建立一個新的
Worker
;
它也會一直不斷的從佇列裡獲取任務來執行,但由於這是一個消費執行緒,根本沒有生產者往裡邊丟任務,所以它會一直 waiting 在從佇列裡獲取任務處,所以也就造成了線上的佇列沒有消費,業務執行緒池沒有執行的問題。
總結
所以之後線上的那個問題加上異常捕獲之後也變得正常了,但我還是有點納悶的是:
既然後續所有的任務都是在執行緒池中執行的,也就是純非同步了,那即便是出現異常也不會拋到消費執行緒中啊。
這不是把我之前儲備的知識點推翻了嘛?不信邪!之後我讓運維給了加上異常捕獲後的線上錯誤日誌。
結果發現在上文提到的眾多 switchcase
中,最後一個竟然是直接操作的資料庫,導致一個非空欄位報錯了?!!
這事也給我個教訓,還是得眼見為實啊。
雖然這個問題改動很小解決了,但復盤整個過程還是有許多需要改進的:
- 消費佇列的執行緒名稱竟然和業務執行緒的字首一樣,導致我光找它就花了許多時間,命名必須得調整。
- 開發規範,防禦式程式設計大家需要養成習慣。
- 未知的技術棧需要謹慎,比如
disruptor
,之前的團隊應該只是看了個高效能的介紹就直接使用,並沒有深究其原理;導致出現問題後對它拿不準。
實體程式碼:
https://github.com/crossoverJie/JCSprout/blob/master/src/main/java/com/crossoverjie/thread/ThreadExceptionTest.java
評論也精彩
1、在文章開頭就說了,導致資料庫某些欄位未更新,如果讓我推斷的話,sql出現問題的可能性更大,應該會先查詢異常日誌進行排查。
2、文中說當出現異常以後,會往上丟擲,但是finally程式碼是始終會執行的,也就是說肯定會建立一個新的消費執行緒,和異常捕獲沒關係吧
3、文中說執行緒池中的執行緒和消費執行緒是純非同步的,這個怎麼理解呢,在我看來,消費執行緒不就是執行緒池中那唯一的一個執行緒嘛
4、一般我們自定義生產者或者消費者,都會進行判斷,如果佇列滿了,則生產執行緒不在生產,如果佇列為空,則消費執行緒不在消費
作者
- sql異常是應該看,但不是根本原因 這次是sql問題可能下次就是其他問題了。
- 文中說了,異常沒有捕獲會導致當前執行緒被回收同時建立一個新的執行緒,但新的執行緒沒法接著之前的執行緒工作,因為沒有新的任務丟進去。(原始碼也列出來了,可以自己模擬下)
- 唯一的那個消費執行緒和業務執行緒不是一個東西,唯一的執行緒消費出來的資料會直接丟到後面的業務執行緒池裡,這裡的純非同步指的是唯一執行緒做的事情和業務執行緒池做的事情是非同步的,它丟進去就不管了。
- 難道你們使用佇列的時候每次put都要看佇列是否滿了嘛?如果是多執行緒put那豈不是判斷的時候還要保證執行緒安 同時對於無界佇列怎麼判斷 或者是阻塞佇列的意義在哪兒 通常我們只需要定義好執行緒池的拒絕策略就可以了,佇列的意義不就是肖峰限流緩衝了嘛
說下我的理解:執行緒池必須每次手動提交一個runnaber,或者callable。執行緒池的work才知道我要做什麼,因為文中只有消費者執行緒一直一個人不停的做一件事,所以他死了,即使執行緒池會建立新執行緒,他依然不知道我的任務是啥。
舉個例子:勞務派遣所提供人,他給我了一個人A,我讓A讓他去集裝箱給我搬東西,,後來A罷工了,勞務所又派來個B,B來了之後就很鬱悶,因為他不知道要幹嘛,沒人給他指定任務。所以就一直等著別人給他任務了。
我想麻煩您再多為我解釋一下,唯一的那個消費執行緒,和業務執行緒池是兩個概念,這個唯一的執行緒是放在另一個執行緒池裡面的,所以這個消費執行緒掛了一遍以後,新執行緒不知道乾啥?
作者
我得描述沒有說清楚。
其實結合文初的那個流程圖應該很容易理解,唯一的那個消費執行緒就是圖中的consumer ,而業務執行緒池是最下邊的藍色雲朵,兩個執行緒池裡都有兩個佇列。
他們兩個毫無關係,主要的問題還是consumer中唯一的執行緒掛了之後新來的執行緒(worker)去佇列裡取任務時根本就取不到任務,這個執行緒池不是下邊那個藍色雲朵,他並沒有生產者。
我覺得,舊的執行緒1從工作佇列獲取任務來執行,現在舊執行緒出異常了,把它殺掉,重新生成一個執行緒2,執行緒2頂替1的功能,一直來獲取原來工作佇列的任務,感覺是可以的。出現你說的佇列滿了,而執行緒2又沒有消費的原因,只可能是執行緒2消費的佇列跟執行緒1消費的佇列不是同一個佇列。
作者
總共就是一個佇列 兄弟你真的想多了,一個執行緒用while 迴圈一直取佇列的資料,突然掛掉 新的執行緒除非在run方法裡寫的寫的也是while 取佇列的資料。
執行緒池裡接收的可是一個執行緒,具體的處理邏輯的是這個執行緒寫好的,這裡等於就是建立了一個新的worker 他咋知道應該做啥事呢?
既然是消費執行緒,那它從哪裡消費任務,不是原來的工作佇列嗎
作者
它的任務就是一個while 迴圈,沒有其他執行緒給他丟任務。難道平時消費佇列不是這麼寫的?
新的worker執行緒為什麼拿不到任務?
作者
文中都有提到。
它也會一直不斷的從佇列裡獲取任務來執行,但由於這是一個消費執行緒,根本沒有生產者往裡邊丟任務,所以它會一直 waiting 在從佇列裡獲取任務處,
大佬,說一下我自己的理解可以嗎?如果理解的對麻煩您回覆一下,執行緒池中生產執行緒,我們向執行緒池中提交的無論是Runnable的實體還是Callable的實體,都可以理解為任務,執行緒去執行任務,這樣也就可以解釋為什麼執行緒池的原始碼是task.run()了,您覺得這樣理解對嗎?
作者
可以這麼理解,下次我會著重探討這個事情哈。
文中:
在單個執行緒的執行緒池中一但丟擲了未被捕獲的異常時,執行緒池會回收當前的執行緒並建立一個新的 Worker;
它也會一直不斷的從佇列裡獲取任務來執行,但由於這是一個消費執行緒,根本沒有生產者往裡邊丟任務,所以它會一直 waiting 在從佇列裡獲取任務處,所以也就造成了線上的佇列沒有消費,業務執行緒池沒有執行的問題
作者這裡的生產者與消費者應該是提交任務到執行緒池與執行緒池中執行緒跑任務,與文章一開始提到業務訊息佇列的消費者與生產者的概念重用了,可能導致讀者將此處的消費者與生產者代入到業務訊息佇列中去。
作者
嗯 有可能 但我我觀察到大部分沒理解的都沒有搞清楚執行緒池是如何透過Worker來進行排程執行緒的。
看了評論,好多人都沒理解建立了新執行緒,這個新執行緒為什麼沒有繼續做舊執行緒的任務。
我覺得是程式碼中的例子有點混淆,執行緒池中提交了2個任務,一個就是列印很簡單,第二個做+1操作,大家誤解留在第二個操作的理解,估計大家把每次+1都誤以為是一個任務提交給執行緒池了,但是這裡就只有2個任務
作者
我的鍋 沒解釋清楚
> 但由於這是一個消費執行緒,根本沒有生產者往裡邊丟任務,所以它會一直 waiting 在從佇列裡獲取任務處。
不太明白 如果佇列沒有任務 那原先第一個執行緒是在執行什麼?為什麼新執行緒不能繼續執行原執行緒的活?
作者
這個問題我起碼回答不下十次了。。。
1. 可以理解為原先的執行緒在執行一個 while 迴圈,迴圈裡是業務邏輯(把資料丟到後續的執行緒池裡),
2. 置頂裡的一個回覆我覺得說的挺清楚的,大家需要先把執行緒池的排程搞清楚更容易理解。
2.1 他會把執行緒池裡的任務封裝為一個 Worker,由這個Worker 從佇列裡獲取任務執行他的 run 方法,**註意是 run 而不是 start()**。
2.2 再貼一次:
說下我的理解:執行緒池必須每次手動提交一個runnaber,或者callable。執行緒池的work才知道我要做什麼,因為文中只有消費者執行緒一直一個人不停的做一件事,所以他死了,即使執行緒池會建立新執行緒,他依然不知道我的任務是啥。
舉個例子:勞務派遣所提供人,他給我了一個人A,我讓A讓他去集裝箱給我搬東西,,後來A罷工了,勞務所又派來個B,B來了之後就很鬱悶,因為他不知道要幹嘛,沒人給他指定任務。所以就一直等著別人給他任務了。
Run1執行緒是一個worker,他負責不斷從阻塞佇列中取。Run 1所在的執行緒池是維護Run 1這樣的一批worker。這個執行緒池max為1,即當前只有一個worker在工作,如果該worker執行完畢,池就從佇列裡取出下一個worker do work。所以當這個唯一的worker因異常結束,池就在從worker佇列取下個worker,阻塞發生在此處。拙見
作者
是的,Worker 只是負責把你丟進來的任務執行,但它並知道你要做什麼。