天天看點

關于同步方法裡面調用異步方法的探究

我在寫代碼的時候(.net core)有時候會碰到void方法裡,調用async方法并且Wait,而且我還看到别人這麼寫了。而且我這麼寫的時候,編譯器沒有提示任何警告。但是看了dudu的文章:一碼阻塞,萬碼等待:ASP.NET Core 同步方法調用異步方法“死鎖”的真相 了解了,這樣寫是有問題的。但是為什麼會有問題呢?我又閱讀了dudu文章裡提到的一篇博文:.NET Threadpool starvation, and how queuing makes it worse 加上自己親手實驗,寫下自己的了解,算是對dudu博文的一個補充和豐富吧。

同步方法裡調用異步方法,一種是wait() 一種是不wait()

這兩種場景都沒有編譯錯誤。

首先我們來看一下,在 void裡調用 async 方法,并且要等待async的結果出來之後,才能進行後續的操作。

咱們看這個Producer,這是一個void方法,裡面調用了異步方法Process(),其中Process()是一個執行1秒的異步方法,調用的方式是<code>Process().Result</code> 或者<code>Process().Wait()</code>。咱們來運作一遍。

關于同步方法裡面調用異步方法的探究

沒有任何問題。看起來,這樣寫完全沒有問題啊,不報錯,運作也是正常的。

接下來,我們修改一下代碼,讓代碼更加接近生産環境的狀态。

我們在Main函數裡加了for循環,并且1秒鐘執行5次Producer(),使用Task.Run(),1秒鐘有5個Task産生。相當于生産環境的qps=5。

接下來我們再執行下,看看結果:

關于同步方法裡面調用異步方法的探究

在第一秒裡隻執行了兩次Task,就卡住了。我們再看下程序資訊:

關于同步方法裡面調用異步方法的探究

沒有CPU消耗,但是線程數一直增加,直到突破一台電腦的最大線程數,導緻伺服器當機。

這明顯出現問題了,線程肯定發生了死鎖,而且還在不斷産生新的線程。

至于為什麼隻執行了兩次Task,我們可以猜測是因為程式中初始的TreadPool 中隻有兩個線程,是以執行了兩次Task,然後就發生了死鎖。

現在我們定義一個Produce2() 這是一個正常的方法,異步函數調用異步函數。

我們再Main函數的循環裡,執行Producer2() ,執行資訊如下:

關于同步方法裡面調用異步方法的探究

仔細觀察這個圖,我們發現第一秒執行了一個Task,第二秒執行了三個Task,從第三秒開始,就穩定執行了4-5次Task,這裡的時間統計不是很精确,但是可以肯定從某個時間開始,程式達到了預期效果,TreadPool中的線程每秒中都能穩定的完成任務。而且我們還能觀察到,在最開始,程式是反應很慢的,那個時候線程不夠用,同時應該在申請新的線程,直到後來線程足夠處理這樣的情況了。咱們再看看這個時候的程序資訊:

關于同步方法裡面調用異步方法的探究

線程數一直穩定在25個,也就是說25個線程就能滿足這個程式的運作了。

到此我們可以證明,在同步方法裡調用異步方法确實是不安全的,尤其在并發量很高的情況下。

我們再深層次讨論下為什麼同步方法裡調用異步方法會卡死,而異步方法調用異步方法則很安全呢?

咱們回到一開始的代碼裡,我們加上一個初始化線程數量的代碼,看看這樣是否還是會出現卡死的狀況。

由于前面的分析我們知道,這個程式在一秒中并行執行5個Task,每個Task裡面也就是Producer 都會執行一個Processer 異步方法,是以粗略估計需要10個線程。于是我們就初始化線程數為10個。

運作一下發現,是沒問題的。說明一開始設定多的線程是有用的,經過實驗發現,隻要初始線程小于10個,都會出現死鎖。而.net core的預設初始線程是肯定小于10個的。

那麼當初始線程小于10個的時候,發生什麼了?發生了大家都聽說過的名詞,線程饑餓。就是線程不夠用了,這個時候ThreadPool生産新的線程滿足需求。

然後我們再關注下,同步方法裡調用異步方法并且.Wait()的情況下會發生什麼。

首先有一個線程A ,開始執行Producer , 它執行到了Process 的時候,新産生了一個的線程 B 去執行這個Task。這個時候 A 會挂起,一直等 B 結束,B被釋放,然後A繼續執行剩下的過程。這樣執行一次Producer 會用到兩個線程,并且A 一直挂起,一直不工作,一直在等B。這個時候線程A 就會阻塞。

這個和上面的差別就是,同時線程A,它執行到Producer的時候,産生了一個新的線程B執行 Process。但是 A 并沒有等B,而是被ThreadPool拿來做别的事情,等B結束之後,ThreadPool 再拿一個線程出來執行剩下的部分。是以這個過程是沒有線程阻塞的。

再結合線程饑餓的情況,也就是ThreadPool 中發生了線程阻塞+線程饑餓,會發生什麼呢?

假設一開始隻有8個線程,第一秒中會并行執行5個Task Producer, 5個線程被拿來執行這5個Task,然後這個5個線程(A)都在阻塞,并且ThreadPool 被要求再拿5個線程(B)去執行Process,但是線程池隻剩下3個線程,是以ThreadPool 需要再産生2個線程來滿足需求。但是ThreadPool 1秒鐘最多生産2個線程,等這2個線程被生産出來以後,又過去了1秒,這個時候無情又進來5個Task,又需要10個線程了。别忘了執行第一波Task的一些線程應該釋放了,釋放多少個呢?應該是3個Task占有的線程,因為有2個在等TreadPool生産新線程嘛。是以釋放了6個線程,5個Task,6個線程,計算一下,就可以知道,隻有一個Task可以被完全執行,其他4個都因為沒有新的線程執行Process而阻塞。

于是ThreadPool 又要去産生4個新的線程去滿足4個被阻塞的Task,花了2秒時間,終于生産完了。但是糟糕又來了10個Task,需要20個線程,而之前釋放的線程已經不足以讓任何一個Task去執行Process了,因為這些不足的線程都被配置設定到了Producer上,沒有線程再可以去執行Process了(經過上面的分析一個Task需要2個線程A,B,并且A阻塞,直到B執行Process完成)。

是以随着時間的流逝,要執行的Task越來越多卻沒有一個能執行結束,而線程也在不斷産生,就産生了我們上面所說的情況。

經過上面的分析我們知道,線上程饑餓的情況下,使用同步方法調用異步方法并且wait結果,是會出問題的,那麼我們應該怎麼辦呢?

首先當然是應該避免這種有風險的做法。

其次,還有一種方法。經過實驗,我發現,使用專有線程

就是TaskCreationOptions.LongRunning 選項,就是開辟一個專用線程,而不是在ThreadPool中拿線程,這樣是不會發生死鎖的。

因為ThreadPool 不管理專用線程,每一個Task進來,都會有專門的線程執行,而Process 則是由ThreadPool 中的線程執行,這樣TheadPool中的線程其實是不存在阻塞的,是以也不存在死鎖。

關于ThreadPool 中的線程調用算法,其實很簡單,每個線程都有一個自己的工作隊列local queue,此外線程池中還有一個global queue全局工作隊列,首先一個線程被建立出來後,先看看自己的工作隊列有沒有被配置設定task,如果沒有的話,就去global queue找task,如果還沒有的話,就去别的線程的工作隊列找Task。

第二種情況:在同步方法裡調用異步方法,不wait()

如果這個異步方法進入的是global Task 則線上程饑餓的情況下,也會發生死鎖的情況。至于為什麼,可以看那篇博文裡的解釋,因為global Task的優先級很高,所有新産生的線程都去執行global Task,而global task又需要一個線程去執行local task,是以産生了死鎖。

繼續閱讀