折騰好久,最終在這里找到了答案: https://stackoverflow.com/questions/10343632/httpclient-getasync-never-returns-when-using-await-async/10351400#10351400
先來看看案例一,代碼如下:
public class ValuesController : ApiController { [Route("getMessage")] public string GetMessage() { NetTask(); return "Ok"; } public async Task NetTask() { using (HttpClient client = new HttpClient()) { HttpRequestMessage request = new HttpRequestMessage { Method = HttpMethod.Get }; request.RequestUri = new Uri("http://www.baidu.com"); var response = await client.SendAsync(request); var result = await response.Content.ReadAsStringAsync(); Debug.WriteLine(result); } } }
上面這段代碼,當我們在瀏覽器上訪問/getMessage時,永遠不會輸出Debug.WriteLine(result);
猜想是不是NetTask()沒有堵塞,導致主線程結束后,NetTask()方法中的異步代碼沒有執行。
運行案例二,把NetTask()堵塞掉,代碼如下:
public string GetMessage() { NetTask().GetAwaiter().GetResult(); return "Ok"; }
訪問/getMessage,發現請求得不到響應,斷點一下,發現NetTask()方法一直被堵塞了,引發了死鎖!
有的人可能會說GetMessage()應該打上async標記,使用await 等待NetTask,沒錯,這樣實現的話代碼是沒有任何問題的,但是不滿足我們現在的要求,假設NetTask()的網絡請求耗時為10秒,如果我們這樣去寫我們的代碼,那GetMessage()這個Action的耗時起碼需要10秒。
也有人會推薦使用Task.Run,用其他線程去執行這個耗時請求。但是這樣的操作還是額外產生了線程的調度(主線程,Task.Run中的線程,await后的線程,共3個線程),我們的理想操作是主線程完成請求前的所有動作后不堵塞完成NetTask()后面的代碼。請求完成后的操作交給其他線程。這樣我們只使用到2個線程。
Context 上下文
在我們使用async/await時,我們首先需要了解"Context",簡單的說,當我們使用async/await時,當程序遇到需要等待的地方,程序會捕捉到當前的Context,當異步方法完成后,程序會恢復捕捉到的Context,並在上面執行后續的代碼。異步的上下文可以分為UI上下文,ASP.NET Request上下文,線程池上下文,讓我們用代碼解釋一下:
// WinForms example (WPF同原理). private async void DownloadFileButton_Click(object sender, EventArgs e) { // 異步方法,當代碼需要await時,程序捕捉當前的上下文,此處為UI上下文。await期間主線程是不會被阻塞的 await DownloadFileAsync(fileNameTextBox.Text); // await完成,程序將為我們恢復捕捉的上下文,也就是UI上下文,因為我們擁有UI上下文所以可以更新UI控件,即使當前的線程可能是其他線程 resultTextBox.Text = "File downloaded!"; } // ASP.NET example protected async void MyButton_Click(object sender, EventArgs e) { // 異步方法,當代碼需要await時,程序捕捉當前的上下文,此處為請求的上下文。await期間允許線程繼續處理其他請求 await DownloadFileAsync(...); // await完成,程序恢復request上下文,即便當前操作的可能是其他線程,但我們擁有請求的上下文,所以我們能響應請求。 Response.Write("File downloaded!"); }
那么再回過頭來解釋一下案例一為什么不能運行到client.SendAsync()后面的代碼,按照我們剛才所說,異步時會為我們捕捉請求的上下文。在看NetTask()並沒有被阻塞,所以請求馬上就結束了。當client.SendAsync()被執行完后無法操作一個已經結束的請求上下文,自然不會執行下面的代碼。當然如果client.SendAsync()執行的夠快,在請求還沒有結束前完成,那么依舊能運行到后續的代碼。
再來解釋一下案例二為什么會死鎖,在案例二中,使用GetAwaiter().GetResult()阻塞住了主線程,異步幫我們捕捉了請求的上下文,當異步完成恢復請求上下文的環境時,因為主線程阻塞等待異步的完成,而異步線程因為主線程阻塞沒辦法操作請求上下文,產生了死鎖。
那么應該怎么解決這種問題呢?
ConfigureAwait
大部分情況,我們可能不需要返回到"主"上下文中,大部分的異步方法可以被組合使用,每個異步操作可能只代表本身,和之前的上下文並沒有什么聯系。此時,我們可以通過ConfigureAwait告訴程序不需要捕捉上下文:
private async Task DownloadFileAsync(string fileName) { // 使用HttpClient或者其他下載文件 var fileContents = await DownloadFileContentsAsync(fileName).ConfigureAwait(false); // 因為配置了ConfigureAwait(false),所以此時不再是之前的上下文,而是Thread pool Context// 將文件寫到硬盤 await WriteToDiskAsync(fileName, fileContents).ConfigureAwait(false); // 第二個ConfigureAwait不是必須的,但是不錯的做法 } // WinForms example (it works exactly the same for WPF). private async void DownloadFileButton_Click(object sender, EventArgs e) { // await,UI線程不會在此阻塞,並且調用異步方法沒有使用ConfigureAwait(false),所以此時會捕捉UI上下文 await DownloadFileAsync(fileNameTextBox.Text); // 我們擁有UI上下文,所以當異步恢復時,可以直接操作UI控件 resultTextBox.Text = "File downloaded!"; }
當然,如果我們操作全程都包括標注了async,也不會出現阻塞的情況,但Action肯定是需要等異步結束后才能返回的。
參考文章:https://blogs.msdn.microsoft.com/pfxteam/2012/04/12/asyncawait-faq/
http://blog.stephencleary.com/2012/07/dont-block-on-async-code.html
http://blog.stephencleary.com/2012/02/async-and-await.html