C--多线程秘籍第二版-全-
C# 多线程秘籍第二版(全)
原文:
zh.annas-archive.org/md5/600c0d032f80b726068d9394684e1d21译者:飞龙
前言
不久以前,典型的个人计算机 CPU 只有一个计算核心,其功耗足以在上面煎鸡蛋。2005 年,英特尔推出了其首款多核 CPU,从那时起,计算机开始向不同方向发展。低功耗和多个计算核心比单个计算核心的性能更重要。这导致了编程范式的变化。现在,我们需要学习如何有效地使用所有 CPU 核心以实现最佳性能,同时,我们还需要通过仅运行特定时间所需的程序来节省电池电量。除此之外,我们还需要以尽可能高效的方式编写服务器应用程序,使用多个 CPU 核心甚至多台计算机,以支持尽可能多的用户。
要能够创建这样的应用程序,你必须学会在程序中有效地使用多个 CPU 核心。如果你使用的是 Microsoft .NET 开发平台和 C#,这本书将是你编写快速响应应用程序的完美起点。
本书的目的是为你提供 C#中多线程和并行编程的逐步指南。我们将从基本概念开始,根据前几章的信息,逐步深入更高级的主题,并以实际世界的并行编程模式、通用 Windows 应用程序和跨平台应用程序示例结束。
本书涵盖的内容
第一章,线程基础,介绍了 C#中线程的基本操作。它解释了什么是线程,使用线程的优缺点,以及其他重要的线程方面。
第二章,线程同步,描述了线程交互的细节。你将了解为什么我们需要协调线程,以及组织线程协调的不同方式。
第三章,使用线程池,解释了线程池的概念。它展示了如何使用线程池,如何处理异步操作,以及使用线程池的优缺点。
第四章,使用任务并行库,深入探讨了任务并行库(TPL)框架。本章概述了 TPL 的每个重要方面,包括任务组合、异常管理和操作取消。
第五章,使用 C# 6.0,详细介绍了最近引入的 C#特性——异步方法。你将了解async和await关键字的意义,如何在不同的场景中使用它们,以及await是如何在底层工作的。
第六章, 使用并发集合描述了.NET Framework 中包含的并行算法的标准数据结构。它为每个数据结构提供了示例编程场景。
第七章, 使用 PLINQ深入探讨了 Parallel LINQ 基础设施。本章描述了任务并行和数据并行,并行化 LINQ 查询,调整并行选项,分区查询以及聚合并行查询的结果。
第八章, 响应式扩展解释了何时以及如何使用响应式扩展框架。你将学习如何组合事件以及如何对事件序列执行 LINQ 查询。
第九章, 使用异步 I/O详细介绍了异步 I/O 过程,包括文件、网络和数据库场景。
第十章, 并行编程模式概述了常见并行编程问题的解决方案。
第十一章
{
lock (lock1)
{
Sleep(1000);
lock (lock2);
}
}
任何命令行输入或输出都如下所示:
```cs
dotnet restore
dotnet run
新术语和重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:"在项目的引用文件夹上右键单击,并选择管理 NuGet 包…菜单选项"。
注意
警告或重要注意事项如下所示。
小贴士
小技巧和窍门如下所示。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者的反馈对我们开发您真正能从中获得最大收益的标题非常重要。
要发送给我们一般反馈,只需发送电子邮件到<feedback@packtpub.com>,并在您的邮件主题中提及书名。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您是 Packt 书籍的骄傲拥有者,我们有多种方式可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持标签上。
-
点击代码下载 & 错误清单。
-
在搜索框中输入书籍的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买这本书的地方。
-
点击代码下载。
下载文件后,请确保您使用最新版本解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误清单提交表单链接,并输入您的错误清单详情来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站,或添加到该标题的错误清单部分。您可以通过从www.packtpub.com/support选择您的标题来查看现有的错误清单。
侵权
在互联网上侵犯版权材料是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以追究补救措施。
请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者以及为我们提供有价值内容方面的帮助。
问题
如果你在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。
第一章. 线程基础
在本章中,我们将介绍使用 C# 中的线程的基本任务。您将学习以下食谱:
-
在 C# 中创建线程
-
暂停线程
-
使线程等待
-
终止线程
-
确定线程状态
-
线程优先级
-
前台和后台线程
-
向线程传递参数
-
使用 C# lock 关键字进行锁定
-
使用
Monitor构造进行锁定 -
处理异常
简介
在过去的某个时刻,常见的计算机只有一个计算单元,不能同时执行多个计算任务。然而,操作系统已经能够同时处理多个程序,实现了多任务的概念。为了防止一个程序永远控制 CPU,导致其他应用程序和操作系统本身挂起,操作系统必须以某种方式将物理计算单元分割成几个虚拟化处理器,并给每个正在执行的程序分配一定量的计算能力。此外,操作系统必须始终具有对 CPU 的优先访问权,并且应该能够对不同程序进行 CPU 访问的优先级排序。线程是这个概念的实现。它可以被认为是一个分配给特定程序并独立运行的虚拟处理器。
注意
记住,线程消耗大量的操作系统资源。试图在许多线程之间共享一个物理处理器将导致操作系统忙于仅管理线程而不是运行程序。
因此,虽然可以增强计算机处理器,使其每秒执行更多命令,但通常与线程的交互是操作系统任务。在单核 CPU 上尝试并行计算某些任务是没有意义的,因为这会比顺序运行这些计算花费更多时间。然而,当处理器开始拥有更多计算核心时,旧程序无法利用这一点,因为它们只使用一个处理器核心。
为了有效地利用现代处理器的计算能力,能够以某种方式编写程序,使其能够使用多个计算核心,这导致将其组织为几个相互通信和同步的线程,这一点非常重要。
本章中的食谱专注于使用 C# 语言对线程执行一些非常基本的操作。我们将涵盖线程的生命周期,包括创建、挂起、使线程等待和终止线程,然后,我们将介绍基本同步技术。
在 C# 中创建线程
在接下来的食谱中,我们将使用 Visual Studio 2015 作为编写 C# 多线程程序的主要工具。本食谱将向您展示如何创建一个新的 C# 程序并在其中使用线程。
注意
可以从 Microsoft 网站下载免费的 Visual Studio Community 2015 IDE 并用于运行代码示例。
准备工作
要完成此食谱,您需要 Visual Studio 2015。没有其他先决条件。此食谱的源代码位于 BookSamples\Chapter1\Recipe1 目录中。
提示
下载示例代码
您可以从 www.packtpub.com 的账户下载此书的示例代码文件。如果您在其他地方购买了此书,您可以访问 www.packtpub.com/support 并注册以将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的 支持 选项卡上。
-
点击 代码下载与勘误表。
-
在 搜索 框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击 代码下载。
文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip / UnRarX for Mac
-
7-Zip/PeaZip for Linux
如何操作...
要了解如何在 C# 程序中创建新程序并使用线程,请按照以下步骤操作:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
确保项目使用 .NET Framework 4.6 或更高版本;然而,本章中的代码也可以与之前的版本兼容。
![如何操作...]()
-
在
Program.cs文件中添加以下using指令:using System; using System.Threading; using static System.Console; -
在
Main方法下方添加以下代码片段:static void PrintNumbers() { WriteLine("Starting..."); for (int i = 1; i < 10; i++) { WriteLine(i); } } -
在
Main方法内部添加以下代码片段:Thread t = new Thread(PrintNumbers); t.Start(); PrintNumbers(); -
运行程序。输出将类似于以下截图:
![如何操作...]()
工作原理...
在步骤 1 和 2 中,我们使用 .Net Framework 版本 4.0 创建了一个简单的 C# 控制台应用程序。然后,在步骤 3 中,我们包含了 System.Threading 命名空间,其中包含程序所需的所有类型。然后,我们使用了 C# 6.0 的 using static 功能,这使得我们可以使用 System.Console 类的静态方法而不需要指定类型名称。
注意
正在执行的程序实例可以称为进程。进程由一个或多个线程组成。这意味着当我们运行程序时,我们始终有一个主线程来执行程序代码。
在步骤 4 中,我们定义了 PrintNumbers 方法,该方法将在主线程和新创建的线程中使用。然后,在步骤 5 中,我们创建了一个运行 PrintNumbers 的线程。当我们构造线程时,会将 ThreadStart 或 ParameterizedThreadStart 委托的实例传递给构造函数。C# 编译器在后台创建此对象,当我们只是输入想要在另一个线程中运行的函数名称时。然后,我们启动一个线程,并在主线程中以常规方式运行 PrintNumbers。
结果,将会有两个从 1 到 10 的随机交叉的数字范围。这说明了PrintNumbers方法在主线程和其他线程上同时运行。
暂停线程
这个食谱将向你展示如何使线程等待一段时间而不浪费操作系统资源。
准备工作
为了完成这个食谱,你需要 Visual Studio 2015。没有其他先决条件。这个食谱的源代码可以在BookSamples\Chapter1\Recipe2中找到。
如何操作...
要理解如何在不浪费操作系统资源的情况下使线程等待,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static void PrintNumbers() { WriteLine("Starting..."); for (int i = 1; i < 10; i++) { WriteLine(i); } } static void PrintNumbersWithDelay() { WriteLine("Starting..."); for (int i = 1; i < 10; i++) { Sleep(TimeSpan.FromSeconds(2)); WriteLine(i); } } -
在
Main方法内部添加以下代码片段:Thread t = new Thread(PrintNumbersWithDelay); t.Start(); PrintNumbers(); -
运行程序。
它是如何工作的...
当程序运行时,它创建了一个线程,该线程将在PrintNumbersWithDelay方法中执行代码。紧接着,它运行PrintNumbers方法。这里的关键特性是在PrintNumbersWithDelay方法中添加了Thread.Sleep方法调用。这会导致执行此代码的线程在打印每个数字之前等待指定的时间(在我们的例子中是 2 秒)。当线程休眠时,它尽可能少地使用 CPU 时间。因此,我们将看到通常运行较晚的PrintNumbers方法中的代码将在单独的线程中先于PrintNumbersWithDelay方法中的代码执行。
使线程等待
这个食谱将向你展示程序如何等待另一个线程中的某些计算完成,以便稍后在代码中使用其结果。仅使用Thread.Sleep方法是不够的,因为我们不知道计算的确切时间。
准备工作
为了完成这个食谱,你需要 Visual Studio 2015。没有其他先决条件。这个食谱的源代码可以在BookSamples\Chapter1\Recipe3中找到。
如何操作...
为了理解程序如何等待另一个线程中的某些计算完成以便稍后使用其结果,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static void PrintNumbersWithDelay() { WriteLine("Starting..."); for (int i = 1; i < 10; i++) { Sleep(TimeSpan.FromSeconds(2)); WriteLine(i); } } -
在
Main方法内部添加以下代码片段:WriteLine("Starting..."); Thread t = new Thread(PrintNumbersWithDelay); t.Start(); t.Join(); WriteLine("Thread completed"); -
运行程序。
它是如何工作的...
当程序运行时,它运行一个长时间运行的线程,打印数字并等待两秒钟后再打印每个数字。但是,在主程序中,我们调用了 t.Join 方法,这允许我们等待线程 t 完成工作。当它完成时,主程序继续运行。借助这种技术,可以在两个线程之间同步执行步骤。第一个线程会等待另一个线程完成,然后继续工作。当第一个线程等待时,它处于 blocked 状态(就像在之前的菜谱中调用 Thread.Sleep 时一样)。
终止线程
在这个菜谱中,我们将描述如何终止另一个线程的执行。
准备工作
要完成这个菜谱,你需要 Visual Studio 2015。没有其他先决条件。这个菜谱的源代码可以在 BookSamples\Chapter1\Recipe4 找到。
如何操作...
要了解如何终止另一个线程的执行,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; -
使用静态
System.Threading.Thread,在Main方法下方添加以下代码片段:static void PrintNumbersWithDelay() { WriteLine("Starting..."); for (int i = 1; i < 10; i++) { Sleep(TimeSpan.FromSeconds(2)); WriteLine(i); } } -
在
Main方法内部添加以下代码片段:WriteLine("Starting program..."); Thread t = new Thread(PrintNumbersWithDelay); t.Start(); Thread.Sleep(TimeSpan.FromSeconds(6)); t.Abort(); WriteLine("A thread has been aborted"); Thread t = new Thread(PrintNumbers); t.Start(); PrintNumbers(); -
运行程序。
它是如何工作的...
当主程序和单独的数字打印线程运行时,我们等待六秒钟,然后在线程上调用 t.Abort 方法。这会将 ThreadAbortException 方法注入到线程中,导致其终止。这非常危险,通常因为这个异常可能在任何时刻发生,可能会完全破坏应用程序。此外,并不总是可以使用这种方法终止线程。目标线程可能会通过调用 Thread.ResetAbort 方法来处理这个异常,从而拒绝终止。因此,不建议使用 Abort 方法来关闭线程。有其他更推荐的方法,例如提供一个 CancellationToken 对象来取消线程执行。这种方法将在 第三章 使用线程池 中描述。
确定线程状态
这个菜谱将描述线程可能具有的可能状态。了解线程是否已启动或是否处于阻塞状态是有用的。请注意,由于线程是独立运行的,其状态可能在任何时候发生变化。
准备工作
要完成这个菜谱,你需要 Visual Studio 2015。没有其他先决条件。这个菜谱的源代码可以在 BookSamples\Chapter1\Recipe5 找到。
如何操作...
要了解如何确定线程状态并获取有关它的有用信息,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static void DoNothing() { Sleep(TimeSpan.FromSeconds(2)); } static void PrintNumbersWithStatus() { WriteLine("Starting..."); WriteLine(CurrentThread.ThreadState.ToString()); for (int i = 1; i < 10; i++) { Sleep(TimeSpan.FromSeconds(2)); WriteLine(i); } } -
在
Main方法内部添加以下代码片段:WriteLine("Starting program..."); Thread t = new Thread(PrintNumbersWithStatus); Thread t2 = new Thread(DoNothing); WriteLine(t.ThreadState.ToString()); t2.Start(); t.Start(); for (int i = 1; i < 30; i++) { WriteLine(t.ThreadState.ToString()); } Sleep(TimeSpan.FromSeconds(6)); t.Abort(); WriteLine("A thread has been aborted"); WriteLine(t.ThreadState.ToString()); WriteLine(t2.ThreadState.ToString()); -
运行程序。
它是如何工作的...
当主程序启动时,它定义了两个不同的线程;其中一个将被终止,而另一个将成功运行。线程状态位于 Thread 对象的 ThreadState 属性中,这是一个 C# 枚举。一开始,线程处于 ThreadState.Unstarted 状态。然后,我们运行它,并假设在 30 次循环迭代的过程中,线程的状态将从 ThreadState.Running 变为 ThreadState.WaitSleepJoin。
小贴士
注意,当前的 Thread 对象始终可以通过 Thread.CurrentThread 静态属性访问。
如果没有发生这种情况,只需增加迭代次数。然后,我们终止第一个线程,并看到现在它处于 ThreadState.Aborted 状态。程序也可能打印出 ThreadState.AbortRequested 状态。这很好地说明了同步两个线程的复杂性。请记住,你不应该在程序中使用线程终止。这里只是为了展示相应的线程状态。
最后,我们可以看到我们的第二个线程 t2 成功完成,并且现在处于 ThreadState.Stopped 状态。还有其他几种状态,但它们部分已弃用,不如我们检查的那些状态有用。
线程优先级
这个菜谱将描述线程优先级的不同选项。设置线程优先级决定了线程将获得多少 CPU 时间。
准备工作
要完成这个菜谱,你需要 Visual Studio 2015。没有其他先决条件。这个菜谱的源代码可以在 BookSamples\Chapter1\Recipe6 找到。
如何完成...
要理解线程优先级的工作原理,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; using static System.Diagnostics.Process; -
在
Main方法下方添加以下代码片段:static void RunThreads() { var sample = new ThreadSample(); var threadOne = new Thread(sample.CountNumbers); threadOne.Name = "ThreadOne"; var threadTwo = new Thread(sample.CountNumbers); threadTwo.Name = "ThreadTwo"; threadOne.Priority = ThreadPriority.Highest; threadTwo.Priority = ThreadPriority.Lowest; threadOne.Start(); threadTwo.Start(); Sleep(TimeSpan.FromSeconds(2)); sample.Stop(); } class ThreadSample { private bool _isStopped = false; public void Stop() { _isStopped = true; } public void CountNumbers() { long counter = 0; while (!_isStopped) { counter++; } WriteLine($"{CurrentThread.Name} with " + $"{CurrentThread.Priority,11} priority " + $"has a count = {counter,13:N0}"); } } -
在
Main方法内部添加以下代码片段:WriteLine($"Current thread priority: {CurrentThread.Priority}"); WriteLine("Running on all cores available"); RunThreads(); Sleep(TimeSpan.FromSeconds(2)); WriteLine("Running on a single core"); GetCurrentProcess().ProcessorAffinity = new IntPtr(1); RunThreads(); -
运行程序。
它是如何工作的...
当主程序启动时,它定义了两个不同的线程。第一个线程 threadOne 具有最高的线程优先级 ThreadPriority.Highest,而第二个线程 threadTwo 具有最低的 ThreadPriority.Lowest 优先级。我们打印出主线程的优先级值,然后在这些所有可用的核心上启动这两个线程。如果我们有多个计算核心,我们应在两秒内得到一个初始结果。通常,具有最高优先级的线程会计算更多的迭代次数,但这两个值应该很接近。然而,如果有其他程序正在运行并加载所有 CPU 核心,情况可能会有很大不同。
为了模拟这种情况,我们设置了 ProcessorAffinity 选项,指示操作系统在我们的单个 CPU 核心上(编号 1)运行所有线程。现在,结果应该非常不同,计算将超过两秒钟。这是因为 CPU 核心主要运行高优先级线程,给其他线程的时间非常少。
注意,这是一个演示操作系统如何与线程优先级协同工作的示例。通常,你不应该编写依赖于这种行为的程序。
前台和后台线程
本食谱将描述前台和后台线程是什么,以及设置此选项如何影响程序的行为。
准备工作
为了完成这个食谱,你需要 Visual Studio 2015。没有其他先决条件。本食谱的源代码可以在 BookSamples\Chapter1\Recipe7 中找到。
如何操作...
为了了解前台和后台线程对程序的影响,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:class ThreadSample { private readonly int _iterations; public ThreadSample(int iterations) { _iterations = iterations; } public void CountNumbers() { for (int i = 0; i < _iterations; i++) { Sleep(TimeSpan.FromSeconds(0.5)); WriteLine($"{CurrentThread.Name} prints {i}"); } } } -
在
Main方法内部添加以下代码片段:var sampleForeground = new ThreadSample(10); var sampleBackground = new ThreadSample(20); var threadOne = new Thread(sampleForeground.CountNumbers); threadOne.Name = "ForegroundThread"; var threadTwo = new Thread(sampleBackground.CountNumbers); threadTwo.Name = "BackgroundThread"; threadTwo.IsBackground = true; threadOne.Start(); threadTwo.Start(); -
运行程序。
它是如何工作的...
当主程序启动时,它定义了两个不同的线程。默认情况下,我们显式创建的线程是前台线程。为了创建后台线程,我们手动将 threadTwo 对象的 IsBackground 属性设置为 true。我们配置这些线程的方式是,第一个线程将更快完成,然后我们运行程序。
在第一个线程完成之后,程序将关闭,后台线程将被终止。这是两者之间的主要区别:一个进程在完成工作之前会等待所有前台线程完成,但如果它有后台线程,它们只需关闭即可。
还需要提到的是,如果一个程序定义了一个未完成的前台线程,主程序将无法正确结束。
向线程传递参数
本食谱将描述如何为我们在另一个线程中运行的代码提供所需的数据。我们将探讨完成此任务的不同方法,并回顾常见的错误。
准备工作
为了完成这个食谱,你需要 Visual Studio 2015。没有其他先决条件。本食谱的源代码可以在 BookSamples\Chapter1\Recipe8 中找到。
如何操作...
要了解如何向线程传递参数,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static void Count(object iterations) { CountNumbers((int)iterations); } static void CountNumbers(int iterations) { for (int i = 1; i <= iterations; i++) { Sleep(TimeSpan.FromSeconds(0.5)); WriteLine($"{CurrentThread.Name} prints {i}"); } } static void PrintNumber(int number) { WriteLine(number); } class ThreadSample { private readonly int _iterations; public ThreadSample(int iterations) { _iterations = iterations; } public void CountNumbers() { for (int i = 1; i <= _iterations; i++) { Sleep(TimeSpan.FromSeconds(0.5)); WriteLine($"{CurrentThread.Name} prints {i}"); } } } -
在
Main方法内部添加以下代码片段:var sample = new ThreadSample(10); var threadOne = new Thread(sample.CountNumbers); threadOne.Name = "ThreadOne"; threadOne.Start(); threadOne.Join(); WriteLine("--------------------------"); var threadTwo = new Thread(Count); threadTwo.Name = "ThreadTwo"; threadTwo.Start(8); threadTwo.Join(); WriteLine("--------------------------"); var threadThree = new Thread(() => CountNumbers(12)); threadThree.Name = "ThreadThree"; threadThree.Start(); threadThree.Join(); WriteLine("--------------------------"); int i = 10; var threadFour = new Thread(() => PrintNumber(i)); i = 20; var threadFive = new Thread(() => PrintNumber(i)); threadFour.Start(); threadFive.Start(); -
运行程序。
它是如何工作的...
当主程序启动时,它首先创建一个ThreadSample类的对象,向它提供一个迭代次数。然后,我们通过对象的CountNumbers方法启动一个线程。这个方法在另一个线程中运行,但它使用数字10,这是我们传递给对象构造函数的值。因此,我们只是以相同的方式间接地将这个迭代次数传递给另一个线程。
更多内容…
另一种传递数据的方式是使用Thread.Start方法,通过接受可以传递给另一个线程的对象。为了这样工作,我们在另一个线程中启动的方法必须接受一个类型为object的单个参数。这个选项通过创建一个threadTwo线程来展示。我们将8作为对象传递给Count方法,在那里它被转换为integer类型。
下一个选项涉及到 lambda 表达式的使用。lambda 表达式定义了一个不属于任何类的方法。我们创建这样的方法,它调用另一个方法并传递所需的参数,然后在另一个线程中启动它。当我们启动threadThree线程时,它打印出 12 个数字,这正是我们通过 lambda 表达式传递给它的数字。
使用 lambda 表达式涉及到另一个名为closure的 C#结构。当我们在一个 lambda 表达式中使用任何局部变量时,C#会生成一个类,并将这个变量作为这个类的属性。所以,实际上,我们做的和在threadOne线程中做的是同一件事,但我们不是自己定义这个类;C#编译器会自动完成这个操作。
这可能会导致几个问题;例如,如果我们从几个 lambda 表达式中使用相同的变量,它们实际上会共享这个变量的值。这在前面的例子中得到了说明,当我们启动threadFour和threadFive时,它们都打印出20,因为变量在两个线程启动之前被更改为保留20的值。
使用 C# lock 关键字进行锁定
这个食谱将描述如何确保当一个线程使用某些资源时,另一个线程不会同时使用它。我们将看到为什么这是必要的,以及线程安全概念是什么。
准备工作
为了完成这个食谱,你需要 Visual Studio 2015。没有其他先决条件。这个食谱的源代码可以在BookSamples\Chapter1\Recipe9找到。
如何操作...
要理解如何使用 C#的 lock 关键字,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; -
在
Main方法下方添加以下代码片段:static void TestCounter(CounterBase c) { for (int i = 0; i < 100000; i++) { c.Increment(); c.Decrement(); } } class Counter : CounterBase { public int Count { get; private set; } public override void Increment() { Count++; } public override void Decrement() { Count--; } } class CounterWithLock : CounterBase { private readonly object _syncRoot = new Object(); public int Count { get; private set; } public override void Increment() { lock (_syncRoot) { Count++; } } public override void Decrement() { lock (_syncRoot) { Count--; } } } abstract class CounterBase { public abstract void Increment(); public abstract void Decrement(); } -
在
Main方法内部添加以下代码片段:WriteLine("Incorrect counter"); var c = new Counter(); var t1 = new Thread(() => TestCounter(c)); var t2 = new Thread(() => TestCounter(c)); var t3 = new Thread(() => TestCounter(c)); t1.Start(); t2.Start(); t3.Start(); t1.Join(); t2.Join(); t3.Join(); WriteLine($"Total count: {c.Count}"); WriteLine("--------------------------"); WriteLine("Correct counter"); var c1 = new CounterWithLock(); t1 = new Thread(() => TestCounter(c1)); t2 = new Thread(() => TestCounter(c1)); t3 = new Thread(() => TestCounter(c1)); t1.Start(); t2.Start(); t3.Start(); t1.Join(); t2.Join(); t3.Join(); WriteLine($"Total count: {c1.Count}"); -
运行程序。
工作原理...
当主程序启动时,它首先创建一个Counter类的对象。这个类定义了一个简单的计数器,它可以增加和减少。然后,我们启动三个线程,它们共享同一个计数器实例,并在循环中进行增加和减少操作。这会导致非确定性的结果。如果我们运行程序几次,它将打印出几个不同的计数器值。可能是0,但大多数情况下不会是。
这是因为Counter类不是线程安全的。当多个线程同时访问计数器时,第一个线程获取计数器的值10并将其增加到11。然后,第二个线程获取值11并将其增加到12。第一个线程获取计数器的值12,但在减量发生之前,第二个线程也获取了计数器的值12。然后,第一个线程将12减到11并保存到计数器中,而第二个线程同时做同样的操作。结果是我们有两个增加操作和一个减少操作,这显然是不正确的。这种情况被称为竞争条件,是多线程环境中错误的一个非常常见原因。
为了确保这种情况不会发生,我们必须确保当一个线程正在与计数器一起工作时,所有其他线程都等待直到第一个线程完成工作。我们可以使用lock关键字来实现这种行为。如果我们锁定一个对象,所有需要访问这个对象的其他线程都将等待在阻塞状态,直到它被解锁。这可能会成为一个严重的性能问题,稍后,在第二章中,你将学习更多关于这个内容。
使用 Monitor 构造函数进行锁定
这个菜谱展示了另一种常见的多线程错误,称为死锁。由于死锁会导致程序停止工作,这个例子中的第一个部分是一个新的Monitor构造函数,它允许我们避免死锁。然后,使用之前描述的lock关键字来引发死锁。
准备工作
要完成这个菜谱,你需要 Visual Studio 2015。没有其他先决条件。这个菜谱的源代码可以在BookSamples\Chapter1\Recipe10找到。
如何做到这一点...
要理解多线程错误死锁,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static void LockTooMuch(object lock1, object lock2) { lock (lock1) { Sleep(1000); lock (lock2); } } -
在
Main方法内添加以下代码片段:object lock1 = new object(); object lock2 = new object(); new Thread(() => LockTooMuch(lock1, lock2)).Start(); lock (lock2) { Thread.Sleep(1000); WriteLine("Monitor.TryEnter allows not to get stuck, returning false after a specified timeout is elapsed"); if (Monitor.TryEnter(lock1, TimeSpan.FromSeconds(5))) { WriteLine("Acquired a protected resource succesfully"); } else { WriteLine("Timeout acquiring a resource!"); } } new Thread(() => LockTooMuch(lock1, lock2)).Start(); WriteLine("----------------------------------"); lock (lock2) { WriteLine("This will be a deadlock!"); Sleep(1000); lock (lock1) { WriteLine("Acquired a protected resource succesfully"); } } -
运行程序。
它是如何工作的...
让我们从LockTooMuch方法开始。在这个方法中,我们只锁定第一个对象,等待一秒钟,然后锁定第二个对象。然后,我们在另一个线程中启动这个方法,并尝试从主线程锁定第二个对象,然后是第一个对象。
如果我们像在这个演示的第二部分中那样使用lock关键字,将会出现死锁。第一个线程持有lock1对象的lock,等待lock2对象变得空闲;主线程持有lock2对象的lock,等待lock1对象变得空闲,但在这个情况下永远不会发生。
实际上,lock关键字是Monitor类使用的语法糖。如果我们使用lock反汇编代码,我们会看到它变成了以下代码片段:
bool acquiredLock = false;
try
{
Monitor.Enter(lockObject, ref acquiredLock);
// Code that accesses resources that are protected by the lock.
}
finally
{
if (acquiredLock)
{
Monitor.Exit(lockObject);
}
}
因此,我们可以直接使用Monitor类;它有一个TryEnter方法,该方法接受一个超时参数,如果在我们可以获取由lock保护的资源之前超时参数过期,则返回false。
处理异常
这个配方将描述如何正确处理其他线程中的异常。始终在线程内部放置try/catch块非常重要,因为不可能在线程代码外部捕获异常。
准备工作
要完成这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter1\Recipe11中找到。
如何操作...
要理解在其他线程中处理异常,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static void BadFaultyThread() { WriteLine("Starting a faulty thread..."); Sleep(TimeSpan.FromSeconds(2)); throw new Exception("Boom!"); } static void FaultyThread() { try { WriteLine("Starting a faulty thread..."); Sleep(TimeSpan.FromSeconds(1)); throw new Exception("Boom!"); } catch (Exception ex) { WriteLine($"Exception handled: {ex.Message}"); } } -
在
Main方法内部添加以下代码片段:var t = new Thread(FaultyThread); t.Start(); t.Join(); try { t = new Thread(BadFaultyThread); t.Start(); } catch (Exception ex) { WriteLine("We won't get here!"); } -
运行程序。
它是如何工作的...
当主程序启动时,它定义了两个将抛出异常的线程。其中一个线程处理异常,而另一个没有。你可以看到第二个异常没有被代码启动部分的try/catch块捕获。所以,如果你直接与线程工作,一般规则是不要从线程中抛出异常,而是在线程代码内部使用try/catch块。
在.NET Framework 的旧版本(1.0 和 1.1)中,这种行为是不同的,未捕获的异常不会强制应用程序关闭。可以通过添加包含以下代码片段的应用程序配置文件(例如app.config)来使用此策略:
<configuration>
<runtime>
<legacyUnhandledExceptionPolicy enabled="1" />
</runtime>
</configuration>
第二章. 线程同步
在本章中,我们将描述一些从多个线程处理共享资源的常见技术。你将学习以下技巧:
-
执行基本的原子操作
-
使用
Mutex结构 -
使用
SemaphoreSlim结构 -
使用
AutoResetEvent结构 -
使用
ManualResetEventSlim结构 -
使用
CountDownEvent结构 -
使用
Barrier结构 -
使用
ReaderWriterLockSlim结构 -
使用
SpinWait结构
简介
如我们在第一章中看到的,线程基础,从几个线程同时使用共享对象是有问题的。然而,同步这些线程以使它们按正确顺序对共享对象执行操作非常重要。在使用 C# lock 关键字进行锁定技巧中,我们遇到了一个称为竞争条件的问题。问题发生是因为这些多个线程的执行没有正确同步。当一个线程执行增加和减少操作时,其他线程必须等待它们的轮次。以这种方式组织线程通常被称为线程同步。
实现线程同步有几种方法。首先,如果没有共享对象,就根本不需要同步。令人惊讶的是,我们经常可以通过重新设计我们的程序并删除共享状态来消除复杂的同步结构。如果可能的话,只需避免从多个线程使用单个对象。
如果我们必须有一个共享状态,第二种方法就是只使用原子操作。这意味着一个操作只占用一个时间量子并立即完成,因此其他线程在第一个操作完成之前不能执行另一个操作。因此,没有必要让其他线程等待这个操作完成,也不需要使用锁;这反过来,排除了死锁的情况。
如果这不可能,并且程序的逻辑更复杂,那么我们必须使用不同的结构来协调线程。这些结构中的一组将等待的线程置于阻塞状态。在阻塞状态下,线程尽可能少地使用 CPU 时间。然而,这意味着它至少会包含一个所谓的上下文切换——操作系统的线程调度器将保存等待线程的状态并切换到另一个线程,然后依次恢复其状态。这需要相当多的资源;然而,如果线程将要长时间挂起,这是好的。这类结构也被称为内核模式结构,因为只有操作系统的内核能够停止线程使用 CPU 时间。
如果我们必须等待一段时间,最好是简单地等待而不是将线程切换到阻塞状态。这将节省我们上下文切换的开销,同时线程等待时会浪费一些 CPU 时间。这样的构造被称为用户模式构造。它们非常轻量级且快速,但当一个线程需要长时间等待时,会浪费大量的 CPU 时间。
为了充分利用两者的优点,存在混合构造;这些尝试首先使用用户模式等待,然后,如果一个线程等待足够长的时间,它将切换到阻塞状态,从而节省 CPU 资源。
在本章中,我们将探讨线程同步的各个方面。我们将介绍如何执行原子操作,以及如何使用.NET Framework 中包含的现有同步构造。
执行基本原子操作
此配方将向您展示如何对一个对象执行基本原子操作,以防止竞态条件而不阻塞线程。
准备工作
要逐步执行此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在BookSamples\Chapter2\Recipe1中找到。
如何操作...
要理解基本原子操作,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; -
在
Main方法下方,添加以下代码片段:static void TestCounter(CounterBase c) { for (int i = 0; i < 100000; i++) { c.Increment(); c.Decrement(); } } class Counter : CounterBase { private int _count; public int Count => _count; public override void Increment() { _count++; } public override void Decrement() { _count--; } } class CounterNoLock : CounterBase { private int _count; public int Count => _count; public override void Increment() { Interlocked.Increment(ref _count); } public override void Decrement() { Interlocked.Decrement(ref _count); } } abstract class CounterBase { public abstract void Increment(); public abstract void Decrement(); } -
在
Main方法中,添加以下代码片段:WriteLine("Incorrect counter"); var c = new Counter(); var t1 = new Thread(() => TestCounter(c)); var t2 = new Thread(() => TestCounter(c)); var t3 = new Thread(() => TestCounter(c)); t1.Start(); t2.Start(); t3.Start(); t1.Join(); t2.Join(); t3.Join(); WriteLine($"Total count: {c.Count}"); WriteLine("--------------------------"); WriteLine("Correct counter"); var c1 = new CounterNoLock(); t1 = new Thread(() => TestCounter(c1)); t2 = new Thread(() => TestCounter(c1)); t3 = new Thread(() => TestCounter(c1)); t1.Start(); t2.Start(); t3.Start(); t1.Join(); t2.Join(); t3.Join(); WriteLine($"Total count: {c1.Count}"); -
运行程序。
它是如何工作的...
当程序运行时,它创建三个线程,这些线程将在TestCounter方法中执行代码。此方法在对象上运行一系列增加/减少操作。最初,Counter对象不是线程安全的,我们在这里得到竞态条件。因此,在第一种情况下,计数器的值不是确定的。我们可能会得到零值;然而,如果您多次运行程序,最终会得到一些不正确的非零结果。
在第一章,线程基础中,我们通过锁定我们的对象来解决这个问题,导致其他线程被阻塞,而一个线程获取旧的计数器值,然后计算并分配新的计数器值。然而,如果我们以这种方式执行此操作,它不能中途停止,我们可以在没有任何锁定的情况下实现正确的结果,这可以通过Interlocked构造来实现。它提供了Increment、Decrement和Add原子方法用于基本数学运算,并帮助我们编写Counter类而不使用锁定。
使用 Mutex 构造
此配方将描述如何使用Mutex构造同步两个不同的程序。Mutex构造是一个同步原语,它只允许一个线程对共享资源进行独占访问。
准备工作
要逐步执行此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在BookSamples\Chapter2\Recipe2中找到。
如何操作...
要了解如何使用Mutex结构同步两个独立程序,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; -
在
Main方法内部,添加以下代码片段:const string MutexName = "CSharpThreadingCookbook"; using (var m = new Mutex(false, MutexName)) { if (!m.WaitOne(TimeSpan.FromSeconds(5), false)) { WriteLine("Second instance is running!"); } else { WriteLine("Running!"); ReadLine(); m.ReleaseMutex(); } } -
运行程序。
它是如何工作的...
当主程序启动时,它使用特定名称定义一个互斥锁,并将initialOwner标志设置为false。这允许程序在互斥锁已创建的情况下获取互斥锁。然后,如果没有获取到互斥锁,程序将简单地显示运行中并等待按下任意键以释放互斥锁并退出。
如果我们启动程序的第二个副本,它将等待 5 秒钟,试图获取互斥锁。如果我们按下第一个程序副本中的任意键,第二个程序将开始执行。然而,如果我们继续等待 5 秒钟,程序的第二副本将无法获取互斥锁。
小贴士
注意,互斥锁是一个全局操作系统对象!始终正确关闭互斥锁;最佳选择是将互斥锁对象包装在using块中。
这使得在不同程序中同步线程成为可能,这在许多场景中可能很有用。
使用SemaphoreSlim结构
此配方将向您展示如何使用SemaphoreSlim结构限制对某些资源的多线程访问。SemaphoreSlim是Semaphore的一个轻量级版本;它限制了可以并发访问资源的线程数。
准备工作
要逐步执行此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在BookSamples\Chapter2\Recipe3中找到。
如何操作...
要了解如何使用SemaphoreSlim结构限制对资源的多线程访问,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方,添加以下代码片段:static SemaphoreSlim _semaphore = new SemaphoreSlim(4); static void AccessDatabase(string name, int seconds) { WriteLine($"{name} waits to access a database"); _semaphore.Wait(); WriteLine($"{name} was granted an access to a database"); Sleep(TimeSpan.FromSeconds(seconds)); WriteLine($"{name} is completed"); _semaphore.Release(); } -
在
Main方法内部,添加以下代码片段:for (int i = 1; i <= 6; i++) { string threadName = "Thread " + i; int secondsToWait = 2 + 2 * i; var t = new Thread(() => AccessDatabase(threadName, secondsToWait)); t.Start(); } -
运行程序。
它是如何工作的...
当主程序启动时,它创建一个SemaphoreSlim实例,并在其构造函数中指定允许的并发线程数。然后,它启动六个具有不同名称和启动时间的线程来运行。
每个线程都试图获取访问数据库的权限,但我们通过使用信号量将并发访问数据库的线程数限制为四个。当四个线程获取到数据库的访问权限时,其他两个线程将等待,直到之前的某个线程完成其工作并通过调用_semaphore.Release方法向其他线程发出信号。
更多内容...
在这里,我们使用一个混合构造函数,它允许我们在等待时间非常短的情况下节省上下文切换。然而,这个构造函数有一个较老的版本,称为 Semaphore。这个版本是一个纯内核时间构造函数。除了在一个非常重要的场景之外,使用它没有意义;我们可以创建一个命名信号量,就像命名互斥锁一样,并使用它来在不同程序中同步线程。SemaphoreSlim 不使用 Windows 内核信号量,也不支持进程间同步,所以在这种情况下使用 Semaphore。
使用 AutoResetEvent 构造函数
在此配方中,有一个示例,说明如何使用 AutoResetEvent 构造函数从线程发送通知。AutoResetEvent 通知等待的线程已发生事件。
准备工作
要逐步执行此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可在 BookSamples\Chapter2\Recipe4 中找到。
如何操作...
要了解如何使用 AutoResetEvent 构造函数在两个线程之间发送通知,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方,添加以下代码片段:private static AutoResetEvent _workerEvent = new AutoResetEvent(false); private static AutoResetEvent _mainEvent = new AutoResetEvent(false); static void Process(int seconds) { WriteLine("Starting a long running work..."); Sleep(TimeSpan.FromSeconds(seconds)); WriteLine("Work is done!"); _workerEvent.Set(); WriteLine("Waiting for a main thread to complete its work"); _mainEvent.WaitOne(); WriteLine("Starting second operation..."); Sleep(TimeSpan.FromSeconds(seconds)); WriteLine("Work is done!"); _workerEvent.Set(); } -
在
Main方法内部,添加以下代码片段:var t = new Thread(() => Process(10)); t.Start(); WriteLine("Waiting for another thread to complete work"); _workerEvent.WaitOne(); WriteLine("First operation is completed!"); WriteLine("Performing an operation on a main thread"); Sleep(TimeSpan.FromSeconds(5)); _mainEvent.Set(); WriteLine("Now running the second operation on a second thread"); _workerEvent.WaitOne(); WriteLine("Second operation is completed!"); -
运行程序。
它是如何工作的...
当主程序启动时,它定义了两个 AutoResetEvent 实例。其中一个是用于从第二个线程向主线程发送信号,另一个是用于从主线程向第二个线程发送信号。我们将 false 传递给 AutoResetEvent 构造函数,指定这两个实例的初始状态为 未发送信号。这意味着任何调用这些对象之一的 WaitOne 方法的线程都将被阻塞,直到我们调用 Set 方法。如果我们初始化事件状态为 true,它将变为 已发送信号,第一个调用 WaitOne 的线程将立即继续。然后事件状态将自动变为 未发送信号,因此我们需要再次调用 Set 方法,以便其他调用此实例上 WaitOne 方法的线程继续。
然后,我们创建一个第二个线程,该线程执行第一个操作 10 秒并等待第二个线程的信号。信号通知第一个操作已完成。现在,第二个线程等待来自主线程的信号。我们在主线程上执行一些额外的工作,并通过调用 _mainEvent.Set 方法发送信号。然后,我们等待来自第二个线程的另一个信号。
AutoResetEvent 是一个内核时间构造函数,所以如果等待时间不显著,最好使用带有 ManualResetEventslim 的下一个配方,它是一个混合构造函数。
使用 ManualResetEventSlim 构造函数
此配方将描述如何使用 ManualResetEventSlim 构造函数使线程之间的信号更加灵活。
准备工作
要逐步完成这个菜谱,你需要 Visual Studio 2015。没有其他先决条件。这个菜谱的源代码可以在BookSamples\Chapter2\Recipe5找到。
如何操作...
要理解ManualResetEventSlim构造的使用,执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码:static void TravelThroughGates(string threadName, int seconds) { WriteLine($"{threadName} falls to sleep"); Sleep(TimeSpan.FromSeconds(seconds)); WriteLine($"{threadName} waits for the gates to open!"); _mainEvent.Wait(); WriteLine($"{threadName} enters the gates!"); } static ManualResetEventSlim _mainEvent = new ManualResetEventSlim(false); -
在
Main方法中添加以下代码:var t1 = new Thread(() => TravelThroughGates("Thread 1", 5)); var t2 = new Thread(() => TravelThroughGates("Thread 2", 6)); var t3 = new Thread(() => TravelThroughGates("Thread 3", 12)); t1.Start(); t2.Start(); t3.Start(); Sleep(TimeSpan.FromSeconds(6)); WriteLine("The gates are now open!"); _mainEvent.Set(); Sleep(TimeSpan.FromSeconds(2)); _mainEvent.Reset(); WriteLine("The gates have been closed!"); Sleep(TimeSpan.FromSeconds(10)); WriteLine("The gates are now open for the second time!"); _mainEvent.Set(); Sleep(TimeSpan.FromSeconds(2)); WriteLine("The gates have been closed!"); _mainEvent.Reset(); -
运行程序。
工作原理...
当主程序启动时,它首先创建一个ManualResetEventSlim构造的实例。然后,我们启动三个线程,等待这个事件信号它们继续执行。
使用这个构造的过程就像让人们通过一个门。我们在之前的菜谱中看到的AutoResetEvent事件就像一个旋转门,一次只允许一个人通过。ManualResetEventSlim是ManualResetEvent的混合版本,它保持开启状态,直到我们手动调用Reset方法。回到代码中,当我们调用_mainEvent.Set时,我们打开它,允许准备好接受这个信号的线程继续工作。然而,线程三还在睡眠中,没有及时到达。我们调用_mainEvent.Reset,因此关闭了它。最后一个线程现在可以继续前进,但它必须等待下一个信号,这个信号将在几秒钟后发生。
更多内容...
就像在之前的菜谱中一样,我们使用了一个混合构造,它不具备在操作系统级别工作的可能性。如果我们需要一个全局事件,我们应该使用EventWaitHandle构造,它是AutoResetEvent和ManualResetEvent的基类。
使用CountDownEvent构造
这个菜谱将描述如何使用CountdownEvent信号构造来等待直到一定数量的操作完成。
准备工作
要逐步完成这个菜谱,你需要 Visual Studio 2015。没有其他先决条件。这个菜谱的源代码可以在BookSamples\Chapter2\Recipe6找到。
如何操作...
要理解CountDownEvent构造的使用,执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码:static CountdownEvent _countdown = new CountdownEvent(2); static void PerformOperation(string message, int seconds) { Sleep(TimeSpan.FromSeconds(seconds)); WriteLine(message); _countdown.Signal(); } -
在
Main方法中,添加以下代码:WriteLine("Starting two operations"); var t1 = new Thread(() => PerformOperation("Operation 1 is completed", 4)); var t2 = new Thread(() => PerformOperation("Operation 2 is completed", 8)); t1.Start(); t2.Start(); _countdown.Wait(); WriteLine("Both operations have been completed."); _countdown.Dispose(); -
运行程序。
工作原理...
当主程序启动时,我们创建一个新的CountdownEvent实例,在构造函数中指定我们希望在两个操作完成时发出信号。然后,我们启动两个线程,当它们完成时向事件发出信号。一旦第二个线程完成,主线程从等待CountdownEvent中返回,并继续执行。使用这个构造,等待多个异步操作完成非常方便。
然而,存在一个显著的缺点;如果我们未能按照要求次数调用_countdown.Signal(),则_countdown.Wait()将永远等待。确保在使用CountdownEvent时,所有线程都通过Signal方法调用完成。
使用Barrier构造
此配方演示了另一个有趣的同步构造,称为Barrier。Barrier构造有助于组织多个线程,以便它们在某个时间点相遇,提供一个在每次线程调用SignalAndWait方法时执行的回调。
准备工作
要逐步执行此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在BookSamples\Chapter2\Recipe7中找到。
如何操作...
要理解Barrier构造的使用,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方,添加以下代码:static Barrier _barrier = new Barrier(2, b => WriteLine($"End of phase {b.CurrentPhaseNumber + 1}")); static void PlayMusic(string name, string message, int seconds) { for (int i = 1; i < 3; i++) { WriteLine("----------------------------------------------"); Sleep(TimeSpan.FromSeconds(seconds)); WriteLine($"{name} starts to {message}"); Sleep(TimeSpan.FromSeconds(seconds)); WriteLine($"{name} finishes to {message}"); _barrier.SignalAndWait(); } } -
在
Main方法内部,添加以下代码:var t1 = new Thread(() => PlayMusic("the guitarist", "play an amazing solo", 5)); var t2 = new Thread(() => PlayMusic("the singer", "sing his song", 2)); t1.Start(); t2.Start(); -
运行程序。
工作原理...
我们创建一个Barrier构造,指定我们想要同步两个线程,并且在这两个线程中的每一个调用_barrier.SignalAndWait方法之后,我们需要执行一个回调,该回调将打印出已完成的阶段数。
每个线程将向Barrier发送两次信号,因此我们将有两个阶段。每次两个线程都调用SignalAndWait方法时,Barrier将执行回调。这对于与多线程迭代算法一起使用,在每个迭代结束时执行一些计算非常有用。迭代结束时,是最后一个线程调用SignalAndWait方法。
使用ReaderWriterLockSlim构造
此配方将描述如何使用ReaderWriterLockSlim构造创建一个线程安全的机制,以便从多个线程中读取和写入集合。ReaderWriterLockSlim代表一个锁,用于管理对资源的访问,允许多个线程进行读取或为写入提供独占访问。
准备工作
要逐步执行此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在BookSamples\Chapter2\Recipe8中找到。
如何操作...
要理解如何使用ReaderWriterLockSlim构造来创建一个线程安全的机制,以便从多个线程中读取和写入集合,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Generic; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方,添加以下代码:static ReaderWriterLockSlim _rw = new ReaderWriterLockSlim(); static Dictionary<int, int> _items = new Dictionary<int, int>(); static void Read() { WriteLine("Reading contents of a dictionary"); while (true) { try { _rw.EnterReadLock(); foreach (var key in _items.Keys) { Sleep(TimeSpan.FromSeconds(0.1)); } } finally { _rw.ExitReadLock(); } } } static void Write(string threadName) { while (true) { try { int newKey = new Random().Next(250); _rw.EnterUpgradeableReadLock(); if (!_items.ContainsKey(newKey)) { try { _rw.EnterWriteLock(); _items[newKey] = 1; WriteLine($"New key {newKey} is added to a dictionary by a {threadName}"); } finally { _rw.ExitWriteLock(); } } Sleep(TimeSpan.FromSeconds(0.1)); } finally { _rw.ExitUpgradeableReadLock(); } } } -
在
Main方法内部,添加以下代码:new Thread(Read){ IsBackground = true }.Start(); new Thread(Read){ IsBackground = true }.Start(); new Thread(Read){ IsBackground = true }.Start(); new Thread(() => Write("Thread 1")){ IsBackground = true }.Start(); new Thread(() => Write("Thread 2")){ IsBackground = true }.Start(); Sleep(TimeSpan.FromSeconds(30)); -
运行程序。
工作原理...
当主程序启动时,它同时运行三个线程,从字典中读取数据,以及两个线程将一些数据写入这个字典。为了实现线程安全,我们使用ReaderWriterLockSlim构造,它专门为这种场景设计。
它有两种类型的锁:一种读锁允许多个线程读取,一种写锁会阻塞其他线程的所有操作,直到这个写锁被释放。还有一个有趣的场景,当我们获得读锁,从集合中读取一些数据,并根据这些数据决定获得写锁并更改集合。如果我们一次性获得写锁,会花费太多时间,不允许我们的读者读取数据,因为当我们获得写锁时,集合被阻塞。为了最小化这种时间,有EnterUpgradeableReadLock/ExitUpgradeableReadLock方法。我们获得读锁并读取数据;如果我们发现我们需要更改底层集合,我们只需使用EnterWriteLock方法升级我们的锁,然后快速执行写操作,并使用ExitWriteLock释放写锁。
在我们的情况下,我们获取一个随机数;然后我们获得一个读锁并检查这个数字是否存在于字典键集合中。如果没有,我们将锁升级为写锁,然后将这个新键添加到字典中。使用try/finally块确保我们总是在获取锁后释放它们是一个好习惯。
我们创建的所有线程都是作为后台线程创建的,在等待 30 秒后,主线程以及所有后台线程都将完成。
使用SpinWait构造
这个配方将描述如何在不涉及内核模式构造的情况下等待一个线程。此外,我们引入了SpinWait,这是一种混合同步构造,设计用于在用户模式下等待一段时间,然后切换到内核模式以节省 CPU 时间。
准备工作
要逐步执行这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter2\Recipe9中找到。
如何做...
要了解如何在不涉及内核模式构造的情况下等待一个线程,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方,添加以下代码:static volatile bool _isCompleted = false; static void UserModeWait() { while (!_isCompleted) { Write("."); } WriteLine(); WriteLine("Waiting is complete"); } static void HybridSpinWait() { var w = new SpinWait(); while (!_isCompleted) { w.SpinOnce(); WriteLine(w.NextSpinWillYield); } WriteLine("Waiting is complete"); } -
在
Main方法内部,添加以下代码:var t1 = new Thread(UserModeWait); var t2 = new Thread(HybridSpinWait); WriteLine("Running user mode waiting"); t1.Start(); Sleep(20); _isCompleted = true; Sleep(TimeSpan.FromSeconds(1)); _isCompleted = false; WriteLine("Running hybrid SpinWait construct waiting"); t2.Start(); Sleep(5); _isCompleted = true; -
运行程序。
它是如何工作的...
当主程序启动时,它会定义一个线程,该线程将执行一个持续 20 毫秒的无穷循环,直到主线程将_isCompleted变量设置为true。我们可以尝试将这个循环运行 20-30 秒,并用 Windows 任务管理器测量 CPU 负载。它将显示大量的处理器时间,这取决于 CPU 有多少核心。
我们使用volatile关键字来声明_isCompleted静态字段。volatile关键字表示该字段可能被同时执行的多线程修改。被声明为volatile的字段不受编译器和处理器优化影响,这些优化假设只有一个线程访问。这确保了字段中始终存在最新的值。
然后,我们使用一个SpinWait版本,它在每次迭代中打印一个特殊标志,显示一个线程是否将要切换到blocked状态。我们运行这个线程 5 毫秒来观察这一点。一开始,SpinWait试图保持在用户模式,大约经过九次迭代后,它开始将线程切换到阻塞状态。如果我们尝试使用这个版本来测量 CPU 负载,我们在 Windows 任务管理器中将看不到任何 CPU 使用情况。
第三章:使用线程池
在本章中,我们将描述用于从多个线程中处理共享资源的常用技术。你将学习以下配方:
-
在线程池上调用委托
-
在线程池上发布异步操作
-
线程池和并行度
-
实现取消选项
-
使用线程池的等待句柄和超时
-
使用计时器
-
使用
BackgroundWorker组件
简介
在前面的章节中,我们讨论了几种创建线程和组织它们合作的方法。现在,让我们考虑另一种场景,我们将创建许多耗时很小的异步操作。正如我们在第一章的简介部分所讨论的,创建线程是一个昂贵的操作,因此对每个短暂、异步操作都这样做将包括显著的开销。
为了解决这个问题,有一个常见的称为池化的方法,可以成功应用于任何我们需要许多短暂、昂贵资源的情况。我们预先分配一定数量的这些资源,并将它们组织成一个资源池。每次我们需要新的资源时,我们只需从池中取出,而不是创建一个新的,在资源不再需要时将其返回到池中。
.NET 线程池是这个概念的实现。它可以通过System.Threading.ThreadPool类型访问。线程池由.NET 公共 语言运行时(CLR)管理,这意味着每个 CLR 有一个线程池实例。ThreadPool类型有一个QueueUserWorkItem静态方法,它接受一个委托,代表用户定义的异步操作。在调用此方法后,这个委托进入内部队列。然后,如果没有线程在池中,它将创建一个新的工作线程并将第一个委托放入队列中。
如果我们将新的操作放入线程池,在之前的操作完成后,可以重新使用这个线程来执行这些操作。然而,如果我们更快地放入新的操作,线程池将创建更多的线程来服务这些操作。存在一个限制以防止创建过多的线程,在这种情况下,新的操作将等待在队列中,直到池中的工作线程空闲出来服务它们。
注意
保持线程池上的操作短暂是非常重要的!不要将长时间运行的操作放入线程池或阻塞工作线程。这将导致所有工作线程变得忙碌,它们将无法再服务用户操作。这反过来会导致性能问题和难以调试的错误。
当我们停止向线程池添加新操作时,它最终会在一段时间空闲后移除不再需要的线程。这将释放不再需要的任何操作系统资源。
我再次强调,线程池旨在执行短运行时操作。使用线程池可以在减少并行度的代价下节省操作系统资源。我们使用更少的线程,但执行异步操作的速度比通常慢,通过可用的工作线程数量进行批处理。如果操作完成得很快,这样做是有意义的,但如果执行许多长时间运行的计算密集型操作,这将降低性能。
另一个非常重要的事情是要非常小心地在 ASP.NET 应用程序中使用线程池。ASP.NET 基础设施本身使用线程池,如果你浪费了线程池中的所有工作线程,那么 Web 服务器将无法再处理传入的请求。建议你在 ASP.NET 中仅使用输入/输出绑定的异步操作,因为它们使用不同的机制称为 I/O 线程。我们将在 第九章 中讨论 I/O 线程,使用异步 I/O。
注意
注意,线程池中的工作线程是后台线程。这意味着当所有前台线程(包括主应用程序线程)完成时,所有后台线程都将停止。
在本章中,你将学习如何使用线程池来执行异步操作。我们将介绍将操作放入线程池的不同方法,以及如何取消操作并防止其长时间运行。
在线程池上调用委托
本食谱将向你展示如何在线程池上异步执行委托。此外,我们还将讨论一种称为 异步编程模型 (APM)的方法,这是历史上 .NET 中的第一个异步编程模式。
准备工作
要进入这个食谱,你需要 Visual Studio 2015。没有其他先决条件。本食谱的源代码可以在 BookSamples\Chapter3\Recipe1 中找到。
如何做...
要了解如何在线程池上调用委托,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:private delegate string RunOnThreadPool(out int threadId); private static void Callback(IAsyncResult ar) { WriteLine("Starting a callback..."); WriteLine($"State passed to a callbak: {ar.AsyncState}"); WriteLine($"Is thread pool thread: {CurrentThread.IsThreadPoolThread}"); WriteLine($"Thread pool worker thread id: {CurrentThread.ManagedThreadId}"); } private static string Test(out int threadId) { WriteLine("Starting..."); WriteLine($"Is thread pool thread: {CurrentThread.IsThreadPoolThread}"); Sleep(TimeSpan.FromSeconds(2)); threadId = CurrentThread.ManagedThreadId; return $"Thread pool worker thread id was: {threadId}"; } -
在
Main方法内添加以下代码:int threadId = 0; RunOnThreadPool poolDelegate = Test; var t = new Thread(() => Test(out threadId)); t.Start(); t.Join(); WriteLine($"Thread id: {threadId}"); IAsyncResult r = poolDelegate.BeginInvoke(out threadId, Callback, "a delegate asynchronous call"); r.AsyncWaitHandle.WaitOne(); string result = poolDelegate.EndInvoke(out threadId, r); WriteLine($"Thread pool worker thread id: {threadId}"); WriteLine(result); Sleep(TimeSpan.FromSeconds(2)); -
运行程序。
工作原理...
当程序运行时,它以传统的方式创建一个线程,然后启动它并等待其完成。由于线程构造函数只接受不返回任何结果的函数,我们使用lambda 表达式来包装对Test方法的调用。我们确保这个线程不是来自线程池,通过打印出Thread.CurrentThread.IsThreadPoolThread属性值。我们还打印出一个托管线程 ID,以识别执行此代码的线程。
然后,我们定义一个委托并通过调用BeginInvoke方法来运行它。此方法接受一个回调,该回调将在异步操作完成后被调用,以及一个用户定义的状态,该状态将被传递到回调中。这个状态通常用于区分一个异步调用与另一个异步调用。因此,我们得到一个实现IAsyncResult接口的result对象。BeginInvoke方法立即返回结果,允许我们在异步操作在线程池的工作线程上执行时继续进行任何工作。当我们需要异步操作的结果时,我们使用从BeginInvoke方法调用返回的result对象。我们可以使用IsCompleted结果属性对其进行轮询,但在这个情况下,我们使用AsyncWaitHandle结果属性来等待它,直到操作完成。完成这些后,为了从它获取结果,我们在委托上调用EndInvoke方法,传递委托参数和我们的IAsyncResult对象。
注意
实际上,使用AsyncWaitHandle是不必要的。如果我们注释掉r.AsyncWaitHandle.WaitOne,代码仍然可以成功运行,因为EndInvoke方法实际上会等待异步操作完成。始终调用EndInvoke(或其他异步 API 的EndOperationName)总是很重要的,因为它会将任何未处理的异常抛回到调用线程。始终在使用此类异步 API 时调用Begin和End方法。
当操作完成时,传递给BeginInvoke方法的回调将被发布到线程池中,更具体地说,是一个工作线程。如果我们注释掉Main方法定义末尾的Thread.Sleep方法调用,回调将不会执行。这是因为当主线程完成时,所有后台线程都将停止,包括这个回调。可能异步调用委托和回调将由同一个工作线程服务,这可以通过工作线程 ID 很容易地看到。
在 .NET 中,使用BeginOperationName/EndOperationName方法和IAsyncResult对象的方法称为异步编程模型或 APM 模式,这样的方法对称为异步方法。这种模式仍在各种 .NET 类库 API 中使用,但在现代编程中,更倾向于使用任务并行库(TPL)来组织异步 API。我们将在第四章 使用任务并行库 中介绍这个主题。
在线程池上发布异步操作
本食谱将描述如何将异步操作放在线程池上。
准备工作
要进入这个食谱,你需要 Visual Studio 2015。没有其他先决条件。这个食谱的源代码可以在BookSamples\Chapter3\Recipe2中找到。
如何做...
要了解如何在线程池上发布异步操作,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:private static void AsyncOperation(object state) { WriteLine($"Operation state: {state ?? "(null)"}"); WriteLine($"Worker thread id: {CurrentThread.ManagedThreadId}"); Sleep(TimeSpan.FromSeconds(2)); } -
在
Main方法内部添加以下代码片段:const int x = 1; const int y = 2; const string lambdaState = "lambda state 2"; ThreadPool.QueueUserWorkItem(AsyncOperation); Sleep(TimeSpan.FromSeconds(1)); ThreadPool.QueueUserWorkItem(AsyncOperation, "async state"); Sleep(TimeSpan.FromSeconds(1)); ThreadPool.QueueUserWorkItem( state => { WriteLine($"Operation state: {state}"); WriteLine($"Worker thread id: {CurrentThread.ManagedThreadId}"); Sleep(TimeSpan.FromSeconds(2)); }, "lambda state"); ThreadPool.QueueUserWorkItem( _ => { WriteLine($"Operation state: {x + y}, {lambdaState}"); WriteLine($"Worker thread id: {CurrentThread.ManagedThreadId}"); Sleep(TimeSpan.FromSeconds(2)); }, "lambda state"); Sleep(TimeSpan.FromSeconds(2)); -
运行程序。
它是如何工作的...
首先,我们定义一个接受单个object类型参数的AsyncOperation方法。然后,我们使用QueueUserWorkItem方法将此方法发布到线程池。然后,我们再次发布此方法,但这次,我们向此方法调用传递一个state对象。此对象将被传递给AsynchronousOperation方法作为state参数。
在这些操作之后让线程休眠 1 秒,这允许线程池重用线程来执行新的操作。如果你注释掉这些Thread.Sleep调用,大多数情况下线程 ID 都会不同。如果不是,可能前两个线程将被重用来执行接下来的两个操作。
首先,我们将一个 lambda 表达式发布到线程池。这里没有特别之处;我们不是定义一个单独的方法,而是使用 lambda 表达式语法。
其次,我们不是传递 lambda 表达式的状态,而是使用闭包机制。这为我们提供了更多的灵活性,并允许我们向异步操作提供多个对象,并为这些对象提供静态类型。因此,将对象传递到方法回调的先前机制实际上是多余的,已经过时。现在我们有了 C# 中的闭包,就没有必要使用它了。
线程池和并行度
本食谱将展示线程池如何与许多异步操作一起工作,以及它与创建多个单独线程的不同之处。
准备工作
要进入这个食谱,你需要 Visual Studio 2015。没有其他先决条件。这个食谱的源代码可以在BookSamples\Chapter3\Recipe3中找到。
如何做...
要了解线程池如何与许多异步操作一起工作,以及它与创建许多单独线程的不同之处,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Diagnostics; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static void UseThreads(int numberOfOperations) { using (var countdown = new CountdownEvent(numberOfOperations)) { WriteLine("Scheduling work by creating threads"); for (int i = 0; i < numberOfOperations; i++) { var thread = new Thread(() => { Write($"{CurrentThread.ManagedThreadId},"); Sleep(TimeSpan.FromSeconds(0.1)); countdown.Signal(); }); thread.Start(); } countdown.Wait(); WriteLine(); } } static void UseThreadPool(int numberOfOperations) { using (var countdown = new CountdownEvent(numberOfOperations)) { WriteLine("Starting work on a threadpool"); for (int i = 0; i < numberOfOperations; i++) { ThreadPool.QueueUserWorkItem( _ => { Write($"{CurrentThread.ManagedThreadId},"); Sleep(TimeSpan.FromSeconds(0.1)); countdown.Signal(); }); } countdown.Wait(); WriteLine(); } } -
在
Main方法内部添加以下代码片段:const int numberOfOperations = 500; var sw = new Stopwatch(); sw.Start(); UseThreads(numberOfOperations); sw.Stop(); WriteLine($"Execution time using threads: {sw.ElapsedMilliseconds}"); sw.Reset(); sw.Start(); UseThreadPool(numberOfOperations); sw.Stop(); WriteLine($"Execution time using the thread pool: {sw.ElapsedMilliseconds}"); -
运行程序。
它是如何工作的...
当主程序启动时,我们创建了多个不同的线程,并在每个线程上运行一个操作。这个操作打印出线程 ID 并阻塞线程 100 毫秒。结果,我们创建了 500 个线程,并行运行所有这些操作。在我的机器上,总时间大约是 300 毫秒,但我们消耗了大量的操作系统资源。
然后,我们遵循相同的流程,但不是为每个操作创建一个线程,而是将它们发布到线程池。之后,线程池开始服务这些操作;它开始在接近结束时创建更多线程;然而,它仍然需要更多的时间,在我的机器上大约是 12 秒。我们为操作系统使用节省了内存和线程,但以应用程序性能为代价。
实现取消选项
这个配方展示了如何在一个线程池上取消异步操作的示例。
准备工作
要进入这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter3\Recipe4中找到。
如何做到这一点...
要了解如何在线程上实现取消选项,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static void AsyncOperation1(CancellationToken token) { WriteLine("Starting the first task"); for (int i = 0; i < 5; i++) { if (token.IsCancellationRequested) { WriteLine("The first task has been canceled."); return; } Sleep(TimeSpan.FromSeconds(1)); } WriteLine("The first task has completed succesfully"); } static void AsyncOperation2(CancellationToken token) { try { WriteLine("Starting the second task"); for (int i = 0; i < 5; i++) { token.ThrowIfCancellationRequested(); Sleep(TimeSpan.FromSeconds(1)); } WriteLine("The second task has completed succesfully"); } catch (OperationCanceledException) { WriteLine("The second task has been canceled."); } } static void AsyncOperation3(CancellationToken token) { bool cancellationFlag = false; token.Register(() => cancellationFlag = true); WriteLine("Starting the third task"); for (int i = 0; i < 5; i++) { if (cancellationFlag) { WriteLine("The third task has been canceled."); return; } Sleep(TimeSpan.FromSeconds(1)); } WriteLine("The third task has completed succesfully"); } -
在
Main方法内部添加以下代码片段:using (var cts = new CancellationTokenSource()) { CancellationToken token = cts.Token; ThreadPool.QueueUserWorkItem(_ => AsyncOperation1(token)); Sleep(TimeSpan.FromSeconds(2)); cts.Cancel(); } using (var cts = new CancellationTokenSource()) { CancellationToken token = cts.Token; ThreadPool.QueueUserWorkItem(_ => AsyncOperation2(token)); Sleep(TimeSpan.FromSeconds(2)); cts.Cancel(); } using (var cts = new CancellationTokenSource()) { CancellationToken token = cts.Token; ThreadPool.QueueUserWorkItem(_ => AsyncOperation3(token)); Sleep(TimeSpan.FromSeconds(2)); cts.Cancel(); } Sleep(TimeSpan.FromSeconds(2)); -
运行程序。
它是如何工作的...
在这里,我们介绍了CancellationTokenSource和CancellationToken构造。它们出现在.NET 4.0 中,现在已成为实现异步操作取消过程的实际标准。由于线程池已经存在很长时间,它没有为取消令牌提供特殊的 API;然而,它们仍然可以使用。
在这个程序中,我们看到有三种组织取消过程的方法。第一种只是轮询并检查CancellationToken.IsCancellationRequested属性。如果它被设置为true,这意味着我们的操作正在被取消,我们必须放弃操作。
第二种方式是抛出OperationCancelledException异常。这允许我们从被取消操作的外部代码中控制取消过程。
最后一种选项是在线程池中注册一个回调,当操作被取消时将被调用。这将允许我们将取消逻辑链入另一个异步操作。
使用线程池的等待句柄和超时
本食谱将描述如何实现线程池操作的超时以及如何在线程池上正确等待。
准备工作
要进入这个食谱,你需要 Visual Studio 2015。没有其他先决条件。本食谱的源代码可以在 BookSamples\Chapter3\Recipe5 中找到。
如何操作...
要了解如何实现超时以及如何在线程池上正确等待,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static void RunOperations(TimeSpan workerOperationTimeout) { using (var evt = new ManualResetEvent(false)) using (var cts = new CancellationTokenSource()) { WriteLine("Registering timeout operation..."); var worker = ThreadPool.RegisterWaitForSingleObject(evt , (state, isTimedOut) => WorkerOperationWait(cts, isTimedOut) , null , workerOperationTimeout , true); WriteLine("Starting long running operation..."); ThreadPool.QueueUserWorkItem(_ => WorkerOperation(cts.Token, evt)); Sleep(workerOperationTimeout.Add(TimeSpan.FromSeconds(2))); worker.Unregister(evt); } } static void WorkerOperation(CancellationToken token, ManualResetEvent evt) { for(int i = 0; i < 6; i++) { if (token.IsCancellationRequested) { return; } Sleep(TimeSpan.FromSeconds(1)); } evt.Set(); } static void WorkerOperationWait(CancellationTokenSource cts, bool isTimedOut) { if (isTimedOut) { cts.Cancel(); WriteLine("Worker operation timed out and was canceled."); } else { WriteLine("Worker operation succeded."); } } -
在
Main方法内部添加以下代码片段:RunOperations(TimeSpan.FromSeconds(5)); RunOperations(TimeSpan.FromSeconds(7)); -
运行程序。
它是如何工作的...
线程池还有一个有用的方法:ThreadPool.RegisterWaitForSingleObject。此方法允许我们在线程池上排队一个回调,并且当提供的等待句柄被信号或发生超时时,此回调将被执行。这允许我们为线程池操作实现超时。
首先,我们注册超时处理异步操作。当以下事件之一发生时,它将被调用:在接收到 ManualResetEvent 对象的信号时,该对象由工作操作在成功完成时设置,或者当第一个操作完成之前发生超时时。如果发生这种情况,我们使用 CancellationToken 来取消第一个操作。
然后,我们在线程池上排队一个长时间运行的工作操作。它运行 6 秒,然后设置一个 ManualResetEvent 信号构造函数,以防它成功完成。在其他情况下,如果请求取消,操作将被放弃。
最后,如果我们为操作提供 5 秒的超时,这还不够。这是因为操作需要 6 秒才能完成,我们需要取消这个操作。所以,如果我们提供一个 7 秒的超时,这是可以接受的,操作将成功完成。
还有更多...
当你有大量线程必须等待在 blocked 状态,等待某些多线程事件构造函数发出信号时,这非常有用。我们不需要阻塞所有这些线程,我们可以使用线程池基础设施。这将允许我们在事件设置之前释放这些线程。这对于需要可扩展性和性能的服务器应用程序来说是一个非常重要的场景。
使用计时器
本食谱将描述如何使用 System.Threading.Timer 对象在线程池上创建周期性调用的异步操作。
准备工作
要进入这个食谱,你需要 Visual Studio 2015。没有其他先决条件。本食谱的源代码可以在 BookSamples\Chapter3\Recipe6 中找到。
如何操作...
要了解如何在线程池上创建周期性调用的异步操作,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static Timer _timer; static void TimerOperation(DateTime start) { TimeSpan elapsed = DateTime.Now - start; WriteLine($"{elapsed.Seconds} seconds from {start}. " + $"Timer thread pool thread id: {CurrentThread.ManagedThreadId}"); } -
在
Main方法内部添加以下代码片段:WriteLine("Press 'Enter' to stop the timer..."); DateTime start = DateTime.Now; _timer = new Timer(_ => TimerOperation(start), null , TimeSpan.FromSeconds(1) , TimeSpan.FromSeconds(2)); try { Sleep(TimeSpan.FromSeconds(6)); _timer.Change(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(4)); ReadLine(); } finally { _timer.Dispose(); } -
运行程序。
它是如何工作的...
首先,我们创建一个新的 Timer 实例。第一个参数是一个将在线程池上执行的表达式。我们调用 TimerOperation 方法,并给它提供一个开始日期。我们不使用用户 state 对象,因此第二个参数为 null;然后,我们指定何时第一次运行 TimerOperation 以及调用之间的周期。所以,第一个值实际上意味着我们将在 1 秒后开始第一次操作,然后,我们每 2 秒运行一次。
之后,我们等待 6 秒并更改我们的计时器。在调用 _timer.Change 方法后 1 秒启动 TimerOperation,然后每个操作运行 4 秒。
提示
计时器可能比这更复杂!
可以以更复杂的方式使用计时器。例如,我们可以通过提供一个具有 Timeout.Infinite 值的计时器周期参数来仅运行计时器操作一次。然后,在计时器异步操作内部,我们可以根据某些自定义逻辑设置计时器操作下一次执行的时间。
最后,我们等待按下 Enter 键并结束应用程序。在它运行时,我们可以看到自程序开始以来经过的时间。
使用 BackgroundWorker 组件
本食谱通过一个 BackgroundWorker 组件的示例描述了异步编程的另一种方法。借助此对象,我们可以将异步代码组织成一系列事件和事件处理器。您将学习如何使用此组件进行异步编程。
准备工作
要进入此食谱,您需要 Visual Studio 2015。没有其他先决条件。此食谱的源代码可在 BookSamples\Chapter3\Recipe7 中找到。
如何做...
要学习如何使用 BackgroundWorker 组件,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.ComponentModel; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static void Worker_DoWork(object sender, DoWorkEventArgs e) { WriteLine($"DoWork thread pool thread id: {CurrentThread.ManagedThreadId}"); var bw = (BackgroundWorker) sender; for (int i = 1; i <= 100; i++) { if (bw.CancellationPending) { e.Cancel = true; return; } if (i%10 == 0) { bw.ReportProgress(i); } Sleep(TimeSpan.FromSeconds(0.1)); } e.Result = 42; } static void Worker_ProgressChanged(object sender, ProgressChangedEventArgs e) { WriteLine($"{e.ProgressPercentage}% completed. " + $"Progress thread pool thread id: {CurrentThread.ManagedThreadId}"); } static void Worker_Completed(object sender, RunWorkerCompletedEventArgs e) { WriteLine($"Completed thread pool thread id: {CurrentThread.ManagedThreadId}"); if (e.Error != null) { WriteLine($"Exception {e.Error.Message} has occured."); } else if (e.Cancelled) { WriteLine($"Operation has been canceled."); } else { WriteLine($"The answer is: {e.Result}"); } } -
在
Main方法内部添加以下代码片段:var bw = new BackgroundWorker(); bw.WorkerReportsProgress = true; bw.WorkerSupportsCancellation = true; bw.DoWork += Worker_DoWork; bw.ProgressChanged += Worker_ProgressChanged; bw.RunWorkerCompleted += Worker_Completed; bw.RunWorkerAsync(); WriteLine("Press C to cancel work"); do { if (ReadKey(true).KeyChar == 'C') { bw.CancelAsync(); } } while(bw.IsBusy); -
运行程序。
它是如何工作的...
当程序启动时,我们创建一个 BackgroundWorker 组件的实例。我们明确表示我们希望我们的后台工作线程支持取消操作和操作进度的通知。
现在,最有趣的部分来了。我们不是使用线程池和委托来操作,而是使用另一种 C#习语,称为事件。事件代表一个通知源和多个订阅者,当通知到达时,它们准备做出反应。在我们的情况下,我们声明我们将订阅三个事件,当它们发生时,我们调用相应的事件处理器。这些是具有特别定义签名的函数,当事件通知其订阅者时将被调用。
因此,我们不是在Begin/End方法对中组织异步 API,而是可以直接启动异步操作,然后订阅在操作执行期间可能发生的事件。这种方法被称为基于事件的异步模式(EAP)。这在历史上是第二次尝试结构化异步程序,而现在,推荐使用 TPL(Task Parallel Library),这将在第四章中描述,使用任务并行库。
因此,我们订阅了三个事件。其中第一个是DoWork事件。当后台工作对象使用RunWorkerAsync方法启动异步操作时,将调用此事件的处理器。事件处理器将在线程池上执行,这是工作取消请求时取消工作以及我们提供操作进度信息的主要操作点。最后,当我们得到结果时,我们将它设置到事件参数中,然后调用RunWorkerCompleted事件处理器。在这个方法内部,我们找出我们的操作是否成功,是否有错误发生,或者是否被取消。
此外,BackgroundWorker组件实际上旨在用于Windows Forms 应用程序(WPF)。其实现使得从后台工作的事件处理器代码中直接操作 UI 控件成为可能,与线程池中的工作线程与 UI 控件交互相比,这非常方便。
第四章:使用任务并行库
在本章中,我们将深入研究一种新的异步编程范式,即任务并行库。您将学习以下技巧:
-
创建任务
-
使用任务执行基本操作
-
组合任务
-
将 APM 模式转换为任务
-
将 EAP 模式转换为任务
-
实现取消选项
-
在任务中处理异常
-
并行运行任务
-
使用
TaskScheduler调整任务的执行
简介
在前面的章节中,您学习了什么是线程,如何使用线程,以及为什么我们需要线程池。使用线程池可以在减少并行度度的代价下节省操作系统资源。我们可以将线程池视为一个抽象层,它隐藏了线程使用的细节,使我们能够专注于程序的逻辑,而不是线程问题。
然而,使用线程池也很复杂。没有简单的方法可以从线程池工作线程中获取结果。我们需要实现自己的方法来获取结果,并在发生异常的情况下,必须正确地将异常传播到原始线程。除此之外,没有简单的方法来创建一系列依赖的异步操作,其中一个操作在另一个操作完成其工作后运行。
为了解决这些问题,已经尝试了多种方法,这导致了异步编程模型和基于事件的异步模式(EAP)的创建,这些模式在第三章中提到,使用线程池。这些模式使得获取结果更容易,并且在传播异常方面做得很好,但组合异步操作仍然需要大量工作,并导致大量代码的产生。
为了解决所有这些问题,.Net Framework 4.0 中引入了一个新的异步操作 API。它被称为任务并行库(TPL)。在.Net Framework 4.5 中它有所改变,为了明确起见,我们将使用项目中.Net Framework 4.6 的最新版本来工作。TPL 可以被视为在线程池之上的另一个抽象层,它隐藏了与线程池工作的底层代码,并为程序员提供了一个更方便和更细粒度的 API。
TPL 的核心概念是任务。任务表示一种异步操作,它可以以多种方式运行,使用单独的线程或不使用。我们将在本章中详细探讨所有可能性。
注意
默认情况下,程序员并不了解任务是如何被具体执行的。TPL 通过隐藏任务实现的细节来提高抽象级别。不幸的是,在某些情况下,这可能导致神秘的错误,例如在尝试从任务获取结果时应用程序挂起。本章将帮助您理解 TPL 底层的机制以及如何避免以不恰当的方式使用它。
任务可以与其他任务以不同的变体组合。例如,我们能够同时启动几个任务,等待它们全部完成,然后运行一个任务,该任务将对所有先前任务的输出执行一些计算。方便的任务组合 API 是 TPL 相比于先前模式的关键优势之一。
还有几种方法可以处理任务产生的异常。由于一个任务可能由几个其他任务组成,而这些任务又可能有它们自己的子任务,因此存在 AggregateException 的概念。此类异常包含其内部所有底层任务的异常,允许我们分别处理它们。
最后但同样重要的是,从版本 5.0 开始,C# 内置了对 TPL 的支持,允许我们使用新的 await 和 async 关键字以非常顺畅和舒适的方式与任务一起工作。我们将在 第五章 中讨论此主题,使用 C# 6.0。
在本章中,您将学习如何使用 TPL 执行异步操作。我们将学习任务是什么,介绍创建任务的不同方法,并学习如何组合任务。我们还将讨论如何将遗留的 APM 和 EAP 模式转换为使用任务,如何正确处理异常,如何取消任务,以及如何处理同时执行的任务。此外,我们还将了解如何正确处理 Windows GUI 应用程序中的任务。
创建任务
此配方展示了任务的基本概念。您将学习如何创建和执行任务。
准备工作
要逐步执行此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在 BookSamples\Chapter4\Recipe1 中找到。
如何操作...
要创建和执行任务,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
注意
这次,请确保您在每个项目中使用 .Net Framework 4.5 或更高版本。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static void TaskMethod(string name) { WriteLine($"Task {name} is running on a thread id " + $"{CurrentThread.ManagedThreadId}. Is thread pool thread: " + $"{CurrentThread.IsThreadPoolThread}"); } -
在
Main方法内添加以下代码片段:var t1 = new Task(() => TaskMethod("Task 1")); var t2 = new Task(() => TaskMethod("Task 2")); t2.Start(); t1.Start(); Task.Run(() => TaskMethod("Task 3")); Task.Factory.StartNew(() => TaskMethod("Task 4")); Task.Factory.StartNew(() => TaskMethod("Task 5"), TaskCreationOptions.LongRunning); Sleep(TimeSpan.FromSeconds(1)); -
运行程序。
工作原理...
当程序运行时,它使用其构造函数创建两个任务。我们将 lambda 表达式作为 Action 委托传递;这允许我们向 TaskMethod 提供一个字符串参数。然后,我们使用 Start 方法运行这些任务。
注意
注意,直到我们调用这些任务的 Start 方法,它们将不会开始执行。很容易忘记实际启动任务。
然后,我们使用Task.Run和Task.Factory.StartNew方法运行两个更多任务。区别在于创建的任务立即开始工作,因此我们不需要显式地调用任务的Start方法。所有任务,编号从Task 1到Task 4,都被放置在线程池工作线程上,并以不确定的顺序运行。如果你多次运行程序,你会发现任务执行顺序是没有定义的。
Task.Run方法只是Task.Factory.StartNew的快捷方式,但后者方法有额外的选项。通常,除非你需要做特别的事情,例如在Task 5的情况下,使用前者方法。我们将此任务标记为长时间运行,因此这个任务将在不使用线程池的单独线程上运行。然而,这种行为可能会根据运行任务的当前任务调度器而改变。你将在本章的最后一个食谱中学习什么是任务调度器。
使用任务执行基本操作
本食谱将描述如何从任务中获取结果值。我们将通过几个场景来了解在线程池或主线程上运行任务之间的区别。
准备工作
要开始这个食谱,你需要 Visual Studio 2015。没有其他先决条件。本食谱的源代码可以在BookSamples\Chapter4\Recipe2中找到。
如何操作...
要使用任务执行基本操作,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static Task<int> CreateTask(string name) { return new Task<int>(() => TaskMethod(name)); } static int TaskMethod(string name) { WriteLine($"Task {name} is running on a thread id " + $"{CurrentThread.ManagedThreadId}. Is thread pool thread: " + $"{CurrentThread.IsThreadPoolThread}"); Sleep(TimeSpan.FromSeconds(2)); return 42; } -
在
Main方法内部添加以下代码片段:TaskMethod("Main Thread Task"); Task<int> task = CreateTask("Task 1"); task.Start(); int result = task.Result; WriteLine($"Result is: {result}"); task = CreateTask("Task 2"); task.RunSynchronously(); result = task.Result; WriteLine($"Result is: {result}"); task = CreateTask("Task 3"); WriteLine(task.Status); task.Start(); while (!task.IsCompleted) { WriteLine(task.Status); Sleep(TimeSpan.FromSeconds(0.5));} WriteLine(task.Status); result = task.Result; WriteLine($"Result is: {result}"); -
运行程序。
它是如何工作的...
首先,我们不将TaskMethod包装成任务来运行它。结果,它是同步执行的,为我们提供了有关主线程的信息。显然,它不是线程池线程。
然后,我们使用Start方法启动Task 1,并等待结果。这个任务将被放置在线程池中,主线程将等待并阻塞,直到任务返回。
我们对Task 2也做同样的处理,只不过我们使用RunSynchronously()方法来运行它。这个任务将在主线程上运行,并且我们得到与第一次调用TaskMethod同步时完全相同的输出。这是一个非常有用的优化,它允许我们避免在非常短暂的操作中使用线程池。
我们以与Task 1相同的方式运行Task 3,但不是阻塞主线程,而是旋转,打印出任务状态,直到任务完成。这显示了几个任务状态,分别是Created、Running和RanToCompletion。
结合任务
此配方将向您展示如何设置相互依赖的任务。我们将学习如何创建在父任务完成后运行的任务。此外,我们还将发现一种为非常短暂的任务节省线程使用的方法。
准备工作
要逐步执行此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在 BookSamples\Chapter4\Recipe3 中找到。
如何操作...
要组合任务,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static int TaskMethod(string name, int seconds) { WriteLine( $"Task {name} is running on a thread id " + $"{CurrentThread.ManagedThreadId}. Is thread pool thread: " + $"{CurrentThread.IsThreadPoolThread}"); Sleep(TimeSpan.FromSeconds(seconds)); return 42 * seconds; } -
在
Main方法内添加以下代码片段:var firstTask = new Task<int>(() => TaskMethod("First Task", 3)); var secondTask = new Task<int>(() => TaskMethod("Second Task", 2)); firstTask.ContinueWith( t => WriteLine( $"The first answer is {t.Result}. Thread id " + $"{CurrentThread.ManagedThreadId}, is thread pool thread: " + $"{CurrentThread.IsThreadPoolThread}"), TaskContinuationOptions.OnlyOnRanToCompletion); firstTask.Start(); secondTask.Start(); Sleep(TimeSpan.FromSeconds(4)); Task continuation = secondTask.ContinueWith( t => WriteLine( $"The second answer is {t.Result}. Thread id " + $"{CurrentThread.ManagedThreadId}, is thread pool thread: " + $"{CurrentThread.IsThreadPoolThread}"), TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously); continuation.GetAwaiter().OnCompleted( () => WriteLine( $"Continuation Task Completed! Thread id " + $"{CurrentThread.ManagedThreadId}, is thread pool thread: " + $"{CurrentThread.IsThreadPoolThread}")); Sleep(TimeSpan.FromSeconds(2)); WriteLine(); firstTask = new Task<int>(() => { var innerTask = Task.Factory.StartNew(() => TaskMethod("Second Task", 5),TaskCreationOptions.AttachedToParent); innerTask.ContinueWith(t => TaskMethod("Third Task", 2), TaskContinuationOptions.AttachedToParent); return TaskMethod("First Task", 2); }); firstTask.Start(); while (!firstTask.IsCompleted) { WriteLine(firstTask.Status); Sleep(TimeSpan.FromSeconds(0.5)); } WriteLine(firstTask.Status); Sleep(TimeSpan.FromSeconds(10)); -
运行程序。
工作原理...
当主程序启动时,我们创建了两个任务,并为第一个任务设置了一个延续(在先决任务完成后运行的代码块)。然后,我们启动两个任务并等待 4 秒,这对于两个任务完成来说足够了。然后,我们对第二个任务运行另一个延续,并通过指定 TaskContinuationOptions.ExecuteSynchronously 选项来尝试同步执行它。当延续非常短暂时,这是一个有用的技术,在主线程上运行它比将其放在线程池中要快。我们能够实现这一点,因为此时第二个任务已经完成。如果我们注释掉 4 秒的 Thread.Sleep 方法,我们将看到此代码将被放在线程池中,因为我们还没有先决任务的结果。
最后,我们为之前的延续定义了一个延续,但方式略有不同,使用了新的 GetAwaiter 和 OnCompleted 方法。这些方法旨在与 C# 语言的异步机制一起使用。我们将在第五章使用 C# 6.0 中介绍这个主题。
演示的最后部分是关于父-子任务关系。我们创建一个新的任务,并在运行此任务的同时,通过提供 TaskCreationOptions.AttachedToParent 选项来运行所谓的子任务。
小贴士
子任务必须在运行父任务时创建,以确保它正确地附加到父任务上!
这意味着父任务将不会完成,直到所有子任务完成其工作。我们还可以运行提供 TaskContinuationOptions.AttachedToParent 选项的子任务的延续。这些延续任务也会影响父任务,并且它将不会完成,直到最后一个子任务结束。
将 APM 模式转换为任务
在此配方中,我们将看到如何将旧式的 APM API 转换为任务。在转换过程中可能发生不同情况都有示例。
准备工作
要开始此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在 BookSamples\Chapter4\Recipe4 中找到。
如何做...
要将 APM 模式转换为任务,执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:delegate string AsynchronousTask(string threadName); delegate string IncompatibleAsynchronousTask(out int threadId); static void Callback(IAsyncResult ar) { WriteLine("Starting a callback..."); WriteLine($"State passed to a callbak: {ar.AsyncState}"); WriteLine($"Is thread pool thread: {CurrentThread.IsThreadPoolThread}"); WriteLine($"Thread pool worker thread id: {CurrentThread.ManagedThreadId}"); } static string Test(string threadName) { WriteLine("Starting..."); WriteLine($"Is thread pool thread: {CurrentThread.IsThreadPoolThread}"); Sleep(TimeSpan.FromSeconds(2)); CurrentThread.Name = threadName; return $"Thread name: {CurrentThread.Name}"; } static string Test(out int threadId) { WriteLine("Starting..."); WriteLine($"Is thread pool thread: {CurrentThread.IsThreadPoolThread}"); Sleep(TimeSpan.FromSeconds(2)); threadId = CurrentThread.ManagedThreadId; return $"Thread pool worker thread id was: {threadId}"; } -
在
Main方法内添加以下代码片段:int threadId; AsynchronousTask d = Test; IncompatibleAsynchronousTask e = Test; WriteLine("Option 1"); Task<string> task = Task<string>.Factory.FromAsync( d.BeginInvoke("AsyncTaskThread", Callback, "a delegate asynchronous call"), d.EndInvoke); task.ContinueWith(t => WriteLine( $"Callback is finished, now running a continuation! Result: {t.Result}")); while (!task.IsCompleted) { WriteLine(task.Status); Sleep(TimeSpan.FromSeconds(0.5)); } WriteLine(task.Status); Sleep(TimeSpan.FromSeconds(1)); WriteLine("----------------------------------------------"); WriteLine(); WriteLine("Option 2"); task = Task<string>.Factory.FromAsync( d.BeginInvoke, d.EndInvoke, "AsyncTaskThread", "a delegate asynchronous call"); task.ContinueWith(t => WriteLine( $"Task is completed, now running a continuation! Result: {t.Result}")); while (!task.IsCompleted) { WriteLine(task.Status); Sleep(TimeSpan.FromSeconds(0.5)); } WriteLine(task.Status); Sleep(TimeSpan.FromSeconds(1)); WriteLine("----------------------------------------------"); WriteLine(); WriteLine("Option 3"); IAsyncResult ar = e.BeginInvoke(out threadId, Callback, "a delegate asynchronous call"); task = Task<string>.Factory.FromAsync(ar, _ => e.EndInvoke(out threadId, ar)); task.ContinueWith(t => WriteLine( $"Task is completed, now running a continuation! " + $"Result: {t.Result}, ThreadId: {threadId}")); while (!task.IsCompleted) { WriteLine(task.Status); Sleep(TimeSpan.FromSeconds(0.5)); } WriteLine(task.Status); Sleep(TimeSpan.FromSeconds(1)); -
运行程序。
它是如何工作的...
在这里,我们定义了两种类型的委托;其中一种使用 out 参数,因此与将 APM 模式转换为任务的 TPL 标准 API 不兼容。然后,我们有三个此类转换的示例。
将 APM 转换为 TPL 的关键点是 Task<T>.Factory.FromAsync 方法,其中 T 是异步操作的结果类型。此方法有几个重载;在第一种情况下,我们传递 IAsyncResult 和 Func<IAsyncResult, string>,这是一个接受 IAsyncResult 实现并返回字符串的方法。由于第一个委托类型提供了与该签名兼容的 EndMethod,因此我们可以将此委托异步调用转换为任务。
在第二个示例中,我们几乎做了同样的事情,但使用了不同的 FromAsync 方法重载,它不允许指定在异步委托调用完成后要执行的回调。我们能够用延续来替换它,但如果回调很重要,我们可以使用第一个示例。
最后一个示例展示了一个小技巧。这次,IncompatibleAsynchronousTask 委托的 EndMethod 使用 out 参数,并且与任何 FromAsync 方法重载都不兼容。然而,将 EndMethod 调用包装到一个适合任务工厂的 lambda 表达式中是非常容易的。
要查看底层任务的状态,我们在等待异步操作结果的同时打印其状态。我们看到第一个任务的状态是 WaitingForActivation,这意味着任务尚未由 TPL 基础设施实际启动。
将 EAP 模式转换为任务
此配方将描述如何将基于事件的异步操作转换为任务。在此配方中,您将找到一个适用于 .NET Framework 类库中每个基于事件的异步 API 的可靠模式。
准备工作
要开始此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在 BookSamples\Chapter4\Recipe5 中找到。
如何做...
要将 EAP 模式转换为任务,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.ComponentModel; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static int TaskMethod(string name, int seconds) { WriteLine( $"Task {name} is running on a thread id " + $"{CurrentThread.ManagedThreadId}. Is thread pool thread: " + $"{CurrentThread.IsThreadPoolThread}"); Sleep(TimeSpan.FromSeconds(seconds)); return 42 * seconds; } -
在
Main方法内添加以下代码片段:var tcs = new TaskCompletionSource<int>(); var worker = new BackgroundWorker(); worker.DoWork += (sender, eventArgs) => { eventArgs.Result = TaskMethod("Background worker", 5); }; worker.RunWorkerCompleted += (sender, eventArgs) => { if (eventArgs.Error != null) { tcs.SetException(eventArgs.Error); } else if (eventArgs.Cancelled) { tcs.SetCanceled(); } else { tcs.SetResult((int)eventArgs.Result); } }; worker.RunWorkerAsync(); int result = tcs.Task.Result; WriteLine($"Result is: {result}"); -
运行程序。
它是如何工作的...
这是一个将 EAP 模式转换为任务的非常简单且优雅的例子。关键点是使用TaskCompletionSource<T>类型,其中T是异步操作的结果类型。
同样重要的是不要忘记将tcs.SetResult方法调用包装在try/catch块中,以确保错误信息始终被设置为任务完成源对象。也可以使用TrySetResult方法代替SetResult来确保结果已成功设置。
实现取消选项
这个配方是关于实现基于任务的异步操作的取消过程。您将学习如何正确使用取消令牌来处理任务,以及如何在任务实际运行之前找出任务是否被取消。
准备工作
要开始这个配方,您需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter4\Recipe6中找到。
如何实现...
要为基于任务的异步操作实现取消选项,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static int TaskMethod(string name, int seconds, CancellationToken token) { WriteLine( $"Task {name} is running on a thread id " + $"{CurrentThread.ManagedThreadId}. Is thread pool thread: " + $"{CurrentThread.IsThreadPoolThread}"); for (int i = 0; i < seconds; i ++) { Sleep(TimeSpan.FromSeconds(1)); if (token.IsCancellationRequested) return -1; } return 42*seconds; } -
在
Main方法内部添加以下代码片段:var cts = new CancellationTokenSource(); var longTask = new Task<int>(() => TaskMethod("Task 1", 10, cts.Token), cts.Token); WriteLine(longTask.Status); cts.Cancel(); WriteLine(longTask.Status); WriteLine("First task has been cancelled before execution"); cts = new CancellationTokenSource(); longTask = new Task<int>(() => TaskMethod("Task 2", 10, cts.Token), cts.Token); longTask.Start(); for (int i = 0; i < 5; i++ ) { Sleep(TimeSpan.FromSeconds(0.5)); WriteLine(longTask.Status); } cts.Cancel(); for (int i = 0; i < 5; i++) { Sleep(TimeSpan.FromSeconds(0.5)); WriteLine(longTask.Status); } WriteLine($"A task has been completed with result {longTask.Result}."); -
运行程序。
它是如何工作的...
这又是一个如何为 TPL 任务实现取消选项的简单示例。您已经熟悉我们在第三章中讨论的取消令牌概念,使用线程池。
首先,让我们仔细看看longTask的创建代码。我们一次性将取消令牌提供给底层任务,然后再次将其提供给任务构造函数。为什么我们需要提供这个令牌两次?
答案是,如果我们在一个任务实际开始之前取消它,那么它的 TPL 基础设施将负责处理取消操作,因为我们的代码根本不会执行。我们知道第一个任务是通过获取其状态被取消的。如果我们尝试在这个任务上调用Start方法,我们将得到InvalidOperationException。
然后,我们处理从我们自己的代码中取消操作的过程。这意味着我们现在完全负责取消过程,并且在取消任务后,其状态仍然是RanToCompletion,因为从 TPL 的角度来看,任务正常完成了其工作。区分这两种情况并理解每种情况下的责任差异非常重要。
处理任务中的异常
这个配方描述了处理异步任务中异常的非常重要的话题。我们将探讨任务抛出的异常的不同方面以及如何获取它们的信息。
准备工作
要逐步执行这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter4\Recipe7找到。
如何做...
要处理任务中的异常,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static int TaskMethod(string name, int seconds) { WriteLine( $"Task {name} is running on a thread id " + $"{CurrentThread.ManagedThreadId}. Is thread pool thread: " + $"{CurrentThread.IsThreadPoolThread}"); Sleep(TimeSpan.FromSeconds(seconds)); throw new Exception("Boom!"); return 42 * seconds; } -
在
Main方法内部添加以下代码片段:Task<int> task; try { task = Task.Run(() => TaskMethod("Task 1", 2)); int result = task.Result; WriteLine($"Result: {result}"); } catch (Exception ex) { WriteLine($"Exception caught: {ex}"); } WriteLine("----------------------------------------------"); WriteLine(); try { task = Task.Run(() => TaskMethod("Task 2", 2)); int result = task.GetAwaiter().GetResult(); WriteLine($"Result: {result}"); } catch (Exception ex) { WriteLine($"Exception caught: {ex}"); } WriteLine("----------------------------------------------"); WriteLine(); var t1 = new Task<int>(() => TaskMethod("Task 3", 3)); var t2 = new Task<int>(() => TaskMethod("Task 4", 2)); var complexTask = Task.WhenAll(t1, t2); var exceptionHandler = complexTask.ContinueWith(t => WriteLine($"Exception caught: {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted ); t1.Start(); t2.Start(); Sleep(TimeSpan.FromSeconds(5)); -
运行程序。
它是如何工作的...
当程序启动时,我们创建一个任务并尝试同步地获取任务结果。Result属性的Get部分使当前线程等待任务完成并将异常传播到当前线程。在这种情况下,我们很容易在捕获块中捕获异常,但这个异常是一个名为AggregateException的包装异常。在这种情况下,它只包含一个异常,因为只有一个任务抛出了这个异常,并且可以通过访问InnerException属性来获取底层异常。
第二个示例基本上相同,但为了访问任务结果,我们使用GetAwaiter和GetResult方法。在这种情况下,我们没有包装异常,因为它是通过 TPL 基础设施解包的。我们立即有一个原始异常,如果我们只有一个底层任务,这将非常方便。
最后一个示例显示了存在两个抛出异常的任务的情况。为了处理异常,我们现在使用一个延续,它仅在先决任务以异常结束的情况下执行。这种行为是通过向延续提供一个TaskContinuationOptions.OnlyOnFaulted选项来实现的。结果,我们打印出AggregateException,并且我们有两个来自它内部的内部异常。
还有更多...
由于任务可能以非常不同的方式连接,结果AggregateException异常可能包含其他聚合异常,以及通常的异常。这些内部聚合异常可能自身包含其他聚合异常。
要去除这些包装器,我们应该使用根聚合异常的Flatten方法。它将返回所有子聚合异常的内部异常的集合。
并行运行任务
这个配方展示了如何处理同时运行的大量异步任务。你将学习如何在所有任务完成或任何正在运行的任务需要完成其工作的情况下有效地得到通知。
准备工作
要开始这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter4\Recipe8找到。
如何做...
要并行运行任务,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Generic; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static int TaskMethod(string name, int seconds) { WriteLine( $"Task {name} is running on a thread id " + $"{CurrentThread.ManagedThreadId}. Is thread pool thread: " + $"{CurrentThread.IsThreadPoolThread}"); Sleep(TimeSpan.FromSeconds(seconds)); return 42 * seconds; } -
在
Main方法内添加以下代码片段:var firstTask = new Task<int>(() => TaskMethod("First Task", 3)); var secondTask = new Task<int>(() => TaskMethod("Second Task", 2)); var whenAllTask = Task.WhenAll(firstTask, secondTask); whenAllTask.ContinueWith(t => WriteLine($"The first answer is {t.Result[0]}, the second is {t.Result[1]}"), TaskContinuationOptions.OnlyOnRanToCompletion); firstTask.Start(); secondTask.Start(); Sleep(TimeSpan.FromSeconds(4)); var tasks = new List<Task<int>>(); for (int i = 1; i < 4; i++) { int counter = i; var task = new Task<int>(() => TaskMethod($"Task {counter}", counter)); tasks.Add(task); task.Start(); } while (tasks.Count > 0) { var completedTask = Task.WhenAny(tasks).Result; tasks.Remove(completedTask); WriteLine($"A task has been completed with result {completedTask.Result}."); } Sleep(TimeSpan.FromSeconds(1)); -
运行程序。
它是如何工作的...
当程序启动时,我们创建两个任务,然后,借助Task.WhenAll方法,我们创建第三个任务,该任务将在所有初始任务完成后完成。结果任务为我们提供了一个答案数组,其中第一个元素包含第一个任务的结果,第二个元素包含第二个结果,依此类推。
然后,我们创建另一个任务列表,并使用Task.WhenAny方法等待这些任务中的任何一个完成。在我们有一个完成的任务后,我们将其从列表中删除,并继续等待其他任务完成,直到列表为空。此方法用于获取任务完成进度或在运行任务时使用超时。例如,我们等待一定数量的任务,其中有一个任务是计数超时。如果这个任务首先完成,我们就取消所有尚未完成的任务。
使用TaskScheduler调整任务的执行
这个配方描述了处理任务的一个重要方面,即从异步代码中正确处理 UI 的方式。你将学习什么是任务调度器,为什么它如此重要,它如何损害我们的应用程序,以及如何使用它来避免错误。
准备工作
要逐步执行此配方,你需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在BookSamples\Chapter4\Recipe9中找到。
如何做到这一点...
要使用TaskScheduler调整任务执行,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# WPF 应用程序项目。这次,我们需要一个具有消息循环的用户界面线程,这在控制台应用程序中是不可用的。
-
在
MainWindow.xaml文件中,在网格元素内(即在<Grid>和</Grid>标签之间)添加以下标记:<TextBlock Name="ContentTextBlock" HorizontalAlignment="Left" Margin="44,134,0,0" VerticalAlignment="Top" Width="425" Height="40"/> <Button Content="Sync" HorizontalAlignment="Left" Margin="45,190,0,0" VerticalAlignment="Top" Width="75" Click="ButtonSync_Click"/> <Button Content="Async" HorizontalAlignment="Left" Margin="165,190,0,0" VerticalAlignment="Top" Width="75" Click="ButtonAsync_Click"/> <Button Content="Async OK" HorizontalAlignment="Left" Margin="285,190,0,0" VerticalAlignment="Top" Width="75" Click="ButtonAsyncOK_Click"/> -
在
MainWindow.xaml.cs文件中,使用以下using指令:using System; using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Input; -
在
MainWindow构造函数下方添加以下代码片段:void ButtonSync_Click(object sender, RoutedEventArgs e) { ContentTextBlock.Text = string.Empty; try { //string result = TaskMethod( // TaskScheduler.FromCurrentSynchronizationContext()).Result; string result = TaskMethod().Result; ContentTextBlock.Text = result; } catch (Exception ex) { ContentTextBlock.Text = ex.InnerException.Message; } } void ButtonAsync_Click(object sender, RoutedEventArgs e) { ContentTextBlock.Text = string.Empty; Mouse.OverrideCursor = Cursors.Wait; Task<string> task = TaskMethod(); task.ContinueWith(t => { ContentTextBlock.Text = t.Exception.InnerException.Message; Mouse.OverrideCursor = null; }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.FromCurrentSynchronizationContext()); } void ButtonAsyncOK_Click(object sender, RoutedEventArgs e) { ContentTextBlock.Text = string.Empty; Mouse.OverrideCursor = Cursors.Wait; Task<string> task = TaskMethod( TaskScheduler.FromCurrentSynchronizationContext()); task.ContinueWith(t => Mouse.OverrideCursor = null, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()); } Task<string> TaskMethod() { return TaskMethod(TaskScheduler.Default); } Task<string> TaskMethod(TaskScheduler scheduler) { Task delay = Task.Delay(TimeSpan.FromSeconds(5)); return delay.ContinueWith(t => { string str = "Task is running on a thread id " + $"{CurrentThread.ManagedThreadId}. Is thread pool thread: " + $"{CurrentThread.IsThreadPoolThread}"; ContentTextBlock.Text = str; return str; }, scheduler); } -
运行程序。
它是如何工作的...
这里,我们遇到了许多新事物。首先,我们创建了一个 WPF 应用程序而不是控制台应用程序。这是必要的,因为我们需要一个具有消息循环的用户界面线程来演示异步运行任务的不同选项。
有一个非常重要的抽象叫做TaskScheduler。这个组件实际上负责任务将如何执行。默认任务调度器将任务放在线程池工作线程上。这是最常见的情况;不出所料,它是 TPL 中的默认选项。我们也知道如何同步运行任务,以及如何将它们附加到父任务以一起运行这些任务。现在,让我们看看我们还可以用任务做什么。
当程序启动时,我们创建了一个包含三个按钮的窗口。第一个按钮调用同步任务执行。代码放置在 ButtonSync_Click 方法内部。在任务运行期间,我们甚至无法移动应用程序窗口。当用户界面线程忙于运行任务且无法响应任何消息循环直到任务完成时,用户界面会完全冻结。这在 GUI 窗口应用程序中是一种相当常见的坏习惯,我们需要找到一种方法来解决这个问题。
第二个问题是,我们尝试从另一个线程访问 UI 控件。图形用户界面控件从未被设计为可以从多个线程使用,为了避免可能出现的错误,不允许从创建它的线程以外的线程访问这些组件。当我们尝试这样做时,我们会得到一个异常,并且异常消息将在 5 秒内在主窗口上打印出来。
为了解决第一个问题,我们尝试异步运行任务。这就是第二个按钮所做的事情;这个按钮的代码放置在 ButtonAsync_Click 方法内部。如果你在调试器中运行任务,你会看到它被放置在线程池中,最终我们将会得到相同的异常。然而,在整个任务运行期间,用户界面始终保持响应。这是一件好事,但我们需要消除异常。
我们已经做到了!为了输出错误消息,我们使用 TaskScheduler.FromCurrentSynchronizationContext 选项提供了一个延续。如果没有这样做,我们就不会看到错误消息,因为我们可能会得到任务内部发生的相同异常。此选项指示 TPL 基础设施将代码放在延续中,并在 UI 线程上运行,同时利用 UI 线程的消息循环异步执行。这解决了从另一个线程访问 UI 控件的问题,但仍然保持了我们的 UI 响应。
为了验证这是否正确,我们按下运行 ButtonAsyncOK_Click 方法内部代码的最后一个按钮。唯一不同的是,我们为任务提供了 UI 线程任务调度器。任务完成后,你会看到它是以异步方式在 UI 线程上运行的。UI 保持响应,即使在等待光标激活的情况下,也可以按下另一个按钮。
然而,有一些技巧可以用来在 UI 线程上运行任务。如果我们回到同步任务代码,并取消注释使用 UI 线程任务调度器获取结果的行,我们将永远不会得到任何结果。这是一个经典的死锁情况:我们在 UI 线程的队列中调度了一个操作,UI 线程等待这个操作完成,但是当它等待时,它无法运行这个操作,它将永远不会结束(甚至开始)。如果我们对一个任务调用 Wait 方法,也会发生这种情况。为了避免死锁,永远不要在调度到 UI 线程的任务上使用同步操作;只需使用 C# 中的 ContinueWith 或 async/await。
第五章:使用 C# 6.0
在本章中,我们将探讨 C# 6.0 编程语言中的原生异步编程支持。你将学习以下技巧:
-
使用
await操作符获取异步任务结果 -
在 lambda 表达式中使用
await操作符 -
使用
await操作符处理后续异步任务 -
使用
await操作符执行并行异步任务 -
处理异步操作中的异常
-
避免使用捕获的同步上下文
-
处理
async void方法 -
设计自定义可等待类型
-
使用
dynamic类型与await结合
简介
到目前为止,你学习了关于任务并行库(Task Parallel Library,TPL)的内容,这是微软最新的异步编程基础设施。它允许我们以模块化的方式设计程序,将不同的异步操作组合在一起。
不幸的是,阅读这样的程序仍然很难理解实际的程序流程。在一个大程序中,会有许多相互依赖的任务和延续,延续运行其他延续,以及用于异常处理的延续。它们都在程序代码的非常不同的地方聚集在一起。因此,理解哪个操作先执行以及接下来会发生什么成为一个非常具有挑战性的问题。
另一个需要注意的问题是,是否将适当的同步上下文传播到每个可能接触用户界面控件的异步任务。仅允许从 UI 线程使用这些控件;否则,我们会得到一个多线程访问异常。
说到异常,我们还需要使用单独的延续任务来处理在先前的异步操作或操作中发生的错误。这反过来又导致复杂的错误处理代码散布在代码的不同部分,彼此之间没有逻辑关系。
为了解决这些问题,C# 的作者们引入了新的语言增强功能,称为 异步函数,与 C# 5.0 版本一起推出。它们确实使异步编程变得简单,但同时也提供了对 TPL 的高级抽象。正如我们在第四章中提到的,使用任务并行库,抽象隐藏了重要的实现细节,以简化异步编程,但代价是程序员失去了许多重要的事物。理解异步函数背后的概念对于创建健壮和可扩展的应用程序非常重要。
要创建一个异步函数,你首先需要使用 async 关键字标记一个方法。如果不这样做,将无法拥有 async 属性或事件访问器方法和构造函数。代码将如下所示:
async Task<string> GetStringAsync()
{
await Task.Delay(TimeSpan.FromSeconds(2));
return "Hello, World!";
}
另一个重要的事实是,异步函数必须返回 Task 或 Task<T> 类型。虽然可以存在 async void 方法,但最好使用 async Task 方法。唯一合理的使用 async void 函数的选项是在你的应用程序中使用顶级 UI 控制事件处理器。
在标记有 async 关键字的方法内部,你可以使用 await 操作符。这个操作符与 TPL 中的任务一起工作,并在任务内部获取异步操作的结果。这些细节将在本章后面进行介绍。你无法在 async 方法外部使用 await 操作符;这将导致编译错误。此外,异步函数的代码中至少应该有一个 await 操作符。然而,如果没有 await 操作符,只会产生编译警告,而不是错误。
重要的是要注意,此方法在 await 调用行之后立即返回。在同步执行的情况下,执行线程将被阻塞 2 秒,然后返回结果。在这里,我们异步等待,同时在执行 await 操作符后立即将工作线程返回到线程池。2 秒后,我们再次从线程池中获取工作线程,并在其上运行异步方法的其余部分。这允许我们在这 2 秒内重用此工作线程来完成其他工作,这对于应用程序的可扩展性至关重要。借助异步函数,我们有一个线性的程序控制流,但它仍然是异步的。这既非常舒适又非常令人困惑。本章中的食谱将帮助你学习异步函数的每一个重要方面。
注意
根据我的经验,如果程序中有两个连续的 await 操作符,人们通常对程序的工作方式有一个常见的误解。许多人认为,如果我们对一系列异步操作使用 await 函数,它们将并行运行。然而,它们实际上是顺序执行的;第二个操作只有在第一个操作完成后才开始。这一点非常重要,我们将在本章后面详细讨论这个话题。
使用 async 和 await 操作符存在一些限制。例如,在 C# 5.0 中,无法将控制台应用程序的 Main 方法标记为 async;你无法在 catch、finally、lock 或 unsafe 块中使用 await 操作符。不允许在异步函数上有 ref 和 out 参数。还有更多细微之处,但这些都是主要点。在 C# 6.0 中,一些这些限制已被移除;由于编译器内部增强,可以在 catch 和 finally 块中使用 await。
在幕后,C# 编译器将异步函数转换为复杂的程序结构。我故意不会详细描述这一点;生成的代码与另一种称为 迭代器 的 C# 结构非常相似,并且实现为一种状态机。由于许多开发者几乎在每一个方法中都开始使用 async 修饰符,我想强调,如果一个方法不是打算以异步或并行方式使用,那么标记方法为 async 没有意义。调用 async 方法会带来显著的性能损失,与使用 async 关键字标记的相同方法相比,通常的方法调用将快 40 到 50 倍。请务必注意这一点。
在本章中,您将学习如何使用 C# 的 async 和 await 关键字来处理异步操作。我们将介绍如何顺序和并行地等待异步操作,讨论如何在 lambda 表达式中使用 await,如何处理异常,以及如何在使用 async void 方法时避免陷阱。为了结束本章,我们将深入研究同步上下文传播,您将学习如何创建自己的可等待对象而不是使用任务。
使用 await 操作符获取异步任务结果
本食谱将引导您了解使用异步函数的基本场景。我们将比较如何使用 TPL 和 await 操作符来获取异步操作的结果。
准备工作
为了逐步完成这个食谱,您将需要 Visual Studio 2015。没有其他先决条件。本食谱的源代码可以在 BookSamples\Chapter5\Recipe1 中找到。
如何操作...
要使用 await 操作符获取异步任务结果,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static Task AsynchronyWithTPL() { Task<string> t = GetInfoAsync("Task 1"); Task t2 = t.ContinueWith(task => WriteLine(t.Result), TaskContinuationOptions.NotOnFaulted); Task t3 = t.ContinueWith(task => WriteLine(t.Exception.InnerException), TaskContinuationOptions.OnlyOnFaulted); return Task.WhenAny(t2, t3); } static async Task AsynchronyWithAwait() { try { string result = await GetInfoAsync("Task 2"); WriteLine(result); } catch (Exception ex) { WriteLine(ex); } } static async Task<string> GetInfoAsync(string name) { await Task.Delay(TimeSpan.FromSeconds(2)); //throw new Exception("Boom!"); return $"Task {name} is running on a thread id {CurrentThread.ManagedThreadId}." + $" Is thread pool thread: {CurrentThread.IsThreadPoolThread}"; } -
在
Main方法内部添加以下代码片段:Task t = AsynchronyWithTPL(); t.Wait(); t = AsynchronyWithAwait(); t.Wait(); -
运行程序。
工作原理...
当程序运行时,我们运行两个异步操作。其中一个是标准的 TPL 代码,另一个使用新的 async 和 await C# 功能。AsynchronyWithTPL 方法启动一个运行 2 秒的任务,然后返回一个包含有关工作线程信息的字符串。然后,我们定义一个后续操作,在操作完成后打印异步操作的结果,另一个用于在发生错误时打印异常详细信息。最后,我们在 Main 方法中返回一个表示其中一个后续任务的任务,并等待其完成。
在AsynchronyWithAwait方法中,我们通过使用await与任务一起实现相同的结果。这就像我们只写了常规的同步代码——我们从任务中获取结果,打印结果,并在任务以错误完成时捕获异常。关键的区别是我们实际上有一个异步程序。在await使用后立即,C#会创建一个任务,该任务具有一个带有await操作符之后所有剩余代码的后续任务,并处理异常传播。然后,我们将此任务返回到Main方法并等待其完成。
注意
注意,根据底层异步操作的性质和当前同步上下文,执行异步代码的确切方式可能不同。我们将在本章后面解释这一点。
因此,我们可以看到程序的第一部分和第二部分在概念上是等效的,但在第二部分中,C#编译器隐式地处理异步代码的工作。实际上,它比第一部分更复杂,我们将在本章接下来的几个菜谱中详细介绍。
请记住,在 Windows GUI 或 ASP.NET 等环境中不建议使用Task.Wait和Task.Result方法。如果程序员不是 100%清楚代码中真正发生的事情,这可能导致死锁。这在本章 4 的使用 TaskScheduler 微调任务执行菜谱中得到了说明,当时我们在 WPF 应用程序中使用了Task.Result。
要测试异常处理的工作方式,只需取消注释GetInfoAsync方法内的throw new Exception行。
在 lambda 表达式中使用await操作符
此菜谱将向您展示如何在 lambda 表达式中使用await。我们将编写一个使用await的匿名方法,并使用 lambda 表达式中的await操作符异步获取方法执行的结果。
准备工作
要逐步完成此菜谱,你需要 Visual Studio 2015。没有其他先决条件。此菜谱的源代码可以在BookSamples\Chapter5\Recipe2中找到。
如何做...
要编写一个使用await的匿名方法,并使用 lambda 表达式中的await操作符异步获取方法执行的结果,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中添加以下using指令:using System; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static async Task AsynchronousProcessing() { Func<string, Task<string>> asyncLambda = async name => { await Task.Delay(TimeSpan.FromSeconds(2)); return $"Task {name} is running on a thread id {CurrentThread.ManagedThreadId}." + $" Is thread pool thread: {CurrentThread.IsThreadPoolThread}"; }; string result = await asyncLambda("async lambda"); WriteLine(result); } -
在
Main方法内部添加以下代码片段:Task t = AsynchronousProcessing(); t.Wait(); -
运行程序。
工作原理...
首先,我们将异步函数移动到AsynchronousProcessing方法中,因为我们不能在Main中使用async。然后,我们使用async关键字描述一个 lambda 表达式。由于任何 lambda 表达式的类型都不能从 lambda 本身推断出来,我们必须明确地将类型指定给 C#编译器。在我们的情况下,类型意味着我们的 lambda 表达式接受一个字符串参数并返回一个Task<string>对象。
然后,我们定义 lambda 表达式体。一个异常是,方法被定义为返回一个Task<string>对象,但实际上我们返回一个字符串,并且没有编译错误!C#编译器会自动为我们生成一个任务并返回它。
最后一步是等待异步 lambda 表达式执行并打印出结果。
使用await运算符与连续的异步任务
这个菜谱将向您展示当我们代码中有多个连续的await方法时,程序的确切流程。您将学习如何使用await方法阅读代码,并理解为什么await调用是一个异步操作。
准备工作
要逐步完成这个菜谱,您需要 Visual Studio 2015。没有其他先决条件。这个菜谱的源代码可以在BookSamples\Chapter5\Recipe3中找到。
如何做到...
为了理解存在连续await方法时的程序流程,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中添加以下using指令:using System; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static Task AsynchronyWithTPL() { var containerTask = new Task(() => { Task<string> t = GetInfoAsync("TPL 1"); t.ContinueWith(task => { WriteLine(t.Result); Task<string> t2 = GetInfoAsync("TPL 2"); t2.ContinueWith(innerTask => WriteLine(innerTask.Result), TaskContinuationOptions.NotOnFaulted | TaskContinuationOptions.AttachedToParent); t2.ContinueWith(innerTask => WriteLine(innerTask.Exception.InnerException), TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.AttachedToParent); }, TaskContinuationOptions.NotOnFaulted | TaskContinuationOptions.AttachedToParent); t.ContinueWith(task => WriteLine(t.Exception.InnerException), TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.AttachedToParent); }); containerTask.Start(); return containerTask; } static async Task AsynchronyWithAwait() { try { string result = await GetInfoAsync("Async 1"); WriteLine(result); result = await GetInfoAsync("Async 2"); WriteLine(result); } catch (Exception ex) { WriteLine(ex); } } static async Task<string> GetInfoAsync(string name) { WriteLine($"Task {name} started!"); await Task.Delay(TimeSpan.FromSeconds(2)); if(name == "TPL 2") throw new Exception("Boom!"); return $"Task {name} is running on a thread id {CurrentThread.ManagedThreadId}." + $" Is thread pool thread: {CurrentThread.IsThreadPoolThread}"; } -
在
Main方法内添加以下代码片段:Task t = AsynchronyWithTPL(); t.Wait(); t = AsynchronyWithAwait(); t.Wait(); -
运行程序。
它是如何工作的...
当程序运行时,我们执行两个异步操作,就像我们在第一个菜谱中所做的那样。然而,这次,我们将从AsynchronyWithAwait方法开始。它看起来仍然像是常规的同步代码;唯一的区别是两个await语句。最重要的一点是,代码仍然是顺序的,Async 2任务只有在前一个任务完成后才会开始。当我们阅读代码时,程序流程非常清晰:我们看到先运行什么,然后是什么接着运行。那么,这个程序是如何异步的呢?好吧,首先,它并不总是异步的。如果我们使用await时任务已经完成,我们将同步地得到其结果。否则,当我们看到代码中的await语句时,常见的做法是注意在这个点上,方法将立即返回,其余的代码将在一个延续任务中运行。由于我们没有阻塞执行,等待操作的结果,所以这是一个异步调用。我们可以在AsynchronyWithAwait方法执行时执行任何其他任务,而不是在Main方法中调用t.Wait。但是,主线程必须等待所有异步操作完成,否则它们将在后台线程上运行时被停止。
AsynchronyWithTPL方法模仿AsynchronyWithAwait方法相同的程序流程。我们需要一个容器任务来处理所有依赖任务。然后,我们启动主任务,并向其添加一组后续任务。当任务完成时,我们打印出结果;然后我们再启动一个任务,该任务在第二个任务完成后继续工作。为了测试异常处理,我们在运行第二个任务时故意抛出异常,并打印出其信息。这组后续任务创建了与第一种方法相同的程序流程,当我们将其与使用await方法的代码进行比较时,我们可以看到它更容易阅读和理解。唯一的技巧是要记住,异步并不总是意味着并行执行。
使用await运算符执行并行异步任务
在本配方中,您将学习如何使用await来并行运行异步操作,而不是通常的顺序执行。
准备工作
要逐步执行此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可在BookSamples\Chapter5\Recipe4中找到。
如何操作...
要理解await运算符在并行异步任务执行中的用法,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码:static async Task AsynchronousProcessing() { Task<string> t1 = GetInfoAsync("Task 1", 3); Task<string> t2 = GetInfoAsync("Task 2", 5); string[] results = await Task.WhenAll(t1, t2); foreach (string result in results) { WriteLine(result); } } static async Task<string> GetInfoAsync(string name, int seconds) { await Task.Delay(TimeSpan.FromSeconds(seconds)); //await Task.Run(() => // Thread.Sleep(TimeSpan.FromSeconds(seconds))); return $"Task {name} is running on a thread id " + $"{CurrentThread.ManagedThreadId}. " + $"Is thread pool thread: {CurrentThread.IsThreadPoolThread}"; } -
在
Main方法内部添加以下代码片段:Task t = AsynchronousProcessing(); t.Wait(); -
运行程序。
工作原理...
在这里,我们定义了两个异步任务,分别运行 3 秒和 5 秒。然后,我们使用Task.WhenAll辅助方法创建另一个任务,该任务只有在所有底层任务都完成后才会完成。然后,我们等待这个组合任务的结果。5 秒后,我们得到所有结果,这意味着任务是在同时运行的。
然而,有一个有趣的观察。当你运行程序时,你可能会注意到这两个任务很可能由同一个线程池中的工作线程来服务。当我们并行运行任务时,这是如何可能的呢?为了使事情更有趣,让我们在GetIntroAsync方法中注释掉await Task.Delay行,并取消注释await Task.Run行,然后再次运行程序。
我们将看到在这种情况下,两个任务将由不同的工作线程提供服务。区别在于 Task.Delay 在底层使用计时器,处理过程如下:我们从线程池中获取工作线程,并等待 Task.Delay 方法返回结果。然后,Task.Delay 方法启动计时器并指定当计时器计算到 Task.Delay 方法指定的秒数时将调用的代码片段。然后,我们立即将工作线程返回到线程池。当计时器事件运行时,我们再次从线程池中获取任何可用的工作线程(这可能是我们最初使用的工作线程)并在其上运行提供给计时器的代码。
当我们使用 Task.Run 方法时,我们从线程池中获取一个工作线程,并使其阻塞几秒钟,这是提供给 Thread.Sleep 方法的。然后,我们获取第二个工作线程并使其也阻塞。在这种情况下,我们消耗了两个工作线程,它们实际上什么也没做,因为它们在等待时无法执行任何其他任务。
我们将在 第九章 使用异步 I/O 中详细讨论第一个场景,我们将讨论一组与数据输入和输出一起工作的异步操作。尽可能使用第一种方法是创建可扩展服务器应用程序的关键。
异步操作中的异常处理
此配方将描述如何使用 C# 中的异步函数处理异常。您将学习在您使用 await 与多个并行异步操作一起时如何处理聚合异常。
准备工作
要逐步执行此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在 BookSamples\Chapter5\Recipe5 中找到。
如何做...
要理解异步操作中的异常处理,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中添加以下using指令:using System; using System.Threading.Tasks; using static System.Console; -
在
Main方法下方添加以下代码片段:static async Task AsynchronousProcessing() { WriteLine("1\. Single exception"); try { string result = await GetInfoAsync("Task 1", 2); WriteLine(result); } catch (Exception ex) { WriteLine($"Exception details: {ex}"); } WriteLine(); WriteLine("2\. Multiple exceptions"); Task<string> t1 = GetInfoAsync("Task 1", 3); Task<string> t2 = GetInfoAsync("Task 2", 2); try { string[] results = await Task.WhenAll(t1, t2); WriteLine(results.Length); } catch (Exception ex) { WriteLine($"Exception details: {ex}"); } WriteLine(); WriteLine("3\. Multiple exceptions with AggregateException"); t1 = GetInfoAsync("Task 1", 3); t2 = GetInfoAsync("Task 2", 2); Task<string[]> t3 = Task.WhenAll(t1, t2); try { string[] results = await t3; WriteLine(results.Length); } catch { var ae = t3.Exception.Flatten(); var exceptions = ae.InnerExceptions; WriteLine($"Exceptions caught: {exceptions.Count}"); foreach (var e in exceptions) { WriteLine($"Exception details: {e}"); WriteLine(); } } WriteLine(); WriteLine("4\. await in catch and finally blocks"); try { string result = await GetInfoAsync("Task 1", 2); WriteLine(result); } catch (Exception ex) { await Task.Delay(TimeSpan.FromSeconds(1)); WriteLine($"Catch block with await: Exception details: {ex}"); } finally { await Task.Delay(TimeSpan.FromSeconds(1)); WriteLine("Finally block"); } } static async Task<string> GetInfoAsync(string name, int seconds) { await Task.Delay(TimeSpan.FromSeconds(seconds)); throw new Exception($"Boom from {name}!"); } -
在
Main方法内部添加以下代码片段:Task t = AsynchronousProcessing(); t.Wait(); -
运行程序。
它是如何工作的...
我们运行四个场景来展示在 C# 中使用 async 和 await 进行错误处理的常见情况。第一个场景非常简单,几乎与常规同步代码相同。我们只是使用 try/catch 语句来获取异常的详细信息。
一个非常常见的错误是在等待多个异步操作时使用相同的方法。如果我们像以前一样使用 catch 块,我们只会从底层的 AggregateException 对象中获取第一个异常。
要收集所有信息,我们必须使用已等待任务的Exception属性。在第三种情况下,我们使用AggregateException的Flatten方法来展开AggregateException层次结构,然后从中解包所有底层异常。
为了说明 C# 6.0 的变化,我们在异常处理代码的catch和finally块中使用await。为了验证在 C#的先前版本中无法在catch和finally块中使用await,您可以通过在项目属性的构建部分高级设置中指定它来编译它针对 C# 5.0。
避免使用捕获的同步上下文
本配方讨论了使用await获取异步操作结果时同步上下文行为的详细信息。您将了解如何和何时关闭同步上下文流。
准备工作
要逐步执行此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在BookSamples\Chapter5\Recipe6中找到。
如何操作...
要了解使用await时同步上下文行为的详细信息,以及学习如何和何时关闭同步上下文流,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
按照以下步骤添加对 Windows Presentation Foundation 库的引用:
-
右键单击项目中的引用文件夹,并选择添加引用…菜单选项。
-
添加以下库的引用:PresentationCore、PresentationFramework、System.Xaml和WindowsBase。您可以使用引用管理器对话框中的搜索功能如下:
![如何操作...]()
-
-
在
Program.cs文件中,添加以下using指令:using System; using System.Diagnostics; using System.Text; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using static System.Console; -
在
Main方法下方添加以下代码片段:private static Label _label; static async void Click(object sender, EventArgs e) { _label.Content = new TextBlock {Text = "Calculating..."}; TimeSpan resultWithContext = await Test(); TimeSpan resultNoContext = await TestNoContext(); //TimeSpan resultNoContext = // await TestNoContext().ConfigureAwait(false); var sb = new StringBuilder(); sb.AppendLine($"With the context: {resultWithContext}"); sb.AppendLine($"Without the context: {resultNoContext}"); sb.AppendLine("Ratio: " + $"{resultWithContext.TotalMilliseconds/resultNoContext.TotalMilliseconds:0.00}"); _label.Content = new TextBlock {Text = sb.ToString()}; } static async Task<TimeSpan> Test() { const int iterationsNumber = 100000; var sw = new Stopwatch(); sw.Start(); for (int i = 0; i < iterationsNumber; i++) { var t = Task.Run(() => { }); await t; } sw.Stop(); return sw.Elapsed; } static async Task<TimeSpan> TestNoContext() { const int iterationsNumber = 100000; var sw = new Stopwatch(); sw.Start(); for (int i = 0; i < iterationsNumber; i++) { var t = Task.Run(() => { }); await t.ConfigureAwait( continueOnCapturedContext: false); } sw.Stop(); return sw.Elapsed; } -
将
Main方法替换为以下代码片段:[STAThread] static void Main(string[] args) { var app = new Application(); var win = new Window(); var panel = new StackPanel(); var button = new Button(); _label = new Label(); _label.FontSize = 32; _label.Height = 200; button.Height = 100; button.FontSize = 32; button.Content = new TextBlock {Text = "Start asynchronous operations"}; button.Click += Click; panel.Children.Add(_label); panel.Children.Add(button); win.Content = panel; app.Run(win); ReadLine(); } -
运行程序。
它是如何工作的...
在本例中,我们研究了异步函数默认行为的一个重要方面。您已经从第四章 使用任务并行库 中了解了任务调度器和同步上下文。默认情况下,await运算符会尝试捕获同步上下文并在其上执行前面的代码。正如我们已经知道的,这有助于我们通过操作用户界面控件来编写异步代码。此外,当使用await时,由于我们不会在等待结果时阻塞 UI 线程,因此不会发生死锁等情况,如前一章所述。
这是合理的,但让我们看看可能会发生什么。在这个例子中,我们通过编程创建了一个 Windows Presentation Foundation 应用程序,并订阅了其按钮点击事件。当点击按钮时,我们运行两个异步操作。其中一个使用常规的await运算符,而另一个使用带有false作为参数值的ConfigureAwait方法。它明确指示我们不应使用捕获的同步上下文在该上下文中运行后续代码。在每个操作内部,我们测量它们完成所需的时间,然后,在主屏幕上显示相应的时间和比率。
因此,我们看到常规的await运算符需要更多的时间来完成。这是因为我们在 UI 线程上发布了 100,000 个后续任务,该线程使用其消息循环异步处理这些任务。在这种情况下,我们不需要在 UI 线程上运行此代码,因为我们没有从异步操作中访问 UI 组件;使用带有false的ConfigureAwait将是一个更高效的解决方案。
值得注意的是,还有一件事。尝试通过仅点击按钮并等待结果来运行程序。现在,再次执行同样的操作,但这次,点击按钮并尝试以随机方式拖动应用程序窗口的两侧。你会注意到捕获的同步上下文上的代码变慢了!这个有趣的副作用完美地说明了异步编程是多么危险。这种情况很容易发生,如果你以前从未遇到过这种行为,那么几乎不可能调试它。
公平起见,让我们看看相反的情况。在上面的代码片段中,在Click方法内部,取消注释被注释的行,并注释掉紧随其后的行。当运行应用程序时,我们将遇到多线程控制访问异常,因为设置Label控件文本的代码不会在捕获的上下文中发布,而是会在线程池工作线程上执行。
解决async void方法的问题
这个配方描述了为什么async void方法非常危险。你将了解在什么情况下可以使用此方法,以及在可能的情况下应该使用什么替代方案。
准备工作
要逐步执行此配方,你需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在BookSamples\Chapter5\Recipe7中找到。
如何操作...
要学习如何使用async void方法,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static async Task AsyncTaskWithErrors() { string result = await GetInfoAsync("AsyncTaskException", 2); WriteLine(result); } static async void AsyncVoidWithErrors() { string result = await GetInfoAsync("AsyncVoidException", 2); WriteLine(result); } static async Task AsyncTask() { string result = await GetInfoAsync("AsyncTask", 2); WriteLine(result); } static async void AsyncVoid() { string result = await GetInfoAsync("AsyncVoid", 2); WriteLine(result); } static async Task<string> GetInfoAsync(string name, int seconds) { await Task.Delay(TimeSpan.FromSeconds(seconds)); if(name.Contains("Exception")) throw new Exception($"Boom from {name}!"); return $"Task {name} is running on a thread id {CurrentThread.ManagedThreadId}." + $" Is thread pool thread: {CurrentThread.IsThreadPoolThread}"; } -
在
Main方法内部添加以下代码片段:Task t = AsyncTask(); t.Wait(); AsyncVoid(); Sleep(TimeSpan.FromSeconds(3)); t = AsyncTaskWithErrors(); while(!t.IsFaulted) { Sleep(TimeSpan.FromSeconds(1)); } WriteLine(t.Exception); //try //{ // AsyncVoidWithErrors(); // Thread.Sleep(TimeSpan.FromSeconds(3)); //} //catch (Exception ex) //{ // Console.WriteLine(ex); //} int[] numbers = {1, 2, 3, 4, 5}; Array.ForEach(numbers, async number => { await Task.Delay(TimeSpan.FromSeconds(1)); if (number == 3) throw new Exception("Boom!"); WriteLine(number); }); ReadLine(); -
运行程序。
工作原理...
当程序启动时,我们通过调用两个方法 AsyncTask 和 AsyncVoid 来启动两个异步操作。第一个方法返回一个 Task 对象,而另一个由于声明为 async void 而不返回任何内容。由于它们是异步的,所以它们都会立即返回,但第一个可以通过返回的任务状态或直接调用其上的 Wait 方法来轻松监控。等待第二个方法完成的唯一方法是实际等待一段时间,因为我们没有声明任何可以用来监控异步操作状态的对象。当然,可以使用某种类型的共享状态变量,并在 async void 方法中设置它,同时从 调用 方法中检查它,但最好只是返回一个 Task 对象。
最危险的部分是异常处理。在 async void 方法中,异常将被发送到当前同步上下文;在我们的例子中,是线程池。线程池上的未处理异常将终止整个进程。可以使用 AppDomain.UnhandledException 事件来拦截未处理的异常,但无法从那里恢复进程。为了体验这一点,我们应该取消注释 Main 方法内的 try/catch 块,然后运行程序。
关于使用 async void lambda 表达式的另一个事实是,它们与 Action 类型兼容,这在标准 .NET Framework 类库中广泛使用。很容易忘记这个 lambda 表达式中的异常处理,这会导致程序再次崩溃。要查看此示例,取消注释 Main 方法中第二个注释掉的块。
我强烈建议仅在 UI 事件处理器中使用 async void。在其他所有情况下,请使用返回 Task 的方法。
设计自定义可等待类型
此过程向您展示如何设计一个与 await 操作符兼容的非常基本的可等待类型。
准备工作
要逐步执行此过程,你需要 Visual Studio 2015。没有其他先决条件。此过程的源代码可以在 BookSamples\Chapter5\Recipe8 中找到。
如何操作...
设计自定义可等待类型,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static async Task AsynchronousProcessing() { var sync = new CustomAwaitable(true); string result = await sync; WriteLine(result); var async = new CustomAwaitable(false); result = await async; WriteLine(result); } class CustomAwaitable { public CustomAwaitable(bool completeSynchronously) { _completeSynchronously = completeSynchronously; } public CustomAwaiter GetAwaiter() { return new CustomAwaiter(_completeSynchronously); } private readonly bool _completeSynchronously; } class CustomAwaiter : INotifyCompletion { private string _result = "Completed synchronously"; private readonly bool _completeSynchronously; public bool IsCompleted => _completeSynchronously; public CustomAwaiter(bool completeSynchronously) { _completeSynchronously = completeSynchronously; } public string GetResult() { return _result; } public void OnCompleted(Action continuation) { ThreadPool.QueueUserWorkItem( state => { Sleep(TimeSpan.FromSeconds(1)); _result = GetInfo(); continuation?.Invoke(); }); } private string GetInfo() { return $"Task is running on a thread id {CurrentThread.ManagedThreadId}." + $" Is thread pool thread: {CurrentThread.IsThreadPoolThread}"; } } -
在
Main方法内添加以下代码片段:Task t = AsynchronousProcessing(); t.Wait(); -
运行程序。
它是如何工作的...
要与 await 操作符兼容,类型应符合 C# 语言规范中声明的若干要求。如果您已安装 Visual Studio 2015,您可以在 C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC#\Specifications\1033 文件夹中找到规范文档(假设您有一个 64 位操作系统并使用了默认的安装路径)。
在第 7.7.7.1 段落中,我们找到了关于可等待表达式的定义:
await 表达式的任务是必须可等待的。一个表达式 t 是可等待的,如果以下任何一个条件成立:
-
t是编译时类型 dynamic -
t具有一个可访问的实例或扩展方法,名为GetAwaiter,没有参数和类型参数,返回类型为A,并且满足以下所有条件:-
A实现了接口System.Runtime.CompilerServices.INotifyCompletion(以下简称为INotifyCompletion以节省篇幅)。 -
A具有一个可访问的、可读的实例属性IsCompleted,其类型为bool。 -
A具有一个可访问的实例方法GetResult,没有参数和类型参数。
-
这些信息足以开始。首先,我们定义一个可等待类型 CustomAwaitable 并实现 GetAwaiter 方法。这反过来又返回 CustomAwaiter 类型的实例。CustomAwaiter 实现了 INotifyCompletion 接口,具有 bool 类型的 IsCompleted 属性,并具有 GetResult 方法,该方法返回 string 类型。最后,我们编写了一段代码,创建了两个 CustomAwaitable 对象,并等待它们两个。
现在,我们应该了解 await 表达式的评估方式。这次,为了避免不必要的细节,没有引用规范。基本上,如果 IsCompleted 属性返回 true,我们就同步调用 GetResult 方法。这可以防止我们在操作已经完成的情况下为异步任务执行分配资源。我们通过向 CustomAwaitable 对象的构造函数方法提供 completeSynchronously 参数来处理这种情况。
否则,我们在 CustomAwaiter 的 OnCompleted 方法上注册一个回调操作,并开始异步操作。当它完成时,它调用提供的回调,通过在 CustomAwaiter 对象上调用 GetResult 方法来获取结果。
注意
此实现仅用于教育目的。无论何时您编写异步函数,最自然的方法是使用标准的 Task 类型。您应该只定义自己的可等待类型,如果您有充分的理由不能使用 Task 类型,并且确切地知道您在做什么。
与设计自定义可等待类型相关的许多其他主题,例如 ICriticalNotifyCompletion 接口实现和同步上下文传播。在了解如何设计可等待类型的基本原理之后,您将能够轻松地使用 C# 语言规范和其他信息源来查找您需要的详细信息。但我想强调,除非您有充分的理由不使用 Task 类型,并且确切地知道您在做什么,否则您应该只使用 Task 类型。
使用动态类型与 await
这个配方展示了如何设计一个与 await 操作符和动态 C# 类型兼容的非常基本的类型。
准备中
要逐步执行此食谱,你需要 Visual Studio 2015。你需要互联网访问来下载 NuGet 包。没有其他先决条件。此食谱的源代码可以在 BookSamples\Chapter5\Recipe9 中找到。
如何操作...
要学习如何使用 dynamic 类型与 await 结合,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
按照以下步骤添加对 ImpromptuInterface NuGet 包的引用:
-
右键单击项目中的 引用 文件夹,并选择 管理 NuGet 包… 菜单选项。
-
现在,添加您首选的 ImpromptuInterface NuGet 包引用。您可以在 管理 NuGet 包 对话框中使用搜索功能,如下所示:
![如何操作...]()
-
-
在
Program.cs文件中,使用以下using指令:using System; using System.Dynamic; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using ImpromptuInterface; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static async Task AsynchronousProcessing() { string result = await GetDynamicAwaitableObject(true); WriteLine(result); result = await GetDynamicAwaitableObject(false); WriteLine(result); } static dynamic GetDynamicAwaitableObject(bool completeSynchronously) { dynamic result = new ExpandoObject(); dynamic awaiter = new ExpandoObject(); awaiter.Message = "Completed synchronously"; awaiter.IsCompleted = completeSynchronously; awaiter.GetResult = (Func<string>)(() => awaiter.Message); awaiter.OnCompleted = (Action<Action>) ( callback => ThreadPool.QueueUserWorkItem(state => { Sleep(TimeSpan.FromSeconds(1)); awaiter.Message = GetInfo(); callback?.Invoke(); }) ); IAwaiter<string> proxy = Impromptu.ActLike(awaiter); result.GetAwaiter = (Func<dynamic>) ( () => proxy ); return result; } static string GetInfo() { return $"Task is running on a thread id {CurrentThread.ManagedThreadId}." + $" Is thread pool thread: {CurrentThread.IsThreadPoolThread}"; } -
在
Program类定义下方添加以下代码:public interface IAwaiter<T> : INotifyCompletion { bool IsCompleted { get; } T GetResult(); } -
在
Main方法内部添加以下代码片段:Task t = AsynchronousProcessing(); t.Wait(); -
运行程序。
它是如何工作的...
在这里,我们重复使用之前食谱中的技巧,但这次,我们借助动态表达式。我们可以借助 NuGet——一个包含许多有用库的包管理器来实现这一目标。这次,我们使用一个动态创建包装器的库,实现我们需要的接口。
要开始,我们创建两个 ExpandoObject 类型的实例,并将它们分配给动态局部变量。这些变量将成为我们的 awaitable 和 awaiter 对象。由于 awaitable 对象只需要有 GetAwaiter 方法,因此提供它没有问题。ExpandoObject(结合 dynamic 关键字)允许我们自定义它,并通过分配相应的值添加属性和方法。实际上,它是一个具有 string 类型的键和 object 类型的值的字典类型集合。如果你熟悉 JavaScript 编程语言,你可能会注意到这与 JavaScript 对象非常相似。
由于 dynamic 允许我们在 C# 中跳过编译时检查,ExpandoObject 被编写成这样:如果你将某个值赋给一个属性,它会在字典中创建一个条目,其中键是属性名,值是提供的任何值。当你尝试获取属性值时,它会进入字典,并提供了存储在相应字典条目中的值。如果值是 Action 或 Func 类型,我们实际上存储了一个委托,这反过来又可以像方法一样使用。因此,dynamic 类型与 ExpandoObject 的组合允许我们创建一个对象,并动态地为其提供属性和方法。
现在,我们需要构建我们的awaiter和awaitable对象。让我们从awaiter开始。首先,我们提供一个名为Message的属性,并为此属性提供一个初始值。然后,我们使用Func<string>类型定义GetResult方法。我们分配一个 lambda 表达式,它返回Message属性值。然后我们实现IsCompleted属性。如果它被设置为true,我们可以跳过其余的工作,并继续到存储在result局部变量中的awaitable对象。我们只需要添加一个返回dynamic对象的方法,并从其中返回我们的awaiter对象。然后,我们可以使用result作为await表达式;然而,它将同步运行。
主要挑战是在我们的动态对象上实现异步处理。C#语言规范指出,awaiter对象必须实现INotifyCompletion或ICriticalNotifyCompletion接口,而ExpandoObject不实现这些接口。即使我们动态地实现OnCompleted方法并将其添加到awaiter对象中,我们也不会成功,因为我们的对象没有实现上述任何一个接口。
为了解决这个问题,我们使用了从 NuGet 获取的ImpromptuInterface库。它允许我们使用Impromptu.ActLike方法动态创建实现所需接口的代理对象。如果我们尝试创建一个实现INotifyCompletion接口的代理,我们仍然会失败,因为proxy对象不再动态,而这个接口只有OnCompleted方法,但没有IsCompleted属性或GetResult方法。作为最后的解决方案,我们定义了一个泛型接口IAwaiter<T>,它实现了INotifyCompletion并添加了所有必需的属性和方法。现在,我们用它来生成代理,并将result对象从GetAwaiter方法返回的awaiter改为proxy。程序现在可以工作了;我们只是构建了一个在运行时完全动态的awaitable对象。
第六章。使用并发集合
在本章中,我们将查看 .NET Framework 基类库中包含的用于并发编程的不同数据结构。你将学习以下食谱:
-
使用
ConcurrentDictionary -
使用
ConcurrentQueue实现异步处理 -
使用
ConcurrentStack改变异步处理顺序 -
使用
ConcurrentBag创建可扩展的爬虫 -
使用
BlockingCollection泛化异步处理
简介
编程需要理解和掌握基本数据结构和算法。为了选择最适合并发情况的数据结构,程序员必须了解许多事情,例如算法的时间复杂度、空间复杂度和大 O 符号。在不同的、众所周知的情况下,我们总是知道哪些数据结构更有效。
对于并发计算,我们需要有适当的数据结构。这些数据结构必须是可扩展的,尽可能避免使用锁,并且同时提供线程安全访问。自版本 4 以来,.NET Framework 中的 System.Collections.Concurrent 命名空间包含几个数据结构。在本章中,我们将介绍几个数据结构,并展示如何使用它们的非常简单的示例。
让我们从 ConcurrentQueue 开始。这个集合使用原子的 比较和交换 (CAS) 操作,这允许我们安全地交换两个变量的值,并使用 SpinWait 来确保线程安全。它实现了一个 先进先出 (FIFO) 集合,这意味着项目从队列中退出的顺序与它们被添加到队列中的顺序相同。要将项目添加到队列中,你调用 Enqueue 方法。TryDequeue 方法尝试从队列中取出第一个项目,而 TryPeek 方法尝试获取第一个项目而不从队列中移除它。
ConcurrentStack 集合也是在不使用任何锁的情况下,仅通过 CAS 操作实现的。这是一个 后进先出 (LIFO) 集合,这意味着最近添加的项目将首先被返回。要添加项目,你可以使用 Push 和 PushRange 方法;要检索,你使用 TryPop 和 TryPopRange,要检查,你可以使用 TryPeek 方法。
ConcurrentBag 集合是一个无序集合,支持重复项。它针对的场景是多个线程以这种方式划分他们的工作,即每个线程产生并消费自己的任务,很少处理其他线程的任务(在这种情况下,它使用锁)。你使用 Add 方法向袋子中添加项目;使用 TryPeek 检查,使用 TryTake 方法从袋子中取出项目。
注意
避免在提到的集合上使用 Count 属性。它们使用链表实现,因此 Count 是一个 O(N) 操作。如果你需要检查集合是否为空,请使用 IsEmpty 属性,这是一个 O(1) 操作。
ConcurrentDictionary 是一个线程安全的字典集合实现。它在读取操作上是无锁的。然而,它需要锁定进行写入操作。并发字典使用多个锁,在字典桶上实现了一个细粒度锁定模型。锁的数量可以通过带有 concurrencyLevel 参数的构造函数来定义,这意味着预计将有多个线程并发更新字典。
注意
由于并发字典使用锁定,因此有许多操作需要获取字典内部的所有锁。这些操作包括:Count、IsEmpty、Keys、Values、CopyTo 和 ToArray。避免在没有需要的情况下使用这些操作。
BlockingCollection 是对 IProducerConsumerCollection 泛型接口实现的先进包装。它具有许多更高级的功能,并且在实现具有一些步骤使用前一步骤处理结果的管道场景时非常有用。BlockingCollection 类支持诸如阻塞、限制内部集合容量、取消集合操作和从多个阻塞集合中检索值等功能。
并发算法可能非常复杂,要涵盖所有并发集合——无论是更高级还是不那么高级的——都需要编写一本单独的书。在这里,我们仅展示了使用并发集合的最简单示例。
使用 ConcurrentDictionary
这个配方展示了在一个单线程环境中,比较常规字典集合与并发字典性能的一个非常简单的场景。
准备工作
要完成这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在 BookSamples\Chapter6\Recipe1 中找到。
如何做到...
要了解常规字典集合与并发字典性能之间的差异,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using static System.Console; -
在
Main方法下方添加以下代码片段:const string Item = "Dictionary item"; const int Iterations = 1000000; public static string CurrentItem; -
在
Main方法内添加以下代码片段:var concurrentDictionary = new ConcurrentDictionary<int, string>(); var dictionary = new Dictionary<int, string>(); var sw = new Stopwatch(); sw.Start(); for (int i = 0; i < Iterations; i++) { lock (dictionary) { dictionary[i] = Item; } } sw.Stop(); WriteLine($"Writing to dictionary with a lock: {sw.Elapsed}"); sw.Restart(); for (int i = 0; i < Iterations; i++) { concurrentDictionary[i] = Item; } sw.Stop(); WriteLine($"Writing to a concurrent dictionary: {sw.Elapsed}"); sw.Restart(); for (int i = 0; i < Iterations; i++) { lock (dictionary) { CurrentItem = dictionary[i]; } } sw.Stop(); WriteLine($"Reading from dictionary with a lock: {sw.Elapsed}"); sw.Restart(); for (int i = 0; i < Iterations; i++) { CurrentItem = concurrentDictionary[i]; } sw.Stop(); WriteLine($"Reading from a concurrent dictionary: {sw.Elapsed}"); -
运行程序。
它是如何工作的...
当程序启动时,我们创建了两个集合。其中一个是标准字典集合,另一个是新创建的并发字典。然后,我们开始向它们添加内容,使用带有锁的标准字典,并测量一百万次迭代完成所需的时间。然后,我们在相同场景下测量 ConcurrentDictionary 集合的性能,并最终比较从两个集合中检索值的性能。
在这个非常简单的场景中,我们发现ConcurrentDictionary在写入操作上比带有锁的普通字典要慢得多,但在检索操作上要快。因此,如果我们需要从字典中进行许多线程安全的读取,ConcurrentDictionary集合是最好的选择。
注意
如果你只需要对字典进行只读的多线程访问,可能没有必要执行线程安全的读取。在这种情况下,使用普通的字典或ReadOnlyDictionary集合会更好。
ConcurrentDictionary集合使用细粒度锁定技术实现,这允许它在多个写入操作上比使用带有锁的普通字典(称为粗粒度锁定)有更好的扩展性。正如我们在本例中所看到的,当我们只使用一个线程时,并发字典要慢得多,但当我们将其扩展到五到六个线程(如果我们有足够的 CPU 核心可以同时运行它们),并发字典实际上会表现得更好。
使用ConcurrentQueue实现异步处理
这个配方将向你展示创建一组由多个工作者异步处理的任务的示例。
准备工作
为了完成这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter6\Recipe2中找到。
如何实现...
要理解创建一组由多个工作者异步处理的任务的工作原理,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; using static System.Console; -
在
Main方法下方添加以下代码片段:static async Task RunProgram() { var taskQueue = new ConcurrentQueue<CustomTask>(); var cts = new CancellationTokenSource(); var taskSource = Task.Run(() => TaskProducer(taskQueue)); Task[] processors = new Task[4]; for (int i = 1; i <= 4; i++) { string processorId = i.ToString(); processors[i-1] = Task.Run( () => TaskProcessor(taskQueue, $"Processor {processorId}", cts.Token)); } await taskSource; cts.CancelAfter(TimeSpan.FromSeconds(2)); await Task.WhenAll(processors); } static async Task TaskProducer(ConcurrentQueue<CustomTask> queue) { for (int i = 1; i <= 20; i++) { await Task.Delay(50); var workItem = new CustomTask {Id = i}; queue.Enqueue(workItem); WriteLine($"Task {workItem.Id} has been posted"); } } static async Task TaskProcessor( ConcurrentQueue<CustomTask> queue, string name, CancellationToken token) { CustomTask workItem; bool dequeueSuccesful = false; await GetRandomDelay(); do { dequeueSuccesful = queue.TryDequeue(out workItem); if (dequeueSuccesful) { WriteLine($"Task {workItem.Id} has been processed by {name}"); } await GetRandomDelay(); } while (!token.IsCancellationRequested); } static Task GetRandomDelay() { int delay = new Random(DateTime.Now.Millisecond).Next(1, 500); return Task.Delay(delay); } class CustomTask { public int Id { get; set; } } -
在
Main方法内添加以下代码片段:Task t = RunProgram(); t.Wait(); -
运行程序。
它是如何工作的...
当程序运行时,我们使用ConcurrentQueue集合的实例创建一个任务队列。然后,我们创建一个取消令牌,在将任务发布到队列后,我们将使用它来停止工作。接下来,我们启动一个单独的工作线程,该线程将任务发布到任务队列。这一部分为我们的异步处理产生工作负载。
现在,让我们定义程序的任务消费部分。我们创建四个工作者,它们将等待随机的时间,从任务队列中获取任务,处理它,然后重复整个过程,直到我们发出取消令牌的信号。最后,我们启动任务生成线程,等待其完成,然后使用取消令牌通知消费者我们已经完成工作。最后一步将是等待所有消费者完成,以完成所有任务的处理。
我们看到任务从开始到结束都在被处理,但后来任务可能会在早期任务之前被处理,因为我们有四个独立运行的工作者,并且任务处理时间不是恒定的。我们看到对队列的访问是线程安全的;没有工作项被取两次。
使用 ConcurrentStack 改变异步处理顺序
这个配方是对上一个配方的一点点修改。我们再次创建一组要由多个工作者异步处理的任务,但这次我们使用 ConcurrentStack 来实现,并观察其中的差异。
准备工作
为了完成这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在 BookSamples\Chapter6\Recipe3 中找到。
如何操作...
要理解使用 ConcurrentStack 实现的一组任务的处理过程,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; using static System.Console; -
在
Main方法下方添加以下代码片段:static async Task RunProgram() { var taskStack = new ConcurrentStack<CustomTask>(); var cts = new CancellationTokenSource(); var taskSource = Task.Run(() => TaskProducer(taskStack)); Task[] processors = new Task[4]; for (int i = 1; i <= 4; i++) { string processorId = i.ToString(); processors[i - 1] = Task.Run( () => TaskProcessor(taskStack, $"Processor {processorId}", cts.Token)); } await taskSource; cts.CancelAfter(TimeSpan.FromSeconds(2)); await Task.WhenAll(processors); } static async Task TaskProducer(ConcurrentStack<CustomTask> stack) { for (int i = 1; i <= 20; i++) { await Task.Delay(50); var workItem = new CustomTask { Id = i }; stack.Push(workItem); WriteLine($"Task {workItem.Id} has been posted"); } } static async Task TaskProcessor( ConcurrentStack<CustomTask> stack, string name, CancellationToken token) { await GetRandomDelay(); do { CustomTask workItem; bool popSuccesful = stack.TryPop(out workItem); if (popSuccesful) { WriteLine($"Task {workItem.Id} has been processed by {name}"); } await GetRandomDelay(); } while (!token.IsCancellationRequested); } static Task GetRandomDelay() { int delay = new Random(DateTime.Now.Millisecond).Next(1, 500); return Task.Delay(delay); } class CustomTask { public int Id { get; set; } } -
在
Main方法内部添加以下代码片段:Task t = RunProgram(); t.Wait(); -
运行程序。
工作原理...
当程序运行时,我们现在创建一个 ConcurrentStack 集合的实例。其余部分几乎与之前的配方相同,只是不再使用并发栈上的 Push 和 TryPop 方法,而是在并发队列上使用 Enqueue 和 TryDequeue。
我们现在可以看到任务处理顺序已经改变。栈是一个后进先出(LIFO)集合,工作者首先处理后面的任务。在并发队列的情况下,任务的处理顺序几乎与它们被添加的顺序相同。这意味着,根据工作者的数量,我们肯定会在给定的时间框架内处理最早创建的任务。在栈的情况下,较早创建的任务优先级较低,可能直到生产者停止向栈提供更多任务之前都不会被处理。这种行为非常特定,在这种情况下使用队列会更好。
使用 ConcurrentBag 创建可扩展的爬虫
这个配方展示了如何在不同数量的独立工作者之间扩展工作负载,这些工作者既生产工作又处理工作。
准备工作
为了完成这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在 BookSamples\Chapter6\Recipe4 中找到。
如何操作...
以下步骤演示了如何在多个既生产工作又处理工作的独立工作者之间扩展工作负载:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading.Tasks; using static System.Console; -
在
Main方法下方添加以下代码片段:static Dictionary<string, string[]> _contentEmulation = new Dictionary<string, string[]>(); static async Task RunProgram() { var bag = new ConcurrentBag<CrawlingTask>(); string[] urls = {"http://microsoft.com/", "http://google.com/", "http://facebook.com/", "http://twitter.com/"}; var crawlers = new Task[4]; for (int i = 1; i <= 4; i++) { string crawlerName = $"Crawler {i}"; bag.Add(new CrawlingTask { UrlToCrawl = urls[i-1], ProducerName = "root"}); crawlers[i - 1] = Task.Run(() => Crawl(bag, crawlerName)); } await Task.WhenAll(crawlers); } static async Task Crawl(ConcurrentBag<CrawlingTask> bag, string crawlerName) { CrawlingTask task; while (bag.TryTake(out task)) { IEnumerable<string> urls = await GetLinksFromContent(task); if (urls != null) { foreach (var url in urls) { var t = new CrawlingTask { UrlToCrawl = url, ProducerName = crawlerName }; bag.Add(t); } } WriteLine($"Indexing url {task.UrlToCrawl} posted by " + $"{task.ProducerName} is completed by {crawlerName}!"); } } static async Task<IEnumerable<string>> GetLinksFromContent(CrawlingTask task) { await GetRandomDelay(); if (_contentEmulation.ContainsKey(task.UrlToCrawl)) return _contentEmulation[task.UrlToCrawl]; return null; } static void CreateLinks() { _contentEmulation["http://microsoft.com/"] = new [] { "http://microsoft.com/a.html", "http://microsoft.com/b.html" }; _contentEmulation["http://microsoft.com/a.html"] = new[] { "http://microsoft.com/c.html", "http://microsoft.com/d.html" }; _contentEmulation["http://microsoft.com/b.html"] = new[] { "http://microsoft.com/e.html" }; _contentEmulation["http://google.com/"] = new[] { "http://google.com/a.html", "http://google.com/b.html" }; _contentEmulation["http://google.com/a.html"] = new[] { "http://google.com/c.html", "http://google.com/d.html" }; _contentEmulation["http://google.com/b.html"] = new[] { "http://google.com/e.html", "http://google.com/f.html" }; _contentEmulation["http://google.com/c.html"] = new[] { "http://google.com/h.html", "http://google.com/i.html" }; _contentEmulation["http://facebook.com/"] = new [] { "http://facebook.com/a.html", "http://facebook.com/b.html" }; _contentEmulation["http://facebook.com/a.html"] = new[] { "http://facebook.com/c.html", "http://facebook.com/d.html" }; _contentEmulation["http://facebook.com/b.html"] = new[] { "http://facebook.com/e.html" }; _contentEmulation["http://twitter.com/"] = new[] { "http://twitter.com/a.html", "http://twitter.com/b.html" }; _contentEmulation["http://twitter.com/a.html"] = new[] { "http://twitter.com/c.html", "http://twitter.com/d.html" }; _contentEmulation["http://twitter.com/b.html"] = new[] { "http://twitter.com/e.html" }; _contentEmulation["http://twitter.com/c.html"] = new[] { "http://twitter.com/f.html", "http://twitter.com/g.html" }; _contentEmulation["http://twitter.com/d.html"] = new[] { "http://twitter.com/h.html" }; _contentEmulation["http://twitter.com/e.html"] = new[] { "http://twitter.com/i.html" }; } static Task GetRandomDelay() { int delay = new Random(DateTime.Now.Millisecond).Next(150, 200); return Task.Delay(delay); } class CrawlingTask { public string UrlToCrawl { get; set; } public string ProducerName { get; set; } } -
在
Main方法内部添加以下代码片段:CreateLinks(); Task t = RunProgram(); t.Wait(); -
运行程序。
工作原理...
该程序通过多个网络爬虫模拟网页索引。网络爬虫是一个通过地址打开网页、索引内容、尝试访问该页面包含的所有链接,并将这些链接页面也进行索引的程序。一开始,我们定义一个包含不同网页 URL 的字典。这个字典模拟包含指向其他页面链接的网页。实现非常简单;它不考虑索引已访问的页面,但简单且使我们能够专注于并发工作负载。
然后,我们创建一个包含爬虫任务的并发袋。我们创建四个爬虫,并为每个爬虫提供一个不同的站点根 URL。然后,我们等待所有爬虫完成。现在,每个爬虫开始索引分配给它的站点 URL。我们通过等待一些随机的时间来模拟网络 I/O 过程;然后,如果页面包含更多 URL,爬虫会将更多爬虫任务发布到袋中。然后,它检查袋中是否还有待爬取的任务。如果没有,爬虫就完成了。
如果我们检查位于前四行(根 URL)下面的输出,通常我们会看到,由爬虫编号N发布的任务通常由同一个爬虫处理。然而,后面的行会有所不同。这是因为内部,ConcurrentBag针对的就是这种场景,即有多个线程既添加项目又移除项目。这是通过让每个线程使用自己的本地项目队列来实现的,因此,当这个队列被占用时,我们不需要任何锁。只有当我们本地队列中没有项目时,我们才会执行一些锁定操作,并尝试从另一个线程的本地队列中“窃取”工作。这种行为有助于在所有工作者之间分配工作,并避免锁定。
使用 BlockingCollection 泛化异步处理
本食谱将描述如何使用BlockingCollection简化工作负载异步处理的实现。
准备工作
要完成这个食谱,你需要 Visual Studio 2015。不需要其他先决条件。本食谱的源代码可以在BookSamples\Chapter6\Recipe5中找到。
如何实现...
要了解BlockingCollection如何简化工作负载异步处理的实现,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Concurrent; using System.Threading.Tasks; using static System.Console; -
在
Main方法下方添加以下代码片段:static async Task RunProgram(IProducerConsumerCollection<CustomTask> collection = null) { var taskCollection = new BlockingCollection<CustomTask>(); if(collection != null) taskCollection= new BlockingCollection<CustomTask>(collection); var taskSource = Task.Run(() => TaskProducer(taskCollection)); Task[] processors = new Task[4]; for (int i = 1; i <= 4; i++) { string processorId = $"Processor {i}"; processors[i - 1] = Task.Run( () => TaskProcessor(taskCollection, processorId)); } await taskSource; await Task.WhenAll(processors); } static async Task TaskProducer(BlockingCollection<CustomTask> collection) { for (int i = 1; i <= 20; i++) { await Task.Delay(20); var workItem = new CustomTask { Id = i }; collection.Add(workItem); WriteLine($"Task {workItem.Id} has been posted"); } collection.CompleteAdding(); } static async Task TaskProcessor( BlockingCollection<CustomTask> collection, string name) { await GetRandomDelay(); foreach (CustomTask item in collection.GetConsumingEnumerable()) { WriteLine($"Task {item.Id} has been processed by {name}"); await GetRandomDelay(); } } static Task GetRandomDelay() { int delay = new Random(DateTime.Now.Millisecond).Next(1, 500); return Task.Delay(delay); } class CustomTask { public int Id { get; set; } } -
在
Main方法内部添加以下代码片段:WriteLine("Using a Queue inside of BlockingCollection"); WriteLine(); Task t = RunProgram(); t.Wait(); WriteLine(); WriteLine("Using a Stack inside of BlockingCollection"); WriteLine(); t = RunProgram(new ConcurrentStack<CustomTask>()); t.Wait(); -
运行程序。
工作原理...
在这里,我们正好采用第一种场景,但现在,我们使用一个提供许多有用功能的BlockingCollection类。首先,我们能够改变任务在阻塞集合中存储的方式。默认情况下,它使用ConcurrentQueue容器,但我们能够使用任何实现了IProducerConsumerCollection泛型接口的集合。为了说明这一点,我们运行程序两次,第二次使用ConcurrentStack作为底层集合。
工作者通过迭代阻塞集合上GetConsumingEnumerable方法调用的结果来获取工作项。如果集合中没有项,迭代器将仅阻塞工作线程,直到有项被发布到集合中。当生产者调用集合上的CompleteAdding方法时,循环结束。这表示工作已完成。
注意
很容易犯错,直接迭代BlockingCollection,因为它本身实现了IEnumerable接口。不要忘记使用GetConsumingEnumerable,否则,你将仅迭代集合的“快照”,并得到完全意外的程序行为。
工作负载生产者将任务插入BlockingCollection,然后调用CompleteAdding方法,这将导致所有工作线程完成。现在,在程序输出中,我们看到两个结果序列,说明了并发队列和堆栈集合之间的差异。
第七章:使用 PLINQ
在本章中,我们将回顾不同的并行编程范式,例如任务并行和数据并行,并介绍数据并行和并行 LINQ 查询的基础知识。你将学习以下技巧:
-
使用
Parallel类 -
并行化 LINQ 查询
-
调整 PLINQ 查询的参数
-
在 PLINQ 查询中处理异常
-
管理 PLINQ 查询中的数据分区
-
为 PLINQ 查询创建自定义聚合器
简介
在.NET Framework 中,有一组被称为 Parallel Framework 的库子集,通常被称为并行框架扩展(PFX),这是这些库的第一个版本的名字。Parallel Framework 与.NET Framework 4.0 一起发布,包括三个主要部分:
-
任务并行库(TPL)
-
并发集合
-
并行 LINQ 或 PLINQ
到目前为止,你已经学习了如何并行运行多个任务,并使它们相互同步。实际上,我们将程序分割成一组任务,不同的线程运行不同的任务。这种方法被称为任务并行,而你到目前为止只学习了任务并行。
想象一下,我们有一个程序,它在一个大数据集上执行一些复杂的计算。将这个数据集分割成更小的块,并行地在这些数据块上运行所需的计算,然后汇总这些计算的结果,这是并行化这个程序最简单的方法。这种编程模型被称为数据并行。
任务并行具有最低的抽象级别。我们将程序定义为任务的组合,明确地定义它们是如何组合的。以这种方式组成的程序可能非常复杂和详细。在这个程序的不同地方定义了并行操作,随着程序的增长,程序变得越来越难以理解和维护。这种使程序并行化的方式被称为无结构并行。如果我们有复杂的并行化逻辑,这是我们必须付出的代价。
然而,当我们有更简单的程序逻辑时,我们可以尝试将更多的并行化细节卸载到 PFX 库和 C#编译器。例如,我们可以说:“我想并行运行这三个方法,我不关心具体如何并行化;让.NET 基础设施决定细节”。这样,抽象级别就提高了,因为我们不需要提供关于我们如何并行化的详细描述。这种做法被称为结构化并行,因为并行化通常是一种声明,并且每个并行化的案例都在程序中的确切位置定义。
注意
可能会有一种印象,认为无结构的并行化是坏习惯,而应该始终使用结构化并行化。我想强调,这并不正确。结构化并行化确实更易于维护,并且在可能的情况下是首选,但它是一种不太通用的方法。一般来说,有许多情况下我们根本无法使用它,以无结构的方式使用 TPL 任务并行化是完全 OK 的。
TPL 有一个Parallel类,它提供了结构化并行的 API。这仍然是 TPL 的一部分,但我们将在本章中回顾它,因为它是从较低抽象级别到较高抽象级别的完美示例。当我们使用Parallel类 API 时,我们不需要提供我们如何分区工作的细节。然而,我们仍然需要明确定义我们如何从分区结果中生成单个结果。
PLINQ 具有最高的抽象级别。它自动将数据分成块,并决定我们是否真的需要并行化查询,或者是否使用常规的顺序查询处理会更有效。然后,PLINQ 基础设施负责合并分区的结果。程序员可以调整许多选项以优化查询并实现最佳性能和结果。
在本章中,我们将介绍Parallel类 API 的使用以及许多不同的 PLINQ 选项,例如使 LINQ 查询并行化,设置执行模式,调整 PLINQ 查询的并行度,处理查询项顺序,以及处理 PLINQ 异常。你还将学习如何管理 PLINQ 查询的数据分区。
使用 Parallel 类
这个配方展示了如何使用Parallel类 API。你将学习如何并行调用方法,如何执行并行循环,以及调整并行化机制。
准备工作
要完成这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter7\Recipe1中找到。
如何做到这一点...
要并行调用方法,执行并行循环,并使用Parallel类调整并行化机制,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Linq; using System.Threading; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static string EmulateProcessing(string taskName) { Sleep(TimeSpan.FromMilliseconds( new Random(DateTime.Now.Millisecond).Next(250, 350))); WriteLine($"{taskName} task was processed on a " + $"thread id {CurrentThread.ManagedThreadId}"); return taskName; } -
在
Main方法内添加以下代码片段:Parallel.Invoke( () => EmulateProcessing("Task1"), () => EmulateProcessing("Task2"), () => EmulateProcessing("Task3") ); var cts = new CancellationTokenSource(); var result = Parallel.ForEach( Enumerable.Range(1, 30), new ParallelOptions { CancellationToken = cts.Token, MaxDegreeOfParallelism = Environment.ProcessorCount, TaskScheduler = TaskScheduler.Default }, (i, state) => { WriteLine(i); if (i == 20) { state.Break(); WriteLine($"Loop is stopped: {state.IsStopped}"); } }); WriteLine("---"); WriteLine($"IsCompleted: {result.IsCompleted}"); WriteLine($"Lowest break iteration: {result.LowestBreakIteration}"); -
运行程序。
它是如何工作的...
这个程序演示了Parallel类的不同功能。Invoke方法允许我们以比在 TPL 中定义任务更少的麻烦并行运行多个操作。与定义任务相比,Invoke方法会阻塞其他线程,直到所有操作都完成,这是一个非常常见且方便的场景。
下一个特性是并行循环,它通过For和ForEach方法定义。我们将仔细研究ForEach,因为它与For非常相似。使用ForEach并行循环,你可以通过将操作委托应用于每个集合项来并行处理任何IEnumerable集合。我们能够提供几个选项,自定义并行化行为,并获得一个显示循环是否成功完成的输出结果。
为了调整我们的并行循环,我们将ParallelOptions类的实例传递给ForEach方法。这允许我们使用CancellationToken取消循环,限制最大并行度(可以并行运行的最大操作数),并提供一个自定义的TaskScheduler类来安排操作任务。操作可以接受一个额外的ParallelLoopState参数,这对于中断循环或检查此时循环的状态非常有用。
有两种方法可以使用此状态停止并行循环。我们可以使用Break或Stop方法。Stop方法告诉循环停止处理任何更多的工作,并将并行循环状态的IsStopped属性设置为true。Break方法在迭代之后停止,但初始的迭代将继续工作。在这种情况下,循环结果的LowestBreakIteration属性将包含Break方法被调用的最低循环迭代数。
并行化 LINQ 查询
这个配方将描述如何使用 PLINQ 使查询并行化,以及如何从并行查询返回到顺序处理。
准备工作
为了完成这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter7\Recipe2中找到。
如何操作...
为了使用 PLINQ 使查询并行化,并从并行查询返回到顺序处理,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static void PrintInfo(string typeName) { Sleep(TimeSpan.FromMilliseconds(150)); WriteLine($"{typeName} type was printed on a thread " + $"id {CurrentThread.ManagedThreadId}"); } static string EmulateProcessing(string typeName) { Sleep(TimeSpan.FromMilliseconds(150)); WriteLine($"{typeName} type was processed on a thread " + $"id {CurrentThread.ManagedThreadId}"); return typeName; } static IEnumerable<string> GetTypes() { return from assembly in AppDomain.CurrentDomain.GetAssemblies() from type in assembly.GetExportedTypes() where type.Name.StartsWith("Web") select type.Name; } -
在
Main方法中添加以下代码片段:var sw = new Stopwatch(); sw.Start(); var query = from t in GetTypes() select EmulateProcessing(t); foreach (string typeName in query) { PrintInfo(typeName); } sw.Stop(); WriteLine("---"); WriteLine("Sequential LINQ query."); WriteLine($"Time elapsed: {sw.Elapsed}"); WriteLine("Press ENTER to continue...."); ReadLine(); Clear(); sw.Reset(); sw.Start(); var parallelQuery = from t in GetTypes().AsParallel() select EmulateProcessing(t); foreach (var typeName in parallelQuery) { PrintInfo(typeName); } sw.Stop(); WriteLine("---"); WriteLine("Parallel LINQ query. The results are being merged on a single thread"); WriteLine($"Time elapsed: {sw.Elapsed}"); WriteLine("Press ENTER to continue...."); ReadLine(); Clear(); sw.Reset(); sw.Start(); parallelQuery = from t in GetTypes().AsParallel() select EmulateProcessing(t); parallelQuery.ForAll(PrintInfo); sw.Stop(); WriteLine("---"); WriteLine("Parallel LINQ query. The results are being processed in parallel"); WriteLine($"Time elapsed: {sw.Elapsed}"); WriteLine("Press ENTER to continue...."); ReadLine(); Clear(); sw.Reset(); sw.Start(); query = from t in GetTypes().AsParallel().AsSequential() select EmulateProcessing(t); foreach (string typeName in query) { PrintInfo(typeName); } sw.Stop(); WriteLine("---"); WriteLine("Parallel LINQ query, transformed into sequential."); WriteLine($"Time elapsed: {sw.Elapsed}"); WriteLine("Press ENTER to continue...."); ReadLine(); Clear(); -
运行程序。
它是如何工作的...
当程序运行时,我们创建一个 LINQ 查询,该查询使用反射 API 从当前应用程序域中加载的程序集获取所有以Web开头的类型。我们使用EmulateProcessing和PrintInfo方法模拟处理每个项和打印它的延迟。我们还使用Stopwatch类来测量每个查询的执行时间。
首先,我们运行一个普通的顺序 LINQ 查询。这里没有并行化,所以所有操作都在当前线程上运行。查询的第二版明确使用了ParallelEnumerable类。ParallelEnumerable包含 PLINQ 逻辑实现,并作为IEnumerable集合功能的扩展方法组织。通常,我们不会明确使用这个类;我们在这里使用它来展示 PLINQ 实际上是如何工作的。第二版并行运行EmulateProcessing;然而,默认情况下,结果会在单个线程上合并,所以查询执行时间应该比第一版少几秒钟。
第三版展示了如何使用AsParallel方法以声明性方式并行运行 LINQ 查询。在这里,我们并不关心实现细节,只是声明我们希望并行运行这个查询。然而,这个版本的关键区别在于我们使用了ForAll方法来输出查询结果。它将在处理每个查询项的同一线程上运行操作,跳过了结果合并步骤。这允许我们并行运行PrintInfo,并且这个版本比上一个版本运行得更快。
最后一个示例展示了如何使用AsSequential方法将 PLINQ 查询转换回顺序查询。我们可以看到这个查询的运行方式与第一个完全相同。
调整 PLINQ 查询的参数
这个配方展示了我们如何使用 PLINQ 查询来管理并行处理选项,以及这些选项在查询执行期间可能产生的影响。
准备工作
为了完成这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter7\Recipe3找到。
如何操作...
要了解如何使用 PLINQ 查询管理并行处理选项及其影响,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Generic; using System.Linq; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下面添加以下代码片段:static string EmulateProcessing(string typeName) { Sleep(TimeSpan.FromMilliseconds( new Random(DateTime.Now.Millisecond).Next(250,350))); WriteLine($"{typeName} type was processed on a thread " + $"id {CurrentThread.ManagedThreadId}"); return typeName; } static IEnumerable<string> GetTypes() { return from assembly in AppDomain.CurrentDomain.GetAssemblies() from type in assembly.GetExportedTypes() where type.Name.StartsWith("Web") orderby type.Name.Length select type.Name; } -
在
Main方法内部添加以下代码片段:var parallelQuery = from t in GetTypes().AsParallel() select EmulateProcessing(t); var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(3)); try { parallelQuery .WithDegreeOfParallelism(Environment.ProcessorCount) .WithExecutionMode(ParallelExecutionMode.ForceParallelism) .WithMergeOptions(ParallelMergeOptions.Default) .WithCancellation(cts.Token) .ForAll(WriteLine); } catch (OperationCanceledException) { WriteLine("---"); WriteLine("Operation has been canceled!"); } WriteLine("---"); WriteLine("Unordered PLINQ query execution"); var unorderedQuery = from i in ParallelEnumerable.Range(1, 30) select i; foreach (var i in unorderedQuery) { WriteLine(i); } WriteLine("---"); WriteLine("Ordered PLINQ query execution"); var orderedQuery = from i in ParallelEnumerable.Range(1, 30).AsOrdered() select i; foreach (var i in orderedQuery) { WriteLine(i); } -
运行程序。
工作原理...
程序演示了程序员可以使用的不同有用的 PLINQ 选项。我们首先创建一个 PLINQ 查询,然后创建另一个提供 PLINQ 调整的查询。
让我们先从取消操作开始。为了能够取消 PLINQ 查询,有一个WithCancellation方法,它接受一个取消令牌对象。在这里,我们在 3 秒后发出取消令牌,这导致查询中的OperationCanceledException异常,并取消剩余的工作。
然后,我们可以为查询指定一个并行度。这是将要用于执行查询的确切并行分区数。在第一个食谱中,我们使用了 Parallel.ForEach 循环,它具有最大并行度选项。它不同之处在于它指定了一个最大分区值,但如果基础设施决定使用更少的并行度以节省资源并实现最佳性能,则可能会有更少的分区。
另一个有趣的选择是使用 WithExecutionMode 方法覆盖查询执行模式。如果 PLINQ 基础设施决定并行化查询只会增加开销并且实际上会运行得更慢,它可以将一些查询以顺序模式处理。使用 WithExecutionMode,我们可以强制查询以并行方式运行。
为了调整查询结果处理,我们有 WithMergeOptions 方法。默认模式是在将结果从查询返回之前,由 PLINQ 基础设施缓冲选择的一定数量的结果。如果查询需要花费大量时间,关闭结果缓冲以尽快获取结果更为合理。
最后一个选项是 AsOrdered 方法。当我们使用并行执行时,集合中的项目顺序可能不会保留。集合中的后续项目可能会在早期项目之前处理。为了防止这种情况,我们需要在并行查询上调用 AsOrdered,以明确告诉 PLINQ 基础设施我们打算保留项目顺序以进行处理。
在 PLINQ 查询中处理异常
本食谱将描述如何在 PLINQ 查询中处理异常。
准备工作
要完成这个食谱,你需要 Visual Studio 2015。没有其他先决条件。本食谱的源代码可以在 BookSamples\Chapter7\Recipe4 中找到。
如何做到这一点...
要了解如何在 PLINQ 查询中处理异常,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Generic; using System.Linq; using static System.Console; -
在
Main方法中添加以下代码片段:IEnumerable<int> numbers = Enumerable.Range(-5, 10); var query = from number in numbers select 100 / number; try { foreach(var n in query) WriteLine(n); } catch (DivideByZeroException) { WriteLine("Divided by zero!"); } WriteLine("---"); WriteLine("Sequential LINQ query processing"); WriteLine(); var parallelQuery = from number in numbers.AsParallel() select 100 / number; try { parallelQuery.ForAll(WriteLine); } catch (DivideByZeroException) { WriteLine("Divided by zero - usual exception handler!"); } catch (AggregateException e) { e.Flatten().Handle(ex => { if (ex is DivideByZeroException) { WriteLine("Divided by zero - aggregate exception handler!"); return true; } return false; }); } WriteLine("---"); WriteLine("Parallel LINQ query processing and results merging"); -
运行程序。
它是如何工作的...
首先,我们在从 -5 到 4 的数字范围内运行一个常规 LINQ 查询。当我们除以 0 时,我们得到 DivideByZeroException,我们像往常一样在 try/catch 块中处理它。
然而,当我们使用 AsParallel 时,我们得到 AggregateException 而不是其他,因为我们现在正在并行运行,利用幕后任务基础设施。AggregateException 将包含在运行 PLINQ 查询期间发生的所有异常。要处理内部的 DivideByZeroException 类,我们使用 Flatten 和 Handle 方法,这些方法在 第五章 的 处理异步操作中的异常 食谱中进行了说明,使用 C# 6.0。
注意
很容易忘记,当我们处理聚合异常时,内部有多个异常是一个非常常见的情况。如果你忘记处理所有这些异常,异常将会冒泡,应用程序将停止工作。
在 PLINQ 查询中管理数据分区
这个配方展示了如何创建一个非常基本的自定义分区策略以特定方式并行化 LINQ 查询。
准备工作
为了完成这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter7\Recipe5找到。
如何做到这一点...
要学习如何创建一个非常基本的自定义分区策略以并行化 LINQ 查询,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static void PrintInfo(string typeName) { Sleep(TimeSpan.FromMilliseconds(150)); WriteLine($"{typeName} type was printed on a thread " + $"id {CurrentThread.ManagedThreadId}"); } static string EmulateProcessing(string typeName) { Sleep(TimeSpan.FromMilliseconds(150)); WriteLine($"{typeName} type was processed on a thread " + $"id { CurrentThread.ManagedThreadId}. Has " + $"{(typeName.Length % 2 == 0 ? "even" : "odd")} length."); return typeName; } static IEnumerable<string> GetTypes() { var types = AppDomain.CurrentDomain .GetAssemblies() .SelectMany(a => a.GetExportedTypes()); return from type in types where type.Name.StartsWith("Web") select type.Name; } public class StringPartitioner : Partitioner<string> { private readonly IEnumerable<string> _data; public StringPartitioner(IEnumerable<string> data) { _data = data; } public override bool SupportsDynamicPartitions => false; public override IList<IEnumerator<string>>GetPartitions( int partitionCount) { var result = new List<IEnumerator<string>>( partitionCount); for (int i = 1; i <= partitionCount; i++) { result.Add(CreateEnumerator(i, partitionCount)); } return result; } IEnumerator<string> CreateEnumerator(int partitionNumber, int partitionCount) { int evenPartitions = partitionCount / 2; bool isEven = partitionNumber % 2 == 0; int step = isEven ? evenPartitions : partitionCount - evenPartitions; int startIndex = partitionNumber / 2 + partitionNumber % 2; var q = _data .Where(v => !(v.Length % 2 == 0 ^ isEven) || partitionCount == 1) .Skip(startIndex - 1); return q .Where((x, i) => i % step == 0) .GetEnumerator(); } } -
在
Main方法内部添加以下代码片段:var timer = Stopwatch.StartNew(); var partitioner = new StringPartitioner(GetTypes()); var parallelQuery = from t in partitioner.AsParallel() // .WithDegreeOfParallelism(1) select EmulateProcessing(t); parallelQuery.ForAll(PrintInfo); int count = parallelQuery.Count(); timer.Stop(); WriteLine(" ----------------------- "); WriteLine($"Total items processed: {count}"); WriteLine($"Time elapsesd: {timer.Elapsed}"); -
运行程序。
它是如何工作的...
为了说明我们能够为 PLINQ 查询选择自定义的分区策略,我们创建了一个非常简单的分区器,该分区器并行处理奇数长度和偶数长度的字符串。为了实现这一点,我们使用string作为类型参数,从标准基类Partitioner<T>派生我们的自定义StringPartitioner类。
我们通过重写SupportsDynamicPartitions属性并将其设置为false来声明我们只支持静态分区。这意味着我们预定义了我们的分区策略。这是一种简单的分区初始集合的方法,但可能会根据集合内部的数据而效率低下。例如,在我们的情况下,如果我们有很多奇数长度的字符串和只有一个偶数长度的字符串,那么其中一个线程会提前完成,而不会帮助处理奇数长度的字符串。另一方面,动态分区意味着我们在运行时对初始集合进行分区,平衡工作负载在工作线程之间。
然后,我们实现GetPartitions方法,其中我们定义以下逻辑:如果只有一个分区,我们简单地处理它上面的所有内容。然而,如果我们有多个分区,那么我们在奇数分区上处理奇数长度的字符串,在偶数分区上处理偶数长度的字符串。
注意
请注意,我们需要创建与partitionCount参数中所述一样多的分区,否则我们将得到Partitioner returned a wrong number of partitions错误。
最后,我们创建我们分区器的实例,并使用它执行一个 PLINQ 查询。我们可以看到不同的线程处理奇数长度和偶数长度的字符串。此外,我们可以通过取消注释WithDegreeOfParallelism方法并更改其参数值来实验。当参数值为1时,将会有顺序的工作项处理,而当增加值时,我们可以看到更多的工作可以并行完成。
为 PLINQ 查询创建自定义聚合器
这个配方展示了如何为 PLINQ 查询创建一个自定义聚合函数。
准备工作
为了完成这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter7\Recipe6中找到。
如何操作...
要理解 PLINQ 查询的自定义聚合函数的工作原理,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static ConcurrentDictionary<char, int> AccumulateLettersInformation( ConcurrentDictionary<char, int> taskTotal , string item) { foreach (var c in item) { if (taskTotal.ContainsKey(c)) { taskTotal[c] = taskTotal[c] + 1; } else { taskTotal[c] = 1; } } WriteLine($"{item} type was aggregated on a thread " + $"id {CurrentThread.ManagedThreadId}"); return taskTotal; } static ConcurrentDictionary<char, int> MergeAccumulators( ConcurrentDictionary<char, int> total, ConcurrentDictionary<char, int> taskTotal) { foreach (var key in taskTotal.Keys) { if (total.ContainsKey(key)) { total[key] = total[key] + taskTotal[key]; } else { total[key] = taskTotal[key]; } } WriteLine("---"); WriteLine($"Total aggregate value was calculated on a thread " + $"id {CurrentThread.ManagedThreadId}"); return total; } static IEnumerable<string> GetTypes() { var types = AppDomain.CurrentDomain .GetAssemblies() .SelectMany(a => a.GetExportedTypes()); return from type in types where type.Name.StartsWith("Web") select type.Name; } -
在
Main方法内部添加以下代码片段:var parallelQuery = from t in GetTypes().AsParallel() select t; var parallelAggregator = parallelQuery.Aggregate( () => new ConcurrentDictionary<char, int>(), (taskTotal, item) => AccumulateLettersInformation(taskTotal, item), (total, taskTotal) => MergeAccumulators(total, taskTotal), total => total); WriteLine(); WriteLine("There were the following letters in type names:"); var orderedKeys = from k in parallelAggregator.Keys orderby parallelAggregator[k] descending select k; foreach (var c in orderedKeys) { WriteLine($"Letter '{c}' ---- {parallelAggregator[c]} times"); } -
运行程序。
工作原理...
在这里,我们实现了能够与 PLINQ 查询一起工作的自定义聚合机制。为了实现这一点,我们必须理解,由于查询正在由多个任务同时并行处理,我们需要提供机制来并行聚合每个任务的结果,然后将这些聚合值组合成一个单一的结果值。
在这个配方中,我们编写了一个聚合函数,用于在 PLINQ 查询中计算字母,它返回IEnumerable<string>集合。它计算每个集合项中的所有字母。为了说明并行聚合过程,我们打印出关于哪个线程处理聚合的每个部分的信息。
我们使用在ParallelEnumerable类中定义的Aggregate扩展方法来聚合 PLINQ 查询结果。它接受四个参数,每个参数都是一个执行聚合过程不同部分的功能。第一个是一个工厂,用于构建聚合器的空初始值。它也被称为种子值。
注意
注意,提供给Aggregate方法的第一个值实际上不是聚合函数的初始种子值,而是一个构造这个初始种子值的工厂方法。如果你只提供一个实例,它将在所有并行运行的分区中使用,这会导致结果不正确。
第二个函数将每个集合项聚合到分区聚合对象中。我们使用AccumulateLettersInformation方法来实现这个函数。它遍历字符串并计算其中的字母。在这里,聚合对象对于每个并行运行的查询分区都是不同的,这就是为什么我们称之为taskTotal。
第三个功能是一个高级聚合函数,它从一个分区中获取一个聚合对象并将其合并到一个全局聚合对象中。我们使用MergeAccumulators方法来实现它。最后一个函数是一个选择函数,它指定了我们从全局聚合对象中需要的确切数据。
最后,我们打印出聚合结果,按集合项中最常用的字母进行排序。
第八章。Reactive Extensions
在本章中,我们将探讨另一个有趣的 .NET 库,它帮助我们创建异步程序,即 Reactive Extensions(Rx)。我们将涵盖以下食谱:
-
将集合转换为异步
Observable -
编写自定义的
Observable -
使用
Subject类型 -
创建
Observable对象 -
使用 LINQ 查询对
Observable集合进行操作 -
使用 Rx 创建异步操作
简介
正如你已经学到的,在 .NET 和 C# 中创建异步程序有几种方法。其中之一是基于事件的异步模式,这在之前的章节中已经提到过。引入事件最初的目的是为了简化 Observer 设计模式的实现。这种模式在对象之间实现通知时很常见。
当我们讨论 Task Parallel Library 时,我们指出事件的主要缺点是它们无法有效地相互组合。另一个缺点是,基于事件的异步模式不应该用来处理通知的序列。想象一下,我们有一个 IEnumerable<string> 提供字符串值。然而,当我们迭代它时,我们不知道一次迭代将花费多少时间。它可能很慢,如果我们使用常规的 foreach 循环或其他同步迭代结构,我们将阻塞我们的线程,直到我们得到下一个值。这种情况被称为 基于拉取 的方法,当我们作为客户端从生产者拉取值时。
相反的方法是 基于推送 的方法,当生产者通知客户端有新值时。这允许将工作卸载到生产者,而客户端在等待下一个值时可以自由地做其他任何事情。因此,目标是得到类似异步版本的 IEnumerable,它产生一系列值,并在序列完成或抛出异常时通知消费者。
从 .NET Framework 4.0 版本开始,包含 IObservable<out T> 和 IObserver<in T> 接口的定义,它们一起代表异步推送式集合及其客户端。它们来自名为 Reactive Extensions(或简称 Rx)的库,该库是在微软内部创建的,以帮助我们有效地组合事件序列和所有其他类型的异步程序,使用可观察集合。这些接口已包含在 .NET Framework 中,但它们的实现和 Rx 库中的所有其他机制仍然分别分发。
注意
Rx 全局是一个跨平台库。它提供了 .NET 3.5、Silverlight 和 Windows Phone 的库。它还支持 JavaScript、Ruby 和 Python。它也是开源的;你可以在 CodePlex 网站上找到 .NET 的 Reactive Extensions 的源代码,以及其他实现可以在 GitHub 上找到。
最令人惊讶的是,可观察集合与 LINQ 兼容,因此我们可以使用声明性查询以异步方式转换和组合这些集合。这也使得我们可以使用扩展方法以与常规 LINQ 提供程序相同的方式向 Rx 程序添加功能。反应式扩展还支持从所有异步编程模式(包括异步编程模型、基于事件的异步模式和任务并行库)过渡到可观察集合,并支持其自己的异步操作运行方式,这与 TPL 仍然非常相似。
反应式扩展库是一个非常强大且复杂的工具,值得单独写一本书。在本章中,我想回顾最有用的场景,即如何有效地处理异步事件序列。我们将观察反应式扩展框架的关键类型,学习以不同的方式创建序列并操作它们,最后检查我们如何使用反应式扩展来运行异步操作并管理它们的选项。
将集合转换为异步可观察对象
本食谱将指导您如何从Enumerable类创建可观察集合以及如何异步处理它。
准备工作
为了完成这个食谱,您将需要 Visual Studio 2015。不需要其他先决条件。本食谱的源代码可以在BookSamples\Chapter8\Recipe1中找到。
如何做...
要了解如何从Enumerable类创建可观察集合并异步处理它,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
通过以下步骤添加对反应式扩展主库NuGet 包的引用:
-
右键单击项目中的引用文件夹,并选择管理 NuGet 包…菜单选项。
-
现在,添加反应式扩展 - 主库NuGet 包。您可以在管理 NuGet 包对话框中搜索rx-main,如图所示:
![如何做...]()
-
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Generic; using System.Reactive.Concurrency; using System.Reactive.Linq; using System.Threading; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static IEnumerable<int> EnumerableEventSequence() { for (int i = 0; i < 10; i++) { Sleep(TimeSpan.FromSeconds(0.5)); yield return i; } } -
在
Main方法内部添加以下代码片段:foreach (int i in EnumerableEventSequence()) { Write(i); } WriteLine(); WriteLine("IEnumerable"); IObservable<int> o = EnumerableEventSequence().().ToObservable(); using (IDisposable subscription = o.Subscribe(Write)) { WriteLine(); WriteLine("IObservable"); } o = EnumerableEventSequence().ToObservable() .SubscribeOn(TaskPoolScheduler.Default); using (IDisposable subscription = o.Subscribe(Write)) { WriteLine(); WriteLine("IObservable async"); ReadLine(); } -
运行程序。
它是如何工作的...
在这里,我们使用EnumerableEventSequence方法模拟一个缓慢的可枚举集合。然后,我们使用常规的foreach循环迭代它,我们可以看到它实际上很慢;我们等待每个迭代完成。
我们随后使用 Reactive Extensions 库中的 ToObservable 扩展方法将这个可枚举集合转换为 Observable。接下来,我们订阅这个 Observable 集合的更新,提供 Console.Write 方法作为操作,该操作将在集合的每次更新时执行。因此,我们得到与之前完全相同的行为;我们等待每个迭代完成,因为我们使用主线程来订阅更新。
注意
我们将订阅对象包装在 using 语句中。尽管这不总是必要的,但销毁订阅是一个良好的实践,这有助于您避免与生命周期相关的错误。
要使程序异步,我们使用 SubscribeOn 方法,向其提供 TPL 任务池调度器。这个调度器会将订阅放置在 TPL 任务池中,从而将工作从主线程卸载。这允许我们保持 UI 响应,并在集合更新时做其他事情。要检查这种行为,您可以从代码中移除最后的 Console.ReadLine 调用。这样做时,我们将立即完成主线程,这迫使所有后台线程(包括 TPL 任务池工作线程)结束,并且我们将从异步集合中得不到任何输出。
如果我们使用 UI 框架,我们必须仅在 UI 线程内与 UI 控件交互。为了实现这一点,我们应该使用带有相应调度器的 ObserveOn 方法。对于 Windows Presentation Foundation,我们有 DispatcherScheduler 类和定义在名为 Rx-XAML 或 Reactive Extensions XAML 支持库的单独 NuGet 包中的 ObserveOnDispatcher 扩展方法。对于其他平台,也有相应的单独 NuGet 包。
编写自定义的 Observable
这个菜谱将描述如何实现 IObservable<in T> 和 IObserver<out T> 接口以获取自定义的 Observable 序列并正确消费它。
准备工作
要逐步实现这个菜谱,您需要 Visual Studio 2015。没有其他先决条件。这个菜谱的源代码可以在 BookSamples\Chapter8\Recipe2 中找到。
如何实现...
要了解如何实现 IObservable<in T> 和 IObserver<out T> 接口以获取自定义的 Observable 序列并消费它,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
添加对 Reactive Extensions 主库 NuGet 包的引用。有关如何操作的更多详细信息,请参阅 将集合转换为异步 Observable 菜谱。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Generic; using System.Reactive.Concurrency; using System.Reactive.Disposables; using System.Reactive.Linq; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:class CustomObserver : IObserver<int> { public void OnNext(int value) { WriteLine($"Next value: {value}; Thread Id: {CurrentThread.ManagedThreadId}"); } public void OnError(Exception error) { WriteLine($"Error: {error.Message}"); } public void OnCompleted() { WriteLine("Completed"); } } class CustomSequence : IObservable<int> { private readonly IEnumerable<int> _numbers; public CustomSequence(IEnumerable<int> numbers) { _numbers = numbers; } public IDisposable Subscribe(IObserver<int> observer) { foreach (var number in _numbers) { observer.OnNext(number); } observer.OnCompleted(); return Disposable.Empty; } } -
在
Main方法内添加以下代码片段:var observer = new CustomObserver(); var goodObservable = new CustomSequence(new[] {1, 2, 3, 4, 5}); var badObservable = new CustomSequence(null); using (IDisposable subscription = goodObservable.Subscribe(observer)) { } using (IDisposable subscription = goodObservable .SubscribeOn(TaskPoolScheduler.Default).Subscribe(observer)) { Sleep(TimeSpan.FromMilliseconds(100)); WriteLine("Press ENTER to continue"); ReadLine(); } using (IDisposable subscription = badObservable .SubscribeOn(TaskPoolScheduler.Default).Subscribe(observer)) { Sleep(TimeSpan.FromMilliseconds(100)); WriteLine("Press ENTER to continue"); ReadLine(); } -
运行程序。
它是如何工作的...
在这里,我们首先通过简单地向控制台打印出可观察集合中下一个项目的信息、错误或序列完成信息来实现我们的观察者。这是一段非常简单的消费者代码,并没有什么特别之处。
有趣的部分是我们的可观察集合实现。我们故意在构造函数中接受一个数字枚举,并且不检查它是否为空。当我们有一个订阅观察者时,我们迭代这个集合,并通知观察者枚举中的每个项目。
然后,我们演示实际的订阅。正如我们所见,异步是通过调用 SubscribeOn 方法实现的,这是一个对 IObservable 的扩展方法,包含异步订阅逻辑。我们并不关心我们的可观察集合中的异步性;我们使用来自 Reactive Extensions 库的标准实现。
当我们订阅正常的可观察集合时,我们只是从中获取所有项目。现在它是异步的,因此我们需要等待一段时间,直到异步操作完成,然后打印消息并等待用户输入。
最后,我们尝试订阅下一个可观察集合,其中我们正在迭代一个空枚举,因此得到一个空引用异常。我们看到异常已经被正确处理,并且执行了 OnError 方法来打印错误详情。
使用 Subject 类型家族
这个菜谱展示了如何使用 Reactive Extensions 库中的 Subject 类型家族。
准备工作
为了完成这个菜谱,你需要 Visual Studio 2015。没有其他先决条件。这个菜谱的源代码可以在 BookSamples\Chapter8\Recipe3 中找到。
如何做到这一点...
要理解从 Reactive Extensions 库中 Subject 类型家族的使用,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
添加对 Reactive Extensions 主库 NuGet 包的引用。有关如何操作的详细信息,请参阅 将集合转换为异步可观察对象 菜谱。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Reactive.Subjects; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static IDisposable OutputToConsole<T>(IObservable<T> sequence) { return sequence.Subscribe( obj => WriteLine($"{obj}") , ex => WriteLine($"Error: {ex.Message}") , () => WriteLine("Completed") ); } -
在
Main方法内部添加以下代码片段:WriteLine("Subject"); var subject = new Subject<string>(); subject.OnNext("A"); using (var subscription = OutputToConsole(subject)) { subject.OnNext("B"); subject.OnNext("C"); subject.OnNext("D"); subject.OnCompleted(); subject.OnNext("Will not be printed out"); } WriteLine("ReplaySubject"); var replaySubject = new ReplaySubject<string>(); replaySubject.OnNext("A"); using (var subscription = OutputToConsole(replaySubject)) { replaySubject.OnNext("B"); replaySubject.OnNext("C"); replaySubject.OnNext("D"); replaySubject.OnCompleted(); } WriteLine("Buffered ReplaySubject"); var bufferedSubject = new ReplaySubject<string>(2); bufferedSubject.OnNext("A"); bufferedSubject.OnNext("B"); bufferedSubject.OnNext("C"); using (var subscription = OutputToConsole(bufferedSubject)) { bufferedSubject.OnNext("D"); bufferedSubject.OnCompleted(); } WriteLine("Time window ReplaySubject"); var timeSubject = new ReplaySubject<string>(TimeSpan.FromMilliseconds(200)); timeSubject.OnNext("A"); Sleep(TimeSpan.FromMilliseconds(100)); timeSubject.OnNext("B"); Sleep(TimeSpan.FromMilliseconds(100)); timeSubject.OnNext("C"); Sleep(TimeSpan.FromMilliseconds(100)); using (var subscription = OutputToConsole(timeSubject)) { Sleep(TimeSpan.FromMilliseconds(300)); timeSubject.OnNext("D"); timeSubject.OnCompleted(); } WriteLine("AsyncSubject"); var asyncSubject = new AsyncSubject<string>(); asyncSubject.OnNext("A"); using (var subscription = OutputToConsole(asyncSubject)) { asyncSubject.OnNext("B"); asyncSubject.OnNext("C"); asyncSubject.OnNext("D"); asyncSubject.OnCompleted(); } WriteLine("BehaviorSubject"); var behaviorSubject = new BehaviorSubject<string>("Default"); using (var subscription = OutputToConsole(behaviorSubject)) { behaviorSubject.OnNext("B"); behaviorSubject.OnNext("C"); behaviorSubject.OnNext("D"); behaviorSubject.OnCompleted(); } -
运行程序。
它是如何工作的...
在这个程序中,我们查看 Subject 类型家族的不同变体。Subject 类型代表 IObservable 和 IObserver 的实现。这在不同的代理场景中非常有用,当我们想要将多个源的事件转换为单个流,或者相反,向多个订阅者广播事件序列时。主题对于在 Reactive Extensions 中进行实验也非常方便。
让我们从基本的 Subject 类型开始。它会在订阅者订阅后立即将事件序列重新翻译给订阅者。在我们的例子中,A 字符串将不会被打印出来,因为订阅发生在它被传输之后。除此之外,当我们对 Observable 调用 OnCompleted 或 OnError 方法时,它将停止进一步的事件序列翻译,所以最后一个字符串也不会被打印出来。
下一个类型,ReplaySubject,非常灵活,允许我们实现三个额外的场景。首先,它可以缓存从广播开始的所有事件,如果我们稍后订阅,我们将首先接收到所有先前的事件。这种行为在第二个例子中得到了说明。在这里,我们将在控制台上有所有四个字符串,因为第一个事件将被缓存并翻译给后来的订阅者。
然后,我们可以指定 ReplaySubject 的缓冲区大小和时间窗口大小。在下一个例子中,我们将主题设置为有两个事件缓冲。如果有更多事件被广播,只有最后两个将被重新翻译给订阅者。所以在这里,我们不会看到第一个字符串,因为我们订阅时主题缓冲区中有 B 和 C。情况与时间窗口相同。我们可以指定 Subject 类型只缓存发生时间在某个时间之前的所有事件,丢弃较旧的事件。因此,在第四个例子中,我们只会看到最后两个事件;较旧的事件不适合时间窗口。
AsyncSubject 类型类似于 TPL 中的 Task 类型。它代表一个单一的后台操作。如果有多个事件被发布,它将等待事件序列完成,并只将最后一个事件提供给订阅者。
BehaviorSubject 类型与 ReplaySubject 类型非常相似,但它只缓存一个值,并允许我们指定一个默认值,以防我们没有发送任何通知。在我们的最后一个例子中,我们会看到所有字符串都被打印出来,因为我们提供了一个默认值,并且所有其他事件都在订阅之后发生。如果我们把 behaviorSubject.OnNext("B"); 这行代码向上移动到 Default 事件下面,它将替换输出中的默认值。
创建一个 Observable 对象
这个配方将描述创建 Observable 对象的不同方法。
准备工作
为了完成这个配方,你需要 Visual Studio 2015。不需要其他先决条件。这个配方的源代码可以在 BookSamples\Chapter8\Recipe4 中找到。
如何做到这一点...
要了解创建 Observable 对象的不同方法,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
添加对 Reactive Extensions Main Library NuGet 包的引用。有关如何操作的详细信息,请参阅 将集合转换为异步 Observable 配方。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Reactive.Disposables; using System.Reactive.Linq; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static IDisposable OutputToConsole<T>(IObservable<T> sequence) { return sequence.Subscribe( obj => WriteLine("{0}", obj) , ex => WriteLine("Error: {0}", ex.Message) , () => WriteLine("Completed") ); } -
在
Main方法内添加以下代码片段:IObservable<int> o = Observable.Return(0); using (var sub = OutputToConsole(o)); WriteLine(" ---------------- "); o = Observable.Empty<int>(); using (var sub = OutputToConsole(o)); WriteLine(" ---------------- "); o = Observable.Throw<int>(new Exception()); using (var sub = OutputToConsole(o)); WriteLine(" ---------------- "); o = Observable.Repeat(42); using (var sub = OutputToConsole(o.Take(5))); WriteLine(" ---------------- "); o = Observable.Range(0, 10); using (var sub = OutputToConsole(o)); WriteLine(" ---------------- "); o = Observable.Create<int>(ob => { for (int i = 0; i < 10; i++) { ob.OnNext(i); } return Disposable.Empty; }); using (var sub = OutputToConsole(o)) ; WriteLine(" ---------------- "); o = Observable.Generate( 0 // initial state , i => i < 5 // while this is true we continue the sequence , i => ++i // iteration , i => i*2 // selecting result ); using (var sub = OutputToConsole(o)); WriteLine(" ---------------- "); IObservable<long> ol = Observable.Interval(TimeSpan.FromSeconds(1)); using (var sub = OutputToConsole(ol)) { Sleep(TimeSpan.FromSeconds(3)); }; WriteLine(" ---------------- "); ol = Observable.Timer(DateTimeOffset.Now.AddSeconds(2)); using (var sub = OutputToConsole(ol)) { Sleep(TimeSpan.FromSeconds(3)); }; WriteLine(" ---------------- "); -
运行程序。
它是如何工作的...
在这里,我们探讨了创建 可观察 对象的不同场景。大部分这个功能都是作为 Observable 类型的静态工厂方法提供的。前两个示例展示了我们可以创建一个产生单个值的 Observable 方法和一个不产生任何值的 Observable 方法。在下一个示例中,我们使用 Observable.Throw 来构建一个触发其观察者 OnError 处理器的 Observable 类。
Observable.Repeat 方法表示一个无限序列。这个方法有不同的重载形式;在这里,我们通过重复 42 个值来构建一个无限序列。然后,我们使用 LINQ 的 Take 方法从这个序列中取出五个元素。Observable.Range 表示一系列值,与 Enumerable.Range 类似。
Observable.Create 方法支持更多自定义场景。有许多重载允许我们使用取消令牌和任务,但让我们看看最简单的一个。它接受一个函数,该函数接受观察者实例并返回一个表示订阅的 IDisposable 对象。如果我们有任何资源需要清理,我们就可以在这里提供清理逻辑,但因为我们实际上不需要它,所以我们只返回一个空的可丢弃对象。
Observable.Generate 方法是创建自定义序列的另一种方式。我们必须提供一个序列的初始值,然后提供一个确定是否生成更多项或完成序列的谓词。然后,我们提供一个迭代逻辑,在我们的例子中是增加计数器。最后一个参数是一个选择函数,它允许我们自定义结果。
最后两个方法处理计时器。Observable.Interval 以 TimeSpan 期间开始产生计时器滴答事件,而 Observable.Timer 指定启动时间。
使用 LINQ 查询对可观察集合进行查询
这个菜谱展示了如何使用 LINQ 查询异步事件序列。
准备工作
要完成这个菜谱,你需要 Visual Studio 2015。没有其他先决条件。这个菜谱的源代码可以在 BookSamples\Chapter8\Recipe5 找到。
如何操作...
要理解对可观察集合使用 LINQ 查询的使用,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
添加对 Reactive Extensions Main Library NuGet 包的引用。有关如何操作的详细信息,请参阅 将集合转换为异步可观察序列 菜谱。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Reactive.Linq; using static System.Console; -
在
Main方法下方添加以下代码片段:static IDisposable OutputToConsole<T>(IObservable<T> sequence, int innerLevel) { string delimiter = innerLevel == 0 ? string.Empty : new string('-', innerLevel*3); return sequence.Subscribe( obj => WriteLine($"{delimiter}{obj}") , ex => WriteLine($"Error: {ex.Message}") , () => WriteLine($"{delimiter}Completed") ); } -
在
Main方法内添加以下代码片段:IObservable<long> sequence = Observable.Interval( TimeSpan.FromMilliseconds(50)).Take(21); var evenNumbers = from n in sequence where n % 2 == 0 select n; var oddNumbers = from n in sequence where n % 2 != 0 select n; var combine = from n in evenNumbers.Concat(oddNumbers) select n; var nums = (from n in combine where n % 5 == 0 select n) .Do(n => WriteLine($"------Number {n} is processed in Do method")); using (var sub = OutputToConsole(sequence, 0)) using (var sub2 = OutputToConsole(combine, 1)) using (var sub3 = OutputToConsole(nums, 2)) { WriteLine("Press enter to finish the demo"); ReadLine(); } -
运行程序。
它是如何工作的...
能够使用 LINQ 对 Observable 事件序列进行操作是 Reactive Extensions 框架的主要优势。还有很多不同的有用场景;不幸的是,在这里展示所有这些场景是不可能的。我试图提供一个简单但非常具有说明性的例子,它没有太多复杂细节,展示了 LINQ 查询在应用于异步可观察集合时的工作原理。
首先,我们创建一个 Observable 事件,它生成一个数字序列,每 50 毫秒一个数字,我们从初始值零开始,取 21 个这样的事件。然后,我们将 LINQ 查询组合到这个序列中。首先,我们只选择序列中的偶数,然后只选择奇数。然后,我们将这两个序列连接起来。
最后的查询展示了如何使用一个非常有用的方法 Do,它允许我们引入副作用,例如,记录结果序列中的每个值。要运行所有查询,我们创建嵌套的订阅,由于序列最初是异步的,我们必须非常小心订阅的生存期。外部作用域代表对计时器的订阅,内部订阅分别处理组合序列查询和副作用查询。如果我们太早按下 Enter,我们只是取消对计时器的订阅,从而停止演示。
当我们运行演示时,我们可以看到不同查询如何实时交互的实际过程。我们可以看到我们的查询是懒加载的,并且只有在订阅其结果时才开始运行。计时器事件的序列打印在第一列。当偶数查询得到一个偶数时,它也会打印出来,并使用 --- 前缀来区分这个序列结果和第一个。最终的查询结果打印在右侧列。
当程序运行时,我们可以看到计时器序列、偶数序列和副作用序列是并行运行的。只有连接操作等待偶数序列完成。如果我们不连接这些序列,我们将有四个并行的事件序列以最有效的方式相互交互!这展示了 Reactive Extensions 的真正力量,并且可以作为一个深入学习这个库的好起点。
使用 Rx 创建异步操作
这个配方展示了如何从其他编程模式定义的异步操作中创建 Observable。
准备工作
为了完成这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在 BookSamples\Chapter8\Recipe6 找到。
如何操作...
要了解如何使用 Rx 创建异步操作,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
添加对响应式扩展主库NuGet 包的引用。有关如何操作的详细信息,请参阅将集合转换为异步可观察对象配方。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Reactive; using System.Reactive.Linq; using System.Reactive.Threading.Tasks; using System.Threading.Tasks; using System.Timers; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static async Task<T> AwaitOnObservable<T>(IObservable<T> observable) { T obj = await observable; WriteLine($"{obj}" ); return obj; } static Task<string> LongRunningOperationTaskAsync(string name) { return Task.Run(() => LongRunningOperation(name)); } static IObservable<string> LongRunningOperationAsync(string name) { return Observable.Start(() => LongRunningOperation(name)); } static string LongRunningOperation(string name) { Sleep(TimeSpan.FromSeconds(1)); return $"Task {name} is completed. Thread Id {CurrentThread.ManagedThreadId}"; } static IDisposable OutputToConsole(IObservable<EventPattern<ElapsedEventArgs>> sequence) { return sequence.Subscribe( obj => WriteLine($"{obj.EventArgs.SignalTime}") , ex => WriteLine($"Error: {ex.Message}") , () => WriteLine("Completed") ); } static IDisposable OutputToConsole<T>(IObservable<T> sequence) { return sequence.Subscribe( obj => WriteLine("{0}", obj) , ex => WriteLine("Error: {0}", ex.Message) , () => WriteLine("Completed") ); } -
将
Main方法替换为以下代码片段:delegate string AsyncDelegate(string name); static void Main(string[] args) { IObservable<string> o = LongRunningOperationAsync("Task1"); using (var sub = OutputToConsole(o)) { Sleep(TimeSpan.FromSeconds(2)); }; WriteLine(" ---------------- "); Task<string> t = LongRunningOperationTaskAsync("Task2"); using (var sub = OutputToConsole(t.ToObservable())) { Sleep(TimeSpan.FromSeconds(2)); }; WriteLine(" ---------------- "); AsyncDelegate asyncMethod = LongRunningOperation; // marked as obsolete, use tasks instead Func<string, IObservable<string>> observableFactory = Observable.FromAsyncPattern<string, string>( asyncMethod.BeginInvoke, asyncMethod.EndInvoke); o = observableFactory("Task3"); using (var sub = OutputToConsole(o)) { Sleep(TimeSpan.FromSeconds(2)); }; WriteLine(" ---------------- "); o = observableFactory("Task4"); AwaitOnObservable(o).Wait(); WriteLine(" ---------------- "); using (var timer = new Timer(1000)) { var ot = Observable. FromEventPattern<ElapsedEventHandler, ElapsedEventArgs>( h => timer.Elapsed += h, h => timer.Elapsed -= h); timer.Start(); using (var sub = OutputToConsole(ot)) { Sleep(TimeSpan.FromSeconds(5)); } WriteLine(" ---------------- "); timer.Stop(); } -
运行程序。
它是如何工作的...
这个配方展示了如何将不同类型的异步操作转换为Observable类。第一个代码片段使用了Observable.Start方法,这与 TPL 中的Task.Run非常相似。它启动了一个异步操作,该操作输出一个字符串结果,然后完成。
注意
我强烈建议您使用任务并行库(Task Parallel Library,简称 TPL)进行异步操作。响应式扩展(Reactive Extensions)也支持这种场景,但为了避免歧义,在讨论单独的异步操作时坚持使用任务会更好,只有在我们需要处理事件序列时才使用 Rx。另一个建议是将所有类型的单独异步操作转换为任务,然后如果需要,再将任务转换为可观察类(Observable class)。
然后,我们用同样的方法处理任务,通过简单地调用ToObservable扩展方法将任务转换为Observable方法。下一个代码片段是关于将异步编程模型(Asynchronous Programming Model,简称 APM)转换为Observable。通常,你会将 APM 转换为任务,然后将任务转换为Observable。然而,存在一种直接转换,这个例子说明了如何运行异步委托并将其包装为Observable操作。
代码片段的下一部分展示了我们能够在Observable操作中使用await运算符。由于我们无法在如Main这样的入口方法上使用async修饰符,我们引入了一个单独的方法,该方法返回一个任务,并在Main方法内部等待这个结果任务的完成。
代码片段的最后部分与将 APM 模式转换为Observable的代码相同,但现在,我们将基于事件的异步模式(Event-based Asynchronous Pattern,简称 EAP)直接转换为Observable类。我们创建了一个计时器,并消耗其 5 秒的事件。然后,我们销毁计时器以清理资源。
第九章。使用异步 I/O
在本章中,我们将详细回顾异步 I/O 操作。你将学习以下技巧:
-
异步处理文件
-
编写异步 HTTP 服务器和客户端
-
异步处理数据库
-
异步调用 WCF 服务
简介
在前面的章节中,我们已经讨论了正确使用异步 I/O 操作的重要性。为什么这很重要?为了有一个坚实的理解,让我们考虑两种类型的应用程序。
当我们在客户端运行应用程序时,最重要的事情之一是拥有一个响应式的用户界面。这意味着无论应用程序发生什么,所有的用户界面元素,如按钮和进度条,都保持快速运行,用户能够立即从应用程序中得到反应。这并不容易实现!如果你尝试在 Windows 中打开记事本文本编辑器,并尝试加载一个几兆字节的文本文件,应用程序窗口将冻结一段时间,因为整个文本首先是从磁盘加载的,然后程序才开始处理用户输入。
这是一个极其重要的问题,在这种情况下,唯一的解决方案是无论如何都要避免阻塞 UI 线程。这反过来又意味着为了防止阻塞 UI 线程,每个与 UI 相关的 API 必须只允许异步调用。这正是 Windows 8 操作系统重新设计 API 的关键原因,几乎用异步类似物替换了所有方法。但是,如果我们的应用程序使用多个线程来实现这个目标,这会影响性能吗?当然,会的!然而,考虑到我们只有一个用户,我们可能需要付出这个代价。让应用程序利用计算机的全部力量来提高效率是很好的,因为所有这些力量都是为了运行应用程序的单个用户。
让我们来看第二种情况。如果我们在一个服务器上运行应用程序,我们会遇到完全不同的状况。我们将可扩展性作为首要任务,这意味着单个用户应该尽可能少地消耗资源。如果我们为每个用户创建许多线程,我们根本无法很好地进行扩展。在高效地平衡我们的应用程序资源消耗方面,这是一个非常复杂的问题。例如,在微软的 Web 应用程序平台 ASP.NET 中,我们使用一个工作线程池来处理客户端请求。这个池中有限数量的工作线程,我们必须最小化每个工作线程的使用时间以实现可扩展性。这意味着我们必须尽快将其返回到池中,以便它可以处理另一个请求。如果我们启动一个需要计算的异步操作,我们将有一个非常低效的工作流程。首先,我们从线程池中取出一个工作线程来处理客户端请求。然后,我们再取出另一个工作线程并在其上启动一个异步操作。现在,我们有两个工作线程在处理我们的请求,但我们实际上只需要第一个线程做些有用的事情!遗憾的是,常见的情况是我们简单地等待异步操作完成,我们消耗了两个工作线程而不是一个。在这种情况下,异步操作实际上比同步执行还要差!我们不需要加载所有 CPU 核心,因为我们已经服务了许多客户端,因此正在使用所有的 CPU 计算能力。我们不需要保持第一个线程的响应性,因为我们没有用户界面。那么,为什么在服务器应用程序中要使用异步操作呢?
答案是,当存在异步 I/O 操作时,我们应该使用异步操作。今天,现代计算机通常有一个硬盘驱动器用于存储文件,以及一个网络卡用于在网络中发送和接收数据。这两个设备都有自己的微计算机,它们在非常低级别上管理 I/O 操作并向操作系统报告结果。这又是一个相当复杂的话题;但为了保持概念清晰,我们可以这样说,程序员有办法启动一个 I/O 操作,并提供给操作系统一段代码,当操作完成时由操作系统回调。在启动 I/O 任务和其完成之间,没有涉及 CPU 工作;这是在相应的磁盘和网络控制器微计算机上完成的。这种方式执行 I/O 任务被称为 I/O 线程;它们使用.NET 线程池实现,并最终使用操作系统提供的 I/O 完成端口基础设施。
在 ASP.NET 中,一旦从工作线程开始异步 I/O 操作,它就可以立即返回到线程池!在操作进行时,此线程可以服务其他客户端。最后,当操作发出完成信号时,ASP.NET 基础设施从线程池中获取一个空闲的工作线程(可能不同于启动操作的那个线程),并完成操作。
好吧;我们现在已经理解了 I/O 线程对于服务器应用程序的重要性。不幸的是,检查任何给定的 API 是否在底层使用 I/O 线程非常困难。除了研究源代码之外,唯一的方法就是简单地知道哪些 .NET Framework 类库利用了 I/O 线程。在本章中,我们将看到如何使用这些 API 之一。你将学习如何异步地处理文件,如何使用网络 I/O 创建 HTTP 服务器并调用 Windows Communication Foundation (WCF) 服务,以及如何使用异步 API 查询数据库。
注意
另一个需要考虑的重要问题是并行性。由于许多原因,密集的并行磁盘操作可能性能非常差。请注意,并行 I/O 操作通常非常无效,可能合理地以顺序方式处理 I/O,但以异步方式进行。
异步处理文件
这个示例指导我们如何创建文件以及如何异步地读取和写入数据。
准备工作
为了逐步执行此示例,你需要 Visual Studio 2015。没有其他先决条件。此示例的源代码可以在 BookSamples\Chapter9\Recipe1 中找到。
如何做...
为了了解如何异步处理文件,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; using static System.Console; using static System.Text.Encoding; -
在
Main方法下方添加以下代码片段:const int BUFFER_SIZE = 4096; static async Task ProcessAsynchronousIO() { using (var stream = new FileStream( "test1.txt", FileMode.Create, FileAccess.ReadWrite, FileShare.None, BUFFER_SIZE)) { WriteLine($"1\. Uses I/O Threads: {stream.IsAsync}"); byte[] buffer = UTF8.GetBytes(CreateFileContent()); var writeTask = Task.Factory.FromAsync( stream.BeginWrite, stream.EndWrite, buffer, 0, buffer.Length, null); await writeTask; } using (var stream = new FileStream("test2.txt", FileMode.Create, FileAccess.ReadWrite,FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous)) { WriteLine($"2\. Uses I/O Threads: {stream.IsAsync}"); byte[] buffer = UTF8.GetBytes(CreateFileContent()); var writeTask = Task.Factory.FromAsync( stream.BeginWrite, stream.EndWrite, buffer, 0, buffer.Length, null); await writeTask; } using (var stream = File.Create("test3.txt", BUFFER_SIZE, FileOptions.Asynchronous)) using (var sw = new StreamWriter(stream)) { WriteLine($"3\. Uses I/O Threads: {stream.IsAsync}"); await sw.WriteAsync(CreateFileContent()); } using (var sw = new StreamWriter("test4.txt", true)) { WriteLine($"4\. Uses I/O Threads: {((FileStream)sw.BaseStream).IsAsync}"); await sw.WriteAsync(CreateFileContent()); } WriteLine("Starting parsing files in parallel"); var readTasks = new Task<long>[4]; for (int i = 0; i < 4; i++) { string fileName = $"test{i + 1}.txt"; readTasks[i] = SumFileContent(fileName); } long[] sums = await Task.WhenAll(readTasks); WriteLine($"Sum in all files: {sums.Sum()}"); WriteLine("Deleting files..."); Task[] deleteTasks = new Task[4]; for (int i = 0; i < 4; i++) { string fileName = $"test{i + 1}.txt"; deleteTasks[i] = SimulateAsynchronousDelete(fileName); } await Task.WhenAll(deleteTasks); WriteLine("Deleting complete."); } static string CreateFileContent() { var sb = new StringBuilder(); for (int i = 0; i < 100000; i++) { sb.Append($"{new Random(i).Next(0, 99999)}"); sb.AppendLine(); } return sb.ToString(); } static async Task<long> SumFileContent(string fileName) { using (var stream = new FileStream(fileName, FileMode.Open, FileAccess.Read,FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous)) using (var sr = new StreamReader(stream)) { long sum = 0; while (sr.Peek() > -1) { string line = await sr.ReadLineAsync(); sum += long.Parse(line); } return sum; } } static Task SimulateAsynchronousDelete(string fileName) { return Task.Run(() => File.Delete(fileName)); } -
在
Main方法内添加以下代码片段:var t = ProcessAsynchronousIO(); t.GetAwaiter().GetResult(); -
运行程序。
它是如何工作的...
当程序运行时,我们以不同的方式创建四个文件并将它们填充随机数据。在第一种情况下,我们使用 FileStream 类及其方法,将异步编程模型 API 转换为任务;在第二种情况下,我们做同样的事情,但向 FileStream 构造函数提供 FileOptions.Asynchronous。
注意
使用 FileOptions.Asynchronous 选项非常重要。如果我们省略此选项,我们仍然可以以异步方式处理文件,但这只是线程池上的异步委托调用!只有当我们提供此选项(或在另一个构造函数重载中提供 bool useAsync)时,我们才使用 FileStream 类的 I/O 异步。
第三个案例使用一些简化的 API,例如File.Create方法和StreamWriter类。它仍然使用 I/O 线程,我们可以使用stream.IsAsync属性来检查。最后一个案例说明了过度简化也是不好的。在这里,我们没有利用 I/O 异步性,而是通过异步委托调用来模拟它。
现在,我们执行从文件的并行异步读取,汇总其内容,然后将其与彼此相加。最后,我们删除所有文件。由于在任何非 Windows 商店应用程序中都没有异步删除文件,我们使用Task.Run工厂方法来模拟异步性。
编写异步 HTTP 服务器和客户端
这个配方展示了如何创建一个简单的异步 HTTP 服务器。
准备工作
要逐步完成这个配方,你需要 Visual Studio 2015。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter9\Recipe2中找到。
如何做...
以下步骤演示了如何创建一个简单的异步 HTTP 服务器:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
添加对
System.Net.Http框架库的引用。 -
在
Program.cs文件中,添加以下using指令:using System; using System.IO; using System.Net; using System.Net.Http; using System.Threading.Tasks; using static System.Console; -
在
Main方法下方添加以下代码片段:static async Task GetResponseAsync(string url) { using (var client = new HttpClient()) { HttpResponseMessage responseMessage = await client.GetAsync(url); string responseHeaders = responseMessage.Headers.ToString(); string response = await responseMessage.Content.ReadAsStringAsync(); WriteLine("Response headers:"); WriteLine(responseHeaders); WriteLine("Response body:"); WriteLine(response); } } class AsyncHttpServer { readonly HttpListener _listener; const string RESPONSE_TEMPLATE = "<html><head><title>Test</title></head><body><h2>Testpage</h2>" + "<h4>Today is: {0}</h4></body></html>"; public AsyncHttpServer(int portNumber) { _listener = new HttpListener(); _listener.Prefixes.Add($"http://localhost:{portNumber}/"); } public async Task Start() { _listener.Start(); while (true) { var ctx = await _listener.GetContextAsync(); WriteLine("Client connected..."); var response = string.Format(RESPONSE_TEMPLATE, DateTime.Now); using (var sw = new StreamWriter(ctx.Response.OutputStream)) { await sw.WriteAsync(response); await sw.FlushAsync(); } } } public async Task Stop() { _listener.Abort(); } } -
在
Main方法内部添加以下代码片段:var server = new AsyncHttpServer(1234); var t = Task.Run(() => server.Start()); WriteLine("Listening on port 1234\. Open http://localhost:1234 in your browser."); WriteLine("Trying to connect:"); WriteLine(); GetResponseAsync("http://localhost:1234").GetAwaiter().GetResult(); WriteLine(); WriteLine("Press Enter to stop the server."); ReadLine(); server.Stop().GetAwaiter().GetResult(); -
运行程序。
工作原理...
在这里,我们使用HttpListener类实现了一个非常简单的 Web 服务器。还有一个TcpListener类用于 TCP 套接字 I/O 操作。我们配置监听器以接受来自任何主机到本地机器1234端口的连接。然后,我们在一个单独的工作线程中启动监听器,以便我们可以从主线程控制它。
当我们使用GetContextAsync方法时发生异步 I/O 操作。不幸的是,它不接受CancellationToken用于取消场景;因此,当我们想要停止服务器时,我们只需调用_listener.Abort方法,这将放弃连接并停止服务器。
要在此服务器上执行异步请求,我们使用位于System.Net.Http程序集和相同命名空间中的HttpClient类。我们使用GetAsync方法发出异步 HTTP GET请求。还有其他 HTTP 请求的方法,例如POST、DELETE和PUT等。HttpClient还有许多其他选项,例如使用不同的格式(如 XML 和 JSON)序列化和反序列化对象,指定代理服务器地址、凭据等。
当您运行程序时,您可以看到服务器已经启动。在服务器代码中,我们使用 GetContextAsync 方法来接受新的客户端连接。此方法在新客户端连接时返回,我们只需将当前日期和时间输出为非常基本的 HTML 语言到响应中。然后,我们请求服务器并打印响应头和内容。您还可以打开浏览器并浏览到 http://localhost:1234/。在这里,您将在浏览器窗口中看到相同的响应。
异步处理数据库
此配方将引导我们通过创建数据库、用数据填充它以及异步读取数据的过程。
准备工作
要逐步执行此配方,您需要 Visual Studio 2015。无需其他先决条件。此配方的源代码可在 BookSamples\Chapter9\Recipe3 中找到。
如何操作...
要了解创建数据库、用数据填充它以及异步读取数据的过程,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Data; using System.Data.SqlClient; using System.IO; using System.Reflection; using System.Threading.Tasks; using static System.Console; -
在
Main方法下方添加以下代码片段:static async Task ProcessAsynchronousIO(string dbName) { try { const string connectionString = @"Data Source=(LocalDB)\MSSQLLocalDB;Initial Catalog=master;" + "Integrated Security=True"; string outputFolder = Path.GetDirectoryName( Assembly.GetExecutingAssembly().Location); string dbFileName = Path.Combine(outputFolder, $"{dbName}.mdf"); string dbLogFileName = Path.Combine(outputFolder, $"{dbName}_log.ldf"); string dbConnectionString = @"Data Source=(LocalDB)\MSSQLLocalDB;" + $"AttachDBFileName={dbFileName};Integrated Security=True;"; using (var connection = new SqlConnection(connectionString)) { await connection.OpenAsync(); if (File.Exists(dbFileName)) { WriteLine("Detaching the database..."); var detachCommand = new SqlCommand("sp_detach_db", connection); detachCommand.CommandType = CommandType.StoredProcedure; detachCommand.Parameters.AddWithValue("@dbname", dbName); await detachCommand.ExecuteNonQueryAsync(); WriteLine("The database was detached succesfully."); WriteLine("Deleting the database..."); if(File.Exists(dbLogFileName)) File.Delete(dbLogFileName); File.Delete(dbFileName); WriteLine("The database was deleted succesfully."); } WriteLine("Creating the database..."); string createCommand = $"CREATE DATABASE {dbName} ON (NAME = N'{dbName}', FILENAME = " + $"'{dbFileName}')"; var cmd = new SqlCommand(createCommand, connection); await cmd.ExecuteNonQueryAsync(); WriteLine("The database was created succesfully"); } using (var connection = new SqlConnection(dbConnectionString)) { await connection.OpenAsync(); var cmd = new SqlCommand("SELECT newid()", connection); var result = await cmd.ExecuteScalarAsync(); WriteLine($"New GUID from DataBase: {result}"); cmd = new SqlCommand( @"CREATE TABLE [dbo].CustomTable NOT NULL, " + "[Name] nvarchar NOT NULL, CONSTRAINT [PK_ID] PRIMARY KEY CLUSTERED " + " ([ID] ASC) ON [PRIMARY]) ON [PRIMARY]", connection); await cmd.ExecuteNonQueryAsync(); WriteLine("Table was created succesfully."); cmd = new SqlCommand( @"INSERT INTO [dbo].[CustomTable] (Name) VALUES ('John'); INSERT INTO [dbo].[CustomTable] (Name) VALUES ('Peter'); INSERT INTO [dbo].[CustomTable] (Name) VALUES ('James'); INSERT INTO [dbo].[CustomTable] (Name) VALUES ('Eugene');", connection); await cmd.ExecuteNonQueryAsync(); WriteLine("Inserted data succesfully"); WriteLine("Reading data from table..."); cmd = new SqlCommand(@"SELECT * FROM [dbo].[CustomTable]", connection); using (SqlDataReader reader = await cmd.ExecuteReaderAsync()) { while (await reader.ReadAsync()) { var id = reader.GetFieldValue<int>(0); var name = reader.GetFieldValue<string>(1); WriteLine("Table row: Id {0}, Name {1}", id, name); } } } } catch(Exception ex) { WriteLine("Error: {0}", ex.Message); } } -
在
Main方法内添加以下代码片段:const string dataBaseName = "CustomDatabase"; var t = ProcessAsynchronousIO(dataBaseName); t.GetAwaiter().GetResult(); Console.WriteLine("Press Enter to exit"); Console.ReadLine(); -
运行程序。
它是如何工作的...
此程序与名为 SQL Server 2014 LocalDb 的软件一起工作。它与 Visual Studio 2015 一起安装,应该可以正常工作。然而,如果出现错误,您可能希望从安装向导中修复此组件。
我们首先配置到数据库文件的路径。我们将数据库文件放置在程序执行文件夹中。将有两个文件:一个用于数据库本身,另一个用于事务日志文件。我们还配置了两个连接字符串,用于定义我们如何连接到数据库。第一个是连接到 LocalDb 引擎以分离数据库;如果它已经存在,则删除并重新创建它。我们在打开连接和执行 SQL 命令时利用 I/O 异步性,分别使用 OpenAsync 和 ExecuteNonQueryAsync 方法。
在此任务完成后,我们附加一个新创建的数据库。在这里,我们创建一个新表并在其中插入一些数据。除了之前提到的方法外,我们还使用 ExecuteScalarAsync 以异步方式从数据库引擎获取标量值,并使用 SqlDataReader.ReadAsync 方法异步地从数据库表读取数据行。
如果我们的数据库中有一个大表,其行包含大二进制值,那么我们会使用 CommandBehavior.SequentialAcess 枚举来创建数据读取器,并使用 GetFieldValueAsync 方法异步地从读取器获取大字段值。
异步调用 WCF 服务
此配方将描述如何创建 WCF 服务,如何在控制台应用程序中托管它,如何使服务元数据对客户端可用,以及如何以异步方式使用它。
准备工作
要逐步执行此配方,您需要 Visual Studio 2015。没有其他先决条件。此配方的源代码可以在BookSamples\Chapter9\Recipe4中找到。
如何操作...
要了解如何使用 WCF 服务,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
添加对
System.ServiceModel库的引用。在项目中的References文件夹上右键单击,并选择添加引用…菜单选项。添加对System.ServiceModel库的引用。您可以在引用管理器对话框中使用搜索功能,如图所示:![如何操作...]()
-
在
Program.cs文件中,添加以下using指令:using System; using System.ServiceModel; using System.ServiceModel.Description; using System.Threading.Tasks; using static System.Console; -
在
Main方法下方添加以下代码片段:const string SERVICE_URL = "http://localhost:1234/HelloWorld"; static async Task RunServiceClient() { var endpoint = new EndpointAddress(SERVICE_URL); var channel = ChannelFactory<IHelloWorldServiceClient> .CreateChannel(new BasicHttpBinding(), endpoint); var greeting = await channel.GreetAsync("Eugene"); WriteLine(greeting); } [ServiceContract(Namespace = "Packt", Name = "HelloWorldServiceContract")] public interface IHelloWorldService { [OperationContract] string Greet(string name); } [ServiceContract(Namespace = "Packt", Name = "HelloWorldServiceContract")] public interface IHelloWorldServiceClient { [OperationContract] string Greet(string name); [OperationContract] Task<string> GreetAsync(string name); } public class HelloWorldService : IHelloWorldService { public string Greet(string name) { return $"Greetings, {name}!"; } } -
在
Main方法内部添加以下代码片段:ServiceHost host = null; try { host = new ServiceHost(typeof (HelloWorldService), new Uri(SERVICE_URL)); var metadata = host.Description.Behaviors.Find<ServiceMetadataBehavior>() ?? new ServiceMetadataBehavior(); metadata.HttpGetEnabled = true; metadata.MetadataExporter.PolicyVersion = PolicyVersion.Policy15; host.Description.Behaviors.Add(metadata); host.AddServiceEndpoint(ServiceMetadataBehavior.MexContractName, MetadataExchangeBindings.CreateMexHttpBinding(), "mex"); var endpoint = host.AddServiceEndpoint(typeof (IHelloWorldService),new BasicHttpBinding(), SERVICE_URL); host.Faulted += (sender, e) => WriteLine("Error!"); host.Open(); WriteLine("Greeting service is running and listening on:"); WriteLine($"{endpoint.Address} ({endpoint.Binding.Name})"); var client = RunServiceClient(); client.GetAwaiter().GetResult(); WriteLine("Press Enter to exit"); ReadLine(); } catch (Exception ex) { WriteLine($"Error in catch block: {ex}"); } finally { if (null != host) { if (host.State == CommunicationState.Faulted) { host.Abort(); } else { host.Close(); } } } -
运行程序。
它是如何工作的...
WCF 是一个框架,允许我们以不同的方式调用远程服务。其中之一,在一段时间前非常流行,是通过使用基于 XML 的协议简单对象访问协议(SOAP)通过 HTTP 调用远程服务。当服务器应用程序调用另一个远程服务时,这是很常见的,并且可以使用 I/O 线程来完成。
Visual Studio 2015 对 WCF 服务提供了丰富的支持;例如,您可以使用添加服务引用菜单选项添加对这类服务的引用。您也可以用我们的服务来做这件事,因为我们提供了服务元数据。
要创建这样的服务,我们需要使用ServiceHost类来托管我们的服务。我们通过提供服务实现类型和基础 URI 来描述我们将托管的服务。然后,我们配置元数据端点和服务端点。最后,在出现错误时处理Faulted事件,并运行托管服务。
注意
请注意,由于它使用 HTTP 绑定,而 HTTP 绑定又使用http.sys,因此需要特殊权限来创建,我们需要有管理员权限来运行服务。您可以在管理员模式下运行 Visual Studio,或者在提升的命令提示符中运行以下命令来添加必要的权限:
netsh http add urlacl url=http://+:1234/HelloWorld user=machine\user
要消费此服务,我们创建一个客户端,这里就是主要技巧所在。在服务器端,我们有一个名为Greet的常规同步方法。此方法在服务合同IHelloWorldService中定义。然而,如果我们想利用异步网络 I/O,我们必须异步调用此方法。我们可以通过创建一个新的服务合同,其中包含匹配的命名空间和服务名称,来定义同步和基于任务异步方法。尽管在服务器端我们没有异步方法定义,但我们遵循命名约定,WCF 基础设施理解我们想要创建一个异步代理方法。
因此,当我们创建一个 IHelloWorldServiceClient 代理通道,并且 WCF 正确地将异步调用路由到服务器端的同步方法时,如果你让应用程序继续运行,你可以打开浏览器并使用其 URL 访问服务,即 http://localhost:1234/HelloWorld。将打开一个服务描述,你可以浏览到允许我们从 Visual Studio 2012 添加服务引用的 XML 元数据。如果你尝试生成引用,你会看到稍微复杂一些的代码,但它是由自动生成的,并且易于使用。
第十章. 并行编程模式
在本章中,我们将回顾程序员在尝试实现并行工作流程时经常遇到的一些常见问题。你将学习以下技巧:
-
实现延迟评估的共享状态
-
使用
BlockingCollection实现并行管道 -
使用 TPL DataFlow 实现并行管道
-
使用 PLINQ 实现 Map/Reduce
简介
编程模式意味着对给定问题的具体和标准解决方案。通常,编程模式是人们积累经验、分析常见问题并为这些问题提供解决方案的结果。
由于并行编程已经存在了很长时间,因此有许多不同的模式被用来编写并行应用程序。甚至还有专门的编程语言来简化特定并行算法的编程。然而,事情开始变得越来越复杂。在本章中,我将为你提供一个起点,从这里你可以进一步学习并行编程。我们将回顾一些非常基础但非常有用的模式,这些模式对于并行编程中的许多常见情况非常有帮助。
首先,我们将使用来自多个线程的 共享状态对象。我想强调的是,你应该尽可能避免使用它。正如我们在前面的章节中讨论的那样,当编写并行算法时,共享状态真的很糟糕,但在许多情况下,它是不可避免的。我们将找出如何延迟对象的实际计算直到它被需要,以及如何实现不同的场景以实现线程安全。
然后,我们将向你展示如何创建一个结构化的并行数据流。我们将回顾一个具体的案例,即生产者/消费者模式,被称为 Parallel Pipeline。我们计划通过首先阻塞集合来实现它,然后我们将看到来自微软的另一个库——TPL DataFlow 对于并行编程是多么有帮助。
我们将要研究的最后一个模式是 Map/Reduce 模式。在现代世界中,这个名字可能意味着非常不同的事情。有些人认为 Map/Reduce 不是一个通用的方法来解决任何问题,而是一个针对大型、分布式集群计算的具体实现。我们将找出这个模式名称背后的含义,并回顾一些在小规模并行应用中可能的工作方式。
实现延迟评估的共享状态
这个技巧展示了如何编程一个延迟评估、线程安全的共享状态对象。
准备工作
要开始这个技巧,你需要运行 Visual Studio 2015。没有其他先决条件。这个技巧的源代码可以在 BookSamples\Chapter10\Recipe1 中找到。
如何做到这一点...
要实现延迟评估的共享状态,执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Threading; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:static async Task ProcessAsynchronously() { var unsafeState = new UnsafeState(); Task[] tasks = new Task[4]; for (int i = 0; i < 4; i++) { tasks[i] = Task.Run(() => Worker(unsafeState)); } await Task.WhenAll(tasks); WriteLine(" --------------------------- "); var firstState = new DoubleCheckedLocking(); for (int i = 0; i < 4; i++) { tasks[i] = Task.Run(() => Worker(firstState)); } await Task.WhenAll(tasks); WriteLine(" --------------------------- "); var secondState = new BCLDoubleChecked(); for (int i = 0; i < 4; i++) { tasks[i] = Task.Run(() => Worker(secondState)); } await Task.WhenAll(tasks); WriteLine(" --------------------------- "); var lazy = new Lazy<ValueToAccess>(Compute); var thirdState = new LazyWrapper(lazy); for (int i = 0; i < 4; i++) { tasks[i] = Task.Run(() => Worker(thirdState)); } await Task.WhenAll(tasks); WriteLine(" --------------------------- "); var fourthState = new BCLThreadSafeFactory(); for (int i = 0; i < 4; i++) { tasks[i] = Task.Run(() => Worker(fourthState)); } await Task.WhenAll(tasks); WriteLine(" --------------------------- "); } static void Worker(IHasValue state) { WriteLine($"Worker runs on thread id {CurrentThread.ManagedThreadId}"); WriteLine($"State value: {state.Value.Text}"); } static ValueToAccess Compute() { WriteLine("The value is being constructed on a thread " + $"id {CurrentThread.ManagedThreadId}"); Sleep(TimeSpan.FromSeconds(1)); return new ValueToAccess( $"Constructed on thread id {CurrentThread.ManagedThreadId}"); } class ValueToAccess { private readonly string _text; public ValueToAccess(string text) { _text = text; } public string Text => _text; } class UnsafeState : IHasValue { private ValueToAccess _value; public ValueToAccess Value =>_value ?? (_value = Compute()); } class DoubleCheckedLocking : IHasValue { private readonly object _syncRoot = new object(); private volatile ValueToAccess _value; public ValueToAccess Value { get { if (_value == null) { lock (_syncRoot) { if (_value == null) _value = Compute(); } } return _value; } } } class BCLDoubleChecked : IHasValue { private object _syncRoot = new object(); private ValueToAccess _value; private bool _initialized; public ValueToAccess Value => LazyInitializer.EnsureInitialized( ref _value, ref _initialized, ref _syncRoot, Compute); } class BCLThreadSafeFactory : IHasValue { private ValueToAccess _value; public ValueToAccess Value => LazyInitializer.EnsureInitialized(ref _value, Compute); } class LazyWrapper : IHasValue { private readonly Lazy<ValueToAccess> _value; public LazyWrapper(Lazy<ValueToAccess> value ) { _value = value; } public ValueToAccess Value => _value.Value; } interface IHasValue { ValueToAccess Value { get; } } -
在
Main方法内添加以下代码片段:var t = ProcessAsynchronously(); t.GetAwaiter().GetResult(); -
运行程序。
它是如何工作的...
第一个示例显示了为什么在多个访问线程中使用UnsafeState对象是不安全的。我们看到Construct方法被多次调用,不同的线程使用不同的值,这显然是不正确的。为了修复这个问题,我们可以在读取值时使用一个锁,如果它尚未初始化,首先创建它。这将有效,但使用锁进行每次读取操作并不高效。为了避免每次都使用锁,我们可以使用一个传统的称为双重检查锁定模式的方法。我们第一次检查值,如果它不是 null,我们避免不必要的锁定并直接使用共享对象。然而,如果它尚未构造,我们使用锁然后第二次检查值,因为它可能在我们的第一次检查和锁操作之间被初始化。如果它仍然未初始化,我们才计算值。我们可以清楚地看到这种方法在第二个示例中是有效的——只有一个Construct方法的调用,第一个调用的线程定义了共享对象状态。
注意
注意,如果延迟计算的对象实现是线程安全的,这并不意味着它的所有属性也都是线程安全的。
例如,如果你向ValueToAccess对象添加一个int公共属性,它将不是线程安全的;你仍然必须使用互斥构造或锁定来确保线程安全。
这种模式非常常见,这就是为什么在基类库中有几个类帮助我们。首先,我们可以使用LazyInitializer.EnsureInitialized方法,它在内部实现了双重检查锁定模式。然而,最舒适的选项是使用Lazy<T>类,它允许我们开箱即用就有线程安全、延迟计算的共享状态。接下来的两个示例表明,它们与第二个示例是等价的,程序的行为也是相同的。唯一的区别是,由于LazyInitializer是一个静态类,我们不需要创建一个新实例,就像在Lazy<T>的情况下,因此,在某些罕见场景中,第一种情况下的性能可能会更好。
最后一个选项是,如果我们不关心Construct方法,可以完全避免锁定。如果它是线程安全的并且没有副作用/严重的性能影响,我们只需运行几次,但只使用第一个构造的值。最后一个示例显示了描述的行为,我们可以使用另一个LazyInitializer.EnsureInitialized方法重载来实现这个结果。
使用 BlockingCollection 实现并行管道
这个配方将描述如何使用标准的BlockingCollection数据结构实现生产者/消费者模式的一个特定场景,这被称为并行管道。
准备工作
要开始这个食谱,你需要运行 Visual Studio 2015。没有其他先决条件。这个食谱的源代码可以在BookSamples\Chapter10\Recipe2找到。
如何实现...
要了解如何使用BlockingCollection实现并行管道,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在
Program.cs文件中添加以下using指令:using System; using System.Collections.Concurrent; using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:private const int CollectionsNumber = 4; private const int Count = 5; static void CreateInitialValues(BlockingCollection<int>[] sourceArrays, CancellationTokenSource cts) { Parallel.For(0, sourceArrays.Length*Count, (j, state) => { if (cts.Token.IsCancellationRequested) { state.Stop(); } int number = GetRandomNumber(j); int k = BlockingCollection<int>.TryAddToAny(sourceArrays, j); if (k >= 0) { WriteLine( $"added {j} to source data on thread " + $"id {CurrentThread.ManagedThreadId}"); Sleep(TimeSpan.FromMilliseconds(number)); } }); foreach (var arr in sourceArrays) { arr.CompleteAdding(); } } static int GetRandomNumber(int seed) { return new Random(seed).Next(500); } class PipelineWorker<TInput, TOutput> { Func<TInput, TOutput> _processor; Action<TInput> _outputProcessor; BlockingCollection<TInput>[] _input; CancellationToken _token; Random _rnd; public PipelineWorker( BlockingCollection<TInput>[] input, Func<TInput, TOutput> processor, CancellationToken token, string name) { _input = input; Output = new BlockingCollection<TOutput>[_input.Length]; for (int i = 0; i < Output.Length; i++) Output[i] = null == input[i] ? null : new BlockingCollection<TOutput>(Count); _processor = processor; _token = token; Name = name; _rnd = new Random(DateTime.Now.Millisecond); } public PipelineWorker( BlockingCollection<TInput>[] input, Action<TInput> renderer, CancellationToken token, string name) { _input = input; _outputProcessor = renderer; _token = token; Name = name; Output = null; _rnd = new Random(DateTime.Now.Millisecond); } public BlockingCollection<TOutput>[] Output { get; private set; } public string Name { get; private set; } public void Run() { WriteLine($"{Name} is running"); while (!_input.All(bc => bc.IsCompleted) && !_token.IsCancellationRequested) { TInput receivedItem; int i = BlockingCollection<TInput>.TryTakeFromAny( _input, out receivedItem, 50, _token); if (i >= 0) { if (Output != null) { TOutput outputItem = _processor(receivedItem); BlockingCollection<TOutput>.AddToAny( Output, outputItem); WriteLine($"{Name} sent {outputItem} to next, on " + $"thread id {CurrentThread.ManagedThreadId}"); Sleep(TimeSpan.FromMilliseconds(_rnd.Next(200))); } else { _outputProcessor(receivedItem); } } else { Sleep(TimeSpan.FromMilliseconds(50)); } } if (Output != null) { foreach (var bc in Output) bc.CompleteAdding(); } } } -
在
Main方法内部添加以下代码片段:var cts = new CancellationTokenSource(); Task.Run(() => { if (ReadKey().KeyChar == 'c') cts.Cancel(); }, cts.Token); var sourceArrays = new BlockingCollection<int>[CollectionsNumber]; for (int i = 0; i < sourceArrays.Length; i++) { sourceArrays[i] = new BlockingCollection<int>(Count); } var convertToDecimal = new PipelineWorker<int, decimal> ( sourceArrays, n => Convert.ToDecimal(n*100), cts.Token, "Decimal Converter" ); var stringifyNumber = new PipelineWorker<decimal, string> ( convertToDecimal.Output, s => $"--{s.ToString("C", CultureInfo.GetCultureInfo("en-us"))}--", cts.Token, "String Formatter" ); var outputResultToConsole = new PipelineWorker<string, string> ( stringifyNumber.Output, s => WriteLine($"The final result is {s} on thread " + $"id {CurrentThread.ManagedThreadId}"), cts.Token, "Console Output" ); try { Parallel.Invoke( () => CreateInitialValues(sourceArrays, cts), () => convertToDecimal.Run(), () => stringifyNumber.Run(), () => outputResultToConsole.Run() ); } catch (AggregateException ae) { foreach (var ex in ae.InnerExceptions) WriteLine(ex.Message + ex.StackTrace); } if (cts.Token.IsCancellationRequested) { WriteLine("Operation has been canceled! Press ENTER to exit."); } else { WriteLine("Press ENTER to exit."); } ReadLine(); -
运行程序。
它是如何工作的...
在前面的例子中,我们实现了最常见的并行编程场景之一。想象一下,我们有一些数据需要通过几个计算阶段,这需要相当长的时间。后一个计算需要前一个计算的结果,所以我们不能并行运行它们。
如果我们只有一个项目要处理,那么提高性能的可能性并不多。然而,如果我们让许多项目通过相同的计算阶段,我们可以使用并行管道技术。这意味着我们不必等到所有项目都通过第一个计算阶段才能进入下一个阶段。只要有一个项目完成了阶段,我们就可以将其移动到下一个阶段;同时,下一个项目正在由前一个阶段处理,以此类推。结果,我们几乎实现了通过第一个项目通过第一个计算阶段所需的时间来实现的并行处理。
这里,我们为每个处理阶段使用四个集合,说明我们可以并行处理每个阶段。我们首先提供通过按C键取消整个过程的可能性。我们创建了一个取消令牌,并运行一个单独的任务来监控C键。然后,我们定义我们的管道。它由三个主要阶段组成。第一个阶段是我们将初始数字放在前四个集合中,这些集合作为后续管道的项目源。这段代码位于CreateInitialValues方法的Parallel.For循环内部,而Parallel.Invoke语句则表示我们并行运行所有阶段;初始阶段也是并行运行的。
下一个阶段是定义我们的管道元素。逻辑定义在PipelineWorker类内部。我们使用输入集合初始化工作者,提供一个转换函数,然后与其他工作者并行运行工作者。这样,我们定义了两个工作者,或者过滤器,因为它们过滤初始序列。其中一个将整数转换为十进制值,另一个将十进制转换为字符串。最后,最后一个工作者只是将每个传入的字符串打印到控制台。在所有地方,我们提供了一个运行线程 ID,以查看一切是如何工作的。此外,我们还添加了人工延迟,以便项目的处理更加自然,因为我们实际上使用了重计算。
因此,我们看到了预期的确切行为。首先,在初始集合中创建了一些项目。然后,我们看到第一个过滤器开始处理它们,在它们被处理的同时,第二个过滤器开始工作。最后,项目被发送到最后一个工作器,它将项目打印到控制台。
使用 TPL DataFlow 实现并行管道
这个食谱展示了如何使用 TPL DataFlow 库实现并行管道模式。
准备中
要开始这个食谱,你需要运行 Visual Studio 2015。没有其他先决条件。这个食谱的源代码可以在BookSamples\Chapter10\Recipe3中找到。
如何做...
要了解如何使用 TPL DataFlow 实现并行管道,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
将引用添加到Microsoft TPL DataFlow NuGet 包。按照以下步骤操作:
-
右键单击项目中的引用文件夹,并选择管理 NuGet 包...菜单选项。
-
现在,将你喜欢的引用添加到Microsoft TPL DataFlow NuGet 包中。你可以在管理 NuGet 包对话框中使用搜索选项,如下所示:
![如何做...]()
-
-
在
Program.cs文件中,添加以下using指令:using System; using System.Globalization; using System.Threading; using System.Threading.Tasks; using System.Threading.Tasks.Dataflow; using static System.Console; using static System.Threading.Thread; -
在
Main方法下方添加以下代码片段:async static Task ProcessAsynchronously() { var cts = new CancellationTokenSource(); Random _rnd = new Random(DateTime.Now.Millisecond); Task.Run(() => { if (ReadKey().KeyChar == 'c') cts.Cancel(); }, cts.Token); var inputBlock = new BufferBlock<int>( new DataflowBlockOptions { BoundedCapacity = 5, CancellationToken = cts.Token }); var convertToDecimalBlock = new TransformBlock<int, decimal>( n => { decimal result = Convert.ToDecimal(n * 100); WriteLine($"Decimal Converter sent {result} to the next stage on " + $"thread id {CurrentThread.ManagedThreadId}"); Sleep(TimeSpan.FromMilliseconds(_rnd.Next(200))); return result; } , new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 4, CancellationToken = cts.Token }); var stringifyBlock = new TransformBlock<decimal, string>( n => { string result = $"--{n.ToString("C", CultureInfo.GetCultureInfo("en-us"))}--"; WriteLine($"String Formatter sent {result} to the next stage on thread id {CurrentThread.ManagedThreadId}"); Sleep(TimeSpan.FromMilliseconds(_rnd.Next(200))); return result; } , new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 4, CancellationToken = cts.Token }); var outputBlock = new ActionBlock<string>( s => { WriteLine($"The final result is {s} on thread id {CurrentThread.ManagedThreadId}"); } , new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 4, CancellationToken = cts.Token }); inputBlock.LinkTo(convertToDecimalBlock, new DataflowLinkOptions { PropagateCompletion = true }); convertToDecimalBlock.LinkTo(stringifyBlock, new DataflowLinkOptions { PropagateCompletion = true }); stringifyBlock.LinkTo(outputBlock, new DataflowLinkOptions { PropagateCompletion = true }); try { Parallel.For(0, 20, new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = cts.Token } , i => { WriteLine($"added {i} to source data on thread id {CurrentThread.ManagedThreadId}"); inputBlock.SendAsync(i).GetAwaiter().GetResult(); }); inputBlock.Complete(); await outputBlock.Completion; WriteLine("Press ENTER to exit."); } catch (OperationCanceledException) { WriteLine("Operation has been canceled! Press ENTER to exit."); } ReadLine(); } -
在
Main方法内部添加以下代码片段:var t = ProcessAsynchronously(); t.GetAwaiter().GetResult(); -
运行程序。
它是如何工作的...
在前面的食谱中,我们实现了一个并行管道模式,通过顺序阶段处理项目。这是一个相当常见的问题,提出编程此类算法的方法之一是使用来自 Microsoft 的 TPL DataFlow 库。它通过 NuGet 分发,易于安装和使用。
TPL DataFlow 库包含不同类型的块,这些块可以用不同的方式相互连接,形成复杂的过程,可以在需要时部分并行和顺序执行。为了查看一些可用的基础设施,让我们使用 TPL DataFlow 库实现前面的场景。
首先,我们定义了将处理我们的数据的不同块。请注意,这些块在构建时可以指定不同的选项;它们可能非常重要。例如,我们将取消令牌传递到我们定义的每个块中,当我们发出取消信号时,它们都会停止工作。
我们从BufferBlock开始我们的过程,将其容量绑定到最大 5 个项目。此块持有项目,以便将它们传递到流程中的下一个块。我们将其限制为五项容量,指定BoundedCapacity选项值。这意味着当此块中有五个项目时,它将停止接受新项目,直到现有项目中的一个传递到下一个块。
下一个块类型是TransformBlock。这个块用于数据转换步骤。在这里,我们定义了两个转换块;其中一个将整数转换为小数,另一个将小数值转换为字符串。我们可以使用MaxDegreeOfParallelism选项为此块指定最大同时工作线程数。
最后一个块是ActionBlock类型。这个块将在每个传入的项目上运行指定的操作。我们使用这个块将项目打印到控制台。
现在,我们使用LinkTo方法将这些块连接起来。在这里,我们有一个简单的顺序数据流,但也可以创建更复杂的方案。在这里,我们还提供了DataflowLinkOptions,并将PropagateCompletion属性设置为true。这意味着当步骤完成时,它将自动将结果和异常传播到下一个阶段。然后,我们并行地向缓冲块添加项目,当我们完成添加新项目后,调用块的Complete方法。然后,我们等待最后一个块完成。在取消的情况下,我们处理OperationCancelledException并取消整个过程。
使用 PLINQ 实现 Map/Reduce
这个食谱将描述如何在使用 PLINQ 的同时实现 Map/Reduce 模式。
准备工作
要开始这个食谱,你需要运行 Visual Studio 2015。没有其他先决条件。这个食谱的源代码可以在BookSamples\Chapter10\Recipe4找到。
如何操作...
要了解如何使用 PLINQ 实现 Map/Reduce,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C#控制台应用程序项目。
-
在
Program.cs文件中,添加以下using指令:using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Text; using System.Threading.Tasks; using Newtonsoft.Json; using static System.Console; -
添加对
Newtonsoft.JsonNuGet 包和System.Net.Http组件的引用。 -
在
Main方法下方添加以下代码片段:static char[] delimiters = { ' ', ',', ';', ':', '\"', '.' }; async static Task<string> ProcessBookAsync( string bookContent, string title, HashSet<string> stopwords) { using (var reader = new StringReader(bookContent)) { var query = reader.EnumLines() .AsParallel() .SelectMany(line => line.Split(delimiters)) .MapReduce( word => new[] { word.ToLower() }, key => key, g => new[] { new { Word = g.Key, Count = g.Count() } } ) .ToList(); var words = query .Where(element => !string.IsNullOrWhiteSpace(element.Word) && !stopwords.Contains(element.Word)) .OrderByDescending(element => element.Count); var sb = new StringBuilder(); sb.AppendLine($"'{title}' book stats"); sb.AppendLine("Top ten words used in this book: "); foreach (var w in words.Take(10)) { sb.AppendLine($"Word: '{w.Word}', times used: '{w.Count}'"); } sb.AppendLine($"Unique Words used: {query.Count()}"); return sb.ToString(); } } async static Task<string> DownloadBookAsync(string bookUrl) { using (var client = new HttpClient()) { return await client.GetStringAsync(bookUrl); } } async static Task<HashSet<string>> DownloadStopWordsAsync() { string url = "https://raw.githubusercontent.com/6/stopwords/master/stopwords-all.json"; using (var client = new HttpClient()) { try { var content = await client.GetStringAsync(url); var words = JsonConvert.DeserializeObject <Dictionary<string, string[]>>(content); return new HashSet<string>(words["en"]); } catch { return new HashSet<string>(); } } } -
在
Main方法内部添加以下代码片段:var booksList = new Dictionary<string, string>() { ["Moby Dick; Or, The Whale by Herman Melville"] = "http://www.gutenberg.org/cache/epub/2701/pg2701.txt", ["The Adventures of Tom Sawyer by Mark Twain"] = "http://www.gutenberg.org/cache/epub/74/pg74.txt", ["Treasure Island by Robert Louis Stevenson"] = "http://www.gutenberg.org/cache/epub/120/pg120.txt", ["The Picture of Dorian Gray by Oscar Wilde"] = "http://www.gutenberg.org/cache/epub/174/pg174.txt" }; HashSet<string> stopwords = DownloadStopWordsAsync().GetAwaiter().GetResult(); var output = new StringBuilder(); Parallel.ForEach(booksList.Keys, key => { var bookContent = DownloadBookAsync(booksList[key]) .GetAwaiter().GetResult(); string result = ProcessBookAsync(bookContent, key, stopwords) .GetAwaiter().GetResult(); output.Append(result); output.AppendLine(); }); Write(output.ToString()); ReadLine(); -
在
Program类定义之后添加以下代码片段:static class Extensions { public static ParallelQuery<TResult> MapReduce<TSource, TMapped, TKey, TResult>( this ParallelQuery<TSource> source, Func<TSource, IEnumerable<TMapped>> map, Func<TMapped, TKey> keySelector, Func<IGrouping<TKey, TMapped>, IEnumerable<TResult>> reduce) { return source.SelectMany(map) .GroupBy(keySelector) .SelectMany(reduce); } public static IEnumerable<string> EnumLines(this StringReader reader) { while (true) { string line = reader.ReadLine(); if (null == line) yield break; yield return line; } } } -
运行程序。
它是如何工作的...
Map/Reduce函数是另一种重要的并行编程模式。它们适用于小型程序和大型多服务器计算。这种模式的意义在于,你拥有两个特殊函数可以应用于你的数据。第一个是Map函数。它接收一组以键/值列表形式存在的初始数据,并生成另一个键/值序列,将数据转换成便于进一步处理的形式。然后,我们使用另一个函数,称为Reduce。Reduce函数接收Map函数的结果,并将其转换为我们实际需要的最小数据集。为了理解这个算法是如何工作的,让我们回顾一下前面的食谱。
在这里,我们将分析四本经典书籍的文本。我们将从古腾堡计划网站(www.gutenberg.org)下载这些书籍,如果在发出大量网络请求时遇到验证码,可能会破坏本示例的程序逻辑。如果在程序的输出中看到 HTML 元素,请在浏览器中打开其中一个书籍 URL 并完成验证码。接下来要做的事情是加载一个我们将跳过分析文本的英文单词列表。在本示例中,我们尝试从 GitHub 加载一个 JSON 编码的单词列表,如果失败,我们只得到一个空列表。
现在,让我们关注我们的Map/Reduce实现,作为PLINQExtensions类中的一个 PLINQ 扩展方法。我们使用SelectMany通过应用Map函数将初始序列转换为所需的序列。这个函数从一个序列元素中产生几个新元素。然后,我们使用keySelector函数选择如何对新的序列进行分组,并使用GroupBy和这个键来产生一个中间的键/值序列。最后,我们对结果分组序列应用Reduce以获得结果。
然后,我们并行运行所有书籍的处理过程。每个处理工作线程将结果信息输出到一个字符串中,当所有工作线程完成后,我们将这些信息打印到控制台。我们这样做是为了避免并发控制台输出,当每个工作线程的文本重叠时,会导致结果信息难以阅读。在每个工作进程中,我们将书籍文本分割成文本行序列,将每一行分割成单词序列,并对其应用我们的MapReduce函数。我们使用Map函数将每个单词转换为小写并用作分组键。然后,我们定义Reduce函数作为将分组元素转换为键值对的转换,其中包含一个包含在文本中找到的唯一单词的Word元素和一个包含有关该单词使用次数信息的Count元素。最后一步是通过调用ToList方法进行查询物化,因为我们需要处理这个查询两次。然后,我们使用我们的停用词列表从我们的统计数据中删除常见单词,并创建一个包含书籍标题、书籍中使用频率最高的 10 个单词以及书籍中唯一单词频率的字符串结果。
第十一章。还有更多
在本章中,我们将探讨 Windows 10 操作系统中的一个新的编程范式。此外,你还将学习如何在 OS X 和 Linux 上运行 .NET 程序。在本章中,你将学习以下食谱:
-
在通用 Windows 平台应用程序中使用计时器
-
在常规应用程序中使用 WinRT
-
在通用 Windows 平台应用程序中使用
BackgroundTask -
在 OS X 上运行 .NET Core 应用程序
-
在 Ubuntu Linux 上运行 .NET Core 应用程序
简介
微软于 2011 年 9 月 13 日在 Build 大会上发布了 Windows 8 的第一个公开测试版。新的操作系统通过引入响应式 UI(适用于具有触摸功能的平板电脑设备)、降低功耗、新的应用程序模型、新的异步 API 和更严格的安全性等特性,试图解决 Windows 几乎所有的问题。
Windows API 改进的核心是一个新的多平台组件系统,WinRT,它是 COM 的逻辑发展。使用 WinRT,程序员可以使用原生 C++ 代码、C# 和 .NET,甚至 JavaScript 和 HTML 来开发应用程序。另一个变化是引入了一个集中的应用程序商店,这在 Windows 平台上之前是不存在的。
作为一个新的应用程序平台,Windows 8 具有向后兼容性,并允许我们运行常规的 Windows 应用程序。这导致了一种情况,即存在两个主要的应用程序类别:Windows Store 应用程序,新程序通过 Windows Store 分发,以及自 Windows 前一个版本以来没有改变的常规经典应用程序。
然而,Windows 8 只是向新的应用程序模型迈出的第一步。微软从用户那里收到了很多反馈,并且很明显,Windows Store 应用程序与人们习惯的太不同了。除此之外,还有一个独立的智能手机操作系统,Windows 8 Phone,它有一个不同的应用程序商店和略微不同的 API 集合。这使得应用程序开发者必须为桌面和智能手机平台创建两个独立的应用程序。
为了改善这种情况,新的 Windows 10 操作系统被引入作为一个统一的所有 Windows 设备的平台。有一个单一的应用程序商店支持每个设备系列,现在,创建一个可以在手机、平板电脑和桌面电脑上运行的应用程序成为可能。因此,Windows Store 应用程序现在被称为通用 Windows 平台应用程序(UWP 应用程序)。这当然意味着你的应用程序会有很多限制——它不应该使用任何平台特定的 API,作为一个程序员,你必须遵守特定的规则。程序必须在有限的时间内响应启动或完成,以保持整个操作系统和其他应用程序的响应。为了节省电池,你的应用程序默认不再在后台运行;相反,它们被挂起并实际上停止执行。
新的 Windows API 是异步的,你只能在你的应用程序中使用白名单中的 API 函数。例如,你不再被允许创建新的Thread类实例。你必须使用系统管理的线程池。许多常规 API 不能再使用,你必须研究新的方法来实现之前的目标。
但这还不是全部。微软开始意识到支持除 Windows 以外的操作系统也很重要。现在,你可以使用一个新的.NET 子集来编写跨平台应用程序,这个子集被称为.NET Core。它的源代码可以在 GitHub 上找到,并且它在 OS X 和 Linux 等平台上得到支持。你可以使用任何文本编辑器,但我建议你看看 Visual Studio Code——这是一个新的轻量级、跨平台代码编辑器,它在 OS X 和 Linux 上运行,并且很好地理解 C#语法。
在本章中,我们将了解通用 Windows 平台应用程序与常规 Windows 应用程序的不同之处,以及我们如何利用一些 WinRT 从常规应用程序中获得的益处。我们还将通过一个具有后台通知的简化通用 Windows 平台应用程序场景进行说明。你还将学习如何在 OS X 和 Linux 上运行.NET 程序。
在通用 Windows 平台应用程序中使用计时器
本教程展示了如何在通用 Windows 平台应用程序中使用简单的计时器。
准备工作
要完成这个教程,你需要 Visual Studio 2015 和 Windows 10 操作系统。没有其他先决条件。本教程的源代码可以在BookSamples\Chapter11\Recipe1找到。
如何操作...
要了解如何在 Windows Store 应用程序中使用计时器,请执行以下步骤:
-
启动 Visual Studio 2015。在
Windows\Universal文件夹中创建一个新的 C# 空白应用(通用 Windows)项目。![如何操作...]()
-
如果你被要求为 Windows 10 启用开发者模式,你必须在控制面板中启用它。
![如何操作...]()
-
然后,确认你确实想要开启开发者模式。
![如何操作...]()
-
在
MainPage.xaml文件中,向Grid元素添加Name属性:<Grid Name="Grid" Background="{StaticResource ApplicationPageBackgroundThemeBrush}"> -
在
MainPage.xaml.cs文件中添加以下using指令:using System; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Navigation; -
在
MainPage构造函数定义上方添加以下代码片段:private readonly DispatcherTimer _timer; private int _ticks; -
将
MainPage()构造函数替换为以下代码片段:public MainPage() { InitializeComponent(); _timer = new DispatcherTimer(); _ticks = 0; } -
在
MainPage构造函数定义下添加OnNavigatedTo()方法:protected override void OnNavigatedTo(NavigationEventArgs e) { } -
在
OnNavigatedTo方法内部添加以下代码片段:base.OnNavigatedTo(e);Grid.Children.Clear(); var commonPanel = new StackPanel { Orientation = Orientation.Vertical, HorizontalAlignment = HorizontalAlignment.Center }; var buttonPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center }; var textBlock = new TextBlock { Text = "Sample timer application", FontSize = 32, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(40) }; var timerTextBlock = new TextBlock { Text = "0", FontSize = 32, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(40) }; var timerStateTextBlock = new TextBlock { Text = "Timer is enabled", FontSize = 32, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(40) }; var startButton = new Button { Content = "Start",FontSize = 32}; var stopButton = new Button { Content = "Stop",FontSize = 32}; buttonPanel.Children.Add(startButton); buttonPanel.Children.Add(stopButton); commonPanel.Children.Add(textBlock); commonPanel.Children.Add(timerTextBlock); commonPanel.Children.Add(timerStateTextBlock); commonPanel.Children.Add(buttonPanel); _timer.Interval = TimeSpan.FromSeconds(1); _timer.Tick += (sender, eventArgs) => { timerTextBlock.Text = _ticks.ToString(); _ticks++; }; _timer.Start(); startButton.Click += (sender, eventArgs) => { timerTextBlock.Text = "0"; _timer.Start(); _ticks = 1; timerStateTextBlock.Text = "Timer is enabled"; }; stopButton.Click += (sender, eventArgs) => { _timer.Stop(); timerStateTextBlock.Text = "Timer is disabled"; }; Grid.Children.Add(commonPanel); -
在 Visual Studio 的解决方案资源管理器中右键单击项目,然后选择部署。
-
运行程序。
工作原理...
当程序运行时,它会创建一个 MainPage 类的实例。在这里,我们在构造函数中实例化 DispatcherTimer 并将 ticks 计数器初始化为 0。然后,在 OnNavigatedTo 事件处理程序中,我们创建我们的 UI 控件并将启动和停止按钮绑定到相应的 lambda 表达式,这些表达式包含 start 和 stop 逻辑。
如您所见,timer 事件处理程序直接与 UI 控件一起工作。这是可以的,因为 DispatcherTimer 是以这种方式实现的,即 timer 的 Tick 事件的处理程序由 UI 线程运行。然而,如果您运行程序,然后切换到其他程序,然后几分钟后切换回程序,您可能会注意到秒表计数器远远落后于实际经过的时间。这是因为通用 Windows 平台应用程序具有完全不同的生命周期。
注意
注意,通用 Windows 平台应用程序的行为与智能手机和平板电脑平台上的应用程序非常相似。它们不是在后台运行,而是在一段时间后挂起,这意味着它们实际上被冻结,直到用户切换回它们。在应用程序挂起之前,您有有限的时间来保存当前应用程序状态,并且当应用程序再次运行时,您能够恢复状态。
虽然这种行为可以节省电源和 CPU 资源,但它为那些需要在后台进行一些处理的应用程序程序创建了许多困难。Windows 10 为此类应用程序提供了一套特殊的 API。我们将在本章后面讨论此类场景。
在常规应用程序中使用 WinRT
这个配方向您展示了如何创建一个能够使用 WinRT API 的控制台应用程序。
准备工作
要完成这个配方,你需要 Visual Studio 2015 和 Windows 10 操作系统。没有其他先决条件。这个配方的源代码可以在 BookSamples\Chapter11\Recipe2 中找到。
如何操作...
要了解如何从常规应用程序中使用 WinRT,请执行以下步骤:
-
启动 Visual Studio 2015。创建一个新的 C# 控制台应用程序项目。
-
在 Visual Studio 的 解决方案资源管理器 中右键单击创建的项目,然后选择 卸载项目… 菜单选项。
-
右键单击未加载的项目,并选择 编辑 ProjectName.csproj 菜单选项。
-
在
<TargetFrameworkVersion>元素下方添加以下 XML 代码:<TargetPlatformVersion>10.0</TargetPlatformVersion> -
保存
.csproj文件,在 Visual Studio 解决方案资源管理器 中右键单击未加载的项目,并选择 重新加载项目 菜单选项。 -
右键单击项目,然后在 Windows 下的 核心库 中选择 添加引用。然后,单击 浏览 按钮。
-
导航到
C:\Program Files (x86)\Windows Kits\10\UnionMetadata并单击Windows.winmd文件。 -
导航到
C:\Program Files\Reference Assemblies\Microsoft\Framework\.NETCore\v4.5并单击System.Runtime.WindowsRuntime.dll文件。 -
在
Program.cs文件中,添加以下using指令:using System; using System.IO; using System.Threading.Tasks; using Windows.Storage; -
在
Main方法下方添加以下代码片段:async static Task AsynchronousProcessing() { StorageFolder folder = KnownFolders.DocumentsLibrary; if (await folder.DoesFileExistAsync("test.txt")) { var fileToDelete = await folder.GetFileAsync("test.txt"); await fileToDelete.DeleteAsync(StorageDeleteOption.PermanentDelete); } var file = await folder.CreateFileAsync("test.txt",CreationCollisionOption.ReplaceExisting); using (var stream = await file.OpenAsync(FileAccessMode.ReadWrite)) using (var writer = new StreamWriter(stream.AsStreamForWrite())) { await writer.WriteLineAsync("Test content"); await writer.FlushAsync(); } using (var stream = await file.OpenAsync(FileAccessMode.Read)) using (var reader = new StreamReader(stream.AsStreamForRead())) { string content = await reader.ReadToEndAsync(); Console.WriteLine(content); } Console.WriteLine("Enumerating Folder Structure:"); var itemsList = await folder.GetItemsAsync(); foreach (var item in itemsList) { if (item is StorageFolder) { Console.WriteLine("{0} folder", item.Name); } else { Console.WriteLine(item.Name); } } } -
将以下代码片段添加到
Main方法中:var t = AsynchronousProcessing(); t.GetAwaiter().GetResult(); Console.WriteLine(); Console.WriteLine("Press ENTER to continue"); Console.ReadLine(); -
在
Program类定义下方添加以下代码片段:static class Extensions { public static async Task<bool> DoesFileExistAsync(thisStorageFolder folder, string fileName) { try { await folder.GetFileAsync(fileName); return true; } catch (FileNotFoundException) { return false; } } } -
运行程序。
它是如何工作的...
在这里,我们使用了一种相当巧妙的方法来从常见的.NET 控制台应用程序中消费 WinRT API。不幸的是,并非所有可用的 API 都适用于此场景,但仍然,它可以用于处理运动传感器、GPS 位置服务等等。
要在 Visual Studio 中引用 WinRT,我们手动编辑.csproj文件,指定应用程序的目标平台为 Windows 10。然后,我们手动引用Windows.winmd以获取访问 Windows 10 API 的权限,并引用System.Runtime.WindowsRuntime.dll以利用 WinRT 异步操作的GetAwaiter扩展方法实现。这允许我们直接在 WinRT API 上使用await。也存在反向转换。当我们创建 WinRT 库时,我们必须公开 WinRT 本地的IAsyncOperation接口家族以进行异步操作,这样它们就可以以语言无关的方式从 JavaScript 和 C++中消费。
WinRT 中的文件操作相当直观;在这里,我们有异步的文件创建和删除操作。尽管如此,WinRT 中的文件操作包含安全限制,鼓励你为你的应用程序使用特殊的 Windows 文件夹,并且不允许你在磁盘驱动器上的任何文件路径上工作。
在通用 Windows 平台应用程序中使用 BackgroundTask
这个食谱指导你如何在通用 Windows 平台应用程序中创建后台任务,该任务在桌面上更新应用程序的磁贴。
准备工作
要完成这个食谱,你需要 Visual Studio 2015 和 Windows 10 操作系统。没有其他先决条件。这个食谱的源代码可以在BookSamples\Chapter11\Recipe3找到。
如何做...
要了解如何在通用 Windows 平台应用程序中使用BackgroundTask,请执行以下步骤:
-
启动 Visual Studio 2015。在
Windows\Universal文件夹下创建一个新的 C# 空白应用(通用 Windows)项目。如果你需要启用 Windows 10 开发者模式,请参考在 Windows Store 应用程序中使用计时器食谱以获取详细说明。 -
打开
Package.appxmanifest文件。在声明选项卡下,将后台任务添加到支持声明中。在属性下,检查支持属性系统事件和计时器,并将入口点的名称设置为YourNamespace.TileSchedulerTask。YourNamespace应该是你的应用程序的命名空间。![如何做...]()
-
在
MainPage.xaml文件中,将以下 XAML 代码插入到Grid元素中:<StackPanel Margin="50"> <TextBlock Name="Clock" Text="HH:mm" HorizontalAlignment="Center" VerticalAlignment="Center" Style="{StaticResource HeaderTextBlockStyle}"/> </StackPanel> -
在
MainPage.xaml.cs文件中,添加以下using指令:using System; using System.Diagnostics; using System.Globalization; using System.Linq; using System.Xml.Linq; using Windows.ApplicationModel.Background; using Windows.Data.Xml.Dom; using Windows.System.UserProfile; using Windows.UI.Notifications; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Navigation; -
在
MainPage构造函数定义上方添加以下代码片段:private const string TASK_NAME_USERPRESENT ="TileSchedulerTask_UserPresent"; private const string TASK_NAME_TIMER ="TileSchedulerTask_Timer"; private readonly CultureInfo _cultureInfo; private readonly DispatcherTimer _timer; -
将
MainPage构造函数替换为以下代码片段:public MainPage() { InitializeComponent(); string language = GlobalizationPreferences.Languages.First(); _cultureInfo = new CultureInfo(language); _timer = new DispatcherTimer(); _timer.Interval = TimeSpan.FromSeconds(1); _timer.Tick += (sender, e) => UpdateClockText(); } -
在
OnNavigatedTo方法上方添加以下代码片段:private void UpdateClockText() { Clock.Text = DateTime.Now.ToString(_cultureInfo.DateTimeFormat.FullDateTimePattern); } private static async void CreateClockTask() { BackgroundAccessStatus result = awaitBackgroundExecutionManager.RequestAccessAsync(); if (result == BackgroundAccessStatus.AllowedMayUseActiveRealTimeConnectivity ||result == BackgroundAccessStatus.AllowedWithAlwaysOnRealTimeConnectivity) { TileSchedulerTask.CreateSchedule(); EnsureUserPresentTask(); EnsureTimerTask(); } } private static void EnsureUserPresentTask() { foreach (var task in BackgroundTaskRegistration.AllTasks) if (task.Value.Name == TASK_NAME_USERPRESENT) return; var builder = new BackgroundTaskBuilder(); builder.Name = TASK_NAME_USERPRESENT; builder.TaskEntryPoint =(typeof(TileSchedulerTask)).FullName; builder.SetTrigger(new SystemTrigger(SystemTriggerType.UserPresent, false)); builder.Register(); } private static void EnsureTimerTask() { foreach (var task in BackgroundTaskRegistration.AllTasks) if (task.Value.Name == TASK_NAME_TIMER) return; var builder = new BackgroundTaskBuilder(); builder.Name = TASK_NAME_TIMER; builder.TaskEntryPoint = (typeof(TileSchedulerTask)).FullName; builder.SetTrigger(new TimeTrigger(180, false)); builder.Register(); } -
将以下代码片段添加到
OnNavigatedTo方法中:_timer.Start(); UpdateClockText(); CreateClockTask(); -
将以下代码片段添加到
MainPage类定义下方:public sealed class TileSchedulerTask : IBackgroundTask { public void Run(IBackgroundTaskInstance taskInstance) { var deferral = taskInstance.GetDeferral(); CreateSchedule(); deferral.Complete(); } public static void CreateSchedule() { var tileUpdater = TileUpdateManager.CreateTileUpdaterForApplication(); var plannedUpdated = tileUpdater.GetScheduledTileNotifications(); DateTime now = DateTime.Now; DateTime planTill = now.AddHours(4); DateTime updateTime = new DateTime(now.Year, now.Month,now.Day, now.Hour, now.Minute, 0).AddMinutes(1); if (plannedUpdated.Count > 0) updateTime = plannedUpdated.Select(x =>x.DeliveryTime.DateTime).Union(new[] { updateTime}).Max(); XmlDocument documentNow = GetTilenotificationXml(now); tileUpdater.Update(new TileNotification(documentNow) {ExpirationTime = now.AddMinutes(1) }); for (var startPlanning = updateTime;startPlanning < planTill; startPlanning =startPlanning.AddMinutes(1)) { Debug.WriteLine(startPlanning); Debug.WriteLine(planTill); try { XmlDocument document = GetTilenotificationXml(startPlanning); var scheduledNotification = newScheduledTileNotification(document,new DateTimeOffset(startPlanning)) { ExpirationTime = startPlanning.AddMinutes(1) }; tileUpdater.AddToSchedule(scheduledNotification); } catch (Exception ex) { Debug.WriteLine("Error: " + ex.Message); } } } private static XmlDocument GetTilenotificationXml(DateTime dateTime) { string language =GlobalizationPreferences.Languages.First(); var cultureInfo = new CultureInfo(language); string shortDate = dateTime.ToString(cultureInfo.DateTimeFormat.ShortTimePattern); string longDate = dateTime.ToString(cultureInfo.DateTimeFormat.LongDatePattern); var document = XElement.Parse(string.Format(@"<tile> <visual> <binding template=""TileSquareText02""> <text id=""1"">{0}</text> <text id=""2"">{1}</text> </binding> <binding template=""TileWideText01""> <text id=""1"">{0}</text> <text id=""2"">{1}</text> <text id=""3""></text> <text id=""4""></text> </binding> </visual> </tile>", shortDate, longDate)); return document.ToXmlDocument(); } } public static class DocumentExtensions { public static XmlDocument ToXmlDocument(thisXElement xDocument) { var xmlDocument = new XmlDocument(); xmlDocument.LoadXml(xDocument.ToString()); return xmlDocument; } } -
运行程序。
它是如何工作的...
前面的程序展示了如何创建一个基于时间的后台任务,以及如何在 Windows 10 开始菜单上的实时磁贴上显示此任务的更新。编程通用 Windows 平台应用程序本身就是一个相当具有挑战性的任务——你必须关注应用程序挂起/恢复其状态以及许多其他事情。在这里,我们将集中精力完成我们的主要任务,而将次要问题留在一旁。
我们的主要目标是当应用程序本身不在前台时运行一些代码。首先,我们创建了一个IBackgroundTask接口的实现。这是我们编写的代码,当接收到触发信号时,将调用Run方法。如果Run方法中包含带有await的异步代码,我们必须使用食谱中显示的特殊延迟对象来明确指定何时开始和结束Run方法的执行。在我们的情况下,方法调用是同步的,但为了说明这个要求,我们使用延迟对象。
在我们的Run方法中,我们为 4 个小时创建了一组每分钟一次的磁贴更新,并使用ScheduledTaskNotification类将其注册到TileUpdateManager中。磁贴使用特殊的 XML 格式来指定文本在其中的确切位置。当我们从系统中触发任务时,它为接下来的 4 个小时安排了一分钟的磁贴更新。然后,我们需要注册我们的后台任务。我们这样做两次;一次注册提供了一个UserPresent触发器,这意味着当用户登录时,此任务将被触发。下一个触发器是一个时间触发器,每 3 小时运行一次任务。
当程序运行时,它会创建一个计时器,该计时器在应用程序处于前台时运行。同时,它尝试注册后台任务;为了注册这些任务,程序需要用户权限,并且会显示一个对话框请求用户权限。现在,我们已经为接下来的 4 小时安排了实时磁贴更新。如果我们关闭我们的应用程序,实时磁贴将继续每分钟显示新的时间。在接下来的 3 小时内,时间触发器将再次运行我们的后台任务,并且我们将安排另一个实时磁贴更新。
在 OS X 上运行.NET Core 应用程序
这个食谱展示了如何在 OS X 上安装.NET Core 应用程序,以及如何构建和运行.NET 控制台应用程序。
准备工作
为了完成这个食谱,你需要一个 Mac OS X 操作系统。没有其他先决条件。这个食谱的源代码可以在BookSamples\Chapter11\Recipe4找到。
如何操作...
要了解如何运行.NET Core 应用程序,请执行以下步骤:
-
在您的 OS X 机器上安装 .NET Core。您可以访问
dotnet.github.io/getting-started/并遵循那里的安装说明。由于 .NET Core 处于预发布阶段,在本书出版之前,安装和使用场景可能会发生变化。在这种情况下,请参考网站上的说明。 -
在您下载了
.pkg文件后,按住 Control 键打开它。这将解锁文件,并允许您安装它。 -
在您安装完软件包后,您需要安装 OpenSSL。最简单的方法是首先安装 homebrew 软件包管理器。打开终端窗口并运行以下命令:
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" -
然后,您可以在其中输入以下内容来安装 OpenSSL:
brew install openssl -
同时还有一个小的注意事项,即撰写本书时,.NET Core 需要增加打开文件限制。这可以通过输入以下内容来实现:
sudo sysctl -w kern.maxfiles=20480 sudo sysctl -w kern.maxfilesperproc=18000 sudo ulimit -S -n 2048 -
现在您已经安装了 .NET Core 并准备开始。要创建一个示例 Hello World 应用程序,您可以创建一个目录并创建一个空的应用程序:
mkdir HelloWorld cd HelloWorld dotnet new -
让我们检查默认应用程序是否工作。要运行代码,我们必须恢复依赖项并构建和运行应用程序。为此,请输入以下命令:
dotnet restore dotnet run -
现在,让我们尝试运行一些异步代码。在
Program.cs文件中,将代码更改为以下内容:using System; using System.Threading.Tasks; using static System.Console; namespace OSXConsoleApplication { class Program { static void Main(string[] args) { WriteLine(".NET Core app on OS X"); RunCodeAsync().GetAwaiter().GetResult(); } static async Task RunCodeAsync() { try { string result = await GetInfoAsync("Async 1"); WriteLine(result); result = await GetInfoAsync("Async 2"); WriteLine(result); } catch (Exception ex) { WriteLine(ex); } } static async Task<string> GetInfoAsync(string name) { WriteLine($"Task {name} started!"); await Task.Delay(TimeSpan.FromSeconds(2)); if(name == "Async 2") throw new Exception("Boom!"); return $"Task {name} completed successfully!" // + $"Thread id {System.Threading.Thread.CurrentThread.ManagedThreadId}." ; } } } -
使用
dotnet run命令运行程序。
它是如何工作的...
在这里,我们从网站上下载包含 .NET Core 安装包的 .pkg 文件并将其安装。我们还使用 homebrew 软件包管理器(它也会被安装)安装 OpenSSL 库。除此之外,我们还在 OS X 中增加打开文件限制,以便能够恢复 .NET Core 依赖项。
然后,我们为 .NET Core 应用程序创建一个单独的文件夹,创建一个空白控制台应用程序,并检查恢复依赖项和运行代码是否一切正常。
最后,我们创建一段简单的异步代码并尝试运行它。它应该运行良好,显示第一个任务成功完成的消息。第二个任务抛出了异常,但被正确处理。但是,如果您尝试取消注释一行旨在显示线程特定信息的代码,代码将无法编译,因为 .NET Core 不支持 Thread API。
在 Ubuntu Linux 上运行 .NET Core 应用程序
这个菜谱展示了如何在 Ubuntu 上安装 .NET Core 应用程序,以及如何构建和运行 .NET 控制台应用程序。
准备工作
要完成这个菜谱,您需要一个 Ubuntu Linux 14.04 操作系统。没有其他先决条件。这个菜谱的源代码可以在 BookSamples\Chapter11\Recipe5 找到。
如何操作...
要了解如何运行 .NET Core 应用程序,请执行以下步骤:
-
在你的 Ubuntu 机器上安装.NET Core。你可以访问
dotnet.github.io/getting-started/并遵循那里的安装说明。由于.NET Core 处于预发布阶段,安装和使用场景可能会在本书出版时发生变化。在这种情况下,请参考网站上的说明。 -
首先,打开一个终端窗口并运行以下命令:
sudo sh -c 'echo "deb [arch=amd64] http://apt-mo.trafficmanager.net/repos/dotnet/ trusty main" > /etc/apt/sources.list.d/dotnetdev.list' sudo apt-key adv --keyserver apt-mo.trafficmanager.net --recv-keys 417A0893 sudo apt-get update -
然后,你可以在终端窗口中输入以下内容来安装.NET Core:
sudo apt-get install dotnet=1.0.0.001331-1 -
现在,你已经安装了.NET Core 并准备就绪。要创建一个示例 Hello World 应用程序,你可以创建一个目录并创建一个空的应用程序:
mkdir HelloWorld cd HelloWorld dotnet new -
让我们检查默认应用程序是否工作。要运行代码,我们必须恢复依赖项并构建和运行应用程序。为此,请输入以下命令:
dotnet restore dotnet run -
现在,让我们尝试运行一些异步代码。在
Program.cs文件中,将代码更改为以下内容:using System; using System.Threading.Tasks; using static System.Console; namespace OSXConsoleApplication { class Program { static void Main(string[] args) { WriteLine(".NET Core app on Ubuntu"); RunCodeAsync().GetAwaiter().GetResult(); } static async Task RunCodeAsync() { try { string result = await GetInfoAsync("Async 1"); WriteLine(result); result = await GetInfoAsync("Async 2"); WriteLine(result); } catch (Exception ex) { WriteLine(ex); } } static async Task<string> GetInfoAsync(string name) { WriteLine($"Task {name} started!"); await Task.Delay(TimeSpan.FromSeconds(2)); if(name == "Async 2") throw new Exception("Boom!"); return $"Task {name} completed successfully!" // + $"Thread id {System.Threading.Thread.CurrentThread.ManagedThreadId}." ; } } } -
使用
dotnet run命令运行程序。
它是如何工作的...
在这里,我们首先设置 apt-get feed,它托管我们需要的.NET Core 包。这是必要的,因为在写作的时候,.NET Core for Linux 可能尚未发布。当然,当发布发生时,它将进入正常的 apt-get feed,你不需要向其中添加自定义 feed。完成此操作后,我们使用 apt-get 安装当前正在工作的.NET Core 版本。
然后,我们为.NET Core 应用程序创建一个单独的文件夹,创建一个空白控制台应用程序,并检查在恢复依赖项和运行代码时是否一切正常。
最后,我们创建一个简单的异步代码并尝试运行它。它应该运行良好,显示第一条任务成功完成的消息,第二条任务抛出了异常,并且被正确处理。但是,如果你尝试取消注释一行旨在显示线程特定信息的代码,代码将无法编译,因为.NET Core 不支持 Thread API。













浙公网安备 33010602011771号