前言 本來這篇文章上個月就該發佈了,但是因為忙 QuarkDoc 一直沒有時間整理,所以耽擱到今天,現在回歸正軌。 C# 5.0 雖然只引入了2個新關鍵詞:async和await。然而它大大簡化了非同步方法的編程。 在 線程池(threadPool)大致介紹了微軟在不同時期使用的不同的非同步模式,有3種 ...
前言
本來這篇文章上個月就該發佈了,但是因為忙 QuarkDoc 一直沒有時間整理,所以耽擱到今天,現在回歸正軌。
C# 5.0 雖然只引入了2個新關鍵詞:async和await。然而它大大簡化了非同步方法的編程。
在 線程池(threadPool)大致介紹了微軟在不同時期使用的不同的非同步模式,有3種:
1.非同步模式
2.基於事件的非同步模式
3.基於任務的非同步模式(TAP)
而最後一種就是利用async和await關鍵字來實現的(TAP是現在微軟極力推崇的一種非同步編程方式)。
但請謹記,async和await關鍵字只是編譯器功能。編譯器會用Task類創建代碼。如果不使用這兩個關鍵詞,用C#4.0的Task類同樣可以實現相同的功能,只是沒有那麼方便而已。
認識async和await
使用async和await關鍵詞編寫非同步代碼,具有與同步代碼相當的結構和簡單性,並且摒棄了非同步編程的複雜結構。
但是在理解上剛開始會很不習慣,而且會把一些情況想當然了,而真實情況會相去甚遠(我犯過這樣的錯誤)。所以根據幾個示例一步步理解更加的靠譜些。
1.一個簡單的同步方法
這是一個簡單的同步方法調用示例:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 Console.WriteLine($"頭部已執行,當前主線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 6 string result = SayHi("jack"); 7 Console.WriteLine(result); 8 Console.WriteLine($"尾部已執行,當前主線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 9 Console.ReadKey(); 10 } 11 static string SayHi(string name) 12 { 13 Task.Delay(2000).Wait();//非同步等待2s 14 Console.WriteLine($"SayHi執行,當前線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 15 return $"Hello,{name}"; 16 } 17 }
執行結果如下,方法在主線程中運行,主線程被阻塞。
2.同步方法非同步化
示例將方法放到任務內執行:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 Console.WriteLine($"頭部已執行,當前主線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 6 string result = SayHiAsync("jack").Result; 7 Console.WriteLine(result); 8 Console.WriteLine($"尾部已執行,當前主線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 9 Console.ReadKey(); 10 } 11 static Task<string> SayHiAsync(string name) 12 { 13 return Task.Run<string>(() => { return SayHi(name); }); 14 } 15 static string SayHi(string name) 16 { 17 Task.Delay(2000).Wait();//非同步等待2s 18 Console.WriteLine($"SayHi執行,當前線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 19 return $"Hello,{name}"; 20 } 21 }
執行結果如下,方法在另外一個線程中運行,因為主線程調用了Result,Result在任務沒有完成時內部會使用Wait,所以主線程還是會被阻塞。
3.延續任務
示例為了避免阻塞主線程使用任務延續的方式:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 Console.WriteLine($"頭部已執行,當前主線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 6 Task<string> task = SayHiAsync("jack"); 7 task.ContinueWith(t =>//延續任務,指定任務執行完成後延續的操作 8 { 9 Console.WriteLine($"延續執行,當前線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 10 string result = t.Result; 11 Console.WriteLine(result); 12 }); 13 Console.WriteLine($"尾部已執行,當前主線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 14 Console.ReadKey(); 15 } 16 static Task<string> SayHiAsync(string name) 17 { 18 return Task.Run<string>(() => { return SayHi(name); }); 19 } 20 static string SayHi(string name) 21 { 22 Task.Delay(2000).Wait();//非同步等待2s 23 Console.WriteLine($"SayHi執行,當前線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 24 return $"Hello,{name}"; 25 } 26 }
執行結果如下,方法在另外一個線程中運行,因為任務附加了延續,延續會在任務完成後處理返回值,而主線程不會被阻塞。這應該就是想要的效果了。
4.使用async和await構建非同步方法調用
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 Console.WriteLine($"頭部已執行,當前主線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 6 CallerWithAsync("jack"); 7 Console.WriteLine($"尾部已執行,當前主線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 8 Console.ReadKey(); 9 } 10 async static void CallerWithAsync(string name) 11 { 12 Console.WriteLine($"非同步調用頭部執行,當前線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 13 string result = await SayHiAsync(name); 14 Console.WriteLine($"非同步調用尾部執行,當前線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 15 Console.WriteLine(result); 16 } 17 static Task<string> SayHiAsync(string name) 18 { 19 return Task.Run<string>(() => { return SayHi(name); }); 20 } 21 static string SayHi(string name) 22 { 23 Task.Delay(2000).Wait();//非同步等待2s 24 Console.WriteLine($"SayHi執行,當前線程Id為:{Thread.CurrentThread.ManagedThreadId}"); 25 return $"Hello,{name}"; 26 } 27 }
執行結果如下,使用await關鍵字來調用返回任務的非同步方法SayHiAsync,而使用await需要有用async修飾符聲明的方法,在SayHiAsync方法為完成前,下麵的方法不會繼續執行。但是主線程並沒有阻塞,且任務處理完成後await後的邏輯繼續執行。
本質:編譯器將await關鍵字後的所有代碼放進了延續(ContinueWith)方法的代碼塊中來轉換await關鍵詞。
解析async和await
1.非同步(async)
使用async修飾符標記的方法稱為非同步方法,非同步方法只可以具有以下返回類型:
1.Task
2.Task<TResult>
3.void
4.從C# 7.0開始,任何具有可訪問的GetAwaiter方法的類型。System.Threading.Tasks.ValueTask<TResult> 類型屬於此類實現(需向項目添加System.Threading.Tasks.Extensions
NuGet 包)。
非同步方法通常包含 await 運算符的一個或多個實例,但缺少 await 表達式也不會導致生成編譯器錯誤。 如果非同步方法未使用 await 運算符標記暫停點,那麼非同步方法會作為同步方法執行,即使有 async 修飾符也不例外,編譯器將為此類方法發佈一個警告。
2.等待(await)
await 表達式只能在由 async 修飾符標記的封閉方法體、lambda 表達式或非同步方法中出現。在其他位置,它會解釋為標識符。
使用await運算符的任務只可用於返回 Task、Task<TResult> 和 System.Threading.Tasks.ValueType<TResult> 對象的方法。
非同步方法同步運行,直至到達其第一個 await 表達式,此時await在方法的執行中插入掛起點,會將方法掛起直到所等待的任務完成,然後繼續執行await後面的代碼區域。
await 表達式並不阻止正在執行它的線程。 而是使編譯器將剩下的非同步方法註冊為等待任務的延續任務。 控制權隨後會返回給非同步方法的調用方。 任務完成時,它會調用其延續任務,非同步方法的執行會在暫停的位置處恢復。
註意:
1.無法等待具有 void 返回類型的非同步方法,並且無效返回方法的調用方捕獲不到非同步方法拋出的任何異常。
2.非同步方法無法聲明 in、ref 或 out 參數,但可以調用包含此類參數的方法。 同樣,非同步方法無法通過引用返回值,但可以調用包含 ref 返回值的方法。
非同步方法運行機理(控制流)
非同步編程中最需弄清的是控制流是如何從方法移動到方法的。
下列示例及說明引自(官方文檔),個人認為已經很清晰了:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 var result = AccessTheWebAsync(); 6 Console.ReadKey(); 7 } 8 async static Task<int> AccessTheWebAsync() 9 { 10 HttpClient client = new HttpClient(); 11 // GetStringAsync返回一個任務。任務Result會得到一個字元串(urlContents)。 12 Task<string> getStringTask = client.GetStringAsync("https://www.cnblogs.com/jonins/"); 13 //您可以在這裡完成不依賴於GetStringAsync的字元串的工作。 14 DoIndependentWork(); 15 //等待的操作員暫停進入WebAsync。 16 //AccessTheWebAsync在getStringTask完成之前不能繼續。 17 //同時,控制權返回到AccessTheWebAsync的調用方。 18 //當getStringTask完成後,控制項權將繼續在這裡工作。 然後,await運算符從getStringTask檢索字元串結果。 19 string urlContents = await getStringTask; 20 //任務完成 21 Console.WriteLine(urlContents.Length); 22 //return語句指定一個整數結果。 23 return urlContents.Length; 24 } 25 static void DoIndependentWork() 26 { 27 Console.WriteLine("Working.........."); 28 } 29 }
多個非同步方法
在一個非同步方法里,可以調用一個或多個非同步方法,如何編碼取決於非同步方法間結果是否相互依賴。
1.順序調用非同步方法
使用await關鍵詞可以調用每個非同步方法,如果一個非同步方法需要使用另一個非同步方法的結果,await關鍵詞就非常必要。
示例如下:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 Console.WriteLine("執行前....."); 6 GetResultAsync(); 7 Console.WriteLine("執行中....."); 8 Console.ReadKey(); 9 } 10 async static void GetResultAsync() 11 { 12 var number1 = await GetResult(10); 13 var number2 = GetResult(number1); 14 Console.WriteLine($"結果分別為:{number1}和{number2.Result}"); 15 } 16 static Task<int> GetResult(int number) 17 { 18 return Task.Run<int>(() => { Task.Delay(1000).Wait(); return number + 10; }); 19 } 20 }
2.使用組合器
如果非同步方法間相互不依賴,則每個非同步方法都不使用await,而是把每個非同步方法的結果賦值給Task變數,就會運行得更快。
示例如下:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 Console.WriteLine("執行前....."); 6 GetResultAsync(); 7 Console.WriteLine("執行中....."); 8 Console.ReadKey(); 9 } 10 async static void GetResultAsync() 11 { 12 Task<int> task1 = GetResult(10); 13 Task<int> task2 = GetResult(20); 14 await Task.WhenAll(task1, task2); 15 Console.WriteLine($"結果分別為:{task1.Result}和{task2.Result}"); 16 } 17 static Task<int> GetResult(int number) 18 { 19 return Task.Run<int>(() => { Task.Delay(1000).Wait(); return number + 10; }); 20 } 21 }
Task類定於2個組合器分別為:WhenAll和WhenAny。
WhenAll是在所有傳入的任務都完成時才返回Task。
WhenAny是在傳入的任務其中一個完成就會返回Task。
非同步方法的異常處理
1.異常處理
以下示例一種是普通的錯誤的捕獲方式,另一種是非同步方法異常捕獲方式:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 6 DontHandle(); 7 HandleError(); 8 Console.ReadKey(); 9 } 10 //錯誤處理 11 static void DontHandle() 12 { 13 try 14 { 15 var task = ThrowAfter(0, "DontHandle Error"); 16 } 17 catch (Exception ex) 18 { 19 20 Console.WriteLine(ex.Message); 21 } 22 } 23 //非同步方法錯誤處理 24 static async void HandleError() 25 { 26 try 27 { 28 await ThrowAfter(2000, "HandleError Error"); 29 } 30 catch (Exception ex) 31 { 32 33 Console.WriteLine(ex.Message); 34 } 35 } 36 //在延遲後拋出異常 37 static async Task ThrowAfter(int ms, string message) 38 { 39 await Task.Delay(ms); 40 throw new Exception(message); 41 } 42 }
執行結果如下:
調用非同步方法,如果只是簡單的放在try/catch塊中,將會捕獲不到異常。這是因為DontHandle方法在ThrowAfter拋出異常之前已經執行完畢(返回void的非同步方法不會等待。這是因為從async void方法拋出的異常無法捕獲。因此非同步方法最好返回一個Task類型)。
非同步方法的一個較好異常處理方式,是使用await關鍵字,將其放在try/catch中。
2.多個非同步方法異常處理
如果調用了多個非同步方法,在第一個非同步方法拋出異常,後續的方法將不會被調用,catch塊內只會處理出現的第一個異常。
所以正確的做法是使用Task.WhenAll,不管任務是否拋出異常都會等到所有任務完成。Task.WhenAll結束後,異常被catch語句捕獲到。如果只是捕獲Exception,我們只能看到WhenAll方法的第一個發生異常的任務信息,不會拋出後續的異常任務。
如果要捕獲所有任務的異常信息,就是對任務聲明變數,在catch塊內可以訪問,再使用IsFaulted屬性檢查任務的狀態,以確認它們是否出現錯誤,然後再進行處理。示例如下:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 HandleError(); 6 Console.ReadKey(); 7 } 8 //正確的處理方式 9 static async void HandleError() 10 { 11 Task t1 = null; 12 Task t2 = null; 13 try 14 { 15 t1 = ThrowAfter(1000, "HandleError-One-Error"); 16 t2 = ThrowAfter(2000, "HandleError-Two-Error"); 17 await Task.WhenAll(t1, t2); 18 } 19 catch (Exception) 20 { 21 if (t1.IsFaulted) 22 Console.WriteLine(t1.Exception.InnerException.Message); 23 if (t2.IsFaulted) 24 Console.WriteLine(t2.Exception.InnerException.Message); 25 } 26 } 27 //在延遲後拋出異常 28 static async Task ThrowAfter(int ms, string message) 29 { 30 await Task.Delay(ms); 31 throw new Exception(message); 32 } 33 }
3.使用AggregateException捕獲非同步方法異常
在 任務(task)中介紹過AggregateException,它包含了等待中所有異常的列表,可輕鬆遍歷處理所有異常信息。示例如下:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 HandleError(); 6 Console.ReadKey(); 7 } 8 //正確的處理方式 9 static async void HandleError() 10 { 11 Task taskResult = null; 12 try 13 { 14 Task t1 = ThrowAfter(1000, "HandleError-One-Error"); 15 Task t2 = ThrowAfter(2000, "HandleError-Two-Error"); 16 await (taskResult = Task.WhenAll(t1, t2)); 17 } 18 catch (Exception) 19 { 20 foreach (var ex in taskResult.Exception.InnerExceptions) 21 { 22 Console.WriteLine(ex.Message); 23 } 24 25 } 26 } 27 //在延遲後拋出異常 28 static async Task ThrowAfter(int ms, string message) 29 { 30 await Task.Delay(ms); 31 throw new Exception(message); 32 } 33 }
重要的補充與建議
1.提高響應能力
.NET有很多非同步API我們都可以通過async/await構建調用提高響應能力,例如:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 Demo(); 6 Console.ReadKey(); 7 } 8 static async void Demo() 9 { 10 HttpClient httpClient = new HttpClient(); 11 var getTaskResult = await httpClient.GetStringAsync("https://www.cnblogs.com/jonins/"); 12 Console.WriteLine(getTaskResult); 13 } 14 }
這些API都有相同原則即以Async結尾。
2.重要建議
1.async方法需在其主體中具有await 關鍵字,否則它們將永不暫停。同時C# 編譯器將生成一個警告,此代碼將會以類似普通方法的方式進行編譯和運行。 請註意這會導致效率低下,因為由 C# 編譯器為非同步方法生成的狀態機將不會完成任何任務。
2.應將“Async”作為尾碼添加到所編寫的每個非同步方法名稱中。這是 .NET 中的慣例,以便更輕鬆區分同步和非同步方法。
3.async void 應僅用於事件處理程式。因為事件不具有返回類型(因此無法返回 Task 和 Task<T>)。 其他任何對 async void 的使用都不遵循 TAP 模型,且可能存在一定使用難度。
例如:async void 方法中引發的異常無法在該方法外部被捕獲或十分難以測試 async void 方法。
3.以非阻止方式處理等待任務
非同步編程準則
非同步編程的準則是確定所需執行的操作是I/O-Bound還是 CPU-Bound。因為這會極大影響代碼性能,並可能導致某些構造的誤用。
考慮兩個問題:
1.你的代碼是否會“等待”某些內容,例如資料庫中的數據或web資源等?如果答案為“是”,則你的工作是 I/O-Bound。
2.你的代碼是否要執行開銷巨大的計算?如果答案為“是”,則你的工作是 CPU-Bound。
如果你的工作為 I/O-Bound,請使用 async 和 await(而不使用 Task.Run)。 不應使用任務並行庫。
如果你的工作為 CPU-Bound,並且你重視響應能力,請使用 async 和 await,併在另一個線程上使用 Task.Run 生成工作。 如果該工作同時適用於併發和並行,則應考慮使用任務並行庫。
結語
如果想要瞭解狀態機請戳:這裡 。
參考資料
C#高級編程(第10版) C# 6 & .NET Core 1.0 Christian Nagel
果殼中的C# C#5.0權威指南 Joseph Albahari
https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/concepts/async/index
https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/async
https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/await