概述
一天之計在於晨,每天的早餐也是必不可少,但是很多人為了節約時間,都是簡單的吃點湊合一下或干脆不吃早餐,這對於個人身體和工作效率來說,無疑是不合理的,那么要如何做一頓早餐呢?如何能節約做早餐的時間呢?本文以一個簡單的小例子,簡述如何做一頓早餐及如何優化做早餐的時間。僅供學習分享使用,如有不足之處,還請指正。
正常情況下,做早餐可以分為以下幾個步驟:
- 倒一杯咖啡。
- 加熱平底鍋,然后煎兩個雞蛋。
- 煎三片培根。
- 烤兩片面包。
- 在烤面包上加黃油和果醬。
- 倒一杯橙汁。
同步方式做早餐
根據以上步驟進行編程,做一份早餐需要編寫程序如下:
1 /// <summary> 2 /// 同步做早餐 3 /// </summary> 4 /// <param name="sender"></param> 5 /// <param name="e"></param> 6 private void btnBreakfast_Click(object sender, EventArgs e) 7 { 8 this.txtInfo.Clear(); 9 Stopwatch watch = Stopwatch.StartNew(); 10 watch.Start(); 11 //1. 倒一杯咖啡。 12 string cup = PourCoffee(); 13 PrintInfo("咖啡沖好了"); 14 //2. 加熱平底鍋,然后煎兩個雞蛋。 15 string eggs = FryEggs(2); 16 PrintInfo("雞蛋煎好了"); 17 //3. 煎三片培根。 18 string bacon = FryBacon(3); 19 PrintInfo("培根煎好了"); 20 //4. 烤兩片面包。 21 string toast = ToastBread(2); 22 //5. 在烤面包上加黃油和果醬。 23 ApplyButter(toast); 24 ApplyJam(toast); 25 PrintInfo("面包烤好了"); 26 //6. 倒一杯橙汁。 27 string oj = PourOJ(); 28 PrintInfo("橙汁倒好了"); 29 PrintInfo("早餐准備完畢!"); 30 watch.Stop(); 31 TimeSpan time = watch.Elapsed; 32 PrintInfo(string.Format("總運行時間為:{0}秒", time.TotalSeconds.ToString("0.00"))); 33 } 34 35 /// <summary> 36 /// 倒一杯咖啡 37 /// </summary> 38 /// <returns></returns> 39 private string PourCoffee() 40 { 41 PrintInfo("正在沖咖啡..."); 42 return "咖啡"; 43 } 44 45 /// <summary> 46 /// 抹果醬 47 /// </summary> 48 /// <param name="toast"></param> 49 private void ApplyJam(string toast) => 50 PrintInfo("往面包抹果醬"); 51 52 /// <summary> 53 /// 抹黃油 54 /// </summary> 55 /// <param name="toast"></param> 56 private void ApplyButter(string toast) => 57 PrintInfo("往面包抹黃油"); 58 59 /// <summary> 60 /// 烤面包 61 /// </summary> 62 /// <param name="slices"></param> 63 /// <returns></returns> 64 private string ToastBread(int slices) 65 { 66 for (int slice = 0; slice < slices; slice++) 67 { 68 PrintInfo("往烤箱里面放面包"); 69 } 70 PrintInfo("開始烤..."); 71 Task.Delay(3000).Wait(); 72 PrintInfo("從烤箱取出面包"); 73 74 return "烤面包"; 75 } 76 77 /// <summary> 78 /// 煎培根 79 /// </summary> 80 /// <param name="slices"></param> 81 /// <returns></returns> 82 private string FryBacon(int slices) 83 { 84 PrintInfo($"放 {slices} 片培根在平底鍋"); 85 PrintInfo("煎第一片培根..."); 86 Task.Delay(3000).Wait(); 87 for (int slice = 0; slice < slices; slice++) 88 { 89 PrintInfo("翻轉培根"); 90 } 91 PrintInfo("煎第二片培根..."); 92 Task.Delay(3000).Wait(); 93 PrintInfo("把培根放盤子里"); 94 95 return "煎培根"; 96 } 97 98 /// <summary> 99 /// 煎雞蛋 100 /// </summary> 101 /// <param name="howMany"></param> 102 /// <returns></returns> 103 private string FryEggs(int howMany) 104 { 105 PrintInfo("加熱平底鍋..."); 106 Task.Delay(3000).Wait(); 107 PrintInfo($"磕開 {howMany} 個雞蛋"); 108 PrintInfo("煎雞蛋 ..."); 109 Task.Delay(3000).Wait(); 110 PrintInfo("雞蛋放盤子里"); 111 112 return "煎雞蛋"; 113 } 114 115 /// <summary> 116 /// 倒橙汁 117 /// </summary> 118 /// <returns></returns> 119 private string PourOJ() 120 { 121 PrintInfo("倒一杯橙汁"); 122 return "橙汁"; 123 }
同步做早餐示例
通過運行示例,發現采用同步方式進行編程,做一份早餐,共計15秒鍾,且在此15秒鍾時間內,程序處於【卡住】狀態,無法進行其他操作。如下所示:
同步做早餐示意圖
同步方式做早餐,就是一個做完,再進行下一個,順序執行,如下所示:
同步方式為何會【卡住】?
因為在程序進程中,會有一個主線程,用於響應用戶的操作,同步方式下,做早餐的和前端頁面同在主線程中,所以當開始做早餐時,就不能響應其他的操作了。這就是【兩耳不聞窗外事,一心只讀聖賢書】的境界。但如果讓用戶長時間處於等待狀態,會讓用戶體驗很不友好。比如,劉玄德三顧茅廬,大雪紛飛之下,諸葛亮在草廬中午睡,劉關張在大雪中靜等。試問有幾人會有玄德的耐心,何況程序也不是諸葛亮,用戶也沒有玄德的耐心!
異步方式做早餐
上述代碼演示了不正確的實踐:構造同步代碼來執行異步操作。 顧名思義,此代碼將阻止執行這段代碼的線程執行任何其他操作。 在任何任務進行過程中,此代碼也不會被中斷。 就如同你將面包放進烤面包機后盯着此烤面包機一樣。 你會無視任何跟你說話的人,直到面包彈出。如何做才能避免線程阻塞呢?答案就是異步。 await
關鍵字提供了一種非阻塞方式來啟動任務,然后在此任務完成時繼續執行。
首先更新代碼,對於耗時的程序,采用異步方式做早餐,如下所示:
1 private async void btnBreakfastAsync_Click(object sender, EventArgs e) 2 { 3 this.txtInfo.Clear(); 4 Stopwatch watch = Stopwatch.StartNew(); 5 watch.Start(); 6 //1. 倒一杯咖啡。 7 string cup = PourCoffee(); 8 PrintInfo("咖啡沖好了"); 9 //2. 加熱平底鍋,然后煎兩個雞蛋。 10 //Task<string> eggs = FryEggsAsync(2); 11 string eggs =await FryEggsAsync(2); 12 PrintInfo("雞蛋煎好了"); 13 //3. 煎三片培根。 14 string bacon =await FryBaconAsync(3); 15 PrintInfo("培根煎好了"); 16 //4. 烤兩片面包。 17 string toast =await ToastBreadAsync(2); 18 //5. 在烤面包上加黃油和果醬。 19 ApplyButter(toast); 20 ApplyJam(toast); 21 PrintInfo("面包烤好了"); 22 //6. 倒一杯橙汁。 23 string oj = PourOJ(); 24 PrintInfo("橙汁倒好了"); 25 PrintInfo("早餐准備完畢!"); 26 watch.Stop(); 27 TimeSpan time = watch.Elapsed; 28 PrintInfo(string.Format("總運行時間為:{0}秒", time.TotalSeconds.ToString("0.00"))); 29 } 30 31 /// <summary> 32 /// 異步烤面包 33 /// </summary> 34 /// <param name="slices"></param> 35 /// <returns></returns> 36 private async Task<string> ToastBreadAsync(int slices) 37 { 38 for (int slice = 0; slice < slices; slice++) 39 { 40 PrintInfo("往烤箱里面放面包"); 41 } 42 PrintInfo("開始烤..."); 43 await Task.Delay(3000); 44 PrintInfo("從烤箱取出面包"); 45 46 return "烤面包"; 47 } 48 49 /// <summary> 50 /// 異步煎培根 51 /// </summary> 52 /// <param name="slices"></param> 53 /// <returns></returns> 54 private async Task<string> FryBaconAsync(int slices) 55 { 56 PrintInfo($"放 {slices} 片培根在平底鍋"); 57 PrintInfo("煎第一片培根..."); 58 await Task.Delay(3000); 59 for (int slice = 0; slice < slices; slice++) 60 { 61 PrintInfo("翻轉培根"); 62 } 63 PrintInfo("煎第二片培根..."); 64 await Task.Delay(3000); 65 PrintInfo("把培根放盤子里"); 66 67 return "煎培根"; 68 } 69 70 /// <summary> 71 /// 異步煎雞蛋 72 /// </summary> 73 /// <param name="howMany"></param> 74 /// <returns></returns> 75 private async Task<string> FryEggsAsync(int howMany) 76 { 77 PrintInfo("加熱平底鍋..."); 78 await Task.Delay(3000); 79 PrintInfo($"磕開 {howMany} 個雞蛋"); 80 PrintInfo("煎雞蛋 ..."); 81 await Task.Delay(3000); 82 PrintInfo("雞蛋放盤子里"); 83 84 return "煎雞蛋"; 85 }
注意:通過測試發現,異步方式和同步方式的執行時間一致,所以采用異步方式並不會縮短時間,但是程序已不再阻塞,可以同時響應用戶的其他請求。
優化異步做早餐
通過上述異步方式,雖然優化了程序,不再阻塞,但是時間並沒有縮短,那么要如何優化程序來縮短時間,以便早早的吃上可口的早餐呢?答案就是在開始一個任務后,在等待任務完成時,可以繼續進行准備其他的任務。 你也幾乎將在同一時間完成所有工作。 你將吃到一頓熱氣騰騰的早餐。通過合並任務和調整任務的順序,將大大節約任務的完成時間,如下所示:
1 /// <summary> 2 /// 優化異步做早餐 3 /// </summary> 4 /// <param name="sender"></param> 5 /// <param name="e"></param> 6 private async void btnBreakfast2_Click(object sender, EventArgs e) 7 { 8 this.txtInfo.Clear(); 9 Stopwatch watch = Stopwatch.StartNew(); 10 watch.Start(); 11 //1. 倒一杯咖啡。 12 string cup = PourCoffee(); 13 PrintInfo("咖啡沖好了"); 14 //2. 加熱平底鍋,然后煎兩個雞蛋。 15 Task<string> eggsTask = FryEggsAsync(2); 16 //3. 煎三片培根。 17 Task<string> baconTask = FryBaconAsync(3); 18 //4.5合起來 烤面包,抹果醬,黃油 19 Task<string> toastTask = MakeToastWithButterAndJamAsync(2); 20 21 string eggs = await eggsTask; 22 PrintInfo("雞蛋煎好了"); 23 24 string bacon = await baconTask; 25 PrintInfo("培根煎好了"); 26 27 string toast = await toastTask; 28 PrintInfo("面包烤好了"); 29 //6. 倒一杯橙汁。 30 string oj = PourOJ(); 31 PrintInfo("橙汁倒好了"); 32 PrintInfo("早餐准備完畢!"); 33 watch.Stop(); 34 TimeSpan time = watch.Elapsed; 35 PrintInfo(string.Format("總運行時間為:{0}秒", time.TotalSeconds.ToString("0.00"))); 36 } 37 38 /// <summary> 39 /// 組合任務 40 /// </summary> 41 /// <param name="number"></param> 42 /// <returns></returns> 43 private async Task<string> MakeToastWithButterAndJamAsync(int number) 44 { 45 var toast = await ToastBreadAsync(number); 46 ApplyButter(toast); 47 ApplyJam(toast); 48 return toast; 49 }
在本例中,合並了【烤面包+抹果醬+抹黃油】為一個任務,這樣是烤面包的同時,可以煎雞蛋,煎培根,三項耗時任務同時執行。在三個任務都完成是,早餐也就做好了,示例如下所示:
通過以上優化示例發現,通過合並任務和調整順序,做一份早餐,需要6.06秒。
優化異步早餐示意圖
優化后的異步做早餐,由於一些任務並發運行,因此節約了時間。示意圖如下所示:
異步異常
上述示例假定所有的任務都可以正常完成,那么如果某一個任務執行過程中發生了異常,要如何捕獲呢?答案是:當任務無法成功完成時,它們將引發異常。 當啟動的任務為 awaited
時,客戶端代碼可捕獲這些異常。
例如當烤面包的時候,烤箱突然着火了,如何處理異常呢?代碼如下所示:
1 private async void btnBreakfastAsync3_Click(object sender, EventArgs e) 2 { 3 try 4 { 5 this.txtInfo.Clear(); 6 Stopwatch watch = Stopwatch.StartNew(); 7 watch.Start(); 8 //1. 倒一杯咖啡。 9 string cup = PourCoffee(); 10 PrintInfo("咖啡沖好了"); 11 //2. 加熱平底鍋,然后煎兩個雞蛋。 12 Task<string> eggsTask = FryEggsAsync(2); 13 //3. 煎三片培根。 14 Task<string> baconTask = FryBaconAsync(3); 15 //4.5合起來 烤面包,抹果醬,黃油 16 Task<string> toastTask = MakeToastWithButterAndJamAsyncEx(2); 17 18 string eggs = await eggsTask; 19 PrintInfo("雞蛋煎好了"); 20 21 string bacon = await baconTask; 22 PrintInfo("培根煎好了"); 23 24 string toast = await toastTask; 25 PrintInfo("面包烤好了"); 26 //6. 倒一杯橙汁。 27 string oj = PourOJ(); 28 PrintInfo("橙汁倒好了"); 29 PrintInfo("早餐准備完畢!"); 30 watch.Stop(); 31 TimeSpan time = watch.Elapsed; 32 PrintInfo(string.Format("總運行時間為:{0}秒", time.TotalSeconds.ToString("0.00"))); 33 } 34 catch (AggregateException ex) { 35 PrintInfo("線程內部異常"); 36 PrintInfo(ex.StackTrace); 37 } 38 catch (Exception ex) 39 { 40 PrintInfo("其他異常"); 41 PrintInfo(ex.Message); 42 } 43 } 44 45 /// <summary> 46 /// 組合任務 47 /// </summary> 48 /// <param name="number"></param> 49 /// <returns></returns> 50 private async Task<string> MakeToastWithButterAndJamAsyncEx(int number) 51 { 52 var toast = await ToastBreadAsyncEx(number); 53 ApplyButter(toast); 54 ApplyJam(toast); 55 return toast; 56 } 57 58 /// <summary> 59 /// 異步烤面包異常 60 /// </summary> 61 /// <param name="slices"></param> 62 /// <returns></returns> 63 private async Task<string> ToastBreadAsyncEx(int slices) 64 { 65 for (int slice = 0; slice < slices; slice++) 66 { 67 PrintInfo("往烤箱里面放面包"); 68 } 69 PrintInfo("開始烤..."); 70 await Task.Delay(2000); 71 PrintInfo("着火了! 面包糊了!"); 72 int a = 1, b = 0; 73 int i = a / b;//制造一個異常 74 //throw new InvalidOperationException("烤箱着火了!"); 75 await Task.Delay(1000); 76 PrintInfo("從烤箱取出面包"); 77 78 return "烤面包"; 79 }
異步任務異常示例
請注意,從烤面包機着火到發現異常,有相當多的任務要完成。 當異步運行的任務引發異常時,該任務出錯。 Task 對象包含 Task.Exception 屬性中引發的異常。 出錯的任務在等待時引發異常。
需要理解兩個重要機制:異常在出錯的任務中的存儲方式,以及在代碼等待出錯的任務時解包並重新引發異常的方式。
當異步運行的代碼引發異常時,該異常存儲在 Task
中。 Task.Exception 屬性為 System.AggregateException,因為異步工作期間可能會引發多個異常。 引發的任何異常都將添加到 AggregateException.InnerExceptions 集合中。 如果該 Exception
屬性為 NULL,則將創建一個新的 AggregateException
且引發的異常是該集合中的第一項。
對於出錯的任務,最常見的情況是 Exception
屬性只包含一個異常。 當代碼 awaits
出錯的任務時,將重新引發 AggregateException.InnerExceptions 集合中的第一個異常。 因此,此示例的輸出顯示 InvalidOperationException
而不是 AggregateException
。 提取第一個內部異常使得使用異步方法與使用其對應的同步方法盡可能相似。 當你的場景可能生成多個異常時,可在代碼中檢查 Exception
屬性。
高效的等待
通過以上示例,需要等待很多任務完成,然后早餐才算做好,那么如何才能高效優雅的等待呢?可以通過使用 Task
類的方法改進上述代碼末尾的一系列 await
語句。其中一個 API 是 WhenAll,它將返回一個其參數列表中的所有任務都已完成時才完成的 Task,如下所示:
1 private async void btnBreakfastAsync4_Click(object sender, EventArgs e) 2 { 3 this.txtInfo.Clear(); 4 Stopwatch watch = Stopwatch.StartNew(); 5 watch.Start(); 6 //1. 倒一杯咖啡。 7 string cup = PourCoffee(); 8 PrintInfo("咖啡沖好了"); 9 //2. 加熱平底鍋,然后煎兩個雞蛋。 10 Task<string> eggsTask = FryEggsAsync(2); 11 //3. 煎三片培根。 12 Task<string> baconTask = FryBaconAsync(3); 13 //4.5合起來 烤面包,抹果醬,黃油 14 Task<string> toastTask = MakeToastWithButterAndJamAsync(2); 15 //等待任務完成 16 await Task.WhenAll(eggsTask, baconTask, toastTask); 17 18 PrintInfo("雞蛋煎好了"); 19 PrintInfo("培根煎好了"); 20 PrintInfo("面包烤好了"); 21 //6. 倒一杯橙汁。 22 string oj = PourOJ(); 23 PrintInfo("橙汁倒好了"); 24 PrintInfo("早餐准備完畢!"); 25 watch.Stop(); 26 TimeSpan time = watch.Elapsed; 27 PrintInfo(string.Format("總運行時間為:{0}秒", time.TotalSeconds.ToString("0.00"))); 28 }
另一種選擇是使用 WhenAny,它將返回一個當其參數完成時才完成的 Task<Task>
。如下所示:
1 private async void btnBreakfastAsync5_Click(object sender, EventArgs e) 2 { 3 this.txtInfo.Clear(); 4 Stopwatch watch = Stopwatch.StartNew(); 5 watch.Start(); 6 //1. 倒一杯咖啡。 7 string cup = PourCoffee(); 8 PrintInfo("咖啡沖好了"); 9 //2. 加熱平底鍋,然后煎兩個雞蛋。 10 Task<string> eggsTask = FryEggsAsync(2); 11 //3. 煎三片培根。 12 Task<string> baconTask = FryBaconAsync(3); 13 //4.5合起來 烤面包,抹果醬,黃油 14 Task<string> toastTask = MakeToastWithButterAndJamAsync(2); 15 //等待任務完成 16 var breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask }; 17 while (breakfastTasks.Count > 0) 18 { 19 Task finishedTask = await Task.WhenAny(breakfastTasks); 20 if (finishedTask == eggsTask) 21 { 22 PrintInfo("雞蛋煎好了"); 23 } 24 else if (finishedTask == baconTask) 25 { 26 PrintInfo("培根煎好了"); 27 } 28 else if (finishedTask == toastTask) 29 { 30 PrintInfo("面包烤好了"); 31 } 32 breakfastTasks.Remove(finishedTask); 33 } 34 //6. 倒一杯橙汁。 35 string oj = PourOJ(); 36 PrintInfo("橙汁倒好了"); 37 PrintInfo("早餐准備完畢!"); 38 watch.Stop(); 39 TimeSpan time = watch.Elapsed; 40 PrintInfo(string.Format("總運行時間為:{0}秒", time.TotalSeconds.ToString("0.00"))); 41 }
以上就是由同步到異步再到優化異步任務的逐步過程,旨在拋磚引玉,一起學習,共同進步。
備注
黃鶴樓【作者】崔顥
昔人已乘黃鶴去,此地空余黃鶴樓。
黃鶴一去不復返,白雲千載空悠悠。
晴川歷歷漢陽樹,芳草萋萋鸚鵡洲。
日暮鄉關何處是?煙波江上使人愁。