近來我在自己負責的項目中大量應用了協(xié)程,提高了很多服務的響應時間。。直到一次需求,測試環(huán)境一切都很美好,上線第二天線下反饋一個聚合頁經(jīng)常超時卡死。分析日志發(fā)現(xiàn),其中一個服務實例雖然進程沒掛,但日志已經(jīng)停止打印,卡死在聚合頁的邏輯,下游的遠程服務一切正常,至此原因很明顯了:死鎖。
原因分析:
接口業(yè)務邏輯內(nèi)使用了大量的async{...}異步協(xié)程,用來執(zhí)行rpc調(diào)用、提高吞吐,很多底層方法用了runBlocking{...}來構造(其實為了少寫點suspend掛起函數(shù)...方便調(diào)用)。另一方面runBlocking{...}可以保證塊內(nèi)邏輯順序、阻塞執(zhí)行,直覺是這里出現(xiàn)了問題。
搜到了一篇kotlin社區(qū)內(nèi)的帖子,很多回復點出了問題所在:
默認的CommonPool線程數(shù)有限,如果底層方法使用runBlocking{...}執(zhí)行阻塞邏輯、并且頂層方法大量啟動并行任務調(diào)用這個方法,此時,這些并行的阻塞任務、底層協(xié)程均被調(diào)度到CommonPool,協(xié)程本質(zhì)上還是需要在線程下才能執(zhí)行的,可此時線程資源已經(jīng)全部被阻塞任務占用,阻塞任務又在等待其內(nèi)的協(xié)程返回結果,自此形成了死鎖。
解決方案:
- 協(xié)程均調(diào)度到一個單獨的自定義線程池,并將線程數(shù)調(diào)高。
- 底層方法消除
runBlocking{...}的使用、均使用suspend重構為掛起函數(shù)。(推薦)
方案1改動量較小,但若訪問量繼續(xù)加大,很容易再次復現(xiàn)問題,并且大量的線程切換會適得其反,因此只適合像我這樣已經(jīng)出了問題的情況下的臨時處理方案。
根本的處理則是如方案2,在并行任務中徹底消除runBlocking{...}的使用。
附一個協(xié)程死鎖的簡單實現(xiàn):
fun main(args: Array<String>) = runBlocking {
println("--- main start ---")
//創(chuàng)建任務list,若默認CommonPool線程數(shù)很多,可加大任務數(shù)量模擬,p.s. List(50)
val deferredList = List(10) {
serviceAsync(it)
}
//并行啟動任務,模擬大量請求下的并發(fā)情況
deferredList.parallelStream().forEach {
runBlocking {
println("start")
println("${it.await()} end")
}
}
//死鎖發(fā)生、永遠不會執(zhí)行到這里
println("--- main end ---")
}
/**
* 異步并行任務
*/
fun serviceAsync(order: Int) = async(CommonPool, CoroutineStart.LAZY) {
blokingIoWork()
order
}
/**
* 模擬耗時的io操作
*/
fun blokingIoWork() = runBlocking {
delay(2, TimeUnit.SECONDS)
}