前言
本打算繼續寫SQL Server系列,接下來應該是死鎖了,但是在.NET Core項目中到處都是異步,最近在寫一個爬蟲用到異步,之前不是很頻繁用到異步,當用到時就有點縮手縮尾,怕留下坑,還是小心點才是,於是一發不可收拾,發現還是too young,所以再次查看資料學習下Task,用到時再學效果可想而知,若有不同意見請在評論中指出。
建議異步返回Task或Task<T>
當在.NET Core中寫爬蟲用到異步去下載資源后接下來進行處理,對於處理完成結果我返回void,想到這里不僅僅一愣,這么到底行不行,翻一翻寫的第一篇博客,只是提醒了我下不要用void,至於為何不用也沒去探討,接下來我們來探討下返回值為Task和void,至於Task<T>這個和Task類似。我們直接看代碼,首先演示void,如下:
private static async void ThrowExceptionAsync() { await Task.Delay(TimeSpan.FromSeconds(1)); throw new InvalidOperationException(); } private static void AsyncVoidExceptions_CannotBeCaughtByCatch() { try { ThrowExceptionAsync(); } catch (Exception ex) { throw ex; } }
然后在控制台中進行調用,如下:
static void Main(string[] args) { AsyncVoidExceptions_CannotBeCaughtByCatch(); Console.ReadKey(); }
此時我們在異步代碼且返回值為void的方法中有一個異常,並且我們在調用該異步方法中去捕捉異常,但是結果並未捕捉到。接下來我們將異步方法返回值修改為Task如下再來看看:
private static async Task ThrowExceptionAsync() { await Task.Delay(TimeSpan.FromSeconds(1)); throw new InvalidOperationException(); } private static async Task AsyncVoidExceptions_CannotBeCaughtByCatch() { try { await ThrowExceptionAsync(); } catch (Exception ex) { throw ex; } }
此時發現返回值Task和void對於異常都無法捕捉到,這么一來是不是返回值使用Task和void皆可以呢,我們注意到對於被調用的異步方法且返回值為Task,我們試試將先接收其返回值,然后再await看看。此時我們對於第二個異步方法修改成如下:
private static async Task AsyncVoidExceptions_CannotBeCaughtByCatch() { Task task = ThrowExceptionAsync(); try { await task; } catch (Exception ex) { throw ex; } }
通過事先接收其返回值Task然后再await,此時我們就能捕捉到異常,而為什么void無法捕捉到異常呢?請看如下解釋
當在Task或者Task<T>中拋出異常時,此時異常信息將被捕捉到並被放到Task對象中,但是在void異步方法啟動時SynchronizationContext將被激活並且此時沒有Task對象,此時異常信息將直接被保存到異步上下文中即(SynchronizationContext)。
對於捕捉void異常信息其實沒有什么根本上的解決辦法,如果是在控制台中可以用下載 Nito.AsyncEx 程序包並將方法放在 AsyncContext.Run(()=>.....) 運行,還有其他等方法,返回值為void更多用在windows客戶端事件處理程序包中,例如如下:
private async void btn_Click(object sender, EventArgs e) { await BtnClickAsync(); } public async Task BtnClickAsync() { // Do asynchronous work. await Task.Delay(1000); }
在異步操作中如果返回值為Task或者Task<T>,我們知道接下來給如何進行處理,但是返回值為void我們根本不知道它什么時候完成,同時利用void來進行單元測試時也不會拋出異常,所以我們對於異步返回值大部分情況下必須使用Task或者Task<T>,除了基於事件處理而不得不返回void外,對於Task或者Task<T>有利於異常捕捉、暴露更多方法如(Task.WhenAll、Task.WhenAny)、方便單元測試等,基於此我們在此下一個基本結論:
雖然在異步方法中提示返回值可以為Task、Task<T>或者void,但是我們強烈建議返回值只為Task或者Task<T>,除了基於事件處理程序外,因為返回值為void無法捕捉異常信息且不方便單元測試,同時根本不知道異步操作什么時候完成。而對於Task異常信息被保存到Task對象中,所以在捕捉異常信息時,首先返回異步方法Task,然后進行await。
但是對於Task捕捉異常信息還有一個問題我們並未探討,請往下看。
public static Task<int> First() { return Task<int>.Factory.StartNew(() => { throw new Exception(" Exception From First!"); }); } public static Task<int> Second() { return Task<int>.Factory.StartNew(() => { throw new Exception(" Exception From Second!"); }); }
上述定義兩個異步方法,並且都拋出異常,接下來我們再來定義一個方法調用上述兩個方法,如下:
public static async Task<int> Caclulate() { return await First() + await Second(); }
上述情況下理論上調用兩個方法應該拋出兩個異常信息才對,但是結果只對一個First異步方法拋出異常,而對於第二個異步方法Second則忽略了,什么情況,還沒看懂,我們進一步進行如下改造。
static void Main(string[] args) { try { Caclulate().Wait(1000); } catch (AggregateException ex) { throw ex; } Console.ReadKey(); }

我們通過聚合異常類 AggregateException 來接收異常信息,結果只拋出一個異常信息,並且是第一個。 我們再利用返回Task來接收並await來看看是否有不同。
public static async Task Test() { var task = Caclulate(); try { await task; } catch (Exception ex) { throw ex; } }
此時也將僅僅拋出第一個異常信息,所以通過這里演示我們可以下個結論:當在異步代碼中調用多個異步方法時,若出現異常,此時則不會拋出聚合異常而僅僅只是拋出第一個異常。
建議異步感染
在異步操作中如果異步代碼又被其他異步代碼調用時,將同步代碼轉換為異步代碼能夠更有效執行,在異步代碼中沒有感染的概念,為什么我提出“感染”這一概念呢,想必正確使用過異步方法的童鞋深有體會,當一個異步方法被另外一個方法調用時,此時另外一個方法若是同步方法,此時會提示將該方法異步,所以通過該傳播行為從最底層異步方法到最高層調用者都將是異步方法(類似僵屍屍毒),這也是我們所推薦的,一旦用了異步代碼則總是用異步代碼,不要將同步代碼和異步代碼混合使用,很容易導致阻塞情況特別是調用Task.Wait或者Task.Result。這一點我有切身感受,在爬蟲中利用同步方法中調用異步代碼,最終獲取該異步方法中的結果通過Task.Rsult,結果利用Windows窗體測試時發現已經被阻塞,一直顯示Task.Result處於計算中。不信,你看如下代碼。所以我們強烈建議:一旦使用異步代碼且總是使用異步代碼讓異步代碼自然過渡層層傳遞,大部分情況下千萬別調用Task.Wait或者Task.Result很容易導致阻塞。
public static class DeadlockDemo { private static async Task DelayAsync() { await Task.Delay(1000); } public static void Test() { var delayTask = DelayAsync(); delayTask.Wait(); } }
private void btn_click(object sender, EventArgs e) { DeadlockDemo.Test(); MessageBox.Show("異步死鎖"); }
將上述代碼在windows form或者ASP.NET程序中運行你會發現上述調用Wait后會導致死鎖,但在控制台中將不會出現這種死鎖情況。按照我們對異步的理解,默認情況下,當一個未被完成的任務被await時,此時將捕捉到當前上下文,直到任務被完成喚醒該方法,如果當前上下文為空,那么此時當前上下文則為SynchronizationContext。對於如winddows form中的GUI或者ASP.NET應用程序,此時任務調度器的上下文則是SynchronizationContext且只允許一塊代碼運行一次,當任務完成時,將試圖在捕捉的當前上下文去執行異步方法中的其他方法,但是此時已經有一個線程當前上下文存在,造成同步方法去等待完成異步方法,結果引起異步方法喚醒當前方法繼續執行,但是當前同步方法也在等待異步方法完成,彼此等待,造成死鎖。
建議異步配置上下文(分情況)
什么時候應該配置上下文,當我們需要等待結果完成時可以配置上下文,如下:
async Task ConfigureContext() { await Task.Delay(1000); await Task.Delay(1000).ConfigureAwait( continueOnCapturedContext: false); }
當進行如上配置后在 await Task.Delay(1000); 之前毫無疑問將在原始上下文中運行, await Task.Delay(1000).ConfigureAwait( continueOnCapturedContext: false); 此時在此之后因為不捕捉上下文,此時將在線程池中運行。我們在此之前演示了一個造成死鎖的例子,通過配置上下文就可以解決。
private static async Task DelayAsync() { await Task.Delay(1000).ConfigureAwait( continueOnCapturedContext: false); } public static void Test() { var delayTask = DelayAsync(); delayTask.Wait(); }

我們知道默認情況下當await一個未完成的任務時,此時將捕獲上下文來喚醒異步方法來執行其余的方法,但是此時我們配置上下文為false,告訴它不需要捕獲我們根本不耗費時間,我們馬上就能完成,此時將解決死鎖的問題。在異步中配置 ConfigureAwait( continueOnCapturedContext: false); 的作用在於:將同步方法轉換為異步方法和防止死鎖。
那么問題來了什么時候不應該配置上下文呢?請繼續看如下例子:
private async void btn_click(object sender, EventArgs e) { Enabled = false; try { await Task.Delay(1000).ConfigureAwait( continueOnCapturedContext: false); } finally { Enabled = true; } }

當點擊按鈕時我們禁用按鈕,同時關閉了其捕獲當前上下文,但是最后我們又需要用到當前上下文,所以此時導致取不到一樣的線程,此時類似跨線程,出現線程不一致的情況。每個異步方法都有其上下文並且每個方法的上下文是獨立開來的。什么意思呢,由於上述我們直接在點擊事件里面關閉了捕獲上下文,如果我們定義一個方法,在此方法里面來關閉捕獲上下文,此時再來在點擊事件里調用該異步方法,此時點擊事件和該異步方法獨立互不影響,千萬別以為調用了該異步方法就說明是在點擊事件里關閉了上下文,如下:
private async void btn_click(object sender, EventArgs e) { Enabled = false; try { await DisableBtnAsync(); } finally { Enabled = true; } } private async Task DisableBtnAsync() { await Task.Delay(1000).ConfigureAwait(continueOnCapturedContext: false); }
由上已經證明了這點,好了本節我們到此結束。
總結
關於異步和Task中的水還是非常深,我也是用到了再去深究,本節算是對異步中的異常捕獲以及返回值和配置上下文作了一個大概的探討。
