2015 年 7 月

第 30 卷,第 7 期

异步编程 - Brownfield 异步开发

作者 Stephen Cleary | 2015 年 7 月

当 Visual Studio Async CTP 出版时,我将处于有利位置。我是两个相对小型的未开发应用程序(将从 async 和 await 中受益)的唯一开发人员。在此期间,包括我自己在内的 MSDN 论坛的各个成员都正在发现、讨论和实施若干个异步的最佳做法。其中最重要的实践编写到我的 2013 年 3 月 MSDN 杂志文章《异步编程中的最佳做法》(msdn.microsoft.com/magazine/jj991977) 中。

将 async 和 await 应用于现有基本代码是一种不同的挑战。Brownfield 代码可能比较混乱,它可能会将该方案进一步复杂化。在将异步应用于我在此处阐述的 Brownfield 代码时,我发现了几个技巧非常有用。在某些情况下,引入异步实际上可能影响设计。如果有必要重构以将现有代码分离到各层,我建议在引入异步之前执行该操作。对本文来说,我将假设您正在使用类似于图 1 中所示的应用程序体系结构。

图 1 含有服务层和业务逻辑层的简单代码结构

public interface IDataService
{
  Task<string> GetAsync(int id);
}
public sealed class WebDataService : IDataService
{
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}
public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  public async Task<string> GetFrobAsync()
  {
    // Try to get the new frob id.
    var result = await _dataService.GetAsync(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return await _dataService.GetAsync(13);
  }
}

何时使用异步

最好的常规方法是首先思考该应用程序实际上在做什么。异步在 I/O 绑定操作方面表现优异,但是有时会有更好的选择用于其他类型的处理。从某种程度上而言,有两种常见方案:CPU 绑定的代码和数据流,其中异步并非最佳选择。

如果您有 CPU 绑定的代码,请考虑使用 Parallel 类或 Parallel LINQ。异步更适合基于事件的系统,其中虽然有一个操作正在处理中,但实际上并没有执行代码。异步方法中的 CPU 绑定的代码仍将以同步方式运行。

不过,通过等待 Task.Run 结果,您可以将 CPU 绑定的代码视作异步代码。这是将 CPU 绑定的工作推离 UI 线程的好方法。以下代码是将 Task.Run 用作异步和并行代码之间的桥梁的示例:

await Task.Run(() => Parallel.ForEach(...));

异步并非最佳选择的其他方案是指您的应用程序处理数据流的情况。异步操作含有明确的开始和结束。例如,需要资源时开始下载资源。资源下载完成时结束。如果您的传入数据更多的是流或订阅,那么异步可能不是最佳方法。例如,考虑使用附加到可能随时提供志愿数据的序列端口的设备。

可以在事件流中使用 async/await。缓冲数据需要一些系统资源,因为这些数据在应用数据读取数据时才会到位。如果您的来源是事件订阅,则考虑使用反应扩展或 TPL 数据流。与普通的异步相比,您可能发现它更自然合适。Rx 和 Dataflow 与异步代码的交互性非常好。

当然,异步是适用于相当多的代码(而非代码的全部)的最佳方法。对于本文的其余部分,我将假设您已考虑过任务并行库和 Rx/Dataflow,并且得出 async/await 是最适合方法的结论。

将同步代码转换为异步代码

有一个常规过程可将现有的同步代码转换成异步代码。它的特点是非常直观。如果这样做了几次之后,甚变得相当厌烦。在撰写本文时,在自动执行同步到异步转换方面没有任何支持。不过,我预计在未来几年将引入这种代码转换。

当您从较低级层开始并向用户级迈进时,这一过程效果最佳。换而言之,首先在访问数据库或 Web API 的数据层方法中引入异步。然后,在您的服务方法中引入异步,接着在业务逻辑中引入异步,最后在用户层中引入异步。如果您的代码不具有定义完善的层,您仍可以转换到 async/await。只是有点困难。

第一步是识别要转换的低级自然异步操作。任何基于 I/O 的内容都是异步的首要候选。常见示例是数据库查询和命令、Web API 调用和文件系统访问。很多时候,此类低级操作已含有一个现有的异步 API。

如果基础库含有一个异步就绪的 API,则您所需要做的就是在同步方法名称上添加一个 Async 后缀(或 TaskAsync 后缀)。例如,对于 First 的实体框架调用可能被替换为对 FirstAsync 的调用。在某些情况中,您可能要使用一个备用类型。例如,HttpClient 是适用于 WebClient 和 HttpWebRequest 的更适合异步的替换内容。在某些情况下,您可能需要升级您的库的版本。例如,实体框架需要异步 API 版本 6。

请考虑使用图 1 中的代码。这是一个简单的示例,其中含有服务层和一些业务逻辑。在此示例中,只有一个低级操作,即从 WebDataService.Get 中的 Web API 中检索 Frob 标识符字符串。这是开始异步转换的逻辑位置。在这种情况下,开发人员可以选择将 WebClient.DownloadString 替换为 WebClient.DownloadStringTaskAsync,或者将 WebClient 替换为更适合异步的 HttpClient。

第二步是将同步 API 调用更改为异步 API 调用,然后等待返回的任务。当代码触发异步方法时,通常等待返回的任务比较合适。此时,编译器会报错。以下代码将造成编译器出错,并显示错误消息:“‘await’操作符只能在异步方法中使用。考虑对此方法标记‘async’修饰符,并将其返回类型更改为‘Task<string>’”:

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

该编译器会指导您完成后续步骤。将该方法标记为异步并更改返回类型。如果异步方法的返回类型无效,则异步方法的返回类型应该是 Task。否则,对于 T 的任何同步方法返回类型,该异步方法返回类型应该是 Task<T>。当您将该返回类型更改为 Task/Task<T> 时,您还应该修改该方法名称以便以 Async 结尾,同时遵循“基于任务的异步模式”指南。以下代码显示作为异步方法的生成方法:

public sealed class WebDataService : IDataService
{
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

在继续往下之前,查看此方法的剩余部分,了解是否存在其他任何阻止或您可以转换成异步的同步 API 调用。异步方法不应该阻止,因此此方法在异步 API 可用的情况下应该调用它们。在此简单示例中,没有其他任何阻止调用。在实际代码中,注意重试逻辑和乐观的冲突解决方案。

此处应该特别注意实体框架。一个细微的难题在于相关实体的延迟加载。这总是以同步方式来完成。如果可能的话,使用其他明确的同步查询来代替延迟加载。

现在,此方法已最终确定了。接下来,转移到引用此方法的所有方法上,并再次按照此过程进行操作。在这种情况下,WebDataService.Get 属于接口实现的一部分,因此您必须更改该接口以启用异步实施:

public interface IDataService
{
  Task<string> GetAsync(int id);
}

接下来,转移到调用方法上,并遵循相同的步骤。最后的结果应该类似于图 2 中所示的代码。遗憾的是,直到所有调用方法都转换成异步及其所有调用方法都转化成异步之类的之后,才编译代码。异步的这种级联性质是 Brownfield 开发的繁琐方面。

图 2 将所有调用方法更改为异步

public interface IDataService
{
  string Get(int id);
}
public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    using (var client = new WebClient())
      return client.DownloadString("http://www.example.com/api/values/" + id);
  }
}
public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  public string GetFrob()
  {
    // Try to get the new frob id.
    var result = _dataService.Get(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return _dataService.Get(13);
  }
}

最终,您的基本代码中的异步操作级别都会提高,直到它成为不被您代码中的其他任何方法调用的方法为止。您的顶级方法直接由正在使用的任何框架进行调用。ASP.NET MVC 等一些框架直接允许异步代码。例如,ASP.NET MVC 控制器操作可以返回 Task 或 Task<T>。Windows Presentation Foundation (WPF) 等其他框架允许使用异步事件处理程序。例如,一个按钮单击事件可能是异步无效。

无路可走

随着异步代码级别在整个应用程序中的提高,您可能到达一个似乎无法继续推进的节点。这种情况的最常见示例是以对象为中心的构造,其与异步代码的功能特性不相符。构造函数、事件和属性都有各自的挑战。

通常,重新思考设计是解决这些困难的最佳方法。一个常见示例就是构造函数。在同步代码中,构造函数方法可能阻止 I/O。在异步领域中,其中一个解决方案就是,使用异步工厂方法来代替构造函数。另一个实例是属性。如果某个属性以同步方式阻止 I/O,则该属性可能是一个方法。此类设计问题随着时间的推移会蔓延到您的基本代码,因此异步转换练习在揭露这些设计问题方面非常有用。

转换技巧

前几次执行异步代码转换可能有点害怕,但是经过一些练习以后就会得心应手。随着您在将同步代码转换为同步代码方面更加轻松之后,以下是几个您可以在转换处理期间使用的技巧。

当您转换代码时,注意并发机会。与同步并发代码相比,异步并发代码通常更短、更简单。例如,考虑使用必须从 REST API 中下载两种不同资源的方法。可以肯定的是,该方法的同步版本几乎会先下载一个,然后再下载另一个。但是,异步版本可轻松地同时开始下载,然后使用 Task.WhenAll 以异步方式等待两个同时完成。

另一个注意事项是取消。通常,同步应用程序用户习惯于等待。如果新版本中的 UI 具有响应性,它们可能具有取消操作的功能。一般情况下,异步代码都支持取消,除非有某些其他原因。大多数情况下,只要提取 CancellationToken 参数并将其传递到它调用的异步方法中,您自己的异步代码便可以支持取消。

您可以使用 Thread 或 BackgroundWorker 来转换任何代码,使其转为使用 Task.Run。与 Thread 或 BackgroundWorker 相比,Task.Run 更易于撰写。例如,通过新型 await 和 Task.Run(而非原始线程构造)更易于表达“启动两个后台计算,然后在两个完成时执行另一操作”。

垂直分区

如果只是您一个人开发了您的应用程序,并且没有任何问题或请求会干扰您的异步转换工作,则目前为止,描述的方法效果很好。非常不切实际,是吗?

如果您没有时间一次性将整个基本代码转换成异步代码,则可以通过略微修改(称为垂直分区)逐渐完成转换。通过这一技巧,您可以对特定的代码部分进行异步转换。如果您只是“尝试”异步代码,垂直分区非常适合您。

若要创建垂直分区,请确定您要进行异步转换的用户级代码。也许,它是保存到数据库(其中,您要保持 UI 响应)的 UI 按钮的事件处理程序,或是执行相同操作的常用 ASP.NET 请求(其中,您要减少特定请求所需的资源)。查看该代码,同时列出该方法的调用树。然后,您可以在低级别方法中开始,向上完成整个树的转换。

毫无疑问,其他代码也会使用相同的低级别方法。由于您尚未准备将所有代码进行异步转换,因此该解决方案的目的是创建该方法的副本。然后将该副本进行异步转换。这样一来,仍可以在每步中构建该解决方案。当您努力执行到用户级代码时,您已经在应用程序中创建了异步代码的垂直分区。建立在我们的示例代码基础上的垂直分区将如图 3 中所示。

图 3 用于将部分代码转换成异步代码的垂直分区

public interface IDataService
{
  string Get(int id);
  Task<string> GetAsync(int id);
}
public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    using (var client = new WebClient())
      return client.DownloadString("http://www.example.com/api/values/" + id);
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}
public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  public string GetFrob()
  {
    // Try to get the new frob id.
    var result = _dataService.Get(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return _dataService.Get(13);
  }
  public async Task<string> GetFrobAsync()
  {
    // Try to get the new frob id.
    var result = await _dataService.GetAsync(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return await _dataService.GetAsync(13);
  }
}

您可能已经注意到,此解决方案中有部分代码重复。同步和异步方法的所有逻辑都会重复,这并不好。在理想情况下,此垂直分区的代码重复只是临时的。等到应用程序完全转换之后,重复代码将从您的源控制中消失。此时,您可以删除所有旧的同步 API。

但是,并非在所有情况下您都能这样做。如果您开发一个库(甚至只在内部使用的一个库),反向兼容是要关注的主要问题。您可能会发现自己需要将同步 API 保留一段时间。

对于这样情况,有三种可能的响应方式。第一,您可以推动采用异步 API。如果您的库含有要处理的异步工作,则它应该公开异步 API。第二,您可以接受代码重复作为反向兼容的不可避免的麻烦。仅当您的团队含有非常高的自律,或反向兼容限制只是临时的情况下,才可以接受该解决方案。

第三个解决方案是,应用此处概述的技巧之一。实际上,虽然我不能推荐所有这些技巧,但是这些技巧在紧要关头非常有用。由于它们的操作本质上是异步操作,因此每个技巧都是围绕着向本质上属于异步的操作提供同步 API,这就是众所周知的反模式(在 bit.ly/1JDLmWD 中发布的服务器和工具博客对此有更加详细的说明)。

阻止技巧

最简单的方法就是直接阻止异步版本。我建议使用 GetAwaiter().GetResult(而非 Wait 或 Result)进行阻止。Wait 和 Result 将把 AggregateException 内的所有异常情况包装在一起,这使错误处理更加复杂化。该示例服务层代码看上去与图 4 中显示的代码一样(如果它使用阻止技巧的话)。

图 4 使用阻止技巧的服务层代码

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    return GetAsync(id).GetAwaiter().GetResult();
  }
  public async Task<string> GetAsync(int id)
  {
    // This code will not work as expected.
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

遗憾的是,正如备注所示,该代码实际上不起作用。它会导致常见的死锁,关于这一点我在之前提及的文章《异步编程中的最佳做法》中进行了描述。

这就是技巧变得比较棘手的情况。虽然可以通过正常的单元测试,但,是如果从 UI 或 ASP.NET 上下文中进行调用的话,相同的代码将会出现死锁。如果您使用阻止技巧,则应该编写检查此类行为的单元测试。图 5 中的代码使用我的 AsyncEx library 中的 Async­Context 类型,这会创建一个类似于 UI 或 ASP.NET 上下文的上下文。

图 5 使用 AsyncContext 类型

[TestClass]
public class WebDataServiceUnitTests
{
  [TestMethod]
  public async Task GetAsync_RetrievesObject13()
  {
    var service = new WebDataService();
    var result = await service.GetAsync(13);
    Assert.AreEqual("frob", result);
  }
  [TestMethod]
  public void Get_RetrievesObject13()
  {
    AsyncContext.Run(() =>
    {
      var service = new WebDataService();
      var result = service.Get(13);
      Assert.AreEqual("frob", result);
    });
  }
}

虽然可以通过异步单元测试,但是同步单元测试将永远无法完成。这将是典型的死锁问题。异步代码捕获当前的上下文并试图恢复它,而同步包装会阻止该上下文中的线程,同时阻止异步操作完成。

在这种情况下,我们的异步代码缺少 ConfigureAwait­(false)。不过,使用 WebClient 会导致相同的问题。WebClient 使用更旧的基于事件的异步模式 (EAP),它总会捕获该上下文。因此,如果您的代码使用 ConfigureAwait(false),则 WebClient 代码中将出现相同的死锁。在这种情况下,您可以使用更适合异步的 HttpClient 来替换 WebClient,以在桌面上发挥作用,如图 6 中所示。

图 6 使用带有 ConfigureAwait(false) 的 HttpClient 阻止死锁

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    return GetAsync(id).GetAwaiter().GetResult();
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new HttpClient())
      return await client.GetStringAsync(
      "http://www.example.com/api/values/" + id).ConfigureAwait(false);
  }
}

死锁技巧要求您的团队拥有严格的纪律。他们需要确保 ConfigureAwait(false) 用于各个位置。他们还必须要求所有相关联库遵循同样的纪律。在某些情况下,这是不可能的。在撰写本文时,HttpClient 甚至已捕获了某些平台上的上下文。

阻止技巧的另一个缺点是,它会要求您使用 ConfigureAwait­(false)。如果异步代码实际上确实需要在捕获的上下文上恢复,则这是完全不合适的。如果您确实采用了阻止技巧,我们强烈建议您使用 AsyncContext 或另一个相似的单线程上下文进行单元测试,以捕捉任何潜伏的死锁。

线程池技巧

一个类似于阻止技巧的方法就是将异步工作卸载到线程池,然后阻止产生的任务。使用此技巧的代码看上去如图 7 中的代码所示。

图 7 线程池技巧的代码

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    return Task.Run(() => GetAsync(id)).GetAwaiter().GetResult();
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

对 Task.Run 的调用会在线程池线程上执行异步方法。此处,它将在没有上下文的情况下运行,因此可以避免死锁。此方法的一个问题是,异步方法无法依赖于特定上下文中的执行操作。因此,它无法使用 UI 元素或 ASP.NET HttpContext.Current。

另外一个更加细微的“难题”在于,异步方法可能恢复任何线程池线程。这不是大多数代码所遇到的问题。如果该方法使用每线程状态或它以不明显的方式依赖 UI 上下文提供的同步,则它是一个问题。

您可以为后台线程创建一个上下文。AsyncEx 库中的 AsyncContext 类型将安装带有“主循环”的单线程上下文。 这会强制异步代码在相同的线程上恢复。这可以避免线程池技巧的更加细微的“难题”。线程池线程带有主循环的示例代码看上去如图 8 中的代码所示。

图 8 使用线程池技巧的主循环

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    var task = Task.Run(() => AsyncContext.Run(() => GetAsync(id)));
    return task.GetAwaiter().GetResult();
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

当然,此方法也有缺点。直到异步方法完成之后,线程池线程才不会在 AsyncContext 中受到阻止。此受阻止的线程以及调用同步 API 的主线程都在其相应位置。因此,在调用期间,有两个线程被阻止了。尤其是在 ASP.NET 上,此方法将严重降低该应用程序的扩展能力。

标志参数技巧

我也尚未使用过这一技巧。在 Stephen Toub 对本文进行技术审阅时向我描述过该方法。这是一个非常棒的方法,也是我在所有这些技巧中最喜欢的一个技巧。

标志参数技巧采用原始方法,将其专有化,并添加一个标志以指明,是以同步方式还是以异步方式运行该方法。然后,它会公开两个公共 API:一个同步 API 以及一个异步 API,如图 9 中所示。

图 9 标志参数技巧公开两个 API

public interface IDataService
{
  string Get(int id);
  Task<string> GetAsync(int id);
}
public sealed class WebDataService : IDataService
{
  private async Task<string> GetCoreAsync(int id, bool sync)
  {
    using (var client = new WebClient())
    {
      return sync
        ? client.DownloadString("http://www.example.com/api/values/" + id)
        : await client.DownloadStringTaskAsync(
        "http://www.example.com/api/values/" + id);
    }
  }
  public string Get(int id)
  {
    return GetCoreAsync(id, sync: true).GetAwaiter().GetResult();
  }
  public Task<string> GetAsync(int id)
  {
    return GetCoreAsync(id, sync: false);
  }
}

此示例中的 GetCoreAsync 方法含有一个重要属性:如果它的同步参数为 true,则它始终返回一个已完成的任务。当该方法的标志参数请求同步行为时,将阻止该方法。否则,它的行为就像正常的异步方法。

同步 Get 包装程序传递标志参数的 true 值,然后检索操作的结果。注意,由于该任务已经完成,因此没有死锁。该业务逻辑遵循类似的模式,如图 10 中所示。

图 10 将标志参数技巧应用于业务逻辑

public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  private async Task<string> GetFrobCoreAsync(bool sync)
  {
    // Try to get the new frob id.
    var result = sync
      ? _dataService.Get(17)
      : await _dataService.GetAsync(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return sync
      ? _dataService.Get(13)
      : await _dataService.GetAsync(13);
  }
  public string GetFrob()
  {
    return GetFrobCoreAsync(sync: true).GetAwaiter().GetResult();
  }
  public Task<string> GetFrobAsync()
  {
    return GetFrobCoreAsync(sync: false);
  }
}

您可以选择公开服务层中的 CoreAsync 方法。这将简化业务逻辑。不过,标志参数方法更多的是实施详细信息。您无需衡量更干净代码与公开实施详细信息之间的优缺点,如图 11 中所示。此技巧的优点在于,方法的逻辑基本保持相同。根据标志参数的值,它只是会调用不同的 API 而已。如果同步和异步 API 之间存在一对一的对应关系(通常是这种情况),该方法效果非常好。

图 11 虽然公开实施详细信息,但代码很干净

public interface IDataService
{
  string Get(int id);
  Task<string> GetAsync(int id);
  Task<string> GetCoreAsync(int id, bool sync);
}
public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  private async Task<string> GetFrobCoreAsync(bool sync)
  {
    // Try to get the new frob id.
    var result = await _dataService.GetCoreAsync(17, sync);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return await _dataService.GetCoreAsync(13, sync);
  }
  public string GetFrob()
  {
    return GetFrobCoreAsync(sync: true).GetAwaiter().GetResult();
  }
  public Task<string> GetFrobAsync()
  {
    return GetFrobCoreAsync(sync: false);
  }
}

如果您要向异步代码路径中添加并发,或没有理想的对应异步 API,则该方法可能无法起作用。例如,我倾向于在 WebDataService 中使用 HttpClient(而非 WebClient),但是我必须衡量添加的复杂性在 GetCoreAsync 方法中造成的影响。

此技巧的主要缺点在于,标志参数是众所周知的反模式。布尔型标志参数是非常好的指示器,指示一个方法中实际上含有两个不同的方法。但是,在单类的实施详细信息中将反模式减少到了最小(除非您选择公开您的 CoreAsync 方法)。尽管如此,它仍然是我最喜欢的技巧。

嵌套消息循环技巧

这是最后一个技巧,也是我最不喜欢的一个技巧。其理念是在 UI 线程内设置一个嵌套消息循环,并在该循环内执行异步代码。此方法不适合在 ASP.NET 上使用。此外,它还可能要求对不同的 UI 平台使用不同的代码。例如,WPF 应用程序可以使用嵌套的调度程序帧,而 Windows 窗体应用程序可以在循环内使用 DoEvents。如果这些异步方法不依赖特定的 UI 平台,您还可以使用 AsyncContext 来执行嵌套循环,如图 12 中所示。

图 12 使用 AsyncContext 执行嵌套消息

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    return AsyncContext.Run(() => GetAsync(id));
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

切勿被此示例代码的简洁性所欺骗。在所有技巧中,该技巧是最危险的,因为您必须得考虑重入。如果该代码使用嵌套调度程序帧或 DoEvents,更会出现这种情况。在这种情况下,整个 UI 层必须立即处理异常的重入。免于重入的应用程序需要长时间的仔细思考和规划。

总结

在理想情况下,您可以执行从同步到异步的相对简单的代码转换,并且一切都非常美好。在实际情况中,同步代码和异步代码通常得共存。如果您只是想要尝试异步,首先创建一个垂直分区(含有代码重复),直到您习惯使用异步为止。如果因为反向兼容必须保留同步代码,则您必须得忍受代码重复或应用上述技巧之一。

有朝一日,异步操作将只在异步 API 中呈现。到那时,一切都变得切合实际了。我希望这些技巧可以帮助您以最适合您的方式将异步引入到您的现有应用程序中。


Stephen Cleary* 生活在密歇根州北部,他是一位丈夫、父亲和程序员。他已从事了 16 年的多线程和异步编程工作,自第一个 CTP 以来便在使用 Microsoft .NET Framework 中的异步支持。访问 stephencleary.com,查看他的项目和博客文章。*

衷心感谢以下 Microsoft 技术专家对本文的审阅:James McCaffery 和 Stephen Toub