异步编程

通过新的 Visual Studio Async CTP 更轻松地进行异步编程

Eric Lippert

 

想像一下,如果人们都象计算机程序那样工作,世界会变成什么模样:

 

void ServeBreakfast(Customer diner)
{
  var order = ObtainOrder(diner);
  var ingredients = ObtainIngredients(order);
  var recipe = ObtainRecipe(order);
  var meal = recipe.Prepare(ingredients);
  diner.Give(meal);
}

当然,每个子例程都可以进一步细分;准备食物可能包括加热平底锅、做煎蛋卷和烤面包。 如果人类象典型的计算机程序那样执行这些类型的任务,那么,我们需要在检查清单中以分层任务序列的形式仔细记下每一项任务,并努力确保完成每件工作,然后再着手下一件工作。

基于子例程的方法似乎是合理的 – 您不能在收到订单之前炒鸡蛋 – 但实际上,这种方法不但浪费时间,而且会导致应用程序的响应速度变慢。 说它浪费时间,是因为您希望在炒鸡蛋的同时烤面包,而不是在鸡蛋炒好并凉下来后再烤面包。 说它会导致响应速度变慢,是因为如果另一名客户在您烹制当前订单的时候到达,您希望接受他的订单,而不是让他在门口等待,直到当前客户吃完她的早餐。 盲目遵循检查清单的服务生根本无法及时响应意外事件。

解决方案一:创建更多线程,招聘更多员工

烹制某人的早餐是一个奇怪的示例,但现实并非如此。 每次您将控制权转交给 UI 线程上某个运行时间较长的子例程时,UI 都会变得完全缺乏响应,直到该子例程完成。 怎么会出现这种情况呢? 应用程序通过在 UI 线程上运行代码来响应 UI 事件,而该线程正忙于执行其他操作。 只有在完成清单上的所有作业后,它才能腾出手来处理沮丧的用户积压的命令。 这个问题的常用解决方案,是使用并发 “同时”执行两件或更多工作。(如果两个线程位于两个独立的处理器上,它们可能真的会同时运行。 在线程数量超出用于处理它们的处理器数量的情况下,操作系统将定期为每个线程调度一个时间段来控制处理器,以此来模拟同时并发。)

一个可能的并发解决方案是创建一个线程池并为每个新客户端分配特定的线程来处理其请求。 在我们的比喻中,您可能需要招聘一组服务生。 新的用餐者到达时,将为其分配一名空闲的服务生。 然后,每名服务生独立完成自己的工作:接受订单、寻找原料、烹制食物,然后上菜。

这种方法的缺陷在于,UI 事件通常都发生在同一线程上,并希望在该线程上得到“全套服务”。 大多数 UI 组件会在 UI 线程上创建请求,并希望仅在该线程上进行通信。 专门为每个 UI 相关任务创建一个新线程并不能解决问题。

要解决这个问题,您可能需要创建一个前台线程,用于监听除“接受订单”外无所事事的 UI 事件,并将它们分配给一个或多个后台工作线程。 在这个比喻中,只有一名服务生(负责与客户交互)和一间厨房(里面满是执行请求的具体工作的厨师)。 UI 线程和工作线程则负责协调他们的通信。 厨师从不与进餐者直接对话,但无论如何,进餐者能够以某种方式获得食物。

这种方法确实解决了“及时响应 UI 事件”问题,但它并不能解决缺乏效率的问题;在工作线程上运行的代码仍然在同步等待鸡蛋炒熟,然后才能将面包放进烤箱。 增加更多 并发即可解决这个问题:您可以为每份订单配备两名厨师,一名厨师负责炒鸡蛋,一名厨师负责烤面包。 但这样做的费用可能相当高昂。 想像一下,您会需要多少厨师,如果需要协调他们的工作会出现什么情况?

这种并发会造成许多众所周知的障碍。 首先,线程是典型的“重量级选手”;默认情况下,一个线程会占用其堆栈上的一百万字节的虚拟内存,以及许多其他系统资源。 其次,UI 对象通常“依附于”UI 线程并且无法从工作线程调用;工作线程和 UI 线程必须进行某种复杂的协调,以便 UI 线程能够从 UI 元素向工作线程发送必要的信息,工作线程能够将更新发送回 UI 线程,而不是直接发送回 UI 元素。 这种协调很难进行编码,而且容易造成争用条件、死锁和其他线程问题。 第三,我们在单线程世界中依赖的许多美好事物(如以可预测且一致的序列读写内存)都不再可靠。 这会导致非常糟糕且难以重现的 Bug。

这样看来,使用基于线程的并发以“重拳出击”方式构建保持响应能力并高效运行的简单程序,似乎并不是正确的解决方案。 人们总会想方设法解决复杂的问题,同时保持对事件的响应能力。 在现实世界中,您不必为每张餐桌分配一名侍者或为每份订单分配两名厨师,以响应所有同时挂起的大量客户请求。 通过线程来解决问题需要招聘大量厨师。 肯定可以找到一个不需要太多并发的更好的解决方案。

解决方案二:使用 DoEvents 导致注意力缺失障碍

针对运行时间较长的操作过程中出现的 UI 缺乏响应问题的常用非并发“解决方案”,是对程序使用 Application.DoEvents,直到问题得到解决。 虽然这确实是一个可行的解决方案,但却不是非常合理的解决方案:

void ServeBreakfast(Customer diner)
{
  var order = ObtainOrder(diner);
  Application.DoEvents();
  var ingredients = ObtainIngredients(order);
  Application.DoEvents();
  var recipe = ObtainRecipe(order);
  Application.DoEvents();
  var meal = recipe.Prepare(ingredients);
  Application.DoEvents();
  diner.Give(meal);
}

基本上,使用 DoEvents 意味着“在我忙于做最后一件事情时看是否有任何令人感兴趣的事情发生。 如果发生了需要我做出响应的事情,请记住我刚才所做的事情,处理新情况,然后回到我离开的位置。”这使您的程序就像是患有注意力缺失障碍:任何新出现的事物都会吸引它的注意力。 听起来这似乎是一个能够提高响应性的解决方案(并且有些时候确实可行),但这种方法仍然存在许多问题。

首先,只有在延迟是由必须多次执行的循环造成的,但每个循环的执行时间都很短暂时,DoEvents 才能发挥最大效用。 您可以通过每隔一小段时间在循环中检查挂起的事件来保持响应性,即使整个循环需要很长时间运行。 但是,这种模式通常并不是导致响应性问题的原因。 多数情况下,响应性问题是由于运行时间较长的固有操作占用太多时间(如尝试通过高延迟网络同步访问某个文件)造成的。 在我们的示例中,运行时间较长的任务也许是准备食物,这时 DoEvents 并不能提供帮助。 或者可能有 DoEvents 的“用武之地”,但这个“地方”处在没有源代码的方法中。

其次,调用 DoEvents 会导致程序首先为所有 最新事件提供全套服务,然后 再完成与早先的事件关联的工作。 想像一下,如果要等到最后一位顾客到齐之后,所有顾客才能获得食物,会出现什么情况? 如果不断有顾客到达,则第一位顾客可能 永远得不到食物,导致“挨饿”。 实际上,顾客可能都得不到食物。 由于需要为较新的事件提供服务,针对早先事件的工作会不断被打断,因此,与早先事件关联的工作的完成时间可能会推迟到遥远的未来。

第三,DoEvents 会造成非常切实的意外重入危险。 也就是说,在为某位顾客提供食物的同时,您查看是否有任何令人感兴趣的最新 UI 事件,并意外地开始再次为同一位进餐者提供食物,即使他已经获得食物。 多数开发者并未设计专门的代码来检测这种重入,最终可能会导致一些非常奇怪的程序状态:从未打算递归的算法最终会意外地通过 DoEvents 进行自身调用。

简言之,DoEvents 只适用于在最普通的情况下解决响应性问题;在管理复杂程序的 UI 响应性方面,它不是一个有效的解决方案。

解决方案三:使用回调彻底修改检查清单

DoEvents 技巧的非并发本质很有吸引力,但很明显,它不是很适合复杂程序的解决方案。 一个更好的办法,是将检查清单上的项目分解成一系列简短的任务,其中每个任务都能够迅速地完成,这样应用程序就可以表现出对事件的响应性。

这种办法并不是新鲜事物;将复杂的问题分解成细小的部分,是我们创建子例程的主要原因。 有趣的是,您不需要严格按照检查清单来确定哪些任务已完成,接下来需要做什么,完成所有任务后,您只需将控制权转交给调用方,每项新任务即可获得必须随后完成的工作的清单。 在特定任务完成之后紧接着的工作称为该任务的“延续”。

某项任务完成后,它可以查看其延续并立即完成该延续。 此外,它也可以调度该延续,令其在稍后运行。 如果该延续需要前一项任务计算所得的信息,则前一项任务可以将这些信息作为参数传递给调用该延续的调用方。

使用这种方法,整件工作基本上被分解为全部能够迅速执行的小段。 系统将表现出响应能力,因为它能够检测到挂起的事件,并在执行任何两个工作小段之间 处理这些事件。 但是,由于与这些新事件关联的任何活动也可以分解成较小的部分并排入将在稍后执行的队列,因此,我们不会遇到新任务导致旧任务无法完成的“挨饿”问题。 系统不会立即处理运行时间较长的新任务,而是将它们排入队列,再进行最后处理。

这个主意很棒,但如何实施这个解决方案,我们却没有任何头绪。 最主要的困难,在于如何确定并告知每个小工作单元其延续是什么(即接下来需要做什么工作)。

通常,在传统的异步代码中,通过注册“回调”函数可以做到这一点。 假设我们拥有“Prepare”的异步版本,该程序使用一个说明接下来需要做什么(即提供食物)的回调函数,如下所示:

void ServeBreakfast(Diner diner)
{
  var order = ObtainOrder(diner);
  var ingredients = ObtainIngredients(order);
  var recipe = ObtainRecipe(order);
  recipe.PrepareAsync(ingredients, meal =>
    {
      diner.Give(meal);
    });
}

现在,ServeBreakfast 在 PrepareAsync 返回后立即 返回;然后释放调用 ServeBreakfast 的任何代码以处理所发生的其他事件。 PrepareAsync 本身并不做任何“实际的”工作,而只是迅速采取任何必要的措施,以确保食物过一段时间后能够做好。 此外,PrepareAsync 还可用于确保在完成准备食物的任务之后的某个时间调用回调方法,并将准备好的食物作为该方法的参数。 因此,进餐者将最终得到食物,虽然她可能需要稍候片刻(如果在准备好食物与提供食物之间发生了需要关注的事件)。

请注意,这个解决方案不需要另外的线程。 可能 PrepareAsync 会在单独的线程上完成准备食物的工作,或者会在 UI 线程上将与准备食物关联的一系列简短任务排成队列,以待稍后执行。 这并不重要;我们所知道的是,PrepareAsync 以某种方式保证了以下两点:在准备食物的过程中,不会出现阻塞 UI 线程的高延迟操作;而且,在准备好请求的食物后,将以某种方式调用回调函数。

但是,假如用于接受订单、获取原料、获取食谱或准备食物的任何 方法延缓了 UI 线程的运行速度,那该怎么办呢? 如果我们拥有上述每个方法的异步版本,我们就可以解决这个更加严重的问题。 生成的程序会是什么样子呢? 请记住,必须为每个方法提供一个回调函数,说明各个方法在完成工作单元后该做什么:

void ServeBreakfast(Diner diner)
{
  ObtainOrderAsync(diner, order =>
  {
    ObtainIngredientsAsync(order, ingredients =>
    {
      ObtainRecipeAsync(order, recipe =>
      {
        recipe.PrepareAsync(ingredients, meal =>
        {
          diner.Give(meal);
        })})})});
}

这个程序似乎相当混乱,但相比于使用基于回调的异步重新编写的真实程序的糟糕情况,这种混乱情况也就不算什么了。 考虑一下,您会如何处理异步循环的创建问题,以及如何处理异常、try-finally 块或其他复杂的控制流。 基本上,您最终会彻底修改您的程序;现在,代码强调的是如何将所有回调关联起来,而不是程序的逻辑工作流应该是什么模样。

解决方案四:使用基于任务的异步通过编译器解决问题

基于回调的异步确实可以保持 UI 线程的响应性,并减少因同步等待运行时间较长的工作完成而造成的时间浪费。 然而,这种解决方案造成的后果似乎比问题本身造成的后果更为严重。 要提高响应性和性能,您必须编写强调异步工作机制 的代码,而代码的意义和用途却变得晦涩难懂。

不过,即将推出的 C# 和 Visual Basic 版本可以帮助您编写强调代码意义和用途的代码,同时为编译器在后台构建您所需的机制提供足够的提示。 这个解决方案分为两个部分:一部分在类型系统 中,另一部分在语言 中。

CLR 4 版本定义了类型 Task<T>(任务并行库 (TPL) 的主力类型),用于表示“将来会生成类型 T 的结果的一些工作”概念。“将在将来完成但不返回任何结果的工作”概念用非泛型 Task 类型表示。

准确地说,将来如何生成类型 T 的结果属于特定任务的实施细节;这项工作可能会完全分配给另一台机器、此机器上的另一个进程、另一个线程,该工作也可能只是读取可以从当前线程轻松访问的之前已缓存的结果。 TPL 任务通常分配给当前进程中的线程池中的工作线程,但该实施细节对于 Task<T> 类型并不重要;事实上,Task<T> 可以表示任何生成 T 的高延迟操作。

该解决方案的语言部分为新的 await 关键字。 常规方法调用是指“记住您所做的,运行此方法直到其全部完成,然后从您离开的位置继续,现在知道了该方法的结果。”相比之下,await 表达式则是指“对此表达式赋值以获得一个表示会在将来生成结果的工作的对象。 将当前方法的剩余部分注册为与该任务的延续关联的回调。 生成任务并注册回调后,立即 将控制权返还给我的调用方。”

以新样式重新编写后,我们的小示例更具可读性:

async void ServeBreakfast(Diner diner)
{
  var order = await ObtainOrderAsync(diner);
  var ingredients = await ObtainIngredientsAsync(order);
  var recipe = await ObtainRecipeAsync(order);
  var meal = await recipe.PrepareAsync(ingredients);
  diner.Give(meal);
}

在这段代码中,每个异步版本都返回一个 Task<Order>、Task<List<Ingredient>>,等等。 每次遇到 await,当前执行的方法会将方法的剩余部分注册为当前任务完成后接下来要进行的工作,然后立即返回。 每个任务将以某种方式完成其操作(或者被调度到当前线程上作为事件运行,或者因为使用了 I/O 完成线程或工作线程),然后在执行方法剩余部分的过程中迫使延续“从离开的位置继续”。

请注意,该方法现在标有新的 async 关键字;这只是一个指示符,用于告知编译器:在此方法的范围内,关键字 await 将被视为工作流将控制权转交给其调用方、并在相关任务完成后再次继续的位置。 还要注意的是,我在本文中使用的示例使用了 C# 代码;使用类似的语法,Visual Basic 将提供类似的功能。 在 C# 和 Visual Basic 中,这些功能的设计在很大程度上受 F# 异步工作流(F# 提供该功能已有一段时间)的影响。

更多详细内容

上述简介仅涉及 C# 和 Visual Basic 中新增异步功能的一些粗浅知识。 有关其后台工作机制以及如何推断异步代码的性能特征的详细说明,请参阅本期杂志中我的同事 Mads Torgersen 和 Stephen Toub 编写的随附文章。

要获得本功能的预览版本、示例、白皮书,以及相关问题、讨论和建设性反馈的社区论坛,请访问 msdn.com/async。 这些语言功能及支持它们的库仍在开发过程中;设计团队欢迎您提供任何反馈。

Eric Lippert 是 Microsoft 的 C# 编译器团队中的首席开发人员。

*衷心感谢以下技术专家对本文的审阅:Mads Torgersen、*Stephen Toub Lucian Wischik