值得一看:C#同步方法中如何調(diào)用異步方法?
原文鏈接:
https://www.cnblogs.com/dacc123/p/12796578.html
前言
我在寫代碼的時(shí)候(.net core)有時(shí)候會(huì)碰到void方法里,調(diào)用async方法并且Wait,而且我還看到別人這么寫了。而且我這么寫的時(shí)候,編譯器沒有提示任何警告。但是看了dudu的文章:一碼阻塞,萬碼等待:ASP.NET Core 同步方法調(diào)用異步方法“死鎖”的真相 了解了,這樣寫是有問題的。但是為什么會(huì)有問題呢?
我又閱讀了dudu文章里提到的一篇博文:.NET Threadpool starvation, and how queuing makes it worse 加上自己親手實(shí)驗(yàn),寫下自己的理解,算是對dudu博文的一個(gè)補(bǔ)充和豐富吧。
同步方法里調(diào)用異步方法
同步方法里調(diào)用異步方法,一種是wait() 一種是不wait()
void fun()
{
funAsync.Wait();
funAsync();
}
這兩種場景都沒有編譯錯(cuò)誤。首先我們來看一下,在 void里調(diào)用 async 方法,并且要等待async的結(jié)果出來之后,才能進(jìn)行后續(xù)的操作。
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleTool2
{
class Program
{
static void Main(string[] args)
{
Producer();
}
static void Producer()
{
var result = Process().Result;
//或者
//Process().Wait();
}
static async Task<bool> Process()
{
await Task.Run(() =>
{
Thread.Sleep(1000);
});
Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString());
return true;
}
}
}
咱們看這個(gè)Producer,這是一個(gè)void方法,里面調(diào)用了異步方法Process(),其中Process()是一個(gè)執(zhí)行1秒的異步方法,調(diào)用的方式是Process().Result 或者Process().Wait(),咱們來運(yùn)行一遍。
沒有任何問題??雌饋?,這樣寫完全沒有問題啊,不報(bào)錯(cuò),運(yùn)行也是正常的。接下來,我們修改一下代碼,讓代碼更加接近生產(chǎn)環(huán)境的狀態(tài)。
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleTool2
{
class Program
{
static void Main(string[] args)
{
while (true)
{
Task.Run(Producer);
Thread.Sleep(200);
}
}
static void Producer()
{
var result = Process().Result;
}
static async Task<bool> Process()
{
await Task.Run(() =>
{
Thread.Sleep(1000);
});
Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString());
return true;
}
}
}
我們在Main函數(shù)里加了for循環(huán),并且1秒鐘執(zhí)行5次Producer(),使用Task.Run(),1秒鐘有5個(gè)Task產(chǎn)生。相當(dāng)于生產(chǎn)環(huán)境的qps=5。接下來我們再執(zhí)行下,看看結(jié)果:
沒有CPU消耗,但是線程數(shù)一直增加,直到突破一臺(tái)電腦的最大線程數(shù),導(dǎo)致服務(wù)器宕機(jī)。這明顯出現(xiàn)問題了,線程肯定發(fā)生了死鎖,而且還在不斷產(chǎn)生新的線程。
至于為什么只執(zhí)行了兩次Task,我們可以猜測是因?yàn)槌绦蛑谐跏嫉腡readPool 中只有兩個(gè)線程,所以執(zhí)行了兩次Task,然后就發(fā)生了死鎖。
現(xiàn)在我們定義一個(gè)Produce2() 這是一個(gè)正常的方法,異步函數(shù)調(diào)用異步函數(shù)。
static async Task Producer2()
{
await Process();
}
我們再M(fèi)ain函數(shù)的循環(huán)里,執(zhí)行Producer2() ,執(zhí)行信息如下:
仔細(xì)觀察這個(gè)圖,我們發(fā)現(xiàn)第一秒執(zhí)行了一個(gè)Task,第二秒執(zhí)行了三個(gè)Task,從第三秒開始,就穩(wěn)定執(zhí)行了4-5次Task,這里的時(shí)間統(tǒng)計(jì)不是很精確,但是可以肯定從某個(gè)時(shí)間開始,程序達(dá)到了預(yù)期效果,TreadPool中的線程每秒中都能穩(wěn)定的完成任務(wù)。而且我們還能觀察到,在最開始,程序是反應(yīng)很慢的,那個(gè)時(shí)候線程不夠用,同時(shí)應(yīng)該在申請新的線程,直到后來線程足夠處理這樣的情況了。咱們再看看這個(gè)時(shí)候的進(jìn)程信息:
線程數(shù)一直穩(wěn)定在25個(gè),也就是說25個(gè)線程就能滿足這個(gè)程序的運(yùn)行了。到此我們可以證明,在同步方法里調(diào)用異步方法確實(shí)是不安全的,尤其在并發(fā)量很高的情況下。
探究原因
我們再深層次討論下為什么同步方法里調(diào)用異步方法會(huì)卡死,而異步方法調(diào)用異步方法則很安全呢?
咱們回到一開始的代碼里,我們加上一個(gè)初始化線程數(shù)量的代碼,看看這樣是否還是會(huì)出現(xiàn)卡死的狀況。由于前面的分析我們知道,這個(gè)程序在一秒中并行執(zhí)行5個(gè)Task,每個(gè)Task里面也就是Producer 都會(huì)執(zhí)行一個(gè)Processer 異步方法,所以粗略估計(jì)需要10個(gè)線程。于是我們就初始化線程數(shù)為10個(gè)。
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleTool2
{
class Program
{
static void Main(string\[\] args) {
ThreadPool.SetMinThreads(10, 10);
while (true)
{
Task.Run(Producer2);
Thread.Sleep(200);
}
}
static void Producer() {
var result = Process().Result;
}
static async Task Producer2() {
await Process();
}
static async Task<bool\> Process() {
await Task.Run(() =>
{
Thread.Sleep(1000);
});
Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString());
return true;
}
}
}
運(yùn)行一下發(fā)現(xiàn),是沒問題的。說明一開始設(shè)置多的線程是有用的,經(jīng)過實(shí)驗(yàn)發(fā)現(xiàn),只要初始線程小于10個(gè),都會(huì)出現(xiàn)死鎖。而.net core的默認(rèn)初始線程是肯定小于10個(gè)的。那么當(dāng)初始線程小于10個(gè)的時(shí)候,發(fā)生什么了?發(fā)生了大家都聽說過的名詞,線程饑餓。就是線程不夠用了,這個(gè)時(shí)候ThreadPool生產(chǎn)新的線程滿足需求。然后我們再關(guān)注下,同步方法里調(diào)用異步方法并且.Wait()的情況下會(huì)發(fā)生什么。
void Producer()
{
Process().Wait()
}
首先有一個(gè)線程A ,開始執(zhí)行Producer , 它執(zhí)行到了Process 的時(shí)候,新產(chǎn)生了一個(gè)的線程 B 去執(zhí)行這個(gè)Task。這個(gè)時(shí)候 A 會(huì)掛起,一直等 B 結(jié)束,B被釋放,然后A繼續(xù)執(zhí)行剩下的過程。這樣執(zhí)行一次Producer 會(huì)用到兩個(gè)線程,并且A 一直掛起,一直不工作,一直在等B。這個(gè)時(shí)候線程A 就會(huì)阻塞。
Task Producer()
{
await Process();
}
這個(gè)和上面的區(qū)別就是,同時(shí)線程A,它執(zhí)行到Producer的時(shí)候,產(chǎn)生了一個(gè)新的線程B執(zhí)行 Process。
但是 A 并沒有等B,而是被ThreadPool拿來做別的事情,等B結(jié)束之后,ThreadPool 再拿一個(gè)線程出來執(zhí)行剩下的部分。所以這個(gè)過程是沒有線程阻塞的。
再結(jié)合線程饑餓的情況,也就是ThreadPool 中發(fā)生了線程阻塞+線程饑餓,會(huì)發(fā)生什么呢?假設(shè)一開始只有8個(gè)線程,第一秒中會(huì)并行執(zhí)行5個(gè)Task Producer, 5個(gè)線程被拿來執(zhí)行這5個(gè)Task,然后這個(gè)5個(gè)線程(A)都在阻塞,并且ThreadPool 被要求再拿5個(gè)線程(B)去執(zhí)行Process,但是線程池只剩下3個(gè)線程,所以ThreadPool 需要再產(chǎn)生2個(gè)線程來滿足需求。但是ThreadPool 1秒鐘最多生產(chǎn)2個(gè)線程,等這2個(gè)線程被生產(chǎn)出來以后,又過去了1秒,這個(gè)時(shí)候無情又進(jìn)來5個(gè)Task,又需要10個(gè)線程了。
別忘了執(zhí)行第一波Task的一些線程應(yīng)該釋放了,釋放多少個(gè)呢?應(yīng)該是3個(gè)Task占有的線程,因?yàn)橛?個(gè)在等TreadPool生產(chǎn)新線程嘛。所以釋放了6個(gè)線程,5個(gè)Task,6個(gè)線程,計(jì)算一下,就可以知道,只有一個(gè)Task可以被完全執(zhí)行,其他4個(gè)都因?yàn)闆]有新的線程執(zhí)行Process而阻塞。
于是ThreadPool 又要去產(chǎn)生4個(gè)新的線程去滿足4個(gè)被阻塞的Task,花了2秒時(shí)間,終于生產(chǎn)完了。但是糟糕又來了10個(gè)Task,需要20個(gè)線程,而之前釋放的線程已經(jīng)不足以讓任何一個(gè)Task去執(zhí)行Process了,因?yàn)檫@些不足的線程都被分配到了Producer上,沒有線程再可以去執(zhí)行Process了(經(jīng)過上面的分析一個(gè)Task需要2個(gè)線程A,B,并且A阻塞,直到B執(zhí)行Process完成)。
所以隨著時(shí)間的流逝,要執(zhí)行的Task越來越多卻沒有一個(gè)能執(zhí)行結(jié)束,而線程也在不斷產(chǎn)生,就產(chǎn)生了我們上面所說的情況。## 我們該怎么辦?經(jīng)過上面的分析我們知道,在線程饑餓的情況下,使用同步方法調(diào)用異步方法并且wait結(jié)果,是會(huì)出問題的,那么我們應(yīng)該怎么辦呢?首先當(dāng)然是應(yīng)該避免這種有風(fēng)險(xiǎn)的做法。其次,還有一種方法。經(jīng)過實(shí)驗(yàn),我發(fā)現(xiàn),使用專有線程
Task.Run(Producer);
改成
Task.Factory.StartNew(
Producer,
TaskCreationOptions.LongRunning
);
就是TaskCreationOptions.LongRunning 選項(xiàng),就是開辟一個(gè)專用線程,而不是在ThreadPool中拿線程,這樣是不會(huì)發(fā)生死鎖的。
因?yàn)門hreadPool 不管理專用線程,每一個(gè)Task進(jìn)來,都會(huì)有專門的線程執(zhí)行,而Process 則是由ThreadPool 中的線程執(zhí)行,這樣TheadPool中的線程其實(shí)是不存在阻塞的,因此也不存在死鎖。
結(jié)語
關(guān)于ThreadPool 中的線程調(diào)用算法,其實(shí)很簡單,每個(gè)線程都有一個(gè)自己的工作隊(duì)列l(wèi)ocal queue,此外線程池中還有一個(gè)global queue全局工作隊(duì)列,首先一個(gè)線程被創(chuàng)建出來后,先看看自己的工作隊(duì)列有沒有被分配task,如果沒有的話,就去global queue找task,如果還沒有的話,就去別的線程的工作隊(duì)列找Task。
第二種情況:在同步方法里調(diào)用異步方法,不wait()
如果這個(gè)異步方法進(jìn)入的是global Task 則在線程饑餓的情況下,也會(huì)發(fā)生死鎖的情況。至于為什么,可以看那篇博文里的解釋,因?yàn)間lobal Task的優(yōu)先級很高,所有新產(chǎn)生的線程都去執(zhí)行g(shù)lobal Task,而global task又需要一個(gè)線程去執(zhí)行l(wèi)ocal task,所以產(chǎn)生了死鎖。
一招搞定github下載速度到2MB/s
良心推薦:.NET Core快速開發(fā)利器WTM!
