本文章是由機器翻譯。

非同步工作

簡化工作的非同步程式設計

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");
}

在這段程式碼中,有 ’s 重大的問題:下載的網站可以採取幾秒或更長的時間。 接下來,調用 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:返回結果

有 ’s 非同步程式設計的使用執行緒的另一個困難:從 Helper 執行緒所執行的作業傳回值取得有點混亂。

在最初的示例中,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 可以換行的非同步作業,並將它公開為工作,工作 API 提供方便的機制,用來將 IAsyncResult 模式轉換成工作: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)撰稿。

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