線程池遇到父子任務(wù),有大坑,要注意!
你好呀,我是歪歪。
最近在使用線程池的時(shí)候踩了一個(gè)坑,給你分享一下。
在實(shí)際業(yè)務(wù)場(chǎng)景下,涉及到業(yè)務(wù)代碼和不同的微服務(wù),導(dǎo)致問題有點(diǎn)難以定位,但是最終分析出原因之后,發(fā)現(xiàn)可以用一個(gè)很簡(jiǎn)單的例子來演示。
所以歪師傅這次先用 Demo 說問題,再說場(chǎng)景,方便吸收。
Demo
老規(guī)矩,還是先上個(gè)代碼:
圖片
這個(gè)代碼的邏輯非常簡(jiǎn)單,首先我們搞了一個(gè)線程池,然后起一個(gè) for 循環(huán)往線程池里面仍了 5 個(gè)任務(wù),這是核心邏輯。
對(duì)于這幾個(gè)任務(wù),我們的這個(gè)自定義線程池處理起來,不能說得心應(yīng)手吧,至少也是手拿把掐。
其他的 StopWatch 是為了統(tǒng)計(jì)運(yùn)行時(shí)間用的。至于 CountDownLatch,你可以理解為在業(yè)務(wù)流程中,需要這五個(gè)任務(wù)都執(zhí)行完成之后才能往下走,所以我搞了一個(gè) CountDownLatch。
這個(gè)代碼運(yùn)行起來是沒有任何問題的,我們?cè)谌罩局兴阉鳌皥?zhí)行完成”,也能搜到 5 個(gè),這個(gè)結(jié)果也能證明程序是正常結(jié)束的:
圖片
同時(shí),可以看到運(yùn)行時(shí)間是 4s。
示意圖大概是這樣的:
圖片
然后歪師傅看著這個(gè)代碼,發(fā)現(xiàn)了一個(gè)可以優(yōu)化的地方:
圖片
這個(gè)地方從數(shù)據(jù)庫(kù)撈出來的數(shù)據(jù),它們之間是沒有依賴關(guān)系的,也就是說它們之間也是可以并行執(zhí)行的。
所以歪師傅把代碼改成了這樣:
圖片
在異步線程里面去處理這部分從數(shù)據(jù)庫(kù)中撈出來的數(shù)據(jù),并行處理加快響應(yīng)速度。
對(duì)應(yīng)到圖片,大概就是這個(gè)意思:
圖片
把程序運(yùn)行起來之后,日志變成了這樣:
圖片
我們搜索“執(zhí)行完成”,也能搜到 5 個(gè)對(duì)應(yīng)輸出。
而且我們就拿“任務(wù)2”來說:
圖片
當(dāng)前線程pool-1-thread-3,---【任務(wù)2】開始執(zhí)行---
當(dāng)前線程pool-1-thread-3,---【任務(wù)2】執(zhí)行完成---
當(dāng)前線程pool-1-thread-1,【任務(wù)2】開始處理數(shù)據(jù)=1
當(dāng)前線程pool-1-thread-2,【任務(wù)2】開始處理數(shù)據(jù)=2
從日志輸出來看,任務(wù) 2 需要處理的兩個(gè)數(shù)據(jù),確實(shí)是在不同的異步線程中處理數(shù)據(jù),也實(shí)現(xiàn)了我的需求。
但是,程序運(yùn)行直接就是到了 9.9ms:
圖片
這個(gè)優(yōu)化這么牛逼的嗎?
從 4s 到了 9.9ms?
稍加分析,你會(huì)發(fā)現(xiàn)這里面是有問題的。
那么問題就來了,到底是啥問題呢?
你也分析分析大概是啥問題,別老是想著直接找答案啊。
問題就是由于轉(zhuǎn)異步了,所以 for 循環(huán)里面的任務(wù)中的 countDownLatch 很快就減到 0 了。
于是 await 繼續(xù)執(zhí)行,所以很快就輸出了程序運(yùn)行時(shí)間。
然而實(shí)際上子任務(wù)還在繼續(xù)執(zhí)行,程序并沒有真正完成。
9.9ms 只是任務(wù)提交到線程池的時(shí)間,每個(gè)任務(wù)的數(shù)據(jù)處理時(shí)間還沒算呢:
圖片
從日志輸出上也可以看出,在輸出了 StopWatch 的日志后,各個(gè)任務(wù)還在處理數(shù)據(jù)。
這樣時(shí)間就顯得不夠真實(shí)。
那么我們應(yīng)該怎么辦呢?
很簡(jiǎn)單嘛,需要子任務(wù)真正執(zhí)行完成后,父任務(wù)的 countDownLatch 才能進(jìn)行 countDown 的動(dòng)作。
具體實(shí)現(xiàn)上就是給子任務(wù)再加一個(gè) countDownLatch 柵欄:
圖片
我們希望的運(yùn)行結(jié)果應(yīng)該是這樣的:
當(dāng)前線程pool-1-thread-3,---【任務(wù)2】開始執(zhí)行---
當(dāng)前線程pool-1-thread-1,【任務(wù)2】開始處理數(shù)據(jù)=1
當(dāng)前線程pool-1-thread-2,【任務(wù)2】開始處理數(shù)據(jù)=2
當(dāng)前線程pool-1-thread-3,---【任務(wù)2】執(zhí)行完成---
即子任務(wù)全部完成之后,父任務(wù)才能算執(zhí)行完成,這樣統(tǒng)計(jì)出來的時(shí)間才是準(zhǔn)確的。
思路清晰,非常完美,再次運(yùn)行,觀察日志我們會(huì)發(fā)現(xiàn):
圖片
呃,怎么回事,日志怎么不輸出了?
是的,就是不輸出了。
不輸出了,就是踩到這個(gè)坑了。
不論你重啟多少次,都是這樣:日志不輸出了,程序就像是卡著了一樣。
坑在哪兒
上面這個(gè) Demo 已經(jīng)是我基于遇到的生產(chǎn)問題,極力簡(jiǎn)化后的版本了。
現(xiàn)在,這個(gè)坑也已經(jīng)呈現(xiàn)在你眼前了。
我們一起來分析一波。
首先,我問你:真的在線上遇到這種程序“假死”的問題,你會(huì)怎么辦?
早幾年,歪師傅的習(xí)慣是抱著代碼慢慢啃,試圖從代碼中找到端倪。
這樣確實(shí)是可以,但是通常來說效率不高。
現(xiàn)在我的習(xí)慣是直接把現(xiàn)場(chǎng) dump 下來,分析現(xiàn)場(chǎng)。
比如在這個(gè)場(chǎng)景下,我們直觀上的感受是“卡住了”,那就 dump 一把線程,管它有棗沒棗,打一桿子再說:
圖片
通過 Dump 文件,可以發(fā)現(xiàn)線程池的線程都在 MainTest 的第 30 行上 parking ,處于等待狀態(tài):
圖片
那么第 30 行是啥玩意?
圖片
這行代碼在干啥?
countDownLatchSub.await();
是父任務(wù)在等待子任務(wù)執(zhí)行結(jié)束,運(yùn)行 finally 代碼,把 countDownLatchSub 的計(jì)數(shù) countDown 到 0,才會(huì)繼續(xù)執(zhí)行:
圖片
所以現(xiàn)在的現(xiàn)象就是子任務(wù)的 countDownLatchSub 把父任務(wù)的攔住了。
換句話說就是父任務(wù)被攔住是因?yàn)樽尤蝿?wù)的 finally 代碼中的 countDownLatchSub.countDown() 方法沒有被執(zhí)行。
好,那么最關(guān)鍵的問題就來了:為什么沒有執(zhí)行?
你先別往下看,閉上眼睛在你的小腦瓜子里面推演一下,琢磨一下:finally 為什么沒有執(zhí)行?
或者再換個(gè)更加接近真實(shí)的問題:子任務(wù)為什么沒有執(zhí)行?
這個(gè)點(diǎn),非常簡(jiǎn)單,可以說一點(diǎn)就破。
琢磨明白了,這個(gè)坑的原理摸摸清楚了。
...
...
...
琢磨明白了嗎?你就刷刷往下看?
沒明白我再給你一個(gè)信息:需要結(jié)合線程池的參數(shù)和運(yùn)行原理來分析。
什么?
你說線程池的運(yùn)行原理你不清楚?
請(qǐng)你取關(guān)好嗎,你個(gè)假粉絲。
...
...
...
好,不管你“恍然大悟”了沒有,歪師傅給你講一下。
讓你知道“一點(diǎn)就破”這四個(gè)是怎么回事兒。
首先,我們把目光聚焦在線程池這里:
圖片
這個(gè)線程池核心線程數(shù)是 3,但是我們要提交 5 個(gè)任務(wù)到線程池去。
父任務(wù)哐哐哐,就把核心線程數(shù)占滿了。
接下來子任務(wù)也要往這個(gè)線程池提交任務(wù)怎么辦?
當(dāng)然是進(jìn)隊(duì)列等著了。
一進(jìn)隊(duì)列,就完?duì)僮印?/p>
到這里,我覺得你應(yīng)該能想明白問題了。
應(yīng)該給到我一個(gè)恍然大悟的表情,并配上“哦哦哦~”這樣的內(nèi)心 OS。
你想想,父任務(wù)這個(gè)時(shí)候干啥?
是不是等在 countDownLatchSub.await() 這里。
而 countDownLatchSub.await() 什么時(shí)候能繼續(xù)執(zhí)行?
是不是要所有子任務(wù)都執(zhí)行 finally 后?
那么子任務(wù)現(xiàn)在在干啥?
是不是都在線程池里面的隊(duì)列等著被執(zhí)行呢?
那線程池隊(duì)列里面的任務(wù)什么時(shí)候才執(zhí)行?
是不是等著有空閑線程的時(shí)候?
那現(xiàn)在有沒有空閑線程?
沒有,所有的線程都去執(zhí)行父任務(wù)去了。
那你想想,父任務(wù)這個(gè)時(shí)候干啥?
是不是等在 countDownLatchSub.await() 這里。
...
父任務(wù)在等子任務(wù)執(zhí)行。
子任務(wù)在等線程池調(diào)度。
線程池在等父任務(wù)釋放線程。
閉環(huán)了,相互等待了,家人們。
這,就是坑。
現(xiàn)在把坑的原理摸清楚了,我在給你說一下真實(shí)的線上場(chǎng)景踩到這個(gè)坑是怎么樣的呢?
圖片
上游發(fā)起請(qǐng)求到微服務(wù) A 的接口 1,該接口需要調(diào)用微服務(wù) B 的接口 2。
但是微服務(wù) B 的接口 2,需要從微服務(wù) A 接口 3 獲取數(shù)據(jù)。
然而在微服務(wù) A 內(nèi)部,全局使用的是同一個(gè)自定義線程池。
更巧的是接口 1 和接口 3 內(nèi)部都使用了這個(gè)自定義線程池做異步并行處理,想著是加快響應(yīng)速度。
整個(gè)情況就變成了這樣:
- 接口 1 收到請(qǐng)求之后,把請(qǐng)求轉(zhuǎn)到自定義線程池中,然后等接口 2 返回。
- 接口 2 調(diào)用接口 3,并等待返回。
- 接口 3 里面把請(qǐng)求轉(zhuǎn)到了自定義線程池中,被放入了隊(duì)列。
- 線程池的線程都被接口 1 給占住了,沒有資源去執(zhí)行隊(duì)列里面的接口 3 任務(wù)。
- 相互等待,一直僵持。
我們的 Demo 還是能比較清晰的看到父子任務(wù)之間的關(guān)系。
但是在這個(gè)微服務(wù)的場(chǎng)景下,在無形之間,就形成了不易察覺的父子任務(wù)關(guān)系。
所以就踩到了這個(gè)坑。
怎么避免
找到了坑的原因,解決方案就隨之而出了。
父子任務(wù)不要共用一個(gè)線程池,給子任務(wù)也搞一個(gè)自定義線程池就可以了:
圖片
運(yùn)行起來看看日志:
圖片
首先整體運(yùn)行時(shí)間只需要 2s 了,達(dá)到了我想要的效果。
另外,我們觀察一個(gè)具體的任務(wù):
當(dāng)前線程pool-1-thread-3,---【任務(wù)2】開始執(zhí)行---
當(dāng)前線程pool-2-thread-1,【任務(wù)2】開始處理數(shù)據(jù)=1
當(dāng)前線程pool-2-thread-4,【任務(wù)2】開始處理數(shù)據(jù)=2
當(dāng)前線程pool-1-thread-3,---【任務(wù)2】執(zhí)行完成---
日志輸出符合我們前面分析的,所有子任務(wù)執(zhí)行完成后,父任務(wù)才打印執(zhí)行完成,且子任務(wù)在不同的線程中執(zhí)行。
而使用不同的線程池,換一個(gè)高大上的說法就叫做:線程池隔離。
而且在一個(gè)項(xiàng)目中,公用一個(gè)線程池,也是一個(gè)埋坑的邏輯。
至少給你覺得關(guān)鍵的邏輯,單獨(dú)分配一個(gè)線程池吧。
避免出現(xiàn)線程池的線程都在執(zhí)行非核心邏輯了,反而重要的任務(wù)在隊(duì)列里面排隊(duì)去了。
這就有點(diǎn)不合理了。
最后,一句話總結(jié)這個(gè)問題:
如果線程池的任務(wù)之間存在父子關(guān)系,那么請(qǐng)不要使用同一個(gè)線程池。如果使用了同一個(gè)線程池,可能會(huì)因?yàn)樽尤蝿?wù)進(jìn)了隊(duì)列,導(dǎo)致父任務(wù)一直等待,出現(xiàn)假死現(xiàn)象。