一個線程罷工的詭異事件

背景

事情(事故)是這樣的,忽然收到報警,線上某個應用裏業務邏輯沒有執行,致使的結果是數據庫裏的某些數據沒有更新。java

雖然是前人寫的代碼,但做爲 Bug maker&killer 只能咬着牙上了。git

由於以前沒有接觸過出問題這塊的邏輯,因此簡單理了下如圖:github

  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,以前的團隊應該只是看了個高性能的介紹就直接使用,並無深究其原理;致使出現問題後對它拿不許。

實例代碼:

https://github.com/crossoverJie/JCSprout/blob/master/src/main/java/com/crossoverjie/thread/ThreadExceptionTest.java

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

相關文章
相關標籤/搜索