C# 并发编程秘籍第二版(一)
原文:
zh.annas-archive.org/md5/94f6d64de2f76d3e98d9e7e8e4ee1394译者:飞龙
前言
我认为这本书封面上的动物,一只棕榈猫鼬,与本书的主题相关。在看到封面之前,我对这种动物一无所知,因此我查找了相关信息。棕榈猫鼬被认为是害虫,因为它们会在阁楼上随处排泄,并在最不合时宜的时候用嘈杂的声音互相争斗。它们的肛门臭腺会分泌出令人作呕的分泌物。它们的濒危物种评级是“无忧”,这显然是政治正确的说法,“你可以杀死尽可能多的这些动物,没有人会想念它们。”棕榈猫鼬喜欢吃咖啡果实,它们将咖啡豆排泄出来。鲁瓦克咖啡是世界上最昂贵的咖啡之一,由从猫鼬排泄物中提取的咖啡豆制成。根据美国特种咖啡协会的说法,“它的味道很糟糕。”
这使得棕榈猫鼬成为并发和多线程开发的完美吉祥物。对于未曾接触过的人来说,并发和多线程是令人不愿意接受的。它们会导致行为良好的代码表现出最可怕的方式。竞态条件等问题会导致响亮的崩溃(似乎总是在生产环境或演示期间)。有些人甚至宣称“线程是邪恶的”,完全避免并发。有少数开发者对并发产生了兴趣,并且毫不畏惧地使用它;但大多数开发者在过去由于并发而受过伤,这种经历让他们对此望而却步。
然而,对于现代应用程序来说,并发迅速成为一项要求。如今的用户期望完全响应的界面,服务器应用程序必须达到前所未有的规模。并发可以解决这两种趋势。
幸运的是,现代化的库使并发变得更容易!并行处理和异步编程不再只是巫师的专属领域。通过提高抽象级别,这些库使响应式和可扩展的应用程序开发成为每个开发者现实可行的目标。如果你在过去遇到过困难重重的并发问题,那么我鼓励你使用现代工具再次尝试。我们可能永远不能说并发很简单,但它确实没有过去那么难了!
适合阅读本书的人
这本书是为那些想学习现代并发方法的开发者编写的。我假设你具有相当数量的.NET 经验,包括对泛型集合、可枚举对象和 LINQ 的理解。我不假设你有任何多线程或异步编程的知识。如果你在这些领域有些经验,你可能仍会发现这本书有帮助,因为它介绍了更安全、更易于使用的新库。
并发对任何类型的应用程序都有用。无论您是在桌面应用程序、移动应用程序还是服务器应用程序上工作;如今并发几乎是无处不在的要求。您可以使用本书中的示例使用户界面更加响应,服务器更具可扩展性。我们已经到了并发无处不在的时代,理解这些技术及其用途是专业开发者的必备知识。
我为什么写这本书
在我职业生涯的早期阶段,我通过艰难的方式学会了多线程。几年后,我又通过艰难的方式学会了异步编程。虽然这两者都是宝贵的经验,但我希望那时我能有一些今天可用的工具和资源。特别是,现代.NET 语言中的async和await支持是非常宝贵的。
然而,如果您今天查看有关学习并发性的书籍和其他资源,几乎所有这些资源都从介绍最低级别的概念开始。有关线程和序列化原语的优秀覆盖,高级技术则被推迟到后面,如果有的话。我认为这有两个原因。首先,许多并发的开发者,比如我自己,确实是先学习了低级别的概念,艰难地通过老式技术。其次,许多书籍已经数年未变,涵盖的是现在已经过时的技术;随着新技术的推出,这些书籍已经更新以包括它们,但不幸的是把它们放在了最后。
我认为这是反过来的。实际上,这本书仅仅涵盖了现代并发的方法。这并不是说理解所有低级概念没有价值。当我上大学学习编程时,有一门课程让我从几个门电路构建虚拟 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 程序可用的库和语言特性,现在并发要容易得多了。微软在显著降低并发门槛方面走在了前列。以前,并发编程是专家领域;而今,每个开发者都可以(也应该)拥抱并发。
并发介绍
继续之前,我想澄清一些我将在本书中使用的术语。这些是我自己的定义,我一贯使用它们来消除不同编程技术的歧义。让我们从并发开始。
并发
同时进行多项任务。
我希望并发的帮助显而易见。端用户应用程序使用并发来在向数据库写入数据的同时响应用户输入。服务器应用程序使用并发来在完成第一个请求的同时响应第二个请求。任何时候你需要应用程序在做一件事的同时正在处理另一件事时,都需要并发。世界上几乎每一个软件应用程序都可以从并发中受益。
大多数开发者一听到“并发”就立刻想到“多线程”。我想要区分这两者。
多线程
使用多个执行线程的并发形式。
多线程是指确实使用多个线程。正如本书中许多示例所示,多线程是一种并发形式,但绝非唯一形式。事实上,在现代应用程序中,直接使用低级别的线程类型几乎没有意义;高级抽象比传统的多线程更强大、更高效。因此,我将尽量减少对过时技术的覆盖。本书中的多线程示例均未使用Thread或BackgroundWorker类型;它们已被更优秀的替代方案取代。
警告
一旦你键入new Thread(),你的项目就已经有了遗留代码。
但不要认为多线程已经过时!多线程仍然存在于线程池中,这是一个有用的工作队列,可以根据需求自动调整自己。线程池进一步实现了另一种重要的并发形式:并行处理。
并行处理
通过将工作分配给多个同时运行的线程来完成大量工作。
并行处理(或并行编程)利用多线程来最大化多个处理器核心的使用。现代 CPU 具有多个核心,如果有很多工作要做,那么让一个核心独自完成所有工作而其他核心空闲是没有意义的。并行处理将工作分配给多个线程,每个线程可以独立地在不同的核心上运行。
并行处理是多线程的一种类型,而多线程是并发的一种类型。在现代应用程序中,还有另一种重要的并发类型,但对许多开发者来说并不那么熟悉:异步编程。
异步编程
一种使用未来或回调来避免不必要线程的并发形式。
未来(或承诺)是一种表示将来某些操作完成的类型。在.NET 中,一些现代的未来类型包括Task和Task<TResult>。旧的异步 API 使用回调或事件来代替未来。异步编程围绕异步操作的概念展开:启动的操作将在稍后某个时刻完成。在操作进行时,它不会阻塞原始线程;启动操作的线程可以自由进行其他工作。当操作完成时,它通过通知其未来或调用其回调或事件来告知应用程序操作已完成。
异步编程是一种强大的并发形式,但直到最近,它需要极其复杂的代码。现代语言中的async和await支持使异步编程几乎和同步(非并发)编程一样简单。
另一种并发形式是响应式编程。异步编程意味着应用程序将启动一个稍后会完成的操作。响应式编程与异步编程密切相关,但是建立在异步事件而不是异步操作的基础上。异步事件可能没有实际的“启动”,可能随时发生,并且可能被多次触发。例如用户输入是一个例子。
响应式编程
一种声明式编程风格,应用程序对事件做出响应。
如果将一个应用程序视为一个大型状态机,该应用程序的行为可以描述为在每个事件中通过更新其状态来响应一系列事件。这并不像听起来那么抽象或理论化;现代框架使这种方法在实际应用中非常有用。响应式编程并不一定是并发的,但它与并发密切相关,因此本书涵盖了基础知识。
通常,在编写并发程序时会混合使用各种技术。大多数应用程序至少使用多线程(通过线程池)和异步编程。可以根据应用程序的不同部分混合和匹配所有各种形式的并发,选择适当的工具。
异步编程介绍
异步编程有两个主要好处。第一个好处是对于终端用户 GUI 程序:异步编程能够提升响应性。每个人都使用过一个在工作时暂时锁定的程序;异步程序可以在工作时保持对用户输入的响应。第二个好处是对于服务器端程序:异步编程能够提升可伸缩性。服务器应用程序可以通过使用线程池来实现一定程度的扩展,但异步服务器应用程序通常可以比这更好地扩展一个数量级。
异步编程的两个好处都源自同一个基本方面:异步编程释放了一个线程。对于 GUI 程序,异步编程释放了 UI 线程;这使得 GUI 应用程序可以保持对用户输入的响应。对于服务器应用程序,异步编程释放了请求线程;这使得服务器可以使用其线程来处理更多的请求。
现代异步.NET 应用程序使用两个关键字:async和await。async关键字添加到方法声明中,起到双重作用:它在该方法内启用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将接受Task或Task<TResult>。
有两种基本方法可以创建Task实例。某些任务表示 CPU 必须执行的实际代码;这些计算任务应通过调用Task.Run(或者如果需要它们在特定调度程序上运行,则通过TaskFactory.StartNew)创建。其他任务表示通知;这些基于事件的任务类型是通过TaskCompletionSource<TResult>(或其快捷方式之一)创建的。大多数 I/O 任务使用TaskCompletionSource<TResult>。
使用async和await进行错误处理是自然的。在以下代码片段中,PossibleExceptionAsync可能会抛出NotSupportedException,但TrySomethingAsync可以自然地捕获异常。捕获的异常保留了其堆栈跟踪,并且没有被人为包装在TargetInvocationException或AggregateException中。
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.Wait、Task<TResult>.Result或GetAwaiter().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)概述。如果想深入了解,还有深入异步文档。
异步流利用了async和await的基础,并将其扩展到处理多个值。异步流围绕异步可枚举的概念构建,类似于常规可枚举,但允许在检索序列的下一个项目时进行异步工作。这是一个非常强大的概念,第三章详细介绍了此内容。异步流在处理单个或分块到达的数据序列时特别有用。例如,如果您的应用程序处理使用limit和offset参数进行分页的 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.Invoke 是 Parallel 方法的一种,它执行一种 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 方法等待任务完成,也可以使用 Result 和 Exception 属性获取工作的结果。直接使用 Task 的代码比使用 Parallel 更复杂,但如果在运行时不知道并行结构,它可能很有用。在这种动态并行性中,你不知道开始处理时需要做多少工作;随着进行,你才会找出。通常,动态工作应该启动需要的子任务,然后等待它们完成。Task 类型有一个特殊标志,TaskCreationOptions.AttachedToParent,可以用于此目的。动态并行性在 Recipe 4.4 中有所涵盖。
任务并行性应该力求独立,就像数据并行性一样。你的委托越独立,程序就越高效。此外,如果你的委托不独立,那么它们就需要同步,编写正确的代码就会更加困难。在任务并行性中,尤其要注意闭包中捕获的变量。记住,闭包捕获的是引用(而不是值),因此可能会出现不明显的共享问题。
所有类型的并行性都有类似的错误处理。因为操作是并行进行的,可能会发生多个异常,因此它们会被包装在抛给你的 AggregateException 中。这种行为在 Parallel.ForEach、Parallel.Invoke、Task.Wait 等中是一致的。AggregateException 类型有一些有用的 Flatten 和 Handle 方法来简化错误处理代码:
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 相同的所有功能,并添加了大量自己的操作符,特别是处理时间的操作符。以下代码从一些不熟悉的操作符 (Interval 和 Timestamp) 开始,以 Subscribe 结束,但中间有一些您应该从 LINQ 中熟悉的 Where 和 Select 操作符:
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 进行实验时非常有用的一种类型。这个“主题”类似于手动实现的可观察流。你的代码可以调用 OnNext、OnError 和 OnCompleted,主题将把这些调用转发给它的订阅者。在实际生产代码中,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 互操作。
线程是低级抽象。线程池是稍高级的抽象;当代码将工作排入线程池时,线程池本身会负责根据需要创建线程。本书涵盖的抽象级别更高:并行和数据流处理根据需要将工作排入线程池。使用这些更高级别抽象的代码比使用低级抽象更容易正确实现。
因此,Thread 和 BackgroundWorker 类型在本书中完全没有涵盖。它们有过自己的时代,而那个时代已经结束了。
并发应用程序的集合
有几个集合类别对并发编程很有用:并发集合和不可变集合。这两个集合类别都在第九章中有所涵盖。并发集合允许多个线程同时安全地更新它们。大多数并发集合使用快照来使一个线程能够枚举值,而另一个线程可能在添加或删除值。并发集合通常比只用锁保护常规集合更有效率。
不可变集合有些不同。不可变集合实际上不能被修改;相反,要修改一个不可变集合,你需要创建一个代表修改后集合的新集合。这听起来效率非常低,但不可变集合在集合实例之间尽可能共享内存,所以情况没有听起来那么糟。不可变集合的好处在于所有操作都是纯粹的,因此它们与函数式代码非常配合。
现代设计
大多数并发技术具有一个相似的特点:它们都具有功能性质。我不是指“功能性”指的是它们能完成工作,而是指一种基于函数组合的编程风格。如果你采用功能性思维方式,你的并发设计将更加简洁。
函数式编程的一个原则是purity(即避免副作用)。解决方案的每一部分都将某些值作为输入并产生某些值作为输出。尽可能避免这些部分依赖全局(或共享)变量或更新全局(或共享)数据结构。无论这部分是一个async方法、一个并行任务、一个 System.Reactive 操作还是一个数据流块,这一点都是真实的。当然,迟早你的计算将不得不产生效果,但如果你能使用纯净的部分处理processing,然后使用results执行更新,你会发现你的代码更加清洁。
函数式编程的另一个原则是immutability。不可变性意味着数据片段不能改变。对于并发程序,不可变数据的一个有用原因是你永远不需要为不可变数据进行同步;它的不变性使得同步变得不必要。不可变数据还有助于避免副作用。开发者开始更多地使用不可变类型,本书包含了几个处理不可变数据结构的技巧。
关键技术摘要
.NET 框架自始至终对异步编程有一些支持。然而,直到 2012 年,也就是.NET 4.5(连同 C# 5.0 和 VB 2012)引入了async和await关键字之前,异步编程一直很困难。本书将使用现代的async/await方法来处理所有异步任务,并提供了一些示例,展示如何在async和旧的异步编程模式之间进行交互。如果需要支持旧平台,请参阅附录 A。
.NET 4.0 引入了任务并行库(Task Parallel Library,TPL),全面支持数据并行和任务并行。如今,即使在资源较少的平台如手机上,也可以使用。TPL 已经内置于.NET 中。
System.Reactive 团队努力支持尽可能多的平台。像async和await一样,System.Reactive 为各种应用程序(包括客户端和服务器)提供了诸多好处。System.Reactive 可以在System.Reactive NuGet 包中找到。
TPL Dataflow 库正式分发在System.Threading.Tasks.Dataflow NuGet 包中。
大多数并发集合都内置于.NET 中;System.Threading.Channels NuGet 包中还提供了一些额外的并发集合。不可变集合则可以在System.Collections.Immutable NuGet 包中找到。
第二章:异步基础
本章介绍了使用 async 和 await 进行异步操作的基础知识。在这里,我们将只处理自然异步操作,例如 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.FromResult、Task.FromException和Task.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 }
存在一个接受任务 IEnumerable 的 Task.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 时,我不观察所有的异常。通常只需响应第一个抛出的错误即可,而不是所有的错误。
在前面的例子中,请注意,ThrowNotImplementedExceptionAsync 和 ThrowInvalidOperationExceptionAsync 方法不会直接抛出异常;它们使用了 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方法一样使用async和await:
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 的详细信息。
大多数情况下,如果你只是实现使用 ValueTask 或 ValueTask<T> 的接口,那么可以简单地使用 async 和 await。更高级的实现是为了当你自己使用 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 的限制之一。
警告
只能一次性等待 ValueTask 或 ValueTask<T>。
要执行更复杂的操作,请通过调用 AsTask 将 ValueTask<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>.Result 比 Task<T>.Result 有更多限制。从 ValueTask<T> 同步检索结果的代码可以调用 ValueTask<T>.Result 或 ValueTask<T>.GetAwaiter().GetResult(),但这些成员不能在 ValueTask<T> 完成之前调用。从 Task<T> 同步获取结果会阻塞调用线程,直到任务完成;ValueTask<T> 不提供这样的保证。
警告
从 ValueTask 或 ValueTask<T> 同步获取结果只能在 ValueTask 完成后进行一次,并且不能再等待或转换为任务。
为了避免重复,当你的代码调用返回 ValueTask 或 ValueTask<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是一个集合,该值也只能提供一次。有关使用async与Task<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 都接受 offset 和 limit 参数以启用结果的分页。假设我们想要定义一个方法,从进行分页的 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>,我们可以使用await和yield 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实现,而异步方法则使用async和await。有了异步流,你可以结合这两者;只需使用返回类型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;
}
这个简单的例子说明了如何使用await与yield 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 个元素。
讨论
自从引入 async 和 await 以来,用户一直在思考如何与 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 操作符都在这里:Where、Select、SelectMany,甚至Join。现在大多数 LINQ 操作符也接受异步委托,就像上面的WhereAwait示例一样。
讨论
异步流是基于拉取的,因此没有像可观察对象那样的与时间相关的操作符。在这个世界中,Throttle和Sample没有意义,因为元素是按需从异步流中拉取出来的。
用于异步流的 LINQ 方法也对常规可枚举对象有用。如果您发现自己处于这种情况下,您可以在任何IEnumerable<T>上调用ToAsyncEnumerable(),然后您将拥有一个异步流接口,您可以使用WhereAwait、SelectAwait和其他支持异步委托的操作符。
在深入研究之前,有必要谈一下命名。本示例中使用WhereAwait作为Where的异步等价物。当您探索用于异步流的 LINQ 操作符时,您会发现一些以Async结尾,而另一些以Await结尾。以Async结尾的操作符返回一个可等待的对象;它们代表一个常规值,而不是一个异步序列。以Await结尾的操作符接受一个异步委托;它们名称中的Await意味着它们实际上在您传递给它们的委托上执行了一个await。
我们已经看过带有 Await 后缀的 Where 和 WhereAwait 的示例。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传递到标记为EnumeratorCancellation的token参数,现在取消请求会导致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.None 和 TaskScheduler.Default 在上面的代码示例中被使用。取消令牌在 Recipe 10.2 中有详细介绍,任务调度器则在 Recipe 13.3 中有介绍。明确指定 StartNew 和 ContinueWith 使用的 TaskScheduler 是个不错的主意。
这种父子任务的安排在动态并行性中很常见,尽管不是必需的。同样可以将每个新任务存储在线程安全的集合中,然后使用 Task.WaitAll 等待它们全部完成。
警告
使用 Task 进行并行处理与使用 Task 进行异步处理完全不同。
Task 类型在并发编程中有两个用途:它可以是并行任务或异步任务。并行任务可能使用阻塞成员,例如 Task.Wait、Task.Result、Task.WaitAll 和 Task.WaitAny。并行任务通常也使用 AttachedToParent 在任务之间创建父/子关系。应使用 Task.Run 或 Task.Factory.StartNew 创建并行任务。
相反地,异步任务应避免使用阻塞成员,而应偏向于使用 await、Task.WhenAll 和 Task.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)以及各种聚合,如 Sum、Average 和更通用的 Aggregate。总体而言,您可以使用普通 LINQ 可以做的任何事情,也可以使用 PLINQ 并行处理。如果您有现有的 LINQ 代码,可以受益于并行运行,那么 PLINQ 是一个很好的选择。
参见
配方 4.1 讲述了如何使用 Parallel 类来对序列中的每个元素执行代码。
配方 10.5 讲述了如何取消 PLINQ 查询。