1. 程式人生 > >使用任務Task 簡化異步編程

使用任務Task 簡化異步編程

可能 在操作 ini net 網址 expose 創建 console 窗體

使用任務簡化異步編程

Igor Ostrovsky

下載代碼示例

異步編程是實現與程序其余部分並發運行的較大開銷操作的一組技術。 常出現異步編程的一個領域是有圖形化 UI 的程序環境:當開銷較大的操作完成時,凍結 UI 通常是不可接受的。 此外,異步操作對於需要並發處理多個客戶端請求的服務器應用程序來說非常重要。

在實踐過程中出現的異步操作的典型例子包括向服務器發送請求並等待響應、從硬盤讀取數據以及運行拼寫檢查等開銷較大的計算。

以一個含 UI 的應用程序為例。 該應用程序可以使用 Windows Presentation Foundation (WPF) 或 Windows 窗體構建。 在此類應用程序中,大部分代碼都在 UI 線程上執行,因為它為源自 UI 控件的事件執行事件處理程序。 當用戶單擊一個按鈕時,UI 線程將選取該消息並執行 Click 事件處理程序。

現在,假設在 Click 事件處理程序中,應用程序將請求發送到服務器並等待響應:

// !!!
Bad code !!!
void Button_Click(object sender, RoutedEventArgs e) {
  WebClient client = new WebClient();
  client.DownloadFile("http://www.microsoft.com", "index.html");
}

此代碼中存在一個主要問題:下載網站需要幾秒鐘或更長時間。 接下來,調用 Button_Click 需要幾秒鐘才能返回。 這意味著 UI 線程會被阻止若幹秒鐘且 UI 會被凍結。 凍結界面會導致用戶體驗不佳,這種情況幾乎都是不可接受的。

要使應用程序 UI 能隨時響應,直到服務器做出響應,則需保證下載不是 UI 線程上的同步操作,這一點很重要。

讓我們嘗試一下解決凍結 UI 問題。 一個可能但並非最佳的解決方案是在不同線程上與服務器通信,以便 UI 線程保持未阻止狀態。 下面是一個使用線程池線程與服務器通信的示例:

// Suboptimal code
void Button_Click(object sender, RoutedEventArgs e) {
  ThreadPool.QueueUserWorkItem(_ => {
    WebClient client = new WebClient();
    client.DownloadFile(
      "http://www.microsoft.com", "index.html");
  });
}

此代碼示例解決了第一版存在的問題:現在 Button_Click 事件不會阻止 UI 線程,但基於線程的解決方案有三個嚴重問題。 讓我們進一步了解一下這些問題。

問題 1:浪費線程池線程

我剛才介紹的解決方法使用來自線程池的線程將請求發送到服務器並等待服務器響應。

線程池線程將保持阻止狀態,直到服務器響應。 在對 WebClient.DownloadFile 的調用完成之前,線程無法返回到線程池中。 由於 UI 不會凍結,因此阻止線程池線程比阻止 UI 線程要好得多,但它確實會浪費線程池的一個線程。

如果應用程序偶爾阻止線程池線程一段時間,性能損失可以忽略不計。 但是,如果應用程序經常阻止,其響應能力可能會因線程池承受的壓力而降低。 線程池將嘗試通過創建更多線程來應對這種情況,但會造成相當大的性能開銷。

本文中介紹的所有其他異步編程模式可解決浪費線程池線程的問題。

問題 2:返回結果

使用線程進行異步編程的另一個難題是:從在幫助器線程上執行的操作返回值將變得略為淩亂。

在最初的示例中,DownloadFile 方法將下載的網頁寫入一個本地文件,因此它具有 void 返回值。 請看問題的另一個版本,您希望將收到的 HTML 指定到 TextBox(名為 HtmlTextBox)的 Text 屬性中,而不是將下載的網頁寫入一個文件。

實現上述過程的一種想當然的錯誤方法如下:

// !!!
Broken code !!!
void Button_Click(object sender, RoutedEventArgs e) {
  ThreadPool.QueueUserWorkItem(_ => {
    WebClient client = new WebClient();
    string html = client.DownloadString(
      "http://www.microsoft.com", "index.html");
    HtmlTextBox.Text = html;
  }); 
}

問題在於 UI 控件 HtmlTextBox 被線程池線程修改。 這是一個錯誤,原因在於只有 UI 線程才有權修改 UI。 出於多種很充分的理由,WPF 和 Windows 窗體中都存在此限制。

要解決此問題,您可以在 UI 線程上捕獲同步環境,然後在線程池線程上將消息發布到該環境:

void Button_Click(object sender, RoutedEventArgs e) {
  SynchronizationContext ctx = SynchronizationContext.Current;
  ThreadPool.QueueUserWorkItem(_ => {
    WebClient client = new WebClient();
    string html = client.DownloadString(
      "http://www.microsoft.com");
    ctx.Post(state => {
      HtmlTextBox.Text = (string)state;
    }, html);
  });
}

認識到從幫助器線程返回值的問題不僅僅限於含 UI 的應用程序,這一點非常重要。 通常,從一個線程將值返回給另一個線程相當復雜,需要使用同步基元。

問題 3:組合異步操作

顯式處理線程也使得組合異步操作變得困難。 例如,要並行下載多個網頁,編寫同步代碼將變得更加困難,而且更容易出錯。

此類實現將保留仍在執行的異步操作的計數器。 必須以線程安全的方式修改該計數器,比如說使用 Interlocked.Decrement。 一旦計數器到達零,處理下載的代碼便會執行。 所有這一切都會導致相當大量的代碼容易出錯。

不用說,使用基於線程的模式甚至將更難正確實現更為復雜的復合模式。

基於事件的模式

使用 Microsoft .NET Framework 進行異步編程的一個常見模式是基於事件的模型。 事件模型公開一個方法,以便在操作完成時啟動異步操作並引發一個事件。

事件模式是公開異步操作的一個慣例,但它不是通過接口之類的顯式約定。 類實現器可以確定遵循模式的忠實程度。 圖 1 顯示了正確實現基於事件的異步編程模式所公開的方法示例。

圖 1 基於事件的模式的方法

public class AsyncExample {
  // Synchronous methods.
public int Method1(string param);
  public void Method2(double param);

  // Asynchronous methods.
public void Method1Async(string param);
  public void Method1Async(string param, object userState);
  public event Method1CompletedEventHandler Method1Completed;

  public void Method2Async(double param);
  public void Method2Async(double param, object userState);
  public event Method2CompletedEventHandler Method2Completed;

  public void CancelAsync(object userState);

  public bool IsBusy { get; }

  // Class implementation not shown.
...
}

WebClient 是 .NET Framework 中的一個類,可通過基於事件的模式實現異步操作。 為了提供 DownloadString 方法的異步變體,WebClient 公開了 DownloadStringAsync 和 CancelAsync 方法以及 DownloadStringCompleted 事件。 以下代碼顯示如何以異步方式實現我們的示例:

void Button_Click(object sender, RoutedEventArgs e) {
  WebClient client = new WebClient();
  client.DownloadStringCompleted += eventArgs => {
      HtmlTextBox.Text = eventArgs.Result;
  };
  client.DownloadStringAsync("http://www.microsoft.com");
}

此實現解決了基於線程的低效解決方案的第 1 個問題:不必要的線程阻止。 對 DownloadStringAsync 的調用會立即返回,而不會阻止 UI 線程或線程池線程。 下載在後臺執行,一旦下載完成,DownloadStringCompleted 事件將在相應線程上執行。

請註意,DownloadStringCompleted 事件處理程序在相應線程上執行,不需要 SynchronizationContext 代碼,而基於線程的解決方案則需要此代碼。 在後臺,WebClient 自動捕獲 SynchronizationContext 並接著將回調發布到該環境。 實現基於事件的模式的類通常可確保 Completed 處理程序在相應線程上執行。

基於事件的異步編程模式不會阻止沒有必要阻止的線程,從這個角度講該模式是高效的,而且它是 .NET Framework 中廣泛使用的兩種模式之一。 不過,基於事件的模式有幾個限制:

  • 該模式是非正式且僅僅依據慣例的,類可以偏離該模式。
  • 將多個異步操作組合起來可能會相當困難,例如處理並行啟動的異步操作或處理異步操作序列。
  • 您無法輪詢和檢查異步操作是否已完成。
  • 使用這些類型時必須十分小心。 例如,如果使用一個實例處理多個異步操作,則必須對註冊事件處理程序進行編碼,以便僅處理一個目標異步操作,即使多次調用該處理程序也是如此。
  • 即使沒有必要在 UI 線程上執行,也將始終在啟動異步操作時捕獲的 SynchronizationContext 上調用事件處理程序,從而導致額外的性能開銷。
  • 難以良好實現,並且需要定義多個類型(例如,事件處理程序或事件參數)。

圖 2 列出了 .NET Framework 4 類的幾個示例,這些類實現基於事件的異步模式。

圖 2 .NET 類中基於事件的異步模式示例

操作
System.Activities.WorkflowInvoker InvokeAsync
System.ComponentModel.BackgroundWorker RunWorkerAsync
System.Net.Mail.SmtpClient SendAsync
System.Net.NetworkInformation.Ping SendAsync
System.Net.WebClient DownloadStringAsync

IAsyncResult 模式

在 .NET 中實現異步操作的另一個慣例是 IAsyncResult 模式。 與基於事件的模型相比,IAsyncResult 是更高級的異步編程解決方案。

在 IAsyncResult 模式中,使用 Begin 和 End 方法公開異步操作。 可以調用 Begin 方法來啟動異步操作,並傳入操作完成時將調用的委托。 可以從回調調用 End 方法,該方法返回異步操作的結果。 或者,可以輪詢操作是否已完成或者同步等待該操作,而不是提供回調。

以 Dns.GetHostAddresses 方法為例,該方法接受一個主機名並返回該主機名解析後的 IP 地址數組。 該方法同步版本的簽名如下所示:

public static IPAddress[] GetHostAddresses(
  string hostNameOrAddress)
The asynchronous version of the method is exposed as follows:
public static IAsyncResult BeginGetHostAddresses(
  string hostNameOrAddress,
  AsyncCallback requestCallback,
  Object state)

public static IPAddress[] EndGetHostAddresses(
  IAsyncResult asyncResult)

以下示例使用 BeginGetHostAddresses 和 EndGetHostAddresses 方法異步查詢 DNS 以獲得地址 www.microsoft.com:

static void Main() {
  Dns.BeginGetHostAddresses(
    "www.microsoft.com",
    result => {
      IPAddress[] addresses = Dns.EndGetHostAddresses(result);
      Console.WriteLine(addresses[0]);
    }, 
    null);
  Console.ReadKey();
}

圖 3 列出了若幹 .NET 類,這些類使用基於事件的模式實現異步操作。 通過比較圖 2圖 3,您將註意到某些類實現基於事件的模式,某些類實現 IAsyncResult 模式,而某些類實現兩種模式。

圖 3 .NET 類中 IAsyncResult 的示例

操作
System.Action BeginInvoke
System.IO.Stream BeginRead
System.Net.Dns BeginGetHostAddresses
System.Net.HttpWebRequest BeginGetResponse
System.Net.Sockets.Socket BeginSend
System.Text.RegularExpressions.MatchEvaluator BeginInvoke
System.Data.SqlClient.SqlCommand BeginExecuteReader
System.Web.DefaultHttpHandler BeginProcessRequest

從歷史角度講,IAsyncResult 模式作為實現異步 API 的高性能方法被引入 .NET Framework 1.0。 不過,它與 UI 線程進行交互需要額外的工作,很難正確實現,而且難以使用。 在 .NET Framework 2.0 中引入基於事件的模式簡化了 IAsyncResult 未能解決的 UI 方面的問題,該模式側重於以下方案:UI 應用程序啟動單個異步應用程序,然後與其一起運行。

任務模式

.NET Framework 4 中引入了一個新類型 System.Threading.Tasks.Task,作為表示異步操作的一種方式。一個 Task 可表示在 CPU 上執行的一項普通計算:

static void Main() {
  Task<double> task = Task.Factory.StartNew(() => { 
    double result = 0; 
    for (int i = 0; i < 10000000; i++) 
      result += Math.Sqrt(i);
    return result;
  });

  Console.WriteLine("The task is running asynchronously...");
  task.Wait();
  Console.WriteLine("The task computed: {0}", task.Result);
}

默認情況下,使用 StartNew 方法創建的 Task 與在線程池上執行代碼的 Task 相對應。 但是,Task 更加通用並且可表示任意異步操作,甚至是與服務器相對應(或者說通信)或從磁盤讀取數據的那些操作。

TaskCompletionSource 是創建表示異步操作的 Task 的常規機制。 TaskCompletionSource 只與一項任務相關聯。 一旦對 TaskCompletionSource 調用 SetResult 方法,相關聯的 Task 便會結束,返回 Task 的結果值(請參見圖 4)。

圖 4 使用 TaskCompletionSource

static void Main() {
  // Construct a TaskCompletionSource and get its 
  // associated Task
  TaskCompletionSource<int> tcs = 
    new TaskCompletionSource<int>();
  Task<int> task = tcs.Task;

  // Asynchronously, call SetResult on TaskCompletionSource
  ThreadPool.QueueUserWorkItem( _ => {
    Thread.Sleep(1000); // Do something
    tcs.SetResult(123);
  });

  Console.WriteLine(
    "The operation is executing asynchronously...");
  task.Wait();

  // And get the result that was placed into the task by 
  // the TaskCompletionSource
  Console.WriteLine("The task computed: {0}", task.Result);
}

在這裏,我使用一個線程池線程對 TaskCompletionSource 調用 SetResult。 不過,要註意的重要一點是,對 TaskCompletionSource 有訪問權限的任何代碼都可以調用 SetResult 方法,比如 Button.Click 事件的事件處理程序、完成某些計算的 Task 以及因服務器響應某個請求而引發的事件等。

因此,TaskCompletionSource 是實現異步操作的很常規的機制。

轉換 IAsyncResult 模式

要使用 Task 進行異步編程,很重要的一點是能夠與使用較舊模型公開的異步操作進行互操作。 雖然 TaskCompletionSource 可以封裝任何異步操作並將其作為 Task 公開,但 Task API 提供一種方便的機制將 IAsyncResult 模式轉換為 Task,即 FromAsync 方法。

以下示例使用 FromAsync 方法將基於 IAsyncResult 的異步操作 Dns.BeginGetHost Addresses 轉換為 Task:

static void Main() {
  Task<IPAddress[]> task = 
    Task<IPAddress[]>.Factory.FromAsync(
      Dns.BeginGetHostAddresses, 
      Dns.EndGetHostAddresses,
      "http://www.microsoft.com", null);
  ...
}

FromAsync 使得將 IAsyncResult 異步操作轉換為任務非常容易。 實際上,實現 FromAsync 的方式類似於使用 ThreadPool 的 TaskCompletionSource 示例。 下面是實現該方法的簡單近似方式,在本例中直接以 GetHostAddresses 為目標:

static Task<IPAddress[]> GetHostAddressesAsTask(
  string hostNameOrAddress) {

  var tcs = new TaskCompletionSource<IPAddress[]>();
  Dns.BeginGetHostAddresses(hostNameOrAddress, iar => {
    try { 
      tcs.SetResult(Dns.EndGetHostAddresses(iar)); }
    catch(Exception exc) { tcs.SetException(exc); }
  }, null);
  return tcs.Task;
}

轉換基於事件的模式

也可以使用 TaskCompletionSource 類將基於事件的異步操作轉換為 Task。 Task 類不為這一轉換提供內置機制,由於基於事件的異步模式僅僅是一種慣例,因此常規機制是不實用的。

下面介紹如何將基於事件的異步操作轉換為任務。 代碼示例顯示獲取 Uri 並返回表示異步操作 WebClient.DownloadStringAsync 的 Task 的方法:

static Task<string> DownloadStringAsTask(Uri address) {
  TaskCompletionSource<string> tcs = 
    new TaskCompletionSource<string>();
  WebClient client = new WebClient();
  client.DownloadStringCompleted += (sender, args) => {
    if (args.Error != null) tcs.SetException(args.Error);
    else if (args.Cancelled) tcs.SetCanceled();
    else tcs.SetResult(args.Result);
  };
  client.DownloadStringAsync(address);
  return tcs.Task;
}

使用這一模式和上節中介紹的模式,您可以將任何現有的異步模式(基於事件或基於 IAsyncResult)轉換為 Task。

處理和組合任務

那麽,為何使用 Task 來表示異步操作? 主要原因是 Task 公開方法以便於處理和組合異步操作。 與 IAsyncResult 和基於事件的方法不同,Task 提供保留關於異步操作、如何與之聯接、如何檢索其結果等的所有相關信息的單個對象。

對於 Task,您可以做的一件有用的事情是等待它完成。 可以在一個 Task 上等待,等待集合中的所有 Task 完成,或等待集合中的任意 Task 完成。

static void Main() {
  Task<int> task1 = new Task<int>(() => ComputeSomething(0));
  Task<int> task2 = new Task<int>(() => ComputeSomething(1));
  Task<int> task3 = new Task<int>(() => ComputeSomething(2));

  task1.Wait();
  Console.WriteLine("Task 1 is definitely done.");

  Task.WaitAny(task2, task3);
  Console.WriteLine("Task 2 or task 3 is also done.");

  Task.WaitAll(task1, task2, task3);
  Console.WriteLine("All tasks are done.");
}

Task 的另一項有用功能是能夠計劃延續任務,即在另一個 Task 完成後立即執行的 Task。 與等待類似,您可以計劃延續任務在特定 Task 完成時運行、在集合中的所有 Task 完成時運行或者在集合中的任意 Task 完成時運行。

以下示例創建一項查詢 DNS 以獲得地址 www.microsoft.com 的任務。 該任務完成後,將啟動延續任務並將結果輸出到控制臺:

static void Main() {
  Task<IPAddress[]> task = 
    Task<IPAddress[]>.Factory.FromAsync(
      Dns.BeginGetHostAddresses, 
      Dns.EndGetHostAddresses,
      "www.microsoft.com", null);

  task.ContinueWith(t => Console.WriteLine(t.Result));
  Console.ReadKey();
}

讓我們看一下更多有趣的示例,它們展示了任務作為異步操作表示形式的強大功能。 圖 5 顯示了並行運行兩個 DNS 查找的示例。 當異步操作表示為任務時,很容易等待多個操作完成。

圖 5 並行運行多個操作

static void Main() {
  string[] urls = new[] { "www.microsoft.com", "www.msdn.com" };
  Task<IPAddress[]>[] tasks = new Task<IPAddress[]>[urls.Length];

  for(int i=0; i<urls.Length; i++) {
    tasks[i] = Task<IPAddress[]>.Factory.FromAsync(
      Dns.BeginGetHostAddresses,
      Dns.EndGetHostAddresses,
      urls[i], null);
  }

  Task.WaitAll(tasks);

  Console.WriteLine(
    "microsoft.com resolves to {0} IP addresses.
msdn.com resolves to {1}",
    tasks[0].Result.Length,
    tasks[1].Result.Length);
}

讓我們看另一個組合多項任務的示例,它采用以下三個步驟:

  1. 通過異步方式並行下載多個 HTML 頁面
  2. 處理 HTML 頁面
  3. 從 HTML 頁面聚合信息

圖 6 顯示如何利用上文所示的 DownloadStringAsTask 方法實現此類計算。 這種實現的顯著好處是兩個不同的 CountParagraphs 方法在不同線程上執行。 在如今多核計算機盛行的條件下,將開銷大的計算工作分散到多個線程的程序將獲得性能優勢。

圖 6 異步下載字符串

static void Main() {
  Task<string> page1Task = DownloadStringAsTask(
    new Uri("http://www.microsoft.com"));
  Task<string> page2Task = DownloadStringAsTask(
    new Uri("http://www.msdn.com"));

  Task<int> count1Task = 
    page1Task.ContinueWith(t => CountParagraphs(t.Result));
  Task<int> count2Task = 
    page2Task.ContinueWith(t => CountParagraphs(t.Result));

  Task.Factory.ContinueWhenAll(
    new[] { count1Task, count2Task },
    tasks => {
      Console.WriteLine(
        "<P> tags on microsoft.com: {0}", 
        count1Task.Result);
      Console.WriteLine(
        "<P> tags on msdn.com: {0}", 
        count2Task.Result);
  });
        
  Console.ReadKey();
}

在同步環境中運行任務

有時,能夠計劃將在特定同步環境中運行的延續任務會非常有用。 例如,在含 UI 的應用程序中,能夠計劃將在 UI 線程上執行的延續任務通常非常有用。

使 Task 與同步環境交互的最簡單方法是創建用於捕獲當前線程環境的 TaskScheduler。 要為 UI 線程創建 TaskScheduler,請在 UI 線程上運行時對 TaskScheduler 類型調用 FromCurrentSynchronizationContext 靜態方法。

以下示例異步下載 www.microsoft.com 網頁,然後將下載的 HTML 指定到 WPF 文本框的 Text 屬性中:

void Button_Click(object sender, RoutedEventArgs e) {
  TaskScheduler uiTaskScheduler =
    TaskScheduler.FromCurrentSynchronizationContext()

  DownloadStringAsTask(new Uri("http://www.microsoft.com"))
    .ContinueWith(
       t => { textBox1.Text = t.Result; },
       uiTaskScheduler);
}

Button_Click 方法的主體將建立最終更新 UI 的異步計算,但 Button_Click 不會等待計算完成。 這樣,UI 線程將不會被阻止,可繼續更新用戶界面並響應用戶操作。

如前所述,在 .NET Framework 4 之前,通常使用 IAsyncResult 模式或基於事件的模式公開異步操作。 有了 .NET Framework 4,您現在便可使用 Task 類作為異步操作的另一種有用的表示形式。 當表示為任務時,異步操作通常更易於處理和組合。 有關使用任務進行異步編程的更多示例包含在 ParallelExtensionsExtras 示例中,可從 code.msdn.microsoft.com/ParExtSamples 下載獲得。

Igor Ostrovsky 是 Microsoft 並行計算平臺團隊的一名軟件開發工程師。Ostrovsky 在 igoro.com 上記錄了他在編程方面的探索,並為“使用 .NET 並行編程”博客(網址為 blogs.msdn.com/pfxteam)撰稿。

衷心感謝以下技術專家對本文的審閱: 並發運行時團隊

原文地址:https://msdn.microsoft.com/zh-cn/magazine/ff959203.aspx

示範代碼:https://code.msdn.microsoft.com/Samples-for-Parallel-b4b76364/file/44488/10/Samples%20for%20Parallel%20Programming%20with%20the%20.NET%20Framework.zip

使用任務Task 簡化異步編程