1. 程式人生 > WINDOWS開發 >C# 非同步程式設計

C# 非同步程式設計

● Async Patterns(非同步模式)
● Foundations(async和await關鍵字)
● ErrorHandling(非同步方法的錯誤處理)

非同步程式設計的重要性

使用非同步程式設計,方法呼叫是在後臺執行(通常線上程或任務的幫助下),並且不會阻塞呼叫執行緒。
本章將學習3種不同模式的非同步程式設計:非同步模式、基於事件的非同步模式和基於任務的非同步模式(Task-based Asynchronous Pattern,TAP)。TAP是利用async和await關鍵字來實現的。通過這裡的比較,將認識到非同步程式設計新模式的真正優勢。

如果應用程式沒有立刻響應使用者的請求,會讓使用者反感。用滑鼠操作,我們習慣了出現延遲,過去幾十年都是這樣操作的。有了觸控UI,應用程式要求立刻響應使用者的請求。否則,使用者就會不斷重複同一個動作。

因為在舊版本的.NET Framework中用非同步程式設計非常不方便,所以並沒有總是這樣做。Visual Studio舊版本是經常阻塞UI執行緒的應用程式之一。例如,在Visual Studio 2010中,開啟一個包含數百個專案的解決方案,這意味可能需要等待很長的時間。自從Visual Studio 2012以來,情況就不一樣了,因為專案都是在後臺非同步載入的,並且選中的專案會優先載入。Visual Studio 2015的一個最新改進是NuGet包管理器不再實現為模式對話方塊。新的NuGet包管理器可以非同步載入包的資訊,同時做其他工作。這是非同步程式設計內建到Visual Studio 2015中帶來的重要變化之一。

很多.NET Framework的API都提供了同步版本和非同步版本。因為同步版本的API用起來更為簡單,所以常常在不適合使用時也用了同步版本的API。在新的Windows執行庫(WinRT)中,如果一個API呼叫時間超過40ms,就只能使用其非同步版本。自從C# 5開始,非同步程式設計和同步程式設計一樣簡單,所以用非同步API應該不會有任何的障礙。

非同步程式設計的基礎

async和await關鍵字只是編譯器功能。編譯器會用Task類建立程式碼。如果不使用這兩個關鍵字,也可以用C# 4.0和Task類的方法來實現同樣的功能,只是沒有那麼方便。

建立任務

所有示例Foundations的程式碼都使用瞭如下依賴項和名稱空間:
依賴項:

NETStandard.Library
名稱空間:

using System;
using System.Threading;
using System.Threading.Tasks;
using static System.Console;

下面從同步方法Greeting開始,該方法等待一段時間後,返回一個字串:

static string Greeting(string name)
    {
      Task.Delay(3000).Wait();
      return $"Hello,{name}";
    }

定義方法GreetingAsync,可以使方法非同步化。基於任務的非同步模式指定,在非同步方法名後加上Async字尾,並返回一個任務。非同步方法GreetingAsync和同步方法Greeting具有相同的輸入引數,但是它返回的是Task。Task定義了一個返回字串的任務。一個比較簡單的做法是用Task.Run方法返回一個任務。泛型版本的Task.Run()建立一個返回字串的任務:

static Task<string> GreetingAsync(string name)
{
  return Task.Run<string>(() =>
  {
    return Greeting(name);
  });
}

呼叫非同步方法

可以使用await關鍵字來呼叫返回任務的非同步方法GreetingAsync。使用await關鍵字需要有用async修飾符宣告的方法。在GreetingAsync方法完成前,該方法內的其他程式碼不會繼續執行。但是,啟動CallerWithAsync方法的執行緒可以被重用。該執行緒沒有阻塞:

private async static void CallerWithAsync()
{
  string result = await GreetingAsync("Stephanie");
  WriteLine(result);
}

如果非同步方法的結果不傳遞給變數,也可以直接在引數中使用await關鍵字。在這裡,GreetingAsync方法返回的結果將像前面的程式碼片段一樣等待,但是這一次的結果會直接傳給WriteLine方法:

private async static void CallerWithAsync2()
{
  WriteLine(await GreetingAsync("Stephanie"));
}

async修飾符只能用於返回.NET型別的Task或viod的方法,以及Windows執行庫的IAsyncOperation。它不能用於程式的入口點,即Main方法不能使用async修飾符。await只能用於返回Task的方法。

延續任務

GreetingAsync方法返回一個Task物件。該Task物件包含任務建立的資訊,並儲存到任務完成。Task類的ContinueWith方法定義了任務完成後就呼叫的程式碼。指派給ContinueWith方法的委託接收將已完成的任務作為引數傳入,使用Result屬性可以訪問任務返回的結果:

private static void CallerWithContinuationTask()
{
  Task<string> t1 = GreetingAsync("Stephanie");
  t1.ContinueWith(t =>
  {
    string result = t.Result;
    WriteLine(result);
  });
}

編譯器把await關鍵字後的所有程式碼放進ContinueWith方法的程式碼塊中來轉換await關鍵字。

同步上下文

如果驗證方法中使用的執行緒,會發現CallerWithAsync方法和CallerWithContinuationTask方法,在方法的不同生命階段使用了不同的執行緒。一個執行緒用於呼叫GreetingAsync方法,另外一個執行緒執行await關鍵字後面的程式碼,或者繼續執行ContinueWith方法內的程式碼塊。

使用一個控制檯應用程式,通常不會有什麼問題。但是,必須保證在所有應該完成的後臺任務完成之前,至少有一個前臺執行緒仍然在執行。示例應用程式呼叫Console.ReadLine來保證主執行緒一直在執行,直到按下返回鍵。

為了執行某些動作,有些應用程式會繫結到指定的執行緒上(例如,在WPF應用程式中,只有UI執行緒才能訪問UI元素),這將會是一個問題。

如果使用async和await關鍵字,當await完成之後,不需要進行任何特別處理,就能訪問UI執行緒。預設情況下,生成的程式碼就會把執行緒轉換到擁有同步上下文的執行緒中。WPF應用程式設定了DispatcherSynchronizationContext屬性,Windows Forms應用程式設定了WindowsFormsSynchronization-Context屬性。如果呼叫非同步方法的執行緒分配給了同步上下文,await完成之後將繼續執行。預設情況下,使用了同步上下文。如果不使用相同的同步上下文,則必須呼叫Task方法ConfigureAwait (continueOnCapturedContext:false)。例如,一個WPF應用程式,其await後面的程式碼沒有用到任何的UI元素。在這種情況下,避免切換到同步上下文會執行得更快。

使用多個非同步方法

在一個非同步方法裡,可以呼叫一個或多個非同步方法。如何編寫程式碼,取決於一個非同步方法的結果是否依賴於另一個非同步方法。

按順序呼叫非同步方法

使用await關鍵字可以呼叫每個非同步方法。在有些情況下,如果一個非同步方法依賴另一個非同步方法的結果,await關鍵字就非常有用。在這裡,GreetingAsync非同步方法的第二次呼叫完全獨立於其第一次呼叫的結果。這樣,如果每個非同步方法都不使用await,那麼整個MultipleAsyncMethods非同步方法將更快地返回結果,如下所示:

private async static void MultipleAsyncMethods()
{
  string s1 = await GreetingAsync("Stephanie");
  string s2 = await GreetingAsync("Matthias");
  WriteLine("Finished both methods.\nResult 1: {s1}\n Result 2: {s2}");
}

使用組合器

如果非同步方法不依賴於其他非同步方法,則每個非同步方法都不使用await,而是把每個非同步方法的返回結果賦值給Task變數,就會執行得更快。GreetingAsync方法返回Task。這些方法現在可以並行運行了。組合器可以幫助實現這一點。一個組合器可以接受多個同一型別的引數,並返回同一型別的值。多個同一型別的引數被組合成一個引數來傳遞。Task組合器接受多個Task物件作為引數,並返回一個Task。
示例程式碼呼叫Task.WhenAll組合器方法,它可以等待,直到兩個任務都完成。

private async static void MultipleAsyncMethodsWithCombinators1()
{
  Task<string> t1 = GreetingAsync("Stephanie");
  Task<string> t2 = GreetingAsync("Matthias");
  await Task.WhenAll(t1,t2);
  WriteLine("Finished both methods.\n " + $"Result 1: {t1.Result}\n Result 2: {t2.Result}");
}

Task類定義了WhenAll和WhenAny組合器。從WhenAll方法返回的Task,是在所有傳入方法的任務都完成了才會返回Task。從WhenAny方法返回的Task,是在其中一個傳入方法的任務完成了就會返回Task。

Task型別的WhenAll方法定義了幾個過載版本。如果所有的任務返回相同的型別,那麼該型別的陣列可用於await返回的結果。GreetingAsync方法返回一個Task,等待返回的結果是一個字串(string)形式。因此,Task.WhenAll可用於返回一個字串陣列:

private async static void MultipleAsyncMethodsWithCombinators2()
{
  Task<string> t1 = GreetingAsync("Stephanie");
  Task<string> t2 = GreetingAsync("Matthias");
  string[] result =  await Task.WhenAll(t1,t2);
  WriteLine("Finished both methods.\n " + $"Result 1: {result[0]}\n Result 2: {result[1]}");
}

轉換非同步模式

並非.NET Framework的所有類都引入了新的非同步方法。在使用框架中的不同類時會發現,還有許多類只提供了BeginXXX方法和EndXXX方法的非同步模式,沒有提供基於任務的非同步模式。但是,可以把非同步模式轉換為基於任務的非同步模式。
首先,從前面定義的同步方法Greeting中,藉助於委託,建立一個非同步方法。Greeting方法接收一個字串作為引數,並返回一個字串。因此,Func<string,string>委託的變數可用於引用Greeting方法。按照非同步模式,BeginGreeting方法接收一個string引數、一個AsyncCallback引數和一個object引數,返回IAsyncResult。EndGreeting方法返回來自Greeting方法的結果——一個字串——並接收一個IAsyncResult引數。這樣,同步方法Greeting就通過一個委託變成非同步方法。

private Func<string,string> greetingInvoker = Greeting;

private IAsyncResult BeginGreeting(string name,AsyncCallback callback,object state)
{
  return greetingInvoker.BeginInvoke(name,callback,state);
}

private string EndGreeting(IAsyncResult ar)
{
  return greetingInvoker.EndInvoke(ar);
}

現在,BeginGreeting方法和EndGreeting方法都是可用的,它們都應轉換為使用async和await關鍵字來獲取結果。TaskFactory類定義了FromAsync方法,它可以把使用非同步模式的方法轉換為基於任務的非同步模式的方法(TAP)。

示例程式碼中,Task型別的第一個泛型引數Task定義了呼叫方法的返回值型別。FromAsync方法的泛型引數定義了方法的輸入型別。這樣,輸入型別又是字串型別。FromAsync方法的前兩個引數是委託型別,傳入BeginGreeting和EndGreeting方法的地址。緊跟這兩個引數後面的是輸入引數和物件狀態引數。因物件狀態沒有用到,所以給它分配null值。因為FromAsync方法返回Task型別,即示例程式碼中的Task,可以使用await,如下所示:

private static async void ConvertingAsyncPattern()
{
  string s = await Task<string>.Factory.FromAsync<string>(BeginGreeting,EndGreeting,"Angela",null);
  WriteLine(s);
}

取消

在一些情況下,後臺任務可能執行很長時間,取消任務就非常有用了。對於取消任務,.NET提供了一種標準的機制。這種機制可用於基於任務的非同步模式。

取消框架基於協助行為,不是強制性的。一個執行時間很長的任務需要檢查自己是否被取消,在這種情況下,它的工作就是清理所有已開啟的資源,並結束相關工作。

取消基於CancellationTokenSource類,該類可用於傳送取消請求。請求傳送給引用CancellationToken類的任務,其中CancellationToken類與CancellationTokenSource類相關聯。

開始取消任務

首先,使用MainWindow類的私有欄位成員定義一個CancellationTokenSource型別的變數cts。該成員用於取消任務,並將令牌傳遞給應取消的方法:

public partial class MainWindow : Window
{
  private SearchInfo _searchInfo = new SearchInfo();
  private object _lockList = new object();
  private CancellationTokenSource _cts;
  //. . .

新新增一個按鈕,用於取消正在執行的任務,新增事件處理程式OnCancel方法。在這個方法中,變數cts用Cancel方法取消任務:

private void OnCancel(object sender,RoutedEventArgs e)
{
  _cts? .Cancel();
}

CancellationTokenSource類還支援在指定時間後才取消任務。CancelAfter方法傳入一個時間值,單位是毫秒,在該時間過後,就取消任務。

使用框架特性取消任務

現在,將CancellationToken傳入非同步方法。框架中的某些非同步方法提供可以傳入CancellationToken的過載版本,來支援取消任務。例如HttpClient類的GetAsync方法。除了URI字串,過載的GetAsync方法還接受CancellationToken引數。可以使用Token屬性檢索CancellationTokenSource類的令牌。

GetAsync方法的實現會定期檢查是否應取消操作。如果取消,就清理資源,之後丟擲OperationCanceledException異常。如下面的程式碼片段所示,catch處理程式捕獲到了該異常:

private async void OnTaskBasedAsyncPattern(object sender,RoutedEventArgs e)
{
  _cts = new CancellationTokenSource();
  try
  {
    foreach (var req in GetSearchRequests())
    {
    var clientHandler = new HttpClientHandler
    {
      Credentials = req.Credentials;
    };
    var client = new HttpClient(clientHandler);
    var response = await client.GetAsync(req.Url,_cts.Token);
    string resp = await response.Content.ReadAsStringAsync();
    //. . .
    }
  }
  catch (OperationCanceledException ex)
  {
    MessageBox.Show(ex.Message);
  }
}

取消自定義任務

如何取消自定義任務?Task類的Run方法提供了過載版本,它也傳遞CancellationToken引數。但是,對於自定義任務,需要檢查是否請求了取消操作。下例中,這是在foreach迴圈中實現的,可以使用IsCancellationRequsted屬性檢查令牌。在丟擲異常之前,如果需要做一些清理工作,最好驗證一下是否請求取消操作。如果不需要做清理工作,檢查之後,會立即用ThrowIfCancellationRequested方法觸發異常:

await Task.Run(() =>
{
  var images = req.Parse(resp);
  foreach (var image in images)
  {
    _cts.Token.ThrowIfCancellationRequested();


    _searchInfo.List.Add(image);
  }
},_cts.Token);

現在,使用者可以取消執行時間長的任務了。