C--并发编程秘籍第二版-全-

C# 并发编程秘籍第二版(全)

原文:zh.annas-archive.org/md5/94f6d64de2f76d3e98d9e7e8e4ee1394

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我认为这本书封面上的动物,一只棕榈猫鼬,与本书的主题相关。在看到封面之前,我对这种动物一无所知,因此我查找了相关信息。棕榈猫鼬被认为是害虫,因为它们会在阁楼上随处排泄,并在最不合时宜的时候用嘈杂的声音互相争斗。它们的肛门臭腺会分泌出令人作呕的分泌物。它们的濒危物种评级是“无忧”,这显然是政治正确的说法,“你可以杀死尽可能多的这些动物,没有人会想念它们。”棕榈猫鼬喜欢吃咖啡果实,它们将咖啡豆排泄出来。鲁瓦克咖啡是世界上最昂贵的咖啡之一,由从猫鼬排泄物中提取的咖啡豆制成。根据美国特种咖啡协会的说法,“它的味道很糟糕。”

这使得棕榈猫鼬成为并发和多线程开发的完美吉祥物。对于未曾接触过的人来说,并发和多线程是令人不愿意接受的。它们会导致行为良好的代码表现出最可怕的方式。竞态条件等问题会导致响亮的崩溃(似乎总是在生产环境或演示期间)。有些人甚至宣称“线程是邪恶的”,完全避免并发。有少数开发者对并发产生了兴趣,并且毫不畏惧地使用它;但大多数开发者在过去由于并发而受过伤,这种经历让他们对此望而却步。

然而,对于现代应用程序来说,并发迅速成为一项要求。如今的用户期望完全响应的界面,服务器应用程序必须达到前所未有的规模。并发可以解决这两种趋势。

幸运的是,现代化的库使并发变得容易!并行处理和异步编程不再只是巫师的专属领域。通过提高抽象级别,这些库使响应式和可扩展的应用程序开发成为每个开发者现实可行的目标。如果你在过去遇到过困难重重的并发问题,那么我鼓励你使用现代工具再次尝试。我们可能永远不能说并发很简单,但它确实没有过去那么难了!

适合阅读本书的人

这本书是为那些想学习现代并发方法的开发者编写的。我假设你具有相当数量的.NET 经验,包括对泛型集合、可枚举对象和 LINQ 的理解。我假设你有任何多线程或异步编程的知识。如果你在这些领域有些经验,你可能仍会发现这本书有帮助,因为它介绍了更安全、更易于使用的新库。

并发对任何类型的应用程序都有用。无论您是在桌面应用程序、移动应用程序还是服务器应用程序上工作;如今并发几乎是无处不在的要求。您可以使用本书中的示例使用户界面更加响应,服务器更具可扩展性。我们已经到了并发无处不在的时代,理解这些技术及其用途是专业开发者的必备知识。

我为什么写这本书

在我职业生涯的早期阶段,我通过艰难的方式学会了多线程。几年后,我又通过艰难的方式学会了异步编程。虽然这两者都是宝贵的经验,但我希望那时我能有一些今天可用的工具和资源。特别是,现代.NET 语言中的asyncawait支持是非常宝贵的。

然而,如果您今天查看有关学习并发性的书籍和其他资源,几乎所有这些资源都从介绍最低级别的概念开始。有关线程和序列化原语的优秀覆盖,高级技术则被推迟到后面,如果有的话。我认为这有两个原因。首先,许多并发的开发者,比如我自己,确实是先学习了低级别的概念,艰难地通过老式技术。其次,许多书籍已经数年未变,涵盖的是现在已经过时的技术;随着新技术的推出,这些书籍已经更新以包括它们,但不幸的是把它们放在了最后。

我认为这是反过来的。实际上,这本书仅仅涵盖了现代并发的方法。这并不是说理解所有低级概念没有价值。当我上大学学习编程时,有一门课程让我从几个门电路构建虚拟 CPU,还有另一门课程讲解汇编语言。在我的职业生涯中,我从未设计过 CPU,只写过几十行汇编,但是我的基础理解每天都在帮助我。尽管如此,最好还是从更高级别的抽象开始;我的第一门编程课不是用汇编语言。

本书填补了一个空白:它是使用现代方法介绍并发的入门和参考。它涵盖了几种不同的并发类型,包括并行、异步和反应式编程。然而,它不涉及任何老式技术,这些在许多其他书籍和在线资源中都有充分的覆盖。

导航本书

以下是本书的结构:

  • 第一章介绍了本书涵盖的各种并发类型:并行、异步、反应式和数据流。

  • 第二章到第六章是对这些并发类型更为详尽的介绍。

  • 剩下的章节各自处理并发的特定方面,并作为常见问题解决方案的参考。

我建议阅读(或至少浏览)第一章,即使您已经熟悉某些并发类型。

警告

由于本书正在印刷中,.NET Core 3.0 仍处于 beta 阶段,因此有关异步流的某些细节可能会发生变化。

在线资源

本书像是对多种不同并发类型的广泛介绍。我尽力包含了我和其他人认为最有帮助的技术,但这本书并非穷尽一切。以下资源是我发现的更彻底探索这些技术的最佳资源:

  • 对于并行编程,我知道的最好资源是 Microsoft Press 的Parallel Programming with Microsoft .NET,其文本可以在线获取。不幸的是,它已经有些过时了。关于 futures 的部分应改用异步代码,关于 pipelines 的部分应使用 Channels 或 TPL Dataflow。

  • 对于异步编程,MSDN 是相当不错的,特别是“异步编程”概述。

  • Microsoft 也提供了TPL Dataflow 的文档。

  • System.Reactive(Rx)是一个在线上受欢迎并不断发展的库。在我看来,今天最好的 Rx 资源是Introduction to Rx,这是 Lee Campbell 的一本电子书。

本书中使用的约定

本书使用以下排版约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

等宽

用于程序清单,以及在段落中引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

等宽粗体

显示用户应该按字面输入的命令或其他文本。

等宽斜体

显示应由用户提供值或由上下文确定值替换的文本。

提示

这个元素表示一个提示或建议。

注释

这个元素表示一般注释。

警告

这个元素表示警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可从https://oreil.ly/concur-c-ckbk2下载。

本书旨在帮助您完成工作。通常情况下,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分,否则您无需联系我们请求许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发包含奥莱利书籍示例的 CD-ROM 需要许可。引用本书回答问题并引用示例代码不需要许可。将本书的大量示例代码整合到您产品的文档中需要许可。

我们感谢,但不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Concurrency in C# Cookbook, Second Edition, by Stephen Cleary (O’Reilly). Copyright 2019 Stephen Cleary, 978-1-492-05450-4。”

如果您认为您使用的代码示例超出了合理使用范围或以上给出的许可,请随时通过permissions@oreilly.com与我们联系。

奥莱利在线学习

注意

近 40 年来,奥莱利传媒为企业的成功提供技术和商业培训、知识和洞见。

我们独特的专家和创新者网络通过书籍、文章、会议和我们的在线学习平台分享他们的知识和专长。奥莱利的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境,以及来自奥莱利和其他 200 多家出版商的大量文本和视频内容。更多信息,请访问http://oreilly.com

如何联系我们

有关本书的评论和问题,请发送至出版商:

  • 奥莱利传媒公司

  • 北格拉文斯坦高速公路 1005 号

  • 加利福尼亚州塞巴斯托波尔 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书设有网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/concur-c-ckbk2查看。

如需对本书提出评论或技术问题,请发送电子邮件至bookquestions@oreilly.com

有关我们的书籍、课程、会议和新闻的更多信息,请访问我们的网站http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://www.youtube.com/oreillymedia

致谢

如果没有这么多人的帮助,本书将无法存在!

首先,我要首先感谢我的主耶稣基督。成为基督徒是我一生中最重要的决定!如果您想获取更多关于这个主题的信息,请随时通过我的个人网页联系我。

其次,我要感谢我的家人,让我能有更多时间投入写作。当我开始写作时,一些作家朋友告诉我:“接下来一年要和家人说再见!” 我当时以为他们在开玩笑。我的妻子曼迪和我们的孩子 SD 和 Emma,在我白天工作后以及晚上和周末写作时非常理解和支持。非常感谢你们,我爱你们!

当然,如果没有我的编辑和技术审阅者:Stephen Toub、Petr Onderka(“svick”)、Nick Paldino(“casperOne”)、Lee Campbell 和 Pedro Felix,这本书不会有现在的水平。所以如果有任何错误通过了,完全是他们的错。开个玩笑!他们的意见在塑造(和修复)内容方面是无价的,当然,剩下的错误都是我自己的责任。特别感谢 Stephen Toub,他教会了我布尔参数技巧(Recipe 14.5),以及无数其他的async主题;还有 Lee Campbell,他帮助我学习了 System.Reactive,使我的观察者模式代码更具表现力。

最后,我想感谢我从中学到这些技巧的一些人:Stephen Toub、Lucian Wischik、Thomas Levesque、Lee Campbell、Stack Overflow 和 MSDN 论坛的成员,以及在我所在密歇根州及周边举办的软件会议的参与者。我很高兴能成为软件开发社区的一部分,如果这本书有任何价值,那是因为已经有很多人指引了道路。谢谢大家!

第一章:并发:概述

并发是优秀软件的关键特征。几十年来,并发虽然可行,但难以实现。并发软件难以编写、难以调试,也难以维护。因此,许多开发者选择了更简单的路径,并避免使用并发。有了现代.NET 程序可用的库和语言特性,现在并发要容易得多了。微软在显著降低并发门槛方面走在了前列。以前,并发编程是专家领域;而今,每个开发者都可以(也应该)拥抱并发。

并发介绍

继续之前,我想澄清一些我将在本书中使用的术语。这些是我自己的定义,我一贯使用它们来消除不同编程技术的歧义。让我们从并发开始。

并发

同时进行多项任务。

我希望并发的帮助显而易见。端用户应用程序使用并发来在向数据库写入数据的同时响应用户输入。服务器应用程序使用并发来在完成第一个请求的同时响应第二个请求。任何时候你需要应用程序在做一件事的同时正在处理另一件事时,都需要并发。世界上几乎每一个软件应用程序都可以从并发中受益。

大多数开发者一听到“并发”就立刻想到“多线程”。我想要区分这两者。

多线程

使用多个执行线程的并发形式。

多线程是指确实使用多个线程。正如本书中许多示例所示,多线程是一种并发形式,但绝非唯一形式。事实上,在现代应用程序中,直接使用低级别的线程类型几乎没有意义;高级抽象比传统的多线程更强大、更高效。因此,我将尽量减少对过时技术的覆盖。本书中的多线程示例均未使用ThreadBackgroundWorker类型;它们已被更优秀的替代方案取代。

警告

一旦你键入new Thread(),你的项目就已经有了遗留代码。

但不要认为多线程已经过时!多线程仍然存在于线程池中,这是一个有用的工作队列,可以根据需求自动调整自己。线程池进一步实现了另一种重要的并发形式:并行处理

并行处理

通过将工作分配给多个同时运行的线程来完成大量工作。

并行处理(或并行编程)利用多线程来最大化多个处理器核心的使用。现代 CPU 具有多个核心,如果有很多工作要做,那么让一个核心独自完成所有工作而其他核心空闲是没有意义的。并行处理将工作分配给多个线程,每个线程可以独立地在不同的核心上运行。

并行处理是多线程的一种类型,而多线程是并发的一种类型。在现代应用程序中,还有另一种重要的并发类型,但对许多开发者来说并不那么熟悉:异步编程

异步编程

一种使用未来或回调来避免不必要线程的并发形式。

未来(或承诺)是一种表示将来某些操作完成的类型。在.NET 中,一些现代的未来类型包括TaskTask<TResult>。旧的异步 API 使用回调或事件来代替未来。异步编程围绕异步操作的概念展开:启动的操作将在稍后某个时刻完成。在操作进行时,它不会阻塞原始线程;启动操作的线程可以自由进行其他工作。当操作完成时,它通过通知其未来或调用其回调或事件来告知应用程序操作已完成。

异步编程是一种强大的并发形式,但直到最近,它需要极其复杂的代码。现代语言中的asyncawait支持使异步编程几乎和同步(非并发)编程一样简单。

另一种并发形式是响应式编程。异步编程意味着应用程序将启动一个稍后会完成的操作。响应式编程与异步编程密切相关,但是建立在异步事件而不是异步操作的基础上。异步事件可能没有实际的“启动”,可能随时发生,并且可能被多次触发。例如用户输入是一个例子。

响应式编程

一种声明式编程风格,应用程序对事件做出响应。

如果将一个应用程序视为一个大型状态机,该应用程序的行为可以描述为在每个事件中通过更新其状态来响应一系列事件。这并不像听起来那么抽象或理论化;现代框架使这种方法在实际应用中非常有用。响应式编程并不一定是并发的,但它与并发密切相关,因此本书涵盖了基础知识。

通常,在编写并发程序时会混合使用各种技术。大多数应用程序至少使用多线程(通过线程池)和异步编程。可以根据应用程序的不同部分混合和匹配所有各种形式的并发,选择适当的工具。

异步编程介绍

异步编程有两个主要好处。第一个好处是对于终端用户 GUI 程序:异步编程能够提升响应性。每个人都使用过一个在工作时暂时锁定的程序;异步程序可以在工作时保持对用户输入的响应。第二个好处是对于服务器端程序:异步编程能够提升可伸缩性。服务器应用程序可以通过使用线程池来实现一定程度的扩展,但异步服务器应用程序通常可以比这更好地扩展一个数量级。

异步编程的两个好处都源自同一个基本方面:异步编程释放了一个线程。对于 GUI 程序,异步编程释放了 UI 线程;这使得 GUI 应用程序可以保持对用户输入的响应。对于服务器应用程序,异步编程释放了请求线程;这使得服务器可以使用其线程来处理更多的请求。

现代异步.NET 应用程序使用两个关键字:asyncawaitasync关键字添加到方法声明中,起到双重作用:它在该方法内启用await关键字,并提示编译器为该方法生成状态机,类似于yield return的工作方式。如果异步方法返回值,它可以返回Task<TResult>,如果不返回值,则返回Task或任何其他“类似任务”的类型,如ValueTask。此外,如果异步方法返回枚举中的多个值,则可以返回IAsyncEnumerable<T>IAsyncEnumerator<T>。类似任务的类型代表未来;它们可以在异步方法完成时通知调用代码。

警告

避免使用async void!可能有一个async方法返回void,但只有在编写async事件处理程序时才应该这样做。没有返回值的常规async方法应该返回Task,而不是void

在此背景下,让我们快速看一个例子:

async Task DoSomethingAsync()
{
  int value = 13;

  // Asynchronously wait 1 second.
  await Task.Delay(TimeSpan.FromSeconds(1));

  value *= 2;

  // Asynchronously wait 1 second.
  await Task.Delay(TimeSpan.FromSeconds(1));

  Trace.WriteLine(value);
}

async方法开始同步执行,就像任何其他方法一样。在async方法内部,await关键字对其参数执行异步等待。首先,它检查操作是否已经完成;如果完成,它将继续执行(同步)。否则,它将暂停async方法并返回一个未完成的任务。当操作稍后完成时,async方法将继续执行。

你可以把async方法看作有几个同步部分,这些部分由await语句分隔开。第一个同步部分在调用方法的任何线程上执行,但其他同步部分在哪里执行呢?答案有点复杂。

当您await一个任务(最常见的场景),当await决定暂停方法时,将捕获一个上下文。这是当前的SynchronizationContext,除非它为null,在这种情况下,上下文是当前的TaskScheduler。方法在捕获的上下文中恢复执行。通常,此上下文是 UI 上下文(如果您在 UI 线程上)或线程池上下文(大多数其他情况)。如果您有一个 ASP.NET 经典(非 Core)应用程序,则上下文也可以是 ASP.NET 请求上下文。ASP.NET Core 使用线程池上下文而不是特殊的请求上下文。

因此,在前面的代码中,所有同步部分都将尝试在原始上下文中恢复。如果你从 UI 线程调用DoSomethingAsync,那么它的每个同步部分都将在该 UI 线程上运行;但如果你从线程池线程调用它,那么它的每个同步部分将在任何线程池线程上运行。

您可以通过等待ConfigureAwait扩展方法的结果并将continueOnCapturedContext参数设置为false来避免此默认行为。以下代码将在调用线程上启动,并在被await暂停后在线程池线程上恢复:

async Task DoSomethingAsync()
{
  int value = 13;

  // Asynchronously wait 1 second.
  await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);

  value *= 2;

  // Asynchronously wait 1 second.
  await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);

  Trace.WriteLine(value);
}
提示

在核心“库”方法中始终调用ConfigureAwait并仅在外部“用户界面”方法中恢复上下文是一个好习惯。

await关键字不仅限于与任务一起工作;它可以与任何符合特定模式的awaitable一起工作。例如,基类库包括ValueTask<T>类型,如果结果通常是同步的,则减少内存分配;例如,如果结果可以从内存中的缓存中读取。ValueTask<T>不直接转换为Task<T>,但它确实遵循可等待模式,因此您可以直接await它。还有其他示例,您也可以构建自己的示例,但大多数情况下,await将接受TaskTask<TResult>

有两种基本方法可以创建Task实例。某些任务表示 CPU 必须执行的实际代码;这些计算任务应通过调用Task.Run(或者如果需要它们在特定调度程序上运行,则通过TaskFactory.StartNew)创建。其他任务表示通知;这些基于事件的任务类型是通过TaskCompletionSource<TResult>(或其快捷方式之一)创建的。大多数 I/O 任务使用TaskCompletionSource<TResult>

使用asyncawait进行错误处理是自然的。在以下代码片段中,PossibleExceptionAsync可能会抛出NotSupportedException,但TrySomethingAsync可以自然地捕获异常。捕获的异常保留了其堆栈跟踪,并且没有被人为包装在TargetInvocationExceptionAggregateException中。

async Task TrySomethingAsync()
{
  try
  {
    await PossibleExceptionAsync();
  }
  catch (NotSupportedException ex)
  {
    LogException(ex);
    throw;
  }
}

async方法抛出(或传播)异常时,异常会放置在其返回的Task上,并且Task已完成。当等待该Task时,await操作符将检索该异常并(重新)抛出它,以保留其原始堆栈跟踪。因此,如果PossibleExceptionAsync是一个async方法,则下面的示例代码会按预期工作:

async Task TrySomethingAsync()
{
  // The exception will end up on the Task, not thrown directly.
  Task task = PossibleExceptionAsync();

  try
  {
    // The Task's exception will be raised here, at the await.
    await task;
  }
  catch (NotSupportedException ex)
  {
    LogException(ex);
    throw;
  }
}

在使用async方法时还有一个重要的指导原则:一旦开始使用async,最好让它在整个代码中延续下去。如果调用了一个async方法,应该(最终)await其返回的任务。抵制调用Task.WaitTask<TResult>.ResultGetAwaiter().GetResult()的诱惑;这样做可能会导致死锁。考虑以下方法:

async Task WaitAsync()
{
  // This await will capture the current context ...
  await Task.Delay(TimeSpan.FromSeconds(1));
  // ... and will attempt to resume the method here in that context.
}

void Deadlock()
{
  // Start the delay.
  Task task = WaitAsync();

  // Synchronously block, waiting for the async method to complete.
  task.Wait();
}

这个示例中的代码在从 UI 或 ASP.NET 经典环境中调用时会造成死锁,因为这些环境只允许一个线程进入。Deadlock将调用WaitAsync,开始延迟。然后Deadlock(同步地)等待该方法完成,阻塞了上下文线程。当延迟完成时,await试图在捕获的上下文中恢复WaitAsync,但由于上下文中已经有一个线程被阻塞,并且上下文只允许一个线程,所以无法执行。可以通过两种方式防止死锁:在WaitAsync中使用ConfigureAwait(false)(使await忽略其上下文),或者await调用WaitAsync(使Deadlock变成异步方法)。

警告

如果使用async,最好全程使用async

要了解更全面的async介绍,Microsoft 提供的在线文档非常棒;我建议至少阅读异步编程概述基于任务的异步模式(TAP)概述。如果想深入了解,还有深入异步文档。

异步流利用了asyncawait的基础,并将其扩展到处理多个值。异步流围绕异步可枚举的概念构建,类似于常规可枚举,但允许在检索序列的下一个项目时进行异步工作。这是一个非常强大的概念,第三章详细介绍了此内容。异步流在处理单个或分块到达的数据序列时特别有用。例如,如果您的应用程序处理使用limitoffset参数进行分页的 API 响应,则异步流是一个理想的抽象。截至撰写本文时,异步流仅在最新的.NET 平台上可用。

并行编程简介

并行编程应该在任何你有相当数量的可以分成独立块的计算工作时使用。并行编程会暂时增加 CPU 使用率以提高吞吐量;这在客户端系统中是可取的,因为 CPU 通常是空闲的,但通常不适用于服务器系统。大多数服务器都内置了一些并行性;例如,ASP.NET 将并行处理多个请求。在服务器上编写并行代码可能在某些情况下仍然有用(如果你知道并发用户数始终很低),但一般情况下,在服务器上进行并行编程会与其内置的并行性相抵触,因此不会带来实际好处。

并行性有两种形式:数据并行性任务并行性。数据并行性是指你有一堆数据项要处理,每个数据项的处理大多数是独立的。任务并行性是指你有一组工作要做,每个工作大多数也是独立的。任务并行性可能是动态的;如果一个工作导致产生几个额外的工作,则它们可以添加到工作池中。

有几种不同的数据并行方式。Parallel.ForEach类似于foreach循环,应在可能的情况下使用。Parallel.ForEach在 Recipe 4.1 中有所介绍。Parallel类还支持Parallel.For,类似于for循环,如果数据处理依赖于索引,则可以使用。使用Parallel.ForEach的代码如下:

void RotateMatrices(IEnumerable<Matrix> matrices, float degrees)
{
  Parallel.ForEach(matrices, matrix => matrix.Rotate(degrees));
}

另一种选择是 PLINQ(Parallel LINQ),它为 LINQ 查询提供了AsParallel扩展方法。Parallel比 PLINQ 更节约资源;Parallel在系统中更加友好,而 PLINQ(默认情况下)会尝试在所有 CPU 上进行分布。Parallel的缺点是它更加显式;在许多情况下,PLINQ 代码更加优雅。PLINQ 在 Recipe 4.5 中有所介绍,代码如下:

IEnumerable<bool> PrimalityTest(IEnumerable<int> values)
{
  return values.AsParallel().Select(value => IsPrime(value));
}

无论你选择的方法是什么,在进行并行处理时有一个指导原则非常重要。

提示

工作块应尽可能彼此独立。

只要你的工作块与其他所有工作块都是独立的,就能最大化并行处理能力。一旦开始在多个线程之间共享状态,就必须同步对共享状态的访问,你的应用程序就会变得不那么并行。第十二章详细介绍了同步的内容。

并行处理的输出可以通过多种方式处理。你可以将结果放入某种并发集合中,或者将结果聚合到摘要中。在并行处理中,聚合很常见;这种 map/reduce 功能也被 Parallel 类的方法重载支持。Recipe 4.2 更详细地讨论了聚合。

现在让我们转向任务并行性。数据并行性专注于处理数据;任务并行性仅仅是关于完成工作。从高层次来看,数据并行性和任务并行性类似;“处理数据”就是一种“工作”。许多并行问题可以用两种方式解决;使用对于手头问题更自然的 API 是很方便的。

Parallel.InvokeParallel 方法的一种,它执行一种 fork/join 任务并行性。这个方法在 Recipe 4.3 中有所涵盖;你只需传入你想并行执行的委托:

void ProcessArray(double[] array)
{
  Parallel.Invoke(
      () => ProcessPartialArray(array, 0, array.Length / 2),
      () => ProcessPartialArray(array, array.Length / 2, array.Length)
  );
}

void ProcessPartialArray(double[] array, int begin, int end)
{
  // CPU-intensive processing...
}

Task 类型最初是为了任务并行性而引入的,尽管如今它也用于异步编程。Task 实例——作为任务并行性的一部分——代表了一些工作。你可以使用 Wait 方法等待任务完成,也可以使用 ResultException 属性获取工作的结果。直接使用 Task 的代码比使用 Parallel 更复杂,但如果在运行时不知道并行结构,它可能很有用。在这种动态并行性中,你不知道开始处理时需要做多少工作;随着进行,你才会找出。通常,动态工作应该启动需要的子任务,然后等待它们完成。Task 类型有一个特殊标志,TaskCreationOptions.AttachedToParent,可以用于此目的。动态并行性在 Recipe 4.4 中有所涵盖。

任务并行性应该力求独立,就像数据并行性一样。你的委托越独立,程序就越高效。此外,如果你的委托不独立,那么它们就需要同步,编写正确的代码就会更加困难。在任务并行性中,尤其要注意闭包中捕获的变量。记住,闭包捕获的是引用(而不是值),因此可能会出现不明显的共享问题。

所有类型的并行性都有类似的错误处理。因为操作是并行进行的,可能会发生多个异常,因此它们会被包装在抛给你的 AggregateException 中。这种行为在 Parallel.ForEachParallel.InvokeTask.Wait 等中是一致的。AggregateException 类型有一些有用的 FlattenHandle 方法来简化错误处理代码:

try
{
  Parallel.Invoke(() => { throw new Exception(); },
      () => { throw new Exception(); });
}
catch (AggregateException ex)
{
  ex.Handle(exception =>
  {
    Trace.WriteLine(exception);
    return true; // "handled"
  });
}

通常,你不必担心线程池如何处理工作。数据和任务并行使用动态调整的分区器来在工作线程之间分割工作。线程池会根据需要增加其线程数。线程池有一个单一的工作队列,每个线程池线程也有自己的工作队列。当线程池线程将额外的工作排队时,它首先发送到自己的队列,因为这个工作通常与当前工作项相关;这种行为鼓励线程处理自己的工作,并最大化缓存命中。如果另一个线程没有工作要做,它会从另一个线程的队列中窃取工作。微软在使线程池尽可能高效方面投入了大量工作,并且如果需要最大性能,你可以调整大量参数。只要你的任务不是过于短小,它们应该能够在默认设置下正常工作。

提示

任务既不应该过于短小,也不应该过于长。

如果你的任务过于短小,那么将数据分解为任务,并在线程池上调度这些任务的开销会变得很大。如果任务过于长,则线程池无法有效地动态调整其工作负载平衡。很难确定什么长度算是太短,什么长度算是太长;这确实取决于所解决的问题以及硬件的大致能力。作为一般规则,我尽量让我的任务尽可能短,而不会遇到性能问题(当任务过于短时,性能会突然下降)。更好的方法是,不直接使用任务,而是使用Parallel类型或者 PLINQ。这些更高级别的并行形式已经内置了分区处理,可以自动处理(并在运行时根据需要调整)。

如果你想深入了解并行编程,关于这个主题最好的书是《使用 Microsoft .NET 进行并行编程》,作者是 Colin Campbell 等人(Microsoft Press)。

引入响应式编程(Rx)

响应式编程比其他形式的并发编程有更高的学习曲线,如果不跟上响应式技能的话,代码维护起来可能会更困难。但如果你愿意学习,响应式编程是非常强大的。响应式编程让你能够将事件流视为数据流。作为经验法则,如果你使用了传递给事件的任何事件参数,那么你的代码将受益于使用 System.Reactive 而不是常规事件处理程序。

提示

System.Reactive 曾被称为 Reactive Extensions,通常简称为“Rx”。这三个术语都指的是同一项技术。

响应式编程基于可观察流的概念。当您订阅一个可观察流时,您将接收任意数量的数据项 (OnNext),然后流可能以单个错误 (OnError) 或 “流结束” 通知 (OnCompleted) 结束。某些可观察流永远不会结束。实际的接口如下所示:

interface IObserver<in T>
{
  void OnNext(T item);
  void OnCompleted();
  void OnError(Exception error);
}

interface IObservable<out T>
{
  IDisposable Subscribe(IObserver<TResult> observer);
}

然而,您永远不应该实现这些接口。Microsoft 的 System.Reactive (Rx) 库已经包含了您可能需要的所有实现。响应式代码看起来非常类似于 LINQ;您可以将其视为 “LINQ to Events”。System.Reactive 拥有与 LINQ 相同的所有功能,并添加了大量自己的操作符,特别是处理时间的操作符。以下代码从一些不熟悉的操作符 (IntervalTimestamp) 开始,以 Subscribe 结束,但中间有一些您应该从 LINQ 中熟悉的 WhereSelect 操作符:

Observable.Interval(TimeSpan.FromSeconds(1))
    .Timestamp()
    .Where(x => x.Value % 2 == 0)
    .Select(x => x.Timestamp)
    .Subscribe(x => Trace.WriteLine(x));

示例代码从一个周期性定时器 (Interval) 开始运行一个计数器,并为每个事件添加时间戳 (Timestamp)。然后,它会过滤事件,只包括偶数计数器值 (Where),选择时间戳值 (Timestamp),最后每个结果的时间戳值到达时,将其写入调试器 (Subscribe)。如果您不理解 Interval 等新操作符,不要担心:这些稍后在本书中会进行介绍。现在只需记住,这是一个与您已经熟悉的 LINQ 查询非常相似的 LINQ 查询。主要区别在于 LINQ to Objects 和 LINQ to Entities 使用 “拉取”模型,即 LINQ 查询的枚举通过查询拉取数据,而 LINQ to Events (System.Reactive) 使用 “推送”模型,即事件到达并自行通过查询传递。

可观察流的定义与其订阅是独立的。最后的例子与以下代码相同:

IObservable<DateTimeOffset> timestamps =
    Observable.Interval(TimeSpan.FromSeconds(1))
        .Timestamp()
        .Where(x => x.Value % 2 == 0)
        .Select(x => x.Timestamp);
timestamps.Subscribe(x => Trace.WriteLine(x));

类型定义可观察流并将其作为 IObservable<TResult> 资源提供是正常的。其他类型可以订阅这些流或与其他操作符结合以创建另一个可观察流。

System.Reactive 的订阅也是一种资源。Subscribe 操作符返回一个 IDisposable,表示订阅。当您的代码完成对可观察流的监听后,应该释放其订阅。

使用热和冷可观察流时,订阅的行为是不同的。热可观察流 是一个始终运行的事件流,如果没有订阅者在事件到达时,它们将丢失。例如,鼠标移动是一个热可观察流。冷可观察流 是不会始终有传入事件的可观察流。冷可观察流将通过订阅启动事件序列。例如,HTTP 下载是一个冷可观察流;订阅导致发送 HTTP 请求。

Subscribe 操作符应始终带有错误处理参数。前面的示例没有这样做;以下是一个更好的示例,如果可观察流以错误结束,将适当地做出响应:

Observable.Interval(TimeSpan.FromSeconds(1))
    .Timestamp()
    .Where(x => x.Value % 2 == 0)
    .Select(x => x.Timestamp)
    .Subscribe(x => Trace.WriteLine(x),
        ex => Trace.WriteLine(ex));

Subject<TResult> 是在使用 System.Reactive 进行实验时非常有用的一种类型。这个“主题”类似于手动实现的可观察流。你的代码可以调用 OnNextOnErrorOnCompleted,主题将把这些调用转发给它的订阅者。在实际生产代码中,Subject<TResult> 很适合进行实验,但你应该努力使用像在 Chapter 6 中涵盖的操作符。

System.Reactive 拥有大量有用的操作符,在本书中我只涵盖了一些精选的。关于 System.Reactive 的更多信息,我推荐阅读优秀的在线书籍 Introduction to Rx

数据流介绍

TPL Dataflow 是异步和并行技术的有趣结合。当你有一系列需要应用到数据的过程时,它非常有用。例如,你可能需要从 URL 下载数据,解析数据,然后与其他数据并行处理。TPL Dataflow 常用作简单的管道,其中数据从一端进入,直到另一端出来。然而,TPL Dataflow 的能力远不止如此;它能处理任何类型的网格。你可以在网格中定义分支、汇合和循环,TPL Dataflow 会适当地处理它们。尽管如此,大多数时候,TPL Dataflow 网格被用作管道。

数据流网格的基本构建单元是 数据流块。一个块可以是目标块(接收数据)、源块(产生数据),或者两者兼而有之。源块可以与目标块链接以创建网格;链接的详细信息请参见 Recipe 5.1。块是半独立的;它们会尝试处理到达的数据并将结果推送到下游。使用 TPL Dataflow 的常规方式是创建所有块,将它们链接在一起,然后从一端开始输入数据。数据会自动从另一端输出。同样,Dataflow 的能力远超出此范围;在数据流动的同时,可以断开链接、创建新块并将其添加到网格中,但这是一种非常高级的场景。

目标块具有用于接收它们接收的数据的缓冲区。通过具有缓冲区,即使它们还没有准备好处理数据项,它们也能够接受新的数据项;这保持了数据通过网格的流动。这种缓冲可能会在分叉场景中引发问题,其中一个源块链接到两个目标块。当源块有数据要发送到下游时,它开始逐个向其链接的块提供数据。默认情况下,第一个目标块只会接受数据并缓冲它,而第二个目标块则永远不会得到任何数据。解决此情况的方法是通过使目标块成为非贪婪的来限制目标块的缓冲区;菜谱 5.4 详细介绍了这一点。

当某个块出现问题时,例如在处理数据项时处理委托引发异常时,该块将出现故障。当块出现故障时,它将停止接收数据。默认情况下,它不会导致整个网格崩溃;这使您能够重建网格的那一部分或重定向数据。但是,这是一个高级场景;大多数情况下,您希望故障沿着链路传播到目标块。数据流也支持此选项;唯一棘手的部分是当异常沿链路传播时,它会被包装在AggregateException中。因此,如果您有一个长的管道,可能会出现深度嵌套的异常;方法AggregateException.Flatten可用于解决此问题:

try
{
  var multiplyBlock = new TransformBlock<int, int>(item =>
  {
    if (item == 1)
      throw new InvalidOperationException("Blech.");
    return item * 2;
  });
  var subtractBlock = new TransformBlock<int, int>(item => item - 2);
  multiplyBlock.LinkTo(subtractBlock,
      new DataflowLinkOptions { PropagateCompletion = true });

  multiplyBlock.Post(1);
  subtractBlock.Completion.Wait();
}
catch (AggregateException exception)
{
  AggregateException ex = exception.Flatten();
  Trace.WriteLine(ex.InnerException);
}

菜谱 5.2 详细介绍了数据流错误处理。

乍一看,数据流网格听起来很像可观察流,它们确实有很多共同点。网格和流都有数据项通过的概念。而且,网格和流都有正常完成(通知没有更多数据到来)和故障完成(通知在数据处理过程中发生错误)的概念。但是,System.Reactive(Rx)和 TPL Dataflow 并没有相同的能力。在处理与时间相关的任何事务时,Rx 可观察流通常比数据流块更好。而在进行并行处理时,数据流块通常比 Rx 可观察流更好。从概念上讲,Rx 更像是设置回调:可观察流中的每个步骤直接调用下一个步骤。相比之下,数据流网格中的每个块都与所有其他块非常独立。Rx 和 TPL Dataflow 都有各自的用途,并存在一定的重叠。它们在一起工作也非常好;菜谱 8.8 详细介绍了 Rx 和 TPL Dataflow 之间的互操作性。

如果您熟悉 Actor 框架,TPL Dataflow 将似乎与其共享一些相似之处。每个数据流块是独立的,它会根据需要启动任务来执行工作,如执行转换委托或将输出推送到下一个块。您还可以设置每个块并行运行,以便它可以启动多个任务来处理额外的输入。由于这种行为,每个块确实与 Actor 框架中的 Actor 具有某种相似性。然而,TPL Dataflow 并不是一个完整的 Actor 框架;特别是,它没有内置支持干净的错误恢复或任何形式的重试。TPL Dataflow 是一个具有类似 Actor 感觉的库,但它不是一个功能完备的 Actor 框架。

最常见的 TPL Dataflow 块类型包括 TransformBlock<TInput, TOutput>(类似于 LINQ 的 Select)、TransformManyBlock<TInput, TOutput>(类似于 LINQ 的 SelectMany)和 ActionBlock<TResult>,它为每个数据项执行一个委托。有关 TPL Dataflow 的更多信息,请参阅MSDN 文档“实现自定义 TPL Dataflow 块指南”

多线程编程简介

线程 是独立的执行器。每个进程中都有多个线程,在其中每个线程可以同时执行不同的任务。每个线程有其自己独立的堆栈,但与进程中的所有其他线程共享相同的内存。在某些应用程序中,有一个特殊的线程。例如,用户界面应用程序有一个特殊的 UI 线程,控制台应用程序有一个特殊的主线程。

每个.NET 应用程序都有一个线程池。线程池维护着一些工作线程,这些线程等待执行您给它们的工作。线程池负责确定线程池中任何时候有多少个线程。有许多配置设置可以调整这种行为,但我建议您不要去碰它;线程池已经经过精心调整,可以覆盖绝大多数实际场景。

几乎没有必要自己创建新线程。您唯一需要创建 Thread 实例的时候是如果您需要一个 STA 线程来进行 COM 互操作。

线程是低级抽象。线程池是稍高级的抽象;当代码将工作排入线程池时,线程池本身会负责根据需要创建线程。本书涵盖的抽象级别更高:并行和数据流处理根据需要将工作排入线程池。使用这些更高级别抽象的代码比使用低级抽象更容易正确实现。

因此,ThreadBackgroundWorker 类型在本书中完全没有涵盖。它们有过自己的时代,而那个时代已经结束了。

并发应用程序的集合

有几个集合类别对并发编程很有用:并发集合和不可变集合。这两个集合类别都在第九章中有所涵盖。并发集合允许多个线程同时安全地更新它们。大多数并发集合使用快照来使一个线程能够枚举值,而另一个线程可能在添加或删除值。并发集合通常比只用锁保护常规集合更有效率。

不可变集合有些不同。不可变集合实际上不能被修改;相反,要修改一个不可变集合,你需要创建一个代表修改后集合的新集合。这听起来效率非常低,但不可变集合在集合实例之间尽可能共享内存,所以情况没有听起来那么糟。不可变集合的好处在于所有操作都是纯粹的,因此它们与函数式代码非常配合。

现代设计

大多数并发技术具有一个相似的特点:它们都具有功能性质。我不是指“功能性”指的是它们能完成工作,而是指一种基于函数组合的编程风格。如果你采用功能性思维方式,你的并发设计将更加简洁。

函数式编程的一个原则是purity(即避免副作用)。解决方案的每一部分都将某些值作为输入并产生某些值作为输出。尽可能避免这些部分依赖全局(或共享)变量或更新全局(或共享)数据结构。无论这部分是一个async方法、一个并行任务、一个 System.Reactive 操作还是一个数据流块,这一点都是真实的。当然,迟早你的计算将不得不产生效果,但如果你能使用纯净的部分处理processing,然后使用results执行更新,你会发现你的代码更加清洁。

函数式编程的另一个原则是immutability。不可变性意味着数据片段不能改变。对于并发程序,不可变数据的一个有用原因是你永远不需要为不可变数据进行同步;它的不变性使得同步变得不必要。不可变数据还有助于避免副作用。开发者开始更多地使用不可变类型,本书包含了几个处理不可变数据结构的技巧。

关键技术摘要

.NET 框架自始至终对异步编程有一些支持。然而,直到 2012 年,也就是.NET 4.5(连同 C# 5.0 和 VB 2012)引入了asyncawait关键字之前,异步编程一直很困难。本书将使用现代的async/await方法来处理所有异步任务,并提供了一些示例,展示如何在async和旧的异步编程模式之间进行交互。如果需要支持旧平台,请参阅附录 A。

.NET 4.0 引入了任务并行库(Task Parallel Library,TPL),全面支持数据并行和任务并行。如今,即使在资源较少的平台如手机上,也可以使用。TPL 已经内置于.NET 中。

System.Reactive 团队努力支持尽可能多的平台。像asyncawait一样,System.Reactive 为各种应用程序(包括客户端和服务器)提供了诸多好处。System.Reactive 可以在System.Reactive NuGet 包中找到。

TPL Dataflow 库正式分发在System.Threading.Tasks.Dataflow NuGet 包中。

大多数并发集合都内置于.NET 中;System.Threading.Channels NuGet 包中还提供了一些额外的并发集合。不可变集合则可以在System.Collections.Immutable NuGet 包中找到。

第二章:异步基础

本章介绍了使用 asyncawait 进行异步操作的基础知识。在这里,我们将只处理自然异步操作,例如 HTTP 请求、数据库命令和网络服务调用。

如果你有一个 CPU 密集型操作,希望将其视为异步操作(例如,以免阻塞 UI 线程),请参阅第四章和 Recipe 8.4。此外,本章仅处理启动一次并完成一次的操作;如果需要处理事件流,请参阅第三章[ch03.html#async-streams]和第六章[ch06.html#rx-basics]。

2.1 暂停一段时间

问题

你需要(异步地)等待一段时间。这是单元测试或实现重试延迟时常见的情况。编写简单超时时,也会遇到这种情况。

解决方案

Task 类型有一个静态方法 Delay,返回在指定时间后完成的任务。

下面的示例代码定义了一个完成异步的任务。在伪造异步操作时,测试同步成功、异步成功以及异步失败非常重要。以下示例返回用于异步成功案例的任务:

async Task<T> DelayResult<T>(T result, TimeSpan delay)
{
  await Task.Delay(delay);
  return result;
}

指数退避是一种策略,其中你增加重试之间的延迟。在使用网络服务时,请确保服务器不会被重试淹没。下一个示例是指数退避的简单实现:

async Task<string> DownloadStringWithRetries(HttpClient client, string uri)
{
  // Retry after 1 second, then after 2 seconds, then 4.
  TimeSpan nextDelay = TimeSpan.FromSeconds(1);
  for (int i = 0; i != 3; ++i)
  {
    try
    {
      return await client.GetStringAsync(uri);
    }
    catch
    {
    }

    await Task.Delay(nextDelay);
    nextDelay = nextDelay + nextDelay;
  }

  // Try one last time, allowing the error to propagate.
  return await client.GetStringAsync(uri);
}
提示

对于生产代码,建议使用更彻底的解决方案,例如Polly NuGet 库;此代码只是演示了 Task.Delay 的使用。

你还可以将 Task.Delay 用作简单的超时。 CancellationTokenSource 是实现超时的常规类型(Recipe 10.3)。你可以在无限 Task.Delay 中包装一个取消标记,以提供在指定时间后取消的任务。最后,将该定时器任务与 Task.WhenAny 结合使用(Recipe 2.5)实现“软超时”。以下示例代码在服务在三秒内未响应时返回 null

async Task<string> DownloadStringWithTimeout(HttpClient client, string uri)
{
  using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
  Task<string> downloadTask = client.GetStringAsync(uri);
  Task timeoutTask = Task.Delay(Timeout.InfiniteTimeSpan, cts.Token);

  Task completedTask = await Task.WhenAny(downloadTask, timeoutTask);
  if (completedTask == timeoutTask)
    return null;
  return await downloadTask;
}

虽然可以使用 Task.Delay 作为“软超时”,但这种方法有局限性。如果操作超时,它不会被取消;在前面的示例中,下载任务将继续下载并在丢弃之前下载完整的响应。首选方法是使用取消标记作为超时,并直接将其传递给操作(在最后一个示例中的 GetStringAsync)。尽管如此,有时操作是不可取消的,在这种情况下,其他代码可能会使用 Task.Delay 模拟 操作超时。

讨论

Task.Delay是用于单元测试异步代码或实现重试逻辑的良好选择。但是,如果需要实现超时,通常使用CancellationToken会更好。

参见

Recipe 2.5 讲解了如何使用Task.WhenAny来确定哪个任务首先完成。

Recipe 10.3 讲解了如何使用CancellationToken作为超时的示例。

2.2 返回已完成的任务

问题

你需要实现一个具有异步签名的同步方法。如果你从一个异步接口或基类继承但希望同步实现它,可能会遇到这种情况。这种技术在单元测试异步代码时特别有用,当你需要一个简单的存根或模拟异步接口时。

解决方案

你可以使用Task.FromResult创建并返回一个已经完成并带有指定值的新Task<T>

interface IMyAsyncInterface
{
  Task<int> GetValueAsync();
}

class MySynchronousImplementation : IMyAsyncInterface
{
  public Task<int> GetValueAsync()
  {
    return Task.FromResult(13);
  }
}

对于没有返回值的方法,可以使用Task.CompletedTask,这是一个成功完成的缓存任务:

interface IMyAsyncInterface
{
  Task DoSomethingAsync();
}

class MySynchronousImplementation : IMyAsyncInterface
{
  public Task DoSomethingAsync()
  {
    return Task.CompletedTask;
  }
}

Task.FromResult仅为成功结果提供已完成的任务。如果需要具有不同类型结果(例如,使用NotImplementedException完成的任务),则可以使用Task.FromException

Task<T> NotImplementedAsync<T>()
{
  return Task.FromException<T>(new NotImplementedException());
}

类似地,有一个Task.FromCanceled用于创建已从给定的CancellationToken取消的任务:

Task<int> GetValueAsync(CancellationToken cancellationToken)
{
  if (cancellationToken.IsCancellationRequested)
    return Task.FromCanceled<int>(cancellationToken);
  return Task.FromResult(13);
}

如果你的同步实现可能失败,那么应该捕获异常并使用Task.FromException将其返回,如下所示:

interface IMyAsyncInterface
{
  Task DoSomethingAsync();
}

class MySynchronousImplementation : IMyAsyncInterface
{
  public Task DoSomethingAsync()
  {
    try
    {
      DoSomethingSynchronously();
      return Task.CompletedTask;
    }
    catch (Exception ex)
    {
      return Task.FromException(ex);
    }
  }
}

讨论

如果你正在使用同步代码实现异步接口,请避免任何形式的阻塞。当方法可以异步实现时,异步方法阻塞然后返回完成的任务并不理想。举个反例,考虑一下.NET BCL 中的Console文本读取器。Console.In.ReadLineAsync会阻塞调用线程直到读取到一行,然后返回一个完成的任务。这种行为并不直观,已经让很多开发者感到意外。如果一个异步方法阻塞了,它会阻止调用线程启动其他任务,这会影响并发性甚至可能导致死锁。

如果你经常使用相同值的Task.FromResult,考虑缓存实际任务。例如,如果你创建一个带有零结果的Task<int>,那么你避免创建额外的实例,这些实例将需要被垃圾回收:

private static readonly Task<int> zeroTask = Task.FromResult(0);
Task<int> GetValueAsync()
{
  return zeroTask;
}

从逻辑上讲,Task.FromResultTask.FromExceptionTask.FromCanceled都是通用的帮助方法和TaskCompletionSource<T>的快捷方式。TaskCompletionSource<T>是一个低级别类型,对于与其他形式的异步代码进行交互非常有用。一般情况下,如果要返回一个已经完成的任务,应该使用Task.FromResult等快捷方式。使用TaskCompletionSource<T>返回在将来某个时间完成的任务。

参见

第 7.1 节 讲述了如何对异步方法进行单元测试。

第 11.1 节 讲述了 async 方法的继承。

第 8.3 节 展示了如何利用 TaskCompletionSource<T> 进行与其他异步代码的通用交互。

2.3 报告进度

问题

您需要在操作执行时响应进度。

解决方案

使用提供的 IProgress<T>Progress<T> 类型。您的 async 方法应接受一个 IProgress<T> 参数;T 是您需要报告的进度类型:

async Task MyMethodAsync(IProgress<double> progress = null)
{
  bool done = false;
  double percentComplete = 0;
  while (!done)
  {
    ...
    progress?.Report(percentComplete);
  }
}

调用代码可以如此使用:

async Task CallMyMethodAsync()
{
  var progress = new Progress<double>();
  progress.ProgressChanged += (sender, args) =>
  {
    ...
  };
  await MyMethodAsync(progress);
}

讨论

根据约定,如果调用者不需要进度报告,则 IProgress<T> 参数可能为 null,因此请务必在您的 async 方法中进行检查。

请记住,IProgress<T>.Report 方法通常是异步的。这意味着在报告进度之前,MyMethodAsync 可能会继续执行。因此,最好将 T 定义为不可变类型,或者至少是值类型。如果 T 是可变引用类型,则每次调用 IProgress<T>.Report 都必须手动创建一个单独的副本。

Progress<T> 在构造时会捕获当前上下文,并在该上下文中调用其回调函数。这意味着如果在 UI 线程上构造 Progress<T>,则可以从其回调函数更新 UI,即使异步方法从后台线程调用 Report

当方法支持进度报告时,它也应尽力支持取消。

IProgress<T> 不仅适用于异步代码;长时间运行的同步代码中也应使用进度和取消。

参见

第 10.4 节 讲述了如何在异步方法中支持取消。

2.4 等待一组任务完成

问题

您有多个任务,需要等待它们全部完成。

解决方案

框架提供了 Task.WhenAll 方法来实现此目的。此方法接受多个任务,并返回一个在所有这些任务完成时完成的任务:

Task task1 = Task.Delay(TimeSpan.FromSeconds(1));
Task task2 = Task.Delay(TimeSpan.FromSeconds(2));
Task task3 = Task.Delay(TimeSpan.FromSeconds(1));

await Task.WhenAll(task1, task2, task3);

如果所有任务具有相同的结果类型,并且它们都成功完成,则 Task.WhenAll 任务将返回包含所有任务结果的数组:

Task<int> task1 = Task.FromResult(3);
Task<int> task2 = Task.FromResult(5);
Task<int> task3 = Task.FromResult(7);

int[] results = await Task.WhenAll(task1, task2, task3);

// "results" contains { 3, 5, 7 }

存在一个接受任务 IEnumerableTask.WhenAll 的重载;然而,我不建议您使用它。每当我将异步代码与 LINQ 混合时,我发现在明确“实体化”序列(即评估序列,创建集合)时代码更清晰:

async Task<string> DownloadAllAsync(HttpClient client,
    IEnumerable<string> urls)
{
  // Define the action to do for each URL.
  var downloads = urls.Select(url => client.GetStringAsync(url));
  // Note that no tasks have actually started yet
  //  because the sequence is not evaluated.

  // Start all URLs downloading simultaneously.
  Task<string>[] downloadTasks = downloads.ToArray();
  // Now the tasks have all started.

  // Asynchronously wait for all downloads to complete.
  string[] htmlPages = await Task.WhenAll(downloadTasks);

  return string.Concat(htmlPages);
}

讨论

如果任何任务抛出异常,那么 Task.WhenAll 将使用该异常使其返回的任务失败。如果多个任务抛出异常,则所有这些异常都被放置在 Task.WhenAll 返回的任务上。然而,在等待该任务时,只会抛出其中一个异常。如果需要每个具体的异常,可以检查 Task.WhenAll 返回的 Task 上的 Exception 属性:

async Task ThrowNotImplementedExceptionAsync()
{
  throw new NotImplementedException();
}

async Task ThrowInvalidOperationExceptionAsync()
{
  throw new InvalidOperationException();
}

async Task ObserveOneExceptionAsync()
{
  var task1 = ThrowNotImplementedExceptionAsync();
  var task2 = ThrowInvalidOperationExceptionAsync();

  try
  {
    await Task.WhenAll(task1, task2);
  }
  catch (Exception ex)
  {
    // "ex" is either NotImplementedException or InvalidOperationException.
    ...
  }
}

async Task ObserveAllExceptionsAsync()
{
  var task1 = ThrowNotImplementedExceptionAsync();
  var task2 = ThrowInvalidOperationExceptionAsync();

  Task allTasks = Task.WhenAll(task1, task2);
  try
  {
    await allTasks;
  }
  catch
  {
    AggregateException allExceptions = allTasks.Exception;
    ...
  }
}

大多数情况下,当使用 Task.WhenAll 时,我观察所有的异常。通常只需响应第一个抛出的错误即可,而不是所有的错误。

在前面的例子中,请注意,ThrowNotImplementedExceptionAsyncThrowInvalidOperationExceptionAsync 方法不会直接抛出异常;它们使用了 async 关键字,因此它们的异常被捕获并放置在一个正常返回的任务上。这是返回可等待类型的方法的正常和预期行为。

参见

方案 2.5 涵盖了等待一组任务中的任意一个完成的方法。

方案 2.6 涵盖了等待一组任务完成并在每个任务完成后执行操作的方法。

方案 2.8 涵盖了对async Task方法进行异常处理的方法。

2.5 等待任意任务完成

问题

你有多个任务,并且只需响应其中一个完成的情况。当你有多个独立的操作尝试时,这种问题最常见,其中有一种“先到先得”的结构。例如,你可以同时从多个 Web 服务请求股票报价,但你只关心第一个响应的情况。

解决方案

使用 Task.WhenAny 方法。Task.WhenAny 方法接受一系列任务,并返回一个在任何任务完成时完成的任务。返回的任务的结果是首个完成的任务。如果这听起来让人困惑,不要担心;这是一种难以解释但通过代码更容易理解的情况:

// Returns the length of data at the first URL to respond.
async Task<int> FirstRespondingUrlAsync(HttpClient client,
    string urlA, string urlB)
{
  // Start both downloads concurrently.
  Task<byte[]> downloadTaskA = client.GetByteArrayAsync(urlA);
  Task<byte[]> downloadTaskB = client.GetByteArrayAsync(urlB);

  // Wait for either of the tasks to complete.
  Task<byte[]> completedTask =
      await Task.WhenAny(downloadTaskA, downloadTaskB);

  // Return the length of the data retrieved from that URL.
  byte[] data = await completedTask;
  return data.Length;
}

讨论

Task.WhenAny 返回的任务永远不会以故障或取消状态完成。这个“外部”任务始终成功完成,其结果值是第一个完成的 Task(即“内部”任务)。如果内部任务以异常完成,那么该异常不会传播到 Task.WhenAny 返回的外部任务。通常应在内部任务完成后 await 内部任务以确保观察到任何异常。

当第一个任务完成时,请考虑是否取消其余任务。如果其他任务没有被取消,但也从未被等待,那么它们就被放弃了。被放弃的任务会继续运行到完成,并且它们的结果将被忽略。这些被放弃的任务的任何异常也将被忽略。如果这些任务没有被取消,它们将继续运行并可能浪费资源,如 HTTP 连接、数据库连接或定时器。

可以使用 Task.WhenAny 来实现超时(例如,将 Task.Delay 作为其中一个任务),但并不推荐。使用取消来表达超时更为自然,并且取消还具有一个额外的好处,即如果超时则可以取消操作。

另一个反模式是对 Task.WhenAny 处理任务完成。起初,保持任务列表并在每个任务完成后从列表中移除似乎是合理的。但这种方法的问题在于它的执行时间为 O(N²),而存在 O(N) 的算法。正确的 O(N) 算法在 Recipe 2.6 中进行了讨论。

另请参阅

Recipe 2.4 描述了异步等待集合中所有任务完成的方法。

Recipe 2.6 描述了等待集合中的任务全部完成并在每个任务完成时执行操作的方法。

Recipe 10.3 描述了使用取消令牌实现超时的方法。

2.6 在任务完成时处理任务

问题

你有一个任务集合需要等待,并且希望在每个任务完成后对其进行处理。但是,你希望在每个任务完成时立即进行处理,而不等待其他任何任务。

以下示例代码启动了三个延迟任务,然后等待每个任务完成:

async Task<int> DelayAndReturnAsync(int value)
{
  await Task.Delay(TimeSpan.FromSeconds(value));
  return value;
}

// Currently, this method prints "2", "3", and "1".
// The desired behavior is for this method to print "1", "2", and "3".
async Task ProcessTasksAsync()
{
  // Create a sequence of tasks.
  Task<int> taskA = DelayAndReturnAsync(2);
  Task<int> taskB = DelayAndReturnAsync(3);
  Task<int> taskC = DelayAndReturnAsync(1);
  Task<int>[] tasks = new[] { taskA, taskB, taskC };

  // Await each task in order.
  foreach (Task<int> task in tasks)
  {
    var result = await task;
    Trace.WriteLine(result);
  }
}

当前代码按顺序等待每个任务,尽管序列中的第三个任务最先完成。你希望代码在每个任务完成时执行处理(例如,Trace.WriteLine),而不等待其他任务。

解决方案

有几种不同的方法可以解决这个问题。在本配方中首先描述的方法是推荐的方法;另一种方法在“讨论”部分中有所描述。

最简单的解决方案是通过引入一个处理等待任务并处理其结果的更高级别 async 方法来重构代码。一旦将处理分离出来,代码就会显著简化:

async Task<int> DelayAndReturnAsync(int value)
{
  await Task.Delay(TimeSpan.FromSeconds(value));
  return value;
}

async Task AwaitAndProcessAsync(Task<int> task)
{
  int result = await task;
  Trace.WriteLine(result);
}

// This method now prints "1", "2", and "3".
async Task ProcessTasksAsync()
{
  // Create a sequence of tasks.
  Task<int> taskA = DelayAndReturnAsync(2);
  Task<int> taskB = DelayAndReturnAsync(3);
  Task<int> taskC = DelayAndReturnAsync(1);
  Task<int>[] tasks = new[] { taskA, taskB, taskC };

  IEnumerable<Task> taskQuery =
      from t in tasks select AwaitAndProcessAsync(t);
  Task[] processingTasks = taskQuery.ToArray();

  // Await all processing to complete
  await Task.WhenAll(processingTasks);
}

或者,可以像这样重写此代码:

async Task<int> DelayAndReturnAsync(int value)
{
  await Task.Delay(TimeSpan.FromSeconds(value));
  return value;
}

// This method now prints "1", "2", and "3".
async Task ProcessTasksAsync()
{
  // Create a sequence of tasks.
  Task<int> taskA = DelayAndReturnAsync(2);
  Task<int> taskB = DelayAndReturnAsync(3);
  Task<int> taskC = DelayAndReturnAsync(1);
  Task<int>[] tasks = new[] { taskA, taskB, taskC };

  Task[] processingTasks = tasks.Select(async t =>
  {
    var result = await t;
    Trace.WriteLine(result);
  }).ToArray();

  // Await all processing to complete
  await Task.WhenAll(processingTasks);
}

所示的重构是解决此问题最清晰和最便携的方式。请注意,它与原始代码略有不同。此解决方案将会并发执行任务处理,而原始代码将会逐个执行任务处理。通常这不是问题,但如果对您的情况不可接受,请考虑使用锁定(Recipe 12.2)或以下替代解决方案。

讨论

如果重构不是一种可接受的解决方案,那么还有一个替代方案。Stephen Toub 和 Jon Skeet 都开发了一个扩展方法,返回一个按顺序完成的任务数组。Stephen Toub 的解决方案可在 .NET 并行编程博客 上找到,Jon Skeet 的解决方案可在 他的编程博客 上找到。

提示

OrderByCompletion 扩展方法也可以在开源 AsyncEx 中找到,在 Nito.AsyncEx NuGet 包 中也是如此。

使用像 OrderByCompletion 这样的扩展方法可以尽量减少对原始代码的更改:

async Task<int> DelayAndReturnAsync(int value)
{
  await Task.Delay(TimeSpan.FromSeconds(value));
  return value;
}

// This method now prints "1", "2", and "3".
async Task UseOrderByCompletionAsync()
{
  // Create a sequence of tasks.
  Task<int> taskA = DelayAndReturnAsync(2);
  Task<int> taskB = DelayAndReturnAsync(3);
  Task<int> taskC = DelayAndReturnAsync(1);
  Task<int>[] tasks = new[] { taskA, taskB, taskC };

  // Await each one as they complete.
  foreach (Task<int> task in tasks.OrderByCompletion())
  {
    int result = await task;
    Trace.WriteLine(result);
  }
}

参见

Recipe 2.4 讲述了异步等待一系列任务完成。

2.7 避免为继续执行设置上下文

问题

当一个 async 方法在一个 await 后恢复时,默认情况下它会在相同的上下文中继续执行。如果该上下文是 UI 上下文,并且有大量的 async 方法在 UI 上下文中恢复执行,这可能会导致性能问题。

解决方案

要避免在上下文中继续执行,在 ConfigureAwait 的结果上 await 并传递 false 给它的 continueOnCapturedContext 参数:

async Task ResumeOnContextAsync()
{
  await Task.Delay(TimeSpan.FromSeconds(1));

  // This method resumes within the same context.
}

async Task ResumeWithoutContextAsync()
{
  await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);

  // This method discards its context when it resumes.
}

讨论

在 UI 线程上运行太多的继续执行会导致性能问题。这种性能问题很难诊断,因为不是单个方法导致系统变慢。相反,随着应用程序变得更加复杂,UI 性能开始遭受“成千上万次的纸疤”。

真正的问题是, UI 线程上的 多少 个继续执行是太多?没有明确的答案,但微软的 Lucian Wischik 公布了指南,用于 Universal Windows 团队:每秒大约一百个左右是可以接受的,但每秒约一千个就太多了。

最好在一开始就避免这个问题。对于每个你编写的 async 方法,如果它不需要恢复到原始上下文,那么使用 ConfigureAwait。这样做没有任何不利之处。

async 代码时,注意上下文也是一个好主意。通常,一个 async 方法应要么需要上下文(处理 UI 元素或 ASP.NET 请求/响应),要么不需要上下文(执行后台操作)。如果你有一个 async 方法,其中一部分需要上下文,另一部分不需要上下文,考虑将其拆分为两个(或更多) async 方法。这种方法有助于更好地将你的代码组织成层次。

参见

第一章 讲述了异步编程的简介。

2.8 异步任务方法中的异常处理

问题

异常处理是任何设计中的关键部分。设计能够处理成功情况是容易的,但直到它也能处理失败情况,设计才是正确的。幸运的是,处理 async Task 方法的异常是直接的。

解决方案

异常可以通过简单的 try/catch 捕获,就像你为同步代码所做的那样:

async Task ThrowExceptionAsync()
{
  await Task.Delay(TimeSpan.FromSeconds(1));
  throw new InvalidOperationException("Test");
}

async Task TestAsync()
{
  try
  {
    await ThrowExceptionAsync();
  }
  catch (InvalidOperationException)
  {
  }
}

async Task 方法中引发的异常会被放置在返回的 Task 上。只有在等待返回的 Task 时才会引发它们:

async Task ThrowExceptionAsync()
{
  await Task.Delay(TimeSpan.FromSeconds(1));
  throw new InvalidOperationException("Test");
}

async Task TestAsync()
{
  // The exception is thrown by the method and placed on the task.
  Task task = ThrowExceptionAsync();
  try
  {
    // The exception is re-raised here, where the task is awaited.
    await task;
  }
  catch (InvalidOperationException)
  {
    // The exception is correctly caught here.
  }
}

讨论

当从 async Task 方法中抛出异常时,该异常会被捕获并放置在返回的 Task 上。由于 async void 方法没有 Task 可以放置其异常,它们的行为会有所不同;如何捕获 async void 方法中的异常在 2.9 节 中有所涉及。

当您 await 一个故障的 Task 时,该任务上的第一个异常将被重新抛出。如果您熟悉重新抛出异常的问题,您可能会想到堆栈跟踪。请放心:当异常被重新抛出时,原始堆栈跟踪会被正确保留。

这种设置听起来有些复杂,但所有这些复杂性都是为了让简单的场景有简单的代码。大多数情况下,您的代码应该从它调用的异步方法中传播异常;它所需做的只是 await 来自该异步方法的返回任务,异常将会自然传播。

有些情况下(例如 Task.WhenAll),一个 Task 可能有多个异常,而 await 只会重新抛出第一个异常。参见 2.4 节,以查看处理所有异常的示例。

参见

2.4 节 讲述了等待多个任务的方法。

2.9 节 讲述了从 async void 方法捕获异常的技术。

7.2 节 讲述了从 async Task 方法抛出异常的单元测试。

2.9 处理异步 void 方法的异常

问题

您有一个 async void 方法,并且需要处理从该方法传播出的异常。

解决方案

没有很好的解决方案。如果可能的话,请将方法更改为返回 Task 而不是 void。在某些情况下,这样做是不可能的;例如,假设您需要对 ICommand 实现进行单元测试(该方法 必须 返回 void)。在这种情况下,您可以为 Execute 方法提供一个返回 Task 的重载:

sealed class MyAsyncCommand : ICommand
{
  async void ICommand.Execute(object parameter)
  {
    await Execute(parameter);
  }

  public async Task Execute(object parameter)
  {
    ... // Asynchronous command implementation goes here.
  }

  ... // Other members (CanExecute, etc.)
}

最好避免从 async void 方法中传播异常。如果必须使用 async void 方法,请考虑将其所有代码都包装在 try 块中,并直接处理异常。

处理 async void 方法异常的另一种解决方案是,当 async void 方法传播异常时,该异常会在该方法开始执行时处于活动状态的 SynchronizationContext 上引发。如果您的执行环境提供了 SynchronizationContext,则通常可以在全局范围内处理这些顶级异常。例如,WPF 具有 Application.DispatcherUnhandledException,Universal Windows 具有 Application.UnhandledException,而 ASP.NET 则具有 UseExceptionHandler 中间件。

也可以通过控制SynchronizationContext来处理async void方法的异常。编写自己的SynchronizationContext并不容易,但可以使用免费的Nito.AsyncEx NuGet 助手库中的AsyncContext类型。AsyncContext对于没有内置SynchronizationContext的应用程序特别有用,如控制台应用程序和 Win32 服务。下一个示例使用AsyncContext来运行并处理async void方法中的异常:

static class Program
{
  static void Main(string[] args)
  {
    try
    {
      AsyncContext.Run(() => MainAsync(args));
    }
    catch (Exception ex)
    {
      Console.Error.WriteLine(ex);
    }
  }

  // BAD CODE!!!
  // In the real world, do not use async void unless you have to.
  static async void MainAsync(string[] args)
  {
    ...
  }
}

讨论

更倾向于使用async Task而不是async void的一个原因是,返回Task的方法更容易进行测试。至少,通过使用返回Task的方法重载返回void的方法,可以得到一个可测试的 API 表面。

如果确实需要提供自己的SynchronizationContext类型(例如AsyncContext),请确保不要将该SynchronizationContext安装在不属于您的任何线程上。一般规则是,不应将此类型放置在已经具有SynchronizationContext的任何线程上(如 UI 或 ASP.NET 经典请求线程);也不应将SynchronizationContext放置在线程池线程上。控制台应用程序的主线程属于您,您手动创建的任何线程也属于您。

提示

AsyncContext类型位于Nito.AsyncEx NuGet 包中。

参见

2.8 菜谱涵盖了使用async Task方法进行异常处理。

7.3 菜谱涵盖了对async void方法进行单元测试。

2.10 创建一个 ValueTask

问题

您需要实现一个返回ValueTask<T>的方法。

解决方案

ValueTask<T>在通常存在同步结果且需要返回异步行为的情况下作为返回类型使用。作为一般规则,对于您自己的应用程序代码,应使用Task<T>作为返回类型,而不是ValueTask<T>。仅在分析显示您可以获得性能提升时,才考虑在您自己的应用程序中使用ValueTask<T>作为返回类型。尽管如此,确实有需要实现返回ValueTask<T>的情况。一个这样的情况是IAsyncDisposable,其DisposeAsync方法返回ValueTask。参见 11.6 菜谱以获取关于异步处理的更详细讨论。

实现返回ValueTask<T>的方法最简单的方式是像普通的async方法一样使用asyncawait

public async ValueTask<int> MethodAsync()
{
  await Task.Delay(100); // asynchronous work.
  return 13;
}

许多情况下,返回ValueTask<T>的方法能够立即返回值;在这种情况下,您可以使用ValueTask<T>构造函数进行优化,然后仅在必要时转发到慢速异步方法:

public ValueTask<int> MethodAsync()
{
  if (CanBehaveSynchronously)
    return new ValueTask<int>(13);
  return new ValueTask<int>(SlowMethodAsync());
}

private Task<int> SlowMethodAsync();

对于非泛型的 ValueTask,也可以采用类似的方法。在这里,使用 ValueTask 的默认构造函数返回一个成功完成的 ValueTask。下面的示例展示了一个只运行其异步处理逻辑一次的 IAsyncDisposable 实现;在后续调用中,DisposeAsync 方法会成功且同步地完成:

private Func<Task> _disposeLogic;

public ValueTask DisposeAsync()
{
  if (_disposeLogic == null)
    return default;

  // Note: this simple example is not threadsafe;
  //  if multiple threads call DisposeAsync,
  //  the logic could run more than once.
  Func<Task> logic = _disposeLogic;
  _disposeLogic = null;
  return new ValueTask(logic());
}

讨论

大多数方法应返回 Task<T>,因为消耗 Task<T> 比消耗 ValueTask<T> 更少出错。详见 2.11 消耗 ValueTask 的详细信息。

大多数情况下,如果你只是实现使用 ValueTaskValueTask<T> 的接口,那么可以简单地使用 asyncawait。更高级的实现是为了当你自己使用 ValueTask<T> 时。

本文涵盖的方法是创建 ValueTask<T>ValueTask 实例的更简单和更常见的方法。还有一种更适合更高级场景的方法,当你需要绝对最小化使用的分配时。这种更高级的方法允许你缓存或池化 IValueTaskSource<T> 实现,并在多个异步方法调用中重复使用它。要开始使用高级场景,请参阅 Microsoft docs 上的 ManualResetValueTaskSourceCore<T> 类型

另请参阅

2.11 使用 ValueTask 的限制 讨论了消耗 ValueTask<T>ValueTask 类型的限制。

11.6 异步处理 讨论了异步处理。

2.11 消耗 ValueTask

问题

你需要消耗 ValueTask<T> 的值。

解决方案

使用 await 是消耗 ValueTask<T>ValueTask 值最简单和常见的方法。大部分情况下,这就是你需要做的:

ValueTask<int> MethodAsync();

async Task ConsumingMethodAsync()
{
  int value = await MethodAsync();
}

在执行并发操作后,也可以执行 await 操作,就像处理 Task<T> 一样:

ValueTask<int> MethodAsync();

async Task ConsumingMethodAsync()
{
  ValueTask<int> valueTask = MethodAsync();
  ... // other concurrent work
  int value = await valueTask;
}

这两种方法都适合,因为 ValueTask 只被等待了一次。这是 ValueTask 的限制之一。

警告

只能一次性等待 ValueTaskValueTask<T>

要执行更复杂的操作,请通过调用 AsTaskValueTask<T> 转换为 Task<T>

ValueTask<int> MethodAsync();

async Task ConsumingMethodAsync()
{
  Task<int> task = MethodAsync().AsTask();
  ... // other concurrent work
  int value = await task;
  int anotherValue = await task;
}

可以放心地多次 await 一个 Task<T>。你也可以做其他事情,比如异步等待多个操作完成(参见 2.4 异步等待多个操作的方法):

ValueTask<int> MethodAsync();

async Task ConsumingMethodAsync()
{
  Task<int> task1 = MethodAsync().AsTask();
  Task<int> task2 = MethodAsync().AsTask();
  int[] results = await Task.WhenAll(task1, task2);
}

然而,对于每个 ValueTask<T>,只能调用一次 AsTask。通常的方法是立即将其转换为 Task<T>,然后忽略 ValueTask<T>。还要注意,不能同时 await 和调用 AsTask 同一个 ValueTask<T>

大多数情况下,代码应该立即 await 一个 ValueTask<T> 或将其转换为 Task<T>

讨论

ValueTask<T> 的其他属性适用于更高级的用法。它们与你可能熟悉的其他属性不太相似;特别是,ValueTask<T>.ResultTask<T>.Result 有更多限制。从 ValueTask<T> 同步检索结果的代码可以调用 ValueTask<T>.ResultValueTask<T>.GetAwaiter().GetResult(),但这些成员不能在 ValueTask<T> 完成之前调用。从 Task<T> 同步获取结果会阻塞调用线程,直到任务完成;ValueTask<T> 不提供这样的保证。

警告

ValueTaskValueTask<T> 同步获取结果只能在 ValueTask 完成后进行一次,并且不能再等待或转换为任务。

为了避免重复,当你的代码调用返回 ValueTaskValueTask<T> 的方法时,应立即 await 这个 ValueTask 或立即调用 AsTask 将其转换为 Task。这个简单的准则虽然不能涵盖所有高级场景,但大多数应用程序不会需要更多。

参见

示例 2.10 讲述了如何从你的方法中返回 ValueTask<T>ValueTask 类型的值。

2.4 和 2.5 的示例介绍了同时等待多个任务的方法。

第三章:异步流

异步流是一种异步接收多个数据项的方式。它们建立在异步可枚举(IAsyncEnumerable<T>)之上。异步可枚举是可枚举的异步版本;也就是说,它可以按需为消费者生成项目,并且每个项目可能是异步生成的。

我发现将异步流与可能更为熟悉的其他类型进行对比并考虑它们之间的差异非常有用。这帮助我记住何时使用异步流以及其他类型何时更合适。

异步流和 Task

使用Task<T>的标准异步方法仅足以异步处理单个数据值。一旦给定的Task<T>完成,就没了;单个Task<T>无法为其消费者提供超过一个T值。即使T是一个集合,该值也只能提供一次。有关使用asyncTask<T>的更多信息,请参阅“异步编程简介”和第二章。

Task<T>与异步流进行比较时,异步流更类似于可枚举。具体而言,IAsyncEnumerator<T>可以逐个提供任意数量的T值。与IEnumerator<T>类似,IAsyncEnumerator<T>的长度可以是无限的。

异步流和 IEnumerable

IAsyncEnumerable<T>,正如其名称所示,类似于IEnumerable<T>。也许这并不奇怪;它们都允许消费者逐个检索元素。不同之处在于名称:一个是异步的,另一个不是。

当您的代码遍历IEnumerable<T>时,它会阻塞,因为它从可枚举中检索每个元素。如果IEnumerable<T>代表某种 I/O 绑定操作,例如数据库查询或 API 调用,那么消费代码最终会阻塞在 I/O 上,这并不理想。IAsyncEnumerable<T>的工作方式与IEnumerable<T>完全相同,只是它异步检索每个下一个元素。

异步流和 Task<IEnumerable>

完全可以异步返回一个包含多个项目的集合;一个常见的例子是Task<List<T>>。但是,返回List<T>的异步方法只有一个return语句;在返回之前,必须完全填充集合。甚至返回Task<IEnumerable<T>>的方法可能会异步返回一个可枚举,但然后该可枚举会同步评估。请考虑 LINQ-to-Entities 具有ToListAsync LINQ 方法,该方法返回Task<List<T>>。当 LINQ 提供程序执行此操作时,它必须与数据库通信并获取所有匹配的响应,然后才能完成填充列表并返回它。

Task<IEnumerable<T>> 的限制在于它不能在获取到项目时返回它们;如果返回一个集合,它必须将所有项目加载到内存中,填充集合,然后一次性返回整个集合。即使返回 LINQ 查询,它也可以异步地构建该查询,但一旦返回查询,每个项目都是同步检索的。IAsyncEnumerable<T> 也异步返回多个项目,但不同之处在于 IAsyncEnumerable<T> 可以对每个返回的项目进行异步操作。这是真正的异步项目流。

异步流和 IObservable<T>

观察者是异步流的真正概念;它们一次产生一个通知,支持真正的异步生产(无阻塞)。但 IObservable<T> 的消费模式与 IAsyncEnumerable<T> 完全不同。有关 IObservable<T> 的更多详细信息,请参见 第六章。

要消费 IObservable<T>,代码需要定义一个类似 LINQ 的查询,通过该查询将可观察通知流动起来,然后订阅可观察对象以开始流动。在处理可观察对象时,代码首先定义如何对传入通知做出 反应,然后才将它们打开(因此称为“响应式”)。相比之下,消费 IAsyncEnumerable<T> 与消费 IEnumerable<T> 非常相似,只是消费是异步的。

还存在背压问题;System.Reactive 中的所有通知都是同步的,因此一旦将一个项目通知发送给其订阅者,可观察对象将继续执行并检索下一个要发布的项目,可能会再次调用 API。如果消费代码是异步消费流(即在每个通知到达时执行某些异步操作),则可观察对象将超前于消费代码。

关于它们之间的区别的一种很好的思考方式是 IObservable<T> 是推送式的,而 IAsyncEnumerable<T> 是拉取式的。可观察流将向您的代码推送通知,但异步流会被动地让您的代码(异步地)从中拉取数据项。只有在消费代码请求下一个项目时,可观察流才会恢复执行。

总结

可能会有一个理论示例很有用。许多 API 都接受 offsetlimit 参数以启用结果的分页。假设我们想要定义一个方法,从进行分页的 API 中检索结果,并且我们希望我们的方法处理分页,使得我们的高级方法不必处理它。

如果我们的方法返回 Task<T>,我们只能返回单个 T。对于调用 API 并返回其结果的单个调用来说这是可以接受的,但如果我们希望我们的方法多次调用 API,则这种返回类型效果不佳。

如果我们的方法返回IEnumerable<T>,我们可以创建一个循环,通过多次调用来分页 API 结果。每次方法调用 API 时,它会使用yield return返回该页的结果。只有在枚举继续时才需要进一步的 API 调用。不幸的是,返回IEnumerable<T>的方法无法是异步的,因此我们所有的 API 调用都被迫是同步的。

如果我们的方法返回Task<List<T>>,那么我们可以通过调用 API 异步分页的循环。然而,代码无法在获取响应时返回每个项目;它必须积累所有结果并一次性返回它们。

如果我们的方法返回IObservable<T>,我们可以使用System.Reactive来实现一个可观察的流,该流在订阅时开始请求,并在获取每个项目时发布。这种抽象是基于推送的;它给消费代码的表现形式是 API 结果被推送给它们,这样处理起来更加笨拙。IObservable<T>更适合接收和响应 WebSocket/SignalR 消息等场景。

如果我们的方法返回IAsyncEnumerable<T>,我们可以使用awaityield return创建一个真正的基于拉取的异步流。IAsyncEnumerable<T>是这种场景的天然选择。

表格 3-1 总结了常见类型的不同角色。

表格 3-1 类型分类

类型 单个或多个值 异步或同步 推送或拉取
T 单个值 同步 不适用
IEnumerable<T> 多个值 同步 不适用
Task<T> 单个值 异步 拉取
IAsyncEnumerable<T> 多个值 异步 拉取
IObservable<T> 单个或多个 异步 推送
警告

当本书付梓时,.NET Core 3.0 仍处于测试版阶段,因此关于异步流的细节可能会有所变化。

3.1 创建异步流

问题

你需要返回多个值,每个值可能需要一些异步工作。这一点通常从以下两个路径之一达到:

  • 你有多个值要返回(作为IEnumerable<T>),然后需要添加异步工作。

  • 你有一个单一的异步返回(作为Task<T>),然后需要添加其他返回值。

解决方案

从方法返回多个值可以通过yield return实现,而异步方法则使用asyncawait。有了异步流,你可以结合这两者;只需使用返回类型IAsyncEnumerable<T>

async IAsyncEnumerable<int> GetValuesAsync()
{
  await Task.Delay(1000); // some asynchronous work
  yield return 10;
  await Task.Delay(1000); // more asynchronous work
  yield return 13;
}

这个简单的例子说明了如何使用awaityield return创建异步流。

一个更真实的例子是异步枚举 API 所有使用分页参数的结果:

async IAsyncEnumerable<string> GetValuesAsync(HttpClient client)
{
  int offset = 0;
  const int limit = 10;
  while (true)
  {
    // Get the current page of results and parse them.
    string result = await client.GetStringAsync(
        $"https://example.com/api/values?offset={offset}&limit={limit}");
    string[] valuesOnThisPage = result.Split('\n');

    // Produce the results for this page.
    foreach (string value in valuesOnThisPage)
      yield return value;

    // If this is the last page, we're done.
    if (valuesOnThisPage.Length != limit)
      break;

    // Otherwise, proceed to the next page.
    offset += limit;
  }
}

GetValuesAsync 开始时,它会对第一页的数据进行异步请求,然后生成第一个元素。当请求第二个元素时,GetValuesAsync 会立即生成,因为它也在同一页的数据中。下一个元素也在该页中,依此类推,直到 10 个元素。然后,当请求第 11 个元素时,valuesOnThisPage 中的所有值都已生成,因此在第一页上没有更多的元素了。GetValuesAsync 将继续执行其 while 循环,转到下一页,进行第二页数据的异步请求,接收新的一批值,然后生成第 11 个元素。

讨论

自从引入 asyncawait 以来,用户一直在思考如何与 yield return 结合使用。多年来,这是不可能的,但是异步流现在已经将这一能力带到了 C# 和现代版本的 .NET 中。

在更现实的示例中,您可能会注意到只有部分结果需要进行异步处理。在示例中,页面长度为 10 时,大约每 10 个元素中只有 1 个需要进行异步处理。如果页面大小为 20,则每 20 个元素中只有 1 个需要异步处理。

这是异步流的一种常见模式。对于许多流来说,大多数异步迭代实际上是同步的;异步流只是允许以异步方式检索任何下一个项。异步流旨在同时考虑异步和同步代码;这就是为什么异步流建立在 ValueTask<T> 上的原因。通过在底层使用 ValueTask<T>,异步流最大化了其效率,无论是同步还是异步地检索项目。有关 ValueTask<T> 更多信息和适用场景,请参见 食谱 2.10。

当您实现异步流时,请考虑支持取消操作。有关异步流取消的详细讨论,请参见 食谱 3.4。有些情况下并不需要真正的取消;消费代码始终可以选择不检索下一个元素。如果没有取消的外部源,则这是一个完全可以接受的方法。如果您有一个异步流,在该流中希望取消异步流,即使在获取下一个元素时也要支持正确的取消操作,则应使用 CancellationToken

参见

食谱 3.2 讨论了如何消费异步流。

食谱 3.4 讨论了如何处理异步流的取消。

食谱 2.10 更详细地介绍了 ValueTask<T> 的使用场景。

3.2 消费异步流

问题

你需要处理异步流的结果,也称为异步可枚举。

解决方案

通过await来消耗异步操作,通常通过foreach来消耗可枚举对象。将这两者结合到await foreach中来消耗异步可枚举对象。例如,给定一个异步可枚举对象,用于分页 API 响应,你可以消耗它并将每个元素写入控制台:

IAsyncEnumerable<string> GetValuesAsync(HttpClient client);

public async Task ProcessValueAsync(HttpClient client)
{
  await foreach (string value in GetValuesAsync(client))
  {
    Console.WriteLine(value);
  }
}

在这里发生的概念上,是调用GetValuesAsync,它返回一个IAsyncEnumerable<T>。然后foreach从该异步可枚举对象创建一个异步枚举器。异步枚举器在逻辑上类似于常规枚举器,只是它们的“获取下一个元素”的操作可能是异步的。因此,await foreach将等待下一个元素到达或异步枚举器完成。如果元素到达,await foreach将执行其循环体;如果异步枚举器完成,则循环将退出。

对每个元素进行异步处理也是很自然的:

IAsyncEnumerable<string> GetValuesAsync(HttpClient client);

public async Task ProcessValueAsync(HttpClient client)
{
  await foreach (string value in GetValuesAsync(client))
  {
    await Task.Delay(100); // asynchronous work
    Console.WriteLine(value);
  }
}

在这种情况下,await foreach不会在循环体完成之前继续下一个元素。因此,await foreach将异步接收第一个元素,然后异步执行该第一个元素的循环体,然后异步接收下一个元素,然后异步执行该下一个元素的循环体,依此类推。

await foreach中隐藏了一个await:即“获取下一个元素”的操作被等待。通过使用ConfigureAwait(false),你可以避免在常规await中捕获上下文,正如 2.7 节中所述。异步流还支持ConfigureAwait(false),它传递给隐藏的await语句中使用的。

IAsyncEnumerable<string> GetValuesAsync(HttpClient client);

public async Task ProcessValueAsync(HttpClient client)
{
  await foreach (string value in GetValuesAsync(client).ConfigureAwait(false))
  {
    await Task.Delay(100).ConfigureAwait(false); // asynchronous work
    Console.WriteLine(value);
  }
}

讨论

await foreach是消耗异步流的最自然方式。语言支持ConfigureAwait(false)来避免在await foreach中的上下文。

可以传入取消令牌;由于异步流的复杂性较高,因此这是更高级的内容,你可以在 3.4 节中找到相关内容。

虽然使用await foreach来消耗异步流既可能又自然,但是也有大量的异步 LINQ 操作符可用;其中一些较受欢迎的操作符在 3.3 节中有所涵盖。

await foreach的主体可以是同步的,也可以是异步的。对于特定的异步示例,这对于在处理其他流抽象(如IObservable<T>)时要正确处理的事情要困难得多。这是因为可观察的订阅必须是同步的,但await foreach允许自然的异步处理。

await foreach生成一个await用于“获取下一个元素”的操作;它还生成一个await用于异步处理可枚举对象的释放。

参见

Recipe 3.1 涵盖了生成异步流。

Recipe 3.4 涵盖了处理异步流的取消。

Recipe 3.3 涵盖了异步流的常见 LINQ 方法。

Recipe 11.6 涵盖了异步处理。

3.3 使用 LINQ 处理异步流

问题

您希望使用经过良好定义和经过充分测试的操作符处理异步流。

解决方案

IEnumerable<T>具有 LINQ to Objects,而IObservable<T>具有 LINQ to Events。这两者都有定义操作符的扩展方法库,您可以使用这些方法构建查询。IAsyncEnumerable<T>也具有 LINQ 支持,由.NET 社区在System.Linq.Async NuGet 包中提供。

举个例子,关于 LINQ 的一个常见问题是如何在Where的谓词是异步的情况下使用Where操作符。换句话说,您希望根据一些异步条件过滤序列,例如,您需要查找数据库或 API 中的每个元素,以查看它是否应包含在结果序列中。Where无法处理异步条件,因为Where操作符要求其委托返回即时、同步的答案。

异步流有一个支持库,定义了许多有用的操作符。在下面的示例中,WhereAwait是正确的选择:

IAsyncEnumerable<int> values = SlowRange().WhereAwait(
    async value =>
    {
      // Do some asynchronous work to determine
      //  if this element should be included.
      await Task.Delay(10);
      return value % 2 == 0;
    });

await foreach (int result in values)
{
  Console.WriteLine(result);
}

// Produce sequence that slows down as it progresses.
async IAsyncEnumerable<int> SlowRange()
{
  for (int i = 0; i != 10; ++i)
  {
    await Task.Delay(i * 100);
    yield return i;
  }
}

用于异步流的 LINQ 操作符也包括同步版本;将同步的Where(或Select,或其他操作符)应用于异步流是有意义的。结果仍然是一个异步流:

IAsyncEnumerable<int> values = SlowRange().Where(
    value => value % 2 == 0);

await foreach (int result in values)
{
  Console.WriteLine(result);
}

您所有熟悉的 LINQ 操作符都在这里:WhereSelectSelectMany,甚至Join。现在大多数 LINQ 操作符也接受异步委托,就像上面的WhereAwait示例一样。

讨论

异步流是基于拉取的,因此没有像可观察对象那样的与时间相关的操作符。在这个世界中,ThrottleSample没有意义,因为元素是按需从异步流中拉取出来的。

用于异步流的 LINQ 方法也对常规可枚举对象有用。如果您发现自己处于这种情况下,您可以在任何IEnumerable<T>上调用ToAsyncEnumerable(),然后您将拥有一个异步流接口,您可以使用WhereAwaitSelectAwait和其他支持异步委托的操作符。

在深入研究之前,有必要谈一下命名。本示例中使用WhereAwait作为Where的异步等价物。当您探索用于异步流的 LINQ 操作符时,您会发现一些以Async结尾,而另一些以Await结尾。以Async结尾的操作符返回一个可等待的对象;它们代表一个常规值,而不是一个异步序列。以Await结尾的操作符接受一个异步委托;它们名称中的Await意味着它们实际上在您传递给它们的委托上执行了一个await

我们已经看过带有 Await 后缀的 WhereWhereAwait 的示例。Async 后缀仅适用于终结操作符——提取某些值或执行某些计算并返回异步标量值而不是异步序列的操作符。终结操作符的示例是 CountAsync,异步流版本的 Count,它可以计算与某些谓词匹配的元素数:

int count = await SlowRange().CountAsync(
    value => value % 2 == 0);

该谓词也可以是异步的,在这种情况下,您将使用 CountAwaitAsync 操作符,因为它既接受异步委托(它将await)又生成单个终端值,即计数:

int count = await SlowRange().CountAwaitAsync(
    async value =>
    {
      await Task.Delay(10);
      return value % 2 == 0;
    });

简而言之,可以接受委托的操作符有两个名称:一个带有 Await 后缀,一个没有。此外,返回终端值而不是异步流的操作符以 Async 结尾。如果一个操作符既接受异步委托,返回终端值,则它具有这两个后缀。

提示

用于异步流的 LINQ 操作符位于 NuGet 包 System.Linq.Async 中。还可以在 NuGet 包 System.Interactive.Async 中找到用于异步流的其他 LINQ 操作符。

参见

配方 3.1 涵盖了生成异步流。

配方 3.2 涵盖了消费异步流。

3.4 异步流与取消

问题

您需要一种取消异步流的方法。

解决方案

并非所有的异步流都需要取消。当达到条件时,可以简单地停止枚举。如果这是唯一需要的“取消”,那么不需要真正的取消,就像以下示例所示:

await foreach (int result in SlowRange())
{
  Console.WriteLine(result);
  if (result >= 8)
    break;
}

// Produce sequence that slows down as it progresses.
async IAsyncEnumerable<int> SlowRange()
{
  for (int i = 0; i != 10; ++i)
  {
    await Task.Delay(i * 100);
    yield return i;
  }
}

话虽如此,取消异步流通常很有用,因为某些操作符将取消标记传递给它们的源流。在这种情况下,您希望使用 CancellationToken 来停止外部代码中的 await foreach

返回 IAsyncEnumerable<T>async 方法可以通过定义标记有 EnumeratorCancellation 属性的参数来接受取消令牌。然后可以自然地使用该令牌,通常是通过将其传递给其他接受取消令牌的 API,如下所示:

using var cts = new CancellationTokenSource(500);
CancellationToken token = cts.Token;
await foreach (int result in SlowRange(token))
{
  Console.WriteLine(result);
}

// Produce sequence that slows down as it progresses.
async IAsyncEnumerable<int> SlowRange(
 [EnumeratorCancellation] CancellationToken token = default)
{
  for (int i = 0; i != 10; ++i)
  {
    await Task.Delay(i * 100, token);
    yield return i;
  }
}

讨论

此处的示例解决方案直接将 CancellationToken 传递给返回异步枚举器的方法。这是最常见的用法。

还有其他情景,您的代码将获得一个异步枚举器,并希望对其使用CancellationToken。在启动可枚举对象的新枚举时使用取消令牌是有意义的。可枚举对象本身是通过SlowRange方法定义的,但直到被消费之前都不会启动。甚至有些情况下,应该为可枚举对象的不同枚举传递不同的取消令牌。

简言之,可枚举对象本身是不可取消的,但由此创建的枚举器是可以取消的。这是一个不常见但重要的用例,也是为什么异步流支持WithCancellation扩展方法,您可以使用它将CancellationToken附加到异步流的特定迭代中。

async Task ConsumeSequence(IAsyncEnumerable<int> items)
{
  using var cts = new CancellationTokenSource(500);
  CancellationToken token = cts.Token;
  await foreach (int result in items.WithCancellation(token))
  {
    Console.WriteLine(result);
  }
}

// Produce sequence that slows down as it progresses.
async IAsyncEnumerable<int> SlowRange(
 [EnumeratorCancellation] CancellationToken token = default)
{
  for (int i = 0; i != 10; ++i)
  {
    await Task.Delay(i * 100, token);
    yield return i;
  }
}

await ConsumeSequence(SlowRange());

通过EnumeratorCancellation参数属性,编译器负责将令牌从WithCancellation传递到标记为EnumeratorCancellationtoken参数,现在取消请求会导致await foreach在处理了少量项目后引发OperationCanceledException

WithCancellation扩展方法不会阻止ConfigureAwait(false)。这两个扩展方法可以链式使用:

async Task ConsumeSequence(IAsyncEnumerable<int> items)
{
  using var cts = new CancellationTokenSource(500);
  CancellationToken token = cts.Token;
  await foreach (int result in items
      .WithCancellation(token).ConfigureAwait(false))
  {
    Console.WriteLine(result);
  }
}

另请参阅

Recipe 3.1 讲述了生成异步流。

Recipe 3.2 讲述了消费异步流。

第十章 讲述了跨多种技术的协作取消。

第四章:并行基础

本章涵盖了并行编程的模式。并行编程用于拆分 CPU 绑定的工作并将其分配给多个线程。这些并行处理的示例仅考虑 CPU 绑定的工作。如果你有自然异步操作(如 I/O 绑定的工作),希望并行执行,请参阅第二章,特别是食谱 2.4。

本章涵盖的并行处理抽象是任务并行库(TPL)的一部分。TPL 是内置于 .NET 框架中的。

4.1 并行处理数据

问题

你有一组数据,并且需要对数据的每个元素执行相同的操作。这个操作是 CPU 绑定的,可能需要一些时间。

解决方案

Parallel 类型包含一个专门为此问题设计的 ForEach 方法。以下示例接受一组矩阵并对它们进行旋转:

void RotateMatrices(IEnumerable<Matrix> matrices, float degrees)
{
  Parallel.ForEach(matrices, matrix => matrix.Rotate(degrees));
}

有些情况下,你可能希望尽早停止循环,例如遇到无效值时。以下示例反转每个矩阵,但如果遇到无效矩阵,它将中止循环:

void InvertMatrices(IEnumerable<Matrix> matrices)
{
  Parallel.ForEach(matrices, (matrix, state) =>
  {
    if (!matrix.IsInvertible)
      state.Stop();
    else
      matrix.Invert();
  });
}

此代码使用 ParallelLoopState.Stop 来停止循环,防止进一步调用循环体。请注意,这是一个并行循环,因此可能已经在运行其他循环体调用,包括当前项之后的项目。在这个代码示例中,如果第三个矩阵不可逆,循环将被停止,不会处理新的矩阵,但其他矩阵(如第四和第五个)可能已经在处理中。

更常见的情况是希望能够取消并行循环。这与停止循环不同;停止循环是从循环内部停止,而取消是从循环外部取消。举例来说,取消按钮可以取消 CancellationTokenSource,从而取消并行循环,如下代码示例:

void RotateMatrices(IEnumerable<Matrix> matrices, float degrees,
    CancellationToken token)
{
  Parallel.ForEach(matrices,
      new ParallelOptions { CancellationToken = token },
      matrix => matrix.Rotate(degrees));
}

需要注意的一点是,每个并行任务可能在不同的线程上运行,因此任何共享状态必须受到保护。以下示例反转每个矩阵并计算无法反转的矩阵的数量:

// Note: this is not the most efficient implementation.
// This is just an example of using a lock to protect shared state.
int InvertMatrices(IEnumerable<Matrix> matrices)
{
  object mutex = new object();
  int nonInvertibleCount = 0;
  Parallel.ForEach(matrices, matrix =>
  {
    if (matrix.IsInvertible)
    {
      matrix.Invert();
    }
    else
    {
      lock (mutex)
      {
        ++nonInvertibleCount;
      }
    }
  });
  return nonInvertibleCount;
}

讨论

Parallel.ForEach 方法允许对值序列进行并行处理。类似的解决方案是并行 LINQ(PLINQ),它提供了与 LINQ 类似的语法和大部分相同的功能。Parallel 和 PLINQ 之间的一个区别是,PLINQ 假定可以使用计算机上的所有核心,而 Parallel 将动态响应 CPU 条件的变化。

Parallel.ForEach 是一个并行的 foreach 循环。如果需要执行并行的 for 循环,Parallel 类还支持 Parallel.For 方法。如果你有多个数据数组都使用相同的索引,Parallel.For 尤其有用。

参见

食谱 4.2 包括并行聚合一系列值,包括求和和平均值。

食谱 4.5 介绍了 PLINQ 的基础知识。

第十章涵盖了取消。

4.2 并行聚合

问题

在并行操作结束时,您需要对结果进行聚合。聚合的示例包括对值求和或找到它们的平均值。

解决方案

Parallel类通过本地值的概念支持聚合,这些变量在并行循环内部存在。这意味着循环体可以直接访问该值,而无需同步。当循环准备好聚合每个本地结果时,它会使用localFinally委托来执行。请注意,localFinally委托需要同步访问保存最终结果的变量。以下是并行求和的示例:

// Note: this is not the most efficient implementation.
// This is just an example of using a lock to protect shared state.
int ParallelSum(IEnumerable<int> values)
{
  object mutex = new object();
  int result = 0;
  Parallel.ForEach(source: values,
      localInit: () => 0,
      body: (item, state, localValue) => localValue + item,
      localFinally: localValue =>
      {
        lock (mutex)
          result += localValue;
      });
  return result;
}

并行 LINQ 比Parallel类具有更自然的聚合支持:

int ParallelSum(IEnumerable<int> values)
{
  return values.AsParallel().Sum();
}

好吧,这有点取巧,因为 PLINQ 内置支持许多常见操作符(例如Sum)。PLINQ 还通过Aggregate操作符支持通用聚合:

int ParallelSum(IEnumerable<int> values)
{
  return values.AsParallel().Aggregate(
      seed: 0,
      func: (sum, item) => sum + item
  );
}

讨论

如果您已经在使用Parallel类,可能希望使用其聚合支持。否则,在大多数场景中,PLINQ 支持更具表现力且代码更短。

另请参阅

食谱 4.5 介绍了 PLINQ 的基础知识。

4.3 并行调用

问题

您有许多方法可以并行调用,这些方法(大多数)彼此独立。

解决方案

Parallel类包含一个简单的Invoke成员,专为这种场景设计。以下示例将数组分成两半,并分别处理每一半:

void ProcessArray(double[] array)
{
  Parallel.Invoke(
      () => ProcessPartialArray(array, 0, array.Length / 2),
      () => ProcessPartialArray(array, array.Length / 2, array.Length)
  );
}

void ProcessPartialArray(double[] array, int begin, int end)
{
  // CPU-intensive processing...
}

如果要调用的次数直到运行时才知道,则还可以将委托数组传递给Parallel.Invoke方法:

void DoAction20Times(Action action)
{
  Action[] actions = Enumerable.Repeat(action, 20).ToArray();
  Parallel.Invoke(actions);
}

Parallel.Invoke支持与Parallel类的其他成员一样的取消:

void DoAction20Times(Action action, CancellationToken token)
{
  Action[] actions = Enumerable.Repeat(action, 20).ToArray();
  Parallel.Invoke(new ParallelOptions { CancellationToken = token }, actions);
}

讨论

Parallel.Invoke对于简单的并行调用是一个很好的解决方案。请注意,如果要为每个输入数据项调用一个动作(请改用Parallel.ForEach),或者如果每个动作产生一些输出(请改用 Parallel LINQ),那么它将不是一个完美的选择。

另请参阅

食谱 4.1 介绍了Parallel.ForEach,它为每个数据项调用一个动作。

食谱 4.5 涵盖了并行 LINQ。

4.4 动态并行性

问题

您有一个更复杂的并行情况,其中并行任务的结构和数量取决于仅在运行时可知的信息。

解决方案

任务并行库(TPL)以Task类型为中心。Parallel类和并行 LINQ 只是强大的Task的便利包装。当您需要动态并行性时,直接使用Task类型是最简单的。

下面是一个示例,其中需要对二叉树的每个节点进行一些昂贵的处理。直到运行时才知道树的结构,因此这是动态并行性的一个良好场景。Traverse 方法处理当前节点,然后创建两个子任务,分别处理节点下面的两个分支(对于本示例,假设必须先处理父节点再处理子节点)。ProcessTree 方法通过创建顶级父任务并等待其完成来启动处理:

void Traverse(Node current)
{
  DoExpensiveActionOnNode(current);
  if (current.Left != null)
  {
    Task.Factory.StartNew(
        () => Traverse(current.Left),
        CancellationToken.None,
        TaskCreationOptions.AttachedToParent,
        TaskScheduler.Default);
  }
  if (current.Right != null)
  {
    Task.Factory.StartNew(
        () => Traverse(current.Right),
        CancellationToken.None,
        TaskCreationOptions.AttachedToParent,
        TaskScheduler.Default);
  }
}

void ProcessTree(Node root)
{
  Task task = Task.Factory.StartNew(
      () => Traverse(root),
      CancellationToken.None,
      TaskCreationOptions.None,
      TaskScheduler.Default);
  task.Wait();
}

AttachedToParent 标志确保每个分支的任务与其父节点的任务链接在一起。这创建了任务实例之间与树节点中父/子关系相对应的父/子关系。父任务执行其委托,然后等待其子任务完成。子任务中的异常随后从子任务传播到其父任务。因此,ProcessTree 可以通过对树根上的单个 Task 调用 Wait 来等待整个树的任务。

如果没有父/子关系的情况,可以通过使用任务继续将任何任务安排在另一个任务之后执行。继续是一个单独的任务,在原始任务完成时执行:

Task task = Task.Factory.StartNew(
    () => Thread.Sleep(TimeSpan.FromSeconds(2)),
    CancellationToken.None,
    TaskCreationOptions.None,
    TaskScheduler.Default);
Task continuation = task.ContinueWith(
    t => Trace.WriteLine("Task is done"),
    CancellationToken.None,
    TaskContinuationOptions.None,
    TaskScheduler.Default);
// The "t" argument to the continuation is the same as "task".

讨论

CancellationToken.NoneTaskScheduler.Default 在上面的代码示例中被使用。取消令牌在 Recipe 10.2 中有详细介绍,任务调度器则在 Recipe 13.3 中有介绍。明确指定 StartNewContinueWith 使用的 TaskScheduler 是个不错的主意。

这种父子任务的安排在动态并行性中很常见,尽管不是必需的。同样可以将每个新任务存储在线程安全的集合中,然后使用 Task.WaitAll 等待它们全部完成。

警告

使用 Task 进行并行处理与使用 Task 进行异步处理完全不同。

Task 类型在并发编程中有两个用途:它可以是并行任务或异步任务。并行任务可能使用阻塞成员,例如 Task.WaitTask.ResultTask.WaitAllTask.WaitAny。并行任务通常也使用 AttachedToParent 在任务之间创建父/子关系。应使用 Task.RunTask.Factory.StartNew 创建并行任务。

相反地,异步任务应避免使用阻塞成员,而应偏向于使用 awaitTask.WhenAllTask.WhenAny。异步任务不应使用 AttachedToParent,但它们可以通过等待另一个任务来形成一种隐式的父/子关系。

参见

Recipe 4.3 描述了如何在并行工作开始时并行调用一系列方法。

4.5 并行 LINQ

问题

您需要对数据序列进行并行处理,以生成另一个数据序列或该数据的摘要。

解决方案

大多数开发人员都熟悉 LINQ,您可以使用它来对序列进行拉取式计算。并行 LINQ(PLINQ)通过并行处理扩展了这种 LINQ 支持。

PLINQ 在流式场景中表现良好,当您有一系列输入并产生一系列输出时。以下是一个简单的例子,仅将序列中的每个元素乘以二(实际场景比简单的乘法更加 CPU 密集):

IEnumerable<int> MultiplyBy2(IEnumerable<int> values)
{
  return values.AsParallel().Select(value => value * 2);
}

示例可以以任何顺序生成其输出;这是并行 LINQ 的默认行为。您还可以指定要保留的顺序。下面的例子仍然是并行处理的,但保留了原始顺序:

IEnumerable<int> MultiplyBy2(IEnumerable<int> values)
{
  return values.AsParallel().AsOrdered().Select(value => value * 2);
}

并行 LINQ 的另一个自然用途是并行聚合或汇总数据。以下代码执行了并行求和操作:

int ParallelSum(IEnumerable<int> values)
{
  return values.AsParallel().Sum();
}

讨论

Parallel 类在许多场景下表现良好,但在聚合或将一个序列转换为另一个序列时,PLINQ 代码更简单。请记住,与 PLINQ 相比,Parallel 类对系统上的其他进程更加友好;尤其是在服务器机器上进行并行处理时,这是一个考虑因素。

PLINQ 提供了许多运算符的并行版本,包括过滤器(Where)、投影(Select)以及各种聚合,如 SumAverage 和更通用的 Aggregate。总体而言,您可以使用普通 LINQ 可以做的任何事情,也可以使用 PLINQ 并行处理。如果您有现有的 LINQ 代码,可以受益于并行运行,那么 PLINQ 是一个很好的选择。

参见

配方 4.1 讲述了如何使用 Parallel 类来对序列中的每个元素执行代码。

配方 10.5 讲述了如何取消 PLINQ 查询。

第五章:数据流基础

TPL Dataflow 是一个强大的库,它允许你创建一个网格或管道,然后(异步地)通过它发送数据。Dataflow 是一种非常声明式的编程风格:通常情况下,你先完全定义网格,然后开始处理数据。网格最终成为一个结构,通过它你的数据流动。这需要你用一种不同的方式来思考你的应用,但一旦你跨越了这一步,数据流就变成了许多场景的自然选择。

每个网格由相互链接的各种块组成。单个块很简单,负责数据处理的一个步骤。当块完成对其数据的处理时,它会将结果传递给任何链接的块。

要使用 TPL Dataflow,在你的应用程序中安装 NuGet 包 System.Threading.Tasks.Dataflow

5.1 链接块

问题

你需要将数据流块链接到彼此以创建一个网格。

解决方案

TPL Dataflow 库提供的块仅定义了最基本的成员。许多有用的 TPL Dataflow 方法实际上是扩展方法。LinkTo 扩展方法提供了一种将数据流块连接在一起的简单方法:

var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
var subtractBlock = new TransformBlock<int, int>(item => item - 2);

// After linking, values that exit multiplyBlock will enter subtractBlock.
multiplyBlock.LinkTo(subtractBlock);

默认情况下,链接的数据流块仅传播数据;它们不传播完成状态(或错误)。如果你的数据流是线性的(像一个管道),那么你可能希望传播完成状态。要传播完成状态(和错误),你可以在链接上设置 PropagateCompletion 选项:

var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
var subtractBlock = new TransformBlock<int, int>(item => item - 2);

var options = new DataflowLinkOptions { PropagateCompletion = true };
multiplyBlock.LinkTo(subtractBlock, options);

...

// The first block's completion is automatically propagated to the second block.
multiplyBlock.Complete();
await subtractBlock.Completion;

讨论

一旦链接,数据将自动从源块流向目标块。PropagateCompletion 选项可以在传递数据的同时传递完成状态;然而,在管道的每一步中,故障块会将其异常传播给下一个块,并封装在 AggregateException 中。因此,如果你有一个传播完成的长管道,原始错误可能会嵌套在多个 AggregateException 实例中。AggregateException 有几个成员,例如 Flatten,可以帮助处理这种情况中的错误。

可以用多种方式链接数据流块;你的网格可以有分支和汇合甚至循环。然而,对于大多数场景,简单的线性管道就足够了。我们主要处理管道(并简要涵盖分支);更高级的场景超出了本书的范围。

DataflowLinkOptions类型为您提供了几种不同的选项,您可以在链接上设置这些选项(例如在此解决方案中使用的PropagateCompletion选项),并且LinkTo重载还可以接受一个谓词,您可以使用它来过滤通过链接的数据。如果数据未通过过滤器,则不会被丢弃。通过过滤器的数据通过该链接传输;未通过过滤器的数据尝试通过备用链接传输,并且如果没有其他链接可供其使用,则留在块中。如果数据项在块中被卡住,那么该块将不会生成任何其他数据项;直到移除该数据项为止,整个块都将处于停滞状态。

另请参阅

菜谱 5.2 涵盖了沿着链路传播错误的方法。

菜谱 5.3 介绍了如何移除块之间的链接。

菜谱 8.8 介绍了如何将数据流块链接到 System.Reactive 的可观测流。

5.2 传播错误

问题

您需要一种方法来响应数据流网格中可能发生的错误。

解决方案

如果传递给数据流块的委托引发异常,那么该块将进入故障状态。当块处于故障状态时,它将丢弃所有数据(并停止接受新数据)。下面的代码中的块永远不会生成任何输出数据;第一个值引发异常,第二个值则被丢弃:

var block = new TransformBlock<int, int>(item =>
{
  if (item == 1)
    throw new InvalidOperationException("Blech.");
  return item * 2;
});
block.Post(1);
block.Post(2);

要捕获数据流块的异常,您应该awaitCompletion属性。Completion属性返回一个Task,当块完成时完成,如果块故障,则Completion任务也将故障:

try
{
  var block = new TransformBlock<int, int>(item =>
  {
    if (item == 1)
      throw new InvalidOperationException("Blech.");
    return item * 2;
  });
  block.Post(1);
  await block.Completion;
}
catch (InvalidOperationException)
{
  // The exception is caught here.
}

当使用PropagateCompletion链接选项传播完成时,错误也会被传播。但是,异常会被包装在AggregateException中传递到下一个块。以下示例从管道末端捕获异常,因此如果从先前的块传播异常,则会捕获AggregateException

try
{
  var multiplyBlock = new TransformBlock<int, int>(item =>
  {
    if (item == 1)
      throw new InvalidOperationException("Blech.");
    return item * 2;
  });
  var subtractBlock = new TransformBlock<int, int>(item => item - 2);
  multiplyBlock.LinkTo(subtractBlock,
      new DataflowLinkOptions { PropagateCompletion = true });
  multiplyBlock.Post(1);
  await subtractBlock.Completion;
}
catch (AggregateException)
{
  // The exception is caught here.
}

每个块都将传入的错误包装在AggregateException中,即使传入的错误已经是AggregateException。如果在管道的早期发生错误并在几个链接下行之前被观察到,则原始错误将被包装在多层AggregateException中。AggregateException.Flatten方法简化了这种情况下的错误处理。

讨论

在构建网格(或管道)时,请考虑如何处理错误。在较简单的情况下,最好只传播错误并在最后一次捕获它们。在更复杂的网格中,您可能需要在数据流完成时观察每个块。

或者,如果你希望你的块在面对异常时仍然可用,你可以选择将异常视为另一种数据,让它们与正确处理的数据一起流过网格。使用这种模式,你可以保持数据流网格的操作性,因为块本身不会故障,并且会继续处理下一个数据项。参见 配方 14.6 了解更多详情。

参见

配方 5.1 讲述了如何建立块之间的链接。

配方 5.3 讲述了如何断开块之间的链接。

配方 14.6 讲述了如何在数据流网格中同时传递异常和数据。

5.3 取消链接块

问题

在处理过程中,您需要动态更改数据流的结构。这是一个几乎不常见的高级场景。

解决方案

你可以随时链接或取消链接数据流块;数据可以自由地通过网格流动,随时链接或取消链接都是安全的。链接和取消链接都是完全线程安全的。

当您创建数据流块链接时,请保留 LinkTo 方法返回的 IDisposable,并在希望取消链接块时将其处理掉:

var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
var subtractBlock = new TransformBlock<int, int>(item => item - 2);

IDisposable link = multiplyBlock.LinkTo(subtractBlock);
multiplyBlock.Post(1);
multiplyBlock.Post(2);

// Unlink the blocks.
// The data posted above may or may not have already gone through the link.
// In real-world code, consider a using block rather than calling Dispose.
link.Dispose();

讨论

除非你能保证链接是空闲的,否则在取消链接时可能会出现竞态条件。然而,通常这些竞态条件并不是一个问题;数据要么在断开链接之前流过链接,要么根本不会。没有竞态条件会导致数据的重复或丢失。

取消链接是一个高级场景,但在少数情况下非常有用。举例来说,没有办法改变链接的过滤器。要改变现有链接的过滤器,你需要取消旧的链接并创建一个新的链接,并可以选择设置 DataflowLinkOptions.Append 为 false。另一个例子是,在战略性的点上取消链接可以用来暂停数据流网格。

参见

配方 5.1 讲述了如何建立块之间的链接。

5.4 限流块

问题

在您的数据流网格中存在分叉场景,并希望数据以负载平衡的方式流动。

解决方案

默认情况下,当块产生输出数据时,它会检查所有链接(按创建顺序),并尝试逐个将数据流过每个链接。此外,每个块默认会维护一个输入缓冲区,在准备好处理数据之前可以接受任意数量的数据。

在分支场景中,这会造成问题,其中一个源块链接到两个目标块:然后第二个块就会饿死。当源块生成数据时,它会尝试将数据流向每个链接。第一个目标块将始终接受数据并缓冲它,因此源块永远不会尝试将数据流向第二个目标块。可以通过使用BoundedCapacity块选项节流目标块来解决此问题。默认情况下,BoundedCapacity设置为DataflowBlockOptions.Unbounded,这会导致第一个目标块即使没有准备好处理数据也缓冲所有数据。

BoundedCapacity可以设置为大于零的任意值(或者当然是DataflowBlockOptions.Unbounded)。只要目标块能够跟得上来自源块的数据,简单的值为 1 就足够了:

var sourceBlock = new BufferBlock<int>();
var options = new DataflowBlockOptions { BoundedCapacity = 1 };
var targetBlockA = new BufferBlock<int>(options);
var targetBlockB = new BufferBlock<int>(options);

sourceBlock.LinkTo(targetBlockA);
sourceBlock.LinkTo(targetBlockB);

讨论

节流在分支场景中进行负载平衡非常有用,但可以在任何需要节流行为的地方使用。例如,如果您正在从 I/O 操作中填充数据流网格的数据,可以在网格中的块上应用BoundedCapacity。这样,直到网格准备好处理数据之前,您都不会读取太多 I/O 数据,并且在能够处理它之前,您的网格不会最终缓冲所有输入数据。

另请参阅

Recipe 5.1 介绍如何将块链接在一起。

5.5 使用数据流块进行并行处理

问题

您希望在数据流网格中进行一些并行处理。

解决方案

默认情况下,每个数据流块彼此独立。当您将两个块链接在一起时,它们将独立处理。因此,每个数据流网格内建有一些自然的并行性。

如果需要进一步的操作,例如,如果有一个执行大量 CPU 计算的特定块,则可以通过设置MaxDegreeOfParallelism选项,使该块并行处理其输入数据。默认情况下,此选项设置为 1,因此每个数据流块一次只处理一个数据片段。

BoundedCapacity可以设置为DataflowBlockOptions.Unbounded或大于零的任何值。以下示例允许任意数量的任务同时乘以数据:

var multiplyBlock = new TransformBlock<int, int>(
    item => item * 2,
    new ExecutionDataflowBlockOptions
    {
      MaxDegreeOfParallelism = DataflowBlockOptions.Unbounded
    });
var subtractBlock = new TransformBlock<int, int>(item => item - 2);
multiplyBlock.LinkTo(subtractBlock);

讨论

MaxDegreeOfParallelism选项使得块内的并行处理变得容易。不那么容易的是确定哪些块需要它。一种技术是在调试器中暂停数据流执行,在那里您可以看到排队的数据项数量(即尚未由块处理的数据项)。意外数量的数据项可能表明某些重组或并行化将有所帮助。

如果数据流块进行异步处理,MaxDegreeOfParallelism也适用。在这种情况下,MaxDegreeOfParallelism选项指定并发级别——一定数量的。每个数据项在开始处理时占据一个槽,并且只有在异步处理完全完成时才释放该槽。

参见

食谱 5.1 涵盖了将块链接在一起。

5.6 创建自定义块

问题

您有要放置到自定义数据流块中的可重用逻辑。这样做可以创建包含复杂逻辑的较大块。

解决方案

您可以使用Encapsulate方法剪切具有单个输入和输出块的任何数据流网格的部分。Encapsulate将从两个端点创建一个单一的块。在这些端点之间传播数据和完成是您的责任。以下代码创建了一个自定义数据流块,将数据和完成状态传播到两个块之间:

IPropagatorBlock<int, int> CreateMyCustomBlock()
{
  var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
  var addBlock = new TransformBlock<int, int>(item => item + 2);
  var divideBlock = new TransformBlock<int, int>(item => item / 2);

  var flowCompletion = new DataflowLinkOptions { PropagateCompletion = true };
  multiplyBlock.LinkTo(addBlock, flowCompletion);
  addBlock.LinkTo(divideBlock, flowCompletion);

  return DataflowBlock.Encapsulate(multiplyBlock, divideBlock);
}

讨论

当您将网格封装为自定义块时,请考虑要向用户公开的选项类型。考虑每个块选项应该(或不应该)传递给内部网格;在许多情况下,某些块选项不适用或没有意义。因此,常见的做法是自定义块定义自己的自定义选项,而不是接受DataflowBlockOptions参数。

DataflowBlock.Encapsulate仅封装具有一个输入块和一个输出块的网格。如果您具有具有多个输入和/或输出的可重用网格,则应将其封装在自定义对象中,并将输入和输出作为类型为ITargetBlock<T>(用于输入)和IReceivableSourceBlock<T>(用于输出)的属性公开。

这些示例都使用Encapsulate创建自定义块。也可以自己实现数据流接口,但这要困难得多。Microsoft 有一篇论文描述了创建自定义数据流块的高级技术。

参见

食谱 5.1 涵盖了将块链接在一起。

食谱 5.2 涵盖了沿着块链接传播错误。

第六章:System.Reactive 基础

LINQ 是一组语言功能,使开发人员能够查询序列。最常见的两个 LINQ 提供程序是内置的 LINQ to Objects(基于 IEnumerable<T>)和 LINQ to Entities(基于 IQueryable<T>)。还有许多其他提供程序可用,大多数提供程序具有相同的一般结构。查询是惰性评估的,序列根据需要生成值。在概念上,这是一种拉模型;在评估过程中,逐个从查询中获取值项。

System.Reactive (Rx) 将事件视为随时间到达的数据序列。因此,你可以将 Rx 视为基于 IObservable<T> 的事件 LINQ。观察者和其他 LINQ 提供程序之间的主要区别在于,Rx 是一种“推”模型,即查询定义了程序在事件到达时如何响应。Rx 在 LINQ 基础上构建,作为扩展方法添加了一些强大的新操作符。

本章介绍了一些常见的 Rx 操作。请记住,所有的 LINQ 操作符也都可用,因此像过滤 (Where) 和投影 (Select) 这样的简单操作在概念上与任何其他 LINQ 提供程序的工作方式相同。我们不会在这里介绍这些常见的 LINQ 操作;我们将专注于 Rx 在 LINQ 之上构建的新功能,特别是涉及时间的功能。

要使用 System.Reactive,请将 NuGet 包 System.Reactive 安装到你的应用程序中。

6.1 转换 .NET 事件

问题

你有一个事件,需要将其视为 System.Reactive 的输入流,每次触发事件时通过 OnNext 产生一些数据。

解决方案

Observable 类定义了几个事件转换器。大多数 .NET 框架事件都兼容 FromEventPattern,但如果你有不遵循常规模式的事件,可以使用 FromEvent

如果事件委托类型为 EventHandler<T>,则 FromEventPattern 的效果最佳。许多较新的框架类型使用此事件委托类型。例如,Progress<T> 类型定义了一个 ProgressChanged 事件,类型为 EventHandler<T>,因此可以轻松用 FromEventPattern 包装:

var progress = new Progress<int>();
IObservable<EventPattern<int>> progressReports =
    Observable.FromEventPattern<int>(
        handler => progress.ProgressChanged += handler,
        handler => progress.ProgressChanged -= handler);
progressReports.Subscribe(data => Trace.WriteLine("OnNext: " + data.EventArgs));

注意这里,data.EventArgs 的类型强制为 intFromEventPattern 的类型参数(如前面的例子中的 int)与 EventHandler<T> 中的 T 类型相同。FromEventPattern 的两个 lambda 参数使 System.Reactive 能够订阅和取消订阅事件。

较新的用户界面框架使用 EventHandler<T>,可以轻松与 FromEventPattern 配合使用,但旧类型通常为每个事件定义一个独特的委托类型。这些也可以与 FromEventPattern 一起使用,但需要更多工作。例如,System.Timers.Timer 类定义了一个 Elapsed 事件,类型为 ElapsedEventHandler。你可以像这样用 FromEventPattern 包装旧事件:

var timer = new System.Timers.Timer(interval: 1000) { Enabled = true };
IObservable<EventPattern<ElapsedEventArgs>> ticks =
    Observable.FromEventPattern<ElapsedEventHandler, ElapsedEventArgs>(
        handler => (s, a) => handler(s, a),
        handler => timer.Elapsed += handler,
        handler => timer.Elapsed -= handler);
ticks.Subscribe(data => Trace.WriteLine("OnNext: " + data.EventArgs.SignalTime));

请注意,在此示例中,data.EventArgs仍然是强类型的。FromEventPattern的类型参数现在是唯一的处理程序类型和派生的EventArgs类型。FromEventPattern的第一个 Lambda 参数是从EventHandler<ElapsedEventArgs>ElapsedEventHandler的转换器;该转换器除了传递事件之外不应执行其他操作。

那种语法确实变得笨拙了。这里有另一种选择,使用反射:

var timer = new System.Timers.Timer(interval: 1000) { Enabled = true };
IObservable<EventPattern<object>> ticks =
    Observable.FromEventPattern(timer, nameof(Timer.Elapsed));
ticks.Subscribe(data => Trace.WriteLine("OnNext: "
    + ((ElapsedEventArgs)data.EventArgs).SignalTime));

使用这种方法,调用FromEventPattern会更加简单。请注意,此方法存在一个缺点:消费者无法获得强类型的数据。因为data.EventArgs的类型是object,您必须自己将其转换为ElapsedEventArgs

讨论

事件是 System.Reactive 流的常见数据源。本文介绍了如何包装符合标准事件模式(第一个参数是发送者,第二个参数是事件参数类型)的任何事件。如果您有不寻常的事件类型,仍然可以使用Observable.FromEvent方法重载将它们包装成可观察对象。

当事件被包装成可观察对象时,每次事件被触发时都会调用OnNext。当处理AsyncCompletedEventArgs时,这可能会导致令人惊讶的行为,因为任何异常都作为数据(OnNext)传递,而不是作为错误(OnError)。例如,考虑WebClient.DownloadStringCompleted的这个包装器:

var client = new WebClient();
IObservable<EventPattern<object>> downloadedStrings =
    Observable.
    FromEventPattern(client, nameof(WebClient.DownloadStringCompleted));
downloadedStrings.Subscribe(
    data =>
    {
      var eventArgs = (DownloadStringCompletedEventArgs)data.EventArgs;
      if (eventArgs.Error != null)
        Trace.WriteLine("OnNext: (Error) " + eventArgs.Error);
      else
        Trace.WriteLine("OnNext: " + eventArgs.Result);
    },
    ex => Trace.WriteLine("OnError: " + ex.ToString()),
    () => Trace.WriteLine("OnCompleted"));
client.DownloadStringAsync(new Uri("http://invalid.example.com/"));

WebClient.DownloadStringAsync以错误完成时,事件会通过AsyncCompletedEventArgs.Error中的异常来触发。不幸的是,System.Reactive 将此视为数据事件,因此如果随后运行前述代码,则会打印出OnNext: (Error)而不是OnError:

有些事件的订阅和取消订阅必须在特定的上下文中完成。例如,许多 UI 控件上的事件必须从 UI 线程订阅。System.Reactive 提供了一个操作符来控制订阅和取消订阅的上下文:SubscribeOn。在大多数情况下,UI 基础的订阅都是从 UI 线程进行的,所以SubscribeOn操作符在大多数情况下并不是必需的。

提示

SubscribeOn控制添加和移除事件处理程序的代码的上下文。不要将其与ObserveOn混淆,后者控制可观察通知的上下文(传递给Subscribe的委托)。

参见

Recipe 6.2 介绍了如何更改引发事件的上下文。

Recipe 6.4 介绍了如何节流事件,以防止订阅者被压倒。

6.2 将通知发送到上下文

问题

System.Reactive 尽力保持线程无关性。因此,它会在当前线程中引发通知(例如,OnNext)。每个OnNext通知都将按顺序发生,但不一定在同一线程上。

您通常希望在特定上下文中引发这些通知。例如,UI 元素应仅从拥有它们的 UI 线程进行操作,因此如果您在响应在线程池线程上到达的通知时更新 UI,则需要切换到 UI 线程。

解决方案

System.Reactive 提供了 ObserveOn 操作符,用于将通知移动到另一个调度程序上。

考虑以下示例,它使用 Interval 操作符每秒创建一个 OnNext 通知:

private void Button_Click(object sender, RoutedEventArgs e)
{
  Trace.WriteLine($"UI thread is {Environment.CurrentManagedThreadId}");
  Observable.Interval(TimeSpan.FromSeconds(1))
      .Subscribe(x => Trace.WriteLine(
          $"Interval {x} on thread {Environment.CurrentManagedThreadId}"));
}

在我的机器上,输出看起来如下:

UI thread is 9
Interval 0 on thread 10
Interval 1 on thread 10
Interval 2 on thread 11
Interval 3 on thread 11
Interval 4 on thread 10
Interval 5 on thread 11
Interval 6 on thread 11

由于 Interval 基于定时器(没有特定的线程),通知是在线程池线程上引发的,而不是在 UI 线程上。如果您需要更新 UI 元素,您可以通过 ObserveOn 传递通知,并传递表示 UI 线程的同步上下文。

private void Button_Click(object sender, RoutedEventArgs e)
{
  SynchronizationContext uiContext = SynchronizationContext.Current;
  Trace.WriteLine($"UI thread is {Environment.CurrentManagedThreadId}");
  Observable.Interval(TimeSpan.FromSeconds(1))
      .ObserveOn(uiContext)
      .Subscribe(x => Trace.WriteLine(
          $"Interval {x} on thread {Environment.CurrentManagedThreadId}"));
}

ObserveOn 的另一个常见用法是在必要时将 UI 线程 切换到其他线程。考虑这样一种情况:每当鼠标移动时,您需要进行一些耗费 CPU 的计算。默认情况下,所有鼠标移动都在 UI 线程上引发,因此您可以使用 ObserveOn 将这些通知移动到线程池线程上,执行计算,然后将结果通知移回 UI 线程:

SynchronizationContext uiContext = SynchronizationContext.Current;
Trace.WriteLine($"UI thread is {Environment.CurrentManagedThreadId}");
Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
        handler => (s, a) => handler(s, a),
        handler => MouseMove += handler,
        handler => MouseMove -= handler)
    .Select(evt => evt.EventArgs.GetPosition(this))
    .ObserveOn(Scheduler.Default)
    .Select(position =>
    {
      // Complex calculation
      Thread.Sleep(100);
      var result = position.X + position.Y;
      var thread = Environment.CurrentManagedThreadId;
      Trace.WriteLine($"Calculated result {result} on thread {thread}");
      return result;
    })
    .ObserveOn(uiContext)
    .Subscribe(x => Trace.WriteLine(
        $"Result {x} on thread {Environment.CurrentManagedThreadId}"));

如果您执行此示例,您会看到计算在线程池线程上进行,并且结果在 UI 线程上打印出来。但是,您也会注意到计算和结果会滞后于输入;它们会排队,因为鼠标位置更新频率超过每 100 毫秒。System.Reactive 提供了几种处理此情况的技术;其中一种常见的技术在 Recipe 6.4 中介绍,即节流输入。

讨论

ObserveOn 实际上将通知移动到 System.Reactive 的 调度程序 上。本文介绍了默认的(线程池)调度程序以及创建 UI 调度程序的一种方法。ObserveOn 操作符的最常见用途是在 UI 线程上移动或移出,但调度程序在其他场景中也很有用。调度程序在更高级的场景中也很有用,例如在单元测试时模拟时间流逝,您可以在 Recipe 7.6 中找到相关内容。

提示

ObserveOn 控制观察通知的上下文。这与控制添加和移除事件处理程序的代码上下文的 SubscribeOn 不同。

参见

Recipe 6.1 讲解了如何从事件创建序列,并使用 SubscribeOn

Recipe 6.4 讲解了对事件流进行节流处理。

Recipe 7.6 介绍了用于测试 System.Reactive 代码的特殊调度程序。

6.3 使用窗口和缓冲区分组事件数据

问题

你有一系列事件,并且希望在事件到达时对其进行分组。例如,您需要对输入的成对事件作出反应。另一个例子是,您需要在两秒的时间窗口内对所有输入作出反应。

解决方案

System.Reactive 提供了一对操作符来分组输入序列:BufferWindowBuffer 会保存输入事件,直到组合完成,然后一次性将它们作为事件集合转发。Window 会逻辑上分组输入事件,但会随着它们的到来立即传递。Buffer 的返回类型是 IObservable<IList<T>>(事件流的集合);Window 的返回类型是 IObservable<IObservable<T>>(事件流的事件流)。

以下示例使用 Interval 操作符每秒创建一个 OnNext 通知,并以两个为一组进行缓冲:

Observable.Interval(TimeSpan.FromSeconds(1))
    .Buffer(2)
    .Subscribe(x => Trace.WriteLine(
        $"{DateTime.Now.Second}: Got {x[0]} and {x[1]}"));

在我的机器上,此代码每两秒产生一对输出:

13: Got 0 and 1
15: Got 2 and 3
17: Got 4 and 5
19: Got 6 and 7
21: Got 8 and 9

以下是使用 Window 创建两个事件组的类似示例:

Observable.Interval(TimeSpan.FromSeconds(1))
    .Window(2)
    .Subscribe(group =>
    {
      Trace.WriteLine($"{DateTime.Now.Second}: Starting new group");
      group.Subscribe(
          x => Trace.WriteLine($"{DateTime.Now.Second}: Saw {x}"),
          () => Trace.WriteLine($"{DateTime.Now.Second}: Ending group"));
    });

在我的机器上,此 Window 示例产生以下输出:

17: Starting new group
18: Saw 0
19: Saw 1
19: Ending group
19: Starting new group
20: Saw 2
21: Saw 3
21: Ending group
21: Starting new group
22: Saw 4
23: Saw 5
23: Ending group
23: Starting new group

这些示例说明了 BufferWindow 之间的区别。Buffer 等待其组内的所有事件,然后发布单个集合。Window 以相同的方式分组事件,但会在事件到达时即刻发布它们;Window 立即发布一个可观察对象,用于发布该窗口的事件。

BufferWindow 也适用于时间跨度。以下代码示例中,所有鼠标移动事件都在一秒钟的窗口内收集:

private void Button_Click(object sender, RoutedEventArgs e)
{
  Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
          handler => (s, a) => handler(s, a),
          handler => MouseMove += handler,
          handler => MouseMove -= handler)
      .Buffer(TimeSpan.FromSeconds(1))
      .Subscribe(x => Trace.WriteLine(
          $"{DateTime.Now.Second}: Saw {x.Count} items."));
}

根据鼠标的移动方式,您应该看到类似以下的输出:

49: Saw 93 items.
50: Saw 98 items.
51: Saw 39 items.
52: Saw 0 items.
53: Saw 4 items.
54: Saw 0 items.
55: Saw 58 items.

讨论

BufferWindow 是您用来管理输入并将其形成您想要的形式的工具之一。另一种有用的技术是限流,您将在 Recipe 6.4 中了解更多。

BufferWindow 都有其他重载版本,可用于更高级的场景。带有 skiptimeShift 参数的重载允许您创建与其他组重叠或在组之间跳过元素的组。还有带有委托参数的重载,允许您动态定义组的边界。

参见

Recipe 6.1 讲解了如何从事件中创建序列。

Recipe 6.4 讲解了如何限流事件流。

6.4 通过限流和采样来控制事件流

问题

编写响应式代码时常见的问题是事件到达速度过快。快速移动的事件流可能会超出程序的处理能力。

解决方案

System.Reactive 提供了专门用于处理大量事件数据的操作符。ThrottleSample 操作符为我们提供了两种不同的方法来控制快速输入事件。

Throttle 操作符建立了一个滑动超时窗口。当接收到新事件时,它会重置超时窗口。当超时窗口到期时,它会发布窗口内最后到达的事件值。

以下示例监控鼠标移动,并使用 Throttle 仅在鼠标静止一秒钟后报告更新:

private void Button_Click(object sender, RoutedEventArgs e)
{
  Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
          handler => (s, a) => handler(s, a),
          handler => MouseMove += handler,
          handler => MouseMove -= handler)
      .Select(x => x.EventArgs.GetPosition(this))
      .Throttle(TimeSpan.FromSeconds(1))
      .Subscribe(x => Trace.WriteLine(
          $"{DateTime.Now.Second}: Saw {x.X + x.Y}"));
}

输出因鼠标移动而大不相同,但在我的机器上的一个示例运行看起来是这样的:

47: Saw 139
49: Saw 137
51: Saw 424
56: Saw 226

Throttle经常用于像自动完成这样的情况,当用户在文本框中输入文本时,您不希望在用户停止输入之前进行实际查找。

Sample 采用了不同的方法来控制快速移动的序列。Sample 建立了一个常规的超时周期,并在每次超时到期时发布该窗口内的最新值。如果在抽样周期内没有收到值,则不会发布任何结果。

下面的示例捕获鼠标移动并在一秒间隔内对其进行抽样。与 Throttle 示例不同,这个 Sample 示例不需要您保持鼠标静止以查看数据:

private void Button_Click(object sender, RoutedEventArgs e)
{
  Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
          handler => (s, a) => handler(s, a),
          handler => MouseMove += handler,
          handler => MouseMove -= handler)
      .Select(x => x.EventArgs.GetPosition(this))
      .Sample(TimeSpan.FromSeconds(1))
      .Subscribe(x => Trace.WriteLine(
          $"{DateTime.Now.Second}: Saw {x.X + x.Y}"));
}

当我第一次让鼠标静止几秒钟然后持续移动时,这是我在我的机器上的输出:

12: Saw 311
17: Saw 254
18: Saw 269
19: Saw 342
20: Saw 224
21: Saw 277

讨论

对于驯服输入的洪流来说,节流和抽样是必不可少的工具。不要忘记您还可以使用标准 LINQ Where 运算符轻松进行过滤。您可以将 ThrottleSample 运算符视为类似于 Where,只是它们基于时间窗口而不是事件数据进行过滤。这三个运算符各自以不同的方式帮助您驯服快速移动的输入流。

参见

Recipe 6.1 介绍了如何从事件创建序列。

Recipe 6.2 介绍了如何改变事件触发的上下文。

6.5 超时

问题

您期望在一定时间内收到事件,并确保您的程序能够及时响应,即使事件没有及时到达。最常见的情况是,这种期望的事件是单个异步操作(例如,期待来自 Web 服务请求的响应)。

解决方案

Timeout 运算符在其输入流上建立了一个滑动超时窗口。每当新事件到达时,超时窗口就会被重置。如果在该窗口内没有看到事件而超时,则 Timeout 运算符将使用一个 TimeoutExceptionOnError 通知结束流。

下面的示例发出对示例域的网页请求,并设置了一秒钟的超时。为了启动网页请求,代码使用 ToObservableTask<T> 转换为 IObservable<T>(参见 Recipe 8.6):

void GetWithTimeout(HttpClient client)
{
  client.GetStringAsync("http://www.example.com/").ToObservable()
      .Timeout(TimeSpan.FromSeconds(1))
      .Subscribe(
          x => Trace.WriteLine($"{DateTime.Now.Second}: Saw {x.Length}"),
          ex => Trace.WriteLine(ex));
}

Timeout 对于异步操作(例如 Web 请求)非常理想,但它可以应用于任何事件流。下面的示例将 Timeout 应用于鼠标移动,这样更容易玩耍:

private void Button_Click(object sender, RoutedEventArgs e)
{
  Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
          handler => (s, a) => handler(s, a),
          handler => MouseMove += handler,
          handler => MouseMove -= handler)
      .Select(x => x.EventArgs.GetPosition(this))
      .Timeout(TimeSpan.FromSeconds(1))
      .Subscribe(
          x => Trace.WriteLine($"{DateTime.Now.Second}: Saw {x.X + x.Y}"),
          ex => Trace.WriteLine(ex));
}

在我的电脑上,我移动了鼠标一下然后静止了一秒钟,得到了以下结果:

16: Saw 180
16: Saw 178
16: Saw 177
16: Saw 176
System.TimeoutException: The operation has timed out.

请注意,一旦TimeoutException被发送到OnError,流就结束了。不再传递更多的鼠标移动。也许您并不希望出现这种行为,因此Timeout操作符有多个重载版本,当超时发生时,会用第二个流替代结束流,并不抛出异常。

下面示例中的代码观察鼠标移动直到超时。超时后,代码观察鼠标点击:

private void Button_Click(object sender, RoutedEventArgs e)
{
  IObservable<Point> clicks =
      Observable.FromEventPattern<MouseButtonEventHandler, MouseButtonEventArgs>(
          handler => (s, a) => handler(s, a),
          handler => MouseDown += handler,
          handler => MouseDown -= handler)
      .Select(x => x.EventArgs.GetPosition(this));

  Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
          handler => (s, a) => handler(s, a),
          handler => MouseMove += handler,
          handler => MouseMove -= handler)
      .Select(x => x.EventArgs.GetPosition(this))
      .Timeout(TimeSpan.FromSeconds(1), clicks)
      .Subscribe(
          x => Trace.WriteLine($"{DateTime.Now.Second}: Saw {x.X},{x.Y}"),
          ex => Trace.WriteLine(ex));
}

在我的机器上,我稍微移动了一下鼠标,然后静止了一秒钟,然后点击了几个不同的点。以下输出显示了鼠标移动快速通过直到超时,然后显示了两次点击:

49: Saw 95,39
49: Saw 94,39
49: Saw 94,38
49: Saw 94,37
53: Saw 130,141
55: Saw 469,4

讨论

在复杂应用程序中,Timeout是一个必不可少的操作符,因为即使世界其他地方没有响应,您始终希望程序响应。当您有异步操作时,它尤其有用,但它也可以应用于任何事件流。请注意,底层操作实际上并没有被取消;在超时的情况下,操作将继续执行,直到成功或失败。

参见

6.1 菜谱介绍了如何从事件创建序列。

8.6 菜谱介绍了如何将异步代码包装为可观察事件流。

10.6 菜谱介绍了如何因CancellationToken取消订阅序列。

10.3 菜谱介绍了如何使用CancellationToken作为超时。

第七章:测试

测试是软件质量的重要组成部分。近年来,单元测试的支持者已经变得司空见惯;似乎你无论读什么或听什么都会提到它。一些人推广测试驱动开发,这是一种编码风格,确保在应用程序完成时有全面的测试。单元测试对代码质量和整体完成时间的好处是众所周知的,但许多开发者仍然不写单元测试。

我鼓励你至少写一些单元测试。从你感到最缺乏信心的代码开始。根据我的经验,单元测试给了我两个主要优势:

  • 更好地理解代码。 你知道应用程序的那一部分能工作但你不知道为什么吗?当真正奇怪的 bug 报告出现时,它总是潜在地存在于你的脑海中。为你觉得困难的代码编写单元测试是了解它如何工作的一个绝佳方法。在编写描述其行为的单元测试后,代码不再神秘;你最终会得到一组描述其行为及其对其他代码的依赖关系的单元测试。

  • 更大的改动信心。 迟早你会收到需要修改令你害怕的代码的功能请求,你将无法再假装它不存在(我知道那种感觉,我也经历过!)。最好是主动出击:在功能请求到来之前为可怕的代码编写单元测试。一旦你的单元测试完成,你就会有一个早期警报系统,如果你的修改破坏了现有行为,它会立即提醒你。当你有一个拉取请求时,单元测试也会让你更有信心,确保代码变更不会破坏现有行为。

这两个优势同样适用于你自己的代码,不仅仅是别人的代码。我相信还有其他优点。单元测试减少 bug 的频率吗?很可能是。单元测试减少项目总体时间吗?可能是。但我描述的优势是确定的;每次我写单元测试时,我都能感受到它们。所以,这是我对单元测试的推销。

本章包含的配方全都是关于测试的。很多开发者(即使通常编写单元测试的那些人)都会避开测试并发代码,因为他们认为这很难。然而,正如这些配方将展示的那样,单元测试并发代码并不像他们想象的那么难。现代特性和库,如async和 System.Reactive,在测试方面都进行了大量的思考,这一点很明显。我鼓励你使用这些配方来编写单元测试,特别是如果你对并发编程还很陌生(即新的并发代码看起来很难或令人害怕)。

7.1 异步方法的单元测试

问题

你有一个async方法需要进行单元测试。

解决方案

大多数现代单元测试框架支持async Task单元测试方法,包括 MSTest、NUnit 和 xUnit。MSTest 从 Visual Studio 2012 开始支持这些测试。如果你使用其他单元测试框架,可能需要升级到最新版本。

这里是一个async MSTest 单元测试的例子:

[TestMethod]
public async Task MyMethodAsync_ReturnsFalse()
{
  var objectUnderTest = ...;
  bool result = await objectUnderTest.MyMethodAsync();
  Assert.IsFalse(result);
}

单元测试框架会注意到方法的返回类型是Task,并智能地等待任务完成,然后标记测试为“成功”或“失败”。

如果你的单元测试框架不支持async Task单元测试,那么它需要一些帮助来等待正在测试的异步操作。一种选择是使用GetAwaiter().GetResult()来同步阻塞任务;如果你使用GetAwaiter().GetResult()而不是Wait(),它会避免AggregateException包装器(如果任务有异常的话)。然而,我更喜欢使用Nito.AsyncEx NuGet 包中的AsyncContext类型:

[TestMethod]
public void MyMethodAsync_ReturnsFalse()
{
  AsyncContext.Run(async () =>
  {
    var objectUnderTest = ...;
    bool result = await objectUnderTest.MyMethodAsync();
    Assert.IsFalse(result);
  });
}

AsyncContext.Run会等待所有异步方法完成。

讨论

最初,模拟异步依赖可能有点笨拙。建议至少测试你的方法如何响应同步成功(使用Task.FromResult进行模拟)、同步错误(使用Task.FromException进行模拟)和异步成功(使用Task.Yield进行模拟并返回值)。你可以在第 2.2 节中找到有关Task.FromResultTask.FromException的覆盖率。Task.Yield可用于强制异步行为,主要用于单元测试:

interface IMyInterface
{
  Task<int> SomethingAsync();
}

class SynchronousSuccess : IMyInterface
{
  public Task<int> SomethingAsync()
  {
    return Task.FromResult(13);
  }
}

class SynchronousError : IMyInterface
{
  public Task<int> SomethingAsync()
  {
    return Task.FromException<int>(new InvalidOperationException());
  }
}

class AsynchronousSuccess : IMyInterface
{
  public async Task<int> SomethingAsync()
  {
    await Task.Yield(); // Force asynchronous behavior.
    return 13;
  }
}

在测试异步代码时,死锁和竞争条件可能比测试同步代码更容易出现。我发现每个测试设置超时时间非常有用;在 Visual Studio 中,你可以向解决方案添加一个测试设置文件,以便设置单独的测试超时时间。默认值相当高;我通常将每个测试的超时设置为两秒。

提示

AsyncContext类型位于Nito.AsyncEx NuGet 包中。

参见

第 7.2 节涵盖了预期失败的异步方法的单元测试。

7.2 测试失败预期的异步方法

问题

你需要编写一个单元测试来检查async Task方法的特定失败情况。

解决方案

如果你在桌面或服务器开发中,MSTest 确实支持通过常规的ExpectedExceptionAttribute进行失败测试:

// Not a recommended solution; see below.
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public async Task Divide_WhenDenominatorIsZero_ThrowsDivideByZero()
{
  await MyClass.DivideAsync(4, 0);
}

然而,这个解决方案并不是最佳选择:ExpectedException实际上设计不佳。它期望的异常可能由单元测试方法调用的任何方法抛出。更好的设计是检查特定代码块是否抛出了该异常,而不是整个单元测试。

大多数现代单元测试框架都以某种形式包含Assert.ThrowsAsync<TException>。例如,你可以像这样使用 xUnit 的ThrowsAsync

[Fact]
public async Task Divide_WhenDenominatorIsZero_ThrowsDivideByZero()
{
  await Assert.ThrowsAsync<DivideByZeroException>(async () =>
  {
    await MyClass.DivideAsync(4, 0);
  });
}
警告

不要忘记await ThrowsAsync 返回的任务!await 将传播任何检测到的断言失败。如果您忽略await并忽略编译器警告,那么您的单元测试将始终在不管方法行为如何的情况下静默成功。

不幸的是,其他几个单元测试框架不包括与 async 兼容的 ThrowsAsync 等效项。如果您发现自己处于这种情况中,请创建您自己的:

/// <summary>
/// Ensures that an asynchronous delegate throws an exception.
/// </summary>
/// <typeparam name="TException">
/// The type of exception to expect.
/// </typeparam>
/// <param name="action">The asynchronous delegate to test.</param>
/// <param name="allowDerivedTypes">
/// Whether derived types should be accepted.
/// </param>
public static async Task<TException> ThrowsAsync<TException>(Func<Task> action,
    bool allowDerivedTypes = true)
    where TException : Exception
{
  try
  {
    await action();
    var name = typeof(Exception).Name;
    Assert.Fail($"Delegate did not throw expected exception {name}.");
    return null;
  }
  catch (Exception ex)
  {
    if (allowDerivedTypes && !(ex is TException))
      Assert.Fail($"Delegate threw exception of type {ex.GetType().Name}" +
          $", but {typeof(TException).Name} or a derived type was expected.");
    if (!allowDerivedTypes && ex.GetType() != typeof(TException))
      Assert.Fail($"Delegate threw exception of type {ex.GetType().Name}" +
          $", but {typeof(TException).Name} was expected.");
    return (TException)ex;
  }
}

您可以像使用任何其他 Assert.ThrowsAsync<TException> 方法一样使用此方法。不要忘记await返回值!

讨论

测试错误处理与测试成功场景一样重要。有人甚至会说更重要,因为成功的场景是软件发布前每个人都会尝试的场景。如果您的应用行为异常,那可能是由于意外的错误情况。

然而,我鼓励开发人员不要再使用 ExpectedException。最好在特定点测试抛出异常,而不是在测试过程中的任何时候测试异常。请使用 ThrowsAsync(或您的单元测试框架中的等效项),或者像最后一个代码示例中一样使用 ThrowsAsync 的实现。

另请参阅

Recipe 7.1 涵盖了单元测试异步方法的基础知识。

7.3 单元测试 async void 方法

问题

您有一个需要进行单元测试的 async void 方法。

解决方案

停止。

而不是解决这个问题,您应尽全力避免它。如果可能将您的 async void 方法更改为 async Task 方法,则应这样做。

如果您的方法必须async void(例如,为了满足接口方法签名),则考虑编写两个方法:一个是包含所有逻辑的 async Task 方法,另一个是只调用 async Task 方法并等待结果的 async void 包装器。async void 方法满足架构要求,而 async Task 方法(包含所有逻辑)是可测试的。

如果无法更改您的方法并且必须async void 方法进行单元测试,则有一种方法可以实现。您可以使用 Nito.AsyncEx 库中的 AsyncContext 类:

// Not a recommended solution; see the rest of this section.
[TestMethod]
public void MyMethodAsync_DoesNotThrow()
{
  AsyncContext.Run(() =>
  {
    var objectUnderTest = new Sut(); // ...;
    objectUnderTest.MyVoidMethodAsync();
  });
}

AsyncContext 类型将等待所有异步操作完成(包括 async void 方法)并传播它们引发的异常。

提示

Nito.AsyncEx NuGet 包中包含 AsyncContext 类型。

讨论

async 代码中的一个关键指导原则是避免使用 async void。我强烈建议您重构代码,而不是为了单元测试 async void 方法而使用 AsyncContext

另请参阅

Recipe 7.1 涵盖了单元测试异步方法的基础知识。

7.4 单元测试数据流网格

问题

您的应用程序中有一个数据流网格,并且您需要验证其正常工作。

解决方案

数据流网格是独立的:它们有自己的生命周期,并且本质上是异步的。因此,测试它们的最自然方式是使用异步单元测试。以下单元测试验证来自 Recipe 5.6 的自定义数据流块:

[TestMethod]
public async Task MyCustomBlock_AddsOneToDataItems()
{
  var myCustomBlock = CreateMyCustomBlock();

  myCustomBlock.Post(3);
  myCustomBlock.Post(13);
  myCustomBlock.Complete();

  Assert.AreEqual(4, myCustomBlock.Receive());
  Assert.AreEqual(14, myCustomBlock.Receive());
  await myCustomBlock.Completion;
}

单元测试失败并不是那么简单。这是因为数据流网格中的异常每次传播到下一个块时都会被包装在另一个AggregateException中。以下示例使用一个辅助方法来确保异常将丢弃数据并通过自定义块传播:

[TestMethod]
public async Task MyCustomBlock_Fault_DiscardsDataAndFaults()
{
  var myCustomBlock = CreateMyCustomBlock();

  myCustomBlock.Post(3);
  myCustomBlock.Post(13);
  (myCustomBlock as IDataflowBlock).Fault(new InvalidOperationException());

  try
  {
    await myCustomBlock.Completion;
  }
  catch (AggregateException ex)
  {
    AssertExceptionIs<InvalidOperationException>(
        ex.Flatten().InnerException, false);
  }
}

public static void AssertExceptionIs<TException>(Exception ex,
    bool allowDerivedTypes = true)
{
  if (allowDerivedTypes && !(ex is TException))
    Assert.Fail($"Exception is of type {ex.GetType().Name}, but " +
        $"{typeof(TException).Name} or a derived type was expected.");
  if (!allowDerivedTypes && ex.GetType() != typeof(TException))
    Assert.Fail($"Exception is of type {ex.GetType().Name}, but " +
        $"{typeof(TException).Name} was expected.");
}

讨论

直接对数据流网格进行单元测试是可行的,但有些笨拙。如果您的网格是较大组件的一部分,那么您可能会发现仅对较大组件进行单元测试(隐式测试网格)更容易。但如果您正在开发可重用的自定义块或网格,则应使用类似前面的单元测试。

参见

Recipe 7.1 涵盖了对async方法的单元测试。

7.5 单元测试 System.Reactive 可观察序列

问题

您的程序的一部分正在使用IObservable<T>,您需要找到一种方法来对其进行单元测试。

解决方案

System.Reactive 有许多操作符来生成序列(例如Return)和其他可以将响应序列转换为常规集合或项的操作符(例如SingleAsync)。你可以使用诸如Return的操作符来创建可观察依赖的存根,并使用诸如SingleAsync的操作符来测试输出。

考虑以下代码,它将 HTTP 服务作为依赖项,并对 HTTP 调用应用超时:

public interface IHttpService
{
  IObservable<string> GetString(string url);
}

public class MyTimeoutClass
{
  private readonly IHttpService _httpService;

  public MyTimeoutClass(IHttpService httpService)
  {
    _httpService = httpService;
  }

  public IObservable<string> GetStringWithTimeout(string url)
  {
    return _httpService.GetString(url)
        .Timeout(TimeSpan.FromSeconds(1));
  }
}

受测试系统是MyTimeoutClass,它消耗一个可观察依赖项并产生一个可观察输出。

Return操作符创建一个包含单个元素的冷序列;您可以使用Return来构建一个简单的存根。SingleAsync操作符返回一个在下一个事件到达时完成的Task<T>SingleAsync可以用于像以下这样的简单单元测试:

class SuccessHttpServiceStub : IHttpService
{
  public IObservable<string> GetString(string url)
  {
    return Observable.Return("stub");
  }
}

[TestMethod]
public async Task MyTimeoutClass_SuccessfulGet_ReturnsResult()
{
  var stub = new SuccessHttpServiceStub();
  var my = new MyTimeoutClass(stub);

  var result = await my.GetStringWithTimeout("http://www.example.com/")
      .SingleAsync();

  Assert.AreEqual("stub", result);
}

在存根代码中另一个重要的操作符是Throw,它返回以错误结束的可观察序列。该操作符使我们能够对错误情况进行单元测试。以下示例使用了来自 Recipe 7.2 的ThrowsAsync辅助方法:

private class FailureHttpServiceStub : IHttpService
{
  public IObservable<string> GetString(string url)
  {
    return Observable.Throw<string>(new HttpRequestException());
  }
}

[TestMethod]
public async Task MyTimeoutClass_FailedGet_PropagatesFailure()
{
  var stub = new FailureHttpServiceStub();
  var my = new MyTimeoutClass(stub);

  await ThrowsAsync<HttpRequestException>(async () =>
  {
    await my.GetStringWithTimeout("http://www.example.com/")
        .SingleAsync();
  });
}

讨论

ReturnThrow非常适合创建可观察存根,而SingleAsync是测试具有async单元测试的简单方法。它们对于简单的可观察序列是一个很好的组合,但是一旦涉及到时间,它们就无法很好地支持。例如,如果您想测试MyTimeoutClass的超时功能,单元测试将需要等待一段时间。然而,这是一个不好的方法:它通过引入竞争条件使您的单元测试不可靠,并且随着添加更多单元测试,它不会很好地扩展。Recipe 7.6 介绍了 System.Reactive 如何特殊处理使您能够模拟时间本身的方法。

参见

Recipe 7.1 讨论了对 async 方法进行单元测试,这与等待 SingleAsync 的单元测试非常相似。

Recipe 7.6 讨论了依赖于时间推移的可观测序列的单元测试。

7.6 使用伪造调度器对 System.Reactive 可观测对象进行单元测试

问题

你有一个依赖于时间的可观测对象,并且想编写一个不依赖于时间的单元测试。依赖于时间的可观测对象包括使用超时、窗口/缓冲以及节流/抽样的对象。你希望对这些进行单元测试,但不希望单元测试运行时间过长。

解决方案

当然,你可以在单元测试中添加延迟;然而,这种方法存在两个问题:1)单元测试运行时间长,2)由于单元测试同时运行,造成了竞争条件,使得时间难以预测。

System.Reactive(Rx)库设计时考虑了测试;事实上,Rx 库本身经过了大量单元测试。为了实现彻底的单元测试,Rx 引入了一个称为 scheduler 的概念,并且 每个 处理时间的 Rx 运算符都是使用这个抽象调度器实现的。

为了使你的可观测对象可测试,你需要允许调用者指定调度器。例如,你可以从 Recipe 7.5 中获取 MyTimeoutClass 并添加一个调度器:

public interface IHttpService
{
  IObservable<string> GetString(string url);
}

public class MyTimeoutClass
{
  private readonly IHttpService _httpService;

  public MyTimeoutClass(IHttpService httpService)
  {
    _httpService = httpService;
  }

  public IObservable<string> GetStringWithTimeout(string url,
      IScheduler scheduler = null)
  {
    return _httpService.GetString(url)
        .Timeout(TimeSpan.FromSeconds(1), scheduler ?? Scheduler.Default);
  }
}

接下来,你可以修改你的 HTTP 服务存根,使其也能理解调度,然后引入可变的延迟:

private class SuccessHttpServiceStub : IHttpService
{
  public IScheduler Scheduler { get; set; }
  public TimeSpan Delay { get; set; }

  public IObservable<string> GetString(string url)
  {
    return Observable.Return("stub")
        .Delay(Delay, Scheduler);
  }
}

现在你可以继续使用 TestScheduler,这是 System.Reactive 库中包含的一种类型。 TestScheduler 让你强大地控制(虚拟)时间。

提示

TestScheduler 与 System.Reactive 的其余部分不在同一个 NuGet 包中;你需要安装 Microsoft.Reactive.Testing NuGet 包。

TestScheduler 让你完全控制时间,但通常你只需设置好你的代码,然后调用 TestScheduler.StartStart 会虚拟推进时间,直到所有操作完成。一个简单的成功测试用例如下所示:

[TestMethod]
public void MyTimeoutClass_SuccessfulGetShortDelay_ReturnsResult()
{
  var scheduler = new TestScheduler();
  var stub = new SuccessHttpServiceStub
  {
    Scheduler = scheduler,
    Delay = TimeSpan.FromSeconds(0.5),
  };
  var my = new MyTimeoutClass(stub);
  string result = null;

  my.GetStringWithTimeout("http://www.example.com/", scheduler)
      .Subscribe(r => { result = r; });

  scheduler.Start();

  Assert.AreEqual("stub", result);
}

该代码模拟了半秒钟的网络延迟。需要注意的是,这个单元测试 不会 花费半秒钟来运行;在我的机器上,大约只需 70 毫秒。半秒钟的延迟仅存在于虚拟时间中。这个单元测试的另一个显著区别是它不是异步的;因为你使用了 TestScheduler,所有测试可以立即完成。

现在一切都在使用测试调度器,很容易测试超时情况:

[TestMethod]
public void MyTimeoutClass_SuccessfulGetLongDelay_ThrowsTimeoutException()
{
  var scheduler = new TestScheduler();
  var stub = new SuccessHttpServiceStub
  {
    Scheduler = scheduler,
    Delay = TimeSpan.FromSeconds(1.5),
  };
  var my = new MyTimeoutClass(stub);
  Exception result = null;

  my.GetStringWithTimeout("http://www.example.com/", scheduler)
      .Subscribe(_ => Assert.Fail("Received value"), ex => { result = ex; });

  scheduler.Start();

  Assert.IsInstanceOfType(result, typeof(TimeoutException));
}

再次强调,前面的单元测试不需要花费 1 秒(或 1.5 秒)来运行;它立即执行,使用虚拟时间。

讨论

在这个配方中,我们只是浅尝了一下 System.Reactive 的调度器和虚拟时间。我建议您在开始编写 System.Reactive 代码时就开始进行单元测试;随着代码变得越来越复杂,您可以放心使用 Microsoft.Reactive.Testing 来处理它。

TestScheduler 还有 AdvanceToAdvanceBy 方法,这些方法使您能够逐步在虚拟时间中前进。在某些情况下,这可能很有用,但您应该努力让您的单元测试只测试一件事情。要测试超时,您可以编写一个单元测试,部分前进 TestScheduler 并确保超时不会过早发生,然后前进 TestScheduler 超过超时值并确保超时确实发生。然而,我更喜欢尽可能运行分开的单元测试;例如,一个单元测试确保超时不会过早发生,另一个单元测试确保超时稍后发生。

另请参见

配方 7.5 覆盖了观察序列单元测试的基础知识。

第八章:互操作性

异步、并行、响应式——每种方法都有其适用的场合,但它们如何一起工作呢?

在本章中,我们将探讨各种互操作场景,在这些场景中,您将学习如何结合这些不同的方法。您将了解到它们是互补的,而不是竞争的;在一个方法遇到另一个方法的边界处,几乎没有摩擦。

8.1 **异步包装器用于带有“Completed”事件的“Async”方法

问题

存在一种较旧的异步模式,使用名为*`Operation`*Async的方法以及名为*`Operation`*Completed的事件。您希望使用旧的异步模式执行操作并等待结果。

提示

*`Operation`*Async*`Operation`*Completed模式称为事件驱动的异步模式(EAP)。您将把它们封装成遵循任务异步模式(TAP)的返回Task方法。

解决方案

通过使用TaskCompletionSource<TResult>类型,您可以创建异步操作的包装器。TaskCompletionSource<TResult>类型控制Task<TResult>,并使您能够在适当的时候完成任务。

此示例定义了一个用于下载stringWebClient的扩展方法。WebClient类型定义了DownloadStringAsyncDownloadStringCompleted。使用这些,您可以定义一个DownloadStringTaskAsync方法,如下所示:

public static Task<string> DownloadStringTaskAsync(this WebClient client,
    Uri address)
{
  var tcs = new TaskCompletionSource<string>();

  // The event handler will complete the task and unregister itself.
  DownloadStringCompletedEventHandler handler = null;
  handler = (_, e) =>
  {
    client.DownloadStringCompleted -= handler;
    if (e.Cancelled)
      tcs.TrySetCanceled();
    else if (e.Error != null)
      tcs.TrySetException(e.Error);
    else
      tcs.TrySetResult(e.Result);
  };

  // Register for the event and *then* start the operation.
  client.DownloadStringCompleted += handler;
  client.DownloadStringAsync(address);

  return tcs.Task;
}

讨论

这个特定的例子并不是很有用,因为WebClient已经定义了DownloadStringTaskAsync,而且有一个更加支持asyncHttpClient可以使用。然而,这种技术同样适用于接口未更新为使用Task的旧异步代码。

提示

对于新代码,始终使用HttpClient。仅在使用旧代码时使用WebClient

通常,用于下载字符串的 TAP 方法将命名为*`Operation`*Async(例如,DownloadStringAsync);但是,在这种情况下,该命名约定不起作用,因为 EAP 已经定义了具有该名称的方法。在这里,约定是将 TAP 方法命名为*`Operation`*TaskAsync(例如,DownloadStringTaskAsync)。

在包装 EAP 方法时,存在“启动”方法可能会抛出异常的可能性;在前面的示例中,DownloadStringAsync可能会抛出异常。在这种情况下,您需要决定是允许异常传播还是捕获异常并调用TrySetException。大多数情况下,这些点抛出的异常是使用错误,所以无论选择哪种选项都没关系。如果不确定异常是否是使用错误,那么建议捕获异常并调用TrySetException

参见

Recipe 8.2 介绍了对 APM 方法(Begin*`Operation`*End*`Operation`*)的 TAP 包装器。

Recipe 8.3 介绍了任何类型通知的 TAP 包装器。

8.2 **异步包装器用于“Begin/End”方法

问题

旧的异步模式使用一对名为Begin*`Operation`*End*`Operation`*的方法,IAsyncResult表示异步操作。您有一个遵循旧的异步模式的操作,并希望使用await消费它。

提示

Begin*`Operation`*End*`Operation`*模式称为异步编程模型(APM)。您将把它们包装成遵循基于任务的异步模式(TAP)的返回Task的方法。

解决方案

包装 APM 的最佳方法是使用TaskFactory类型上的FromAsync方法之一。FromAsync在内部使用TaskCompletionSource<TResult>,但在包装 APM 时,使用FromAsync要简单得多。

此示例定义了一个为WebRequest定义扩展方法的例子,该方法发送 HTTP 请求并获取响应。WebRequest类型定义了BeginGetResponseEndGetResponse;您可以像这样定义一个GetResponseAsync方法:

public static Task<WebResponse> GetResponseAsync(this WebRequest client)
{
  return Task<WebResponse>.Factory.FromAsync(client.BeginGetResponse,
      client.EndGetResponse, null);
}

讨论

FromAsync有令人困惑的多种重载!

通常最好像示例中那样调用FromAsync。首先,传递Begin*`Operation`*方法(不调用它),然后传递End*`Operation`*方法(不调用它)。接下来,传递Begin*`Operation`*所需的所有参数,但最后的AsyncCallbackobject参数除外。最后,传递null

特别是,在调用FromAsync之前不要调用Begin*`Operation`*方法。您可以调用FromAsync,传递从Begin*`Operation`*获取的IAsyncOperation,但如果以这种方式调用它,FromAsync将被迫使用较低效的实现。

也许你会想知道为什么推荐的模式总是在最后传递一个null。在.NET 4.0 中引入了FromAsyncTask类型,而async还不存在。那时,在异步回调中常见使用state对象,而Task类型通过其AsyncState成员支持此功能。在新的async模式中,不再需要状态对象,因此对于state参数总是传递null是正常的。如今,state仅用于在优化内存使用时避免闭包实例。

另请参阅

配方 8.3 涵盖了为任何类型的通知编写 TAP 包装器。

8.3 任意内容的异步包装器

问题

您有一个不寻常或非标准的异步操作或事件,并希望通过await消费它。

解决方案

TaskCompletionSource<T>类型可用于在任何场景中构造Task<T>对象。使用TaskCompletionSource<T>,您可以以三种不同的方式完成任务:成功的结果、故障或取消。

async出现之前,Microsoft 推荐了另外两种异步模式:APM(食谱 8.2)和 EAP(食谱 8.1)。然而,APM 和 EAP 都相当笨拙,并且在某些情况下很难正确使用。因此,出现了一种非官方的约定,使用回调方法,如以下方法:

public interface IMyAsyncHttpService
{
  void DownloadString(Uri address, Action<string, Exception> callback);
}

这类方法遵循这样的约定,即DownloadString将启动(异步)下载,并在完成时通过回调调用callback,传递结果或异常。通常情况下,callback在后台线程上被调用。

类似前面示例的非标准异步方法可以使用TaskCompletionSource<T>来进行包装,以便它自然地与await一起工作,如下一个示例所示:

public static Task<string> DownloadStringAsync(
    this IMyAsyncHttpService httpService, Uri address)
{
  var tcs = new TaskCompletionSource<string>();
  httpService.DownloadString(address, (result, exception) =>
  {
    if (exception != null)
      tcs.TrySetException(exception);
    else
      tcs.TrySetResult(result);
  });
  return tcs.Task;
}

讨论

您可以使用相同的TaskCompletionSource<T>模式来包装任何非标准的异步方法,无论多么非标准。首先创建TaskCompletionSource<T>实例。接下来,安排一个回调,使TaskCompletionSource<T>适当地完成其任务。然后,启动实际的异步操作。最后,返回附加到该TaskCompletionSource<T>Task<T>

为了确保这种模式的重要性,您必须确保TaskCompletionSource<T>始终被完成。特别是要仔细考虑您的错误处理,并确保TaskCompletionSource<T>会得到适当的完成。在最后一个示例中,异常明确地传递到回调中,因此您不需要catch块;但有些非标准模式可能需要您在回调中捕获异常并将其放置在TaskCompletionSource<T>上。

参见

食谱 8.1 涵盖了用于 EAP 成员的 TAP 包装器(*`Operation`*Async*`Operation`*Completed)。

食谱 8.2 涵盖了用于 APM 成员的 TAP 包装器(Begin*`Operation`*End*`Operation`*)。

8.4 用于并行代码的异步包装器

问题

您有(CPU 绑定的)并行处理,希望使用await消耗它。通常情况下,这是希望的,以使您的 UI 线程不会因等待并行处理完成而阻塞。

解决方案

Parallel 类型和并行 LINQ 使用线程池来进行并行处理。它们还会将调用线程作为其中一个并行处理线程,因此如果从 UI 线程调用并行方法,则 UI 将在处理完成之前无响应。

为了保持 UI 的响应性,在Task.Run中包装并await结果:

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

本食谱的关键在于并行代码包括调用线程在其用于并行处理的线程池中。这对于并行 LINQ 和 Parallel 类都是如此。

讨论

这是一个简单的配方,但经常被忽视。通过使用 Task.Run,您将所有并行处理推送到线程池。Task.Run 返回一个代表该并行工作的 Task,UI 线程可以(异步地)等待其完成。

本配方仅适用于 UI 代码。在服务器端(例如 ASP.NET)很少进行并行处理,因为服务器主机已经执行了并行处理。因此,服务器端代码不应执行并行处理,也不应将工作推送到线程池。

另请参阅

第四章介绍了并行代码的基础知识。

第二章介绍了异步代码的基础知识。

8.5 用于 System.Reactive 可观察对象的异步包装器

问题

您有一个您希望使用 await 消耗的可观察流。

解决方案

首先,您需要决定您对事件流中的哪些可观察事件感兴趣。这些是常见的情况:

  • 流结束前的最后一个事件

  • 下一个事件

  • 所有事件

要捕获流中的最后一个事件,您可以 await LastAsync 的结果,或者直接 await 可观察对象:

IObservable<int> observable = ...;
int lastElement = await observable.LastAsync();
// or:  int lastElement = await observable;

当您 await 可观察对象或 LastAsync 时,代码(异步地)等待直到流完成并返回最后一个元素。在底层,await 是订阅流。

要捕获流中的下一个事件,请使用 FirstAsync。在以下代码中,await 订阅流,然后在第一个事件到达时完成(并取消订阅):

IObservable<int> observable = ...;
int nextElement = await observable.FirstAsync();

要捕获流中的所有事件,您可以使用 ToList

IObservable<int> observable = ...;
IList<int> allElements = await observable.ToList();

讨论

System.Reactive 库提供了使用 await 消耗流所需的所有工具。唯一棘手的部分是您必须考虑 awaitable 是否会等待直到流完成。在本配方的示例中,LastAsyncToList 和直接 await 将等待直到流完成;FirstAsync 仅等待下一个事件。

如果这些示例不满足您的需求,请记住,您可以完全利用 LINQ 的强大功能以及 System.Reactive 操纵器。诸如 TakeBuffer 的运算符也可以帮助您在不必等待整个流完成的情况下异步等待所需的元素。

一些与 await 一起使用的运算符——如 FirstAsyncLastAsync——实际上并不返回 Task<T>。如果您计划使用 Task.WhenAllTask.WhenAny,那么您需要一个实际的 Task<T>,您可以通过在任何可观察对象上调用 ToTask 来获得。ToTask 将返回一个在流中完成的 Task<T>

另请参阅

配方 8.6 介绍了在可观察流中使用异步代码的方法。

配方 8.8 介绍了将可观察流用作数据流块输入的方法(可以执行异步工作)。

6.3 节介绍了用于可观察流的窗口和缓冲区。

8.6 System.Reactive 对异步代码的可观察包装

问题

您有一个想要与可观察对象结合的异步操作。

解决方案

任何异步操作都可以被视为执行以下两种操作之一的可观察流:

  • 生成单个元素然后完成

  • 未产生任何元素的故障

要实现这种转换,System.Reactive 库可以简单地将Task<T>转换为IObservable<T>。以下代码开始异步下载网页,并将其视为可观察序列:

IObservable<HttpResponseMessage> GetPage(HttpClient client)
{
  Task<HttpResponseMessage> task =
      client.GetAsync("http://www.example.com/");
  return task.ToObservable();
}

ToObservable方法假定您已经调用了async方法并有一个Task可以转换。

另一种方法是调用StartAsyncStartAsync也会立即调用async方法,但支持取消:如果取消订阅,则会取消async方法:

IObservable<HttpResponseMessage> GetPage(HttpClient client)
{
  return Observable.StartAsync(
      token => client.GetAsync("http://www.example.com/", token));
}

ToObservableStartAsync立即启动异步操作,无需等待订阅;可观察对象是“热的”。要创建一个“冷”的可观察对象,仅在订阅时开始操作,请使用FromAsync(它也支持像StartAsync一样的取消):

IObservable<HttpResponseMessage> GetPage(HttpClient client)
{
  return Observable.FromAsync(
      token => client.GetAsync("http://www.example.com/", token));
}

FromAsyncToObservableStartAsync显著不同,后两者返回已经开始的async操作的可观察对象。FromAsync每次订阅时启动一个新的独立async操作。

最后,您可以使用SelectMany的特殊重载来为源流中的每个事件启动异步操作。SelectMany也支持取消。

以下示例获取现有的 URL 事件流,然后在每个 URL 到达时初始化请求:

IObservable<HttpResponseMessage> GetPages(
    IObservable<string> urls, HttpClient client)
{
  return urls.SelectMany(
      (url, token) => client.GetAsync(url, token));
}

讨论

System.Reactive 在引入async之前就已存在,但添加了这些操作符(及其他操作符),以便与async代码良好地互操作。建议您使用描述的操作符,即使您可以使用其他 System.Reactive 操作符构建相同的功能。

参见

8.5 节介绍了如何使用异步代码消耗可观察流。

8.8 节涵盖了使用数据流块(可能包含异步代码)作为可观察流源的方法。

8.7 异步流与数据流网格

问题

您的解决方案的一部分使用了异步流,另一部分使用了数据流网格,并且需要在它们之间传递数据。

解决方案

将通道作为异步流来消耗是内置在通道类型中的;有关详细信息,请参见 9.8 节。将 TPL Dataflow 块作为异步流消耗则稍微复杂一些,但当然是可行的。我发现最简单的方法是首先为数据流块定义一个扩展方法,使其 API 更类似于通道,然后使用该扩展方法将它们作为异步流消耗:

public static class DataflowExtensions
{
  public static bool TryReceiveItem<T>(this ISourceBlock<T> block, out T value)
  {
    if (block is IReceivableSourceBlock<T> receivableSourceBlock)
      return receivableSourceBlock.TryReceive(out value);

    try
    {
      value = block.Receive(TimeSpan.Zero);
      return true;
    }
    catch (TimeoutException)
    {
      // There is no item available right now.
      value = default;
      return false;
    }
    catch (InvalidOperationException)
    {
      // The block is complete and there are no more items.
      value = default;
      return false;
    }
  }

  public static async IAsyncEnumerable<T> ReceiveAllAsync<T>(
      this ISourceBlock<T> block,
 [EnumeratorCancellation] CancellationToken cancellationToken = default)
  {
    while (await block
        .OutputAvailableAsync(cancellationToken).ConfigureAwait(false))
    {
      while (block.TryReceiveItem(out var value))
      {
        yield return value;
      }
    }
  }
}

详细内容请参见第 3.4 节,关于EnumeratorCancellation属性的详细信息。

使用前面代码示例中的扩展方法,可以将任何输出数据流块消耗为异步流:

var multiplyBlock = new TransformBlock<int, int>(value => value * 2);

multiplyBlock.Post(5);
multiplyBlock.Post(2);
multiplyBlock.Complete();

await foreach (int item in multiplyBlock.ReceiveAllAsync())
{
  Console.WriteLine(item);
}

还可以将异步流用作数据流块的项目来源。您只需循环获取项目并将其放入块中。以下代码中有几个假设可能不适用于每种场景。首先,代码假设您希望在流完成时完成块。其次,它始终在其调用线程上运行;某些场景可能希望始终在线程池线程上运行整个循环:

public static async Task WriteToBlockAsync<T>(
    this IAsyncEnumerable<T> enumerable,
    ITargetBlock<T> block, CancellationToken token = default)
{
  try
  {
    await foreach (var item in enumerable
        .WithCancellation(token).ConfigureAwait(false))
    {
      await block.SendAsync(item, token).ConfigureAwait(false);
    }

    block.Complete();
  }
  catch (Exception ex)
  {
    block.Fault(ex);
  }
}

讨论

此处的扩展方法旨在作为一个起点。特别是,WriteToBlockAsync扩展方法确实做了一些假设;在使用之前,请务必考虑这些方法的行为,并确保它们在您的场景中的行为是适当的。

查看也可参考

第 9.8 节介绍了如何将通道作为异步流进行消耗。

第 3.4 节介绍了取消异步流的相关内容。

第五章介绍了 TPL Dataflow 的相关技巧。

第三章介绍了异步流的相关技巧。

8.8 System.Reactive 可观察对象和数据流网格

问题

您的解决方案的一部分使用了 System.Reactive 的可观察对象,另一部分使用了数据流网格,您需要它们进行通信。

System.Reactive 的可观察对象和数据流网格各自具有自己的用途,部分概念重叠;此处演示了它们如何轻松地协同工作,以便您可以在作业的每个部分使用最佳工具。

解决方案

首先,让我们考虑将数据流块用作可观察流的输入。以下代码创建了一个缓冲块(不进行任何处理),并通过调用AsObservable从该块创建了一个可观察接口:

var buffer = new BufferBlock<int>();
IObservable<int> integers = buffer.AsObservable();
integers.Subscribe(data => Trace.WriteLine(data),
    ex => Trace.WriteLine(ex),
    () => Trace.WriteLine("Done"));

buffer.Post(13);

缓冲块和可观察流可以正常完成或出现错误,而AsObservable方法将块的完成(或故障)转换为可观察流的完成。但是,如果块因异常而故障,则在传递给可观察流时该异常将被包装在AggregateException中。这类似于链接块传播它们的故障的方式。

将网格视为可观察流的目的地只是稍微复杂了一点。以下代码调用AsObserver以使块能够订阅可观察流:

IObservable<DateTimeOffset> ticks =
    Observable.Interval(TimeSpan.FromSeconds(1))
        .Timestamp()
        .Select(x => x.Timestamp)
        .Take(5);

var display = new ActionBlock<DateTimeOffset>(x => Trace.WriteLine(x));
ticks.Subscribe(display.AsObserver());

try
{
  display.Completion.Wait();
  Trace.WriteLine("Done.");
}
catch (Exception ex)
{
  Trace.WriteLine(ex);
}

与之前一样,可观察流的完成被转换为块的完成,而可观察流的任何错误被转换为块的故障。

讨论

数据流块和可观察流在概念上有很多共同之处。它们都通过它们传递数据,并且都理解完成和故障。它们设计用于不同的场景;TPL 数据流适用于异步和并行编程的混合,而 System.Reactive 则适用于反应式编程。然而,概念上的重叠足够兼容,它们能够非常自然地很好地协同工作。

参见

Recipe 8.5 讲解了如何使用异步代码消耗可观察流。

Recipe 8.6 讲解了如何在可观察流中使用异步代码。

8.9 将 System.Reactive 可观察对象转换为异步流

问题

您的解决方案的一部分使用了 System.Reactive 的可观察对象,并且希望将它们作为异步流消耗。

解决方案

System.Reactive 的可观察对象是推送型的,而异步流是拉取型的。因此,一开始就需要意识到这种概念上的不匹配。您需要一种方法来保持对可观察流的响应性,存储其通知直到消费代码请求它们。

最简单的解决方案已经包含在 System.Linq.Async 库中:

IObservable<long> observable =
    Observable.Interval(TimeSpan.FromSeconds(1));

// WARNING: May consume unbounded memory; see discussion!
IAsyncEnumerable<long> enumerable =
    observable.ToAsyncEnumerable();
提示

ToAsyncEnumerable 扩展方法位于 System.Linq.Async NuGet 包中。

然而,需要注意的是,这个简单的 ToAsyncEnumerable 扩展方法在内部使用了一个无界的生产者/消费者队列。本质上,这与您可以自己编写的使用通道作为无界生产者/消费者队列的扩展方法相同:

// WARNING: May consume unbounded memory; see discussion!
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(
    this IObservable<T> observable)
{
  Channel<T> buffer = Channel.CreateUnbounded<T>();
  using (observable.Subscribe(
      value => buffer.Writer.TryWrite(value),
      error => buffer.Writer.Complete(error),
      () => buffer.Writer.Complete()))
  {
    await foreach (T item in buffer.Reader.ReadAllAsync())
      yield return item;
  }
}

这些都是简单的解决方案,但它们使用了无界队列,因此只有在消费者能够(最终)跟上可观察事件时才应使用它们。如果生产者在一段时间内运行得比消费者快,是可以接受的;在此期间,可观察事件进入缓冲区。只要生产者最终赶上,前述解决方案就会起作用。但是,如果生产者始终比消费者运行得快,可观察事件将继续到达,扩展缓冲区,并最终耗尽进程的所有内存。

您可以通过使用有界队列来避免内存问题。其中的折衷是,如果可观察事件填满队列,您必须决定如何处理额外的项。一种选择是丢弃额外的项;以下示例代码使用有界通道,在缓冲区满时丢弃最旧的可观察通知:

// WARNING: May discard items; see discussion!
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(
    this IObservable<T> observable, int bufferSize)
{
  var bufferOptions = new BoundedChannelOptions(bufferSize)
  {
    FullMode = BoundedChannelFullMode.DropOldest,
  };
  Channel<T> buffer = Channel.CreateBounded<T>(bufferOptions);
  using (observable.Subscribe(
      value => buffer.Writer.TryWrite(value),
      error => buffer.Writer.Complete(error),
      () => buffer.Writer.Complete()))
  {
    await foreach (T item in buffer.Reader.ReadAllAsync())
      yield return item;
  }
}

讨论

当您的生产者运行速度快于消费者时,您有两个选择:要么缓冲生产者项目(假设生产者最终能够赶上),要么限制生产者的项目数量。本食谱的第二种解决方案通过丢弃不适合缓冲区的项目来限制生产者的项目。您还可以通过使用专为此设计的可观察操作符,如 ThrottleSample 来限制生产者的项目;详细信息请参见 食谱 6.4。根据您的需求,在将输入可观察对象转换为 IAsyncEnumerable<T> 之前,最好使用 ThrottleSample 技术中的一种来限制输入可观察对象。

除了有界队列和无界队列,还有第三个选项未在此处介绍:使用背压来通知可观察流,在缓冲区准备好接收通知之前必须停止生成通知。不幸的是,截至撰写本文时,System.Reactive 尚未标准化背压模式,因此这不是一个可行的选项。背压是复杂而微妙的,其他语言的响应式库已经实现了不同的背压模式。尚不清楚 System.Reactive 是否会采纳其中一种,发明自己的背压模式,还是干脆放弃解决背压的问题。

另请参阅

食谱 6.4 介绍了用于节流输入的 System.Reactive 操作符。

食谱 9.8 介绍了如何使用 Channel 作为无限制的生产者/消费者队列。

食谱 9.10 介绍了使用 Channel 作为采样队列,在其满时丢弃项目。

第九章:集合

在并发应用程序中,使用适当的集合是至关重要的。我不是在谈论像List<T>这样的标准集合;我假设你已经了解了这些。本章的目的是介绍专门用于并发或异步使用的新集合。

不可变集合 是永远不会改变的集合实例。乍一看,这听起来完全没用;但实际上它们非常有用,即使在单线程、非并发应用程序中也是如此。只读操作(例如枚举)直接作用于不可变实例。写操作(例如添加项目)返回一个新的不可变实例,而不是更改现有实例。这并不像听起来的那么浪费,因为大多数情况下,不可变集合共享大部分内存。此外,不可变集合具有隐式安全的优势,可以从多个线程安全访问;因为它们不能改变,所以它们是线程安全的。

小贴士

不可变集合位于System.Collections.Immutable NuGet 包中。

不可变集合是新的,但在新开发中应考虑使用它们,除非你需要一个可变实例。如果你不熟悉不可变集合,我建议你从 Recipe 9.1 开始,即使你不需要栈或队列,因为我将覆盖所有不可变集合遵循的几种常见模式。

有特殊的方法可以更有效地构建具有大量现有元素的不可变集合;这些示例代码仅逐个添加元素。如果需要加快初始化速度,MSDN 文档详细介绍了如何高效构建不可变集合。

线程安全集合

这些可变集合实例可以同时被多个线程修改。线程安全的集合使用细粒度锁和无锁技术的混合方式,以确保线程被阻塞的时间最少(通常根本不会被阻塞)。对于许多线程安全集合,枚举集合会创建集合的快照,然后枚举该快照。线程安全集合的关键优势在于,它们可以安全地从多个线程访问,但操作只会在很短的时间内(如果有的话)阻塞你的代码。

生产者/消费者集合

这些可变集合实例被设计用于特定目的:允许(可能多个)生产者向集合推送项目,同时允许(可能多个)消费者从集合中取出项目。因此,它们充当生产者代码和消费者代码之间的桥梁,同时还具有限制集合中项目数量的选项。生产者/消费者集合可以具有阻塞或异步 API。例如,当集合为空时,阻塞生产者/消费者集合将阻塞调用的消费者线程,直到添加另一个项目;但异步生产者/消费者集合将允许调用的消费者线程异步等待直到添加另一个项目。

本章节中使用了许多不同的生产者/消费者集合,不同的生产者/消费者集合具有不同的优势。查看 表 9-1 可以帮助确定您应该使用哪一个。

表 9-1. 生产者/消费者集合

特性 Channels BlockingCollection BufferBlock AsyncProducer-ConsumerQueue AsyncCollection
队列语义
堆栈/袋子语义
同步 API
异步 API
当满时丢弃项目
由 Microsoft 测试
提示

Channels 可在 System.Threading.Channels NuGet 包中找到,BufferBlock<T>System.Threading.Tasks.Dataflow NuGet 包中,AsyncProducerConsumerQueue<T>AsyncCollection<T>Nito.AsyncEx NuGet 包中。

9.1 不可变堆栈和队列

问题

您需要一个不经常更改且可以安全地被多个线程访问的堆栈或队列。

例如,队列可以用作执行操作的序列,堆栈可以用作撤销操作的序列。

解决方案

不可变堆栈和队列是最简单的不可变集合。它们的行为与标准Stack<T>Queue<T>非常相似。就性能而言,不可变堆栈和队列与标准堆栈和队列具有相同的时间复杂度;然而,在简单的频繁更新集合的场景中,标准堆栈和队列更快。

堆栈是一种先进后出的数据结构。以下代码创建一个空的不可变堆栈,推送两个项目,枚举项目,然后弹出一个项目:

ImmutableStack<int> stack = ImmutableStack<int>.Empty;
stack = stack.Push(13);
stack = stack.Push(7);

// Displays "7" followed by "13".
foreach (int item in stack)
  Trace.WriteLine(item);

int lastItem;
stack = stack.Pop(out lastItem);
// lastItem == 7

请注意,在示例中我们不断重写本地变量 stack。不可变集合遵循一种模式,它们返回一个更新的集合;原始集合引用不会改变。这意味着一旦您获得对特定不可变集合实例的引用,它将永远不会改变。考虑以下示例:

ImmutableStack<int> stack = ImmutableStack<int>.Empty;
stack = stack.Push(13);
ImmutableStack<int> biggerStack = stack.Push(7);

// Displays "7" followed by "13".
foreach (int item in biggerStack)
  Trace.WriteLine(item);

// Only displays "13".
foreach (int item in stack)
  Trace.WriteLine(item);

在内部实现上,两个栈共享用于包含项13的内存。这种实现方式非常高效,同时可以轻松快速地获取当前状态的快照。每个不可变集合实例本身天然线程安全,但不可变集合也可以在单线程应用程序中使用。在我看来,不可变集合在代码更具功能性或需要存储大量快照且希望尽可能共享内存时尤为有用。

队列类似于栈,但是它们是先进先出的数据结构。以下代码创建了一个空的不可变队列,入队两个项,枚举这些项,然后出队一个项:

ImmutableQueue<int> queue = ImmutableQueue<int>.Empty;
queue = queue.Enqueue(13);
queue = queue.Enqueue(7);

// Displays "13" followed by "7".
foreach (int item in queue)
  Trace.WriteLine(item);

int nextItem;
queue = queue.Dequeue(out nextItem);
// Displays "13".
Trace.WriteLine(nextItem);

讨论

这个菜谱介绍了两种最简单的不可变集合,栈和队列。还涵盖了几个对所有不可变集合都适用的重要设计理念:

  • 不可变集合的实例永远不会改变。

  • 由于它永远不会改变,因此天然线程安全。

  • 当您对不可变集合调用修改方法时,会返回一个新的修改后的集合。

警告

即使不可变集合是线程安全的,对不可变集合的引用并不是线程安全的。指向不可变集合的变量需要与其他变量一样的同步保护(参见第十二章)。

不可变集合非常适合共享状态。然而,它们不适合作为通信通道。特别是不要使用不可变队列在线程间通信;生产者/消费者队列在这方面效果更佳。

小贴士

ImmutableStack<T>ImmutableQueue<T>可以在System.Collections.Immutable NuGet 包中找到。

另请参阅

菜谱 9.6 介绍了线程安全(阻塞式)可变队列。

菜谱 9.7 介绍了线程安全(阻塞式)可变栈。

菜谱 9.8 介绍了支持异步的可变队列。

菜谱 9.11 介绍了支持异步的可变栈。

菜谱 9.12 介绍了阻塞/异步可变队列。

9.2 不可变列表

问题

您需要一种可以进行索引而不经常变化且可以安全地被多个线程访问的数据结构。

解决方案

列表是一种通用的数据结构,可以用于各种应用状态。不可变列表允许索引,但需要注意性能特征。它们并不仅仅是List<T>的简单替代品。

ImmutableList<T>支持与List<T>类似的方法,如以下示例所示:

ImmutableList<int> list = ImmutableList<int>.Empty;
list = list.Insert(0, 13);
list = list.Insert(0, 7);

// Displays "7" followed by "13".
foreach (int item in list)
  Trace.WriteLine(item);

list = list.RemoveAt(1);

不可变列表在内部组织为二叉树,以便不可变列表实例可以最大化与其他实例共享的内存量。因此,对于一些常见操作,ImmutableList<T>List<T>之间存在性能差异(参见表 9-2)。

表 9-2. 不可变列表的性能差异

操作 List ImmutableList
添加 摊销 O(1) O(log N)
插入 O(N) O(log N)
移除在 O(N) O(log N)
项[索引] O(1) O(log N)

需要注意的是,ImmutableList<T>的索引操作是 O(log N),而不是您可能期望的 O(1)。如果在现有代码中用ImmutableList<T>替换List<T>,则需要考虑如何访问集合中的项。

这意味着在可能的情况下应使用foreach而不是for。在ImmutableList<T>上执行的foreach循环的时间复杂度为 O(N),而在相同集合上执行的for循环的时间复杂度为 O(N * log N):

// The best way to iterate over an ImmutableList<T>.
foreach (var item in list)
  Trace.WriteLine(item);

// This will also work, but it will be much slower.
for (int i = 0; i != list.Count; ++i)
  Trace.WriteLine(list[i]);

讨论

ImmutableList<T>是一个很好的通用数据结构,但由于其性能差异,您不能盲目地用它替换所有List<T>的用法。List<T>通常是默认使用的——除非需要不同的集合。ImmutableList<T>并不是如此普遍;您需要仔细考虑其他不可变集合,并选择最适合您情况的那个。

提示

ImmutableList<T>位于System.Collections.Immutable NuGet 包中。

参见

食谱 9.1 涵盖了不可变堆栈和队列,类似于只允许访问特定元素的列表。

MSDN 对ImmutableList<T>.Builder的文档介绍了一种有效的填充不可变列表的方法。

9.3 不可变集合

问题

您需要一个数据结构,不需要存储重复项,不经常更改,并且可以安全地由多个线程访问。

例如,文件中的单词索引是集合的一个好用例。

解决方案

有两种不可变集合类型:ImmutableHashSet<T>是独特项的集合,而ImmutableSortedSet<T>排序的独特项集合。这两种类型有相似的接口:

ImmutableHashSet<int> hashSet = ImmutableHashSet<int>.Empty;
hashSet = hashSet.Add(13);
hashSet = hashSet.Add(7);

// Displays "7" and "13" in an unpredictable order.
foreach (int item in hashSet)
  Trace.WriteLine(item);

hashSet = hashSet.Remove(7);

只有排序集合允许像列表一样进行索引:

ImmutableSortedSet<int> sortedSet = ImmutableSortedSet<int>.Empty;
sortedSet = sortedSet.Add(13);
sortedSet = sortedSet.Add(7);

// Displays "7" followed by "13".
foreach (int item in sortedSet)
  Trace.WriteLine(item);
int smallestItem = sortedSet[0];
// smallestItem == 7

sortedSet = sortedSet.Remove(7);

未排序集合和排序集合具有类似的性能(参见表 9-3)。

表 9-3. 不可变集合的性能

操作 ImmutableHashSet ImmutableSortedSet
添加 O(log N) O(log N)
移除 O(log N) O(log N)
项[索引] n/a O(log N)

但是,我建议您使用未排序集合,除非您知道它需要排序。许多类型仅支持基本相等性而不支持完全比较,因此未排序集合可用于比排序集合更多的类型。

关于排序集的一个重要说明是,其索引是 O(log N),而不是 O(1),就像 ImmutableList<T> 一样,它在 Recipe 9.2 中讨论过。这意味着在这种情况下应该尽可能使用 foreach 而不是 for 来处理 ImmutableSortedSet<T>

讨论

不可变集合是有用的数据结构,但是填充大型不可变集合可能会很慢。大多数不可变集合都有特殊的构建器,可以在可变方式下快速构建它们,然后将其转换为不可变集合。对许多不可变集合而言是如此,但我发现它们对于不可变集合特别有用。

小贴士

ImmutableHashSet<T>ImmutableSortedSet<T> 在 NuGet System.Collections.Immutable 包中。

参见

Recipe 9.7 讨论了线程安全的可变背包,它们类似于集合。

Recipe 9.11 讨论了与异步兼容的可变背包。

MSDN documentation on ImmutableHashSet<T>.Builder 讨论了填充不可变哈希集的高效方式。

MSDN documentation on ImmutableSortedSet<T>.Builder 讨论了填充不可变排序集的高效方式。

9.4 不可变字典

问题

您需要一个不经常更改且可以安全地被多个线程访问的键/值集合。例如,您可能希望在查找集合中存储参考数据,这些参考数据很少更改,但应该对不同线程可用。

解决方案

有两种不可变字典类型:ImmutableDictionary<TKey, TValue>ImmutableSortedDictionary<TKey, TValue>。从它们的名称可以猜出,ImmutableDictionary 中的项没有可预测的顺序,而 ImmutableSortedDictionary 确保其元素已排序。

这两种集合类型都有非常相似的成员:

ImmutableDictionary<int, string> dictionary =
    ImmutableDictionary<int, string>.Empty;
dictionary = dictionary.Add(10, "Ten");
dictionary = dictionary.Add(21, "Twenty-One");
dictionary = dictionary.SetItem(10, "Diez");

// Displays "10Diez" and "21Twenty-One" in an unpredictable order.
foreach (KeyValuePair<int, string> item in dictionary)
  Trace.WriteLine(item.Key + item.Value);

string ten = dictionary[10];
// ten == "Diez"

dictionary = dictionary.Remove(21);

注意使用 SetItem。在可变字典中,您可以尝试像 dictionary[key] = item 这样做,但是不可变字典必须返回更新后的不可变字典,因此它们使用 SetItem 方法代替:

ImmutableSortedDictionary<int, string> sortedDictionary =
    ImmutableSortedDictionary<int, string>.Empty;
sortedDictionary = sortedDictionary.Add(10, "Ten");
sortedDictionary = sortedDictionary.Add(21, "Twenty-One");
sortedDictionary = sortedDictionary.SetItem(10, "Diez");

// Displays "10Diez" followed by "21Twenty-One".
foreach (KeyValuePair<int, string> item in sortedDictionary)
  Trace.WriteLine(item.Key + item.Value);

string ten = sortedDictionary[10];
// ten == "Diez"

sortedDictionary = sortedDictionary.Remove(21);

无序字典和有序字典有类似的性能,但我建议您使用无序字典,除非您需要元素排序(参见 Table 9-4)。总体而言,无序字典可能略快。此外,无序字典可用于任何键类型,而有序字典要求它们的键类型完全可比较。

表 9-4 不可变字典的性能

操作 ImmutableDictionary<TKey, TV> ImmutableSortedDictionary<TKey, TV>
Add O(log N) O(log N)
SetItem O(log N) O(log N)
Item[key] O(log N) O(log N)
Remove O(log N) O(log N)

讨论

在我的经验中,字典是处理应用程序状态时常见且有用的工具。它们可用于任何类型的键/值或查找场景。

与其他不可变集合一样,不可变字典具有用于高效构建的构建器机制,如果字典包含许多元素。例如,如果在启动时加载初始引用数据,则应使用构建器机制来构建初始的不可变字典。另一方面,如果您的引用数据在应用程序执行过程中逐渐构建,则使用常规的不可变字典 Add 方法可能是可接受的。

提示

ImmutableDictionary<TK, TV>ImmutableSortedDictionary<TK, TV> 包含在 System.Collections.Immutable NuGet 包中。

参见

第 9.5 节 涵盖了线程安全的可变字典。

MSDN 关于 ImmutableDictionary<TK,TV>.Builder 涵盖了填充不可变字典的高效方式。

MSDN 关于 ImmutableSortedDictionary<TK,TV>.Builder 涵盖了填充不可变排序字典的高效方式。

9.5 线程安全的字典

问题

您有一个键/值集合(例如内存中的缓存),需要保持同步,尽管多个线程同时读取和写入它。

解决方案

.NET 框架中的 ConcurrentDictionary<TKey, TValue> 类型是一个真正的宝藏数据结构。它是线程安全的,使用细粒度锁和无锁技术的混合确保在绝大多数场景下快速访问。

其 API 确实需要花一点时间适应。它与标准的 Dictionary<TKey, TValue> 类型非常不同,因为它必须处理来自多个线程的并发访问。但是一旦您在这个示例中学会了基础知识,您会发现 ConcurrentDictionary<TKey, TValue> 是最有用的集合类型之一。

首先,让我们学习如何向集合写入值。要设置键的值,您可以使用 AddOrUpdate

var dictionary = new ConcurrentDictionary<int, string>();
string newValue = dictionary.AddOrUpdate(0,
    key => "Zero",
    (key, oldValue) => "Zero");

AddOrUpdate 有点复杂,因为它必须根据并发字典的当前内容执行几项操作。第一个方法参数是键。第二个参数是一个委托,将键(在本例中为 0)转换为要添加到字典中的值(在本例中为 "Zero")。仅当字典中不存在该键时才会调用此委托。第三个参数是另一个委托,将键(0)和旧值转换为要存储在字典中的更新值("Zero")。仅当字典中存在该键时才会调用此委托。AddOrUpdate 返回该键的新值(由委托之一返回的相同值)。

现在让我们来看一下真正让你感到头疼的部分:为了使并发字典正常工作,AddOrUpdate可能必须多次调用一个或两个委托。这种情况非常罕见,但是确实可能发生。因此,你的委托应该简单快速,不应该引起任何副作用。这意味着你的委托应该只创建值;它不应该更改应用程序中的任何其他变量。对于你传递给ConcurrentDictionary<TKey, TValue>方法的所有委托,都应遵循相同的原则。

还有几种其他向字典添加值的方法。其中一种捷径是只使用索引语法:

// Using the same "dictionary" as above.
// Adds (or updates) key 0 to have the value "Zero".
dictionary[0] = "Zero";

索引语法功能较弱;它不提供根据现有值更新值的能力。然而,语法更简单,如果你已经有了要存储在字典中的值,它可以正常工作。

查看如何读取值。这可以通过TryGetValue轻松完成:

// Using the same "dictionary" as above.
bool keyExists = dictionary.TryGetValue(0, out string currentValue);

如果在字典中找到键,则TryGetValue将返回true并设置out值。如果未找到键,则TryGetValue将返回false。你也可以使用索引语法来读取值,但我发现这不太有用,因为如果找不到键,它会抛出异常。请记住,并发字典有多个线程同时读取、更新、添加和删除值;在许多情况下,很难知道键是否存在,直到尝试读取它为止。

删除值与读取值一样简单:

// Using the same "dictionary" as above.
bool keyExisted = dictionary.TryRemove(0, out string removedValue);

TryRemoveTryGetValue几乎相同(当然),只有在字典中找到键时才会删除键/值对。

讨论

尽管ConcurrentDictionary<TKey, TValue>是线程安全的,但这并不意味着它的操作是原子的。如果多个线程同时调用AddOrUpdate,它们可能都会检测到键不存在,并且同时执行创建新值的委托。

我认为ConcurrentDictionary<TKey, TValue>非常棒,主要是因为其功能强大的AddOrUpdate方法。然而,并不是所有情况下它都适用。ConcurrentDictionary<TKey, TValue>在多线程读写共享集合时表现最佳。如果更新不频繁(如果它们更为稀少),那么ImmutableDictionary<TKey, TValue>可能更合适。

ConcurrentDictionary<TKey, TValue>最适合于共享数据的情况,多个线程共享同一集合。如果一些线程仅添加元素,而其他线程仅删除元素,则生产者/消费者集合会更好地满足你的需求。

ConcurrentDictionary<TKey, TValue>不是唯一的线程安全集合。BCL 还提供ConcurrentStack<T>ConcurrentQueue<T>ConcurrentBag<T>。线程安全集合通常用作生产者/消费者集合,在本章的其余部分将进行介绍。

参见

配方 9.4 介绍了不可变字典,如果字典的内容变化非常少,则非常理想。

9.6 阻塞队列

问题

您需要一个传输介质,用于将消息或数据从一个线程传递到另一个线程。例如,一个线程可以加载数据,并在加载时将其推送到传输介质;同时,传输介质的接收端有其他线程接收并处理数据。

解决方案

.NET 类型BlockingCollection<T>设计为这种类型的传输介质。默认情况下,BlockingCollection<T>是一个阻塞队列,提供先进先出的行为。

阻塞队列需要被多个线程共享,并且通常被定义为私有的只读字段:

private readonly BlockingCollection<int> _blockingQueue =
    new BlockingCollection<int>();

通常,一个线程要么向集合添加项目要么从集合中移除项目,但不会两者兼而有之。添加项目的线程称为生产者线程,移除项目的线程称为消费者线程

生产者线程可以通过调用Add添加项目,并且当生产者线程完成时(即所有项目都已添加),可以通过调用CompleteAdding完成集合。这会通知集合不再添加项目,并且集合可以通知其消费者没有更多项目。

这是一个简单的生产者示例,添加两个项目,然后标记集合为完成:

_blockingQueue.Add(7);
_blockingQueue.Add(13);
_blockingQueue.CompleteAdding();

消费者线程通常在循环中运行,等待下一个项目然后处理它。如果将生产者代码放在单独的线程中(例如通过Task.Run),那么可以像这样消费这些项目:

// Displays "7" followed by "13".
foreach (int item in _blockingQueue.GetConsumingEnumerable())
  Trace.WriteLine(item);

如果您希望有多个消费者,可以同时从多个线程调用GetConsumingEnumerable。然而,每个项目只会传递给这些线程中的一个。当集合完成时,可枚举对象完成。

讨论

前面的示例都使用了GetConsumingEnumerable来作为消费者线程的一种常见情况。然而,也有一个Take成员允许消费者只消费单个项目而不是运行循环消费所有项目。

当您使用这样的传输介质时,需要考虑如果生产者运行得比消费者快会发生什么。如果您生成的项目比您消费它们的速度快,那么可能需要限制您的队列。

当您想要异步访问传输介质时,例如 UI 线程希望充当消费者时,阻塞队列非常适合(例如线程池线程)。配方 9.8 介绍了异步队列。

提示

每当您将这样的传输介质引入到您的应用程序中时,请考虑切换到 TPL Dataflow 库。大多数情况下,使用 TPL Dataflow 比构建自己的传输介质和后台线程更简单。

BufferBlock<T>来自 TPL Dataflow 可以像阻塞队列一样工作,TPL Dataflow 允许构建用于处理的管道或网格。然而,在许多更简单的情况下,像BlockingCollection<T>这样的普通阻塞队列是适当的设计选择。

您还可以使用AsyncEx库的AsyncProducerConsumerQueue<T>,它可以像阻塞队列一样工作。

参见

9.7 食谱 涵盖了阻塞栈和袋,如果您需要类似的传输通道而不需要先进先出语义。

9.8 食谱 涵盖了具有异步而不是阻塞 API 的队列。

9.12 食谱 涵盖了既有异步又有阻塞 API 的队列。

9.9 食谱 涵盖了限制其项目数量的队列。

9.7 阻塞栈和袋

问题

您需要一个传输通道来从一个线程传递消息或数据到另一个线程,但不希望(或不需要)这个通道具有先进先出语义。

解决方案

.NET 类型BlockingCollection<T>默认作为阻塞队列,但也可以像任何种类的生产者/消费者集合一样工作。实际上,它是围绕实现IProducerConsumerCollection<T>的线程安全集合的包装器。

所以,你可以创建一个具有后进先出(栈)语义或无序(袋)语义的BlockingCollection<T>

BlockingCollection<int> _blockingStack = new BlockingCollection<int>(
    new ConcurrentStack<int>());
BlockingCollection<int> _blockingBag = new BlockingCollection<int>(
    new ConcurrentBag<int>());

重要的是要记住,现在围绕项目排序存在竞争条件。如果让相同的生产者代码在任何消费者代码之前执行,然后在生产者代码之后执行消费者代码,则项目的顺序将完全像栈一样:

// Producer code
_blockingStack.Add(7);
_blockingStack.Add(13);
_blockingStack.CompleteAdding();

// Consumer code
// Displays "13" followed by "7".
foreach (int item in _blockingStack.GetConsumingEnumerable())
  Trace.WriteLine(item);

当生产者代码和消费者代码在不同的线程上(这是通常情况),消费者始终获取最近添加的项目。例如,生产者可以添加7,消费者可以取7,生产者可以添加13,消费者可以取13。消费者在返回第一个项目之前不会等待CompleteAdding的调用。

讨论

关于阻塞队列应用于限制内存使用的节流考虑与阻塞栈和袋相同。如果您的生产者运行得比消费者快,而且您需要限制阻塞栈/袋的内存使用,可以像 9.9 食谱中所示那样使用节流。

本篇介绍使用GetConsumingEnumerable作为消费者代码;这是最常见的场景。还有一个Take成员,允许消费者只消费单个项目,而不是运行循环消费所有项目。

如果您想异步访问共享的栈或袋而不是通过阻塞(例如,让您的 UI 线程充当消费者),请参阅 9.11 食谱。

参见

9.6 食谱 涵盖了阻塞队列,比阻塞栈或袋更常用。

9.11 食谱 涵盖了异步栈和袋。

9.8 异步队列

问题

你需要一种传递消息或数据的通道,以先进先出的方式从代码的一部分传递到另一部分,而不阻塞线程。

例如,一个代码片段可以正在加载数据,它在加载时将数据推送到通道中;同时,UI 线程正在接收数据并显示它。

解决方案

你需要的是一个具有异步 API 的队列。核心 .NET 框架中没有这样的类型,但可以从 NuGet 上找到几个选项。

第一种选项是使用 Channels。Channels 是用于异步生产者/消费者集合的现代库,非常注重高性能处理高频场景。生产者通常使用WriteAsync向通道写入项,在它们完成所有生产后,其中一个调用Complete通知通道未来不会再有更多项,例如:

Channel<int> queue = Channel.CreateUnbounded<int>();

// Producer code
ChannelWriter<int> writer = queue.Writer;
await writer.WriteAsync(7);
await writer.WriteAsync(13);
writer.Complete();

// Consumer code
// Displays "7" followed by "13".
ChannelReader<int> reader = queue.Reader;
await foreach (int value in reader.ReadAllAsync())
  Trace.WriteLine(value);

这种更自然的消费者代码使用了异步流;更多信息请参阅第三章。截至本文撰写时,异步流仅适用于最新的 .NET 平台;旧平台可以使用以下模式:

// Consumer code (older platforms)
// Displays "7" followed by "13".
ChannelReader<int> reader = queue.Reader;
while (await reader.WaitToReadAsync())
  while (reader.TryRead(out int value))
    Trace.WriteLine(value);

注意旧平台消费者代码中的双重while循环;这是正常的。WaitToReadAsync会异步等待直到有可读取的项或通道已标记为完成;当有可读取的项时返回trueTryRead会尝试读取一个项(立即和同步),如果读取到项则返回true。如果TryRead返回false,这可能是因为当前没有可用项,或者可能是因为通道已标记为完成且以后不会再有更多项。因此,当TryRead返回false时,内部while循环退出,并且消费者再次调用WaitToReadAsync,如果通道已标记为完成,则返回false

另一种生产者/消费者队列选项是使用 TPL Dataflow 库中的BufferBlock<T>BufferBlock<T>与通道相似。以下示例显示了如何声明BufferBlock<T>,生产者代码的样子以及消费者代码的样子:

var _asyncQueue = new BufferBlock<int>();

// Producer code
await _asyncQueue.SendAsync(7);
await _asyncQueue.SendAsync(13);
_asyncQueue.Complete();

// Consumer code
// Displays "7" followed by "13".
while (await _asyncQueue.OutputAvailableAsync())
  Trace.WriteLine(await _asyncQueue.ReceiveAsync());

示例消费者代码使用了OutputAvailableAsync,如果只有一个消费者则确实很有用。如果有多个消费者,则可能OutputAvailableAsync会对多个消费者返回true,即使只有一个项。如果队列已完成,则ReceiveAsync将抛出InvalidOperationException。因此,如果有多个消费者,则消费者代码通常看起来更像是以下这样:

while (true)
{
  int item;
  try
  {
    item = await _asyncQueue.ReceiveAsync();
  }
  catch (InvalidOperationException)
  {
    break;
  }
  Trace.WriteLine(item);
}

您还可以使用Nito.AsyncEx NuGet 库中的AsyncProducerConsumerQueue<T>类型。其 API 与BufferBlock<T>类似但并非完全相同:

var _asyncQueue = new AsyncProducerConsumerQueue<int>();

// Producer code
await _asyncQueue.EnqueueAsync(7);
await _asyncQueue.EnqueueAsync(13);
_asyncQueue.CompleteAdding();

// Consumer code
// Displays "7" followed by "13".
while (await _asyncQueue.OutputAvailableAsync())
  Trace.WriteLine(await _asyncQueue.DequeueAsync());

这个消费者代码还使用了OutputAvailableAsync,并且和BufferBlock<T>一样存在相同的问题。如果有多个消费者,消费者代码通常看起来更像是以下这样:

while (true)
{
  int item;
  try
  {
    item = await _asyncQueue.DequeueAsync();
  }
  catch (InvalidOperationException)
  {
    break;
  }
  Trace.WriteLine(item);
}

讨论

我建议在可能的情况下尽量使用通道作为异步生产者/消费者队列。除了节流外,它们还具有多个抽样选项,并且高度优化。但是,如果您的应用逻辑可以表达为通过其流动数据的“管道”,那么 TPL Dataflow 可能是一个更自然的选择。最后的选择是AsyncProducerConsumerQueue<T>,如果您的应用已经使用了来自AsyncEx的其他类型,则可能会有意义。

小贴士

通道可以在System.Threading.Channels NuGet 包中找到。BufferBlock<T> 类型在System.Threading.Tasks.Dataflow NuGet 包中。AsyncProducerConsumerQueue<T> 类型在Nito.AsyncEx NuGet 包中。

参见

9.6 配方涵盖了具有阻塞语义而不是异步语义的生产者/消费者队列。

9.12 配方涵盖了既有阻塞又有异步语义的生产者/消费者队列。

9.7 配方涵盖了异步堆栈和包,如果您想要一个没有先入先出语义的类似通道。

9.9 节流队列

问题

您有一个生产者/消费者队列,而且您的生产者可能比消费者运行得更快,这将导致不必要的内存使用。您还想保留所有队列项,因此需要一种方法来限制生产者的速度。

解决方案

当您使用生产者/消费者队列时,除非您确信消费者总是更快,否则您确实需要考虑如果您的生产者比您的消费者跑得快会发生什么。如果您生产的速度快于消费速度,则可能需要对队列进行节流。您可以通过指定最大元素数量来节流队列。当队列“满”时,它会向生产者施加背压,阻塞它们直到队列有更多空间。

通道可以通过创建有界通道而不是无界通道来进行节流。由于通道是异步的,生产者将被异步地限制:

Channel<int> queue = Channel.CreateBounded<int>(1);
ChannelWriter<int> writer = queue.Writer;

// This Write completes immediately.
await writer.WriteAsync(7);

// This Write (asynchronously) waits for the 7 to be removed
// before it enqueues the 13.
await writer.WriteAsync(13);

writer.Complete();

BufferBlock<T> 内置支持节流,详细探讨请参阅 5.4 配方。使用数据流块时,您可以设置BoundedCapacity选项:

var queue = new BufferBlock<int>(
    new DataflowBlockOptions { BoundedCapacity = 1 });

// This Send completes immediately.
await queue.SendAsync(7);

// This Send (asynchronously) waits for the 7 to be removed
// before it enqueues the 13.
await queue.SendAsync(13);

queue.Complete();

上述代码片段中的生产者使用了异步的SendAsync API;同样的方法适用于同步的Post API。

AsyncEx 类型 AsyncProducerConsumerQueue<T> 支持节流。只需用适当的值构造队列:

var queue = new AsyncProducerConsumerQueue<int>(maxCount: 1);

// This Enqueue completes immediately.
await queue.EnqueueAsync(7);

// This Enqueue (asynchronously) waits for the 7 to be removed
// before it enqueues the 13.
await queue.EnqueueAsync(13);

queue.CompleteAdding();

阻塞生产者/消费者队列还支持节流。您可以使用BlockingCollection<T>在创建时传递适当的值来限制项目的数量:

var queue = new BlockingCollection<int>(boundedCapacity: 1);

// This Add completes immediately.
queue.Add(7);

// This Add waits for the 7 to be removed before it adds the 13.
queue.Add(13);

queue.CompleteAdding();

讨论

当生产者可能比消费者运行得更快时,节流是必要的。一个您必须考虑的场景是,如果您的应用程序运行在不同于您的硬件的环境中,生产者是否可能比消费者更快。通常需要一些节流以确保您的应用程序可以在未来的硬件和/或云实例上运行,这些硬件和实例通常比开发者的机器更受限制。

节流将对生产者施加反压力,使其放慢速度以确保消费者能够处理所有项目,而不会导致不必要的内存压力。如果您不需要处理每个项目,您可以选择采样而不是节流。请参见食谱 9.10 了解采样生产者/消费者队列的方法。

提示

通道位于System.Threading.Channels NuGet 包中。BufferBlock<T>类型位于System.Threading.Tasks.Dataflow NuGet 包中。AsyncProducerConsumerQueue<T>类型位于Nito.AsyncEx NuGet 包中。

另请参阅

食谱 9.8 介绍了基本的异步生产者/消费者队列使用方法。

食谱 9.6 介绍了基本的同步生产者/消费者队列使用方法。

食谱 9.10 介绍了采样生产者/消费者队列,作为节流的替代方法。

9.10 采样队列

问题

您有一个生产者/消费者队列,但您的生产者可能比您的消费者运行得更快,这导致了不必要的内存使用。您不需要保留所有队列项目;您需要一种方法来筛选队列项目,以便较慢的生产者只需要处理重要的项目。

解决方案

通道是应用输入项采样的最简单方法。一个常见的例子是始终获取最新的n个项目,在队列满时丢弃最旧的项目:

Channel<int> queue = Channel.CreateBounded<int>(
    new BoundedChannelOptions(1)
    {
      FullMode = BoundedChannelFullMode.DropOldest,
    });
ChannelWriter<int> writer = queue.Writer;

// This Write completes immediately.
await writer.WriteAsync(7);

// This Write also completes immediately.
// The 7 is discarded unless a consumer has already retrieved it.
await writer.WriteAsync(13);

这是一种简单的方法来控制输入流,防止其淹没消费者。

还有其他BoundedChannelFullMode选项。例如,如果您希望保留最旧的项目,您可以在通道满时丢弃任何新项目:

Channel<int> queue = Channel.CreateBounded<int>(
    new BoundedChannelOptions(1)
    {
      FullMode = BoundedChannelFullMode.DropWrite,
    });
ChannelWriter<int> writer = queue.Writer;

// This Write completes immediately.
await writer.WriteAsync(7);

// This Write also completes immediately.
// The 13 is discarded unless a consumer has already retrieved the 7.
await writer.WriteAsync(13);

讨论

通道非常适合进行简单的采样。在许多情况下特别有用的选项是BoundedChannelFullMode.DropOldest。更复杂的采样可能需要由消费者自行完成。

如果您需要进行基于时间的采样,例如“每秒只有 10 个项目”,请使用 System.Reactive。System.Reactive 具有与时间相关的自然操作符。

提示

通道位于System.Threading.Channels NuGet 包中。

另请参阅

食谱 9.9 介绍了限制通道流量的节流功能,通过阻塞生产者而不是丢弃项目来限制通道中的项目数量。

食谱 9.8 介绍了基本的通道使用,包括生产者和消费者代码。

菜谱 6.4 介绍了使用System.Reactive进行节流和采样,支持基于时间的采样。

9.11 异步堆栈和包

问题

您需要一个传输管道,将消息或数据从代码的一部分传递到另一部分,但您不希望(或不需要)该传输管道具有先进先出的语义。

解决方案

Nito.AsyncEx库提供了类型AsyncCollection<T>,默认情况下类似于异步队列,但也可以充当任何类型的生产者/消费者集合。围绕IProducerConsumerCollection<T>的包装器,AsyncCollection<T>也是.NET BlockingCollection<T>async等效项,该项在菜谱 9.7 中有所介绍。

AsyncCollection<T>支持后进先出(堆栈)或无序(包)语义,取决于您传递给其构造函数的集合类型:

var _asyncStack = new AsyncCollection<int>(
    new ConcurrentStack<int>());
var _asyncBag = new AsyncCollection<int>(
    new ConcurrentBag<int>());

请注意,在堆栈中项目顺序方面存在竞争条件。如果所有生产者在消费者开始之前完成,则项目的顺序类似于常规堆栈:

// Producer code
await _asyncStack.AddAsync(7);
await _asyncStack.AddAsync(13);
_asyncStack.CompleteAdding();

// Consumer code
// Displays "13" followed by "7".
while (await _asyncStack.OutputAvailableAsync())
  Trace.WriteLine(await _asyncStack.TakeAsync());

当生产者和消费者同时执行(这是通常情况),消费者总是会获取最近添加的项。这将导致整个集合的行为不完全像一个堆栈。当然,包集合根本没有排序。

AsyncCollection<T>支持节流,如果生产者可能比消费者更快地向集合中添加内容,则这是必需的。只需使用适当的值构造集合即可:

var _asyncStack = new AsyncCollection<int>(
    new ConcurrentStack<int>(), maxCount: 1);

现在相同的生产者代码将根据需要异步等待:

// This Add completes immediately.
await _asyncStack.AddAsync(7);

// This Add (asynchronously) waits for the 7 to be removed
// before it enqueues the 13.
await _asyncStack.AddAsync(13);

_asyncStack.CompleteAdding();

示例消费者代码使用了OutputAvailableAsync,其限制与菜谱 9.8 中描述的相同。如果有多个消费者,消费者代码通常看起来更像以下内容:

while (true)
{
  int item;
  try
  {
    item = await _asyncStack.TakeAsync();
  }
  catch (InvalidOperationException)
  {
    break;
  }
  Trace.WriteLine(item);
}

讨论

AsyncCollection<T>只是具有略有不同 API 的BlockingCollection<T>的异步等效项。

提示

AsyncCollection<T>类型位于Nito.AsyncEx NuGet 包中。

参见

菜谱 9.8 介绍了异步队列,比异步堆栈或包更为常见。

菜谱 9.7 介绍了同步(阻塞)堆栈和包。

9.12 阻塞/异步队列

问题

您需要一个传输管道,以先进先出的方式将消息或数据从代码的一部分传递到另一部分,并且需要灵活性,以将生产者端或消费者端视为同步或异步。

例如,后台线程可能正在加载数据并将其推送到传输管道中,如果传输管道太满,您希望后台线程同步阻塞。同时,UI 线程正在从传输管道接收数据,您希望 UI 线程异步从传输管道中拉取数据,以保持 UI 的响应性。

解决方案

在查看第 9.6 节中的阻塞队列和第 9.8 节中的异步队列后,现在我们将学习一些同时支持阻塞和异步 API 的队列类型。

第一个是 TPL Dataflow NuGet 库中的BufferBlock<T>ActionBlock<T>BufferBlock<T>可以很容易地用作异步生产者/消费者队列(详见第 9.8 节):

var queue = new BufferBlock<int>();

// Producer code
await queue.SendAsync(7);
await queue.SendAsync(13);
queue.Complete();

// Consumer code for a single consumer
while (await queue.OutputAvailableAsync())
  Trace.WriteLine(await queue.ReceiveAsync());

// Consumer code for multiple consumers
while (true)
{
  int item;
  try
  {
    item = await queue.ReceiveAsync();
  }
  catch (InvalidOperationException)
  {
    break;
  }

  Trace.WriteLine(item);
}

如您在以下示例中所见,BufferBlock<T>也支持生产者和消费者的同步 API:

var queue = new BufferBlock<int>();

// Producer code
queue.Post(7);
queue.Post(13);
queue.Complete();

// Consumer code
while (true)
{
  int item;
  try
  {
    item = queue.Receive();
  }
  catch (InvalidOperationException)
  {
    break;
  }

  Trace.WriteLine(item);
}

使用BufferBlock<T>的消费者代码相当笨拙,因为这不是编写代码的“数据流方式”。TPL Dataflow 库包括许多可以链接在一起的块,使您能够定义反应网格。在这种情况下,可以使用ActionBlock<T>定义完成特定操作的生产者/消费者队列:

// Consumer code is passed to queue constructor.
ActionBlock<int> queue = new ActionBlock<int>(item => Trace.WriteLine(item));

// Asynchronous producer code
await queue.SendAsync(7);
await queue.SendAsync(13);

// Synchronous producer code
queue.Post(7);
queue.Post(13);
queue.Complete();

如果在您期望的平台上 TPL Dataflow 库不可用,则Nito.AsyncEx中也有一个AsyncProducerConsumerQueue<T>类型,它还支持同步和异步方法:

var queue = new AsyncProducerConsumerQueue<int>();

// Asynchronous producer code
await queue.EnqueueAsync(7);
await queue.EnqueueAsync(13);

// Synchronous producer code
queue.Enqueue(7);
queue.Enqueue(13);

queue.CompleteAdding();

// Asynchronous single consumer code
while (await queue.OutputAvailableAsync())
  Trace.WriteLine(await queue.DequeueAsync());

// Asynchronous multi-consumer code
while (true)
{
  int item;
  try
  {
    item = await queue.DequeueAsync();
  }
  catch (InvalidOperationException)
  {
    break;
  }
  Trace.WriteLine(item);
}

// Synchronous consumer code
foreach (int item in queue.GetConsumingEnumerable())
  Trace.WriteLine(item);

讨论

如果可能的话,我建议使用BufferBlock<T>ActionBlock<T>,因为 TPL Dataflow 库经过的测试比Nito.AsyncEx库更加全面。然而,如果您的应用程序已经使用了AsyncEx库的其他类型,那么AsyncProducerConsumerQueue<T>可能也会很有用。

也可以间接地使用System.Threading.Channels进行同步操作。它们的自然 API 是异步的,但由于它们是线程安全的集合,您可以通过将生产或消费代码包装在Task.Run中,然后阻塞Task.Run返回的任务来强制它们同步工作,就像这样:

Channel<int> queue = Channel.CreateBounded<int>(10);

// Producer code
ChannelWriter<int> writer = queue.Writer;
Task.Run(async () =>
{
  await writer.WriteAsync(7);
  await writer.WriteAsync(13);
  writer.Complete();
}).GetAwaiter().GetResult();

// Consumer code
ChannelReader<int> reader = queue.Reader;
Task.Run(async () =>
{
  while (await reader.WaitToReadAsync())
    while (reader.TryRead(out int value))
      Trace.WriteLine(value);
}).GetAwaiter().GetResult();

TPL Dataflow 块,AsyncProducerConsumerQueue<T>和 Channels 都支持通过在构造过程中传递选项来进行节流。当生产者推送项目比消费者消耗它们更快时,节流是必需的,这可能会导致您的应用程序占用大量内存。

小贴士

BufferBlock<T>ActionBlock<T>类型位于System.Threading.Tasks.Dataflow NuGet 包中。AsyncProducerConsumerQueue<T>类型位于Nito.AsyncEx NuGet 包中。Channels 位于System.Threading.Channels NuGet 包中。

另请参阅

第 9.6 节介绍了阻塞生产者/消费者队列。

第 9.8 节介绍了异步生产者/消费者队列。

第 5.4 节介绍了数据流块的节流。

第十章:取消

.NET 4.0 框架引入了详尽且设计良好的取消支持。这种支持是协作性的,这意味着可以请求取消但不能强制执行取消。由于取消是协作性的,除非编写支持取消的代码,否则不可能取消代码。因此,我建议尽可能在自己的代码中支持取消。

取消是一种信号类型,有两个不同的方面:触发取消的源和响应取消的接收器。在.NET 中,源是 CancellationTokenSource,接收器是 CancellationToken。本章的配方涵盖了取消的来源和接收器在正常使用中的应用,并描述了如何使用取消支持与非标准取消形式进行交互。

取消被视为一种特殊的错误。约定是取消的代码将抛出 OperationCanceledException 类型的异常(或其派生类型,如 TaskCanceledException)。这样调用代码就知道已观察到取消。

为了向调用代码指示您的方法支持取消,您应该将 CancellationToken 作为参数。该参数通常是最后一个参数,除非您的方法还报告进度(配方 2.3)。您还可以考虑为不需要取消的消费者提供重载或默认参数值:

public void CancelableMethodWithOverload(CancellationToken cancellationToken)
{
  // Code goes here.
}

public void CancelableMethodWithOverload()
{
  CancelableMethodWithOverload(CancellationToken.None);
}

public void CancelableMethodWithDefault(
    CancellationToken cancellationToken = default)
{
  // Code goes here.
}

CancellationToken.None 表示一个永远不会被取消的取消标记,是一个特殊值,等同于 default(CancellationToken)。当消费者不希望操作被取消时,会传递这个值。

异步流处理取消方式类似,但更复杂。有关异步流的取消详细信息,请参见配方 3.4。

10.1 发出取消请求

问题

您的代码调用可取消的代码(接受 CancellationToken 参数),而您需要取消它。

解决方案

CancellationTokenSource 类型是 CancellationToken 的源头。它仅使代码能够响应取消请求;CancellationTokenSource 的成员允许代码请求取消。

每个 CancellationTokenSource 都是独立的(除非将它们链接在一起,如配方 10.8 所述)。Token 属性返回该源的 CancellationTokenCancel 方法则发出实际的取消请求。

以下代码演示了如何创建 CancellationTokenSource,以及如何使用 TokenCancel。该代码使用了一个 async 方法,因为在短代码示例中更容易说明;相同的 Token/Cancel 对被用于取消所有类型的代码:

void IssueCancelRequest()
{
  using var cts = new CancellationTokenSource();
  var task = CancelableMethodAsync(cts.Token);

  // At this point, the operation has been started.

  // Issue the cancellation request.
  cts.Cancel();
}

在上面的示例代码中,task 变量在启动后被忽略;在真实的代码中,该任务可能会被存储在某个地方,并等待其完成,以便最终用户能够看到最终结果。

当您取消代码时,几乎总会存在竞态条件。可取消的代码可能在取消请求发出时几乎要完成,如果它在完成之前没有检查其取消令牌,它将实际上成功完成。实际上,当您取消代码时,有三种可能的结果:它可能响应取消请求(抛出 OperationCanceledException),它可能成功完成,或者它可能由于与取消无关的错误而完成(抛出其他异常)。

下面的代码与上一个示例相似,但它等待任务完成,展示了所有三种可能的结果:

async Task IssueCancelRequestAsync()
{
  using var cts = new CancellationTokenSource();
  var task = CancelableMethodAsync(cts.Token);

  // At this point, the operation is happily running.

  // Issue the cancellation request.
  cts.Cancel();

  // (Asynchronously) wait for the operation to finish.
  try
  {
    await task;
    // If we get here, the operation completed successfully
    //  before the cancellation took effect.
  }
  catch (OperationCanceledException)
  {
    // If we get here, the operation was canceled before it completed.
  }
  catch (Exception)
  {
    // If we get here, the operation completed with an error
    //  before the cancellation took effect.
    throw;
  }
}

通常,设置 CancellationTokenSource 和执行取消操作是在不同的方法中完成的。一旦取消了 CancellationTokenSource 实例,它就会永久取消。如果您需要另一个源,必须创建另一个实例。以下代码是一个更实际的基于 GUI 的示例,使用一个按钮启动异步操作,另一个按钮取消它。它还禁用和启用 StartButtonCancelButton,以确保一次只能进行一个操作:

private CancellationTokenSource _cts;

private async void StartButton_Click(object sender, RoutedEventArgs e)
{
  StartButton.IsEnabled = false;
  CancelButton.IsEnabled = true;
  try
  {
    _cts = new CancellationTokenSource();
    CancellationToken token = _cts.Token;
    await Task.Delay(TimeSpan.FromSeconds(5), token);
    MessageBox.Show("Delay completed successfully.");
  }
  catch (OperationCanceledException)
  {
    MessageBox.Show("Delay was canceled.");
  }
  catch (Exception)
  {
    MessageBox.Show("Delay completed with error.");
    throw;
  }
  finally
  {
    StartButton.IsEnabled = true;
    CancelButton.IsEnabled = false;
  }
}

private void CancelButton_Click(object sender, RoutedEventArgs e)
{
  _cts.Cancel();
  CancelButton.IsEnabled = false;
}

讨论

本配方中最真实的示例使用了一个 GUI 应用程序,但不要认为取消仅适用于用户界面。取消在服务器上同样适用;例如,ASP.NET 提供了一个表示请求超时或客户端断开连接的取消令牌。在服务器端,取消令牌源可能更为罕见,但您仍然可以使用它们;如果需要取消某些 ASP.NET 取消范围之外的操作(例如请求处理的某部分的额外超时),这些令牌非常有用。

参见

Recipe 10.4 讲述了如何在 async 代码中传递令牌。

Recipe 10.5 讲述了如何在并行代码中传递令牌。

Recipe 10.6 讲述了如何在响应式代码中使用令牌。

Recipe 10.7 讲述了如何在数据流网络中传递令牌。

10.2 通过轮询响应取消请求

问题

您的代码中有一个需要支持取消操作的循环。

解决方案

当您的代码中有一个处理循环时,没有更低级别的 API 可以传递 CancellationToken。在这种情况下,您应该定期检查令牌是否已取消。以下代码在执行 CPU 绑定的循环时定期观察令牌:

public int CancelableMethod(CancellationToken cancellationToken)
{
  for (int i = 0; i != 100; ++i)
  {
    Thread.Sleep(1000); // Some calculation goes here.
    cancellationToken.ThrowIfCancellationRequested();
  }
  return 42;
}

如果你的循环非常紧凑(即,循环体执行非常快),那么你可能希望限制检查取消令牌的频率。如常,在进行此类更改之前和之后,请先测量性能,然后决定哪种方式最佳。以下代码与之前的示例类似,但循环迭代更多,因此我添加了对令牌检查频率的限制:

public int CancelableMethod(CancellationToken cancellationToken)
{
  for (int i = 0; i != 100000; ++i)
  {
    Thread.Sleep(1); // Some calculation goes here.
    if (i % 1000 == 0)
      cancellationToken.ThrowIfCancellationRequested();
  }
  return 42;
}

应该使用的适当限制完全取决于你正在执行的工作量和取消需求的响应速度。

讨论

大多数情况下,你的代码应该将 CancellationToken 直接传递给下一层。在配方 10.4、10.5、10.6 和 10.7 中有此类示例。本配方中的轮询技术只有在需要支持取消的处理循环时才应使用。

CancellationToken 上还有另一个成员叫做 IsCancellationRequested,当令牌被取消时开始返回 true。有些人使用此成员来响应取消,通常通过返回默认值或 null。我不建议大多数代码使用此方法。标准的取消模式是引发 OperationCanceledException,由 ThrowIfCancellationRequested 处理。如果调用堆栈上游的代码想要捕获异常并像结果是 null 一样处理,那么可以这样做,但是任何使用 CancellationToken 的代码都应该遵循标准的取消模式。如果你决定不遵循取消模式,请务必清楚地记录下来。

ThrowIfCancellationRequested 通过 轮询 取消令牌来工作;你的代码必须定期调用它。还有一种方法可以注册在请求取消时调用的回调函数。回调方法更多地是为了与其他取消系统进行交互;10.9 配方 讲解了在取消时使用回调的方法。

另请参阅

10.4 配方 讲解了将令牌传递给 async 代码。

10.5 配方 讲解了将令牌传递给并行代码的方法。

10.6 配方 讲解了在响应式代码中使用令牌的方法。

10.7 配方 讲解了将令牌传递给数据流网络的方法。

10.9 配方 讲解了使用回调而非轮询来响应取消请求。

10.1 配方 讲解了发出取消请求。

10.3 由于超时而取消

问题

你有一些代码需要在超时后停止运行。

解决方案

取消是超时情况的自然解决方案。超时只是取消请求的一种类型。需要取消的代码只需像处理任何其他取消请求一样观察取消令牌;它既不应该知道也不关心取消源是定时器。

还有一些方便的取消令牌源方法,它们基于计时器自动发出取消请求。您可以将超时传递给构造函数:

async Task IssueTimeoutAsync()
{
  using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
  CancellationToken token = cts.Token;
  await Task.Delay(TimeSpan.FromSeconds(10), token);
}

或者,如果您已经有一个CancellationTokenSource实例,您可以为该实例启动超时:

async Task IssueTimeoutAsync()
{
  using var cts = new CancellationTokenSource();
  CancellationToken token = cts.Token;
  cts.CancelAfter(TimeSpan.FromSeconds(5));
  await Task.Delay(TimeSpan.FromSeconds(10), token);
}

讨论

要使用超时执行代码,请使用CancellationTokenSourceCancelAfter(或构造函数)。还有其他方法可以做同样的事情,但使用现有的取消系统是最简单和最有效的选择。

记住,需要取消的代码需要观察取消令牌;不可能轻易取消不可取消的代码。

另请参阅

配方 10.4 涵盖了向async代码传递令牌。

配方 10.5 涵盖了向并行代码传递令牌。

配方 10.6 涵盖了在响应式代码中使用令牌。

配方 10.7 涵盖了向数据流网格传递令牌。

10.4 取消异步代码

问题

您正在使用async代码并且需要支持取消。

解决方案

在异步代码中支持取消的最简单方法是将CancellationToken直接传递给下一层。以下示例代码执行异步延迟,然后返回一个值;通过将令牌传递给Task.Delay来支持取消:

public async Task<int> CancelableMethodAsync(CancellationToken cancellationToken)
{
  await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
  return 42;
}

许多异步 API 支持CancellationToken,因此自己启用取消通常只需要简单地获取一个令牌并传递它。作为一般规则,如果您的方法调用使用CancellationToken的 API,则您的方法也应该接受一个CancellationToken并将其传递给每个支持它的 API。

讨论

不幸的是,一些方法不支持取消。当您遇到这种情况时,没有简单的解决方案。除非将代码包装在单独的可执行文件中,否则无法安全地停止任意代码。如果您的代码调用不支持取消的代码,并且不想将该代码包装在单独的可执行文件中,您始终可以通过假装取消操作来选择忽略结果。

尽可能地提供取消选项是很重要的。这是因为在更高级别正确地取消依赖于更低级别的正确取消。因此,当您编写自己的async方法时,请尽量包含取消支持;您永远不知道哪个更高级别的方法将要调用您的方法,而它可能需要取消功能。

另请参阅

配方 10.1 涵盖了发出取消请求。

配方 10.3 涵盖了使用取消作为超时。

10.5 取消并行代码

问题

您正在使用并行代码并且需要支持取消。

解决方案

支持取消操作的最简单方法是通过CancellationToken传递给并行代码。Parallel方法通过接受ParallelOptions实例来支持此操作。您可以通过以下方式在ParallelOptions实例上设置CancellationToken

void RotateMatrices(IEnumerable<Matrix> matrices, float degrees,
    CancellationToken token)
{
  Parallel.ForEach(matrices,
      new ParallelOptions { CancellationToken = token },
      matrix => matrix.Rotate(degrees));
}

或者,可以直接在循环体中观察CancellationToken

void RotateMatrices2(IEnumerable<Matrix> matrices, float degrees,
    CancellationToken token)
{
  // Warning: not recommended; see below.
  Parallel.ForEach(matrices, matrix =>
  {
    matrix.Rotate(degrees);
    token.ThrowIfCancellationRequested();
  });
}

另一种方法工作量更大,并且不太灵活,因为并行循环会在AggregateException中包装OperationCanceledException。此外,如果将CancellationToken作为ParallelOptions实例的一部分传递,Parallel类可能会更智能地决定多久检查该令牌。因此,最好将令牌作为选项传递。如果将令牌作为选项传递,还可以将令牌传递给循环体,但不要仅仅将令牌传递给循环体。

并行 LINQ(PLINQ)还具有使用WithCancellation操作符的内置取消支持:

IEnumerable<int> MultiplyBy2(IEnumerable<int> values,
    CancellationToken cancellationToken)
{
  return values.AsParallel()
      .WithCancellation(cancellationToken)
      .Select(item => item * 2);
}

讨论

对于良好的用户体验,支持并行工作的取消很重要。如果您的应用程序正在进行并行工作,至少在短时间内会使用大量 CPU。即使不会干扰同一台机器上的其他应用程序,用户也会注意到高 CPU 使用率。因此,建议在进行并行计算(或任何其他 CPU 密集型工作)时支持取消操作,即使高 CPU 使用率的总时间不会非常长。

参见

配方 10.1 涵盖了发出取消请求。

10.6 取消 System.Reactive 代码

问题

您有一些响应式代码,需要使其支持取消。

解决方案

System.Reactive 库中有一个对可观察流的订阅概念。您的代码可以释放该订阅以取消对流的订阅。在许多情况下,这已足以逻辑上取消流。例如,以下代码在按下一个按钮时订阅鼠标点击事件,并在按下另一个按钮时取消订阅(取消订阅):

private IDisposable _mouseMovesSubscription;

private void StartButton_Click(object sender, RoutedEventArgs e)
{
  IObservable<Point> mouseMoves = Observable
      .FromEventPattern<MouseEventHandler, MouseEventArgs>(
          handler => (s, a) => handler(s, a),
          handler => MouseMove += handler,
          handler => MouseMove -= handler)
      .Select(x => x.EventArgs.GetPosition(this));
  _mouseMovesSubscription = mouseMoves.Subscribe(value =>
  {
    MousePositionLabel.Content = "(" + value.X + ", " + value.Y + ")";
  });
}

private void CancelButton_Click(object sender, RoutedEventArgs e)
{
  if (_mouseMovesSubscription != null)
    _mouseMovesSubscription.Dispose();
}

使用所有其他部分用于取消的CancellationTokenSource/CancellationToken系统使 System.Reactive 与之一起工作非常方便。本配方的其余部分介绍了 System.Reactive 可观察对象如何与CancellationToken交互。

主要用例之一是将可观察代码包装在异步代码中。基本方法已在配方 8.5 中介绍过,现在您想要添加CancellationToken支持。一般来说,最简单的方法是使用响应式操作执行所有操作,然后调用ToTask将最后产生的元素转换为可等待任务。以下代码展示了如何异步获取序列中的最后一个元素:

CancellationToken cancellationToken = ...
IObservable<int> observable = ...
int lastElement = await observable.TakeLast(1).ToTask(cancellationToken);
// or: int lastElement = await observable.ToTask(cancellationToken);

取第一个元素非常类似;只需在调用ToTask之前修改可观察对象即可:

CancellationToken cancellationToken = ...
IObservable<int> observable = ...
int firstElement = await observable.Take(1).ToTask(cancellationToken);

将整个可观察序列异步转换为任务同样类似:

CancellationToken cancellationToken = ...
IObservable<int> observable = ...
IList<int> allElements = await observable.ToList().ToTask(cancellationToken);

最后,让我们考虑相反的情况。我们已经讨论了几种处理方式,这些方式在 System.Reactive 代码响应 CancellationToken — 即,CancellationTokenSource 取消请求被转换为该订阅的处置。也可以反过来:响应处置而发出取消请求。

FromAsyncStartAsyncSelectMany 运算符都支持取消,就像在 Recipe 8.6 中所示的那样。这些运算符涵盖了绝大多数的使用情况。Rx 还提供了一个 CancellationDisposable 类型,当其被处置时取消一个 CancellationToken。你可以直接使用 CancellationDisposable,就像这样:

using (var cancellation = new CancellationDisposable())
{
  CancellationToken token = cancellation.Token;
  // Pass the token to methods that respond to it.
}
// At this point, the token is canceled.

讨论

System.Reactive (Rx) 有其自己的取消概念:处理订阅的释放。本文介绍了如何使 Rx 在 .NET 4.0 引入的通用取消框架中良好运作的几种方式。只要您在代码的 Rx 部分,使用 Rx 订阅/释放系统;如果仅在边界引入 CancellationToken 支持,则更为清晰。

参见

Recipe 8.5 讲述了围绕 Rx 代码的异步包装(不带取消支持)。

Recipe 8.6 讲述了围绕异步代码的 Rx 包装(带取消支持)。

Recipe 10.1 讲述了发出取消请求的过程。

10.7 取消数据流网格

问题

您正在使用数据流网格,并且需要支持取消。

解决方案

在您的代码中支持取消的最佳方式是通过将 CancellationToken 传递给可取消的 API。数据流网格中的每个块都支持取消作为其 DataflowBlockOptions 的一部分。如果您想扩展您的自定义数据流块以支持取消,设置块选项的 CancellationToken 属性:

IPropagatorBlock<int, int> CreateMyCustomBlock(
    CancellationToken cancellationToken)
{
  var blockOptions = new ExecutionDataflowBlockOptions
  {
    CancellationToken = cancellationToken
  };
  var multiplyBlock = new TransformBlock<int, int>(item => item * 2,
      blockOptions);
  var addBlock = new TransformBlock<int, int>(item => item + 2,
      blockOptions);
  var divideBlock = new TransformBlock<int, int>(item => item / 2,
      blockOptions);

  var flowCompletion = new DataflowLinkOptions
  {
    PropagateCompletion = true
  };
  multiplyBlock.LinkTo(addBlock, flowCompletion);
  addBlock.LinkTo(divideBlock, flowCompletion);

  return DataflowBlock.Encapsulate(multiplyBlock, divideBlock);
}

在这个例子中,我在网格中的每个块上应用了CancellationToken,虽然这并非完全必要。因为我同时在链接中传播完成,我可以将其应用于第一个块并允许其传播。取消被认为是错误的一种特殊形式,因此管道中更深层的块将会因为这个错误而完成。话虽如此,如果我取消一个网格,我可能也会同时取消每个块,所以在这种情况下,我通常会设置每个块的CancellationToken选项。

讨论

在数据流网格中,取消不是刷新的一种形式。当取消一个块时,它会丢弃其所有的输入并拒绝接收任何新项。因此,如果在块运行时取消它,您会丢失数据。

参见

Recipe 10.1 讲述了发出取消请求的过程。

10.8 注入取消请求

问题

您的代码层需要响应取消请求,并向下一层发出自己的取消请求。

解决方案

.NET 4.0 取消系统内置支持这种情况,称为链接取消标记。可以创建一个与一个(或多个)现有标记相关联的取消标记源。当创建链接取消标记源时,当任何现有标记被取消或链接源被显式取消时,结果标记就会被取消。

下面的代码执行异步 HTTP 请求。传递给GetWithTimeoutAsync方法的标记表示用户请求的取消,并且GetWithTimeoutAsync方法还为请求应用超时:

async Task<HttpResponseMessage> GetWithTimeoutAsync(HttpClient client,
    string url, CancellationToken cancellationToken)
{
  using CancellationTokenSource cts = CancellationTokenSource
      .CreateLinkedTokenSource(cancellationToken);
  cts.CancelAfter(TimeSpan.FromSeconds(2));
  CancellationToken combinedToken = cts.Token;

  return await client.GetAsync(url, combinedToken);
}

当用户取消现有的cancellationToken或链接源通过CancelAfter取消时,生成的combinedToken会被取消。

讨论

尽管前面的示例仅使用了单个CancellationToken源,但CreateLinkedTokenSource方法可以接受任意数量的取消标记作为参数。这使您可以从中实现逻辑取消的单个组合标记。例如,ASP.NET 提供了一个取消标记,表示用户断开连接(HttpContext.RequestAborted);处理程序代码可以创建一个链接标记,响应用户断开连接或自己的取消原因,如超时。

要记住链接取消标记源的生命周期。前面的示例是通常的用例,其中一个或多个取消标记传递给方法,然后将它们链接在一起并作为组合标记传递。还要注意,示例代码使用了using语句,确保在操作完成时(并且不再使用组合标记时),链接的取消标记源会被处理。考虑一下,如果代码没有处理链接的取消标记源会发生什么:可能GetWithTimeoutAsync方法会多次使用相同的(长期存在的)现有标记调用,这种情况下,每次调用方法都会链接一个新的标记源。即使 HTTP 请求完成(而且没有任何使用组合标记的东西),链接的源仍然附加到现有的标记上。为了防止这种内存泄漏,请在不再需要组合标记时处理链接的取消标记源。

参见

食谱 10.1 概述了一般情况下发出取消请求的操作。

食谱 10.3 涵盖了使用取消作为超时的情况。

10.9 与其他取消系统的互操作

问题

您有一些带有自己取消观念的外部或遗留代码,并且希望使用标准的CancellationToken来控制它。

解决方案

CancellationToken 有两种主要方式来响应取消请求:轮询(在 第 10.2 节 中讨论)和回调(本节的主题)。轮询通常用于 CPU 绑定的代码,如数据处理循环;而回调通常用于其他所有场景。您可以使用 CancellationToken.Register 方法为令牌注册回调。

例如,假设您正在封装 System.Net.NetworkInformation.Ping 类型,并且希望能够取消 ping。Ping 类已经具有基于 Task 的 API,但不支持 CancellationToken。相反,Ping 类具有自己的 SendAsyncCancel 方法,您可以使用该方法取消 ping。为此,请注册一个调用该方法的回调:

async Task<PingReply> PingAsync(string hostNameOrAddress,
    CancellationToken cancellationToken)
{
  using var ping = new Ping();
  Task<PingReply> task = ping.SendPingAsync(hostNameOrAddress);
  using CancellationTokenRegistration _ = cancellationToken
      .Register(() => ping.SendAsyncCancel());
  return await task;
}

现在,当请求取消时,CancellationToken 将为您调用 SendAsyncCancel 方法,取消 SendPingAsync 方法。

讨论

CancellationToken.Register 方法可用于与任何类型的替代取消系统进行交互。但请注意,当方法接受 CancellationToken 时,取消请求应仅取消该操作。某些替代取消系统通过关闭某些资源来实现取消,这可能会取消多个操作;这种取消系统与 CancellationToken 不太匹配。如果决定将此类取消封装在 CancellationToken 中,则应记录其不寻常的取消语义。

要记住回调注册的生命周期。Register 方法返回一个可处置对象,在不再需要该回调时应予以处理。前面的示例代码使用 using 语句在异步操作完成时进行清理。如果代码没有该 using 语句,那么每次使用相同(长期存在的)CancellationToken 调用代码时,都会添加另一个回调(这反过来会使 Ping 对象保持活动状态)。为避免内存和资源泄漏,请在不再需要回调时处置回调注册。

参见

第 10.2 节 涵盖了通过轮询而非回调来响应取消令牌。

第 10.1 节 概述了一般的取消请求发出。

第十一章:友好的函数式面向对象编程

现代程序需要异步编程;如今,服务器必须比以往更好地扩展,终端用户应用程序必须比以往更具响应性。开发人员发现他们必须学习异步编程,当他们探索这个世界时,他们发现它经常与他们习惯的传统面向对象编程相冲突。

这样做的核心原因是因为异步编程是函数式的。通过“函数式”,我不是指“它有效”;我指的是它是一种函数式编程风格,而不是过程式编程风格。很多开发人员在大学学习了基本的函数式编程,之后几乎没有再碰过。如果像(car (cdr '(3 5 7)))这样的代码让你感到不安,因为被压抑的记忆涌入脑海,那么你可能属于这一类别。但不要害怕;一旦习惯了,现代异步编程并不那么难。

async的主要突破在于你仍然可以在编写和理解异步方法时以过程化方式思考。这使得编写和理解异步方法变得更容易。然而,在底层,异步代码仍然具有函数式的特性,当人们试图将async方法强行融入传统面向对象设计时,这会导致一些问题。本章的示例处理异步代码与面向对象编程相冲突的摩擦点。

当将现有的面向对象编码基础转换为友好的async编码基础时,这些摩擦点尤为明显。

11.1 异步接口和继承

问题

你有一个在接口或基类中的方法,你想要将其变成异步的。

解决方案

理解这个问题及其解决方案的关键是意识到async是一个实现细节。async关键字只能应用于具有实现的方法;不可能将其应用于抽象方法或接口方法(除非它们有默认实现)。但是,你可以定义一个与async方法具有相同签名的方法,只是没有async关键字。

记住类型是可等待的,而不是方法。你可以await一个方法返回的Task,无论该方法是否使用async实现。因此,一个接口或抽象方法可以直接返回一个Task(或Task<T>),并且该方法的返回值是可等待的。

以下代码定义了一个带有异步方法的接口(不带async关键字),该接口的实现(带有async),以及一个独立的方法,该方法通过await消耗接口的方法:

interface IMyAsyncInterface
{
  Task<int> CountBytesAsync(HttpClient client, string url);
}

class MyAsyncClass : IMyAsyncInterface
{
  public async Task<int> CountBytesAsync(HttpClient client, string url)
  {
    var bytes = await client.GetByteArrayAsync(url);
    return bytes.Length;
  }
}

async Task UseMyInterfaceAsync(HttpClient client, IMyAsyncInterface service)
{
  var result = await service.CountBytesAsync(client, "http://www.example.com");
  Trace.WriteLine(result);
}

这个模式也适用于基类中的抽象方法。

异步方法签名仅意味着实现可能是异步的。如果实际实现没有真正的异步工作要做,那么它也可能是同步的。例如,一个测试存根可以通过使用类似FromResult的东西来实现相同的接口(不带async):

class MyAsyncClassStub : IMyAsyncInterface
{
  public Task<int> CountBytesAsync(HttpClient client, string url)
  {
    return Task.FromResult(13);
  }
}

讨论

在撰写本文时,asyncawait 仍在不断普及中。随着异步方法变得更加普遍,接口和基类上的异步方法也将变得更加常见。只要记住可等待的是返回类型(而不是方法),以及异步方法定义可以是异步的或同步的,它们并不难处理。

参见

Recipe 2.2 介绍了返回已完成任务,使用同步代码实现异步方法签名。

11.2 异步构造:工厂

问题

您正在编写一个需要在其构造函数中完成一些异步工作的类型。

解决方案

构造函数不能是async,也不能使用await关键字。在构造函数中使用await肯定会很有用,但这将大大改变 C#语言。

一种可能性是拥有一个构造函数和一个async初始化方法,以便可以像这样使用该类型:

var instance = new MyAsyncClass();
await instance.InitializeAsync();

这种方法有一些缺点。很容易忘记调用InitializeAsync方法,并且在构造完成后实例不能立即可用。

更好的解决方案是使类型成为其自身的工厂。以下类型展示了异步工厂方法模式:

class MyAsyncClass
{
  private MyAsyncClass()
  {
  }

  private async Task<MyAsyncClass> InitializeAsync()
  {
    await Task.Delay(TimeSpan.FromSeconds(1));
    return this;
  }

  public static Task<MyAsyncClass> CreateAsync()
  {
    var result = new MyAsyncClass();
    return result.InitializeAsync();
  }
}

构造函数和InitializeAsync方法都是private的,以防其他代码误用它们;因此,创建实例的唯一方式是通过静态的CreateAsync工厂方法。调用代码在初始化完成之前无法访问实例。

其他代码可以像这样创建一个实例:

MyAsyncClass instance = await MyAsyncClass.CreateAsync();

讨论

此模式的主要优点是其他代码无法获得未初始化的MyAsyncClass实例。这就是为什么我在可以使用它时更喜欢这种模式而不是其他方法的主要原因。

不幸的是,这种方法在某些场景下不起作用,特别是当您的代码使用依赖注入提供程序时。没有主要的依赖注入或控制反转库能与async代码配合工作。如果您发现自己处于这些情况之一,那么可以考虑几种替代方案。

如果您正在创建的实例实际上是一个共享资源,那么您可以使用 Recipe 14.1 中讨论的异步延迟类型。否则,您可以使用 Recipe 11.3 中讨论的异步初始化模式。

这是一个推荐的示例:

class MyAsyncClass
{
  public MyAsyncClass()
  {
    InitializeAsync();
  }

  // BAD CODE!!
  private async void InitializeAsync()
  {
    await Task.Delay(TimeSpan.FromSeconds(1));
  }
}

乍一看,这似乎是一个合理的方法:您得到一个启动异步操作的常规构造函数;然而,由于使用了async void,存在几个缺点。第一个问题是当构造函数完成时,实例仍在异步初始化中,并且没有明显的方法来确定异步初始化何时完成。第二个问题是错误处理:InitializeAsync引发的任何异常不能被围绕对象构造的任何catch子句捕获。

参见

Recipe 11.3 讲述了异步初始化模式,这是一种与依赖注入/控制反转容器一起使用的异步构造方式。

Recipe 14.1 讲述了异步延迟初始化,这是如果实例在概念上是共享资源或服务,则是一种可行的解决方案。

11.3 异步构造:异步初始化模式

问题

您正在编写一个类型,其构造函数需要进行一些异步工作,但不能使用异步工厂模式(Recipe 11.2)因为实例是通过反射创建的(例如,依赖注入/控制反转库,数据绑定,Activator.CreateInstance等)。

解决方案

当您遇到这种情况时,必须返回一个未初始化的实例,尽管可以通过应用常见模式来缓解这种情况:异步初始化模式。每个需要异步初始化的类型都应定义一个属性,如下所示:

Task Initialization { get; }

我通常喜欢在需要异步初始化的类型的标记接口中定义这个:

/// <summary>
/// Marks a type as requiring asynchronous initialization
/// and provides the result of that initialization.
/// </summary>
public interface IAsyncInitialization
{
  /// <summary>
  /// The result of the asynchronous initialization of this instance.
  /// </summary>
  Task Initialization { get; }
}

当您实现此模式时,应在构造函数中启动初始化(并分配Initialization属性)。异步初始化的结果(包括任何异常)通过该Initialization属性公开。以下是使用异步初始化实现简单类型的示例实现:

class MyFundamentalType : IMyFundamentalType, IAsyncInitialization
{
  public MyFundamentalType()
  {
    Initialization = InitializeAsync();
  }

  public Task Initialization { get; private set; }

  private async Task InitializeAsync()
  {
    // Asynchronously initialize this instance.
    await Task.Delay(TimeSpan.FromSeconds(1));
  }
}

如果您使用依赖注入/控制反转库,可以使用以下代码创建和初始化此类型的实例:

IMyFundamentalType instance = UltimateDIFactory.Create<IMyFundamentalType>();
var instanceAsyncInit = instance as IAsyncInitialization;
if (instanceAsyncInit != null)
  await instanceAsyncInit.Initialization;

您可以将此模式扩展到允许异步初始化的类型组合。在以下示例中,定义了另一种依赖于IMyFundamentalType的类型:

class MyComposedType : IMyComposedType, IAsyncInitialization
{
  private readonly IMyFundamentalType _fundamental;

  public MyComposedType(IMyFundamentalType fundamental)
  {
    _fundamental = fundamental;
    Initialization = InitializeAsync();
  }

  public Task Initialization { get; private set; }

  private async Task InitializeAsync()
  {
    // Asynchronously wait for the fundamental instance to initialize,
    //  if necessary.
    var fundamentalAsyncInit = _fundamental as IAsyncInitialization;
    if (fundamentalAsyncInit != null)
      await fundamentalAsyncInit.Initialization;

    // Do our own initialization (synchronous or asynchronous).
    ...
  }
}

组合类型在所有组件初始化完成之前会等待。遵循的规则是每个组件都应在InitializeAsync结束时初始化完成。这确保了所有依赖类型作为组合初始化的一部分被初始化。任何组件初始化引发的异常会传播到组合类型的初始化过程中。

讨论

如果可能的话,我建议使用异步工厂(Recipe 11.2)或异步延迟初始化(Recipe 14.1)而不是这个解决方案。这些是最佳方法,因为您永远不会暴露未初始化的实例。然而,如果您的实例是由依赖注入/控制反转、数据绑定等创建的,那么您将被迫暴露未初始化的实例,在这种情况下,我建议使用本配方中的异步初始化模式。

从异步接口的配方(Recipe 11.1)记住,异步方法签名只意味着该方法可能是异步的。MyComposedType.InitializeAsync代码是一个很好的例子:如果IMyFundamentalType实例不同时实现IAsyncInitializationMyComposedType本身没有异步初始化,则其InitializeAsync方法将同步完成。

检查实例是否实现IAsyncInitialization并对其进行初始化的代码有点笨拙,当有一个依赖于更多组件的组合类型时,情况会变得更加复杂。可以很容易地创建一个辅助方法来简化代码:

public static class AsyncInitialization
{
  public static Task WhenAllInitializedAsync(params object[] instances)
  {
    return Task.WhenAll(instances
        .OfType<IAsyncInitialization>()
        .Select(x => x.Initialization));
  }
}

您可以调用InitializeAllAsync并传入您想要初始化的任何实例;该方法将忽略不实现IAsyncInitialization的实例。然后,依赖于三个注入实例的组合类型的初始化代码可以看起来像以下内容:

private async Task InitializeAsync()
{
 // Asynchronously wait for all 3 instances to initialize, if necessary.
 await AsyncInitialization.WhenAllInitializedAsync(_fundamental,
     _anotherType, _yetAnother);

 // Do our own initialization (synchronous or asynchronous).
 ...
}

另请参阅

Recipe 11.2 介绍了异步工厂,这是一种进行异步构建而不暴露未初始化实例的方法。

Recipe 14.1 介绍了异步延迟初始化,如果实例是共享资源或服务,则可以使用该方法。

Recipe 11.1 介绍了异步接口。

11.4 异步属性

问题

您有一个您想要使async的属性。该属性未用于数据绑定。

解决方案

这是在将现有代码转换为使用async时经常遇到的问题;在这种情况下,您有一个其 getter 调用现在是异步的方法的属性。然而,“异步属性”这种东西并不存在。不能在属性上使用async关键字,而这是一个好事。属性的 getter 应该返回当前值;它们不应该启动后台操作:

// What we think we want (does not compile).
public int Data
{
  async get
  {
    await Task.Delay(TimeSpan.FromSeconds(1));
    return 13;
  }
}

当您发现您的代码需要一个“异步属性”时,您的代码真正需要的是略有不同。解决方案取决于您的属性值是否需要被评估一次或多次;您可以在以下语义之间做出选择:

  • 每次读取时异步评估的值

  • 一次异步评估并为将来访问缓存的值

如果你的“异步属性”在每次读取时都需要启动一个新的(异步)评估,那么它不是一个属性;它是一个伪装成方法的属性。如果在将同步代码转换为异步时遇到了这种情况,那么现在是时候承认原始设计实际上是错误的了;该属性本来应该是一个方法:

// As an asynchronous method.
public async Task<int> GetDataAsync()
{
  await Task.Delay(TimeSpan.FromSeconds(1));
  return 13;
}

返回Task<int>直接从属性中是可能的,如下面的代码所示:

// This "async property" is an asynchronous method.
// This "async property" is a Task-returning property.
public Task<int> Data
{
  get { return GetDataAsync(); }
}

private async Task<int> GetDataAsync()
{
  await Task.Delay(TimeSpan.FromSeconds(1));
  return 13;
}

然而,我不建议这种方法。如果每次访问属性都会启动一个新的异步操作,那么这个“属性”实际上应该是一个方法。它是一个异步方法使得每次都启动一个新的异步操作更加清晰,因此 API 不会误导。食谱 11.3 和 11.6 确实使用返回任务的属性,但这些属性适用于整个实例;它们不会在每次读取时启动新的异步操作。

有时候你希望每次检索属性值时都对其进行评估。其他时候,你希望属性仅启动一次(异步)评估并缓存该结果以供将来使用。在这种情况下,你可以使用异步懒初始化。这种解决方案在食谱 14.1 中有详细说明,但与此同时,这里是一个示例,展示了代码的样子:

// As a cached value
public AsyncLazy<int> Data
{
  get { return _data; }
}

private readonly AsyncLazy<int> _data =
    new AsyncLazy<int>(async () =>
    {
      await Task.Delay(TimeSpan.FromSeconds(1));
      return 13;
    });

代码将仅执行一次异步评估,然后将同一值返回给所有调用者。调用代码看起来像下面这样:

int value = await instance.Data;

在这种情况下,属性语法是适当的,因为只有一个评估在进行。

讨论

自问一个重要的问题是是否读取属性应该启动一个新的异步操作;如果答案是肯定的,那么使用异步方法而不是属性。如果属性应该作为延迟评估的缓存,则使用异步初始化(参见食谱 14.1)。在这个食谱中,我没有涵盖用于数据绑定的属性;我在食谱 14.3 中涵盖了这些内容。

当你将同步属性转换为“异步属性”时,这里有一个要做的示例:

private async Task<int> GetDataAsync()
{
  await Task.Delay(TimeSpan.FromSeconds(1));
  return 13;
}

public int Data
{
  // BAD CODE!!
  get { return GetDataAsync().Result; }
}

当谈论在async代码中的属性时,值得考虑状态如何与异步代码相关联。如果你正在将同步代码库转换为异步,这一点尤为重要。考虑你在 API 中暴露的任何状态(例如通过属性);对于每个状态,问自己,具有异步操作进行中的对象的当前状态是什么?并没有正确的答案,但重要的是考虑你想要的语义,并进行文档记录。

例如,考虑Stream.Position,它表示流指针的当前偏移量。使用同步 API 时,当你调用Stream.ReadStream.Write时,读取/写入完成并且Stream.Position更新以反映新位置,然后ReadWrite方法返回。这对同步代码的语义很清晰。

现在,考虑Stream.ReadAsyncStream.WriteAsync:何时应更新Stream.Position?当读取/写入操作完成时,还是在实际发生之前?如果在操作完成之前更新它,Stream.Position会在ReadAsync/WriteAsync返回时同步更新,还是可能稍后才会发生?

这是一个很好的例子,展示了一个公开状态的属性在同步代码中具有完全清晰的语义,但在异步代码中却没有明显正确的语义。这并不是世界末日——你只需要在使你的类型支持异步时考虑整个 API,并记录你选择的语义。

另请参阅

Recipe 14.1 详细介绍了异步延迟初始化。

Recipe 14.3 介绍了需要支持数据绑定的“异步属性”。

11.5 异步事件

问题

当你需要使用可能是async的处理程序处理事件,并且需要检测处理程序是否已完成时,你有一个事件。请注意,在引发事件时,这是一个罕见的情况;通常在引发事件时,你并不关心处理程序何时完成。

解决方案

检测async void处理程序何时返回是不可行的,因此你需要一些替代方法来检测异步处理程序何时完成。Universal Windows 平台引入了一个称为deferrals的概念,你可以使用它来跟踪异步处理程序。异步处理程序在其第一个await之前分配一个延迟,并在完成时通知延迟。同步处理程序不需要使用延迟。

Nito.AsyncEx库包含一个名为DeferralManager的类型,被引发事件的组件使用。这个延迟管理器允许事件处理程序分配延迟,并跟踪所有延迟何时完成。

对于每个需要等待处理程序完成的事件,你首先扩展你的事件参数类型:

public class MyEventArgs : EventArgs, IDeferralSource
{
  private readonly DeferralManager _deferrals = new DeferralManager();

  ... // Your own constructors and properties

  public IDisposable GetDeferral()
  {
    return _deferrals.DeferralSource.GetDeferral();
  }

  internal Task WaitForDeferralsAsync()
  {
    return _deferrals.WaitForDeferralsAsync();
  }
}

当处理异步事件处理程序时,最好使你的事件参数类型是线程安全的。实现这一点的最简单方法是使其不可变(即,其所有属性都是只读的)。

然后,每次你引发事件时,你可以(异步地)等待所有异步事件处理程序完成。以下代码将在没有处理程序时返回一个已完成的任务;否则,它将创建你事件参数类型的新实例,传递给处理程序,并等待任何异步处理程序完成:

public event EventHandler<MyEventArgs> MyEvent;

private async Task RaiseMyEventAsync()
{
  EventHandler<MyEventArgs> handler = MyEvent;
  if (handler == null)
    return;

  var args = new MyEventArgs(...);
  handler(this, args);
  await args.WaitForDeferralsAsync();
}

然后,异步事件处理程序可以在using块内使用延期;延期在被处理时通知延期管理器:

async void AsyncHandler(object sender, MyEventArgs args)
{
  using IDisposable deferral = args.GetDeferral();
  await Task.Delay(TimeSpan.FromSeconds(2));
}

这与 Universal Windows 延期的工作方式略有不同。在 Universal Windows API 中,每个需要延期的事件定义其自己的延期类型,并且该延期类型有一个显式的Complete方法,而不是IDisposable

讨论

在 .NET 中,有两种逻辑上不同的事件类型,它们的语义差别很大。我称之为通知事件命令事件;这不是官方术语,只是我为了清晰起见选择的术语。通知事件是为了通知其他组件某种情况而引发的事件。通知是单向的;事件的发送者不在乎是否有任何事件接收者。在通知事件中,发送者和接收者可以完全断开连接。大多数事件都是通知事件;一个例子是按钮点击。

相反,命令事件是为了代表发送组件实现某些功能而引发的事件。命令事件在术语的真正意义上并不是“事件”,尽管它们通常被实现为 .NET 事件。命令的发送者必须等待接收者处理完它才能继续。如果你使用事件来实现访问者模式,那么这些就是命令事件。生命周期事件也是命令事件,因此 ASP.NET 页面生命周期事件和许多 UI 框架事件,如 Xamarin 的Application.PageAppearing,属于这一类别。任何实际上是实现的 UI 框架事件也是命令事件(例如BackgroundWorker.DoWork)。

通知事件不需要任何特殊的代码来启用异步处理程序;事件处理程序可以是async void并且能够正常工作。当事件发送者引发事件时,异步事件处理程序不会立即完成,但这并不重要,因为它们只是通知事件。所以,如果你的事件是通知事件,你需要做的工作总量是:什么都不用做。

命令事件是另一回事。当你有一个命令事件时,你需要一种方法来检测处理程序何时完成。前面使用延期的解决方案应该仅用于命令事件。

提示

Nito.AsyncEx NuGet 包中的DeferralManager类型。

参见

第二章介绍了异步编程的基础知识。

11.6 异步处理

问题

你有一个具有异步操作但还需要启用其资源释放的类型。

解决方案

在处理实例的释放时,有几个常见的选项:你可以将释放视为应用于所有现有操作的取消请求,或者你可以实现真正的异步释放

将释放视为取消在 Windows 上有着历史悠久的先例;例如文件流和套接字在关闭时取消任何现有的读取或写入。通过定义自己的私有CancellationTokenSource并将该令牌传递给内部操作,你可以在 .NET 中实现类似的效果。通过以下代码,Dispose将取消操作但不会等待这些操作完成:

class MyClass : IDisposable
{
  private readonly CancellationTokenSource _disposeCts =
      new CancellationTokenSource();

  public async Task<int> CalculateValueAsync()
  {
    await Task.Delay(TimeSpan.FromSeconds(2), _disposeCts.Token);
    return 13;
  }

  public void Dispose()
  {
    _disposeCts.Cancel();
  }
}

代码展示了围绕Dispose的基本模式。在实际应用程序中,你应该进行检查以确保对象尚未被释放,并允许用户提供自己的CancellationToken(使用来自菜谱 10.8 的技术):

public async Task<int> CalculateValueAsync(CancellationToken cancellationToken)
{
  using CancellationTokenSource combinedCts = CancellationTokenSource
      .CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);
  await Task.Delay(TimeSpan.FromSeconds(2), combinedCts.Token);
  return 13;
}

当调用Dispose时,正在进行的操作将被取消:

async Task UseMyClassAsync()
{
  Task<int> task;
  using (var resource = new MyClass())
  {
    task = resource.CalculateValueAsync(default);
  }

  // Throws OperationCanceledException.
  var result = await task;
}

对于某些类型,将Dispose实现为取消请求完全可以(例如,HttpClient具有这些语义)。然而,其他类型需要知道所有操作何时完成。对于这些类型,你需要某种形式的异步处理。

异步释放是在 C# 8.0 和 .NET Core 3.0 中引入的一种技术。BCL 引入了一个新的IAsyncDisposable接口,它是IDisposable的异步等价物。同时,语言还引入了一个await using语句,它是using的异步等价物。因此,现在希望在释放期间执行异步工作的类型现在具备了这种能力:

class MyClass : IAsyncDisposable
{
  public async ValueTask DisposeAsync()
  {
    await Task.Delay(TimeSpan.FromSeconds(2));
  }
}

DisposeAsync的返回类型是ValueTask而不是Task,但标准的asyncawait关键字在处理ValueTask时与处理Task一样有效。

实现IAsyncDisposable的类型通常由await using来消耗:

await using (var myClass = new MyClass())
{
  ...
} // DisposeAsync is invoked (and awaited) here.

如果需要避免使用ConfigureAwait(false),是可行的,但有点麻烦,因为你必须在await using语句之外声明变量:

var myClass = new MyClass();
await using (myClass.ConfigureAwait(false))
{
  ...
} // DisposeAsync is invoked (and awaited) here with ConfigureAwait(false).

讨论

异步释放肯定比将Dispose实现为取消请求更容易,只有在确实需要时才应使用更复杂的方法。事实上,大多数情况下你甚至可以不释放任何东西,这显然是最简单的方法,因为你不必做任何事情。

本篇介绍了两种处理释放的模式;如果需要,也可以同时使用它们。同时使用可以给你的类型赋予使用await using时的干净关闭语义,以及使用Dispose时的“取消”语义。总体上我不建议这样做,但这确实是一种选择。

另请参阅

菜谱 10.8 介绍了链接的取消令牌。

菜谱 11.1 介绍了异步接口。

菜谱 2.10 讨论了实现返回ValueTask的方法。

菜谱 2.7 介绍了如何使用ConfigureAwait(false)来避免上下文。

第十二章:同步

当您的应用程序使用并发(几乎所有.NET 应用程序都会这样)时,您需要注意一种情况:一段代码需要更新数据,同时其他代码需要访问相同的数据。每当发生这种情况时,您都需要同步对数据的访问。本章的配方涵盖了用于同步访问的最常见类型。但是,如果您适当地使用本书中的其他配方,您会发现许多常见的同步已经由相应的库为您完成。在深入研究同步配方之前,让我们更详细地看看可能需要或不需要同步的一些常见情况。

提示

本节中的同步解释略有简化,但结论都是正确的。

同步有两种主要类型:通信数据保护。当一段代码需要通知另一段代码某些条件(例如,新消息已到达)时使用通信。我将在本章的配方中更详细地讨论通信;本介绍的其余部分讨论数据保护。

以下所有三个条件都为真时,您需要使用同步来保护共享数据:

  • 多段代码同时运行。

  • 这些段代码正在访问(读取或写入)相同的数据。

  • 至少有一段代码正在更新(写入)数据。

第一个条件的原因显而易见;如果您的整个代码从头到尾顺序运行,并且从不并发发生,那么您永远不必担心同步。这对一些简单的控制台应用程序来说是成立的,但绝大多数.NET 应用程序确实会使用某种并发。第二个条件意味着,如果每段代码都有自己的本地数据,而它们不共享,则无需同步;本地数据从未从任何其他代码访问。如果存在共享数据但数据永远不会更改,比如使用不可变类型定义数据,也不需要同步。第三个条件涵盖的是像配置值之类的在应用程序开始时设置然后从不更改的情况。如果仅读取共享数据,则不需要同步;只有共享更新的数据需要同步。

数据保护的目的是为每段代码提供数据的一致视图。如果一段代码正在更新数据,那么您可以使用同步使这些更新对系统的其他部分看起来是原子的。

学习何时需要同步需要一些实践,因此在开始本章的配方之前,我们将通过几个例子来讲解。首先,考虑以下代码:

async Task MyMethodAsync()
{
  int value = 10;
  await Task.Delay(TimeSpan.FromSeconds(1));
  value = value + 1;
  await Task.Delay(TimeSpan.FromSeconds(1));
  value = value - 1;
  await Task.Delay(TimeSpan.FromSeconds(1));
  Trace.WriteLine(value);
}

如果MyMethodAsync方法是从线程池线程调用的(例如,在Task.Run内部),那么访问value的代码行可能会在不同的线程池线程上运行。但是它是否需要同步?不需要,因为它们都不可能同时运行。该方法是异步的,但也是顺序执行的(意味着它一次进行一个部分的进展)。

好的,让我们稍微复杂化这个例子。这次我们将运行并发的异步代码:

private int value;

async Task ModifyValueAsync()
{
  await Task.Delay(TimeSpan.FromSeconds(1));
  value = value + 1;
}

// WARNING: may require synchronization; see discussion below.
async Task<int> ModifyValueConcurrentlyAsync()
{
  // Start three concurrent modifications.
  Task task1 = ModifyValueAsync();
  Task task2 = ModifyValueAsync();
  Task task3 = ModifyValueAsync();

  await Task.WhenAll(task1, task2, task3);

  return value;
}

上述代码正在启动三个并发运行的修改。它是否需要同步?这要看情况。如果你知道该方法是从 GUI 或 ASP.NET 上下文(或任何只允许一段代码同时运行的上下文)调用的,那么不需要同步,因为实际的data修改代码运行时,它会在不同的时间运行于其他两个data修改之外。例如,如果前面的代码在 GUI 上下文中运行,那么只有一个 UI 线程会执行每个data修改,因此必须一个接一个地执行它们。因此,如果你知道上下文是一次一个的上下文,那么就不需要同步。然而,如果相同的方法是从线程池线程(例如,从Task.Run)调用的,那么就需要同步。在这种情况下,三个data修改可以在不同的线程池线程上运行并同时更新data.Value,因此你需要同步访问data.Value

现在让我们考虑另一个问题:

private int value;

async Task ModifyValueAsync()
{
  int originalValue = value;
  await Task.Delay(TimeSpan.FromSeconds(1));
  value = originalValue + 1;
}

考虑如果ModifyValueAsync被同时调用多次会发生什么。即使它是从一个一次一个的上下文中调用的,数据成员在每次ModifyValueAsync调用之间是共享的,并且当该方法执行await时,值可能随时更改。如果你想要避免这种共享,即使在一次一个的上下文中,你也可能需要应用同步。换句话说,为了确保每次调用ModifyValueAsync都等到所有先前的调用完成,你需要添加同步。即使上下文确保所有代码只使用一个线程(比如 UI 线程),在这种情况下也是如此。在这种情况下,同步是异步方法的一种限流方式(参见 Recipe 12.2)。

让我们再看一个async示例。你可以使用Task.Run来执行我称之为“简单并行处理”的操作——这是一种基本的并行处理方式,不提供Parallel/PLINQ 真正并行处理所具有的效率和可配置性。以下代码使用简单并行处理更新一个共享值:

// BAD CODE!!
async Task<int> SimpleParallelismAsync()
{
  int value = 0;
  Task task1 = Task.Run(() => { value = value + 1; });
  Task task2 = Task.Run(() => { value = value + 1; });
  Task task3 = Task.Run(() => { value = value + 1; });
  await Task.WhenAll(task1, task2, task3);
  return value;
}

这段代码在线程池上运行了三个单独的任务(通过Task.Run),它们都修改同一个value。因此,我们的同步条件适用,并且在这里确实需要同步。请注意,尽管value是一个局部变量,我们仍然需要同步;尽管它是局部于一个方法,但它仍然在多个线程之间共享

转向真正的并行代码,让我们考虑一个使用Parallel类型的示例:

void IndependentParallelism(IEnumerable<int> values)
{
  Parallel.ForEach(values, item => Trace.WriteLine(item));
}

由于这段代码使用了Parallel,我们必须假设并行循环的主体(item => Trace.WriteLine(item))可能在多个线程上运行。然而,循环的主体只从自己的数据中读取;这里没有线程之间的数据共享。Parallel类将数据分配给线程,以便它们中的任何一个不必共享其数据。每个运行其循环主体的线程都与运行相同循环主体的所有其他线程是独立的。因此,前述代码不需要同步。

让我们看一个聚合示例,类似于食谱 4.2 中涵盖的示例:

// BAD CODE!!
int ParallelSum(IEnumerable<int> values)
{
  int result = 0;
  Parallel.ForEach(source: values,
      localInit: () => 0,
      body: (item, state, localValue) => localValue + item,
      localFinally: localValue => { result += localValue; });
  return result;
}

在这个例子中,代码再次使用多个线程;这次,每个线程从其本地值初始化为 0 开始(() => 0),并且对于每个线程处理的输入值,它将输入值添加到其本地值中((item, state, localValue) => localValue + item)。最后,所有本地值都添加到返回值中(localValue => { result += localValue; })。前两个步骤没有问题,因为没有线程之间共享的内容;每个线程的本地和输入值与所有其他线程的本地和输入值是独立的。然而,最后一步是有问题的;当每个线程的本地值添加到返回值时,这是一个共享变量(result)被多个线程访问并更新的情况。因此,在最后一步中,您需要使用同步(参见食谱 12.1)。

PLINQ、数据流和响应式库与Parallel示例非常相似:只要您的代码只处理自己的输入,就无需担心同步。我发现如果我适当地使用这些库,大多数代码几乎不需要我添加同步。

最后,让我们讨论集合。请记住,需要同步的三个条件是多段代码共享数据数据更新

不可变类型自然是线程安全的,因为它们无法更改;不可能更新不可变集合,因此不需要同步。例如,以下代码不需要同步,因为每个单独的线程池线程将值推送到堆栈时,它正在创建一个新的具有该值的不可变堆栈,不会改变原始的stack

async Task<bool> PlayWithStackAsync()
{
  ImmutableStack<int> stack = ImmutableStack<int>.Empty;

  Task task1 = Task.Run(() => Trace.WriteLine(stack.Push(3).Peek()));
  Task task2 = Task.Run(() => Trace.WriteLine(stack.Push(5).Peek()));
  Task task3 = Task.Run(() => Trace.WriteLine(stack.Push(7).Peek()));
  await Task.WhenAll(task1, task2, task3);

  return stack.IsEmpty; // Always returns true.
}

当您的代码使用不可变集合时,通常会有一个共享的“根”变量,该变量本身不是不可变的。在这种情况下,您需要使用同步。在以下代码中,每个线程将一个值推送到堆栈(创建一个新的不可变堆栈),然后更新共享的根变量;代码需要同步以更新stack变量:

// BAD CODE!!
async Task<bool> PlayWithStackAsync()
{
  ImmutableStack<int> stack = ImmutableStack<int>.Empty;

  Task task1 = Task.Run(() => { stack = stack.Push(3); });
  Task task2 = Task.Run(() => { stack = stack.Push(5); });
  Task task3 = Task.Run(() => { stack = stack.Push(7); });
  await Task.WhenAll(task1, task2, task3);

  return stack.IsEmpty;
}

线程安全集合(例如,ConcurrentDictionary)与不可变集合非常不同。与不可变集合不同,线程安全集合可以被更新。但它们内置了所有需要的同步,所以你不必担心同步集合更改。如果以下代码更新了一个Dictionary而不是ConcurrentDictionary,它将需要同步;但由于它更新了一个ConcurrentDictionary,所以不需要同步:

async Task<int> ThreadsafeCollectionsAsync()
{
  var dictionary = new ConcurrentDictionary<int, int>();

  Task task1 = Task.Run(() => { dictionary.TryAdd(2, 3); });
  Task task2 = Task.Run(() => { dictionary.TryAdd(3, 5); });
  Task task3 = Task.Run(() => { dictionary.TryAdd(5, 7); });
  await Task.WhenAll(task1, task2, task3);

  return dictionary.Count; // Always returns 3.
}

12.1 阻塞锁

问题

你有一些共享数据,需要安全地从多个线程读取和写入。

解决方案

这种情况的最佳解决方案是使用lock语句。当一个线程进入锁时,它将阻止任何其他线程进入该锁,直到锁被释放为止:

class MyClass
{
  // This lock protects the _value field.
  private readonly object _mutex = new object();

  private int _value;

  public void Increment()
  {
    lock (_mutex)
    {
      _value = _value + 1;
    }
  }
}

讨论

.NET 框架中还有许多其他类型的锁,例如MonitorSpinLockReaderWriterLockSlim。在大多数应用程序中,几乎不应直接使用这些锁类型。特别是当开发人员没有必要使用ReaderWriterLockSlim时,会很自然地跳到它。基本的lock语句可以很好地处理 99%的情况。

在使用锁时有四个重要的指导原则:

  • 限制锁的可见性。

  • 记录锁保护的内容。

  • 最小化锁定代码。

  • 在持有锁时不要执行任意代码。

首先,你应该努力限制锁的可见性。在lock语句中使用的对象应该是一个私有字段,绝不应该暴露给类外的任何方法。通常每种类型最多只有一个锁成员;如果你有多个,请考虑将该类型重构为不同的类型。你可以锁定任何引用类型,但我更倾向于专门为lock语句使用一个字段,就像最后一个例子中那样。如果你在另一个实例上进行锁定,请确保它是私有的,只能在你的类内部使用;它不应该从构造函数传入或从属性获取器返回。你绝不应该lock(this)或锁定任何Typestring的实例;这些锁定可能会导致死锁,因为它们可以从其他代码中访问。

其次,记录锁保护的内容。在初次编写代码时很容易忽视这一步,但随着代码复杂性的增加,它变得更加重要。

第三,尽量减少在持有锁时执行的代码。要注意的一件事是阻塞调用;理想情况下,你的代码在持有锁时不应该阻塞。

最后,绝对不要在锁内调用任意代码。任意代码可能包括触发事件、调用虚方法或调用委托。如果必须执行任意代码,请在释放锁之后执行。

参见

Recipe 12.2 介绍了与async兼容的锁。lock语句不兼容await

Recipe 12.3 介绍了线程间的信号传递。lock语句旨在保护共享数据,而不是在线程间发送信号。

食谱 12.5 讲解了节流,这是锁的一种泛化。锁可以被视为一次只允许一个线程通过。

12.2 异步锁

问题

如果你有一些共享数据,并且需要安全地从多个代码块中进行读写,这些代码块可能使用 await

解决方案

.NET 框架中的 SemaphoreSlim 类型已在 .NET 4.5 中更新为与 async 兼容。以下是如何使用它的方法:

class MyClass
{
  // This lock protects the _value field.
  private readonly SemaphoreSlim _mutex = new SemaphoreSlim(1);

  private int _value;

  public async Task DelayAndIncrementAsync()
  {
    await _mutex.WaitAsync();
    try
    {
      int oldValue = _value;
      await Task.Delay(TimeSpan.FromSeconds(oldValue));
      _value = oldValue + 1;
    }
    finally
    {
      _mutex.Release();
    }
  }
}

你还可以使用 Nito.AsyncEx 库中的 AsyncLock 类型,它具有稍微更加优雅的 API:

class MyClass
{
  // This lock protects the _value field.
  private readonly AsyncLock _mutex = new AsyncLock();

  private int _value;

  public async Task DelayAndIncrementAsync()
  {
    using (await _mutex.LockAsync())
    {
      int oldValue = _value;
      await Task.Delay(TimeSpan.FromSeconds(oldValue));
      _value = oldValue + 1;
    }
  }
}

讨论

与 食谱 12.1 中提到的相同准则同样适用于这里,特别是:

  • 限制锁的可见性。

  • 记录锁保护的内容。

  • 最小化在锁内的代码。

  • 在持有锁时绝不执行任意代码。

保持你的锁实例私有;不要在类外部暴露它们。确保清楚地记录(并仔细考虑)锁实例到底保护什么。最小化在持有锁时执行的代码。特别地,不要调用任意代码,包括触发事件、调用虚方法和调用委托。

提示

Nito.AsyncEx NuGet 包中的 AsyncLock 类型。

参见

食谱 12.4 讲解了与 async 兼容的信号处理。锁的作用是保护共享数据,而不是作为信号。

食谱 12.5 讲解了节流,这是锁的一种泛化。锁可以被视为一次只允许一个线程通过。

12.3 阻塞信号

问题

你需要从一个线程向另一个线程发送通知。

解决方案

最常见和通用的跨线程信号是 ManualResetEventSlim。手动重置事件可以处于两种状态中的一种:信号或未信号。任何线程都可以将事件设置为信号状态或将事件重置为未信号状态。线程还可以等待事件信号。

下面两个方法由不同的线程调用;一个线程等待另一个线程的信号:

class MyClass
{
  private readonly ManualResetEventSlim _initialized =
      new ManualResetEventSlim();

  private int _value;

  public int WaitForInitialization()
  {
    _initialized.Wait();
    return _value;
  }

  public void InitializeFromAnotherThread()
  {
    _value = 13;
    _initialized.Set();
  }
}

讨论

ManualResetEventSlim 是一个很好的通用信号,用于一个线程向另一个线程发送信号,但只有在适当时才应使用它。如果“信号”实际上是通过线程之间发送某些数据的 消息,那么考虑使用生产者/消费者队列。另一方面,如果信号只是用于协调对共享数据的访问,则应使用锁。

.NET 框架中还有其他较少使用的线程同步信号类型。如果 ManualResetEventSlim 不符合你的需求,考虑使用 AutoResetEventCountdownEventBarrier

ManualResetEventSlim 是一个同步信号,因此 WaitForInitialization 将阻塞调用线程,直到信号被发送。如果你想等待信号而不阻塞线程,则需要一个异步信号,如 食谱 12.4 中描述的那样。

参见

食谱 9.6 讲解了阻塞生产者/消费者队列。

12.1 配方 涵盖阻塞锁。

12.4 配方 涵盖 async 兼容的信号。

12.4 异步信号

问题

您需要从代码的一部分发送通知到另一部分,并且通知的接收者必须异步等待它。

解决方案

使用 TaskCompletionSource<T> 异步发送通知,如果通知只需发送一次。发送代码调用 TrySetResult,接收代码等待其 Task 属性:

class MyClass
{
  private readonly TaskCompletionSource<object> _initialized =
      new TaskCompletionSource<object>();

  private int _value1;
  private int _value2;

  public async Task<int> WaitForInitializationAsync()
  {
    await _initialized.Task;
    return _value1 + _value2;
  }

  public void Initialize()
  {
    _value1 = 13;
    _value2 = 17;
    _initialized.TrySetResult(null);
  }
}

TaskCompletionSource<T> 类型可用于异步等待任何类型的情况 —— 在本例中,来自代码其他部分的通知。如果信号仅发送一次,则此方法效果很好,但如果需要同时关闭和打开信号,则效果不佳。

Nito.AsyncEx 库包含 AsyncManualResetEvent 类型,这是用于异步代码的 ManualResetEvent 的近似等效物。以下示例是编造的,但展示了如何使用 AsyncManualResetEvent 类型:

class MyClass
{
  private readonly AsyncManualResetEvent _connected =
      new AsyncManualResetEvent();

  public async Task WaitForConnectedAsync()
  {
    await _connected.WaitAsync();
  }

  public void ConnectedChanged(bool connected)
  {
    if (connected)
      _connected.Set();
    else
      _connected.Reset();
  }
}

讨论

信号是一种通用的通知机制。但如果这个“信号”是消息,用于从一段代码发送数据到另一段代码,则考虑使用生产者/消费者队列。同样,不要仅仅为了协调对共享数据的访问而使用通用信号;在这种情况下,请使用异步锁。

提示

AsyncManualResetEvent 类型位于 Nito.AsyncEx NuGet 包中。

另请参阅

9.8 配方 涵盖异步生产者/消费者队列。

12.2 配方 涵盖异步锁。

12.3 配方 涵盖阻塞信号,可用于跨线程的通知。

12.5 节流

问题

您有高度并发的代码,实际上是过于并发,需要一些方法来限制并发。

当应用程序的某些部分无法跟上其他部分时,导致数据项累积并消耗内存时,代码过于并发。在这种情况下,通过节流部分代码可以防止内存问题。

解决方案

解决方案根据代码正在执行的并发类型而异。这些解决方案都将并发限制为特定值。反应式扩展具有更强大的选项,例如滑动时间窗口;有关 System.Reactive observables 的节流更详尽地介绍在 6.4 配方 中。

Dataflow 和并行代码都具有内置选项用于节流并发:

IPropagatorBlock<int, int> DataflowMultiplyBy2()
{
  var options = new ExecutionDataflowBlockOptions
  {
    MaxDegreeOfParallelism = 10
  };

  return new TransformBlock<int, int>(data => data * 2, options);
}

// Using Parallel LINQ (PLINQ)
IEnumerable<int> ParallelMultiplyBy2(IEnumerable<int> values)
{
  return values.AsParallel()
      .WithDegreeOfParallelism(10)
      .Select(item => item * 2);
}

// Using the Parallel class
void ParallelRotateMatrices(IEnumerable<Matrix> matrices, float degrees)
{
  var options = new ParallelOptions
  {
    MaxDegreeOfParallelism = 10
  };
  Parallel.ForEach(matrices, options, matrix => matrix.Rotate(degrees));
}

并发异步代码可以通过使用 SemaphoreSlim 进行节流:

async Task<string[]> DownloadUrlsAsync(HttpClient client,
    IEnumerable<string> urls)
{
  using var semaphore = new SemaphoreSlim(10);
  Task<string>[] tasks = urls.Select(async url =>
  {
    await semaphore.WaitAsync();
    try
    {
      return await client.GetStringAsync(url);
    }
    finally
    {
      semaphore.Release();
    }
  }).ToArray();
  return await Task.WhenAll(tasks);
}

讨论

当您发现代码使用了过多资源(例如 CPU 或网络连接)时,可能需要进行节流。请记住,最终用户通常拥有比开发者更弱的计算机,因此最好进行稍微多一点的节流,而不是太少。

另请参阅

6.4 配方 涵盖反应式代码的节流。

第十三章:调度

当一段代码执行时,它必须在某个线程上运行。调度器 是一个决定某段代码在哪里运行的对象。在 .NET 框架中有几种不同的调度器类型,它们稍微有些不同地被并行和数据流代码使用。

我建议在可能的情况下指定调度器;默认情况通常是正确的。例如,在异步代码中,await 操作符会自动在相同的上下文中恢复方法,除非你覆盖了这个默认行为,如 Recipe 2.7 中所述。类似地,响应式代码对于引发其事件有合理的默认上下文,你可以通过 ObserveOn 覆盖它们,如 Recipe 6.2 中所述。

如果你需要其他代码在特定上下文(例如 UI 线程上下文或 ASP.NET 请求上下文)中执行,则可以使用本章中的调度配方来控制代码的调度。

13.1 在线程池中安排工作

问题

当你有一段代码,明确希望在线程池线程上执行时。

解决方案

绝大多数情况下,你会想使用Task.Run,这非常简单。以下代码会阻塞线程池线程 2 秒:

Task task = Task.Run(() =>
{
  Thread.Sleep(TimeSpan.FromSeconds(2));
});

Task.Run 也完全理解返回值和异步 lambda。以下代码中 Task.Run 返回的任务将在 2 秒后完成,结果为 13:

Task<int> task = Task.Run(async () =>
{
  await Task.Delay(TimeSpan.FromSeconds(2));
  return 13;
});

Task.Run 返回一个 Task(或 Task<T>),可以自然地被异步或响应式代码消耗。

讨论

Task.Run 在 UI 应用程序中非常理想,当你有耗时的工作需要执行,而不能在 UI 线程上完成时。例如,Recipe 8.4 使用 Task.Run 将并行处理推送到线程池线程。然而,在 ASP.NET 上除非你非常确定自己知道在做什么,否则不要在 ASP.NET 上使用 Task.Run。在 ASP.NET 上,请求处理代码已经在线程池线程上运行,因此将其推送到另一个线程池线程通常是适得其反的。

Task.RunBackgroundWorkerDelegate.BeginInvokeThreadPool.QueueUserWorkItem 的有效替代品。这些旧的 API 不应在新代码中使用;使用Task.Run 的代码编写起来更容易且随时间维护起来也更简单。此外,Task.Run 处理了大多数 Thread 的使用案例,因此大多数 Thread 的使用也可以替换为 Task.Run(只有单线程公寓线程是个例外)。

并行和数据流代码默认在线程池上执行,因此通常不需要在使用Parallel、Parallel LINQ 或 TPL Dataflow 库执行的代码中使用Task.Run

如果你正在进行动态并行处理,则应该使用Task.Factory.StartNew而不是Task.Run。这是必要的,因为Task.Run返回的Task已经配置为用于异步使用(即,被异步或响应式代码消耗)。它也不支持高级概念,如父/子任务,在动态并行代码中更为常见。

参见

Recipe 8.6 讲解了如何使用响应式代码消耗异步代码(例如从Task.Run返回的任务)。

Recipe 8.4 讲解了如何通过Task.Run异步等待并行代码,这是最简单的方式。

Recipe 4.4 讲解了动态并行处理,这种情况下应使用Task.Factory.StartNew而不是Task.Run

13.2 使用任务调度器执行代码

问题

你有多段代码需要以某种特定的方式执行。例如,你可能需要所有代码在 UI 线程上执行,或者可能只需要同时执行一定数量的代码片段。

本篇介绍了如何定义和构造用于这些代码片段的调度器。如何实际应用该调度器是接下来两篇的主题。

解决方案

.NET 中有很多不同的类型可以处理调度;本篇重点介绍TaskScheduler,因为它是可移植且相对容易使用的。

最简单的TaskSchedulerTaskScheduler.Default,它将工作排队到线程池中。你很少会在自己的代码中指定TaskScheduler.Default,但要意识到它的重要性,因为它是许多调度场景的默认值。Task.Run、并行和数据流代码都使用TaskScheduler.Default

你可以通过使用TaskScheduler.FromCurrentSynchronizationContext来捕获特定的上下文,然后稍后将工作安排回去:

TaskScheduler scheduler = TaskScheduler.FromCurrentSynchronizationContext();

这段代码创建了一个TaskScheduler来捕获当前的SynchronizationContext并将代码安排到该上下文中。SynchronizationContext是一个表示通用调度上下文的类型。在.NET 框架中有几种不同的上下文;大多数 UI 框架提供了代表 UI 线程的SynchronizationContext,而 ASP.NET Core 之前提供了代表 HTTP 请求上下文的SynchronizationContext

ConcurrentExclusiveSchedulerPair是.NET 4.5 中引入的另一种强大类型;实际上,它是两个相关的调度器。ConcurrentScheduler成员是一个允许多个任务同时执行的调度器,只要没有任务在ExclusiveScheduler上执行。ExclusiveScheduler一次只执行一个任务,并且只有在ConcurrentScheduler上没有任务正在执行时才执行:

var schedulerPair = new ConcurrentExclusiveSchedulerPair();
TaskScheduler concurrent = schedulerPair.ConcurrentScheduler;
TaskScheduler exclusive = schedulerPair.ExclusiveScheduler;

ConcurrentExclusiveSchedulerPair的一个常见用途是只使用ExclusiveScheduler来确保一次只执行一个任务。在ExclusiveScheduler上执行的代码将在线程池上运行,但将被限制为仅对使用同一ExclusiveScheduler实例的所有其他代码执行独占。

另一个ConcurrentExclusiveSchedulerPair的用途是作为一个限流调度器。你可以创建一个ConcurrentExclusiveSchedulerPair来限制其并发性。在这种情况下,通常不使用ExclusiveScheduler

var schedulerPair = new ConcurrentExclusiveSchedulerPair(
    TaskScheduler.Default, maxConcurrencyLevel: 8);
TaskScheduler scheduler = schedulerPair.ConcurrentScheduler;

注意,这种限流仅在执行时限流代码;这与 Recipe 12.5 中涵盖的逻辑限流有很大不同。特别是,异步代码在等待操作时不被认为正在执行。ConcurrentScheduler限制执行代码;其他限流,如SemaphoreSlim,在更高级别(即整个async方法)限流。

讨论

您可能已经注意到,最后一个代码示例将TaskScheduler.Default传递给ConcurrentExclusiveSchedulerPair的构造函数中。这是因为ConcurrentExclusiveSchedulerPair在现有的TaskScheduler周围应用其并发/独占逻辑。

本教程介绍了TaskScheduler.FromCurrentSynchronizationContext,用于在捕获的上下文中执行代码。也可以直接使用SynchronizationContext在该上下文中执行代码;然而,我不建议这种方法。尽可能使用await运算符在隐式捕获的上下文中恢复,或者使用TaskScheduler包装器。

永远不要使用特定于平台的类型在 UI 线程上执行代码。WPF、Silverlight、iOS 和 Android 都提供Dispatcher类型,Universal Windows 使用CoreDispatcher,而 Windows Forms 有ISynchronizeInvoke接口(即Control.Invoke)。不要在新代码中使用这些类型;只需假装它们不存在。SynchronizationContext是围绕这些类型的通用抽象。

System.Reactive (Rx)引入了更通用的调度器抽象:IScheduler。Rx 调度器能够包装任何其他类型的调度器;TaskPoolScheduler将包装任何TaskFactory(其中包含TaskScheduler)。Rx 团队还定义了一个可以手动控制用于测试的IScheduler实现。如果确实需要使用调度器抽象,我建议使用 Rx 中的IScheduler;它设计良好,定义明确,易于测试。然而,大多数情况下不需要调度器抽象,早期的库(如任务并行库(TPL)和 TPL Dataflow)仅理解TaskScheduler类型。

参见

Recipe 13.3 涵盖了将TaskScheduler应用于并行代码的方法。

Recipe 13.4 讲述了如何在数据流代码中应用 TaskScheduler

Recipe 12.5 讲述了更高级别的逻辑限流。

Recipe 6.2 讲述了用于事件流的 System.Reactive 调度器。

Recipe 7.6 讲述了 System.Reactive 测试调度器。

13.3 调度并行代码

问题

你需要控制并行代码中各个代码片段的执行方式。

解决方案

一旦创建了适当的 TaskScheduler 实例(参见 Recipe 13.2),你可以将其包含在传递给 Parallel 方法的选项中。以下代码接受一个矩阵序列的序列。它启动了一些并行循环,并希望限制所有循环的总并行度,而不管每个序列中有多少矩阵:

void RotateMatrices(IEnumerable<IEnumerable<Matrix>> collections, float degrees)
{
  var schedulerPair = new ConcurrentExclusiveSchedulerPair(
      TaskScheduler.Default, maxConcurrencyLevel: 8);
  TaskScheduler scheduler = schedulerPair.ConcurrentScheduler;
  ParallelOptions options = new ParallelOptions { TaskScheduler = scheduler };
  Parallel.ForEach(collections, options,
      matrices => Parallel.ForEach(matrices, options,
          matrix => matrix.Rotate(degrees)));
}

讨论

Parallel.Invoke 还接受 ParallelOptions 的实例,因此你可以像对待 Parallel.ForEach 一样向 Parallel.Invoke 传递 TaskScheduler。如果你正在进行动态并行代码,你可以直接将 TaskScheduler 传递给 TaskFactory.StartNewTask.ContinueWith

无法将 TaskScheduler 传递给并行 LINQ(PLINQ)代码。

参见

Recipe 13.2 讲述了常见的任务调度器以及如何在它们之间进行选择。

13.4 数据流同步使用调度器

问题

你需要控制数据流代码中各个代码片段的执行方式。

解决方案

一旦创建了适当的 TaskScheduler 实例(参见 Recipe 13.2),你可以将其包含在传递给数据流块的选项中。当从 UI 线程调用时,以下代码创建一个数据流网格,通过线程池将其所有输入值乘以二,然后将结果值附加到列表框的项目上(在 UI 线程上):

var options = new ExecutionDataflowBlockOptions
{
  TaskScheduler = TaskScheduler.FromCurrentSynchronizationContext(),
};
var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
var displayBlock = new ActionBlock<int>(
    result => ListBox.Items.Add(result), options);
multiplyBlock.LinkTo(displayBlock);

讨论

在协调数据流网格不同部分的块操作时,指定 TaskScheduler 特别有用。例如,你可以使用 ConcurrentExclusiveSchedulerPair.ExclusiveScheduler 确保块 A 和 C 永远不会同时执行代码,同时允许块 B 在任何时候执行。

请注意,通过 TaskScheduler 进行同步仅适用于代码执行时。例如,如果你有一个运行异步代码并应用独占调度器的动作块,那么在等待时代码不被认为是正在运行。

你可以为任何类型的数据流块指定 TaskScheduler。即使一个块可能不执行你的代码(例如 BufferBlock<T>),它仍然有一些必要的内部任务需要完成,它将使用提供的 TaskScheduler 进行所有内部工作。

参见

Recipe 13.2 讲述了常见的任务调度器以及如何在它们之间进行选择。

第十四章:场景

在本章中,我们将介绍各种类型和技术,以解决编写并发程序时的一些常见场景。这些类型的情况可能填满另一本完整的书,因此我只选择了一些我认为最有用的情况。

14.1 初始化共享资源

问题

您有一个在代码的多个部分之间共享的资源。第一次访问该资源时需要对其进行初始化。

解决方案

.NET 框架包括一种专门用于此目的的类型:Lazy<T>。您可以使用用于初始化实例的工厂委托构造Lazy<T>类型的实例。然后,通过Value属性使实例可用。以下代码演示了Lazy<T>类型:

static int _simpleValue;
static readonly Lazy<int> MySharedInteger = new Lazy<int>(() => _simpleValue++);

void UseSharedInteger()
{
  int sharedValue = MySharedInteger.Value;
}

无论多少线程同时调用UseSharedInteger,工厂委托只执行一次,并且所有线程都等待相同的实例。创建后,实例被缓存,并且所有对Value属性的未来访问都返回相同的实例(在上面的示例中,MySharedInteger.Value始终为0)。

如果初始化需要异步工作,可以使用Lazy<Task<T>>,可以使用类似的方法:

static int _simpleValue;
static readonly Lazy<Task<int>> MySharedAsyncInteger =
    new Lazy<Task<int>>(async () =>
    {
      await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
      return _simpleValue++;
    });

async Task GetSharedIntegerAsync()
{
  int sharedValue = await MySharedAsyncInteger.Value;
}

在这个示例中,委托返回一个Task<int>,即一个确定整数值的异步操作。无论代码的哪些部分同时调用ValueTask<int>只创建一次并返回给所有调用者。然后,每个调用者可以选择(异步地)等待任务完成,方法是将任务传递给await

前面的代码是一种可接受的模式,但还有一些额外的考虑因素。首先,异步委托可能在调用Value的任何线程上执行,并且该委托将在该上下文内执行。如果可能有不同类型的线程调用Value(例如,UI 线程和线程池线程,或两个不同的 ASP.NET 请求线程),则始终在线程池线程上执行异步委托可能更好。通过将工厂委托包装在Task.Run调用中,可以很容易地实现这一点:

static int _simpleValue;
static readonly Lazy<Task<int>> MySharedAsyncInteger =
  new Lazy<Task<int>>(() => Task.Run(async () =>
  {
    await Task.Delay(TimeSpan.FromSeconds(2));
    return _simpleValue++;
  }));

async Task GetSharedIntegerAsync()
{
  int sharedValue = await MySharedAsyncInteger.Value;
}

另一个考虑因素是,Task<T>实例只创建一次。如果异步委托抛出异常,则Lazy<Task<T>>将缓存该失败的任务。这很少是可取的;通常最好的做法是在下次请求懒惰值时重新执行委托,而不是缓存异常。没有办法“重置”Lazy<T>,但可以创建一个新的类来处理重新创建Lazy<T>实例的情况:

public sealed class AsyncLazy<T>
{
  private readonly object _mutex;
  private readonly Func<Task<T>> _factory;
  private Lazy<Task<T>> _instance;

  public AsyncLazy(Func<Task<T>> factory)
  {
    _mutex = new object();
    _factory = RetryOnFailure(factory);
    _instance = new Lazy<Task<T>>(_factory);
  }

  private Func<Task<T>> RetryOnFailure(Func<Task<T>> factory)
  {
    return async () =>
    {
      try
      {
        return await factory().ConfigureAwait(false);
      }
      catch
      {
        lock (_mutex)
        {
          _instance = new Lazy<Task<T>>(_factory);
        }
        throw;
      }
    };
  }

  public Task<T> Task
  {
    get
    {
      lock (_mutex)
        return _instance.Value;
    }
  }
}

static int _simpleValue;
static readonly AsyncLazy<int> MySharedAsyncInteger =
  new AsyncLazy<int>(() => Task.Run(async () =>
  {
    await Task.Delay(TimeSpan.FromSeconds(2));
    return _simpleValue++;
  }));

async Task GetSharedIntegerAsync()
{
  int sharedValue = await MySharedAsyncInteger.Task;
}

讨论

此配方中的最终代码示例是异步延迟初始化的通用代码模式,有些笨拙。AsyncEx 库包含一个名为 AsyncLazy<T> 的类型,它就像一个 Lazy<Task<T>>,在线程池上执行其工厂委托,并具有失败重试选项。它也可以直接等待,因此声明和使用看起来如下所示:

static int _simpleValue;
private static readonly AsyncLazy<int> MySharedAsyncInteger =
  new AsyncLazy<int>(async () =>
  {
    await Task.Delay(TimeSpan.FromSeconds(2));
    return _simpleValue++;
  },
  AsyncLazyFlags.RetryOnFailure);

public async Task UseSharedIntegerAsync()
{
  int sharedValue = await MySharedAsyncInteger;
}
提示

AsyncLazy<T> 类型位于 Nito.AsyncEx NuGet 包中。

另请参阅

第一章 涵盖了基本的 async/await 编程。

Recipe 13.1 涵盖了将工作调度到线程池的方法。

14.2 System.Reactive 延迟评估

问题

您希望每次有人订阅时都创建一个新的源可观察对象。例如,您希望每个订阅都代表对 web 服务的不同请求。

解决方案

System.Reactive 库具有一个名为 Observable.Defer 的操作符,该操作符每次订阅可观察对象时都会执行一个委托。该委托充当创建可观察对象的工厂。以下代码使用 Defer 来在每次有人订阅可观察对象时调用一个异步方法:

void SubscribeWithDefer()
{
  var invokeServerObservable = Observable.Defer(
      () => GetValueAsync().ToObservable());
  invokeServerObservable.Subscribe(_ => { });
  invokeServerObservable.Subscribe(_ => { });

  Console.ReadKey();
}

async Task<int> GetValueAsync()
{
  Console.WriteLine("Calling server...");
  await Task.Delay(TimeSpan.FromSeconds(2));
  Console.WriteLine("Returning result...");
  return 13;
}

如果执行此代码,应该会看到以下输出:

Calling server...
Calling server...
Returning result...
Returning result...

讨论

您自己的代码通常不会多次订阅可观察对象,但某些 System.Reactive 操作符在其实现中会这样做。例如,Observable.While 操作符会在条件为 true 时重新订阅源序列。Defer 允许您定义一个每次新订阅时都会重新评估的可观察对象。如果需要刷新或更新该可观察对象的数据,则这非常有用。

另请参阅

Recipe 8.6 涵盖了在可观察对象中包装异步方法。

14.3 异步数据绑定

问题

您正在异步检索数据,并需要将结果数据绑定(例如,在 Model-View-ViewModel 设计的 ViewModel 中)。

解决方案

当数据绑定使用属性时,必须立即且同步返回某种结果。如果实际值需要异步确定,可以返回默认结果,稍后使用正确的值更新属性。

请记住,异步操作可能会失败,也可能会成功。由于您正在编写 ViewModel,因此可以使用数据绑定来更新 UI 以反映错误条件。

Nito.Mvvm.Async library 中有一个名为 NotifyTask 的类型可用于此目的:

class MyViewModel
{
  public MyViewModel()
  {
    MyValue = NotifyTask.Create(CalculateMyValueAsync());
  }

  public NotifyTask<int> MyValue { get; private set; }

  private async Task<int> CalculateMyValueAsync()
  {
    await Task.Delay(TimeSpan.FromSeconds(10));
    return 13;
  }
}

可以将数据绑定到NotifyTask<T>属性的各种属性,如本示例所示:

<Grid>
  <Label Content="Loading..."
      Visibility="{Binding MyValue.IsNotCompleted,
 Converter={StaticResource BooleanToVisibilityConverter}}"/>
  <Label Content="{Binding MyValue.Result}"
      Visibility="{Binding MyValue.IsSuccessfullyCompleted,
 Converter={StaticResource BooleanToVisibilityConverter}}"/>
  <Label Content="An error occurred" Foreground="Red"
      Visibility="{Binding MyValue.IsFaulted,
 Converter={StaticResource BooleanToVisibilityConverter}}"/>
</Grid>

MvvmCross 库中有一个 MvxNotifyTask,与 NotifyTask<T> 非常相似。

讨论

您也可以编写自己的数据绑定包装器,而不使用库中的一个。以下代码提供了基本思路:

class BindableTask<T> : INotifyPropertyChanged
{
  private readonly Task<T> _task;

  public BindableTask(Task<T> task)
  {
    _task = task;
    var _ = WatchTaskAsync();
  }

  private async Task WatchTaskAsync()
  {
    try
    {
      await _task;
    }
    catch
    {
    }

    OnPropertyChanged("IsNotCompleted");
    OnPropertyChanged("IsSuccessfullyCompleted");
    OnPropertyChanged("IsFaulted");
    OnPropertyChanged("Result");
  }

  public bool IsNotCompleted { get { return !_task.IsCompleted; } }
  public bool IsSuccessfullyCompleted
  {
    get { return _task.Status == TaskStatus.RanToCompletion; }
  }
  public bool IsFaulted { get { return _task.IsFaulted; } }
  public T Result
  {
    get { return IsSuccessfullyCompleted ? _task.Result : default; }
  }

  public event PropertyChangedEventHandler PropertyChanged;

  protected virtual void OnPropertyChanged(string propertyName)
  {
    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
  }
}

注意,这里有一个空的catch子句是有意为之:该代码明确希望捕获所有异常,并通过数据绑定处理这些情况。此外,该代码明确不希望使用ConfigureAwait(false),因为应在 UI 线程上引发PropertyChanged事件。

提示

NotifyTask类型位于Nito.Mvvm.Async NuGet 包中。MvxNotifyTask类型位于MvvmCross NuGet 包中。

另请参阅

第一章 讨论了基本的async/await编程。

配方 2.7 讨论了如何使用ConfigureAwait

14.4 隐式状态

问题

你有一些状态变量,需要在调用堆栈的不同点访问。例如,你有一个当前操作标识符,你希望用于日志记录,但不想将其添加为每个方法的参数。

解决方案

最佳解决方案是向方法添加参数,将数据存储为类的成员,或使用依赖注入为代码的不同部分提供数据。然而,在某些情况下,这样做会使代码变得过于复杂。

AsyncLocal<T>类型使您能够为状态提供一个对象,可以在逻辑“上下文”中存储它。以下代码展示了如何使用AsyncLocal<T>设置稍后由日志记录方法读取的操作标识符:

private static AsyncLocal<Guid> _operationId = new AsyncLocal<Guid>();

async Task DoLongOperationAsync()
{
  _operationId.Value = Guid.NewGuid();

  await DoSomeStepOfOperationAsync();
}

async Task DoSomeStepOfOperationAsync()
{
  await Task.Delay(100); // Some async work

  // Do some logging here.
  Trace.WriteLine("In operation: " + _operationId.Value);
}

许多时候,在单个AsyncLocal<T>实例中拥有更复杂的数据结构(如值堆栈)是很有用的。这是可能的,但有一个重要注意事项:您应该只在AsyncLocal<T>中存储不可变数据。每当需要更新数据时,应该覆盖现有值。通常有助于将AsyncLocal<T>隐藏在一个助手类型中,以确保存储的数据是不可变的并且正确更新:

internal sealed class AsyncLocalGuidStack
{
  private readonly AsyncLocal<ImmutableStack<Guid>> _operationIds =
      new AsyncLocal<ImmutableStack<Guid>>();

  private ImmutableStack<Guid> Current =>
      _operationIds.Value ?? ImmutableStack<Guid>.Empty;

  public IDisposable Push(Guid value)
  {
    _operationIds.Value = Current.Push(value);
    return new PopWhenDisposed(this);
  }

  private void Pop()
  {
    ImmutableStack<Guid> newValue = Current.Pop();
    if (newValue.IsEmpty)
      newValue = null;
    _operationIds.Value = newValue;
  }

  public IEnumerable<Guid> Values => Current;

  private sealed class PopWhenDisposed : IDisposable
  {
    private AsyncLocalGuidStack _stack;

    public PopWhenDisposed(AsyncLocalGuidStack stack) =>
        _stack = stack;

    public void Dispose()
    {
      _stack?.Pop();
      _stack = null;
    }
  }
}

private static AsyncLocalGuidStack _operationIds = new AsyncLocalGuidStack();

async Task DoLongOperationAsync()
{
  using (_operationIds.Push(Guid.NewGuid()))
    await DoSomeStepOfOperationAsync();
}

async Task DoSomeStepOfOperationAsync()
{
  await Task.Delay(100); // some async work

  // Do some logging here.
  Trace.WriteLine("In operation: " +
      string.Join(":", _operationIds.Values));
}

封装类型确保底层数据是不可变的,并且新值被推送到堆栈上。它还提供了一种便利的IDisposable方法来从堆栈中弹出值。

讨论

旧代码可能使用ThreadStatic属性来处理同步代码使用的上下文状态。将旧代码转换为异步时,AsyncLocal<T>是替换ThreadStaticAttribute的首选。AsyncLocal<T>可用于同步和异步代码,并应该是现代应用程序中隐式状态的默认选择。

另请参阅

第一章 讨论了基本的async/await编程。

第九章 讨论了几种不可变集合,用于在需要将复杂数据存储为隐式状态时使用。

14.5 同步和异步代码相同

问题

你有一些代码需要通过同步和异步 API 公开,但你不想重复逻辑。在更新代码以支持异步时,经常会遇到这种情况,但现有的同步消费者不能(暂时)改变。

解决方案

如果可能的话,请尝试按照现代设计指南组织您的代码,例如端口和适配器(六边形架构),将业务逻辑与 I/O 等副作用分离。如果能够达到这种情况,那么不需要为任何事情同时暴露同步和异步 API;您的业务逻辑总是同步的,而 I/O 总是异步的。

然而,这是一个非常崇高的目标,在现实世界中,棕地代码可能会很混乱,在采用异步代码之前很少有时间使其完美。即使现有的 API 设计不佳,也经常需要维护以保持向后兼容性。

在这种情况下,没有完美的解决方案。许多开发人员尝试使同步代码调用异步代码,或使异步代码调用同步代码,但这两种方法都是反模式。在这种情况下,我倾向于使用布尔参数黑客。这是一种在单个方法中保持所有逻辑的方法,同时暴露同步和异步 API。

布尔参数黑客的主要思想是,有一个包含逻辑的私有核心方法。该核心方法具有异步签名,并带有布尔参数,确定核心方法是否应该是异步的。如果布尔参数指定核心方法应该是同步的,那么它必须返回一个已完成的任务。然后,您可以编写同时转发到核心方法的异步和同步 API 方法:

private async Task<int> DelayAndReturnCore(bool sync)
{
  int value = 100;

  // Do some work.
  if (sync)
    Thread.Sleep(value); // Call synchronous API.
  else
    await Task.Delay(value); // Call asynchronous API.

  return value;
}

// Asynchronous API
public Task<int> DelayAndReturnAsync() =>
    DelayAndReturnCore(sync: false);

// Synchronous API
public int DelayAndReturn() =>
    DelayAndReturnCore(sync: true).GetAwaiter().GetResult();

异步 API DelayAndReturnAsync 调用带有布尔参数 sync 设置为 falseDelayAndReturnCore;这意味着 DelayAndReturnCore 可能会异步执行,并使用底层异步的“延迟”API Task.Delay。从 DelayAndReturnCore 返回的任务会直接返回给 DelayAndReturnAsync 的调用者。

同步 API DelayAndReturn 调用带有布尔参数 sync 设置为 trueDelayAndReturnCore;这意味着 DelayAndReturnCore 必须同步执行,并使用底层同步的“延迟”API Thread.SleepDelayAndReturnCore 返回的任务必须已经完成,因此可以安全地提取结果。DelayAndReturn 使用 GetAwaiter().GetResult() 从任务中检索结果;这样做可以避免使用 Task<T>.Result 属性时可能出现的 AggregateException 包装器。

讨论

这不是一个理想的解决方案,但它可以帮助处理现实世界的应用场景。

对于这个解决方案,现在需要注意一些注意事项。如果Core方法未能正确地尊重其sync参数,可能会出现最严重的问题。如果Core方法在synctrue时返回了一个不完整的任务,那么同步 API 很容易会发生死锁;同步 API 可以阻塞其任务的唯一原因是它知道任务已经完成。类似地,如果Core方法在syncfalse时阻塞了线程,那么应用程序的效率就不如预期。

可以对这个解决方案进行改进的一个方法是在同步 API 中添加一个检查,验证返回的任务实际上是已完成的。如果它曾经未完成过,那么这就是一个严重的编码错误。

另请参见

第一章介绍了基本的async/await编程,包括讨论一般情况下在异步代码中阻塞可能导致的死锁问题。

14.6 数据流网格中的铁路编程

问题

您已经建立了一个数据流网格,但有些数据项未能处理。您希望以一种方式响应这些错误,以保持数据流网格的正常运行。

解决方案

默认情况下,如果一个块在处理数据项时遇到异常,那么该块将会故障,导致无法继续处理任何数据项。这个解决方案的核心思想是将异常视为另一种数据。如果数据流网格操作的类型可以是异常数据,那么即使出现异常,网格仍然可以继续运行并处理其他数据项。

这种编程方式有时被称为“铁路”编程,因为网格中的项目可以被视为沿着两条单独的轨道行驶。第一条是正常的“数据”轨道:如果一切顺利,项目将留在“数据”轨道上,并通过网格进行转换和操作,直到到达网格的末端。第二条轨道是“错误”轨道;在任何块中,如果处理项目时出现异常,该异常将转移到“错误”轨道并通过网格传递。异常项目不会被处理;它们只是从块传递到块,因此它们也会到达网格的末端。网格中的终端块最终会接收到一系列项目,每个项目都是数据项或异常项;数据项表示已成功完成整个网格的数据,异常项表示网格某个点的处理错误。

要设置这种“铁路”编程,首先需要定义一个表示数据项或异常的类型。如果要使用预先构建的类型,有几种可用。这种类型在函数式编程社区中很常见,通常称为TryErrorExceptional,是Either单子的特例。我定义了自己的Try<T>类型作为示例;它在Nito.Try NuGet 包中,源代码在GitHub 上

一旦您有某种Try<T>类型,设置网格有点繁琐,但并不可怕。每个数据流块的类型应从T更改为Try<T>,并且该块中的任何处理都应通过将一个Try<T>值映射到另一个来完成。使用我的Try<T>类型,通过调用Try<T>.Map来完成这一点。我发现定义小工厂方法用于铁路导向数据流块而不是在行内添加额外代码会很有帮助。以下代码是一个帮助方法的示例,它构造一个在Try<T>值上操作的TransformBlock,通过调用Try<T>.Map

private static TransformBlock<Try<TInput>, Try<TOutput>>
    RailwayTransform<TInput, TOutput>(Func<TInput, TOutput> func)
{
  return new TransformBlock<Try<TInput>, Try<TOutput>>(t => t.Map(func));
}

有了这些帮助程序,数据流网格创建代码会更加简单:

var subtractBlock = RailwayTransform<int, int>(value => value - 2);
var divideBlock = RailwayTransform<int, int>(value => 60 / value);
var multiplyBlock = RailwayTransform<int, int>(value => value * 2);

var options = new DataflowLinkOptions { PropagateCompletion = true };
subtractBlock.LinkTo(divideBlock, options);
divideBlock.LinkTo(multiplyBlock, options);

// Insert data items into the first block.
subtractBlock.Post(Try.FromValue(5));
subtractBlock.Post(Try.FromValue(2));
subtractBlock.Post(Try.FromValue(4));
subtractBlock.Complete();

// Receive data/exception items from the last block.
while (await multiplyBlock.OutputAvailableAsync())
{
  Try<int> item = await multiplyBlock.ReceiveAsync();
  if (item.IsValue)
    Console.WriteLine(item.Value);
  else
    Console.WriteLine(item.Exception.Message);
}

讨论

铁路编程是避免数据流块故障的好方法。由于铁路编程是基于单子的函数式编程构造,将其转换为.NET 时有些笨拙,但可用。如果您有一个需要容错的数据流网格,那么铁路编程绝对值得一试。

参见

Recipe 5.2 讲述了异常如何影响块的正常方式,并可以通过网格传播,如果不使用铁路编程。

14.7 节流进度更新

问题

您有一个长时间运行的操作,报告进度,并在 UI 中显示进度更新。但进度更新过于频繁,导致 UI 无响应。

解决方案

考虑以下代码,它非常快速地报告进度:

private string Solve(IProgress<int> progress)
{
  // Count as quickly as possible for 3 seconds.
  var endTime = DateTime.UtcNow.AddSeconds(3);
  int value = 0;
  while (DateTime.UtcNow < endTime)
  {
    value++;
    progress?.Report(value);
  }
  return value.ToString();
}

你可以通过将其包装在Task.Run中并传入IProgress<T>,从 GUI 应用程序执行此代码。以下示例代码适用于 WPF,但相同的概念适用于任何 GUI 平台(WPF、Xamarin 或 Windows Forms):

// For simplicity, this code updates a label directly.
// In a real-world MVVM application, those assignments
//  would instead be updating a ViewModel property
//  which is data-bound to the actual UI.
private async void StartButton_Click(object sender, RoutedEventArgs e)
{
  MyLabel.Content = "Starting...";
  var progress = new Progress<int>(value => MyLabel.Content = value);
  var result = await Task.Run(() => Solve(progress));
  MyLabel.Content = $"Done! Result: {result}";
}

这段代码会导致 UI 在我的机器上变得无响应相当长的时间,大约 20 秒,然后突然 UI 重新响应并且只显示"Done! Result:"消息。中间的进度报告从未被看到。发生的情况是后台代码非常快地向 UI 线程发送进度报告,以至于在运行仅 3 秒后,UI 线程需要大约额外的 17 秒来处理所有这些进度报告,一遍又一遍地更新标签。最后,UI 线程最后一次更新标签的值为"Done! Result:",然后最终有时间重新绘制屏幕,向用户显示更新后的标签值。

首先要意识到的是,我们需要节流进度报告。这是确保 UI 在进度更新之间有足够时间重新绘制自身的唯一方法。接下来要意识到的是,我们希望基于时间而不是报告数量来进行节流。虽然您可能会试图通过仅发送每百个或更多报告中的一个来节流进度报告,但出于“讨论”部分所述的原因,这并不理想。

我们希望处理时间表明我们应该考虑 System.Reactive。事实上,System.Reactive 具有专门设计用于按时间节流的操作符。因此,System.Reactive 似乎在这个解决方案中将发挥作用。

要开始,您可以定义一个IProgress<T>实现,该实现为每个进度报告触发一个事件,然后通过包装该事件创建一个可观察对象来接收这些进度报告:

public static class ObservableProgress
{
  private sealed class EventProgress<T> : IProgress<T>
  {
    void IProgress<T>.Report(T value) => OnReport?.Invoke(value);
    public event Action<T> OnReport;
  }

  public static (IObservable<T>, IProgress<T>) Create<T>()
  {
    var progress = new EventProgress<T>();
    var observable = Observable.FromEvent<T>(
        handler => progress.OnReport += handler,
        handler => progress.OnReport -= handler);
    return (observable, progress);
  }
}

方法ObservableProgress.Create<T>将创建一对:一个IObservable<T>和一个IProgress<T>,其中所有发送到IProgress<T>的进度报告将发送到IObservable<T>的订阅者。现在我们有了进度报告的可观察流;下一步是对其进行节流。

我们希望更新 UI 的速度足够慢,以使其保持响应,并且我们希望更新 UI 的速度足够快,以便用户能看到更新。人类感知远远慢于计算机显示,因此有很大的可能性。如果您更喜欢真实的可读性,每秒节流一次更新可能足够了。如果您更喜欢更实时的反馈,我发现每 100 或 200 毫秒(ms)节流一次更新足够快,以至于用户看到事情正在迅速发生,并获得进度详细信息的一般感觉,同时仍然足够慢以使 UI 保持响应。

另一个要记住的点是,进度报告可能会从其他线程引发—在这种情况下,它们是从后台线程引发的。节流应尽可能靠近源头完成,因此我们希望在后台线程上保持节流。然而,更新 UI 的代码需要在 UI 线程上运行。考虑到这一点,您可以定义一个 CreateForUi 方法,处理节流和转换到 UI 线程:

public static class ObservableProgress
{
  // Note: this must be called from the UI thread.
  public static (IObservable<T>, IProgress<T>) CreateForUi<T>(
      TimeSpan? sampleInterval = null)
  {
    var (observable, progress) = Create<T>();
    observable = observable
        .Sample(sampleInterval ?? TimeSpan.FromMilliseconds(100))
        .ObserveOn(SynchronizationContext.Current);
    return (observable, progress);
  }
}

现在,您有一个辅助方法,可以在更新到达用户界面之前对其进行节流。您可以在前面的代码示例中的按钮点击处理程序中使用这个辅助方法:

// For simplicity, this code updates a label directly.
// In a real-world MVVM application, those assignments
//  would instead be updating a ViewModel property
//  which is data-bound to the actual UI.
private async void StartButton_Click(object sender, RoutedEventArgs e)
{
  MyLabel.Content = "Starting...";
  var (observable, progress) = ObservableProgress.CreateForUi<int>();
  string result;
  using (observable.Subscribe(value => MyLabel.Content = value))
    result = await Task.Run(() => Solve(progress));
  MyLabel.Content = $"Done! Result: {result}";
}

新代码调用了我们的辅助方法 ObservableProgress.CreateForUi,它创建了 IObservable<T>IProgress<T> 对。代码订阅进度更新,并保持到 Solve 完成为止。最后,它将 IProgress<T> 传递给长时间运行的 Solve 方法。当 Solve 调用 IProgress<T>.Report 时,这些报告首先在 100 毫秒的时间窗口内进行采样,每 100 毫秒转发一次更新到 UI 线程,并用于更新标签文本。现在,UI 完全响应!

讨论

这个配方是本书中其他配方的有趣组合!没有引入新技术;我们只是介绍了如何组合这些配方以得出这个解决方案。

在野外经常见到的这个问题的另一种替代解决方案是“模数解决方案”。这个解决方案的背后思想是 Solve 本身必须节流自己的进度更新;例如,如果代码只想处理每 100 个实际更新的一个更新,那么代码可能使用一些模数技术,如 if (value % 100 == 0) progress?.Report(value);

采用模数方法存在几个问题。首先,没有“正确”的模数值;通常,开发人员会尝试各种值,直到在他们自己的笔记本电脑上运行良好。然而,同样的代码在运行在客户的大型服务器或不足的虚拟机内时可能表现不佳。此外,不同的平台和环境缓存方式差异很大,这可能导致代码运行比预期快得多(或慢得多)。当然,“最新”的计算机硬件的能力随时间而变化。因此,模数值最终只是一个猜测;它不会在所有地方和所有时间都正确。

模数方法的另一个问题是,它试图在代码的错误部分修复问题。这个问题纯粹是一个 UI 问题;UI 存在问题,并且 UI 层应该为其提供解决方法。在这个配方的示例代码中,Solve 表示一些后台业务处理逻辑;它不应关心 UI 特定的问题。控制台应用程序可能希望使用与 WPF 应用程序非常不同的模数。

模数方法正确的一点是,在将更新发送到 UI 线程之前最好对更新进行节流。本示例中的解决方案也是如此:在将更新发送到 UI 线程之前,它立即在后台线程上同步地节流更新。通过注入自己的IProgress<T>实现,UI 能够在不需要对Solve方法本身进行任何更改的情况下进行自己的节流。

参见

Recipe 2.3 涵盖了使用IProgress<T>从长时间运行的操作报告进度。

Recipe 13.1 涵盖了使用Task.Run在线程池线程上运行同步代码。

Recipe 6.1 涵盖了使用FromEvent将.NET 事件包装成可观察对象。

Recipe 6.4 涵盖了使用Sample按时间节流可观察对象。

Recipe 6.2 涵盖了使用ObserveOn将可观察通知移动到另一个上下文。

附录 A. Legacy 平台支持

本书讨论的许多技术也对旧版平台有一定的支持。如果您不得不支持这些平台,本附录中的信息可能帮助您确定可用的技术。在旧版平台上使用这些技术并非理想;即使您能让它们运行,也要记住唯一的长期解决方案是更新代码的平台目标。本附录主要作为历史参考,而非推荐;尽管如此,旧代码的维护者可能会发现它有用。

Table A-1 总结了不同技术在 legacy 平台上的支持情况。

Table A-1. Legacy 平台支持

平台 async Parallel Reactive Dataflow Concurrent collections Immutable collections
.NET 4.5 NuGet NuGet NuGet
.NET 4.0 NuGet NuGet
Windows Phone Apps 8.1 NuGet NuGet NuGet
Windows Phone SL 8.0 NuGet NuGet NuGet
Windows Phone SL 7.1 NuGet NuGet
Silverlight 5 NuGet NuGet

Legacy 平台支持 Async

如果您需要在旧的 legacy 平台上支持 async,请安装 Microsoft.Bcl.Async 的 NuGet 包。

警告

不要使用 Microsoft.Bcl.Async 在运行于 .NET 4.0 的 ASP.NET 上启用 async 代码!.NET 4.5 中已更新 ASP.NET 管道以支持 async,您必须使用 .NET 4.5 或更新版本进行 async ASP.NET 项目。Microsoft.Bcl.Async 仅适用于非 ASP.NET 应用程序。

Table A-2. Async 的 Legacy 平台支持

平台 Async 支持
.NET 4.5
.NET 4.0 NuGet: Microsoft.Bcl.Async
Windows Phone Apps 8.1
Windows Phone SL 8.0
Windows Phone 7.1 NuGet: Microsoft.Bcl.Async
Silverlight 5 NuGet: Microsoft.Bcl.Async

使用 Microsoft.Bcl.Async 时,现代 Task 类型的许多成员位于 TaskEx 类型上,包括 DelayFromResultWhenAllWhenAny

Legacy 平台支持 Dataflow

要使用 TPL Dataflow,请将 NuGet 包 System.Threading.Tasks.Dataflow 安装到您的应用程序中。TPL Dataflow 库对较旧的平台的支持有限(Table A-3)。

警告

不要使用旧版的 Microsoft.Tpl.Dataflow 包。它已不再维护。

Table A-3. TPL Dataflow 的 Legacy 平台支持

平台 Dataflow 支持
.NET 4.5 NuGet: System.Threading.Tasks.Dataflow
.NET 4.0
Windows Phone Apps 8.1 NuGet: System.Threading.Tasks.Dataflow
Windows Phone SL 8.0 NuGet: System.Threading.Tasks.Dataflow
Windows Phone SL 7.1
Silverlight 5

Legacy 平台支持 System.Reactive

若要使用 System.Reactive,请在你的应用程序中安装 NuGet 包System.Reactive。System.Reactive 一直以来都具有广泛的平台支持(表格 A-4);然而,大多数旧平台已不再受支持:

表格 A-4. System.Reactive 的旧平台支持

平台 响应式支持
.NET 4.7.2 NuGet: System.Reactive
.NET 4.5 NuGet: System.Reactive v3.x
.NET 4.0 NuGet: Rx.Main
Windows Phone Apps 8.1 NuGet: System.Reactive v3.x
Windows Phone SL 8.0 NuGet: System.Reactive v3.x
Windows Phone SL 7.1 NuGet: Rx.Main
Silverlight 5 NuGet: Rx.Main
警告

旧的 Rx.Main 包已不再维护。

附录 B. 识别和解释异步模式

异步代码的好处在 .NET 发明之前就已广为人知。在 .NET 早期,出现了几种不同的异步代码风格,被这里或那里使用,最终被废弃。这些并非全都是坏主意;其中许多为现代 async/await 方法铺平了道路。然而,现在有很多遗留代码使用了旧的异步模式。本附录将讨论更常见的模式,解释它们的工作原理以及如何与现代代码集成。

有时,同一类型多年来更新,支持多个异步模式。也许最好的例子是 Socket 类。以下是 Socket 类的核心 Send 操作的一些成员:

class Socket
{
  // Synchronous
  public int Send(byte[] buffer, int offset, int size, SocketFlags flags);

  // APM
  public IAsyncResult BeginSend(byte[] buffer, int offset, int size,
      SocketFlags flags, AsyncCallback callback, object state);
  public int EndSend(IAsyncResult result);

  // Custom, very close to APM
  public IAsyncResult BeginSend(byte[] buffer, int offset, int size,
      SocketFlags flags, out SocketError error,
      AsyncCallback callback, object state);
  public int EndSend(IAsyncResult result, out SocketError error);

  // Custom
  public bool SendAsync(SocketAsyncEventArgs e);

  // TAP (as an extension method)
  public Task<int> SendAsync(ArraySegment<byte> buffer,
      SocketFlags socketFlags);

  // TAP (as an extension method) using more efficient types
  public ValueTask<int> SendAsync(ReadOnlyMemory<byte> buffer,
      SocketFlags socketFlags, CancellationToken cancellationToken = default);
}

遗憾的是,由于大多数文档都是按字母顺序排列,并且有大量重载以试图简化使用,类型如 Socket 变得难以理解。希望本节的指南能有所帮助。

任务异步模式(TAP)

任务异步模式(TAP)是现代异步 API 模式,适用于 await 使用。每个异步操作由返回可等待对象的单个方法表示。"可等待对象" 是任何可以由 await 消耗的类型;通常是 TaskTask<T>,但也可能是 ValueTaskValueTask<T>,一个框架定义的类型(例如,由通用 Windows 应用程序使用的 IAsyncActionIAsyncOperation<T>),甚至是库定义的自定义类型。

TAP 方法通常以 Async 后缀命名。但这只是一种约定;并非所有 TAP 方法都带有 Async 后缀。如果 API 开发者认为异步上下文已充分暗示,可以省略此后缀;例如,Task.WhenAllTask.WhenAny 就没有 Async 后缀。此外,请注意, TAP 方法可能会带有 Async 后缀(例如,WebClient.DownloadStringAsync 不是 TAP 方法)。在这种情况下,通常 TAP 方法会带有 TaskAsync 后缀(例如,WebClient.DownloadStringTaskAsync 是 TAP 方法)。

返回异步流的方法也遵循类似于 TAP 的模式,使用 Async 作为后缀。即使它们不返回可等待对象,它们也会返回可等待流——可以使用 await foreach 消耗的类型。

可以通过以下特征识别任务异步模式(TAP):

  1. 操作由单个方法表示。

  2. 方法返回可等待对象或可等待流。

  3. 方法通常以 Async 结尾。

下面是一个具有 TAP API 的类型示例:

class ExampleHttpClient
{
  public Task<string> GetStringAsync(Uri requestUri);

  // Synchronous equivalent, for comparison
  public string GetString(Uri requestUri);
}

使用 await 可以实现任务型异步模式,并且本书的大部分内容都涵盖了这一点。如果你在没有理解如何使用 await 的情况下来到这个附录,那我不确定我能在这一点上帮助你,但你可以试着阅读第 1 和 2 章节,看看是否能唤起你的记忆。

异步编程模型(APM)

在 TAP 之后,异步编程模型(APM)模式可能是您会遇到的下一个最常见模式。这是第一个异步操作具有一级对象表示的模式。该模式的显著特征是与一对管理操作的方法一起使用的 IAsyncResult 对象,其中一个以 Begin 开头,另一个以 End 开头。

IAsyncResult本地重叠 I/O 强烈影响。APM 模式允许消费代码以同步或异步方式运行。消费代码可以从以下选项中选择:

  • 阻塞操作完成。这通过调用 End 方法来完成。

  • 在做其他事情的同时轮询操作是否完成。

  • 提供一个回调委托,在操作完成时调用。

在所有情况下,消费代码必须最终调用 End 方法以检索异步操作的结果。如果在调用 End 时操作尚未完成,则会阻塞调用线程直到操作完成。

Begin 方法接受 AsyncCallback 参数和 object 参数(通常称为 state)作为其最后两个参数。这些参数由消费代码使用,以在操作完成时调用回调委托。object 参数可以是任何你想要的;这是在 .NET 的早期阶段之前使用的,甚至在 lambda 方法或匿名方法存在之前。它仅用于为 AsyncCallback 参数提供上下文。

APM 在微软库中相当普遍,但在更广泛的 .NET 生态系统中并不常见。这是因为从未有任何可重用的 IAsyncResult 实现,并且正确实现该接口相当复杂。此外,组合基于 APM 的系统也很困难。我只见过少数几个自定义的 IAsyncResult 实现;所有这些都是 Jeffrey Richter 发表在他的文章 “Concurrent Affairs: Implementing the CLR Asynchronous Programming Model” 中的通用 IAsyncResult 实现的某个版本,该文章发表在 2007 年 3 月的 MSDN Magazine 上。

可以通过以下特征识别异步编程模型模式:

  1. 操作由一对方法表示,一个以 Begin 开头,另一个以 End 开头。

  2. Begin 方法返回一个 IAsyncResult,除了所有正常的输入参数外,还有额外的 AsyncCallback 参数和额外的 object 参数。

  3. End方法只接受一个IAsyncResult,并返回结果值(如果有)。

这是一个具有 APM API 的示例类型:

class MyHttpClient
{
  public IAsyncResult BeginGetString(Uri requestUri,
      AsyncCallback callback, object state);
  public string EndGetString(IAsyncResult asyncResult);

  // Synchronous equivalent, for comparison
  public string GetString(Uri requestUri);
}

通过将其转换为 TAP 来使用 APM,可以使用Task.Factory.FromAsync;参见 Recipe 8.2 和Microsoft 文档

有些情况下,代码几乎遵循了 APM 模式,但并非完全如此;例如,旧的Microsoft.TeamFoundation客户端库在其Begin方法中不包括object参数。在这些情况下,Task.Factory.FromAsync将不起作用,然后您可以选择两个选项。效率较低的选项是调用Begin方法并将IAsyncResult传递给FromAsync。不太优雅的选项是使用更灵活的TaskCompletionSource<T>;参见 Recipe 8.3。

基于事件的异步编程(EAP)

基于事件的异步编程(EAP)定义了一组匹配的方法/事件对。方法通常以Async结尾,并最终引发以Completed结尾的事件。

在处理 EAP 时有一些注意事项,使得其比最初看起来更加复杂。首先,必须记住在调用方法之前将处理程序添加到事件之前;否则,可能会出现竞争条件,事件可能在您订阅之前发生,然后您将永远看不到其完成。其次,按照 EAP 模式编写的组件通常在某个时刻捕获当前的SynchronizationContext,然后在该上下文中引发其事件。一些组件在构造函数中捕获SynchronizationContext,而其他组件则在调用方法并开始异步操作时捕获它。

基于事件的异步编程模式可以通过以下特征来识别:

  1. 操作由事件和方法表示。

  2. 事件以Completed结尾。

  3. Completed事件的事件参数类型可能是从AsyncCompletedEventArgs派生的。

  4. 方法通常以Async结尾。

  5. 方法返回void

Async结尾的 EAP 方法与以Async结尾的 TAP 方法有所区别,因为 EAP 方法返回void,而 TAP 方法返回可等待类型。

这是一个具有 EAP API 的示例类型:

class GetStringCompletedEventArgs : AsyncCompletedEventArgs
{
  public string Result { get; }
}

class MyHttpClient
{
  public void GetStringAsync(Uri requestUri);
  public event Action<object, GetStringCompletedEventArgs> GetStringCompleted;

  // Synchronous equivalent, for comparison
  public string GetString(Uri requestUri);
}

通过将其转换为 TAP 来消耗 EAP,可以使用TaskCompletionSource<T>;参见 Recipe 8.3 和Microsoft 文档

连续传递样式(CPS)

这是其他语言中更常见的一种模式,特别是 JavaScript 和 TypeScript,由 Node.js 开发人员使用。在这种模式中,每个异步操作都会接受一个回调委托,当操作完成时会调用该委托,无论是成功还是出错。此模式的变体使用 两个 回调委托,一个用于成功,另一个用于错误。这种类型的回调称为“continuation”,并且 continuation 作为参数传递,因此得名“continuation passing style”。这种模式在 .NET 世界中从未普及,但有几个较老的开源库使用了它。

通过以下特征可以识别 Continuation Passing Style 模式:

  1. 操作由单个方法表示。

  2. 该方法接受一个额外的参数,这是一个回调委托;回调委托接受两个参数,一个用于错误,另一个用于结果。

  3. 或者,操作方法接受两个额外参数,都是回调委托;一个回调委托仅用于错误,另一个回调委托仅用于结果。

  4. 回调委托通常命名为 donenext

下面是一个具有 continuation-passing style API 的示例类型:

class MyHttpClient
{
  public void GetString(Uri requestUri, Action<Exception, string> done);

  // Synchronous equivalent, for comparison
  public string GetString(Uri requestUri);
}

通过使用 TaskCompletionSource<T> 将 CPS 转换为 TAP 来消耗,传递仅完成 TaskCompletionSource<T> 的回调委托;参见 Recipe 8.3。

自定义异步模式

非常专业化的类型有时会定义自己的自定义异步模式。其中最著名的例子是 Socket 类型,它定义了一个通过传递代表操作的 SocketAsyncEventArgs 实例的模式。引入此模式的原因是 SocketAsyncEventArgs 可以被重用,从而减少了对执行大量网络活动的应用程序的内存使用量。现代应用程序可以使用 ValueTask<T>ManualResetValueTaskSourceCore<T> 来获得类似的性能增益。

自定义模式没有任何共同特征,因此最难识别。幸运的是,自定义异步模式并不常见。

下面是一个具有自定义异步 API 的示例类型:

class MyHttpClient
{
  public void GetString(Uri requestUri,
      MyHttpClientAsynchronousOperation operation);

  // Synchronous equivalent, for comparison
  public string GetString(Uri requestUri);
}

TaskCompletionSource<T> 是消耗自定义异步模式的唯一方式;参见 Recipe 8.3。

ISynchronizeInvoke

所有之前的模式都是针对已启动的异步操作,并且一旦启动,它们就会完成。一些组件遵循订阅模型:它们代表基于推送的事件流,而不是一次启动并完成的单个操作。一个好的订阅模型示例是 FileSystemWatcher 类型。为了观察文件系统的变化,消费代码首先订阅多个事件,然后将 EnableRaisingEvents 属性设置为 true。一旦 EnableRaisingEventstrue,可能会引发多个文件系统变化事件。

一些组件为其事件使用ISynchronizeInvoke模式。它们公开一个ISynchronizeInvoke属性,消费者将该属性设置为允许组件调度工作的实现。这通常用于将工作安排到 UI 线程,以便在 UI 线程上引发组件的事件。按照惯例,如果ISynchronizeInvokenull,则不进行事件同步,并且可能在后台线程上引发。

可以通过以下特征识别ISynchronizeInvoke模式:

  1. 有一个ISynchronizeInvoke类型的属性。

  2. 该属性通常称为SynchronizingObject

这是使用ISynchronizeInvoke模式的一个示例类型:

class MyHttpClient
{
  public ISynchronizeInvoke SynchronizingObject { get; set; }
  public void StartListening();
  public event Action<string> StringArrived;
}

由于ISynchronizeInvoke暗示订阅模型中的多个事件,正确的消费这些组件的方法是将这些事件转换为可观察流,可以使用FromEvent(参见 Recipe 6.1)或Observable.Create

posted @ 2024-06-18 17:53  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报