非同步程式設計(async&await)
前言
本來這篇文章上個月就該釋出了,但是因為忙 ofollow,noindex">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.一個簡單的同步方法
這是一個簡單的同步方法呼叫示例:
1class Program 2{ 3static void Main(string[] args) 4{ 5Console.WriteLine($"頭部已執行,當前主執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 6string result = SayHi("jack"); 7Console.WriteLine(result); 8Console.WriteLine($"尾部已執行,當前主執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 9Console.ReadKey(); 10} 11static string SayHi(string name) 12{ 13Task.Delay(2000).Wait();//非同步等待2s 14Console.WriteLine($"SayHi執行,當前執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 15return $"Hello,{name}"; 16} 17}
執行結果如下,方法在主執行緒中執行,主執行緒被阻塞。
2.同步方法非同步化
示例將方法放到任務內執行:
1class Program 2{ 3static void Main(string[] args) 4{ 5Console.WriteLine($"頭部已執行,當前主執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 6string result = SayHiAsync("jack").Result; 7Console.WriteLine(result); 8Console.WriteLine($"尾部已執行,當前主執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 9Console.ReadKey(); 10} 11static Task<string> SayHiAsync(string name) 12{ 13return Task.Run<string>(() => { return SayHi(name); }); 14} 15static string SayHi(string name) 16{ 17Task.Delay(2000).Wait();//非同步等待2s 18Console.WriteLine($"SayHi執行,當前執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 19return $"Hello,{name}"; 20} 21}
執行結果如下,方法在另外一個執行緒中執行,因為主執行緒呼叫了 Result , Result 在任務沒有完成時內部會使用 Wait ,所以主執行緒還是會被阻塞。
3.延續任務
示例為了避免阻塞主執行緒使用任務延續的方式:
1class Program 2{ 3static void Main(string[] args) 4{ 5Console.WriteLine($"頭部已執行,當前主執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 6Task<string> task = SayHiAsync("jack"); 7task.ContinueWith(t =>//延續任務,指定任務執行完成後延續的操作 8{ 9Console.WriteLine($"延續執行,當前執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 10string result = t.Result; 11Console.WriteLine(result); 12}); 13Console.WriteLine($"尾部已執行,當前主執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 14Console.ReadKey(); 15} 16static Task<string> SayHiAsync(string name) 17{ 18return Task.Run<string>(() => { return SayHi(name); }); 19} 20static string SayHi(string name) 21{ 22Task.Delay(2000).Wait();//非同步等待2s 23Console.WriteLine($"SayHi執行,當前執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 24return $"Hello,{name}"; 25} 26}
執行結果如下,方法在另外一個執行緒中執行,因為任務附加了 延續 ,延續會在任務完成後處理返回值,而主執行緒不會被阻塞。這應該就是想要的效果了。
4.使用async和await構建非同步方法呼叫
1class Program 2{ 3static void Main(string[] args) 4{ 5Console.WriteLine($"頭部已執行,當前主執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 6CallerWithAsync("jack"); 7Console.WriteLine($"尾部已執行,當前主執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 8Console.ReadKey(); 9} 10async static void CallerWithAsync(string name) 11{ 12Console.WriteLine($"非同步呼叫頭部執行,當前執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 13string result = await SayHiAsync(name); 14Console.WriteLine($"非同步呼叫尾部執行,當前執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 15Console.WriteLine(result); 16} 17static Task<string> SayHiAsync(string name) 18{ 19return Task.Run<string>(() => { return SayHi(name); }); 20} 21static string SayHi(string name) 22{ 23Task.Delay(2000).Wait();//非同步等待2s 24Console.WriteLine($"SayHi執行,當前執行緒Id為:{Thread.CurrentThread.ManagedThreadId}"); 25return $"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 返回值的方法。
非同步方法執行機理(控制流)
非同步程式設計中最需弄清的是控制流是如何從方法移動到方法的。
下列示例及說明引自( 官方文件 ),個人認為已經很清晰了:
1class Program 2{ 3static void Main(string[] args) 4{ 5var result = AccessTheWebAsync(); 6Console.ReadKey(); 7} 8async static Task<int> AccessTheWebAsync() 9{ 10HttpClient client = new HttpClient(); 11// GetStringAsync返回一個任務。任務Result會得到一個字串(urlContents)。 12Task<string> getStringTask = client.GetStringAsync("https://www.cnblogs.com/jonins/"); 13//您可以在這裡完成不依賴於GetStringAsync的字串的工作。 14DoIndependentWork(); 15//等待的操作員暫停進入WebAsync。 16//AccessTheWebAsync在getStringTask完成之前不能繼續。 17//同時,控制權返回到AccessTheWebAsync的呼叫方。 18//當getStringTask完成後,控制元件權將繼續在這裡工作。 然後,await運算子從getStringTask檢索字串結果。 19string urlContents = await getStringTask; 20//任務完成 21Console.WriteLine(urlContents.Length); 22//return語句指定一個整數結果。 23return urlContents.Length; 24} 25static void DoIndependentWork() 26{ 27Console.WriteLine("Working.........."); 28} 29}
多個非同步方法
在一個非同步方法裡,可以呼叫一個或多個非同步方法,如何編碼取決於 非同步方法間結果是否相互依賴 。
1.順序呼叫非同步方法
使用 await 關鍵詞可以呼叫每個非同步方法,如果一個非同步方法需要使用另一個非同步方法的結果, await 關鍵詞就非常必要。
示例如下:
1class Program 2{ 3static void Main(string[] args) 4{ 5Console.WriteLine("執行前....."); 6GetResultAsync(); 7Console.WriteLine("執行中....."); 8Console.ReadKey(); 9} 10async static void GetResultAsync() 11{ 12var number1 = await GetResult(10); 13var number2 =GetResult(number1); 14Console.WriteLine($"結果分別為:{number1}和{number2.Result}"); 15} 16static Task<int> GetResult(int number) 17{ 18return Task.Run<int>(() => { Task.Delay(1000).Wait(); return number + 10; }); 19} 20}
2.使用組合器
如果 非同步方法間相互不依賴 ,則每個非同步方法都不使用 await ,而是把每個非同步方法的結果賦值給 Task 變數,就會執行得更快。
示例如下:
1class Program 2{ 3static void Main(string[] args) 4{ 5Console.WriteLine("執行前....."); 6GetResultAsync(); 7Console.WriteLine("執行中....."); 8Console.ReadKey(); 9} 10async static void GetResultAsync() 11{ 12Task<int> task1 = GetResult(10); 13Task<int> task2 = GetResult(20); 14await Task.WhenAll(task1, task2); 15Console.WriteLine($"結果分別為:{task1.Result}和{task2.Result}"); 16} 17static Task<int> GetResult(int number) 18{ 19return Task.Run<int>(() => { Task.Delay(1000).Wait(); return number + 10; }); 20} 21}
Task 類定於2個組合器分別為: WhenAll 和 WhenAny 。
WhenAll 是在所有傳入的任務都完成時才返回 Task 。
WhenAny 是在傳入的任務其中一個完成就會返回 Task 。
非同步方法的異常處理
1.異常處理
以下示例一種是普通的錯誤的捕獲方式,另一種是非同步方法異常捕獲方式:
1class Program 2{ 3static void Main(string[] args) 4{ 5 6DontHandle(); 7HandleError(); 8Console.ReadKey(); 9} 10//錯誤處理 11static void DontHandle() 12{ 13try 14{ 15var task = ThrowAfter(0, "DontHandle Error"); 16} 17catch (Exception ex) 18{ 19 20Console.WriteLine(ex.Message); 21} 22} 23//非同步方法錯誤處理 24static async void HandleError() 25{ 26try 27{ 28await ThrowAfter(2000, "HandleError Error"); 29} 30catch (Exception ex) 31{ 32 33Console.WriteLine(ex.Message); 34} 35} 36//在延遲後丟擲異常 37static async Task ThrowAfter(int ms, string message) 38{ 39await Task.Delay(ms); 40throw 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 屬性檢查任務的狀態,以確認它們是否出現錯誤,然後再進行處理。示例如下:
1class Program 2{ 3static void Main(string[] args) 4{ 5HandleError(); 6Console.ReadKey(); 7} 8//正確的處理方式 9static async void HandleError() 10{ 11Task t1 = null; 12Task t2 = null; 13try 14{ 15t1 = ThrowAfter(1000, "HandleError-One-Error"); 16t2 = ThrowAfter(2000, "HandleError-Two-Error"); 17await Task.WhenAll(t1, t2); 18} 19catch (Exception) 20{ 21if (t1.IsFaulted) 22Console.WriteLine(t1.Exception.InnerException.Message); 23if (t2.IsFaulted) 24Console.WriteLine(t2.Exception.InnerException.Message); 25} 26} 27//在延遲後丟擲異常 28static async Task ThrowAfter(int ms, string message) 29{ 30await Task.Delay(ms); 31throw new Exception(message); 32} 33}
3.使用AggregateException捕獲非同步方法異常
在 任務(task) 中介紹過 AggregateException ,它包含了等待中所有異常的列表,可輕鬆遍歷處理所有異常資訊。示例如下:
1class Program 2{ 3static void Main(string[] args) 4{ 5HandleError(); 6Console.ReadKey(); 7} 8//正確的處理方式 9static async void HandleError() 10{ 11Task taskResult = null; 12try 13{ 14Task t1 = ThrowAfter(1000, "HandleError-One-Error"); 15Task t2 = ThrowAfter(2000, "HandleError-Two-Error"); 16await (taskResult = Task.WhenAll(t1, t2)); 17} 18catch (Exception) 19{ 20foreach (var ex in taskResult.Exception.InnerExceptions) 21{ 22Console.WriteLine(ex.Message); 23} 24 25} 26} 27//在延遲後丟擲異常 28static async Task ThrowAfter(int ms, string message) 29{ 30await Task.Delay(ms); 31throw new Exception(message); 32} 33}
重要的補充與建議
1.提高響應能力
.NET有很多非同步API我們都可以通過 async/await 構建呼叫提高響應能力,例如:
1class Program 2{ 3static void Main(string[] args) 4{ 5Demo(); 6Console.ReadKey(); 7} 8static async void Demo() 9{ 10HttpClient httpClient = new HttpClient(); 11var getTaskResult = await httpClient.GetStringAsync("https://www.cnblogs.com/jonins/"); 12Console.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