1. 程式人生 > >一碼阻塞,萬碼等待:ASP.NET Core 同步方法呼叫非同步方法“死鎖”的真相

一碼阻塞,萬碼等待:ASP.NET Core 同步方法呼叫非同步方法“死鎖”的真相

在我們 2015 年開始的從 .NET Framework 向 .NET Core 遷移的工程中,遇到的最大的坑就是標題中所說的——同步方法中呼叫非同步方法發生”死鎖”。雖然在 .NET Framework 時代就知道不能在同步方法中呼叫非同步方法,但我們卻明知路有坑,偏向此路行。不是我們自討苦吃,而是被迫無奈,因為在 .NET Core 2.0 之前,BCL(基礎類庫)中有些 API 只有非同步實現沒有同步實現,比如用於將主機名解析為 IP 地址的 API —— Dns.GetHostAddressesAsync() 。

但最終“被迫無奈”變成“血的教訓”,這根本不是坑,而是無底洞。無論在開發與測試環境中多麼正常,只要一發布到生產環境有一定併發量就會發生“死鎖” —— 大量請求無響應,一直處於等待狀態,執行緒池發飆,執行緒數持續不斷地增長,記憶體隨之增長,直至撐爆伺服器(詳見當時的一篇隨筆

.NET Core 中遇到奇怪的執行緒死鎖問題:記憶體與執行緒數不停地增長)。

我們想盡一切方法,用盡網上能找到的同步方法呼叫非同步方法避免死鎖的辦法,都於事無補,唯有去掉同步方法呼叫非同步方法的程式碼。當我們意識這是一個無底洞後,趕緊繞道而行,全面放棄在同步方法中呼叫非同步方法,並將“千萬千萬不要在同步方法中呼叫非同步方法”作為一條 .NET Core 開發準則。

這段踩坑踩到無底洞的血淚史,每當想起都很心痛,心痛不是當時的任何努力都是那麼的蒼白無力,而是對問題背後原因的困惑 —— 為什麼同步方法中 Wait 非同步方法會產生如此致命的後果?如果真的千萬千萬不能這麼幹,那 .NET Core 為什麼不直接在編譯時就報錯?“死鎖”的背後究竟發生了什麼?

。。。

2018年10月20日偶然間發現一個網站 —— dotNET Weekly ,在其中發現一篇10月17日釋出的博文 —— .NET Threadpool starvation, and how queuing makes it worse,在讀懂這篇博文之後,聯絡到之前踩坑的經歷,終於想通了“死鎖”的背後(只是個人推測,並不一定正確)。

.NET Core 執行緒池有 n+1 個佇列,每個執行緒有自己的本地佇列(n),整個執行緒池有一個全域性佇列(1)。每個執行緒接活(從佇列中取出任務執行)的順序是這樣的:先從自己的本地佇列中找活 -> 如果本地佇列為空,則從全域性佇列中找活 -> 如果全域性佇列為空,則從其他執行緒的本地佇列中搶活。

我們來想象一下非同步方法等待同步方法的場景。當10個併發請求到達時(進入的是全域性佇列),假設執行緒池中正好有10個空閒執行緒,這10個執行緒立馬把活接過來,但執行緒在執行過程中遇到了同步方法等待非同步方法(Task.Wait)的情況而進入阻塞狀態,無奈地無所事事地在那乾等非同步方法執行完成而無法幫其他執行緒幹活(這時情況已經有些不妙,由於阻塞執行緒池少了10個幹活的執行緒)。雪上加霜的是,這些阻塞的執行緒所等待的非同步方法在完成非同步操作執行 await 之後的程式碼時也需要執行緒,不僅幹活的執行緒少了,而且剩下的執行緒要乾的活更多了(情況更不妙了)。隨著併發請求持續不斷地進來,形勢變得越來越嚴峻,被阻塞的執行緒越來越多,能幹活的執行緒越來越少而且要乾的活越來越多,於是越來越多的一線幹活的執行緒的佇列開始排起了長隊。火上澆油的是,那些阻塞著的執行緒要退出阻塞狀態需要等它們所等待的任務被正忙得不可開交的幹活執行緒執行,幹活執行緒越忙,它們被阻塞的時間越長。於是出現了一個奇怪的場面,一群不幹活的執行緒圍觀並等待著少數幹活的執行緒,眼看著這些幹活執行緒的佇列排隊越來越長,雖然它們也能幹活,但由於它們被關在小黑屋裡,無法出手相助,要等它們的主人將它們釋放出來,而它們的主人就排在長隊中等著從幹活執行緒那拿到小黑屋的鑰匙。。。這樣的場面最終只有一個結局,所有幹活的執行緒的本地佇列都排起了長隊,沒有空閒的執行緒。

好戲開始了,不,是災難開始了。執行緒池中沒有空閒執行緒,全域性佇列中的活沒人接,於是全域性佇列開始排隊,執行緒池的執行緒不夠用,如果不趕緊補充執行緒進來,執行緒池會被餓死(Threadpool Starvation)。救援行動開始了,CLR 趕緊生產執行緒餵給執行緒池,由於全域性佇列享有最高優先順序(根據之前所述的執行緒接活順序),一喂進去就被全域性佇列吃了,但 CLR 一秒鐘只能生產1-2個執行緒,遠遠滿足不了全域性佇列的胃口,而最需要救援的各個幹活執行緒的本地佇列連湯都喝不到。除了 CLR 的外部救援,執行緒池也同時進行自救,有些執行緒玩命幹活,終於處理完了自己佇列中的任務,終於有機會可以幫助其他同伴了,但是它們立即接到了上級命令 —— 以最快速度去救援全域性佇列,軍令不可違,它們眼睜睜地看著同伴絕望地處理著一望無際的長隊中的任務,奔赴全域性佇列,自救也救不到幹活執行緒的本地佇列。

這種完全以全域性佇列為中心、救地位最高的、不救最需要的救援行動最終帶來了毀滅性的結果。那些解救全域性佇列的執行緒又因為 Task.Wait 而阻塞而需要更多的執行緒執行阻塞所等待的任務。救援行動變成了自殺行動,執行緒池就這樣被活活餓死了(Threadpool Starvation)。

這就是我所推測的真相,真相背後的真正罪魁禍首其實是對執行緒的阻塞,所以千萬千萬不要阻塞(blocking)執行緒。