异步任务

使用任务简化异步编程

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)撰稿。

衷心感谢以下技术专家对本文的审阅: 并发运行时团队