C-10-和--NET6-并行和并发编程指南-全-

C#10 和 .NET6 并行和并发编程指南(全)

原文:zh.annas-archive.org/md5/d9402ed94860db63b33a3aeee96cb0de

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

并行编程和并发在现代软件开发中变得非常普遍。在本书中,您将学习如何在构建下一个应用程序时利用 .NET 6 中的最新异步、并行和并发功能。我们将探讨多线程 C# 开发模式和最佳实践的强大之处。通过通过简洁的实际示例探索 .NET 中线程的优点和挑战,为您的项目选择正确的选项将变得习以为常。

在向新的或现有的 .NET 应用程序引入多线程时,您有许多选择。本书的目标不仅是教会您如何使用 C# 和 .NET 中的并行编程和并发,还帮助您了解在特定场景下应选择哪种结构。无论您是为桌面、移动、网页还是云应用开发,性能和响应性都是应用程序成功的关键。本书将帮助所有类型的 C# 开发者将他们的应用程序扩展到用户的需求,并避免在多线程开发中经常遇到的陷阱。

本书面向对象

本书面向希望在使用 .NET 构建应用程序时采用最新并行和并发特性的初学者到中级水平的 .NET 开发者。您应该对 C# 语言有扎实的理解,并熟悉 .NET Framework 或 .NET Core 的某个版本。

本书涵盖内容

第一章托管线程概念,涵盖了在 .NET 中使用托管线程的基础知识。我们将讨论如何创建和销毁线程、处理异常、同步数据和 .NET 提供用于处理后台操作的对象。您将获得在 .NET 应用程序中管理线程的基本理解。本章中的实际示例将说明如何在 C# 项目中使用托管线程。

第二章.NET 中多线程编程的演变,介绍了将在后续章节中更深入探讨的一些概念和功能,包括 async/await、并发集合和并行性。您将了解在选择如何处理应用程序中的并发时,它们的选项是如何扩展的。

第三章托管线程的最佳实践,涵盖了在整合托管线程概念时的一些最佳实践。我们将涵盖静态数据、死锁和耗尽托管资源等重要概念。这些都是可能导致应用程序不稳定和意外行为的问题。您将获得避免这些陷阱的实际建议。

第四章使用线程池提高用户界面响应性,解释了如何在 .NET 中使用线程池。本章中的实际示例将为您提供确保 .NET 应用程序中 UI 响应性的宝贵选项。

第五章, 使用 C# 进行异步编程,解释了 C# 中的异步编程,并探讨了在 .NET 中最佳使用任务的方法。

第六章, 并行编程概念,深入探讨了任务并行库 (TPL) 和任务概念。

第七章, 任务并行库 (TPL) 和数据流,介绍了 TPL 数据流库,并通过深入示例说明了其使用的常见模式。

第八章, 并行数据结构和并行 LINQ (PLINQ),探讨了 .NET 的一些有用功能,包括并行 LINQ (PLINQ)。通过一些 C# 中 PLINQ 的实际示例来跟随。

第九章, 在 .NET 中使用并发集合,深入探讨了有助于在代码中使用并发和并行时提供数据完整性的某些并发集合。

第十章, 使用 Visual Studio 调试多线程应用程序,教您如何利用 Visual Studio 的功能调试多线程 .NET 应用程序。本章将通过具体示例详细探讨这些工具。

第十一章, 取消异步工作,深入探讨了使用 .NET 取消并发和并行工作的不同方法。您将深入了解如何安全地取消异步工作。

第十二章, 单元测试异步、并发和并行代码,提供了一些具体的建议和现实世界的示例,说明开发者如何对使用多线程结构的代码进行单元测试。这些示例将说明单元测试如何在覆盖执行多线程操作代码的同时仍然可靠。

要充分利用本书

要跟随本书中的示例,建议 Windows 开发者使用以下软件:

  • Visual Studio 2022 版本 17.0 或更高版本

  • .NET 6

虽然推荐使用这些工具,但如果您已安装 .NET 6 SDK,则可以使用您首选的编辑器处理大多数示例。例如,macOS 10.13 或更高版本的 Visual Studio 2022 for Mac、JetBrains Rider 或 Visual Studio Code 都可以正常工作。然而,对于任何 WPF 或 WinForms 项目,都需要 Visual Studio 和 Windows。当 Visual Studio 和 .NET 的新版本发布时,也应与本书中的示例兼容。

预期您对 C# 和 .NET 有基础的了解,并具备对语言集成查询 (LINQ) 的实际操作知识。

最新的 Visual Studio 2022 安装说明和先决条件始终可以在 Microsoft Docs 上找到:docs.microsoft.com/visualstudio/install/install-visual-studio?view=vs-2022

如果您正在使用本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。

如果您不熟悉 LINQ,Microsoft Docs 上有一个很好的 C#参考,可以帮助您在学习本书中的示例之前开始:docs.microsoft.com/dotnet/csharp/programming-guide/concepts/linq/

在阅读本书之后,我还建议探索.NET 并行编程团队博客上的帖子。大多数文章都有几年历史,但它们探讨了在构建暴露并行编程结构的.NET 库时所做的许多决策背后的思考:devblogs.microsoft.com/pfxteam/

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6。如果代码有更新,它将在 GitHub 仓库中更新。

我们还提供了来自我们丰富的图书和视频目录中的其他代码包,可在 GitHub 上找到:github.com/PacktPublishing/。查看它们吧!

下载颜色图像

我们还提供了包含本书中使用的截图和图表的颜色图像的 PDF 文件。您可以从这里下载:packt.link/Z4GcQ

使用的约定

本书使用了多种文本约定。

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“通过调用ThreadPool.SetMaxThreads,你可以更改workerThreadscompletionPortThreads的最大值。”

代码块设置如下:

public async Task PerformCalculations()
{
    _runningTotal = 3;
    await MultiplyValue().ContinueWith(async (Task) => {
        await AddValue();
        });
    Console.WriteLine($”Running total is {_runningTotal}”);
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

    private async Task MultiplyValue()
    {
        await Task.Delay(100);
        var currentTotal = Interlocked.Read(ref 
            _runningTotal);
        Interlocked.Exchange(ref _runningTotal, 
            currentTotal * 10);
    }
}

任何命令行输入或输出都应如下编写:

$ mkdir css
$ cd css

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“让我们看看如何在我们的CancellationPatterns项目中实现这个的快速示例。”

小贴士或重要注意事项

看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上发现我们作品的任何非法副本,我们将非常感激您能提供位置地址或网站名称。请通过 copyright@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《使用 C# 10 和 .NET 6 进行并行编程和并发》,我们很乐意听到您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都非常重要,并将帮助我们确保我们提供高质量的内容。

第一部分:.NET 线程简介

在本部分,你将学习 .NET 中托管线程的基础知识,了解它自 .NET 框架早期以来是如何演变的,并掌握一些最佳实践以避免常见的陷阱。

本部分包含以下章节:

  • 第一章托管线程概念

  • 第二章,*.NET 多线程编程的演变

  • 第三章托管线程的最佳实践

  • 第四章使用线程提高用户界面响应性

第一章:第一章: 管理线程概念

C#中的asyncawait关键字。本书将涵盖接下来的章节中所有这些概念。

在本章中,我们将从.NET 中如何使用托管线程的基本知识开始。您将学习如何创建和销毁线程,处理异常,同步数据,并利用.NET 提供的对象来处理后台操作。此外,您还将获得.NET 应用程序中线程管理的基本理解。本章中的实际示例将说明如何在 C#项目中利用托管线程。

在本章中,我们将涵盖以下主题:

  • .NET 线程基础知识

  • 创建和销毁线程

  • 处理线程异常

  • 在线程间同步数据

  • 调度和取消工作

通过从.NET 线程的核心概念开始,您将在学习本书的过程中获得坚实的基础。理解基础知识对于在.NET 应用程序中引入线程和异步操作时防止犯下常见错误非常重要。耗尽资源或将应用程序的数据置于无效状态是非常容易的。让我们从使用 C#进行托管线程开始吧。

技术要求

为了在您的应用程序中负责任地使用线程,您应该确切了解线程是什么以及您的应用程序的进程如何使用线程。

  • Visual Studio 2022 版本 17.0 或更高版本

  • .NET 6

虽然以下推荐是好的,但只要您安装了.NET 6,您就可以使用您喜欢的编辑器。例如,Visual Studio 2022 for Mac,JetBrains Rider 或 Visual Studio Code 都将同样有效。

本章的所有代码示例都可以在 GitHub 上找到,网址为 https://github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter01。

.NET 线程基础知识

是时候学习 C#和.NET 中线程的基本知识,开始我们的旅程了。我们将介绍.NET 6 中可用的托管线程概念,但许多这些功能自.NET 之初就已经存在。《System.Threading》命名空间自.NET Framework 1.0 以来就可用。在随后的 20 年里,为开发者添加了许多有用的功能。

为了在您的应用程序中负责任地使用线程,您应该确切了解线程是什么以及您的应用程序的进程如何使用线程。

线程和进程

我们将从应用程序处理的基本单元,线程和进程开始我们的旅程。一个进程封装了一个应用程序的所有执行。这在所有平台和框架中都是正确的。在.NET 中,您可以将进程视为您的.exe或托管服务。

注意

在.NET Framework 中,引入了应用程序域(或 app domains)的概念,它在一个进程内创建隔离单元。这些应用程序域通过隔离加载到新应用程序域中的代码的执行来提供安全和可靠性。应用程序域仍然存在,但在.NET Core 和.NET 的现代版本中,开发人员无法创建或卸载它们。要了解更多关于应用程序域的信息,请查看 Microsoft Docs 上的这篇文章:https://docs.microsoft.com/dotnet/framework/app-domains/application-domains。

线程代表进程中的一个单一执行单元。默认情况下,.NET 应用程序将在单个线程上执行所有其逻辑(即主要或主线程)。开发人员可以利用托管线程和其他.NET 结构从单线程世界迁移到多线程世界,但您如何知道何时采取这一步?

我们应该在.NET 中使用多线程的什么时候?

在决定是否将线程引入应用程序时,需要考虑多个因素。这些因素既包括应用程序内部的,也包括外部的。外部因素包括硬件方面,例如应用程序将部署的位置、应用程序运行处的处理器有多强大,以及在这些系统上还将运行哪些其他类型的进程?

如果您的应用程序将竞争有限的资源,最好在多线程的使用上保持谨慎。如果用户觉得您的应用程序正在影响他们系统的性能,您将需要减少您的过程所消耗的线程数量。另一个需要考虑的因素是您的应用程序相对于系统上其他应用程序的重要性。关键任务应用程序将分配更多资源,以便在需要时保持响应。

引入线程的其他常见原因与应用程序本身有关。桌面和移动应用程序需要保持用户界面UI)对用户输入的响应。如果应用程序需要处理大量数据或从数据库、文件或网络资源中加载数据,则在主线程上执行可能会导致 UI 冻结或延迟。此外,在多个线程上并行执行长时间运行的任务可以减少任务的整体执行时间。

如果任务的执行对应用程序状态不是至关重要的,这些操作可以卸载到后台线程。让我们看看.NET 中前台线程和后台线程之间的区别。

背景线程

前台线程和后台线程之间的区别可能并非如您所想。作为前台线程创建的管理线程不是 UI 线程或主线程。前台线程是那些如果正在运行,将阻止托管进程终止的线程。如果一个应用程序被终止,任何正在运行的背景线程都将被停止,以便进程可以关闭。

默认情况下,新创建的线程是前台线程。要创建一个新的后台线程,在启动线程之前将Thread.IsBackground属性设置为true。此外,您还可以使用IsBackground属性来确定现有线程的后台状态。让我们看看一个你可能想在应用程序中使用后台线程的例子。

在这个例子中,我们将使用 Visual Studio 创建一个控制台应用程序,该应用程序将在后台线程上持续检查网络连接的状态。创建一个新的.NET 6 控制台应用程序项目,命名为BackgroundPingConsoleApp,并在Program.cs中输入以下代码:

Console.WriteLine("Hello, World!");
var bgThread = new Thread(() =>
{
    while (true)
    {
        bool isNetworkUp = System.Net.NetworkInformation
            .NetworkInterface.GetIsNetworkAvailable();
        Console.WriteLine($"Is network available? Answer: 
            {isNetworkUp}");
        Thread.Sleep(100);
    }
});
bgThread.IsBackground = true;
bgThread.Start();
for (int i = 0; i < 10; i++)
{
    Console.WriteLine("Main thread working...");
    Task.Delay(500);
}
Console.WriteLine("Done");
Console.ReadKey();

在运行并检查输出之前,让我们讨论前面代码的每个部分:

  1. 第一个Console.WriteLine语句是由项目模板创建的。我们将保留它以帮助说明控制台输出的顺序。

  2. 接下来,我们创建一个新的名为bgThreadThread类型。在线程的主体内部,有一个将连续执行直到线程被终止的while循环。在循环内部,我们调用GetIsNetworkAvailable方法并将该调用的结果输出到控制台。在重新开始之前,我们使用Thread.Sleep注入 100 毫秒的延迟。

  3. 在创建线程之后的下一行是本节课的关键部分:

    bgThread.IsBackground = true;
    

IsBackground属性设置为true使得我们的新线程成为后台线程。这告诉我们的应用程序,线程内部执行的代码对应用程序不是关键的,进程可以在不需要等待线程完成其工作的情况下终止。在这里这是一个好事,因为我们所创建的while循环永远不会完成。

  1. 在下一行,我们使用Start方法启动线程。

  2. 接下来,应用程序在其主线程内部启动一些工作。一个for循环将执行 10 次,并将"Main thread working..."输出到控制台。在循环的每次迭代结束时,使用Task.Delay等待 500 毫秒,希望为后台线程执行一些工作提供一些时间。

  3. for循环之后,应用程序将输出"Done"到控制台,并等待用户输入以使用Console.ReadKey方法终止应用程序。

现在,运行应用程序并检查控制台输出。当你觉得运行时间足够长时,可以按任意键停止应用程序:

图 1.1 – 查看线程化控制台应用程序的输出

图 1.1 – 查看线程化控制台应用程序的输出

结果可能不是你所期望的。你可以看到程序在开始任何后台线程的工作之前,已经在主线程上执行了所有逻辑。稍后,我们将看到如何更改线程的优先级以操纵哪些工作将首先被处理。

在这个例子中,重要的是要理解,我们能够通过按下一个键来执行 Console.ReadKey 命令来停止控制台应用程序。即使后台线程仍在运行,进程也不认为线程是应用程序的关键。如果你注释掉以下行,应用程序将不再通过按键来终止:

bgThread.IsBackground = true;

应用程序必须通过关闭命令窗口或使用 Visual Studio 中的 调试 | 停止调试 菜单项来停止。稍后,在 调度和取消工作 部分中,我们将学习如何在一个受管理的线程中取消工作。

在我们查看更多使用受管理线程的示例之前,我们将花一些时间来了解它们究竟是什么。

什么是受管理的线程?

在 .NET 中,我们在上一个示例中使用的 System.Threading.Thread 类。当前进程的受管理执行环境监视了作为进程一部分运行的所有的线程。.NET 代码中的 PInvoke 调用。如果这个线程是第一次进入受管理的环境,.NET 将创建一个新的 Thread 对象,由执行环境进行管理。

可以使用 Thread 对象的 ManagedThreadId 属性唯一地标识一个受管理的线程。这个属性是一个整数,保证在整个线程中是唯一的,并且随着时间的推移不会改变。

ThreadState 属性是一个只读属性,它提供了 Thread 对象当前的执行状态。在 .NET 线程基础 部分的示例中,如果我们调用 bgThread.Start() 之前检查了 ThreadState 属性,它将是 未启动。调用 Start 后,状态将变为 后台。如果不是后台线程,调用 Start 将将 ThreadState 属性更改为 正在运行

这里是 ThreadState 枚举值的完整列表:

  • 已中止:线程已被中止。

  • 请求中止:已请求中止但尚未完成。

  • 后台:线程在后台运行(IsBackground 已设置为 true)。

  • 正在运行:线程目前正在运行。

  • 已停止:线程已被停止。

  • 请求停止:已请求停止但尚未完成。

  • 已挂起:线程已被挂起。

  • 请求挂起:已请求挂起线程但尚未完成。

  • 未启动:线程已经被创建但尚未启动。

  • 等待/睡眠/加入:线程目前被阻塞。

Thread.IsAlive 属性是一个较为通用的属性,可以告诉你一个线程是否正在运行。它是一个 boolean 类型的属性,如果线程已经开始并且没有被以某种方式停止或取消,它将返回 true

线程还有一个Name属性,如果它们从未被设置,则默认为null。一旦在线程上设置了Name属性,它就不能再更改。如果你尝试设置非null线程的Name属性,它将抛出InvalidOperationException

我们将在本章的剩余部分介绍托管线程的更多方面。在下一节中,我们将深入了解.NET 中创建和销毁线程的可用方法和选项。

创建和销毁线程

首先讨论的应该是Thread类。此外,我们还将查看一些暂停或中断线程执行的方法。最后,我们将介绍一些销毁或终止线程执行的方法。

让我们更详细地讨论创建和启动线程。

创建托管线程

创建Thread对象。Thread类有四个构造函数重载:

  • Thread(ParameterizedThreadStart): 这创建一个新的Thread对象。它是通过传递一个构造函数带有参数的对象的委托来实现的,该参数可以在调用Thread.Start()时传递。

  • Thread(ThreadStart): 这将创建一个新的Thread对象,该对象将执行要调用的方法,该方法作为ThreadStart属性提供。

  • Thread(ParameterizedThreadStart, Int32): 这添加了一个maxStackSize参数。避免使用这个重载,因为它最好让.NET 管理堆栈大小。

  • Thread(ThreadStart, Int32): 这添加了一个maxStackSize参数。避免使用这个重载,因为它最好让.NET 管理堆栈大小。

我们的第一个示例使用了Thread(ThreadStart)构造函数。让我们看看使用ParameterizedThreadStart通过限制while循环的迭代次数来传递值的那个代码版本:

Console.WriteLine("Hello, World!");
var bgThread = new Thread((object? data) =>
{
    if (data is null) return;
    int counter = 0;
var result = int.TryParse(data.ToString(), 
        out int maxCount);
    if (!result) return;
    while (counter < maxCount)
    {
        bool isNetworkUp = System.Net.NetworkInformation
            .NetworkInterface.GetIsNetworkAvailable();
        Console.WriteLine($"Is network available? Answer: 
            {isNetworkUp}");
        Thread.Sleep(100);
        counter++;
    }
});
bgThread.IsBackground = true;
bgThread.Start(12);
for (int i = 0; i < 10; i++)
{
    Console.WriteLine("Main thread working...");
    Task.Delay(500);
}
Console.WriteLine("Done");
Console.ReadKey();

如果你运行应用程序,它将像最后一个示例一样运行,但后台线程应该只输出 12 行到控制台。你可以尝试将不同的整数值传递给Start方法,以查看这对控制台输出的影响。

如果你想获取当前正在执行代码的线程的引用,你可以使用Thread.CurrentThread静态属性:

var currentThread = System.Threading.Thread.CurrentThread;

如果你的代码需要检查当前线程的ManagedThreadIdPriority或它是否在后台运行,这可能很有用。

接下来,让我们看看我们如何暂停或中断线程的执行。

暂停线程执行

有时,暂停线程的执行是必要的。一个常见的现实生活中的例子是在后台线程上的重试机制。如果你有一个将日志数据发送到网络资源的方法,但网络不可用,你可以调用Thread.Sleep来等待特定的时间间隔然后再尝试。Thread.Sleep是一个静态方法,它将阻塞当前线程指定的时间(毫秒数)。不可能在除了当前线程之外的线程上调用Thread.Sleep

我们已经在本章的示例中使用了 Thread.Sleep,但让我们稍微修改一下代码,看看它如何影响事件顺序。将线程中的 Thread.Sleep 间隔更改为 10,删除将其变为后台线程的代码,并将 Task.Delay() 调用更改为 Thread.Sleep(100)

Console.WriteLine("Hello, World!");
var bgThread = new Thread((object? data) =>
{
    if (data is null) return;
    int counter = 0;
    var result = int.TryParse(data.ToString(), out int 
        maxCount);
    if (!result) return;
    while (counter < maxCount)
    {
        bool isNetworkUp = System.Net.NetworkInformation.
            NetworkInterface.GetIsNetworkAvailable();
        Console.WriteLine($"Is network available? Answer: 
             {isNetworkUp}");
        Thread.Sleep(10);
        counter++;
    }
});
bgThread.Start(12);
for (int i = 0; i < 12; i++)
{
    Console.WriteLine("Main thread working...");
    Thread.Sleep(100);
}
Console.WriteLine("Done");
Console.ReadKey();

再次运行应用程序时,您可以看到,在主线程上放置更大的延迟可以让 bgThread 中的进程在主线程完成其工作之前开始执行:

图 1.2 – 使用 Thread.Sleep 改变事件顺序

图 1.2 – 使用 Thread.Sleep 改变事件顺序

可以调整两个 Thread.Sleep 间隔以查看它们如何影响控制台输出。试一试!

此外,还可以将 Timeout.Infinite 传递给 Thread.Sleep。这将导致线程暂停,直到它被另一个线程或托管环境中断或终止。通过调用 Thread.Interrupt 来中断阻塞或暂停的线程。当线程被中断时,它将接收到 ThreadInterruptedException 异常。

异常处理程序应允许线程继续工作或清理任何剩余的工作。如果未处理异常,运行时会捕获异常并停止线程。在运行中的线程上调用 Thread.Interrupt 不会有任何效果,直到该线程被阻塞。

现在您已经了解了如何创建中断线程,让我们通过学习如何销毁线程来结束本节。

销毁托管线程

通常,Thread.Abort 方法。在 .NET Framework 中,对线程调用 Thread.Abort 会引发 ThreadAbortedException 异常并停止线程运行。在 .NET Core 或任何 .NET 的新版本中都没有提供终止线程的功能。如果某些代码需要被强制停止,建议您将其运行在与其他代码分开的进程中,并使用 Process.Kill 来终止其他进程。

任何其他线程终止都应通过取消合作来处理。我们将在 调度和取消工作 部分中看到如何做到这一点。接下来,让我们讨论一些在处理托管线程时需要处理的异常。

处理线程异常

有几种异常类型是针对托管线程的特定类型,包括我们在上一节中提到的 ThreadInterruptedException 异常。另一种特定于线程的异常类型是 ThreadAbortException。然而,正如我们在上一节中讨论的,.NET 6 中不支持 Thread.Abort,因此,尽管这种异常类型存在于 .NET 6 中,但处理它并不是必要的,因为这种类型的异常仅在 .NET Framework 应用程序中是可能的。

另外两个异常是ThreadStartException异常和ThreadStateException异常。如果在使用线程的用户代码执行之前启动托管线程存在问题,则会抛出ThreadStartException异常。当在当前ThreadState属性不可用的情况下调用线程上的方法时,会抛出ThreadStateException异常。例如,在一个已经启动的线程上调用Thread.Start是无效的,并会导致ThreadStateException异常。通常可以通过在操作线程之前检查ThreadState属性来避免这些类型的异常。

在多线程应用程序中实现全面的异常处理非常重要。如果托管线程中的代码开始无声地失败,没有任何日志记录或导致进程终止,应用程序可能会进入无效状态。这也可能导致性能下降和响应迟钝。虽然许多应用程序的性能下降可能会很快被发现,但某些服务和基于非 GUI 的其他应用程序可能会在一段时间内没有任何问题被发现。在异常处理程序中添加日志记录,并有一个在日志报告失败时提醒用户的过程,将有助于防止未检测到的失败线程的问题。

在下一节中,我们将讨论多线程代码的另一个挑战:在多个线程间保持数据同步。

在线程间同步数据

在本节中,我们将探讨.NET 中用于在多个线程间同步数据的一些方法。如果处理不当,线程间的共享数据可能是多线程开发的主要痛点之一。在.NET 中,具有线程保护机制的类被称为线程安全

多线程应用程序中的数据可以通过几种不同的方式进行同步:

  • Monitor类或借助.NET 编译器的帮助。

  • 手动同步:.NET 中有几个可用于手动同步数据的同步原语

  • 同步上下文:这仅在.NET Framework 和 Xamarin 应用程序中可用。

  • System.Collections.Concurrent 类:有一些专门的.NET 集合用于处理并发。我们将在第九章中探讨这些内容。

在本节中,我们将探讨前两种方法。让我们首先讨论如何在您的应用程序中同步代码区域。

同步代码区域

您可以使用几种技术来同步代码区域。我们将首先讨论的是Monitor类。您可以使用Monitor.EnterMonitor.Exit调用将多个线程可以访问的代码块包围起来:

...
Monitor.Enter(order);
order.AddDetails(orderDetail);
Monitor.Exit(order);
...

在这个例子中,假设你有一个 order 对象,它正由多个线程并行更新。Monitor 类将在当前线程向 order 对象添加 orderDetail 项目时锁定其他线程的访问。最小化向其他线程引入等待时间的关键是只锁定需要同步的代码行。

注意

如本节所述,Interlocked 类在用户模式下执行原子操作,而不是在内核模式下。如果你想了解更多关于这种区别的信息,我建议查看 Nguyen Thai Duong 的这篇博客文章:https://duongnt.com/interlocked-synchronization/。

Interlocked 类提供了在多个线程间共享的对象上执行原子操作的方法。以下方法列表是 Interlocked 类的一部分:

  • Add:这个方法将两个整数相加,用它们的和替换第一个

  • And:这是两个整数的位与操作

  • CompareExchange:这个方法比较两个对象是否相等,如果相等则替换第一个

  • Decrement:这个方法会递减一个整数

  • Exchange:这个方法将变量设置为新的值

  • Increment:这个方法会递增一个整数

  • Or:这是两个整数的位或操作

这些 Interlocked 操作将仅在操作期间锁定对目标对象的访问。

此外,C# 中的 lock 语句可以用来锁定对代码块的访问,使其只能由单个线程访问。lock 语句是一个使用 .NET 的 Monitor.EnterMonitor.Exit 操作实现的编程语言构造。

对于 lockMonitor 块,有一些内置的编译器支持。如果在这些块中抛出异常,锁会自动释放。C# 编译器在同步代码周围生成一个 try/finally 块,并在 finally 块中调用 Monitor.Exit

让我们通过查看一些其他提供手动数据同步支持的 .NET 类来结束本节关于同步的讨论。

手动同步

在跨多个线程同步数据时,使用手动同步是很常见的。某些类型的数据无法用其他方式保护,例如这些:

  • 全局字段:这些是可以跨应用程序全局访问的变量。

  • 静态字段:这些是类中的静态变量。

  • 实例字段:这些是类中的实例变量。

这些字段没有方法体,因此无法在它们周围放置同步代码区域。使用手动同步,你可以保护所有使用这些对象的地方。这些区域可以用 C# 中的 lock 语句进行保护,但某些其他同步原语提供了对共享数据的访问,并且可以在更细粒度级别上协调线程间的交互。我们将首先检查的构造是 System.Threading.Mutex 类。

Mutex 类与 Monitor 类类似,它阻止对代码区域的访问,但它还可以提供授予其他进程访问的能力。当使用 Mutex 类时,使用 WaitOne()ReleaseMutex() 方法来获取和释放锁。让我们看看相同的顺序/顺序详情示例。这次,我们将使用在类级别声明的 Mutex 类:

private static Mutex orderMutex = new Mutex();
...
orderMutex.WaitOne();
order.AddDetails(orderDetail);
orderMutex.ReleaseMutex();
...

如果你想在 Mutex 类上强制执行超时周期,你可以调用带有超时值的 WaitOne 重载:

orderMutex.WaitOne(500);

重要的是要注意,当你完成对对象的操作时,Mutex 是通过调用对象的 Dispose() 方法来释放的。此外,你还可以在 using 块中封装可释放的类型,以便间接地将其释放。

在本节中,我们将要检查的最后一个 .NET 手动锁定结构是 ReaderWriterLockSlim 类。如果你有一个在多个线程间使用的对象,你可以使用这种类型;但大多数代码都是从该对象中读取数据。你不想在读取数据的代码块中锁定对对象的访问,但你确实想防止在对象被更新或同时写入时进行读取。这被称为“多个读取者,单个写入者”。

这个 ContactListManager 类包含一个可以按电话号码添加或检索的联系人类表。该类假设这些操作可以从多个线程调用,并在 GetContactByPhoneNumber 方法中使用 ReaderWriterLockSlim 类应用读锁,在 AddContact 方法中使用写锁。锁在 finally 块中释放,以确保它们总是被释放,即使遇到异常也是如此:

public class ContactListManager
{
    private readonly List<Contact> contacts;
    private readonly ReaderWriterLockSlim contactLock = 
        new ReaderWriterLockSlim();
    public ContactListManager(
        List<Contact> initialContacts)
    {
        contacts = initialContacts;
    }
    public void AddContact(Contact newContact)
    {
        try
        {
            contactLock.EnterWriteLock();
            contacts.Add(newContact);
        }
        finally
        {
            contactLock.ExitWriteLock();
        }
    }
    public Contact GetContactByPhoneNumber(string 
        phoneNumber)
    {
        try
        {
            contactLock.EnterReadLock();
            return contacts.FirstOrDefault(x => 
                x.PhoneNumber == phoneNumber);
        }
        finally
        {
            contactLock.ExitReadLock();
        }
    }
}

如果你向 ContactListManager 类添加一个 DeleteContact 方法,你会利用相同的 EnterWriteLock 方法来防止与类中其他操作的冲突。如果在 contacts 的某个使用中忘记加锁,它可能导致其他任何操作失败。此外,还可以将超时应用于 ReaderWriterLockSlim 锁:

contacts.EnterWriteLock(1000);

在本节中,我们还没有涵盖几个其他同步原语,但我们讨论了一些你将最常用的最常见类型。要了解更多关于手动同步可用类型的信息,你可以访问 Microsoft Docs,网址为 https://docs.microsoft.com/dotnet/standard/threading/overview-of-synchronization-primitives。

现在我们已经探讨了在处理托管线程时同步数据的不同方法,在结束第一章之前,让我们再讨论两个重要的话题。我们将讨论在线程上调度工作以及如何协作地取消托管线程的技术。

调度和取消工作

在应用程序中编排多线程处理时,了解如何调度和取消托管线程上的工作非常重要。

让我们从查看在 .NET 中如何使用托管线程进行调度开始。

调度托管线程

当涉及到托管线程时,调度并不像听起来那么明确。没有机制可以告诉操作系统在特定时间启动工作或在特定间隔内执行。虽然你可以编写这种逻辑,但这可能不是必要的。调度托管线程的过程只是通过设置线程的优先级来管理的。为此,将Thread.Priority属性设置为可用的ThreadPriority值之一:HighestAboveNormalNormal(默认)、BelowNormalLowest

通常情况下,优先级较高的线程会先于优先级较低的线程执行。通常,优先级为Lowest的线程将不会执行,直到所有优先级更高的线程都已完成。如果Lowest优先级的线程已经开始执行,而一个Normal优先级的线程启动,则Lowest优先级的线程将被挂起,以便运行Normal优先级的线程。这些规则并非绝对,但你可以将它们作为参考。大多数情况下,你会为你的线程保留默认的Normal优先级。

当存在多个相同优先级的线程时,操作系统将循环遍历它们,在挂起工作并移动到下一个相同优先级的线程之前,为每个线程分配一个最大时间配额。这种逻辑会因操作系统而异,并且进程的优先级可以根据应用程序是否在 UI 的前台而改变。

让我们使用我们的网络检查代码来测试线程优先级:

  1. 首先在 Visual Studio 中创建一个新的控制台应用程序

  2. 向项目中添加一个名为NetworkingWork的新类,并添加一个名为CheckNetworkStatus的方法,其实现如下:

    public void CheckNetworkStatus(object data)
    {
        for (int i = 0; i < 12; i++)
        {
            bool isNetworkUp = System.Net.
                NetworkInformation.NetworkInterface
                    .GetIsNetworkAvailable();
            Console.WriteLine($"Thread priority 
                {(string)data}; Is network available? 
                    Answer: {isNetworkUp}");
            i++;
        }
    }
    

调用代码将传递一个参数,该参数包含当前正在执行消息的线程的优先级。这将作为for循环内部控制台输出的一个部分被添加,这样用户可以看到哪些优先级的线程首先运行。

  1. 接下来,将Program.cs的内容替换为以下代码:

    using BackgroundPingConsoleApp_sched;
    Console.WriteLine("Hello, World!");
    var networkingWork = new NetworkingWork();
    var bgThread1 = new 
        Thread(networkingWork.CheckNetworkStatus);
    var bgThread2 = new 
        Thread(networkingWork.CheckNetworkStatus);
    var bgThread3 = new 
        Thread(networkingWork.CheckNetworkStatus);
    var bgThread4 = new 
        Thread(networkingWork.CheckNetworkStatus);
    var bgThread5 = new 
        Thread(networkingWork.CheckNetworkStatus);
    bgThread1.Priority = ThreadPriority.Lowest;
    bgThread2.Priority = ThreadPriority.BelowNormal;
    bgThread3.Priority = ThreadPriority.Normal;
    bgThread4.Priority = ThreadPriority.AboveNormal;
    bgThread5.Priority = ThreadPriority.Highest;
    bgThread1.Start("Lowest");
    bgThread2.Start("BelowNormal");
    bgThread3.Start("Normal");
    bgThread4.Start("AboveNormal");
    bgThread5.Start("Highest");
    for (int i = 0; i < 10; i++)
    {
        Console.WriteLine("Main thread working...");
    }
    Console.WriteLine("Done");
    Console.ReadKey();
    

代码创建了五个Thread对象,每个对象都有一个不同的Thread.Priority值。为了使事情更有趣,线程是按照它们的优先级顺序相反的方式启动的。你可以自己尝试改变这个顺序,看看执行顺序是如何受到影响的。

  1. 现在运行应用程序并检查输出:

图 1.3 – 来自五个不同线程的控制台输出

图 1.3 – 来自五个不同线程的控制台输出

你可以看到,在我的案例中,操作系统是 Windows 11,有时会在所有优先级较高的线程完成工作之前执行优先级较低的线程。选择下一个要运行的线程的算法有点神秘。你还应该记住,这是多线程。多个线程同时运行。可以同时运行的线程的确切数量将因处理器或虚拟机配置而异。

让我们通过学习如何取消一个正在运行的线程来结束本次内容。

取消托管线程

取消托管线程是理解托管线程时需要了解的更重要概念之一。如果你在前台线程上运行长时间运行的操作,它们应该支持取消。有时你可能想通过你的应用程序的 UI 允许用户取消进程,或者取消可能是应用程序关闭时的清理过程的一部分。

要在托管线程中取消操作,你将使用CancellationToken参数。Thread对象本身并不像.NET 中的一些现代线程构造函数那样内置对取消令牌的支持。因此,我们必须将令牌传递给在新创建的线程中运行的方法。在下一个练习中,我们将修改前面的示例以支持取消:

  1. 首先,更新NetworkingWork.cs,使得传递给CheckNetworkStatus的参数是一个CancellationToken参数:

    public void CheckNetworkStatus(object data)
    {
        var cancelToken = (CancellationToken)data;
        while (!cancelToken.IsCancellationRequested)
        {
            bool isNetworkUp = System.Net
                .NetworkInformation.NetworkInterface
                    .GetIsNetworkAvailable();
            Console.WriteLine($"Is network available? 
                Answer: {isNetworkUp}");
        }
    }
    

代码将在IsCancellationRequested变为true之前,在while循环中持续检查网络状态。

  1. Program.cs中,我们将回到只使用一个Thread对象的工作方式。删除或注释掉所有之前的后台线程。为了将CancellationToken参数传递给Thread.Start方法,创建一个新的CancellationTokenSource对象,并将其命名为ctSource。取消令牌在Token属性中可用:

    var pingThread = new 
        Thread(networkingWork.CheckNetworkStatus);
    var ctSource = new CancellationTokenSource();
    pingThread.Start(ctSource.Token);
    ...
    
  2. 接下来,在for循环内部,添加一个Thread.Sleep(100)语句,以便在主线程挂起时允许pingThread执行:

    for (int i = 0; i < 10; i++)
    {
        Console.WriteLine("Main thread working...");
        Thread.Sleep(100);
    }
    
  3. for循环完成后,调用Cancel()方法,将线程重新连接到主线程,并销毁ctSource对象。Join方法将阻塞当前线程,等待pingThread使用此线程完成:

    ...
    ctSource.Cancel();
    pingThread.Join();
    ctSource.Dispose();
    
  4. 现在,当你运行应用程序时,你将看到在主线程上的最后一个Thread.Sleep语句执行后不久,网络检查停止:

图 1.4 – 控制台应用程序中取消线程

图 1.4 – 控制台应用程序中取消线程

现在,网络检查应用程序在监听按键以关闭应用程序之前,优雅地取消了线程工作。

当你在托管线程上有一个长时间运行的过程时,你应该在代码遍历循环、开始过程的下一个步骤以及在过程中的其他逻辑检查点时检查取消。如果操作使用计时器定期执行工作,则应在计时器每次执行时检查令牌。

另一种监听取消的方式是在托管线程内部注册一个Token.Register方法以接收取消回调。下面的CheckNetworkStatus2方法将像前面的示例一样工作:

public void CheckNetworkStatus2(object data)
{
    bool finish = false;
    var cancelToken = (CancellationToken)data;
    cancelToken.Register(() => {
        // Clean up and end pending work
        finish = true;
    });
    while (!finish)
    {
        bool isNetworkUp = System.Net.NetworkInformation
            .NetworkInterface.GetIsNetworkAvailable();
        Console.WriteLine($"Is network available? Answer: 
            {isNetworkUp}");
    }
}

如果你的代码中有多个部分需要监听取消请求,使用这样的委托会更有用。一个回调方法可以调用几个清理方法或设置另一个在整个线程中被监控的标志。它很好地封装了清理操作。

我们将在第十一章中重新讨论取消操作,因为我们将介绍新的并行和并发概念。然而,本节应该为理解接下来要讨论的内容提供一个坚实的基础。

这就结束了关于托管线程的最后一节。让我们总结一下,回顾一下我们已经学到的内容。

摘要

在本章中,我们介绍了托管线程的基础和System.Threading.Thread类。你现在应该对如何在.NET 中创建和调度线程有一个很好的理解。你学习了将数据传递给线程的一些技术以及如何使用后台线程进行非关键操作,这样它们就不会阻止你的应用程序终止。最后,我们使用了.NET 中取消线程的两种不同技术。

在下一章第二章中,我们将学习.NET 在过去 20 年中如何简化并改进了开发者的并行编程和并发。在.NET 4.5 中,通过添加asyncawait关键字,实现了重大改进,而.NET Core 移除了一些.NET Framework 的遗留线程结构。

问题

  1. 什么是托管线程?

  2. 你如何创建一个后台线程?

  3. 如果你尝试设置正在运行的线程的IsBackground属性会发生什么?

  4. .NET 是如何处理托管线程的调度的?

  5. 最大的线程优先级是什么?

  6. 在.NET 6 中调用Thread.Abort()时,线程会发生什么?

  7. 你如何将数据传递给新线程中的方法?

  8. 你如何注册一个回调,以便在请求取消线程时被调用?

第二章:第二章:.NET 中多线程编程的演变

随着 .NET 和 C# 在过去 20 年中的发展,引入了新的和创新的多线程编程方法。C# 添加了新的语言功能来支持异步编程,而 .NET Framework 和 .NET Core 添加了新的类型来支持这些语言。最具影响力的改进是在 C# 5 和 .NET Framework 4.0 中引入的,当时微软添加了 asyncawait 关键字。

本章将介绍将在后续章节中更深入探讨的概念和功能。这些概念包括 .NET 的 asyncawait、并发集合和并行。我们将从探讨何时以及为什么在 .NET 和 C# 中添加了线程功能开始。然后,我们将创建一些实际示例来展示如何使用这些新概念。最后,我们将通过讨论在您的项目中何时使用这些新功能是有意义的来结束本章。选择最适合每个实际场景的最佳工具非常重要。

在本章中,您将了解以下内容:

  • .NET 年份的线程

  • 超越线程基础

  • 并行简介

  • 并发简介

  • asyncawait 的基础知识

  • 选择正确的道路

到本章结束时,您将了解在选择如何处理 .NET 应用程序中的并发时,您的选项是如何扩大的。

技术要求

要跟随本章中的示例,建议 Windows 用户使用以下软件:

  • Visual Studio 2022 版本 17.0 或更高版本。

  • .NET 6.

  • 要使用 WorkingWithTimers 项目,您需要安装 Visual Studio 的 .NET 桌面开发工作负载。

虽然这些是推荐使用的,但如果您已安装 .NET 6,您可以使用您喜欢的编辑器。例如,macOS 10.13 或更高版本的 Visual Studio 2022 for Mac、JetBrains Rider 或 Visual Studio Code 都可以正常工作。

本章的所有代码示例都可以在 GitHub 上找到:https://github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter02。

让我们从 .NET 和 C# 的历史课开始本章。

.NET 年份的线程

自从 2002 年引入 .NET Framework 1.0 和 C# 1.0 以来,在 .NET 和 C# 中处理线程的工作已经经历了许多演变。在第一章中讨论的大部分关于 System.Threading.Thread 对象的概念,自那些 .NET 的早期日子起就已经可用。虽然 Thread 对象在 .NET 6 中仍然可用,并且对于简单场景可能很有用,但今天有更多优雅和现代的解决方案。

本节将突出显示最具影响力的并行和并发功能何时被添加。我们将首先跳过 8 年,来到 2010 年。

C# 4 和 .NET Framework 4.0

2010 年,微软发布了 Visual Studio 2010,与 C# 4 和.NET Framework 4.0 一同发布。虽然一些早期的语言和框架特性,如泛型lambda 表达式匿名方法有助于促进后来的线程功能,但这些 2010 年的发布自 2002 年以来对线程功能来说是最重要的。.NET Framework 包括了以下特性,将在后续章节中更详细地探讨:

  • System.Collections.Concurrent 命名空间提供对多线程代码中数据集合的安全访问。

  • Parallel.ForParallel.ForEach 以及使用 Parallel.Invoke 来调用并行操作。

  • AsParallel, WithCancellation, 和 WithDegreeOfParallelism

我们将在“并发简介”和“并行简介”部分介绍这些特性。接下来,我们将学习两年后包含在.NET 和 C#中的重要线程功能。

C# 5 和 6 以及 .NET Framework 4.5.x

2012 年,微软发布了可能是使用.NET 进行现代多线程编程最重要的特性:使用asyncawait进行异步编程。asyncawait关键字在 C# 5 中添加,当时.NET Framework 4.5 添加了 TPL。TPL 的核心是位于新System.Threading.Tasks命名空间中的Task类。

Task对象从异步操作返回,为开发者提供了检查操作状态或等待其完成的方式。异步任务的工作是在线程池的后台线程上执行的,而不是在主线程上。我们将在“线程基础之外”部分了解更多关于线程池的内容。本章的“async 和 await 基础”部分和第五章将更深入地讨论 TPL 的基本知识。第五章。

在接下来的几年中,添加了一些与异步编程相关的工具和语言特性。2013 年,发布了.NET Framework 4.5.1。这个版本对应于 Visual Studio 2013 的发布,它为异常处理程序的catchfinally块添加了异步调试功能。

下一个特性是在 2017 年随着微软从.NET Framework 持续转向.NET Core 而出现的。

C# 7.x 和 .NET Core 2.0

.NET 团队发布的.NET Core 的第二大版本包括了新的ValueTaskValueTask<TResult>类型。ValueTask类型是一个结构,它封装了一个Task或一个IValueTaskSource实例,并包含了一些额外的字段。它仅在 C# 7.0 或更高版本中使用时可用。添加ValueTask类型是因为在实践中,许多异步操作是同步完成的,但仍然需要分配一个Task实例来返回给调用者。在这些情况下,可以通过用ValueTask替换Task来提高性能,因为ValueTask在同步完成其工作时不会产生任何分配。要了解更多关于引入ValueTask背后的动机以及何时使用它,你可以阅读.NET 团队 Stephen Toub 的以下博客文章:https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/。

注意

如果你不太熟悉 Stephen Toub,他是微软的合作伙伴软件工程师,并在.NET 团队担任开发者。他在.NET 团队的工作对于将asyncawait和 TPL 带给.NET 开发者社区至关重要。你可以在.NET 并行编程博客上阅读他的其他文章:https://devblogs.microsoft.com/pfxteam/author/toub/。

C# 7.0 还引入了_)来替换一个故意未使用的变量。一个由异步调用返回的Task实例。通过在这种情况下使用丢弃,向编译器明确表示你想要忽略返回的Task实例。丢弃也可以在其他场景中用作变量的占位符。使用丢弃可以使你的代码意图更清晰,在某些情况下,还可以减少内存分配。你可以在 Microsoft Docs 网站上了解更多关于它们的使用方法:https://docs.microsoft.com/dotnet/csharp/fundamentals/functional/discards。

2017 年后期,发布了 C# 7.1 版本,为异步编程添加了一个值得注意的功能:可以将类的Main方法声明为异步。这使得从Main方法中直接等待其他异步方法成为可能。

下一个值得注意的异步功能是在 2019 年随着 C# 8 的发布一同出现的。

C# 8 和.NET Core 3.0

当 2019 年发布了 C# 8 和.NET Core 3.0 时,添加了几个语言和.NET 功能来支持新的IAsyncEnumerable类型,以提供异步数据的流式源。

让我们检查一个使用IAsyncEnumerable的代码片段:

public async IAsyncEnumerable<Order> 
    GetLargeOrdersForCustomerAsync(int custId)
{
    await foreach (var order in 
        GetOrdersByCustomerAsync(custId))
    {
        if (order.Items.Count > 10) yield return order;
    }
}

在这个示例中,使用了新的await foreach语言特性来调用一个异步方法以获取客户的全部订单。然后,它使用yield return操作,通过IAsyncEnumerable类型在处理过程中返回每个包含超过 10 个项目的order对象。我们将在第五章中介绍更多使用IAsyncEnumerable的实际场景。

C# 8 中添加的另一个异步功能是 System.IAsyncDisposable 接口。当实现 IAsyncDisposable 时,你的类必须实现一个无参数的 DisposeAsync 方法。如果你的类消耗实现了 IAsyncDisposable 的托管资源,并且它们不能与 async using 块一起在线释放,你应该实现 IAsyncDisposable 并在受保护的 DisposeAsyncCore 方法中清理这些资源。有关同时使用 IDisposableIAsyncDisposable 的综合示例,你可以查看 Microsoft Docs 上的示例,链接为 https://docs.microsoft.com/dotnet/standard/garbage-collection/implementing-disposeasync#implement-both-dispose-and-async-dispose-patterns。

这带我们来到了 C# 和 .NET 的最新版本。让我们回顾一下在 2021 年的这些版本中,异步开发人员有哪些新功能。

C# 10 和 .NET 6

.NET 6 与 C# 10 一起在 2021 年 11 月发布。.NET 6 中的新功能之一是 System.Text.Json 能够序列化和反序列化 IAsyncEnumerable 类型。在 .NET 6 之前,序列化的 IAsyncEnumerable 类型将包含一个空的 JSON 对象。这在 .NET 6 中被视为一个破坏性变更,但这是一个改进。变更背后的主要动机是支持 ASP.NET Core MVC 控制器方法中的 IAsyncEnumerable<T> 响应。

对于异步开发人员来说,.NET 6 的另一个值得注意的功能是 Visual Studio 2021 中的 C# 项目模板得到了现代化,以利用包括 C# 7.1 及以后的 async Main 方法在内的几个最近的语言功能。当 .NET 6 发布候选版 2 在 2021 年 10 月发布时,.NET 团队在其博客上发布了关于这些更新模板的信息:https://devblogs.microsoft.com/dotnet/announcing-net-6-release-candidate-2/#net-sdk-c-project-templates-modernized。

这应该能让你了解每个重要的线程功能是在 C# 和 .NET 中何时添加的,并为本章即将到来的部分设定了舞台,我们将介绍并行编程和并发的一些基础知识。让我们从查看线程的一些更多功能开始,首先是 .NET 线程池。

超越线程基础

在我们介绍使用 .NET 和 C# 的并行编程、并发和异步编程之前,我们还有一些线程概念需要覆盖。其中最重要的是 .NET 管理线程池,它被用于在 C# 中异步执行的等待方法调用。

管理线程池

System.Threading命名空间中的ThreadPool类自.NET 开始以来就是其中的一部分。它为开发者提供了一个工作线程池,他们可以利用它来在后台执行任务。实际上,这是线程池线程的一个关键特性。它们是运行在默认优先级的后台线程。当这些线程中的任何一个完成任务时,它将被返回到可用的线程池以等待其下一个任务。您可以将尽可能多的任务排队到线程池中,但活动线程的数量受操作系统根据处理器能力和其他运行进程分配给您的应用程序的数量限制。

如果您要在.NET 6 应用程序中使用ThreadPool类,您通常会通过 TPL(Task Parallel Library)来这样做,但让我们探索如何直接使用ThreadPool.QueueUserWorkItem。以下代码采用了第一章的示例场景,但使用ThreadPool线程来执行后台过程:

Console.WriteLine("Hello, World!");
ThreadPool.QueueUserWorkItem((o) =>
{
    for (int i = 0; i < 20; i++)
    {
        bool isNetworkUp = System.Net.NetworkInformation.
            NetworkInterface.GetIsNetworkAvailable();
        Console.WriteLine($"Is network available? Answer: 
            {isNetworkUp}");
        Thread.Sleep(100);
    }
});
for (int i = 0; i < 10; i++)
{
    Console.WriteLine("Main thread working...");
    Task.Delay(500);
}
Console.WriteLine("Done");
Console.ReadKey();

在这里,关键的区别是无需将IsBackground设置为true,并且您不需要调用Start()。进程将在项目被排队到ThreadPool上时或当下一个ThreadPool可用时启动。虽然您可能不会经常在代码中显式使用ThreadPool,但它被.NET 中许多常见的线程功能所利用。因此,了解它是如何工作的是很重要的。

在.NET 中,使用ThreadPool的常见功能之一是计时器。

线程和计时器

在本节中,我们将检查两个使用ThreadPool的计时器类,即System.Timers.TimerSystem.Threading.Timer。这两种类型都适用于托管线程,并且可在.NET 6 支持的每个平台上使用。

注意

一些额外的计时器仅适用于 Web 或 Windows 平台开发。本节将专注于平台无关的计时器。要了解更多关于其他计时器的信息,您可以参考 Microsoft Docs 网站上的文档:https://docs.microsoft.com/dotnet/standard/threading/timers。

System.Timers.Timer

您可能最熟悉System.Timers命名空间中的Timer对象。此计时器将在Interval属性指定的间隔内在线程池线程上触发一个Elapsed事件。可以通过使用布尔Enabled属性来停止或启动该机制。如果您需要Elapsed事件仅触发一次,可以将AutoReset属性设置为false

注意

要跟随本例中的代码,请从本章 GitHub 仓库的WorkingWithTimers项目中下载代码:https://github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter02。

此示例使用Timer对象来检查新消息,并在找到任何消息时提醒用户:

  1. 首先,声明一个 Timer 对象,并在 InitializeTimer 方法中设置它:

    private System.Timers.Timer? _timer;
    private void InitializeTimer()
    {
        _timer = new System.Timers.Timer
        {
            Interval = 1000
        };
        _timer.Elapsed += _timer_Elapsed;
    }
    
  2. 接下来,创建 _timer_Elapsed 事件处理程序以检查消息并更新用户:

    private void _timer_Elapsed(object? sender, 
        System.Timers.ElapsedEventArgs e)
    {
        int messageCount = CheckForNewMessageCount();
        if (messageCount > 0)
        {
            AlertUser(messageCount);
        }
    }
    
  3. 在将 _timer 对象的 Enabled 属性设置为 true 之后,Elapsed 事件将每秒触发一次。在 TimerSample 类的 StartTimer()StopTimer() 方法中:

    public void StartTimer()
    {
        if (_timer == null)
        {
            InitializeTimer();
        }
        if (_timer != null && !_timer.Enabled)
        {
            _timer.Enabled = true;
        }
    }
    public void StopTimer()
    {
        if (_timer != null && _timer.Enabled)
        {
            _timer.Enabled = false;
        }
    }
    
  4. 运行 WorkingWithTimers 项目,并尝试使用 启动定时器停止定时器 按钮。

当定时器启用时,您应该在 Visual Studio 的调试 输出 窗口中每秒看到消息。

注意

请记住,定时器事件是在线程池线程上触发的。在这些方法中执行的代码可能无法更新 UI。这些定时器示例是 InvokeRequired 在表单或用户控件上的一部分,如果需要,然后使用 Invoke 方法更新 UI。有关如何更新 WinForms UI 的更多信息,可以在 Microsoft Docs 网站上找到,网址为 https://docs.microsoft.com/dotnet/desktop/winforms/controls/how-to-make-thread-safe-calls。

在您自己的应用程序中,您将使用 AlertUser 方法向用户显示警报消息或在 UI 中更新通知图标。接下来,让我们尝试使用 System.Threading.Timer 类。

System.Threading.Timer

现在,我们将使用 System.Threading.Timer 类创建相同的示例。这个 Timer 类的初始化方式略有不同:

注意

要跟随本例中的代码,请从本章 GitHub 存储库的 WorkingWithTimers 项目中下载代码:https://github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter02。

  1. 首先,创建一个新的 InitializeTimer 方法:

    private void InitializeTimer()
    {
        var updater = new MessageUpdater();
        _timer = new System.Threading.Timer(
        callback: new TimerCallback(TimerFired),
        state: updater,
        dueTime: 500,
        period: 1000);
    }
    

Timer 类的构造函数接受四个参数。callback 参数是在定时器周期结束时在线程池上调用的委托。state 参数是要传递给回调委托的对象。dueTime 参数告诉定时器在第一次触发定时器之前要等待多长时间(以毫秒为单位)。最后,period 参数指定每个委托调用之间的间隔(以毫秒为单位)。

  1. 实例化定时器后,它将立即开始。没有 Enabled 属性来启动或停止这个定时器。当您完成使用后,您应该使用 Dispose 方法或 DisposeAsync 方法来释放它。这发生在我们的 DisposeTimerAsync 方法中:

    public void StartTimer()
    {
        if (_timer == null)
        {
            InitializeTimer();
        }
    }
    public async Task DisposeTimerAsync()
    {
        if (_timer != null)
        {
            await _timer.DisposeAsync();
        }
    }
    
  2. MessageUpdater 是一个用作 TimerCallback 方法提供的 state 对象的类。它有一个处理消息计数更新的单方法。通过这个类可以封装更新用户关于新消息的逻辑。在我们的例子中,它将简单地更新调试输出中的新消息数量:

    internal class MessageUpdater
    {
        internal void Update(int messageCount)
        {
            Debug.WriteLine($"You have {messageCount} new 
                messages!");
        }
    }
    
  3. 需要检查的最后一部分是 TimerFired 回调方法:

    private void TimerFired(object? state)
    {
        int messageCount = CheckForNewMessageCount();
        if (messageCount > 0 &&
            state is MessageUpdater updater)
        {
            updater.Update(messageCount);
        }
    }
    

与前一个示例中的 _timer_Elapsed 方法类似,这个方法只是简单地检查是否有新消息,并触发更新。然而,这次更新是由 MessageUpdater 类执行的,在你的应用程序中,这个类可以通过 IMessageUpdater 接口进行抽象,并注入到这个类中,以实现关注点的分离和测试性的提高。

  1. 通过在应用程序中使用 启动线程计时器停止线程计时器 按钮尝试这个示例。你应该会在 输出 窗口中看到带有新消息计数的调试消息出现,就像在前一个示例中做的那样。

这两个计时器服务于类似的目的;然而,大多数情况下,你将想要使用 System.Threading.Timer 来利用其异步特性。但是,如果你需要频繁地停止和启动计时器进程,System.Timers.Timer 类是一个更好的选择。

现在我们已经介绍了一些额外的托管线程概念来使你的知识水平一致,现在是时候转换方向,引入使用 C# 进行并行编程的概念。

并行简介

在探索 C# 和 .NET 中线程的历史时,我们了解到并行是在 .NET Framework 4.0 中引入给开发者的。在本节中,将通过 System.Threading.Tasks.Parallel 类通过 TPL 揭示将要探讨的方面。此外,我们还将通过示例介绍 PLINQ 的基础知识。这些数据并行概念将在 第六章第七章第八章 中以更详细的方式,通过实际案例进行讲解。

从高层次来看,并行是同时执行多个任务的概念。这些任务可能相互关联,但这不是必需的。实际上,并行运行的相关任务更有可能遇到同步问题或相互阻塞。例如,如果你的应用程序从订单服务加载订单数据,并从 Azure blob store 加载用户偏好和应用程序状态,这两个进程可以并行运行,无需担心冲突或数据同步。另一方面,如果应用程序从两个不同的订单服务加载订单数据,并在单个集合中合并结果,你需要一个同步策略。

这种场景将在 第九章 中进行更深入的讨论。在本节中,我们将通过学习 Parallel 类的一些用法来为这些高级场景做准备。让我们从 Parallel.Invoke 开始。

使用 Parallel.Invoke

Parallel.Invoke 是一个可以执行多个操作的方法,并且它们可以并行执行。它们执行顺序没有保证。每个操作将被排队到线程池中。当所有操作都完成后,Invoke 调用将返回。

在这个例子中,Parallel.Invoke调用将执行四个操作:ParallelInvokeExample类中的另一个方法DoComplexWork、一个 lambda 表达式、一个内联声明的Action和一个委托。以下是完整的ParallelInvokeExample类:

internal class ParallelInvokeExample
{
    internal void DoWorkInParallel()
    {
        Parallel.Invoke(
            DoComplexWork,
            () => {
                Console.WriteLine($"Hello from lambda 
                expression. Thread id: 
                {Thread.CurrentThread.ManagedThreadId}");
            },
            new Action(() =>
            {
                Console.WriteLine($"Hello from Action. 
                Thread id: {Thread.CurrentThread
                .ManagedThreadId}");
            }),
            delegate ()
            {
                Console.WriteLine($"Hello from delegate. 
                Thread id: {Thread.CurrentThread
                .ManagedThreadId}");
            }
        );
    }
    private void DoComplexWork()
    {
        Console.WriteLine($"Hello from DoComplexWork 
        method. Thread id: {Thread.CurrentThread
       .ManagedThreadId}");
    }
}

创建ParallelInvokeExample的新实例并从控制台应用程序执行DoWorkInParallel将产生类似于以下内容的输出,尽管操作顺序可能不同:

图 2.1 – DoWorkInParallel 方法产生的输出

图 2.1 – DoWorkInParallel 方法产生的输出

在下一节中,我们将学习如何实现Parallel.ForEach循环,并讨论你可能想要利用它的情况。

使用 Parallel.ForEach

Parallel.ForEach 可能是.NET 中Parallel类中使用最频繁的成员。这是因为,在许多情况下,你可以直接将标准foreach循环的主体用于Parallel.ForEach循环中。然而,在将任何并行性引入代码库时,你必须确保被调用的代码是线程安全的。如果Parallel.ForEach循环的主体修改了任何集合,你可能需要采用第一章中讨论的同步方法之一,或者使用.NET 的并发集合之一。我们将在并发介绍部分介绍并发集合。

作为使用Parallel.ForEach的示例,我们将创建一个接受数字列表的方法,并检查每个数字是否包含在当前时间的字符串表示中:

internal void ExecuteParallelForEach(IList<int> numbers)
{
    Parallel.ForEach(numbers, number =>
    {
        bool timeContainsNumber = DateTime.Now.
            ToLongTimeString().Contains(number.ToString());
        if (timeContainsNumber)
        {
            Console.WriteLine($"The current time contains 
            number {number}. Thread id: {Thread.
            CurrentThread.ManagedThreadId}");
        }
        else
        {
            Console.WriteLine($"The current time does not 
            contain number {number}. Thread id: 
            {Thread.CurrentThread.ManagedThreadId}");
        }
    });
}

下面是从控制台应用程序的Main方法调用ExecuteParallelForEach的代码:

var numbers = new List<int> { 1, 3, 5, 7, 9, 0 };
var foreachExample = new ParallelForEachExample();
foreachExample.ExecuteParallelForEach(numbers);

执行程序,并检查控制台输出。你应该能看到使用了多个线程来处理循环:

图 2.2 – Parallel.ForEach 循环的控制台输出

图 2.2 – Parallel.ForEach 循环的控制台输出

接下来,我们将通过介绍 PLINQ 来总结.NET 中的并行性这一章节。

Parallel LINQ 基础

本节将探讨向你的代码中添加一些并行性的最简单方法之一。通过将AsParallel方法添加到你的 LINQ 查询中,你可以将其转换为 PLINQ 查询,AsParallel之后的操作在必要时将在线程池上执行。在决定何时使用 PLINQ 时需要考虑许多因素。我们将在第八章中深入讨论这些因素。对于这个示例,我们将介绍检查每个给定整数是否为偶数的Where子句。为了帮助说明 PLINQ 如何影响序列,还引入了Task.Delay。以下是完整的ParallelLinqExample类实现:

internal void ExecuteLinqQuery(IList<int> numbers)
{
    var evenNumbers = numbers.Where(n => n % 2 == 0);
    OutputNumbers(evenNumbers, "Regular");
}
internal void ExecuteParallelLinqQuery(IList<int> numbers)
{
    var evenNumbers = numbers.AsParallel().Where(n => 
        IsEven(n));
    OutputNumbers(evenNumbers, "Parallel");
}
private bool IsEven(int number)
{
    Task.Delay(100);
    return number % 2 == 0;
}
private void OutputNumbers(IEnumerable<int> numbers, string 
    loopType)
{
    var numberString = string.Join(",", numbers);
    Console.WriteLine($"{loopType} number string: 
        {numberString}");
}

在你的控制台应用程序的Main方法中,添加一些代码将整数列表传递给ExecuteLinqQueryExecuteParallelLinqQuery方法:

var linqNumbers = new List<int> { 0, 1, 2, 3, 4, 5, 6, 7, 
    8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 };
var linqExample = new ParallelLinqExample();
linqExample.ExecuteLinqQuery(linqNumbers);
linqExample.ExecuteParallelLinqQuery(linqNumbers);

检查输出,你应该会看到 PLINQ 序列中数字的顺序已改变:

图 2.3 – LINQ 和 PLINQ 查询的控制台输出

图 2.3 – LINQ 和 PLINQ 查询的控制台输出

我们将在本书的 第二部分使用 C# 进行并行编程和并发 中,通过几章来探讨并行性的更多方面。让我们转换一下,学习一些 C# 中的并发模式。

并发简介

那么,什么是并发,它与 C# 和 .NET 中的并行性有何关系?这两个术语经常被互换使用,如果你仔细想想,它们确实有相似的含义。当多个线程并行执行时,它们是并发执行的。在这本书中,当我们讨论设计托管线程时应遵循的模式时,我们将使用并发这个术语。此外,我们还将讨论并发集合,这些集合是在 .NET Framework 4.0 中介绍给 .NET 开发者的。让我们先了解 System.Collections.Concurrent 命名空间中的并发集合。

.NET 有几个集合是内置线程安全的。这些集合都可以在 System.Collections.Concurrent 命名空间中找到。在本节中,我们将介绍五个集合。其余三个是 Partitioner 的变体。这些将在 第九章 中探讨,我们将通过实际示例与每种集合类型一起工作。

注意

虽然在使用并发集合时,你的代码不需要使用锁,但这些集合内部正在使用不同的同步技术。其中一些使用锁,而另一些则具有重试机制来处理竞争。要了解更多关于并发集合如何处理竞争的信息,请查看 Microsoft 在 .NET 并行编程博客上的这篇博客文章:https://devblogs.microsoft.com/pfxteam/faq-are-all-of-the-new-concurrent-collections-lock-free/.

我们将要介绍的第一个集合是 ConcurrentBag<T>

ConcurrentBag

ConcurrentBag<T> 集合是一个并发集合,旨在存储一组无序对象。允许重复值,当 T 是可空类型时,也允许 null 值。它是一个出色的线程安全替代品,用于数组、List<T> 或其他不需要项目排序的 IEnumerable<T> 实例。

在内部,ConcurrentBag<T> 为每个添加项目的线程存储一个项目链表。当从集合中取出或查看项目时,将优先考虑由当前线程添加项目的内部列表。假设线程 1 添加了项目 A、B 和 C,线程 2 添加了项目 D、E、F 和 G。如果线程 1 调用 TryPeekTryTake 四次,ConcurrentBag<T> 将首先从 A、B 和 C 列表中获取项目,然后再从包含线程 2 项目链表中获取项目。

以下列表详细说明了您在大多数实现中可能会使用的 ConcurrentBag<T> 的属性和方法:

  • Add(T): 这会将一个对象添加到集合中。

  • TryPeek(out T): 这尝试从集合中获取一个值,并通过 out 参数返回,但不移除该项。

  • TryTake(out T): 这尝试通过 out 参数从集合中获取一个值并移除它。

  • Clear(): 这清除集合中的所有对象。

  • Count: 这返回集合中的对象数量。

  • IsEmpty: 这返回一个 bool 值,指示集合是否为空。

  • ToArray(): 这返回类型为 T 的对象数组。

ConcurrentBag<T> 集合有两个构造函数。一个构造函数不接受任何参数,只是简单地创建一个新的空集合。另一个接受一个要复制到新集合中的 IEnumerable<T> 类型的对象。

接下来,让我们快速看一下 ConcurrentQueue<T> 集合。

ConcurrentQueue

.NET 的 ConcurrentQueue<T> 集合在实现上与它的线程不安全版本 Queue<T> 类似。因此,当将线程管理引入现有代码库时,它是一个很好的 Queue<T> 替代品。ConcurrentQueue<T> 是一个强类型对象列表,它强制执行 先进先出FIFO)逻辑,这是 队列 的定义。

注意

FIFO 逻辑在制造业和仓库管理软件中很常见。当处理易腐货物时,首先使用最老的原料是很重要的。因此,那些首先被放入仓库的货物托盘,在系统请求该类型的托盘时,应该首先被提取。

这些是 ConcurrentQueue<T> 类型常用的成员:

  • Enqueue(T): 这将一个新的对象添加到队列中。

  • TryPeek(out T): 这尝试获取队列前面的对象而不移除它。

  • TryDequeue(out T): 这尝试获取队列前面的对象并移除它。

  • Clear(): 这清除队列。

  • Count: 这返回队列中的对象数量。

  • IsEmpty: 这返回一个 bool 值,指示队列是否为空。

  • ToArray(): 这将队列中的对象作为类型为 T 的数组返回。

ConcurrentBag<T>集合类似,ConcurrentQueue<T>集合有两个构造函数:一个无参数的和一个接受Ienumerable<T>类型以填充新队列的构造函数。接下来,让我们介绍一个类似的集合:ConcurrentStack<T>

ConcurrentStack

ConcurrentStack<T>可以被视为ConcurrentQueue<T>,但它使用Push方法而不是Enqueue,移除项目使用TryPop方法而不是TryDequeueConcurrentStack<T>集合的另一个优点是它可以在一个操作中添加或删除多个对象。这些范围操作通过使用PushRangeTryPopRange方法支持。范围操作接受T的数组作为参数。

在.NET 6 中,ConcurrentStack<T>ConcurrentQueue<T>都实现了IReadOnlyCollection<T>接口。这意味着一旦集合被创建,它就是只读的,不能重新分配或设置为null。你只能添加或删除项目或使用Clear()来清空集合。

让我们继续介绍最强大的并发集合之一,BlockingCollection<T>

BlockingCollection

BlockingCollection<T>是一个线程安全的对象集合,实现了包括IProducerConsumerCollection<T>在内的多个接口。IProducerConsumerCollection<T>接口提供了一组成员,旨在支持需要实现生产者/消费者模式的应用程序。

注意

生产者/消费者模式是一种并发设计模式,其中一组数据由一个或多个生产者线程并发提供。同时,有一个或多个消费者线程监控和获取正在生产的数据以并发消费和处理。BlockingCollection<T>集合是这种生产者/消费者模式中的数据存储。你可以在维基百科上了解更多关于生产者/消费者模式的信息,链接为 https://en.wikipedia.org/wiki/Producer–consumer_problem。

BlockingCollection<T>有多个方法和属性,有助于生产者/消费者工作流程。你可以通过调用CompleteAdding方法来指示生产者进程已完成向集合添加项目。一旦调用此方法,就不能再使用AddTryAdd方法向集合添加更多项目。如果你计划在你的工作流程中使用CompleteAdding,最好始终使用TryAdd并在向集合添加对象时检查布尔结果。如果集合已被标记为添加完成,调用Add将抛出InvalidOperationException。此外,你可以检查IsAddingCompleted属性以确定CompleteAdding是否已被调用。

消费者进程通过TakeTryTake方法从BlockingCollection<T>中移除项目。再次强调,使用TryTake更安全,可以避免在集合为空时出现任何异常。如果已调用CompleteAdding并且所有对象都已从集合中移除,则IsCompleted属性将返回true

我们将探讨一个真实的生产者/消费者实现,位于第九章 .NET。现在,让我们继续本节最后一个并发集合,ConcurrentDictionary<T>

ConcurrentDictionary<TKey, TValue>

如你所猜,ConcurrentDictionary<TKey, TValue> 是在处理托管线程时 Dictionary<TKey, TValue> 的优秀替代品。这两个集合都实现了 IDictionary<TKey, TValue> 接口。此集合的并发版本增加了以下用于并发处理数据的方法:

  • TryAdd: 尝试将新的键/值对添加到字典中,并返回一个布尔值,指示对象是否成功添加到字典中。如果键已存在,则方法将返回 false

  • TryUpdate: 此操作传递一个键以及项的现有值和新值。如果它存在于字典中且提供了现有值,则将现有项更新为新值。返回的布尔值指示对象是否在字典中成功更新。

  • AddOrUpdate: 此方法将根据键是否存在,在字典中添加或更新项,并使用更新委托来执行基于当前值和新值的任何逻辑。

  • GetOrAdd: 如果键不存在,此方法将添加项。如果它已存在,则返回现有值。

这些是 .NET 中最重要的和常见的并发集合,需要理解。我们将涵盖每个集合的一些示例,并在稍后学习 System.Collections.Concurrent 中的更多集合,但本节应该为理解接下来要学习的内容提供一个坚实的基础。

在下一节中,我们将介绍添加到 C# 5.0 的 C# asyncawait 关键字。

async 和 await 的基础知识

当 TPL 在 .NET Framework 4.5 中引入时,C# 5.0 也添加了对基于任务的异步编程的语言支持,使用了 asyncawait 关键字。这立即成为在 C# 和 .NET 中实现异步工作流的首选方法。现在,10 年过去了,async/await 和 TPL 已经成为构建健壮、可扩展 .NET 应用程序不可或缺的一部分。你可能想知道为什么在应用程序中采用异步编程如此重要。

理解 async 关键字

编写异步代码有许多原因。如果你在 Web 服务器上编写服务器端代码,使用异步可以让服务器在代码等待长时间运行的操作时处理额外的请求。在客户端应用程序中,使用异步代码释放 UI 线程执行其他操作,可以让你的 UI 对用户保持响应。

在.NET 中采用异步编程的另一个重要原因是许多第三方和开源库都在使用async/await。即使是.NET 本身,在每次发布中也越来越多地暴露异步 API,尤其是涉及 IO 操作的部分:网络、文件和数据库访问。

让我们尝试用 C#编写你的第一个异步方法。

编写异步方法

创建和消费异步方法很容易。让我们用一个新控制台应用程序的简单示例来尝试:

  1. 在 Visual Studio 中创建一个新的控制台应用程序,并将其命名为AsyncConsoleExample

  2. 向项目中添加一个名为NetworkHelper的类,并向该类添加以下方法:

    internal async Task CheckNetworkStatusAsync()
    {
        Task t = NetworkCheckInternalAsync();
        for (int i = 0; i < 8; i++)
        {
            Console.WriteLine("Top level method 
               working...");
            await Task.Delay(500);
        }
        await t;
    }
    private async Task NetworkCheckInternalAsync()
    {
        for (int i = 0; i < 10; i++)
        {
            bool isNetworkUp = System.Net.
               NetworkInformation.NetworkInterface
                   .GetIsNetworkAvailable();
            Console.WriteLine($"Is network available? 
                Answer: {isNetworkUp}");
            await Task.Delay(100);
        }
    }
    

在前面的代码中,有一些事情需要注意。两个方法都有一个async修饰符,表示它们将等待一些工作并异步运行。在方法内部,我们使用await关键字与Task.Delay的调用。这将确保在此点之后的代码不会执行,直到等待的方法完成。然而,在这段时间内,活动线程可以被释放以在其他地方执行其他工作。

最后,看看对NetworkCheckInternalAsync的调用。我们不是等待这个调用,而是将返回的Task实例捕获到一个名为t的变量中,并且不在for循环之后等待它。这意味着两个方法中的for循环将并发运行。如果我们等待NetworkCheckInternalAsync,它的for循环将在CheckNetworkStatusAsync中的for循环开始之前完成。

  1. 接下来,用以下代码替换Program.cs中的代码:

    using AsyncConsoleExample;
    Console.WriteLine("Hello, async!");
    var networkHelper = new NetworkHelper();
    await networkHelper.CheckNetworkStatusAsync();
    Console.WriteLine("Main method complete.");
    Console.ReadKey();
    

我们正在等待CheckNetworkStatusAsync的调用。这是可能的,因为.NET 6 控制台应用程序中的默认Main方法是异步的。如果你在一个未标记为async的方法中尝试await某个东西,你将得到编译器错误。我们将在第五章中探讨当你必须从现有的非异步代码中调用异步方法时可以使用的一些选项。

  1. 最后,运行应用程序并检查输出:

图 2.4 – 异步示例应用程序的控制台输出

图 2.4 – 异步示例应用程序的控制台输出

如预期的那样,捕获异步方法的结果使得两个循环可以并发运行。尝试等待NetworkCheckInternalAsync的调用,看看输出如何变化。你应该会看到私有方法的所有输出都会在CheckNetworkStatusAsync中的for循环开始输出之前出现。

这是对 C#异步编程世界的简要介绍。我们将在本书的其余部分大量使用它。让我们通过讨论在构建新项目或增强现有应用程序时如何选择利用这些选项来结束讨论。

选择正确的路径

现在你已经接触了一些高级托管线程概念,并行编程、并发集合以及 async/await 模式,让我们讨论它们如何在现实世界中相互结合。在 .NET 中进行多线程开发时,选择正确的路径通常涉及多个这些概念。

当使用 .NET 6 时,你应该通常选择在项目中创建 async 方法。本章中讨论的原因很有说服力。异步编程保持了客户端和服务器应用程序的响应性,async 在 .NET 本身中被广泛使用。

当你的代码需要快速处理一组项目并且底层执行处理的代码是线程安全的时候,可以利用一些 Parallel 类操作。这是可以引入并发集合的一个地方。如果任何并行或异步操作正在操作共享数据,数据应该存储在 .NET 并发集合之一中。

如果你正在处理现有的代码,通常最谨慎的做法是限制添加的多线程代码量。像这样的遗留项目是逐步添加一些 ThreadPoolParallel 操作并测试结果的好地方。对应用程序的功能和性能进行测试非常重要。关于托管线程的性能测试工具将在 第十章 中介绍。

这份初步指南将帮助你了解如何通过托管线程提高应用程序的性能。我们将在本书的其余部分继续构建你的学习和这些指南。让我们总结一下,并讨论本章你学到了什么。

概述

在本章中,我们首先回顾了 C#、.NET 和托管线程的简要历史。我们讨论了微软在过去 20 年中如何添加异步和并行编程的功能。接下来,我们游览了使用 .NET 的并行编程、并发集合以及使用 C# 的异步开发。最后,我们探讨了在哪些情况下你可能选择这些概念用于自己的应用程序,以及为什么你通常会选择多个概念。你将能够运用本章学到的知识,开始思考在日常工作中托管线程的实际应用。

在下一章中,我们将讨论到目前为止你所学的知识,并讨论一些概念在实际应用中的最佳实践。

问题

  1. 在 .NET 中,哪个类管理着应用程序可用的线程池线程?

  2. asyncawait 关键字是在哪个版本的 C# 中引入的?

  3. TPL 是在哪个版本的 .NET 中引入的?

  4. IAsyncEnumerable 是在哪个版本的 .NET Core 中引入的?

  5. 每个 async 方法应该返回什么类型?

  6. 在多线程场景中,你会选择哪个并发集合来替换 Dictionary<TKey, TValue>

  7. 在.NET 中,哪种并发集合经常与生产者/消费者设计模式一起使用?

  8. .NET 中哪个并行特性包含AsParallel方法?

第三章:第三章: 管理线程的最佳实践

当构建利用并行性和并发性的应用程序时,开发者需要了解一些关于集成管理线程概念的最佳实践。本章将在这方面提供帮助。我们将涵盖重要概念,如处理静态数据、避免死锁以及耗尽管理资源。这些都是可能导致应用程序不稳定和意外行为的问题领域。

在本章中,您将学习以下概念:

  • 处理静态对象

  • 处理死锁和竞态条件

  • 线程限制和其他建议

到本章结束时,您将具备避免最常见的管理线程陷阱的知识。

技术要求

要跟随本章中的示例,以下软件是推荐给 Windows 开发者的:

  • Visual Studio 2022 版本 17.0 或更高

  • .NET 6

虽然这些是推荐的,但如果您已安装.NET 6,您可以使用您喜欢的编辑器。例如,macOS 10.13 或更高版本的 Visual Studio 2022 for Mac、JetBrains Rider 或 Visual Studio Code 都将同样有效。

本章的所有代码示例都可以在 GitHub 上找到,链接为github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter03

我们将开始讨论处理.NET 中静态数据的一些最佳实践。

处理静态对象

当在.NET 中处理静态数据时,关于管理线程有一些重要的事情需要理解。

静态数据和构造函数

关于从管理线程访问静态数据的一个重要事项与构造函数有关。在任何类的静态成员可以访问之前,其静态构造函数必须首先完成运行。运行时会阻塞线程执行,直到静态构造函数运行,以确保所有必需的初始化都已完成。

如果您在自己的代码库中使用静态对象,您将知道哪些类有静态构造函数,并可以控制其内部的逻辑复杂性。当静态数据不在您的控制范围内,在第三方库或.NET 本身内部时,事情可能并不那么清晰。

让我们通过一个快速示例来展示在这种场景中可能遇到的潜在延迟。

  1. 首先在 Visual Studio 中创建一个新的.NET 控制台应用程序,命名为ThreadingStaticDataExample

  2. 向项目中添加一个名为 WorkstationState 的新类,并包含以下静态成员:

    internal static string Name { get; set; }
    internal static string IpAddress { get; set;}
    internal static bool IsNetworkAvailable { get; set; }
    internal static DateTime? NetworkConnectivity
        LastUpdated { get; set; }
    static WorkstationState()
    {
        Name = Dns.GetHostName();
        IpAddress = GetLocalIPAddress(Name);
        IsNetworkAvailable = NetworkInterface
            .GetIsNetworkAvailable();
        NetworkConnectivityLastUpdated = DateTime.UtcNow;
        Thread.Sleep(2000);
    }
    private static string GetLocalIPAddress
        (string hostName)
    {
        var hostEntry = Dns.GetHostEntry(hostName);
        foreach (var address in hostEntry.AddressList
                            .Where(a => a.AddressFamily == 
                             AddressFamily.InterNetwork))
        {
            return address.ToString();
        }
        return string.Empty;
    }
    

这个类将包含有关当前工作站的一些信息,包括主机名、本地 IP 地址以及网络是否当前可用。私有GetLocalIpAddress方法根据提供的主机名获取本地 IP。

WorkstationState有一个静态构造函数,用于设置初始属性数据和通过Thread.Sleep调用注入两秒的延迟。这将帮助我们模拟应用程序获取一些其他需要时间从慢速网络连接检索的网络信息。

  1. 接下来,添加一个名为WorkstationHelper的类。这个类将包含一个异步方法,用于更新WorkstationState中的静态IsNetworkAvailableNetworkConnectivityLastUpdated属性,并将IsNetworkAvailable的值返回给调用者:

    internal async Task<bool> GetNetworkAvailability()
    {
        await Task.Delay(100);
        WorkstationState.IsNetworkAvailable = 
            NetworkInterface.GetIsNetworkAvailable();
        WorkstationState.NetworkConnectivityLastUpdated = 
            DateTime.UtcNow;
        return WorkstationState.IsNetworkAvailable;
    }
    

如果你想要在循环中调用此方法并通过改变注入的延迟进行实验,也可以等待Task.Delay调用。

  1. 最后,更新Program.cs以调用GetNetworkAvailability并更新控制台输出,包括连接性、主机名和 IP 地址:

    using ThreadingStaticDataExample;
    Console.WriteLine("Hello, World!");
    Console.WriteLine($"Current datetime: 
        {DateTime.UtcNow}");
    var helper = new WorkstationHelper();
    await helper.GetNetworkAvailability();
    Console.WriteLine($"Network availability last updated 
      {WorkstationState.NetworkConnectivityLastUpdated} 
        for computer {WorkstationState.Name} at IP 
          {WorkstationState.IpAddress}");
    
  2. 运行程序并检查输出。你可以看到,在静态构造函数注入的两个Console.WriteLine调用之间有 2 秒的延迟:

Hello, World!
Current datetime: 2/12/2022 4:07:13 PM
Network availability last updated 2/12/2022 4:07:15 PM for computer ALVINASHCRABC3A at IP 10.211.55.3

当处理托管线程时,静态构造函数是需要注意的静态数据的一个方面。一个更常见的问题是控制线程之间的静态对象的并发读写访问。

控制对静态对象的共享访问

当涉及到静态数据时,最佳实践是尽可能避免使用它。一般来说,这会使你的代码更难以测试、更难以扩展,并且在并发工作时更容易出现意外行为。然而,有时静态数据是不可避免的。你可能正在处理一个遗留代码库,其中重构代码以删除静态变量可能是有风险的,或者是一个过于庞大的任务。当数据很少变化,或者类是无状态的时,静态类也可能很有用。

对于不可避免使用静态对象的情况,可以采取一些预防措施。让我们回顾一些这些措施,并讨论每个措施的优点,从锁定机制开始。

在*第一章中,我们讨论了一些用于锁定对象以供共享使用的策略。锁**在处理静态变量时尤为重要,因为随着对象作用域的增加,并发访问的可能性也会增加。

防止多个线程同时访问一个对象的最简单方法是将访问它的任何代码用锁包围起来。让我们修改WorkstationHelper中的代码,以防止多个调用GetNetworkActivity同时写入WorkstationState属性:

internal class WorkstationHelper
{
    private static object _workstationLock = new object();
    internal async Task<bool> GetNetworkAvailability()
    {
        await Task.Delay(100);
lock( _workstationLock)
        {
            WorkstationState.IsNetworkAvailable = 
                NetworkInterface.GetIsNetworkAvailable();
            WorkstationState.NetworkConnectivityLastUpdated 
                = DateTime.UtcNow;
        }
        return WorkstationState.IsNetworkAvailable;
    }
}

我们添加了一个私有的静态_workstationLock对象,并且我们正在将其用作包围WorkstationState属性写入的锁块的一部分。如果现在在Parallel.ForEach或其他并发操作中使用GetNetworkAvailability,则一次只能有一个线程进入该锁块。

你可以使用在第一章中讨论过的任何锁定机制。选择最适合你场景的功能。你还可以利用.NET 的另一个功能,即ThreadStatic属性。

ThreadStatic 属性

可以将ThreadStatic属性添加到静态字段中,以指示为每个线程创建对象的一个单独的静态实例。只有在需要此行为时才应使用ThreadStatic属性,并且应在代码中良好地记录。如果使用不当,它可能会产生意外的结果。

标记为ThreadStatic的字段不应在构造函数中初始化其数据,因为初始化只会应用于当前线程。其他线程上的值将是null或该类型的默认值。

如果你将ThreadStatic属性应用于WorkstationStateNetworkConnectivityLastUpdated属性,并在Parallel.For循环中调用WorkstationHelper.GetNetworkAvailability三十次,那么在Program.cs中读取的值可能是也可能不是写入静态实例中的最后一个值。Program.cs中的变量将包含在Parallel.For循环内部主线程中写入的最后一个值。

  1. 要亲自尝试,请将ThreadStatic属性添加到NetworkConnectivityLastUpdated,并将其作为内部字段而不是属性。该属性不能应用于属性:

    [ThreadStatic]
    internal static DateTime? 
        NetworkConnectivityLastUpdated;
    
  2. 然后更新Program.cs以使用Parallel.For循环:

    using ThreadingStaticDataExample;
    Console.WriteLine("Hello, World!");
    Console.WriteLine($"Current datetime: 
        {DateTime.UtcNow}");
    var helper = new WorkstationHelper();
    Parallel.For(1, 30, async (x) =>
    {
        await helper.GetNetworkAvailability();
    });
    Console.WriteLine($"Network availability last updated 
        {WorkstationState.NetworkConnectivityLastUpdated} 
            for computer {WorkstationState.Name} at IP 
               {WorkstationState.IpAddress}");
    

输出中的日期/时间值之间的时间间隔现在每次运行程序时都会变化,因为写入控制台的最后值可能不是所有线程中的最终值。

虽然ThreadStatic应仅应用于需要每个线程的实例的场景,但另一种与静态类似的应用模式是单例。让我们讨论在多线程应用程序中使用单例。

与单例一起工作

单例模式是一种对象设计模式,它只允许创建该对象的一个实例。这种设计模式是最常见的之一,并且大多数.NET 开发者都了解它。每个主流依赖注入(DI)框架都允许将注册的类型注册为单例。容器将为这些类型中的每一个创建一个实例,每次请求该类型时都提供相同的实例。

我们可以使用lock和一点额外的代码手动创建一个WorkstationState的单例。这是WorkstationStateSingleton

public class WorkstationStateSingleton
{
    private static WorkstationStateSingleton? 
        _singleton = null;
    private static readonly object _lock = new();
    WorkstationStateSingleton()
    {
        Name = Dns.GetHostName();
        IpAddress = GetLocalIPAddress(Name);
        IsNetworkAvailable = 
            NetworkInterface.GetIsNetworkAvailable();
        NetworkConnectivityLastUpdated = 
            DateTime.UtcNow;
    }
    public static WorkstationStateSingleton Instance
    {
        get
        {
            lock (_lock)
            {
                if (_singleton == null)
                {
                    _singleton = new 
                       WorkstationStateSingleton();
                }
                return _singleton;
            }
        }
    }
...
}

类的完整实现可以在本章技术要求部分引用的 GitHub 存储库中找到。查看chapter3文件夹中的ThreadingStaticDataExample

使其成为单例需要采取两个步骤。首先,构造函数是私有的,这样只有WorkstationStateSingleton可以创建自己的实例。其次,创建一个静态的Instance方法。如果它不是null,它将返回自己的_singleton实例。否则,它将创建实例以返回。使用_lock包围这段代码确保在并发线程上不会创建两次实例。

单例与静态类面临相同的挑战。如果共享数据可以被管理线程并发访问,则应该由锁来保护。对于注册在 DI 容器中的单例,增加的挑战是必须在容器相同的范围内声明一个lock对象、Mutex或另一种机制。这将确保所有可能使用单例的数据也能强制执行相同的锁。

注意

请注意,单例的使用通常不被认为是良好的实践。因此,许多开发者认为它们是一种反模式。然而,了解它们以及你的代码中现有的单例可能如何受到多线程代码的影响是很重要的。

死锁是积极锁定的一种陷阱。积极锁定是指你在代码的许多部分使用对象锁定,而这些部分可能并行执行。在下一节中,我们将讨论管理线程中的死锁和竞态条件

管理死锁和竞态条件

就像开发者可用的许多工具一样,滥用管理线程的功能可能会在运行时对你的应用程序产生不利影响。死锁和竞态条件是多线程编程可能产生的两种情况:

  • 死锁发生在多个线程试图锁定相同的资源,结果无法继续执行。

  • 竞态条件发生在多个线程正在尝试更新特定例程时,并且正确的结果取决于它们执行的顺序。

图 3.2 – 两个线程争夺相同资源,导致死锁

图 3.2 – 两个线程争夺相同资源,导致死锁

首先,让我们讨论死锁以及避免它们的一些技术。

缓解死锁

在你的应用程序中避免死锁至关重要。如果一个死锁涉及的线程是应用程序的 UI 线程,它将导致应用程序冻结。当只有非 UI 线程发生死锁时,诊断问题可能更困难。死锁的线程池线程将阻止应用程序关闭,但死锁的后台线程则不会。

在生产环境中出现问题时,良好的代码调试工具对于调试问题至关重要。如果问题可以在您的开发环境中重现,使用 Visual Studio 调试器逐步执行代码是找到死锁来源的最快方式。我们将在 第十章 中详细讨论调试技术。

通过递归或尝试在相同资源上获取锁的嵌套方法,是创建死锁的最简单方法之一。看看以下代码:

private object _lock = new object();
private List<string> _data;
public DeadlockSample()
{
    _data = new List<string> { "First", "Second",
        "Third" };
}
public async Task ProcessData()
{
    lock (_lock)
    {
        foreach(var item in _data)
        {
            Console.WriteLine(item);
        }
        await AddData();
    }
}
private async Task AddData()
{
    lock (_lock)
    {
        _data.AddRange(GetMoreData());
        await Task.Delay(100);
    }
}

ProcessData 方法正在锁定 _lock 对象并使用 _data 进行处理。然而,它调用了 AddData,这也试图获取相同的锁。这个锁永远不会变得可用,进程将会死锁。在这种情况下,问题很明显。如果 AddData 从多个地方调用或者父代码中涉及任何 Parallel.ForEach 循环,会怎样?一些父代码使用 _data 并获取锁,但有些则没有。这就是 ReaderWriterLockSlim 中的非阻塞读锁可以帮助防止死锁的情况。

防止死锁的另一种方法是使用 Monitor.TryEnter 给锁尝试添加超时。在这个例子中,如果在一秒内无法获取锁,代码将超时:

private void AddDataWithMonitor()
{
    if (Monitor.TryEnter(_lock, 1000))
    {  
        try
        {  
            _data.AddRange(GetMoreData());
        }  
        finally
        {  
            Monitor.Exit(_lock);  
        }  
    }  
    else
    {  
        Console.WriteLine($"AddData: Unable to acquire 
            lock. Stack trace: {Environment.StackTrace}");
    }
}

记录获取锁失败的情况可以帮助您确定代码中可能的死锁来源,以便您可以重新编写代码以避免它们。

接下来,让我们看看在多线程应用程序中竞态条件是如何发生的。

避免竞态条件

当多个线程同时读取和写入相同的变量时,就会发生竞态条件。如果没有任何锁,结果可能是完全不可预测的。某些操作可以被其他并行线程的结果覆盖。即使有锁,两个线程操作顺序的改变也可能改变结果。以下是一个没有锁的简单示例,它并行执行了一些加法和乘法:

private int _runningTotal;
public void PerformCalculationsRace()
{
    _runningTotal = 3;
    Parallel.Invoke(() => {
        AddValue().Wait();
    }, () => {
        MultiplyValue().Wait();
    });
    Console.WriteLine($"Running total is {_runningTotal}");
}
private async Task AddValue()
{
    await Task.Delay(100);
    _runningTotal += 15;
}
private async Task MultiplyValue()
{
    await Task.Delay(100);
    _runningTotal = _runningTotal * 10;
}

我们都知道,当结合加法和乘法时,操作顺序很重要。如果这两个操作按顺序处理,两个结果可以是 18045,但如果 AddValueMultiplyValue 在执行各自的操作之前都读取了初始值 3,则最后一个完成的方法将写入 1830 作为 _runningTotal 的最终值。

如果您想确保乘法发生在加法之前,可以将 PerformCalculations 方法重写为使用 MultiplyValue 返回的 Task 上的 ContinueWith 方法:

public async Task PerformCalculations()
{
    _runningTotal = 3;
    await MultiplyValue().ContinueWith(async (Task) => {
        await AddValue();
        });
    Console.WriteLine($"Running total is {_runningTotal}");
}

这段代码在加法之前总是先进行乘法,并且总是以 _runningTotal 等于 45 结束。在整个代码中使用 asyncawait 确保在需要时使用线程池中的线程时,UI 或服务进程保持响应。

在上一章中讨论的Interlocked类也可以用于对共享资源执行数学操作。Interlocked.AddInterlocked.Exchange可以在并行中对_runningTotal变量执行线程安全操作。以下是修改后的原始Parallel.Invoke示例,使用Interlocked方法与_runningTotal

public class InterlockedSample
{
    private long _runningTotal;
    public void PerformCalculations()
    {
        _runningTotal = 3;
        Parallel.Invoke(() => {
            AddValue().Wait();
        }, () => {
            MultiplyValue().Wait();
        });
        Console.WriteLine($"Running total is 
            {_runningTotal}");
    }
    private async Task AddValue()
    {
        await Task.Delay(100);
        Interlocked.Add(ref _runningTotal, 15);
    }
    private async Task MultiplyValue()
    {
        await Task.Delay(100);
        var currentTotal = Interlocked.Read(ref 
            _runningTotal);
        Interlocked.Exchange(ref _runningTotal, 
            currentTotal * 10);
    }
}

这两个操作仍然可能以不同的顺序执行,但现在_runningTotal的使用已被锁定且线程安全。Interlocked类比使用锁定语句更高效,对于这种简单的更改,它将提供更好的性能。

在代码中执行并发操作时,保护所有共享资源非常重要。通过创建一个精心设计的锁定策略,你将在保持应用程序线程安全的同时实现最佳性能。让我们以关于线程限制的指导来结束这一章。

线程限制和其他建议

因此,使用多个线程确实可以加快应用程序的性能。你可能应该开始用Parallel.ForEach循环替换所有的foreach循环,并在线程池线程上调用所有你的服务和辅助方法,对吧?有没有限制,它们是什么?好吧,当涉及到线程时,绝对存在限制。

可以同时执行的线程数受系统上的处理器和处理器核心数限制。硬件限制无法规避,因为 CPU(或在虚拟机上运行时为虚拟 CPU)只能运行这么多线程。此外,你的应用程序必须与其他系统上运行的其他进程共享这些 CPU。如果你的 CPU 有四个核心,它正在积极运行五个其他应用程序,而你的程序正在尝试执行一个具有多个线程的过程,系统不太可能一次接受你的多个线程。

.NET 线程池已针对基于可用线程数的不同场景进行了优化,但你也可以做一些事情来防止过度使用系统。一些并行操作,如Parallel.ForEach,可以限制循环尝试使用的线程数。你可以向操作提供一个ParallelOptions对象并设置MaxDegreeOfParallelism选项。默认情况下,循环将使用调度器提供的所有线程。

你可以通过以下实现确保最大值不超过系统可用核心数的一半:

public void ProcessParallelForEachWithLimits
    (List<string> items)
{
    int max = Environment.ProcessorCount > 1 ? 
                Environment.ProcessorCount / 2 : 1;
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = max
    };
    Parallel.ForEach(items, options, y => {
        // Process items
    });
}

PLINQ 操作也可以使用WithDegreeOfParallelism扩展方法来限制最大并行度:

public bool ProcessPlinqWithLimits(List<string> items)
{
    int max = Environment.ProcessorCount > 1 ? 
        Environment.ProcessorCount / 2 : 1;
    return items.AsParallel()
        .WithDegreeOfParallelism(max)
        .Any(i => CheckString(i));
}
private bool CheckString(string item)
{
    return !string.IsNullOrWhiteSpace(item);
}

如果需要,应用程序也可以调整线程池的最大值。通过调用ThreadPool.SetMaxThreads,你可以更改workerThreadscompletionPortThreads的最大值。completionPortThreads是线程池上的异步 I/O 线程的数量。通常不需要更改这些值,并且你可以设置一些限制。最大值不能设置为小于系统核心数或小于线程池当前的最小值。你可以使用ThreadPool.GetMinThreads查询当前的最小值。以下是如何安全地将最大线程值设置为大于当前最小值的示例:

private void UpdateThreadPoolMax()
{
    ThreadPool.GetMinThreads(out int workerMin, out int 
        completionMin);
    int workerMax = GetProcessingMax(workerMin);
    int completionMax = GetProcessingMax(completionMin);
    ThreadPool.SetMaxThreads(workerMax, completionMax);
}
private int GetProcessingMax(int min)
{
    return min < Environment.ProcessorCount ?
                    Environment.ProcessorCount * 2 :
                    min * 2;
}

在你的应用程序中为操作分配线程的数量有一些其他的一般性指南需要遵循。尽量避免为共享资源的操作分配多个线程。例如,如果你有一个将活动记录到文件的服务,你不应该分配超过一个后台工作线程来进行记录。阻塞的文件 I/O 操作将阻止第二个线程写入,直到第一个线程完成。在这种情况下,你并没有获得任何效率的提升。

如果你发现自己正在应用程序中的对象上添加大量的锁定,那么你可能是在使用过多的线程,或者任务分配需要改变以减少对资源的竞争。尝试通过消耗的数据类型来划分线程任务的责任。你可能有很多并行任务调用服务来获取数据,但一旦数据返回,只需要一个或两个线程来处理数据。

你可能听说过线程饥饿这个术语。这通常发生在有太多的线程正在阻塞或等待资源变得可用时。有一些常见的场景会发生这种情况:

  • :有太多的线程在竞争相同的锁定资源。分析你的代码以确定如何减少竞争。

  • async。这允许 web 服务器在你等待操作完成的同时服务其他请求。

  • 过多的线程:创建过多的线程池线程会导致更多的空闲线程等待处理。这也增加了线程竞争和饥饿的可能性。

避免这些做法,.NET 将尽力管理线程池以服务于你的应用程序和其他系统上的应用程序。

最后,不要使用Thread.SuspendThread.Resume来尝试控制多个线程间操作的顺序。相反,利用本章讨论的其他技术,包括锁定机制和Task.ContinueWith

在本章中,我们已经介绍了许多关于托管线程的最佳实践。让我们通过回顾我们所学的内容来结束本章。

摘要

在本章中,我们讨论了在 C# 和 .NET 中处理托管线程时应遵循的一些最佳实践。我们首先创建了一些示例,说明了如何在多线程应用程序中管理和处理静态数据。这些示例说明了如何利用锁、与单例一起工作,以及静态构造函数在处理静态数据时如何影响性能。接下来,我们探索了一些避免死锁和竞态条件的技巧。如果你设计算法以最小化锁定需求,这两个陷阱都可以避免。最后,我们查看了一些可以调整多个并行和线程池操作限制的 .NET 功能。

到目前为止,你已经准备好在 .NET 项目中负责任地使用托管线程了。有关托管线程的最佳实践,你可以查看 Microsoft Docs 上的建议:https://docs.microsoft.com/en-us/dotnet/standard/threading/managed-threading-best-practices。

在下一章(第四章)中,你将学习如何利用并行性和并发性来保持应用程序的响应性,并了解一些从非 UI 线程更新 UI 的最佳实践。

问题

  1. 哪个设计模式模型描述了如何创建只有一个实例的对象?

  2. 哪个 .NET 属性会导致静态字段在每个线程中只有一个实例?

  3. 线程死锁是什么?

  4. 在尝试访问已锁定资源时,Monitor 类上的哪个方法可以指定超时?

  5. 哪个轻量级类可以用于锁定值类型以进行原子操作?

  6. 哪个线程安全操作可以用来添加两个整数?

  7. Parallel.ForParallel.ForEach 循环中可以设置哪个选项来限制使用的线程数?

  8. 你如何限制 PLINQ 查询中使用的线程数?

  9. 在线程池中找到当前最小线程值的方法的名称是什么?

第四章:第四章:用户界面响应性和线程

将线程概念引入项目的主要原因之一是希望保持应用程序对用户输入的响应性。通过服务、数据库或文件系统访问数据可能会引入延迟,而 用户界面UI) 应保持响应。本章中的实际示例将为确保 .NET 客户端应用程序中的 UI 响应性提供有价值的选项。

在本章中,我们将做以下事情:

  • 利用后台线程

  • 使用线程池

  • 无异常更新 UI 线程

到本章结束时,您将了解如何利用并行性和并发性来保持客户端应用程序的响应性和性能。

技术要求

为了跟随本章中的示例,以下软件被推荐给 Windows 用户:

  • Visual Studio 2022 版本 17.0 或更高版本

  • .NET 6

虽然这些是推荐的,但如果您已安装 .NET 6,您可以使用您喜欢的编辑器。例如,macOS 10.13 或更高版本的 Visual Studio 2022 for Mac、JetBrains Rider 或 Visual Studio Code 都可以正常工作。

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter04

让我们从讨论如何使用后台线程执行非关键任务而不影响 UI 性能开始。

利用后台线程

第一章 中,我们学习了如何创建后台线程,并讨论了它们的一些用途。后台线程的优先级低于进程的主线程和其他线程池线程。此外,活跃的后台线程不会阻止用户或系统终止应用程序。

这意味着后台线程非常适合以下任务:

  • 编写日志和数据分析

  • 监控网络或文件系统资源

  • 将数据读入应用程序

不要将后台线程用于以下关键应用程序操作:

  • 保存应用程序状态

  • 执行数据库事务

  • 应用数据处理

当决定某些工作是否可以由后台线程处理时,一个好的规则是问自己,如果突然中断工作来关闭应用程序,是否会危及系统的数据完整性。那么,您如何知道您正在创建后台线程还是前台线程呢?

哪些线程是后台线程?

我们已经了解到,可以通过将 IsBackground 属性设置为 true 来显式创建一个后台线程。默认情况下,通过调用 Thread 构造函数创建的所有其他线程都是前台线程。应用程序的主要(或主)线程是一个前台线程。所有 ThreadPool 线程都是后台线程。这包括由 Task Parallel Library (TPL) 启动的所有异步操作。

因此,如果所有基于任务的操作,如 async 方法,都在后台线程上执行,你应该避免使用它们来保存重要的应用程序数据吗?.NET 允许在 async / await 操作进行中关闭你的应用程序吗?如果有前台线程正在等待一个 async 操作,应用程序将不会在操作完成之前终止。如果你不使用 await,或者你在线程池上启动操作时使用 Task.Run,应用程序在动作完成之前可能正常终止。

使用 awaitasync 方法一起使用的好处是,你在保持 UI 响应的同时获得了控制执行流程的灵活性。让我们讨论客户端应用程序中的 asyncawait,并创建一个 Windows Presentation Foundation (WPF) 应用程序的示例,该程序从多个来源加载数据。

使用 async、await、任务和 WhenAll

在你的代码中使用 asyncawait 是引入一些后台工作使用 ThreadPool 的最简单方法。异步方法必须用 async 关键字装饰,并将返回 System.Threading.Tasks.Task 类型而不是 void 返回类型。

注意

Async 方法返回 Task,因此调用方法可以等待该方法的返回结果。如果你创建一个返回类型为 voidasync 方法,它将无法被等待,并且调用代码将在 async 方法完成之前继续处理后续代码。需要注意的是,只有事件处理程序应该声明为 async 并具有 void 返回类型。

如果方法返回 string,则 async 等效将返回 Task<string> 泛型类型。让我们看看每种情况的示例:

private async Task ProcessDataAsync()
{
    // Process data here
}
private async Task<string> GetStringDataAsync()
{
    string stringData;
    // Build string here
    ...
    return stringData;
}

当你调用一个 async 方法时,有两种常见的模式需要遵循。

  • 首先,你可以等待调用并将返回类型设置为方法内部返回的类型变量:

    await ProcessDataAsync();
    string data = await GetStringDataAsync();
    
  • 第二种选择是在调用方法时使用 Task 变量,稍后等待它们:

    Task dataTask = ProcessDataAsync();
    Task<string> stringDataTask = GetStringDataAsync();
    DoSomeOtherSynchronousWork();
    string data = await stringDataTask;
    await dataTask;
    

使用第二种方法,应用程序可以在两个 async 方法在后台线程上继续运行的同时执行一些同步工作。一旦同步工作完成,应用程序将等待这两个 async 方法。

让我们在一个更实际的示例项目中应用我们的 async 知识。在这个例子中,我们将创建一个新的 Windows 客户端应用程序,并使用 async 方法。我们将通过使用 Task.Delay 注入非阻塞延迟来模拟这些方法中的慢速服务调用以获取数据。每个方法将需要几秒钟才能返回其数据,但 UI 将保持对用户输入的响应:

  1. 首先在 Visual Studio 中创建一个新的 WPF 项目。将项目命名为 AwaitWithWpf

  2. 向项目中添加两个新类,分别命名为 OrderMainViewModel。你的解决方案现在应该看起来像这样:

图 4.1 – Visual Studio 中的 AwaitWithWpf 解决方案

图 4.1 – Visual Studio 中的 AwaitWithWpf 解决方案

  1. 接下来,打开 Microsoft.Toolkit.Mvvm 包中的 MVVM Toolkit 并将其添加到你的项目中:

图 4.2 – 将 Microsoft.Toolkit.Mvvm 包添加到项目中

图 4.2 – 将 Microsoft.Toolkit.Mvvm 包添加到项目中

我们将使用 MainViewModel 类。

注意

MVVM Toolkit 是一个开源的 MVVM 库,它是微软维护的 Windows Community Toolkit 的一部分。如果你不熟悉 MVVM 模式或 MVVM Toolkit,你可以在 Microsoft Docs 上了解更多信息:docs.microsoft.com/windows/communitytoolkit/mvvm/introduction

  1. 现在,打开 Order 类并添加以下实现:

    public class Order
    {
        public int OrderId { get; set; }
        public string? CustomerName { get; set; }
        public bool IsArchived { get; set; }
    }
    

这将为每个订单提供一些属性,以便在 MainWindow 上填充订单列表时显示。

  1. 现在,我们将开始构建 MainViewModel 的实现。第一步是添加一个要绑定到 UI 的订单列表和一个在我们要加载订单时执行的命令:

    public class MainViewModel : ObservableObject
    {
        private ObservableCollection<Order> _orders = 
            new();
        public MainViewModel()
        {
            LoadOrderDataCommand = new AsyncRelayCommand
                (LoadOrderDataAsync);
        }
        public ICommand LoadOrderDataCommand { get; set; }
        public ObservableCollection<Order> Orders
        {
            get { return _orders; }
            set
            {
                SetProperty(ref _orders, value);
            }
        }
        private async Task LoadOrderDataAsync()
        {
            // TODO – Add code to load orders
        }
    }
    

在进行下一步之前,让我们回顾一下 MainViewModel 类的一些属性:

  • MainViewModel 类继承自 MVVM Toolkit 提供的 ObservableObject 类型。

  • 这个基类实现了 INotifyPropertyChanged 接口,该接口被 WPF 数据绑定用于在数据绑定属性值更改时通知 UI。

  • Orders 属性将通过 WPF 数据绑定将订单列表提供给 UI。在 ObservableObject 基类上调用 SetProperty 会设置 _orders 后备变量的值并触发属性更改通知。

  • LoadOrderDataCommand 属性将由 MainWindow 上的按钮执行。在构造函数中,该属性被初始化为一个新的 AsyncRelayCommand,当命令被 UI 调用时,它会调用 LoadOrderDataAsync

  1. 不要忘记将必要的 using 语句添加到类中:

    using Microsoft.Toolkit.Mvvm.ComponentModel;
    using Microsoft.Toolkit.Mvvm.Input;
    using System.Collections.Generic;
    using System.Collections.ObjectModel;
    using System.Threading.Tasks;
    using System.Windows.Input;
    
  2. 接下来,让我们创建两个 async 方法来加载订单数据。一个将创建当前订单,另一个将创建已存档订单的列表。这些通过 Order 类上的 IsArchived 属性来区分。每个方法都使用 Task.Delay 来模拟在缓慢的互联网或网络连接上的服务调用:

    private async Task<List<Order>> GetCurrentOrders
        Async()
    {
        var orders = new List<Order>();
        await Task.Delay(4000);
        orders.Add(new Order { OrderId = 55, CustomerName 
            = "Tony", IsArchived = false });
        orders.Add(new Order { OrderId = 56, CustomerName 
            = "Peggy", IsArchived = false });
        orders.Add(new Order { OrderId = 60, CustomerName 
            = "Carol", IsArchived = false });
        orders.Add(new Order { OrderId = 62, CustomerName 
            = "Bruce", IsArchived = false });
        return orders;
    }
    private async Task<List<Order>> GetArchivedOrders
        Async()
    {
        var orders = new List<Order>();
        await Task.Delay(5000);
        orders.Add(new Order { OrderId = 3, CustomerName = 
            "Howard", IsArchived = true });
        orders.Add(new Order { OrderId = 18, CustomerName 
            = "Steve", IsArchived = true });
        orders.Add(new Order { OrderId = 19, CustomerName 
            = "Peter", IsArchived = true });
        orders.Add(new Order { OrderId = 21, CustomerName 
            = "Mary", IsArchived = true });
        orders.Add(new Order { OrderId = 25, CustomerName 
            = "Gwen", IsArchived = true });
        orders.Add(new Order { OrderId = 34, CustomerName 
            = "Harry", IsArchived = true });
        orders.Add(new Order { OrderId = 36, CustomerName 
            = "Bob", IsArchived = true });
        orders.Add(new Order { OrderId = 49, CustomerName 
            = "Bob", IsArchived = true });
        return orders;
    }
    
  3. 现在,我们需要创建一个同步的 ProcessOrders 方法,将两个订单列表合并,并使用完整的数据集更新 Orders 属性:

    private void ProcessOrders(List<Order> currentOrders, 
        List<Order> archivedOrders)
    {
        List<Order> allOrders = new(currentOrders);
        allOrders.AddRange(archivedOrders);
        Orders = new ObservableCollection<Order>
            (allOrders);
    }
    
  4. 构建 MainViewModel 类的最后一步是最重要的。将以下实现添加到 LoadOrderDataAsync 方法中:

    private async Task LoadOrderDataAsync()
    {
        Task<List<Order>> currentOrdersTask = 
            GetCurrentOrdersAsync();
        Task<List<Order>> archivedOrdersTask = 
            GetArchivedOrdersAsync();
        List<Order>[] results = await Task.WhenAll(new 
            Task<List<Order>>[] {
            currentOrdersTask, archivedOrdersTask
        }).ConfigureAwait(false);
        ProcessOrders(results[0], results[1]);
    }
    

此方法调用 GetCurrentOrdersAsyncGetArchivedOrdersAsync,并将每个调用捕获在一个 Task<List<Order>> 变量中。你可以简单地等待每个调用并将返回的订单存储在 List<Order> 变量中。然而,这意味着第二个方法将不会在第一个方法完成之前开始执行。通过等待 Task.WhenAll,方法可以在后台线程上并行执行。

如果你的方法都返回相同的数据类型,你可以在返回类型的数组中捕获 Task.WhenAll 的结果。在我们的例子中,我们正在接收一个 List<Order> 的数组,并将其传递给 ProcessOrders

  1. 现在,让我们转到 MainWindow.xaml.cs 的代码隐藏文件。在调用 InitializeComponent 之后,在构造函数中添加以下代码来设置 MainWindowDataContext

    public MainWindow()
    {
        InitializeComponent();
        var vm = new MainViewModel();
        DataContext = vm;
    }
    

DataContextMainWindow 的 XAML 中所有 Binding 引用的来源。我们将在下一步创建我们 UI 的 XAML。

  1. 最后要更新的文件是 MainWindow.xaml。打开 XAML 文件,首先向 Grid 添加两行。第一行将包含另一个 Grid,其中包含 ButtonTextBox。第二行将包含 ListView 来显示订单列表。我们将在稍后为订单创建一个模板:

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <Grid Grid.Row="0" Margin="4">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>
            <Button Content="Load Data" Grid.Column="0" 
                Margin="2" Width="200"
            Command="{Binding Path=LoadOrderData
                Command}"/>
            <TextBox Grid.Column="1" Margin="2"/>
        </Grid>
        <ListView Grid.Row="1" ItemsSource="{Binding 
            Path=Orders}" Margin="4">
        </ListView>
    </Grid>
    

我在 XAML 标记中突出显示了两个数据绑定实例。ButtonCommand 绑定到 LoadOrderDataCommand 属性,ListViewItemsSource 绑定到 Orders 属性。设置 ItemsSource 将使 Order 类的属性可用于 ListView.ItemTemplate 的成员。

  1. 让我们接下来为 ListView 添加 ItemTemplate。在 ItemTemplate 中定义 DataTemplate 定义了 ListView 中每个项目的结构:

    <ListView Grid.Row="1" ItemsSource="{Binding 
        Path=Orders}" Margin="4">
        <ListView.ItemTemplate>
            <DataTemplate>
                <StackPanel Margin="2">
                    <StackPanel Orientation="Horizontal">
                        <TextBlock Text="Order Id:"
                                   Margin="2,2,0,2"
                                   Width="100"/>
                        <TextBox IsReadOnly="True"
                               Width="200"
                               Text="{Binding 
                               Path=OrderId}" Margin="2"/>
                    </StackPanel>
                    <StackPanel Orientation="Horizontal">
                        <TextBlock Text="Customer:"
                                   Margin="2,2,0,2"
                                   Width="100"/>
                        <TextBox IsReadOnly="True"
                                 Width="200"
                                 Text="{Binding 
                                 Path=CustomerName}" 
                                 Margin="2"/>
                    </StackPanel>
                    <StackPanel Orientation="Horizontal">
                        <TextBlock Text="Archived:"
                                   Margin="2,2,0,2"
                                   Width="100"/>
                        <TextBox IsReadOnly="True"
                                 Width="200"
                                 Text="{Binding 
                                 Path=IsArchived}"
                                  Margin="2"/>
                    </StackPanel>
                </StackPanel>
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
    

每个 Order 实例将渲染为一个包含三个水平对齐的 StackPanel 元素的 StackPanel,显示 OrderIdCustomerNameIsArchived 数据绑定属性标签和值。

  1. 我们已经准备好运行应用程序并查看一切是如何工作的。程序启动后,点击 ListView。在你等待的时候,尝试在 async/awaitTask.WhenAll 方法右侧的框中输入一些文本。一旦数据加载完成,你应该在可滚动的列表中看到十二个订单:

图 4.2 – 在 AsyncWithWpf 应用程序中查看订单列表

图 4.2 – 在 AsyncWithWpf 应用程序中查看订单列表

在实际的生产应用程序中,两个async方法的实现将被替换为从数据库或 Web 服务获取数据的调用。无论返回和填充数据需要多长时间,UI 的其他部分都将保持对用户输入的响应。你想要做的更改之一是向 UI 添加一个指示器,告知用户数据正在加载。你还应该禁用LoadOrderDataAsync

该示例说明了在 Windows 应用程序中使用asyncawait的好处。这些async调用在 TPL 中使用ThreadPool。让我们看看在 Windows 应用程序中利用ThreadPool的其他方法。

使用线程池

在.NET 应用程序中,还有其他方法可以使用ThreadPool线程。让我们讨论一种情况,即你想要实现与上一个示例中asyncawait达到相同结果,但获取订单数据的方法并未标记为async。一个选择是将方法更新为async。如果这段代码不在你的控制范围内进行更改,你还有一些其他选项可用。

ThreadPool类有一个名为QueueUserWorkItem的方法。此方法接受一个要调用的方法,并将其排队在线程池上执行。我们可以像这样在我们的项目中使用它:

ThreadPool.QueueUserWorkItem(GetCurrentOrders);

使用这种方法有几个问题。主要问题是方法调用没有返回值来获取订单列表。你可以通过一些包装方法来解决这个问题,这些方法更新一个共享的线程安全集合,如BlockingCollection。这不是一个好的设计,有一个更好的选择。

在 TPL 引入之前,QueueUserWorkItem方法更常被使用。在当今基于任务的世界中,你可以使用Task.Run来执行同步方法作为async。让我们更新我们的 WPF 项目以使用Task.Run

  1. 只需要修改一个文件来使用Task.Run,那就是MainViewModel。首先,将GetCurrentOrdersAsyncGetArchivedOrdersAsync更新为不再为async方法。它们还应该被重命名为GetCurrentOrdersGetArchivedOrders,以便消费者知道它们不是async方法:

    private List<Order> GetCurrentOrders()
    {
        var orders = new List<Order>();
        Thread.Sleep(4000);
        orders.Add(new Order { OrderId = 55, CustomerName 
            = "Tony", IsArchived = false });
        orders.Add(new Order { OrderId = 56, CustomerName 
            = "Peggy", IsArchived = false });
        orders.Add(new Order { OrderId = 60, CustomerName 
            = "Carol", IsArchived = false });
        orders.Add(new Order { OrderId = 62, CustomerName 
            = "Bruce", IsArchived = false });
        return orders;
    }
    private List<Order> GetArchivedOrders()
    {
        var orders = new List<Order>();
        Thread.Sleep(5000);
        orders.Add(new Order { OrderId = 3, CustomerName = 
            "Howard", IsArchived = true });
        orders.Add(new Order { OrderId = 18, CustomerName 
            = "Steve", IsArchived = true });
        orders.Add(new Order { OrderId = 19, CustomerName 
            = "Peter", IsArchived = true });
        orders.Add(new Order { OrderId = 21, CustomerName 
            = "Mary", IsArchived = true });
        orders.Add(new Order { OrderId = 25, CustomerName 
            = "Gwen", IsArchived = true });
        orders.Add(new Order { OrderId = 34, CustomerName 
            = "Harry", IsArchived = true });
        orders.Add(new Order { OrderId = 36, CustomerName 
            = "Bob", IsArchived = true });
        orders.Add(new Order { OrderId = 49, CustomerName 
            = "Bob", IsArchived = true });
        return orders;
    }
    

这些更改很小,我在前面的源代码中已经突出显示了它们。方法声明中已经移除了async修饰符,方法已经被重命名,并且它们不再返回任务,每个方法中的Task.Delay已经被更新为Thread.Sleep

  1. 接下来,我们将更新LoadOrderDataAsync方法,使用Task.Run调用同步方法:

    private async Task LoadOrderDataAsync()
    {
        Task<List<Order>> currentOrdersTask = 
            Task.Run(GetCurrentOrders);
        Task<List<Order>> archivedOrdersTask = 
            Task.Run(GetArchivedOrders);
        List<Order>[] results = await Task.WhenAll(new 
            Task<List<Order>>[] {
            currentOrdersTask, archivedOrdersTask
        }).ConfigureAwait(false);
        ProcessOrders(results[0], results[1]);
    }
    

没有其他必要的更改。Task.Run将返回相同的Task<List<Order>>类型,这仍然可以与Task.WhenAll一起使用以等待它们的完成。

  1. 运行程序,它应该和之前完全一样工作。当加载数据时,UI 保持响应。

这是一种将asyncawait集成到现有代码中的极好方式,但在向应用程序添加线程时始终要小心。在此应用程序中,被调用的两个方法都没有访问任何共享数据。因此,没有必要考虑线程安全性。如果这些方法正在更新一个私有的订单集合,你需要引入一个锁定机制或使用线程安全的订单集合。

在我们讨论 UI 线程之前,还有一个其他的Task方法需要讨论。Task.Factory.StartNew方法的使用与Task.Run类似。实际上,你可以以相同的方式使用它们。此代码使用Task.Run来获取当前订单的Task

Task<List<Order>> currentOrdersTask = Task.Run
     (GetCurrentOrders);

这段代码使用Task.Factory.StartNew做同样的事情:

Task<List<Order>> currentOrdersTask = Task.Factory.StartNew
     (GetCurrentOrders);

在这种情况下,你应该使用Task.Run。这是一个较新的方法,它只是一个简化最常见用例的快捷方式。Task.Factory.StartNew方法有一些额外的重载用于特定用途。此示例使用StartNew调用GetCurrentOrders并带有一些可选参数:

Task<List<Order>> currentOrdersTask = 
    Task.Factory.StartNew(GetCurrentOrders, 
    CancellationToken.None, 
    TaskCreationOptions.AttachedToParent, 
    TaskScheduler.Default);

我们提供的有趣选项是TaskCreationOptions.AttachedToParent。这样做是将调用方法的任务完成与子任务GetCurrentOrders相链接。默认行为是它们的完成是未链接的。要查看可用重载及其用途的完整列表,你可以在 Microsoft Docs 上查看:docs.microsoft.com/dotnet/api/system.threading.tasks.taskfactory.startnew.

注意

.NET 团队的Stephen Toub在他的博客文章中讨论了Task.RunTask.Factory.StartNew的区别以及为什么你可能想要选择每个选项。你可以在.NET Parallel Programming博客上阅读他的文章:devblogs.microsoft.com/pfxteam/task-run-vs-task-factory-startnew/.

现在,让我们继续讨论在何时你需要编写代码来显式地从后台线程更新 UI 线程。

无异常地更新 UI 线程

当在.NET 应用程序中处理托管线程时,开发者必须学会避免许多陷阱。开发者常见的错误之一是在 Windows 应用程序的非 UI 线程中更新 UI 控件。这种错误不会被编译器检测到。开发者将收到一个运行时错误,表明在主线程上创建的控件不能在其他线程上修改。

那么,如何避免这些运行时错误呢?最好的办法是完全不在后台线程中更新 UI 控件。WPF 通过 MVVM 模式和数据绑定帮助避免了这个问题。绑定更新会自动由 .NET 调度到 UI 线程。您可以从后台线程安全地更新 ViewModel 类中的属性,而不会在运行时引发错误。

如果您直接在代码中更新 UI 控件,无论是在 WinForms 应用程序中还是在 WPF 控件的代码后文件中,您可以使用 Invoke 调用来 推送 执行到主线程。WinForms 和 WPF 之间的实现略有不同。让我们从一个 WPF 示例开始。如果您有一个在后台线程上执行某些工作的方法,并且它需要更新 WPF 窗口上 TextBoxText 属性,您可以将代码包裹在一个操作中:

Application.Current.Dispatcher.Invoke(new Action(() => { 
    usernameTextBox.Text = "John Doe";
}));

Dispatcher.Invoke 将执行推送至主线程。请注意,如果主线程正忙于其他工作,您的后台线程将在这里等待此操作完成。如果您的后台工作器想要触发并忘记此操作,您可以使用 Dispatcher.BeginInvoke 代替。

假设我们想要更新 usernameTextBox,但这次我们正在处理一个 WinForms 项目。可以通过使用 FormUserControl 执行代码来实现相同的调用。这个例子是一个包含两个按钮的 WinForms 应用程序。点击一个按钮将调用 UpdateUsername 方法。另一个按钮将调用 Task.Run(UpdateUsername),将其放在后台线程上。要确定是否需要 Invoke 来访问主线程,您检查只读布尔属性 InvokeRequired。如果线程池选择在主线程上运行 Task,则可能不需要:

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
    }
    private void btnRunInBackground_Click(object sender, 
        EventArgs e)
    {
        Task.Run(UpdateUsername);
    }
    private void btnRunOnMainThread_Click(object sender, 
        EventArgs e)
    {
        UpdateUsername();
    }
    private void UpdateUsername()
    {
        var updateAction = new Action(() =>
        {
            usernameTextBox.Text = "John Doe";
        });
        if (this.InvokeRequired)
        {
            this.Invoke(updateAction);
        }
        else
        {
            updateAction();
        }
    }
}

无论点击哪个按钮,usernameTextBox 都将成功显示名称 John Doe

图 4.3 – 更新 WinForms 表单上的控件

图 4.3 – 更新 WinForms 表单上的控件

与 WPF 类似,WinForms 也提供了一个 BeginInvoke 方法,如果后台代码不需要等待主线程更新完成。BeginInvoke 还可以接受一个 EndInvoke 委托,当主线程调用完成时,该委托将接收回调。

本节为您在 Windows 客户端应用程序中使用 .NET 管理线程提供了一个良好的起点。让我们总结一下本章所学的内容。

摘要

在本章中,我们学习了一些提高客户端应用程序性能的有用技术。我们首先从探索 WPF 应用程序中 ViewModel 的 asyncawait 的不同用法开始。在那个项目中,我们看到了等待 Task.WhenAll 不会阻塞主线程,这保持了用户输入对 UI 的响应性。我们讨论了如何使用 Task.RunTask.Factory.StartNew 从异步代码中调用同步代码,使得将托管线程引入现有应用程序变得更加容易。我们通过学习一些技术来更新 UI 线程,而不会在运行时引发异常来结束本章。

在阅读本章后,你应该在使用 asyncawait 和 TPL 的代码中感到更加自在。尝试将这里学到的知识应用到自己的客户端应用程序中。关于 asyncawait 的更多阅读,你可以查看 Microsoft Docs 上的这篇 C# 文章:docs.microsoft.com/dotnet/csharp/async

在下一章中,我们将更深入地探讨使用 asyncawait 和 TPL。我们将从本章中的一些概念出发,在介绍一些最佳实践的同时进行扩展。

问题

  1. 每个异步方法应该返回什么类型?

  2. 哪个方法可以用来等待多个任务?

  3. 哪个启动新任务的方法接受 TaskDispatcher 作为参数之一?

  4. 在调用异步方法时,哪种类型的线程将执行任务?

  5. 在 WPF 应用程序中,从后台线程更新用户控件时应使用哪种方法?

  6. 在 WinForms 控件上使用哪个方法可以在主线程上执行操作,但不需要等待方法完成?

  7. 在 WinForms 中,如何检查调用 Invoke 是否必要?

第二部分:使用 C# 进行并行编程和并发

是时候深入探讨使用 C# 和 .NET 6 的现代并行编程和并发方法了。本部分将探讨当今最常用的某些实际应用实践。

本部分包含以下章节:

  • 第五章, 使用 C# 进行异步编程

  • 第六章, 并行编程概念

  • 第七章, 任务并行库 (TPL) 和数据流

  • 第八章, 并行数据结构和并行 LINQ (PLINQ)

  • 第九章, 在 .NET 中使用并发集合

第五章:第五章:使用 C# 进行异步编程

.NET 的 asyncawait 关键字是在 .NET Framework 4.5 中引入的。这些关键字在 C# 5 中与 C# 语言一起发布。现在,十年过去了,TAP 模型已成为大多数 .NET 开发者工具集的一个组成部分。

本章将解释 C# 中的异步编程,探讨如何使用 Task 对象,并深入研究使用 asyncawait 在 .NET 中针对 I/O 绑定CPU 绑定 场景的最佳实践。

在本章中,你将了解以下内容:

  • 更多关于 .NET 异步编程的信息

  • 与 Task 对象一起工作

  • 与同步代码的互操作

  • 与多个后台任务一起工作

  • 异步编程最佳实践

到本章结束时,你将对异步编程有更深入的理解,并且应该有足够的信心将高级异步功能添加到团队的项目中。

技术要求

在本章中,我们将使用 .NET 命令行界面CLI)和 Visual Studio Code 来构建和运行示例项目。为了跟随示例,以下软件是推荐的:

  • Visual Studio Code 版本 1.65 或更高版本

  • .NET 6 或更高版本

虽然这些是推荐的,但如果已经安装了 .NET 6,你可以使用你喜欢的编辑器。例如,如果你使用的是 Windows 10 或 11,则可以使用 Visual Studio 2022 版本 17.0 或更高版本,如果你使用的是 macOS 10.13 或更高版本,则可以使用 Visual Studio 2022 for Mac,或者 JetBrains Rider 也可以正常工作。

本章的所有代码示例都可以在 GitHub 上找到,链接为 github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter05

让我们从通过一些使用 asyncawait 的 TAP 模型示例开始吧。

更多关于 .NET 异步编程的信息

异步代码通常被引入的两个场景类型:

  • I/O 绑定操作:这些涉及从网络或磁盘获取的资源。

  • CPU 绑定操作:这些是在内存中进行的、CPU 密集型操作。

在本节中,我们将创建一些使用 asyncawait 为每种操作类型提供真实世界示例。无论你是等待外部进程完成,还是在你的应用程序中执行 CPU 密集型操作,你都可以利用异步代码来提高应用程序的性能。

让我们先看看一些 I/O 绑定操作的示例。

I/O 绑定操作

当你处理受文件或网络操作约束的 I/O 绑定代码时,你的代码应该使用 asyncawait 来等待操作完成。

.NET 用于执行网络和文件 I/O 的方法都是异步的,因此不需要使用 Task.Run

  • ReadToEndAsync方法在Environment.NewLine字符处分割文本,并将数据作为List<string>实例返回。文件中的每一行文本都是列表中的一个项:

    public async Task<List<string>> GetDataAsync
        (string filePath)
    {
        using var file = File.OpenText(filePath);
        var data = await file.ReadToEndAsync();
        return data.Split(new[] { Environment.NewLine },
            StringSplitOptions.RemoveEmptyEntries)
                .ToList();
    }
    
  • 使用HttpClient类下载提供的 URL 上的文件,在分割并返回文本行的列表之前,使用await关键字:

    public async Task<List<string>> GetOnlineDataAsync
        (string url)
    {
        var httpClient = new HttpClient();
        var data = await httpClient.GetStringAsync(url);
        return data.Split(new[] { Environment.NewLine },
            StringSplitOptions.RemoveEmptyEntries)
                .ToList();
    }
    

这些是一些常见的 I/O 密集型操作,但什么是 CPU 密集型操作,它与 I/O 密集型操作有何不同?

CPU 密集型操作

在这种情况下,你的应用程序不是在等待外部过程完成。应用程序本身正在执行一个需要时间才能完成的 CPU 密集型操作,并且你希望应用程序在操作完成之前保持响应。

在这个例子中,我们有一个接受List<string>实例的方法,其中列表中的每个项都包含这个JournalEntry类的 XML 表示:

[Serializable]
public class JournalEntry
{
    public string Title { get; set; }
    public string Description { get; set; }
    public DateTime EntryDate { get; set; }
    public string EntryText { get; set; }
}

假设EntryText可以非常大,因为一些在日志应用程序中写作的用户会为单个条目添加数十页的文本。每个条目都以 XML 格式存储在数据库中,加载条目的应用程序有一个DeserializeEntries方法,用于反序列化每个 XML 字符串,并将数据作为List<JournalEntry>实例返回:

private List<JournalEntry> DeserializeEntries(List<string> 
    journalData)
{
    var deserializedEntries = new List<JournalEntry>();
    var serializer = new XmlSerializer(typeof
        (JournalEntry));
    foreach (var xmlEntry in journalData)
    {
        if (xmlEntry == null) continue;
        using var reader = new StringReader(xmlEntry);
        var entry = (JournalEntry)serializer.Deserialize
            (reader)!;
        if (entry == null) continue;
        deserializedEntries.Add(entry);
    }
    return deserializedEntries;
}

在添加了几个月的日志条目之后,用户抱怨加载现有条目所需的时间。他们希望在数据加载时开始创建新条目。

幸运的是,使用异步.NET 代码可以在等待长时间运行的过程完成时保持应用程序的用户界面响应。线程可以在非阻塞调用完成之前自由执行其他工作。通过添加一个名为DeserializeJournalDataAsync的异步方法,该方法使用awaitTask.Run方法调用现有方法,客户端代码可以在用户创建新的日志条目时保持响应:

public async Task<List<JournalEntry>> 
    DeserializeJournalDataAsync(List<string> journalData)
{
    return await Task.Run(() => DeserializeEntries
        (journalData));
}

如果你正在使用 JSON 格式的序列化数据而不是 XML,反序列化的同步和异步方法非常相似。这是因为.NET 在System.Text.Json.JsonSerializer类中提供了DeserializeDeserializeAsync方法。以下是这两种方法,其中突出了它们之间的差异:

public List<JournalEntry> DeserialzeJsonEntries
    (List<string> journalData)
{
    var deserializedEntries = new List<JournalEntry>();
    foreach (var jsonEntry in journalData)
    {
        if (string.IsNullOrWhiteSpace(jsonEntry)) continue;
        deserializedEntries.Add(JsonSerializer.Deserialize
            <JournalEntry>(jsonEntry)!);
    }
    return deserializedEntries;
}
public async Task<List<JournalEntry>> Deserialize
     JsonEntriesAsync(List<string> journalData)
{
    var deserializedEntries = new List<JournalEntry>();
    foreach (var jsonEntry in journalData)
    {
        if (string.IsNullOrWhiteSpace(jsonEntry)) continue;
        using var stream = new MemoryStream(Encoding
            .Unicode.GetBytes(jsonEntry));
        deserializedEntries.Add((await JsonSerializer
            .DeserializeAsync<JournalEntry>(stream))!);
    }
    return deserializedEntries;
}

Deserialize方法接受string,但DeserializeAsync不接受。相反,我们必须从jsonEntry字符串创建一个MemoryStream实例,并将其传递给DeserializeAsync。除此之外,只有方法的返回类型不同。

让我们通过查看一个处理 JSON 反序列化日志条目列表的另一个方法来结束本节。在这个例子中,仅处理单个 JSON 条目的方法。一个名为GetJournalEntriesAsync的父方法使用 LINQ 的Select运算符对列表中的每个字符串调用DeserializeJsonEntryAsync,并将IEnumerable<Task<JournalEntry>>实例存储在getJournalTasks变量中:

public async Task<List<JournalEntry>> 
    GetJournalEntriesAsync(List<string> journalData)
{
    var journalTasks = journalData.Select(entry => 
        DeserializeJsonEntryAsync(entry));
    return (await Task.WhenAll(journalTasks)).ToList();
}
private async Task<JournalEntry> DeserializeJsonEntryAsync
    (string jsonEntry)
{
    if (string.IsNullOrWhiteSpace(jsonEntry)) return new 
        JournalEntry();
    using var stream = new MemoryStream
        (Encoding.Unicode.GetBytes(jsonEntry));
    return (await JsonSerializer.DeserializeAsync
        <JournalEntry>(stream))!;
}

突出的代码等待 journalTasks 中的所有 Task 对象,将每个调用的结果作为 JournalEntry 对象的数组返回。你可以声明 GetJournalEntriesAsync 的返回类型为 Task<JournalEntry[]>,或者像我们在本示例中那样使用 ToList 返回 Task<List<JournalEntry>>。你可以看到当需要遍历一个项目列表并对每个项目进行异步调用时,LINQ 如何简化你的代码。

你已经看到了在代码中使用异步和 await 的不同方法,无论是针对 I/O 密集型操作还是 CPU 密集型操作。

接下来,我们将讨论如何链式调用嵌套异步方法以及如何启动该链的最高层。

嵌套异步方法

在使用异步方法时,当您想要保持执行顺序时,使用 await 是很重要的。同样重要的是要保留对当前线程入口点的等待调用链。

例如,如果你的应用程序是一个控制台应用程序,主要的入口点是 Program.cs 中的 Main 方法。如果你不能使这个 Main 方法变为 async,那么在 Main 之下的所有方法调用都不会使用 await 关键字。这就是为什么 .NET 现在支持 async Main 方法的原因。现在,当你使用 .NET 6 创建一个新的控制台应用程序时,它默认有一个 async Main 方法。

如果执行的入口点是事件处理器,你应该将事件处理器方法标记为 async。这是唯一一次你会看到具有 void 返回类型的 async 方法:

private async void saveButton_Click(object sender, 
    EventArgs e)
{
    await SaveData();
}

让我们看看在控制台应用程序中正确链式调用多个嵌套异步方法的例子:

  1. 首先创建一个新的控制台应用程序。在名为 AsyncSamples 的文件夹内,运行以下命令:

    dotnet new console –framework net6.0
    
  2. 当进程完成后,在 Visual Studio Code 或你选择的编辑器中打开新的 AsyncSamples.csproj 文件。

  3. 向项目中添加一个名为 TaskSample 的新类

  4. 将以下代码添加到 TaskSample 类中:

    public async Task DoThingsAsync()
    {
        Console.WriteLine($"Doing things in 
            {nameof(DoThingsAsync)}");
        await DoFirstThingAsync();
        await DoSecondThingAsync();
        Console.WriteLine($"Did things in 
            {nameof(DoThingsAsync)}");
    }
    private async Task DoFirstThingAsync()
    {
        Console.WriteLine($"Doing something in 
            {nameof(DoFirstThingAsync)}");
        await DoAnotherThingAsync();
        Console.WriteLine($"Did something in 
            {nameof(DoFirstThingAsync)}");
    }
    private async Task DoSecondThingAsync()
    {
        Console.WriteLine($"Doing something in 
            {nameof(DoSecondThingAsync)}");
        await Task.Delay(500);
        Console.WriteLine($"Did something in 
            {nameof(DoSecondThingAsync)}");
    }
    private async Task DoAnotherThingAsync()
    {
        Console.WriteLine($"Doing something in 
            {nameof(DoAnotherThingAsync)}");
        await Task.Delay(1500);
        Console.WriteLine($"Did something in 
            {nameof(DoAnotherThingAsync)}");
    }
    
  5. 现在打开 Program.cs 并添加一些代码来调用 DoThingsAsync

    using AsyncSamples;
    Console.WriteLine("Start processing"…");
    var taskSample = new TaskSample();
    await taskSample.DoThingsAsync();
    Console.WriteLi"e("Done processing"..");
    

让我们通过我们的项目调用的方法的顺序和层次结构来举例说明。Main 方法调用 DoThingsAsync,然后 DoThingsAsync 又依次调用 DoFirstThingAsyncDoSecondThingAsync。最后,在 DoFirstThingAsync 中,调用了 DoAnotherThingAsync。当使用 await 操作符调用每个这些 async 方法时,操作顺序是可预测的:

图 5.1:等待方法的操作顺序

图 5.1:等待方法的操作顺序

  1. 运行程序并检查控制台输出的顺序。一切都应该按照预期的顺序执行:

图 5.2:检查 AsyncSamples 控制台应用程序的输出

图 5.2:检查 AsyncSamples 控制台应用程序的输出

  1. 接下来,我们将向 TaskSample 类添加两个额外的函数:

    public async Task DoingThingsWrongAsync()
    {
        Console.WriteLine($"Doing things in 
            {nameof(DoingThingsWrongAsync)}");
        DoFirstThingAsync();
        await DoSecondThingAsync();
        Console.WriteLine($"Did things in 
            {nameof(DoingThingsWrongAsync)}");
    }
    public async Task DoBlockingThingsAsync()
    {
        Console.WriteLine($"Doing things in 
            {nameof(DoBlockingThingsAsync)}");
        DoFirstThingAsync().Wait();
        await DoSecondThingAsync();
        Console.WriteLine($"Did things in 
            {nameof(DoBlockingThingsAsync)}");
    }
    

DoingThingsWrongAsync 方法已经从对 DoFirstThingAsync 的调用中移除了 await。因此,DoSecondThingAsync 的执行将在 DoFirstThingAsync 完成之前开始。如果后续代码没有依赖于 DoFirstThingAsync 内部发生的处理,这可能没问题。然而,任何未处理的异常都不会自动向上冒泡到 调用 方法。对于该调用的 Task 实例,其 Status 值将为 FaultedIsFaulted 属性将为 true,而 Exception 属性将包含未处理的异常信息。

在前面的例子中,DoFirstThingAsync 中的任何未处理的异常都将不会被检测到。如果你有一个没有等待 Task 实例的情况,确保在出现异常的情况下监控 Task 实例的状态。这也是为什么你不应该有 async void 方法的原因之一。它不会返回一个 Task 实例来等待。

DoBlockingThings 方法将保持操作的正确顺序,但通过调用 DoFirstThingAsync().Wait() 而不是等待调用,执行 DoBlockingThings 的线程将被阻塞。它将等待 DoFirstThingAsync 的调用完成,而不是在长时间运行的异步方法完成之前自由地执行其他工作。使用 Wait()Result 等阻塞调用可以快速耗尽 ThreadPool 中可用的线程。

  1. Program.cs 更新为调用所有三个公共 TaskSample 方法:

    using AsyncSamples;
    Console.WriteLine("Start processing...");
    var taskSample = new TaskSample();
    await taskSample.DoThingsAsync();
    Console.WriteLine("Continue processing...");
    await taskSample.DoingThingsWrongAsync();
    Console.WriteLine("Continue processing...");
    await taskSample.DoBlockingThingsAsync();
    Console.WriteLine("Done processing...");
    
  2. 现在运行程序并检查控制台输出,以查看省略 DoingThingsWrongAsync 内部的 await 对其有何影响:

图 5.3:调用所有 TaskSample 方法时的控制台输出

图 5.3:调用所有 TaskSample 方法时的控制台输出

输出可能会因 ThreadPool 线程的分配方式而略有不同。在这种情况下,对 DoFirstThingAsync 的第二次调用在第三次调用同一方法开始之前保持未完成状态。即使 Program.cs 等待其 DoingThingsWrongAsync 的调用,在该方法内部的代码在 DoBlockingThingsAsync 的下一次调用被触发后仍然在执行。

当异步任务没有被等待时,事情可能会变得非常不可预测。除非你有充分的理由不这样做,否则你应该始终等待任务。接下来,让我们探索 Task 类中可用的某些属性和方法。

与任务对象一起工作

在将线程引入现有项目时,直接与 Task 对象一起工作可能非常有用。正如我们在上一节中看到的,引入 asyncawait 时更新整个调用栈非常重要。在一个大型代码库中,这些更改可能是广泛的,并且需要进行相当多的回归测试。

你可以使用 TaskTask<TResult> 来包装你想异步运行的方法。这两种 Task 类型代表方法或动作正在执行的非阻塞工作。当你想使用返回 void 的方法时,使用 Task。对于具有非 void 返回类型的方法,使用 Task<TResult>

下面是两个同步方法签名及其异步等价的例子:

public interface IAsyncExamples
{
    void ProcessOrders(List<Order> orders);
    Task ProcessOrdersAsync(List<Order> orders);
    List<Order> GetOrders(int customerId);
    Task<List<Order>> GetOrdersAsync(int customerId);
}

我们在本章中看到了一些使用 Task 对象的例子。现在,是时候探索这两个类型的额外属性、方法和用途了。

探索 Task 方法

首先,我们将通过实际例子发现一些常用的 Task 方法。考虑接受要处理和提交的订单列表的 ProcessOrders 方法。使用的四个 Task 方法如下:

  • Task.Run: 在线程池上的线程上运行一个方法

  • Task.Factory.StartNew: 在线程池上的线程上运行一个方法,并提供 TaskCreationOptions

  • processOrdersTask.ContinueWith: 当 processOrdersTask 完成,它将在同一个线程池线程上执行提供的方法。

  • Task.WaitAll: 此方法将阻塞当前线程并等待数组中的所有任务。

这些方法在以下代码中被突出显示:

public void ProcessOrders(List<Order> orders, int 
    customerId)
{
    Task<List<Order>> processOrdersTask = Task.Run(() => 
        PrepareOrders(orders));
    Task labelTask = Task.Factory.StartNew(() => 
        CreateLabels(orders), TaskCreationOptions
            .LongRunning);
    Task sendTask = processOrdersTask.ContinueWith(task => 
        SendOrders(task.Result));
    Task.WaitAll(new[] { labelTask, sendTask });
    SendConfirmation(customerId);
}

这就是前面例子中每一行发生的事情:

  1. Task.Run 将创建一个新的后台线程并将其队列在 ThreadPool

  2. Task.Factory.StartNew 也会创建一个新的后台线程,并将其队列在 ThreadPool 上。此外,我们为 StartNew 提供了 TaskCreattionOptions.LongRunning 作为参数,以表明创建额外的线程是合理的,因为此任务可能需要一段时间才能完成。这将防止其他任务在 ThreadPool 上排队时的延迟。

  3. ContinueWithSendOrders 队列在 ThreadPool 线程上,但线程不会启动,直到 processOrdersTask 完成。

  4. Task.WaitAllasync 方法 Task.WhenAll 的同步等价。它将阻塞当前线程,直到 labelTasksendTask 完成。

  5. 最后,调用 SendConfirmation 通知客户他们的订单已处理并发送。

以这种方式使用任务可以达到与等待任务以实现并行处理相同的结果的 async 方法。主要区别在于,当调用 WaitAll 时,当前线程将在 步骤 4 处被阻塞。

我们接下来要探索的另一个有用的方法是 RunSynchronously。这个方法启动一个任务,但在当前线程上同步执行。异步等价方法是调用任务上的 Start

在这个例子中,ProcessData 方法接受一个参数,指示数据是否必须在 UI 线程上处理。可能有些数据处理需要与 UI 交互,向用户展示一些选项或其他反馈:

public void ProcessData(object data, bool uiRequired)
{
    Task processTask = new(() => DoDataProcessing(data));
    if (uiRequired)
    {
        // Run on current thread (UI thread assumed for 
            example)
        processTask.RunSynchronously();
    }
    else
    {
        // Run on ThreadPool thread in background
        processTask.Start();
    }
}

接下来,让我们探索 TaskTask<TResult> 类的一些属性。

探索 Task 属性

在本节中,我们将回顾Task对象上可用的属性。大多数属性都与任务的状态相关,因此我们将从Status属性开始。Status属性返回TaskStatus,它是一个具有八个可能值的枚举:

  • Created (0): 任务已被创建和初始化,但尚未在ThreadPool上安排。

  • WaitingForActivation (1): 任务正在等待.NET 进行安排

  • WaitingToRun (2): 任务已被安排,但尚未开始执行

  • Running (3): 任务目前正在运行。

  • WaitingForChildrenToComplete (4): 任务已完成,但有附加的子任务仍在运行或等待运行

  • RanToCompletion (5): 任务成功运行到完成

  • Canceled (6): 任务被取消并确认了取消

  • Faulted (7): 在执行任务时遇到了未处理的异常

TaskTask<TResult>的以下属性是检查状态的快捷方式:

  • IsCanceled: 如果任务的StatusCanceled,则返回true

  • IsCompleted: 如果任务的StatusRanToCompletionCanceledFaulted,则返回true

  • IsCompletedSuccessfully: 如果任务的StatusRanToCompletion,则返回true

  • IsFaulted: 如果任务的StatusFaulted,则返回true

使用这些属性可以简化代码中的状态检查。Task对象的剩余实例属性如下:

  • AsyncState: 返回创建任务时提供的状态。如果没有提供状态,则此属性返回null

  • CreationOptions: 返回创建任务时提供的CreationOptions值。如果没有提供选项,则默认为TaskCreationOptions.None

  • Exception: 返回一个包含在任务运行期间遇到的未处理异常的AggregateException实例。应在处理AggregateException类型的try/catch块中调用WaitWaitAll

  • Id: 为任务分配的系统标识符

让我们快速看一下如何正确捕获AggregateException实例并检查故障任务的Exception属性:

Task ordersTask = Task.Run(() => ProcessOrders(orders, 
    123));
try
{
    ordersTask.Wait();
    Console.WriteLine($"ordersTask Status: 
        {ordersTask.Status}");
} 
catch (AggregateException)
{
    Console.WriteLine($"Exception in ordersTask! Error 
        message: {ordersTask.Exception.Message}");
}   

此代码将在完成后将任务的状态写入控制台。如果遇到未处理的异常,错误消息将在catch块中写入控制台。

现在你对TaskTask<TResult>的成员更加熟悉了,让我们讨论一些从异步代码调用同步代码以及反之亦然的用例。

与同步代码的互操作

当与现有项目一起工作并引入异步代码到系统中时,将会有同步和异步代码相交的点。我们已经在本章中看到了一些处理这种互操作性的例子。在本节中,我们将关注双向互操作性:同步调用异步和异步调用同步。

我们将创建一个包含表示旧代码的同步方法的类和包含现代 async 方法的另一组类的示例项目。

让我们先讨论如何在旧同步代码中消费 async 方法。

从同步方法执行异步

在本例中,我们将使用一个 .NET 控制台应用程序来获取患者及其药物列表。应用程序将调用同步的 GetPatientAndMedications 方法,该方法反过来调用异步的 GetPatientInfoAsync 方法:

  1. 首先创建一个新的 .NET 控制台应用程序

  2. PatientProviderMedication 类添加到 Models 文件夹,并将 HealthcareServiceMedicationLoader 类添加到 SyncToAsync 文件夹:

图 5.4:从同步代码调用异步的初始项目结构

图 5.4:从同步代码调用异步的初始项目结构

  1. 为模型类添加必要的属性:

    public class Medication
    {
        public int Id { get; set; }
        public string? Name { get; set; }
    }
    public class Provider
    {
        public int Id { get; set; }
        public string? Name { get; set; }
    }
    public class Patient
    {
        public int Id { get; set; }
        public string? Name { get; set; }
        public List<Medication>? Medications { get; set; }
        public Provider? PrimaryCareProvider { get; set; }
    }
    
  2. HealthcareService 类中创建 GetPatientInfoAsync 方法。此方法在注入 2 秒异步延迟后创建一个带有提供者和两种药物的病人:

    public async Task<Patient> GetPatientInfoAsync
        (int patientId)
    {
        await Task.Delay(2000);
        Patient patient = new()
        {
            Id = patientId,
            Name = "Smith, Terry",
            PrimaryCareProvider = new Provider
            {
                Id = 999,
                Name = "Dr. Amy Ng"
            },
            Medications = new List<Medication>
            {
                new Medication { Id = 1, Name = 
                    "acetaminophen" },
                new Medication { Id = 2, Name = 
                    "hydrocortisone cream" }
            }
        };
        return patient;
    }
    
  3. MedicationLoader 服务添加实现:

    public class MedicationLoader
    {
        private HealthcareService _healthcareService;
        public MedicationLoader()
        {
            _healthcareService = new HealthcareService();
        }
        public Patient? GetPatientAndMedications(int 
            patientId)
        {
            Patient? patient = null;
            try
            {
                patient = _healthcareService
                   .GetPatientInfoAsync(patientId).Result;
            }
            catch (AggregateException ae)
            {
                Console.WriteLine($"Error loading patient. 
                    Message: {ae.Flatten().Message}");
            }
            if (patient != null)
            {
                patient = ProcessPatientInfo(patient);
                return patient;
            }
            else
            {
                return null;
            }
        }
        private Patient ProcessPatientInfo(Patient 
            patient)
        {
            // Add additional processing here.
            return patient;
        }
    }
    

GetPatientAndMedications 方法调用 GetPatientInfoAsync 并使用 Result 属性同步等待异步方法完成并返回值。使用 Result 与在返回无值的异步方法上使用 Wait() 方法相同。当前线程在方法完成前被阻塞。

我们将调用封装在一个 try/catch 块中,该块处理 AggregateException 实例。如果调用成功,并且 patient 变量不是 null,则在返回患者数据给调用者之前调用 ProcessPatientInfo

  1. 将此代码添加到 Program.cs 中以调用同步方法:

    using SyncAndAsyncSamples.Models;
    using SyncAndAsyncSamples.SyncToAsync;
    Console.WriteLine("Hello, sync to async world!");
    var medLoader = new MedicationLoader();
    Patient? patient = medLoader.GetPatientAndMedications
        (123);
    Console.WriteLine($"Loaded patient: {patient.Name} 
        with {patient.Medications.Count} medications.");
    
  2. 运行程序。您应该在窗口中看到以下输出:

Hello, sync to async world!
Loaded patient: Smith, Terry with 2 medications.

接下来,让我们尝试使用异步方法调用一些旧同步代码来加载相同的数据。

将同步代码作为异步执行

在本例中,我们将模仿上一个示例。将有一个具有异步方法的 PatientLoader 实例调用具有同步方法的 PatientService 实例:

  1. 在您的项目中添加一个新 AsyncToSync 文件夹,并添加 PatientService 类:

  2. 创建一个与上一个示例中的 GetPatientInfoAsync 方法具有相似实现的 GetPatientInfo 方法:

    public Patient GetPatientInfo(int patientId)
    {
        Thread.Sleep(2000);
        Patient patient = new()
        {
            Id = patientId,
            Name = "Smith, Terry",
            PrimaryCareProvider = new Provider
            {
                Id = 999,
                Name = "Dr. Amy Ng"
            },
            Medications = new List<Medication>
            {
                new Medication { Id = 1, Name = 
                    "acetaminophen" },
                new Medication { Id = 2, Name = 
                    "hydrocortisone cream" }
            }
        };
        return patient;
    }
    

这里的不同之处在于方法不是 async,它返回 Patient 实例而不是 Task<Patient> 实例,并且我们使用 Thread.Sleep 而不是 Task.Delay 注入延迟。

  1. AsyncToSync 文件夹中创建 PatientLoader 类,并开始其实现,通过创建 PatientService 的新实例:

    private PatientService _patientService = new 
        PatientService();
    
  2. 现在从上一个示例创建 ProcessPatientInfo 的异步版本:

    private async Task<Patient> ProcessPatientInfoAsync
        (Patient patient)
    {
        await Task.Delay(100);
        // Add additional processing here.
        return patient;
    }
    
  3. 现在创建 GetPatientAndMedsAsync 方法:

    public async Task<Patient?> GetPatientAndMedsAsync
        (int patientId)
    {
        Patient? patient = null;
        try
        {
            patient = await Task.Run(() => 
               _patientService.GetPatientInfo(patientId));
        }
        catch (Exception e)
        {
            Console.WriteLine($"Error loading patient. 
                Message: {e.Message}");
        }
        if (patient != null)
        {
            patient = await ProcessPatientInfoAsync
                (patient);
            return patient;
        }
        else
        {
            return null;
        }
    }
    

与上一个示例的主要区别被突出显示。GetPatientInfosynchronous 类被包裹在一个对 await Task.Run 的调用中,这将等待调用而不阻塞当前线程执行其他工作。

我们现在在 catch 块中使用 Exception 而不是 AggregateException。你应该始终与阻塞的 WaitResult 调用一起使用 AggregateException,并使用 Exceptionasyncawait 一起使用。

最后,如果 patient 变量不是 null,则等待对 ProcessPatientInfoAsync 的异步调用。

  1. 接下来更新 Program.cs 以调用新的 PatientLoader 代码:

    using SyncAndAsyncSamples.AsyncToSync;
    using SyncAndAsyncSamples.Models;
    Console.WriteLine("Hello, async to sync world!");
    var loader = new PatientLoader();
    Patient? patient = await loader.GetPatientAndMedsAsync
        (123);
    Console.WriteLine($"Loaded patient: {patient.Name} 
        with {patient.Medications.Count} medications.");
    
  2. 运行程序,输出应该类似于上一个示例:

Hello, async to sync world!
Loaded patient: Smith, Terry with 2 medications.

到现在为止,你应该已经对如何在异步和同步代码之间进行交互有了扎实的理解。让我们继续前进,创建一个从多个 async 方法并行加载数据的示例。

与多个后台任务一起工作

在本节中,我们将看到从多个来源并行加载数据的代码示例,而不是等待方法准备好返回数据给调用者。对于同步和异步代码,技术略有不同,但总体思路是相同的。

首先,回顾这个调用三个异步方法并使用 Task.WhenAll 等待返回患者数据的方法:

public async Task<Patient> LoadPatientAsync(int patientId)
{
    var taskList = new List<Task>
    {
        LoadPatientInfoAsync(patientId),
        LoadProviderAsync(patientId),
        LoadMedicationsAsync(patientId)
    };
    await Task.WhenAll(taskList.ToArray());
    _patient.Medications = _medications;
    _patient.PrimaryCareProvider = _provider;
    return _patient;
}

现在,回顾这个方法的同步版本,它使用了 Task.WaitAll:

public Patient LoadPatient(int patientId)
{
    var taskList = new List<Task>
    {
        LoadPatientInfoAsync(patientId),
        LoadProviderAsync(patientId),
        LoadMedicationsAsync(patientId)
    };
    Task.WaitAll(taskList.ToArray());
    _patient.Medications = _medications;
    _patient.PrimaryCareProvider = _provider;
    return _patient;
}

即使是这个使用阻塞 WaitAll 调用的代码版本,其执行速度也将快于对三个方法分别进行单独的同步调用。

这个 ParallelPatientLoader 类的完整实现可以在本章的 GitHub 仓库中找到。让我们通过列出使用 asyncawaitTask 对象的一些最佳实践来结束本章。

异步编程最佳实践

当处理异步代码时,有许多最佳实践你应该知道。在本节中,我们将列出你在日常开发中应该记住的最重要的一些最佳实践。大卫·福勒(David Fowler),他是微软 ASP.NET 团队的资深成员,也是 .NET 专家,维护了一个包含许多其他最佳实践的开放源代码列表。我建议在处理你自己的项目时将此页面添加到书签,以供以后参考:github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md#asynchronous-programming

这些是我(不分先后顺序)在处理异步代码时推荐的最高建议:

  1. 总是优先选择 asyncawait 而不是同步方法以及像 Wait()Result 这样的阻塞调用。如果你正在创建一个新的项目,你应该从一开始就考虑使用异步。

  2. 除非你使用 Task.WhenAll 来同时等待多个操作,否则你应该直接等待一个方法,而不是创建一个 Task 实例然后等待它。

  3. 不要使用 async void。您的异步方法应始终返回 TaskTask<TResult>ValueTaskValueTask<TResult>。唯一的例外是具有返回 void 的现有签名的事件处理器。在 .NET 6 中,Main 事件方法可以是异步的。

  4. 不要混合阻塞代码和异步代码。通过调用堆栈使用 async 调用。

  5. 除非您需要向 StartNew 重载方法之一传递额外的参数,否则请使用 Task.Run 而不是 Task.Factory.StartNew

  6. 长运行的 async 方法应支持取消。我们将在 第十一章 中深入讨论取消。

  7. 同步使用共享数据。您的代码应该添加锁以防止跨线程使用对象中的数据被覆盖。

  8. 总是使用 asyncawait 进行 I/O 密集型工作,如网络和文件访问。

  9. 当您创建一个 async 方法时,请将其名称添加 Async 后缀。这有助于一眼区分 syncasync 方法。用于返回用户信息的 async 方法应命名为 GetUserInfoAsync,而不是 GetUserInfo

  10. 不要在异步方法中使用 Thread.Sleep。如果您的代码必须等待固定的时间段,请使用 await Task.Delay

这些是我的 10 条启动规则,但还有许多关于使用 .NET 进行异步开发的最佳实践。随着我们继续阅读剩余的章节,我们将发现更多。

让我们总结并回顾一下本章我们学到的关于异步编程的知识。

摘要

在本章中,我们介绍了关于使用 C# 和 .NET 进行异步开发的大量信息。我们首先介绍了一些处理应用程序中 I/O 密集型和 CPU 密集型操作的方法。

接下来,我们创建了一些使用 TaskTask<TResult> 类的实用示例,并发现了如何与多个 Task 对象一起工作。您得到了一些关于现代异步代码和旧式同步方法之间互操作的实际建议。最后,我们介绍了在处理异步代码和 Task 对象时需要记住的一些最重要的规则。

在下一章中,第六章,您将学习如何使用 Task Parallel Library (TPL) 在 .NET 中进行并行编程的细节,并了解如何避免并行编程的常见陷阱。

问题

  1. Task 的哪个属性使得阻塞调用能够从底层方法返回数据?

  2. 应该使用 Task 类的哪个 async 方法来等待多个任务?

  3. Task.WhenAll() 的阻塞等效方法是什么?

  4. 一个 async 方法应该总是返回什么类型?

  5. async 方法更适合 I/O 密集型还是 CPU 密集型操作?

  6. 对或错Async 方法不应该以 Async 作为后缀。

  7. 哪种方法可以用来在 async 调用中包装同步方法?

第六章:第六章:并行编程概念

Task 对象。本章将进一步探讨 TPL 中的 System.Threading.Tasks.Parallel 成员以及一些额外的任务概念,用于处理相关任务。

并行编程、并发编程和异步编程之间的界限并不总是清晰的,随着我们继续阅读,您将发现这三个概念相交的地方。

在本章中,您将学习以下内容:

  • 开始使用 TPL

  • .NET 中的并行循环

  • 并行任务之间的关系

  • 并行编程的常见陷阱

在本章结束时,您将了解如何在自己的项目中使用并行编程,为什么您会选择并行循环而不是标准循环,以及何时使用 asyncawait 而不是并行循环。

技术要求

要跟随本章中的示例,建议 Windows 开发者使用以下软件:

  • Visual Studio 2022 版本 17.0 或更高版本

  • .NET 6

虽然这些是推荐使用的,但如果您已安装 .NET 6,您可以使用您喜欢的编辑器。例如,macOS 10.13 或更高版本的 Visual Studio 2022 for Mac、JetBrains Rider 或 Visual Studio Code 都可以正常工作。

本章的所有代码示例都可以在 GitHub 上找到,链接为 github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter06

让我们从讨论 TPL 以及它在 .NET 并行编程世界中的位置开始。

开始使用 TPL

.NET Framework 4.0 中的 System.ThreadingSystem.Threading.Tasks 命名空间。TPL 提供了使并行性和并发性对 .NET 开发者来说更简单的功能。您不需要在代码中管理 ThreadPool 任务。TPL 处理线程管理,并自动根据处理器能力和可用性调整活动线程的数量。

开发者应在需要将并行性或并发性引入代码以提高性能时使用 TPL。然而,TPL 并不是每个场景的正确选择。您如何知道何时选择 TPL 以及每个场景的最佳 TPL 构造?

让我们探索一些常见的场景。

I/O 密集型操作

当处理 I/O 密集型操作,如文件操作、数据库调用或 Web 服务调用时,使用 Task 对象和 C# 的 async/await 操作进行异步编程是您的最佳选择。如果您的服务需要您循环遍历一个大型集合,并为循环中的每个对象进行服务调用,您应考虑重构服务以返回单个服务调用中的数据。这将最小化与每个网络操作相关的开销。它还将允许您的客户端代码对服务进行单个 async 调用,同时保持主线程可以执行其他工作。

I/O 密集型操作通常不适合并行操作,但每条规则都有例外。如果你需要遍历文件系统中的一组文件夹和子文件夹,并行循环可能非常适合这个任务。然而,重要的是,你的循环的任何迭代都不要尝试访问相同的文件,以避免锁定问题。

现在,让我们探索一些 CPU 密集型场景。

CPU 密集型操作

CPU 密集型操作不依赖于外部资源,如文件系统、网络或互联网。它们涉及在应用程序进程内处理内存中的数据。有许多类型的数据转换属于这一类别。你的应用程序可能正在序列化或反序列化数据,在文件类型之间转换,或处理图像或其他二进制数据。

这类操作对于数据并行性和特定的并行循环来说是有意义的,但有几点例外。首先,如果每个迭代不是非常 CPU 密集型,使用 TPL 带来的开销不值得。如果过程非常密集,但迭代对象很少,考虑使用Parallel.Invoke而不是Parallel.ForParallel.ForEach中的一个并行循环。对于 CPU 密集度较低的操作使用并行结构可能会因为使用 TPL 的开销而减慢你的代码。在第第十章中,我们将学习如何使用 Visual Studio 来确定并行和并发代码的性能。

现在你已经了解在应用程序中何时使用并行性,让我们探索一些使用Parallel.ForParallel.ForEach的实际示例。

.NET 中的并行循环

在本节中,我们将探讨一些在.NET 项目中利用数据并行性的示例。C#的forforeach循环的并行版本,Parallel.ForParallel.ForEach,是System.Threading.Tasks.Parallel命名空间的一部分。使用这些并行循环的方式与使用它们的 C#标准对应物相似。

一个关键的区别是,并行循环的主体被声明为continue以停止循环的当前迭代,而不中断整个循环,你会使用return语句。使用break跳出并行循环的等效操作是使用Stop()Break()语句。

让我们看看在.NET WinForms 应用程序中使用Parallel.For循环的一个示例。

基本并行Parallel.For循环

我们将创建一个新的FileProcessor类,它将遍历文件以聚合文件大小并找到最近写入的文件:

  1. 首先在 Visual Studio 中创建一个新的.NET 6 WinForms 项目

  2. 添加一个名为FileData的新类。这个类将包含FileProcessor中的数据:

    public class FileData
    {
        public List<FileInfo> FileInfoList { get; set; } = 
            new();
        public long TotalSize { get; set; } = 0;
        public string LastWrittenFileName 
            { get; set; } = "";
        public DateTime LastFileWriteTime { get; set; }
    }
    

我们将返回一个包含所选文件夹中文件的FileInfo对象列表,所有文件的总大小,最后写入的文件名以及文件被写入的日期和时间。

  1. 接下来,创建一个名为 FileProcessor 的新类

  2. FileProcessor 添加一个名为 GetInfoForFiles 的静态方法:

    public static FileData GetInfoForFiles(string[] files)
    {
        var results = new FileData();
        var fileInfos = new List<FileInfo>();
        long totalFileSize = 0;
        DateTime lastWriteTime = DateTime.MinValue;
        string lastFileWritten = "";
        object dateLock = new();
        Parallel.For(0, files.Length,
                index => {
    FileInfo fi = new(files[index]);
                    long size = fi.Length;
    DateTime lastWrite = 
                        fi.LastWriteTimeUtc;
                    lock (dateLock)
                    {
                        if (lastWriteTime < lastWrite)
                        {
                            lastWriteTime = lastWrite;
                            lastFileWritten = fi.Name;
    }
                    }
    Interlocked.Add(ref totalFileSize, 
                        size);
                    fileInfos.Add(fi);
                });
        results.FileInfoList = fileInfos;
        results.TotalSize = totalFileSize;
        results.LastFileWriteTime = lastWriteTime;
        results.LastWrittenFileName = lastFileWritten;
        return results;
    }
    

在前面的代码中,Parallel.For 循环及其体中的 lambda 表达式 被突出显示。关于循环内的代码有一些需要注意的事项:

  1. 首先,index 作为参数提供给 lambda 表达式,以便表达式体可以使用它来访问 files 数组的当前成员。

  2. totalFileSize 在调用 Interlocked.Add 时更新。这是在并行代码中安全地添加值的最高效方式。

  3. 没有一种简单的方法可以利用 Interlocked 来更新 lastWriteTime DateTime 值。因此,我们使用一个带有 dateLock 对象的 lock 块来安全地读取和设置方法级别的 lastWriteTime 变量。

  4. 接下来,打开 Form1.cs 的设计器,并将以下控件添加到窗体中:

    private GroupBox FileProcessorGroup;
    private Button FolderProcessButton;
    private Button FolderBrowseButton;
    private TextBox FolderToProcessTextBox;
    private Label label1;
    private TextBox FolderResultsTextBox;
    private Label label2;
    private FolderBrowserDialog folderToProcessDialog;
    

在本章 GitHub 仓库中查看 Form1.designer.cs 文件 (github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter06/WinFormsParallelLoopApp) 以审查和设置这些控件的所有属性。

当你完成时,窗体的设计器应该看起来像这样:

图 6.1 – Visual Studio 中完成的 Form1.cs 设计视图

图 6.1 – Visual Studio 中完成的 Form1.cs 设计视图

  1. 接下来,双击 Form1 设计器,代码背后将生成一个 FolderBrowserButton_Click 事件处理程序。将以下代码添加到使用 folderToProcessDialog 对象向用户显示文件夹选择对话框的代码中:

    private void FolderBrowseButton_Click(object sender, 
        EventArgs e)
    {
        var result = folderToProcessDialog.ShowDialog();
        if (result == DialogResult.OK)
        {
            FolderToProcessTextBox.Text = 
                folderToProcessDialog.SelectedPath;
        }
    }
    

选定的文件夹路径将设置在 FolderToProcessTextBox 中,用于下一步。用户可以手动输入或粘贴文件夹路径到字段中。如果您想防止手动输入,可以将 FolderToProcessTextBox.ReadOnly 设置为 true

  1. 接下来,双击代码背后的 FolderProcessButton_Click 事件处理程序将生成。将以下代码添加到调用 FileProcessor 并在 FolderResultsTextBox 中显示结果的代码中:

    private void FolderProcessButton_Click(object sender, 
        EventArgs e)
    {
        if (!string.IsNullOrWhiteSpace
            (FolderToProcessTextBox.Text) &&
            Directory.Exists(FolderToProcessTextBox.Text))
        {
            string[] filesToProcess = Directory.GetFiles
                (FolderToProcessTextBox.Text);
            FileData? results = FileProcessor
                .GetInfoForFiles(filesToProcess);
            if (results == null)
            {
                FolderResultsTextBox.Text = "";
                return;
            }
            StringBuilder resultText = new();
            resultText.Append($"Total file count: 
                {results.FileInfoList.Count}; ");
            resultText.AppendLine($"Total file size: 
                {results.TotalSize} bytes");
            resultText.Append($"Last written file: 
                {results.LastWrittenFileName} ");
            resultText.Append($"at 
                {results.LastFileWriteTime}");
            FolderResultsTextBox.Text = 
                resultText.ToString();
        }
    }
    

这里的代码足够简单。静态 GetInfoForFiles 方法返回一个包含文件信息的 FileData 实例。我们使用 StringBuilder 创建要设置在 FolderResultsTextBox 中的输出。

  1. 我们已经准备好运行应用程序。在 Visual Studio 中开始调试项目并尝试一下。你的结果应该看起来像这样:

图 6.2 – 运行并行循环应用程序

图 6.2 – 运行并行循环应用程序

就这些了。如果你想尝试更高级的功能,你可以尝试修改项目以处理所选文件夹的所有子文件夹中的文件。让我们对项目进行不同的修改,以便减少对 Interlocked.Add 的锁定调用。

带有线程局部变量的并行循环

Parallel.For 构造函数有一个重载版本,将允许我们的代码为每个参与循环的线程保持总文件大小的运行小计。这意味着我们只有在从每个线程聚合小计到 totalFileSize 时才需要使用 Interlocked.Add。这是通过提供 Interlocked.Add 只会调用 5 次而不是 200 次来实现的,而不会丢失任何线程安全性:

public static FileData GetInfoForFilesThreadLocal(string[] 
    files)
{
    var results = new FileData();
    var fileInfos = new List<FileInfo>();
    long totalFileSize = 0;
    DateTime lastWriteTime = DateTime.MinValue;
    string lastFileWritten = "";
    object dateLock = new();
    Parallel.For<long>(0, files.Length, () => 0,
        (index, loop, subtotal) => {
            FileInfo fi = new(files[index]);
            long size = fi.Length;
            DateTime lastWrite = fi.LastWriteTimeUtc;
            lock (dateLock)
            {
                if (lastWriteTime < lastWrite)
                {
                    lastWriteTime = lastWrite;
                    lastFileWritten = fi.Name;
                }
            }
            subtotal += size;
            fileInfos.Add(fi);
            return subtotal;
            },
(runningTotal) => Interlocked.Add(ref 
            totalFileSize, runningTotal)
    );
    results.FileInfoList = fileInfos;
    results.TotalSize = totalFileSize;
    results.LastFileWriteTime = lastWriteTime;
    results.LastWrittenFileName = lastFileWritten;
    return results;
}

总结前面的更改,你会注意到我们正在使用 Parallel.For<long> 泛型方法来指示 subtotal 线程局部变量应该是 long 而不是默认的 int 类型。大小在第一个 lambda 表达式中添加到 subtotal,而不需要任何锁定表达式。我们现在必须返回 subtotal,以便其他迭代可以访问数据。最后,我们向 For 添加了一个最终参数,该参数包含一个 lambda 表达式,该表达式使用 Interlocked.Add 将每个线程的 runningTotal 添加到 totalFileSize

如果你将 FolderProcessButton_Click 更改为调用 GetInfoForFilesThreadLocal,输出将相同,但性能将得到提升,可能不是很明显。性能提升取决于你选择的文件夹中的文件数量。

现在我们已经尝试了几个 Parallel.For 循环的练习,让我们创建一个使用 Parallel.ForEach 方法的示例。

简单的 Parallel.ForEach 循环

Parallel.ForEach 方法,如 Parallel.For,在用法上与它们的非并行对应方法类似。当你有一个要处理的 IEnumerable 集合时,你会使用 Parallel.ForEach 而不是 Parallel.For。在这个示例中,我们将创建一个新的方法,该方法接受一个包含图像文件的 List<string> 列表以迭代并转换为 Bitmap 对象:

  1. 首先,在 FileProcessor 类中创建一个新的私有静态方法,命名为 ConvertJpgToBitmap。此方法将打开每个 JPG 文件并返回一个包含图像数据的新 Bitmap

    private static Bitmap ConvertJpgToBitmap(string 
        fileName)
    {
        Bitmap bmp;
        using (Stream bmpStream = File.Open(fileName, 
            FileMode.Open))
        {
            Image image = Image.FromStream(bmpStream);
            bmp = new Bitmap(image);
        }
        return bmp;
    }
    
  2. 接下来,在同一个类中创建一个公共静态方法,命名为 ConvertFilesToBitmaps

    public static List<Bitmap> ConvertFilesToBitmaps
        (List<string> files)
    {
        var result = new List<Bitmap>();
        Parallel.ForEach(files, file =>
        {
            FileInfo fi = new(file);
            string ext = fi.Extension.ToLower();
            if (ext == ".jpg" || ext == ".jpeg")
            {
                result.Add(ConvertJpgToBitmap(file));
            }
        });
        return result;
    }
    

此方法接受包含所选文件夹中文件的 List<string>。在 Parallel.ForEach 循环内部,它检查文件是否有 .jpg.jpeg 文件扩展名。如果有,它将转换为位图并添加到 result 集合中。

  1. Form1.cs 中添加一个新按钮。将 Name 属性设置为 ProcessJpgsButton,将 Text 属性设置为 Process JPGs

  2. 双击新按钮以在代码隐藏文件中创建事件处理器。将以下代码添加到新的事件处理器中:

    private void ProcessJpgsButton_Click(object sender, 
        EventArgs e)
    {
        if (!string.IsNullOrWhiteSpace
            (FolderToProcessTextBox.Text) &&
            Directory.Exists(FolderToProcessTextBox.Text))
        {
            List<string> filesToProcess = Directory
                .GetFiles(FolderToProcessTextBox.Text)
                    .ToList();
            List<Bitmap> results = FileProcessor
                .ConvertFilesToBitmaps(filesToProcess);
            StringBuilder resultText = new();
            foreach (var bmp in results)
            {
                resultText.AppendLine($"Bitmap height: 
                    {bmp.Height}");
            }
            FolderResultsTextBox.Text = 
                resultText.ToString();
        }
    }
    
  3. 现在,运行项目,选择包含一些 JPG 文件的文件夹,并点击新的 处理 JPGs 按钮。你应该会在输出中看到每个转换后的 JPG 的高度。

这就是简单的 Parallel.ForEach 循环所需的所有内容。如果你需要取消一个长时间运行的并行循环,你能做什么?让我们更新我们的示例,使用 Parallel.ForEachAsync 来实现这一点。

取消 Parallel.ForEachAsync 循环

Parallel.ForEachAsync 是 .NET 6 中的新特性。它是 Parallel.ForEach 的可等待版本,其主体是一个 async lambda 表达式。让我们更新之前的示例,使用这个新的并行方法,并添加取消操作的能力:

  1. 我们将首先创建一个 async 版本的 ConvertFilesToBitmaps,命名为 ConvertFilesToBitmapsAsync。以下是其差异:

    public static async Task<List<Bitmap>> 
        ConvertFilesToBitmapsAsync(List<string> files, 
            CancellationTokenSource cts)
    {
        ParallelOptions po = new()
        {
            CancellationToken = cts.Token,
    MaxDegreeOfParallelism = 
                Environment.ProcessorCount == 1 ? 1
                          : Environment.ProcessorCount - 1
        };
        var result = new List<Bitmap>();
        try
        {
    await Parallel.ForEachAsync(files, po, async 
    (file, _cts) => 
            {
                FileInfo fi = new(file);
                string ext = fi.Extension.ToLower();
                if (ext == ".jpg" || ext == "jpeg")
                {
                    result.Add(ConvertJpgToBitmap(file));
                    await Task.Delay(2000, _cts);
                }
            });
        }
        catch (OperationCanceledException e)
        {
            MessageBox.Show(e.Message);
        }
        finally
        {
            cts.Dispose();
        }
        return result;
    }
    

新方法是 async,返回 Task<List<Bitmap>>,接受 CancellationTokenSource,并在创建 ParallelOptions 时使用它传递给 Parallel.ForEachAsync 方法。Parallel.ForEachAsync 被等待,其 lambda 表达式被声明为 async,这样我们就可以等待新添加的 Task.Delay,给我们足够的时间在循环完成前点击 取消 按钮。

Parallel.ForEachAsync 包裹在处理 OperationCanceledExceptiontry/catch 块中,使得方法能够捕获取消操作。在取消操作被处理后,我们将向用户显示一条消息。

代码还设置了 ProcessorCount 选项。如果只有一个 CPU 核心可用,我们将设置该值为 1;否则,我们希望使用的核心数不超过可用核心数减一。.NET 运行时通常管理这个值非常好,所以你应该只有在发现它提高了应用程序的性能时才更改此选项。

  1. Form1.cs 文件中,添加一个新的 CancellationTokenSource 私有变量:

    private CancellationTokenSource _cts;
    
  2. 更新事件处理程序为 async,将 _cts 设置为 CancellationTokenSource 的新实例,并将其传递给 ConvertFilesToBitmapsAsync。同样,在该调用中添加 await

所需的所有更改都在以下代码片段中突出显示:

private async void ProcessJpgsButton_Click(object 
    sender, EventArgs e)
{
    if (!string.IsNullOrWhiteSpace
        (FolderToProcessTextBox.Text) &&
        Directory.Exists(FolderToProcessTextBox.Text))
    {
        _cts = new CancellationTokenSource();
        List<string> filesToProcess = Directory
           .GetFiles(FolderToProcessTextBox.Text)
               .ToList();
        List<Bitmap> results = await FileProcessor
            .ConvertFilesToBitmapsAsync
(filesToProcess, _cts);
        StringBuilder resultText = new();
        foreach (var bmp in results)
        {
            resultText.AppendLine($"Bitmap height: 
                {bmp.Height}");
        }
        FolderResultsTextBox.Text = resultText
            .ToString();
    }
}
  1. 在表单中添加一个名为 CancelButton 的新按钮,其标题为 取消

  2. 双击 取消 按钮并添加以下事件处理程序代码:

    private void CancelButton_Click(object sender, 
        EventArgs e)
    {
        if (_cts != null)
        {
            _cts.Cancel();
        }
    }
    
  3. 运行应用程序,浏览并选择包含 JPG 文件的文件夹,点击 处理 JPGs 按钮,然后立即点击 取消 按钮。你应该会收到一条消息,表明处理已被取消。不会进一步处理记录。

我们将在 第十一章 中了解更多关于取消异步和并行工作的内容。现在,让我们讨论 Parallel.Invoke 构造以及 TPL 中任务之间的关系。

并行任务之间的关系

在上一章中,第五章,我们学习了如何使用 asyncawait 来并行执行工作,并通过使用 ContinueWith 来管理任务的流程。在本节中,我们将检查一些可以用来管理并行运行的任务之间关系的 TPL 功能。

让我们先深入探讨 TPL 提供的 Parallel.Invoke 方法。

Parallel.Invoke 的底层

第二章,我们学习了如何使用 Parallel.Invoke 方法并行执行多个任务。现在我们将重新审视 Parallel.Invoke,并发现其底层发生了什么。考虑使用它来调用两个方法:

Parallel.Invoke(DoFirstAction, DoSectionAction);

这就是幕后发生的事情:

List<Task> taskList = new();
taskList.Add(Task.Run(DoFirstAction));
taskList.Add(Task.Run(DoSectionAction));
Task.WaitAll(taskList.ToArray());

将创建两个任务并将它们排队在线程池中。假设系统有可用资源,这两个任务应该被选中并并行运行。调用方法将阻塞当前线程,等待并行任务完成。动作将阻塞调用线程,直到最长运行的任务完成。

如果这对您的应用程序来说是可接受的,使用 Parallel.Invoke 可以使代码更简洁、更容易理解。然而,如果您不想阻塞调用线程,有几个选项。首先,让我们对第二个示例进行修改,使用 await

List<Task> taskList = new();
taskList.Add(Task.Run(DoFirstAction));
taskList.Add(Task.Run(DoSectionAction));
await Task.WhenAll(taskList.ToArray());

通过等待 Task.WhenAll 而不是使用 Task.WaitAll,我们允许当前线程在等待两个子任务并行完成处理的同时执行其他工作。要使用 Parallel.Invoke 实现相同的结果,我们可以将其包装在 Task 中:

await Task.Run(() => Parallel.Invoke(DoFirstTask, 
    DoSecondTask));

同样的技术可以用 Parallel.For 来使用,以避免在等待循环完成时阻塞调用线程。对于 Parallel.ForEach 来说,这并不是必需的。我们不需要将 Parallel.ForEach 包装在 Task 中,而是可以用 Parallel.ForEachAsync 来替换它。我们在本章前面了解到,.NET 6 添加了 Parallel.ForEachAsync,它返回 Task 并可以等待。

接下来,让我们讨论如何管理父任务与其子任务之间的关系。

理解并行子任务

当执行嵌套任务时,默认情况下,父任务不会等待其子任务,除非我们使用 Wait() 方法或 await 语句。然而,在使用 Task.Factory.StartNew() 时,我们可以通过一些选项来控制这种默认行为。为了说明可用的选项,我们将创建一个新的示例项目:

  1. 首先,创建一个新的 C# 控制台应用程序,命名为 ParallelTaskRelationshipsSample

  2. 向项目中添加一个名为 ParallelWork 的类。这是我们创建父方法和它们的子方法的地方。

  3. 将以下三个方法添加到 ParallelWork 类中。这些将是我们的子方法。每个方法在启动和完成时都会写入一些控制台输出。使用 Thread.SpinWait 注入延迟。如果你不熟悉 Thread.SpinWait,它将当前线程放入一个循环中,循环次数由指定的迭代数决定,注入等待而不将线程从调度程序中移除:

    public void DoFirstItem()
    {
        Console.WriteLine("Starting DoFirstItem");
        Thread.SpinWait(1000000);
        Console.WriteLine("Finishing DoFirstItem");
    }
    public void DoSecondItem()
    {
        Console.WriteLine("Starting DoSecondItem");
        Thread.SpinWait(1000000);
        Console.WriteLine("Finishing DoSecondItem");
    }
    public void DoThirdItem()
    {
        Console.WriteLine("Starting DoThirdItem");
        Thread.SpinWait(1000000);
        Console.WriteLine("Finishing DoThirdItem");
    }
    
  4. 接下来,添加一个名为 DoAllWork 的方法。此方法将创建一个父任务,该任务调用前面的三个方法并带有子任务。没有添加代码来等待子任务:

    public void DoAllWork()
    {
        Console.WriteLine("Starting DoAllWork");
        Task parentTask = Task.Factory.StartNew(() =>
        {
            var child1 = Task.Factory.StartNew
                 (DoFirstItem);
            var child2 = Task.Factory.StartNew
                  (DoSecondItem);
            var child3 = Task.Factory.StartNew
                  (DoThirdItem);
        });
        parentTask.Wait();
        Console.WriteLine("Finishing DoAllWork");
    }
    
  5. 现在,在 Program.cs 中添加一些代码来运行 DoAllWork

    using ParallelTaskRelationshipsSample;
    var parallelWork = new ParallelWork();
    parallelWork.DoAllWork();
    Console.ReadKey();
    
  6. 运行程序并检查输出。正如你所预期的,父任务在它的子任务之前完成:

图 6.3 – 控制台应用程序运行 DoAllWork

图 6.3 – 控制台应用程序运行 DoAllWork

  1. 接下来,让我们创建一个名为 DoAllWorkAttached 的方法。此方法将运行相同的三个子任务,但子任务将包含 TaskCreationOptions.AttachedToParent 选项:

    public void DoAllWorkAttached()
    {
        Console.WriteLine("Starting DoAllWorkAttached");
        Task parentTask = Task.Factory.StartNew(() =>
        {
            var child1 = Task.Factory.StartNew
                (DoFirstItem, TaskCreationOptions
                    .AttachedToParent);
            var child2 = Task.Factory.StartNew
                (DoSecondItem, TaskCreationOptions
                    .AttachedToParent);
            var child3 = Task.Factory.StartNew
                (DoThirdItem, TaskCreationOptions
                    .AttachedToParent);
        });
        parentTask.Wait();
        Console.WriteLine("Finishing DoAllWorkAttached");
    }
    
  2. Program.cs 更新为调用 DoAllWorkAttached 而不是 DoAllWork 并重新运行应用程序:

图 6.4 – 运行我们的应用程序并调用 DoAllWorkAttached

图 6.4 – 运行我们的应用程序并调用 DoAllWorkAttached

你可以看到,即使我们没有明确等待子任务,父任务也不会在它的子任务完成之前完成。

现在,假设你还有一个父任务,它不应该等待其子任务,无论是否使用 TaskCreationOptions.AttachedToParent 选项启动。让我们创建一个新的方法来处理这种情况:

  1. 创建一个名为 DoAllWorkDenyAttach 的方法,以下是其代码:

    public void DoAllWorkDenyAttach()
    {
        Console.WriteLine("Starting DoAllWorkDenyAttach");
        Task parentTask = Task.Factory.StartNew(() =>
        {
            var child1 = Task.Factory.StartNew
                (DoFirstItem, TaskCreationOptions
                    .AttachedToParent);
            var child2 = Task.Factory.StartNew
                 (DoSecondItem, TaskCreationOptions
                     .AttachedToParent);
            var child3 = Task.Factory.StartNew
                (DoThirdItem, TaskCreationOptions
                    .AttachedToParent);
        }, TaskCreationOptions.DenyChildAttach);
        parentTask.Wait();
        Console.WriteLine("Finishing DoAllWork
            DenyAttach");
    }
    

子任务仍然使用 AttachedToParent 选项创建,但父任务现在设置了 DenyChildAttach 选项。这将覆盖子任务附加到父任务的要求。

  1. Program.cs 更新为调用 DoAllWorkDenyAttach 并再次运行应用程序:

图 6.5 – 控制台应用程序调用 DoAllWorkDenyAttach

图 6.5 – 控制台应用程序调用 DoAllWorkDenyAttach

你可以看到,DenyChildAttach 确实覆盖了每个子任务上设置的 AttachToParent 选项。父任务在没有等待子任务的情况下完成,就像调用 DoAllWork 时那样。

关于这个示例的最后一点。你可能已经注意到,我们使用了 Task.Factory.StartNew 而不是 Task.Run,即使我们不需要设置 TaskCreationOption。这是因为 Task.Run 将禁止任何子任务附加到父任务。如果你在 DoAllWorkAttached 方法中使用 Task.Run 作为父任务,父任务将首先完成,就像在其他方法中那样。

让我们通过介绍一些在使用 .NET 进行并行编程时可能遇到的潜在陷阱来结束本章。

并行编程的常见陷阱

当使用 TPL 时,有一些做法要避免,以确保在您的应用程序中获得最佳结果。在某些情况下,错误使用并行性可能会导致性能下降。在其他情况下,它可能导致错误或数据损坏。

并行性不能保证

当使用并行循环之一或 Parallel.Invoke 时,迭代可以并行运行,但并不保证一定会这样做。这些并行委托中的代码应该能够在任何场景下成功运行。

并行循环并不总是更快

我们在本章 earlier 讨论过这一点,但重要的是要记住,forforeach 循环的并行版本并不总是更快。如果每个循环迭代运行得很快,添加并行的开销可能会减慢您的应用程序。

在向应用程序引入任何线程时,这一点非常重要。在引入并发或并行之前和之后始终测试您的代码,以确保性能提升值得线程开销。

谨防阻塞 UI 线程

记住,Parallel.ForParallel.ForEach阻塞调用。如果您在 UI 线程上使用它们,它们将阻塞 UI 直到调用结束。这个阻塞时间至少是运行时间最长的循环迭代的时间。

正如我们在上一节中讨论的,您可以通过调用 Task.Run 来包装并行代码,将执行从 UI 线程移动到线程池上的后台线程。

线程安全

不要在并行循环中调用非线程安全的 .NET 方法。每个 .NET 类型的线程安全性在 Microsoft Docs 上有记录。使用 .NET API 浏览器快速查找有关特定 .NET API 的信息:docs.microsoft.com/dotnet/api/

限制在并行循环中使用静态 .NET 方法,即使它们被标记为线程安全。它们不会引起错误或数据一致性问题,但可能会对循环性能产生负面影响。即使是调用 Console.WriteLine 也应仅用于测试或演示目的。不要在生产代码中使用这些方法。

UI 控件

在 Windows 客户端应用程序中,不要尝试在并行循环中访问 UI 控件。WinForms 和 WPF 控件只能从创建它们的线程访问。您可以使用 Dispatcher.Invoke 在其他线程上调用操作,但这将产生性能影响。最好在并行循环完成后更新 UI。

ThreadLocal 数据

记住要在您的并行循环中利用 ThreadLocal 变量。我们在本章 earlier 的 带有线程局部变量的并行循环 部分展示了如何做到这一点。

这就涵盖了您对 C# 和 .NET 并行编程的介绍。让我们通过回顾本章学到的所有内容来结束。

摘要

在本章中,我们学习了如何在.NET 应用程序中利用并行编程概念。我们亲自动手使用了Parallel.ForParallel.ForEachParallel.ForEachAsync循环。在这些部分,我们学习了如何在保持线程安全的同时安全地聚合数据。接下来,我们学习了如何管理父任务与其并行子任务之间的关系。这将有助于确保你的应用程序保持预期的操作顺序。

最后,我们讨论了在实现应用程序中的并行性时需要避免的一些重要陷阱。开发者应该注意避免在自己的应用程序中遇到这些陷阱。

要了解更多关于.NET 中数据并行的信息,Microsoft Docs 上的数据并行文档是一个很好的起点:docs.microsoft.com/dotnet/standard/parallel-programming/data-parallelism-task-parallel-library.

在下一章中,我们将继续探索 TPL,通过学习如何利用 TPL 数据流库中包含的各种构建块。

问题

  1. 哪个并行循环在给定次数的迭代中并行执行一个委托?

  2. 哪个并行循环是Parallel.ForEach的可等待版本?

  3. 哪个并行方法可以并行执行两个或更多提供的行为?

  4. 哪个Task.Factory.StartNew选项可以将子任务的完成附加到其父任务上?

  5. 哪个Task.Factory.StartNew选项可以提供给父任务以防止任何子任务附加?

  6. 为什么在使用TaskCreationOptions建立父子关系时永远不应该使用Task.Run

  7. 并行循环是否总是比它们的传统对应物更快?

第七章:第七章:任务并行库(TPL)和数据流

任务并行库TPL)数据流库包含构建块,用于在.NET 中编排异步工作流。本章将介绍 TPL 数据流库,描述库中数据流块的类型,并通过实际示例说明使用数据流块的一些常见模式。

当处理大量数据并在多个阶段进行处理,或者您的应用程序以连续流接收数据时,数据流库非常有用。数据流块提供了一种实现生产者/消费者设计模式的绝佳方式。

为了理解这一点,我们将创建一个实现此模式的示例项目,并检查数据流库的其他实际应用。

注意

重要的是要知道 TPL 数据流库不是作为.NET 运行时或 SDK 的一部分进行分发的。它可以从 Microsoft 的 NuGet 包中获取。我们将使用 Visual Studio 中的NuGet 包资源管理器NPE)将其添加到我们的示例项目中。

在本章中,我们将涵盖以下主题:

  • 介绍 TPL 数据流库

  • 实现生产者/消费者模式

  • 使用多个块创建数据管道

  • 处理来自多个数据源的数据

到本章结束时,您将了解每种类型的数据流块的目的,并能够在适当的项目中添加数据流库。

您还将了解在哪些情况下,数据流块不比简单的并行编程替代方案(如Parallel.ForEach)提供优势。

技术要求

为了跟随本章中的示例,以下软件是推荐给 Windows 开发者的:

  • Visual Studio 2022 版本 17.0 或更高版本

  • .NET 6

  • 要完成 WPF 示例,您需要安装 Visual Studio 的.NET 桌面开发工作负载

虽然这些是推荐的,但如果您已安装.NET 6,您可以使用您喜欢的编辑器。例如,macOS 10.13 或更高版本的 Visual Studio 2022 for Mac、JetBrains Rider 或 Visual Studio Code 都将同样有效。

本章的代码示例可以在 GitHub 上找到:github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter07

让我们从讨论 TPL 数据流库及其为什么可以成为在.NET 中实现并行编程的绝佳方式开始。

介绍 TPL 数据流库

TPL 数据流库与 TPL 本身一样可用。它在 2010 年随System.Threading.Tasks.Dataflow命名空间发布。数据流库旨在建立在 TPL 提供的并行编程基础知识之上,扩展以解决数据流场景(因此库的名称)。数据流库由称为的基础类组成。每个数据流块负责整体流程中的特定操作或步骤。

数据流库由三种基本类型的块组成:

  • ISourceBlock<TOutput>接口。源块可以从你定义的工作流程中读取其数据。

  • ITargetBlock<TInput>接口,是一个数据接收器。

  • IPropagatorBlock<TInput, TOutput>接口。应用程序可以从中读取数据并将数据写入它们。

当你将多个数据流块连接起来创建一个工作流程时,所得到的系统被称为ISourceBlock<TOutput>.LinkTo方法。这就是传播器块可以在管道中间发挥作用的地方。它们可以作为工作流程中链接的源和目标。如果一个源块的消息可以被多个目标处理,你可以添加过滤功能来检查源提供的对象属性,以确定哪个目标或传播器块应该接收该对象。

在数据流块之间传递的对象通常被称为消息。你可以将数据流管道视为网络或消息系统。通过网络流动的数据单元是消息。每个块都负责以某种方式读取、写入或转换每个消息。

要向目标块发送消息,你可以使用Post方法同步发送或使用SendAsync方法异步发送。在源块中,可以使用ReceiveTryReceiveReceiveAsync方法接收消息。ReceiveTryReceive方法都是同步的。Choose方法将监视多个源块以获取数据,并从第一个提供数据的源返回一个消息。

要将源块的消息提供给目标块,源可以调用目标的OfferData方法。OfferData方法返回一个DataflowMessageStatus枚举,它有几个可能的值:

  • Accepted:消息已被接受,并将由目标处理。

  • Declined:消息被目标拒绝。源块仍然拥有该消息,并且无法处理其下一个消息,直到当前消息被另一个目标接受。

  • DecliningPermanently:消息已被拒绝,目标不再可用于处理。所有后续的消息都将被当前目标拒绝。源块将解除与返回此状态的目标的链接。

  • Postponed:接受消息已被推迟。它可能在稍后由目标接受。在这种情况下,源可以等待或尝试将消息传递给另一个替代目标块。

  • NotAvailable:当目标尝试接受消息时,消息不再可用。这可能在目标在消息被推迟后尝试接受消息时发生,但源块已经将消息传递给另一个目标块。

数据流块支持Complete方法和Completion属性的概念。Complete方法用于在块上请求完成,而Completion属性返回一个Task,称为块的IDataflowBlock接口,该接口由ISourceBlockITargetBlock共同继承。

完成任务可以用来确定一个块是否遇到了错误或已被取消。让我们看看如何:

  1. 处理数据流块遇到错误的最简单方法是调用块的Completion属性的Wait,并在try/catch块中处理AggregateException异常类型:

    try
    {
       inputBlock.Completion.Wait();
    }
    catch (AggregateException ae)
    {
       ae.Handle(e =>
       {
          Console.WriteLine($"Error processing input - 
              {e.GetType().Name}: {e.Message}");
       });
    }
    
  2. 如果你想在不需要使用阻塞的Wait调用的情况下做同样的事情,你可以await完成任务并处理Exception类型:

    try
    {
        await inputBlock.Completion;
    }
    catch (Exception e)
    {
        Console.WriteLine($"Error processing input - 
            {e.GetType().Name}: {e.Message}");
    }
    
  3. 另一种选择是在完成任务上使用ContinueWith方法。在延续块内部,你可以检查任务的状态以确定它是否为FaultedCanceled

    try
    {
        inputBlock.ContinueWith(task =>
        {
    Console.WriteLink($"Task completed with a 
                status of {task.Status}");
        });
        await inputBlock.Completion;
    }
    catch (Exception e)
    {
        Console.WriteLine($"Error processing input - 
            {e.GetType().Name}: {e.Message}");
    }
    

当我们在下一节创建一个使用生产者/消费者模式的示例项目时,我们将看到更多关于数据流块使用的综合示例。在我们检查数据流块类型之前,让我们讨论一下为什么微软创建了该库。

为什么使用 TPL 数据流库?

TPL 数据流库是由微软创建的,作为一种编排异步数据处理工作流的方法。数据从数据源流入管道中的第一个数据流块。源可以是数据库、本地或网络文件夹、摄像头或.NET 可以访问的几乎所有其他类型的输入设备。一个或多个块可以是管道的一部分,每个块负责单个操作。以下图表说明了数据流管道的两个抽象:

图 7.1 – 数据流管道示例

图 7.1 – 数据流管道示例

你可以考虑的一个现实世界示例是使用网络摄像头来捕获图像帧。在两步流程中,如示例 1所示,将网络摄像头视为数据输入数据流块 1可以对图像进行一些图像处理以优化图像外观,而数据流块 2将调用Azure 认知服务API 以识别每张图像中的对象。结果将包含一个.NET 类,每个输入图像都包含图像二进制数据和包含每个图像中识别对象的属性。

接下来,让我们了解数据流库中可用的块类型。

数据流块类型

数据流库中有九个预定义的块。这些可以分成三个不同的类别。第一个类别是缓冲区块

缓冲区块

BufferBlock<T>BroadcastBlock<T>WriteOnceBlock<T> 的用途。

缓冲区块

BufferBlock<T> 是一个异步队列机制,实现 BufferBlock 可以有多个数据源和多个目标配置。然而,BufferBlock 中的每条消息只能被发送到一个目标块。消息在成功发送后从队列中移除。

以下片段将客户姓名推送到 BufferBlock,然后读取前五个姓名并在控制台输出:

BufferBlock<string> customerBlock = new();
foreach (var customer in customers)
{
    await customerBlock.SendAsync(customer.Name);
}
for (int i = 0; i < 5; i++)
{
    Console.WriteLine(await customerBlock.ReceiveAsync());
}
// The code could display the following output:
//    Robert Jones
//    Jita Smith
//    Patty Xu
//    Sam Alford
//    Melissa Allen

广播块

BroadcastBlock<T>BufferBlock 的使用方式相似,但其目的是只为消费者提供最近发布的消息。它也可以用来向多个消费者发送相同的值。发送到 BroadcastBlock 的消息在消费者接收后不会被移除。

以下片段每次调用 Receive 方法时都会读取相同的警报消息:

var alertBlock = new BroadcastBlock<string>(null);
alertBlock.Post("Network is unavailable!");
for (int i = 0; i < 5; i++)
{
    Console.WriteLine(alertBlock.Receive());
}

单次写入块

如其名所示,WriteOnceBlock<T> 只能写入一次。在接收到第一条消息后,所有对 PostSendAsync 的调用都将被块忽略。不会抛出异常。数据将被简单地丢弃。

以下示例与我们的 BufferBlock 片段类似。然而,因为我们现在使用的是 WriteOnceBlock,所以只有第一个客户的姓名将被块接受:

WriteOnceBlock<string> customerBlock = new();
foreach (var customer in customers)
{
    await customerBlock.SendAsync(customer.Name);
}
Console.WriteLine(await customerBlock.ReceiveAsync());

执行块

ActionBlock<TInput> 是一个 TransformBlock<TInput, TOuput>,而 TransformManyBlock<TInput, TOutput> 都是传播块。

操作块

ActionBlock 是一个接受 Action<T>Func<TInput, Task> 作为其构造函数参数的块。当操作返回或 Func 的任务完成时,对输入消息的操作被认为是完成的。你可以使用操作同步委托或 Func 进行异步操作。

在此片段中,我们将使用 Console.WriteLine 将客户姓名输出到控制台,这是在 Action 中提供的,并将其发送到块:

var customerBlock = new ActionBlock<string>(name => 
    Console.WriteLine(name));
foreach (var customer in customers)
{
   await customerBlock.SendAsync(customer.Name);
}
customerBlock.Complete();
await customerBlock.Completion;

传播块

TransformBlock<TInput, TOutput>ActionBlock 类似。然而,作为一个传播块,它为接收到的每条消息返回一个输出值。可以提供给 TransformBlock 构造函数的两个可能的委托签名是 Func<TInput, TOutput> 用于同步操作和 Func<TInput, Task<TOutput>> 用于异步操作。

以下示例使用一个 TransformBlock,它将在检索到前五个输出值并在控制台显示之前将客户姓名转换为大写:

var toUpperBlock = new TransformBlock<string, string>(name 
      => name.ToUpper());
foreach (var customer in customers)
{
   toUpperBlock.Push(customer.Name);
}
for (int i = 0; i < 5; i++)
{
   Console.WriteLine(toUpperBlock.Receive());
}

TransformManyBlock

TransformManyBlock<TInput, TOutput>TransformBlock 类似,但该块可以为接收到的每个输入值返回一个或多个值。TransformManyBlock 的可能委托签名分别为 Func<TInput, IEnumerable<TOutput>>Func<TInput, Task<IEnumerable<TOutput>>>,分别用于同步和异步操作。

在此代码片段中,我们将向 TransformManyBlock 传递一个客户名称,该块将返回一个包含客户名字中各个字符的可枚举:

var nameCharactersBlock = new TransformManyBlock<string, 
    char>(name => name.ToCharArray());
nameCharactersBlock.Post(customerName);
for (int i = 0; i < (customerName.Length; i++)
{
   Console.WriteLine(nameCharactersBlock.Receive());
}

分组块

BatchBlock<T> 是一个传播块,而 JoinBlock<T1, T2>BatchedJoinBlock<T1, T2> 都是源块。

BatchBlock

BatchBlock 接受数据批次并生成输出数据数组。在创建 BatchBlock 时,你指定输入批次大小。BatchBlockdataflowBlockOptions 可选构造函数参数中有一个 Greedy 属性,用于指定贪婪模式

  • Greedytrue,这是其默认值时,该块会继续处理每个接收到的输入值,并在达到批大小后输出一个数组。

  • Greedyfalse 时,在创建批大小为数的数组时,可以暂停接收到的消息。

贪婪模式通常性能更好,但如果你需要协调来自多个源的输入,你可能需要使用非贪婪模式

在本例中,BatchBlock 将学生名字按班级分开,最大容量为 12:

var studentBlock = new BatchBlock<string>(12);
// Assume studentList contains 20 students.
foreach (var student in studentList)
{
   studentBlock.Post(student.Name);
}
// Signal that we are done adding items.
studentBlock.Complete();
// Print the size of each class.
Console.WriteLine($"The number of students in class 1 is { 
    studentBlock.Receive().Count()}.");  // 12 students
Console.WriteLine($"The number of students in class 2 is { 
    studentBlock.Receive().Count()}.");  // 8 students

JoinBlock

JoinBlock 有两个签名:JoinBlock<T1, T2>JoinBlock<T1, T2, T3>JoinBlock<T1, T2>Target1Target2 属性,用于接受输入并返回一个 Tuple<T1, T2>,当每个目标对被填充时。JoinBlock<T1, T2, T3>Target1Target2Target3 属性,并在每个目标集完成时返回一个 Tuple<T1, T2, T3>

JoinBlock 也具有贪婪和非贪婪模式,贪婪模式是默认行为。当切换到非贪婪模式时,所有输入都会推迟到已经接收输入的目标,直到填充完整的输出集并发送为输出。

在本例中,我们将创建一个 JoinBlock 来将一个人的名字、姓氏和年龄组合到输出元组中:

var joinBlock = new JoinBlock<string, string, int>();
joinBlock.Target1.Post("Sally");
joinBlock.Target1.Post("Raj");
joinBlock.Target2.Post("Jones");
joinBlock.Target2.Post("Gupta");
joinBlock.Target3.Post(7);
joinBlock.Target3.Post(23);
for (int i = 0; i < 2; i++)
{
   var data = joinBlock.Receive();
   if (data.Item3 < 18)
   {
         Console.WriteLine($"{data.Item1} {data.Item2} is a 
             child.");
   }
   else
   {
         Console.WriteLine($"{data.Item1} {data.Item2} is 
             an adult.");
   }
}

BatchedJoinBlock

BatchedJoinBlock 类似于 JoinBlock,但输出中的元组包含构造函数中指定的批量大小的 IList 项目:Tuple(IList(T1), IList(T2))Tuple(IList(T1), IList(T2), IList(T3))。批处理的概念与 BatchBlock 相同。

作为练习,尝试在 JoinBlock 示例的基础上添加更多人员到列表中,将他们分成每组四人的批次,并输出每个批次中最年长的人的名字。

现在我们已经探讨了所有可用数据流块的一些示例,让我们进入一些真实世界的数据流示例。在下一节中,我们将使用一些数据流块来创建生产者/消费者实现。

实现生产者/消费者模式

TPL 数据流库中的块为实施生产者/消费者模式提供了一个出色的平台。如果你不熟悉这个设计模式,它涉及两个操作和一个工作队列。生产者是第一个操作。它负责用数据或工作单元填充队列。消费者负责从队列中取出项目并以某种方式对其采取行动。系统中可以有一个或多个生产者和一个或多个消费者。你可以根据流程的哪个部分是瓶颈来改变生产者或消费者的数量。

现实世界场景示例

将生产者/消费者模式与一个现实世界场景联系起来,想想为节日聚会准备礼物的情况。你和一位伙伴一起合作准备礼物。你负责取来并摆放待包装的礼物。你是生产者。你的伙伴从你的队列中取走物品,并为每个礼物包装。他们是消费者。如果队列开始拥堵,你可以找到另一个朋友(或消费者)来帮忙包装,从而提高整体吞吐量。另一方面,如果你花费太多时间寻找待包装的每个礼物,你可以添加另一个生产者来帮助他们找到并填充队列。这将使消费者保持忙碌,并提高流程的效率。

在我们的.NET 生产者/消费者示例中,我们将构建一个简单的 WPF 应用程序,从多个 RSS 源获取博客文章,并在单个ListView控件中显示它们。列表中的每一行将包括博客文章的日期、类别以及文章内容的 HTML 摘要。应用程序中的生产者将从 RSS 源获取文章,并为每篇博客文章添加一个SyndicationItem到队列中。我们将从三个博客获取文章,并为每个博客创建一个生产者。

消费者将从队列中取出SyndicationItem,并使用ActionBlock委托为每个SyndicationItem创建一个BlogPost对象。我们将创建三个消费者,以跟上我们三个生产者排队的项目。当过程完成后,BlogPost对象的列表将被设置为ListViewItemSource。让我们开始吧:

  1. 首先,创建一个新的 WPF 项目,使用.NET 6。将项目命名为ProducerConsumerRssFeeds

  2. 打开NuGet 包管理器,在安装选项卡中搜索Syndication,并将System.ServiceModel.Syndication包添加到项目中。这个包将使从任何 RSS 源获取数据变得简单。

  3. 向项目中添加一个名为BlogPost的新类。这将是我们在ListView中显示的每篇博客文章的模型对象。向新类添加以下属性:

    public class BlogPost
    {
        public string PostDate { get; set; } = "";
        public string? Categories { get; set; }
        public string? PostContent { get; set; }
    }
    
  4. 现在,是时候创建一个服务类来获取给定 RSS 源 URL 的博客文章了。向项目中添加一个名为RssFeedService的新类,并向该类添加一个名为GetFeedItems的方法:

    using System.Collections.Generic;
    using System.ServiceModel.Syndication;
    using System.Xml;
    ...
    public static IEnumerable<SyndicationItem> 
        GetFeedItems(string feedUrl)
    {
        using var xmlReader = XmlReader.Create(feedUrl);
        SyndicationFeed rssFeed = SyndicationFeed.Load
            (xmlReader);
        return rssFeed.Items;
    }
    

静态 SyndicationFeed.Load 方法使用 XmlReader 从提供的 feedUrl 获取 XML 并将其转换为 IEnumerable<SyndicationItem> 以从方法返回。

  1. 接下来,创建一个名为 FeedAggregator 的新类。这个类将包含调用每个博客的 GetFeedItems 并将每个博客帖子的数据转换以便在 UI 中显示的生产者/消费者逻辑。我们正在聚合的三个博客如下:

    • .NET 博客

    • Windows 博客

    • 微软 365 博客

使用 FeedAggregator 的第一步是创建一个名为 ProduceFeedItems 的生产者方法和一个名为 QuseueAllFeeds 的父方法,这将启动三个生产者方法的实例:

private async Task QueueAllFeeds(BufferBlock
    <SyndicationItem> itemQueue)
{
    Task feedTask1 = ProduceFeedItems(itemQueue, 
       "https://devblogs.microsoft.com/dotnet/feed/");
    Task feedTask2 = ProduceFeedItems(itemQueue, 
        "https://blogs.windows.com/feed");
    Task feedTask3 = ProduceFeedItems(itemQueue, 
        "https://www.microsoft.com/microsoft-
            365/blog/feed/");
    await Task.WhenAll(feedTask1, feedTask2, 
         feedTask3);
    itemQueue.Complete();
}
private async Task ProduceFeedItems
    (BufferBlock<SyndicationItem> itemQueue, string 
        feedUrl)
{
    IEnumerable<SyndicationItem> items = 
        RssFeedService.GetFeedItems(feedUrl);
    foreach (SyndicationItem item in items)
    {
        await itemQueue.SendAsync(item);
    }
}

我们使用 BufferBlock<SyndicationItem> 作为我们的队列。每个生产者都调用 GetFeedItems 并将返回的每个 SyndicationItem 添加到 BufferBlockQueueAllFeeds 方法使用 Task.WhenAll 等待所有生产者完成向队列添加项目。然后,它通过调用 itemQueue.Complete() 通知 BufferBlock 所有生产者已完成。

  1. 接下来,我们将创建我们的消费者方法。这个方法,命名为 ConsumeFeedItem,将负责将 BufferBlock 提供的 SyndicationItem 转换成一个 BlogPost 对象。每个 BlogPost 都将被添加到 ConcurrentBag<BlogPost> 中。我们在这里使用线程安全的集合,因为将有多个消费者向列表中添加输出:

    private void ConsumeFeedItem(SyndicationItem nextItem, 
        ConcurrentBag<BlogPost> posts)
    {
        if (nextItem != null && nextItem.Summary != null)
        {
            BlogPost newPost = new();
            newPost.PostContent = nextItem.Summary.Text
                .ToString();
            newPost.PostDate = nextItem.PublishDate
                .ToLocalTime().ToString("g");
            if (nextItem.Categories != null)
            {
                newPost.Categories = string.Join(",", 
                    nextItem.Categories.Select(c => 
                        c.Name));
            }
            posts.Add(newPost);
        }
    }
    
  2. 现在,是时候将生产者/消费者逻辑结合起来。创建一个名为 GetAllMicrosoftBlogPosts 的方法:

    public async Task<IEnumerable<BlogPost>> 
        GetAllMicrosoftBlogPosts()
    {
        var posts = new ConcurrentBag<BlogPost>();
        // Create queue of source posts
        BufferBlock<SyndicationItem> itemQueue = new(new 
            DataflowBlockOptions { BoundedCapacity = 
                10 });
        // Create and link consumers
        var consumerOptions = new Execution
            DataflowBlockOptions { BoundedCapacity = 1 };
        var consumerA = new ActionBlock<SyndicationItem>
            ((i) => ConsumeFeedItem(i, posts), 
                consumerOptions);
        var consumerB = new ActionBlock<SyndicationItem>
            ((i) => ConsumeFeedItem(i, posts), 
                consumerOptions);
        var consumerC = new ActionBlock<SyndicationItem>
            ((i) => ConsumeFeedItem(i, posts), 
                consumerOptions);
        var linkOptions = new DataflowLinkOptions { 
            PropagateCompletion = true, };
        itemQueue.LinkTo(consumerA, linkOptions);
        itemQueue.LinkTo(consumerB, linkOptions);
        itemQueue.LinkTo(consumerC, linkOptions);
        // Start producers
        Task producers = QueueAllFeeds(itemQueue);
        // Wait for producers and consumers to complete
        await Task.WhenAll(producers, consumerA.Completion,
            consumerB.Completion, consumerC.Completion);
        return posts;
    }
    
    1. 方法首先创建一个 ConcurrentBag<BlogPost> 来聚合最终用于 UI 的帖子列表。然后,它创建一个具有 BoundedCapacity10itemQueue 对象。这个有界容量意味着在任何时候不能入队超过 10 个项目。一旦队列达到 10,所有生产者都必须等待消费者退队一些项目。这可能会降低处理过程的性能,但可以防止生产代码中潜在的内存溢出问题。我们的示例在处理来自三个博客的帖子时不会出现内存不足的危险,但你可以在需要时看到如何使用 BoundedCapacity。你可以像这样创建没有 BoundedCapacity 的队列:
    BufferBlock<SyndicationItem> itemQueue = new();
    
    1. 方法的下一部分创建了三个使用 ActionBlock<SyndicationItem> 并以 ConsumeFeedItem 作为提供的代理的消费者。每个消费者都通过 LinkTo 方法链接到队列。将消费者的 BoundedCapacity 设置为 1 告诉生产者在当前消费者正在忙于处理一个项目时,可以继续到下一个消费者。

    2. 一旦建立了链接,我们可以通过调用 QueueAllFeeds 来启动生产者。然后,我们必须 await 生产者和每个消费者 ActionBlockCompletion 对象。通过将生产者和消费者的完成状态链接起来,我们不需要显式地 await 消费者的 Completion 对象:

    var linkOptions = new DataflowLinkOptions { 
        PropagateCompletion = true, };
    
  3. 下一步是创建一些 UI 控件来向用户显示信息。打开MainWindow.xaml文件,并用以下标记替换现有的Grid

    <Grid>
        <ListView x:Name="mainListView">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition 
                                Width="150"/>
                            <ColumnDefinition 
                                Width="300"/>
                            <ColumnDefinition 
                                Width="500"/>
                        </Grid.ColumnDefinitions>
                        <TextBlock Grid.Column="0"  
                            Text="{Binding PostDate}"
                                Margin="3"/>
                        <TextBox IsReadOnly="True" 
                            Grid.Column="1"
                               Text="{Binding Categories}"
                                   Margin="3"
                                     TextWrapping="Wrap"/>
                        <TextBox IsReadOnly="True" 
                            Grid.Column="2" 
                              Text="{Binding PostContent}"
                                 Margin="3"/>
                    </Grid>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
    </Grid>
    

解释 WPF、XAML 和数据绑定的细节超出了本书的范围。如果您想了解更多关于 WPF 的信息,请查看 Sheridan Yeun 的《Mastering Windows Presentation Foundation》:www.packtpub.com/product/mastering-windows-presentation-foundation-second-edition/9781838643416。这个标记的作用是创建一个新的ListView控件,并使用DataTemplate来定义控件中每个列表项的结构。对于每个项,我们定义一个TextBlockTextBox来保存列表中每个BlogPost对象的价值。

  1. 我们必须做的最后一件事是调用GetAllMicrosoftBlogPosts方法并填充 UI。打开MainWindow.xaml.cs并添加以下代码:

    public MainWindow()
    {
        InitializeComponent();
        Loaded += MainWindow_Loaded;
    }
    private async void MainWindow_Loaded(object sender, 
        RoutedEventArgs e)
    {
        FeedAggregator aggregator = new();
        var items = await aggregator
            .GetAllMicrosoftBlogPosts();
        mainListView.ItemsSource = items;
    }
    

MainWindow加载后,从GetAllMicrosoftBlogPosts返回的项目被设置为mainListView.ItemsSource。这将允许数据绑定到我们在 XAML 中定义的DataTemplate元素。

  1. 现在,运行项目并查看效果:

图 7.2 – 首次运行 ProducerConsumerRssFeeds WPF 应用程序

图 7.2 – 首次运行 ProducerConsumerRssFeeds WPF 应用程序

如您所见,列表显示了来自每个微软博客的 10 篇博客摘要。这是微软博客默认可以返回的最大项目数。

您可以通过增加或减少项目中的生产者和消费者数量来尝试实验。增加更多的消费者是否会加快处理速度?尝试将您最喜欢的博客源添加到生产者列表中,看看会发生什么。

注意

您可能已经注意到,由 RSS 源返回的内容摘要包含 HTML,我们只是在TextBox控件中以纯文本形式渲染它。如果您想使用正确渲染 HTML 的RichTextBox,请查看 CodeProject 上的这个示例项目,它使用 WPF RichTextBoxwww.codeproject.com/articles/1097390/displaying-html-in-a-wpf-richtextbox

在下一节中,我们将创建另一个示例,使用不同类型的数据流块来创建数据管道。

使用多个块创建数据管道

使用数据流块的最大优点之一是能够将它们连接起来,创建一个完整的工作流程或数据管道。在上一节中,我们看到了生产者和消费者块之间是如何进行连接的。在本节中,我们将创建一个包含五个数据流块的控制台应用程序,这些块全部连接在一起以完成一系列任务。我们将利用 TransformBlockTransformManyBlockActionBlock 从 RSS 源中提取并输出一个列表,其中包含所有博客文章中独特的分类。按照以下步骤操作:

  1. 首先,在 Visual Studio 中创建一个新的 .NET 6 控制台应用程序,命名为 OutputBlogCategories

  2. 添加我们在上一个示例中使用的 System.ComponentModel.Syndication NuGet 包。

  3. 添加与上一个示例中相同的 RssFeedService 类。您可以在 RssFeedService 项目的上下文中右键单击,然后复制/粘贴我们在上一个示例中使用的相同代码。

  4. 在项目中添加一个名为 FeedCategoryTransformer 的新类,并创建一个名为 GetCategoriesForFeed 的方法:

    public static async Task GetCategoriesForFeed(string 
        url)
    {
    }
    
  5. 在接下来的几个步骤中,我们将为 GetCategoriesForFeed 方法创建实现。首先,创建一个名为 downloadFeedTransformBlock,它接受字符串 url 并从 GetFeedItems 方法返回 IEnumerable<SyndicationItem>

    // Downloads the requested blog posts.
    var downloadFeed = new TransformBlock<string, 
        IEnumerable<SyndicationItem>>(url =>
    {
        Console.WriteLine("Fetching feed from '{0}'...", 
            url);
        return RssFeedService.GetFeedItems(url);
    });
    
  6. 接下来,创建一个接受 IEnumerable<SyndicationItem> 并返回 List<SyndicationCategory>TransformBlock。这个块将从每篇博客文章中获取完整的分类列表,并将它们作为一个单独的列表返回:

    // Aggregates the categories from all the posts.
    var createCategoryList = new TransformBlock
        <IEnumerable<SyndicationItem>, List
            <SyndicationCategory>>(items =>
    {
        Console.WriteLine("Getting category list...");
        var result = new List<SyndicationCategory>();
        foreach (var item in items)
        {
            result.AddRange(item.Categories);
        }
        return result;
    });
    
  7. 现在,创建另一个 TransformBlock。这个块将接受来自上一个块的 List<SyndicationCategory>,移除所有重复项,并返回过滤后的 List<SyndicationCategory>

    // Removes duplicates.
    var deDupList = new TransformBlock<List
        <SyndicationCategory>, List<SyndicationCategory>>
            (categories =>
    {
        Console.WriteLine("De-duplicating category 
            list...");
        var categoryComparer = new CategoryComparer();
        return categories.Distinct(categoryComparer)
            .ToList();
    });
    

要在复杂对象如 SyndicationCategory 上使用 LINQ Distinct 扩展方法,需要一个实现 IEqualityComparer<T> 的自定义比较器。你可以从本章的 GitHub 仓库中获取 CategoryComparer 的完整源代码:github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter07

  1. 接下来,创建一个名为 createCategoryStringTransformManyBlock。这个块将接受去重的 List<SyndicationCategory> 并为每个分类的 Name 属性返回一个字符串。因此,该块对整个列表调用一次,但将依次为列表中的每个项目调用流程中的下一个块:

    // Gets the category names from the list of category 
        objects.
    var createCategoryString = new TransformManyBlock
        <List<SyndicationCategory>, string>(categories =>
    {
        Console.WriteLine("Extracting category names...");
        return categories.Select(c => c.Name);
    });
    
  2. 最后一个块是一个名为 printCategoryInCapsActionBlock。这个块将使用 ToUpper 将每个分类名称输出到控制台,全部转换为大写:

    // Prints the upper-cased unique categories to the 
        console.
    var printCategoryInCaps = new ActionBlock<string>
        (categoryName =>
    {
        Console.WriteLine($"Found CATEGORY 
            {categoryName.ToUpper()}");
    });
    
  3. 现在数据流块已经配置好了,是时候将它们连接起来。创建一个 DataflowLinkOptions,它将传播每个块的完成状态。然后,使用 LinkTo 方法将链中的每个块连接到下一个块:

    var linkOptions = new DataflowLinkOptions { 
        PropagateCompletion = true };
    downloadFeed.LinkTo(createCategoryList, linkOptions);
    createCategoryList.LinkTo(deDupList, linkOptions);
    deDupList.LinkTo(createCategoryString, linkOptions);
    createCategoryString.LinkTo(printCategoryInCaps, 
        linkOptions);
    
  4. 创建 GetCategoriesForFeed 方法的最后几个步骤包括将 url 发送到第一个块,将其标记为 Complete,并等待链中的最后一个块:

    await downloadFeed.SendAsync(url);
    downloadFeed.Complete();
    await printCategoryInCaps.Completion;
    
  5. 现在,打开 Program.cs 并更新代码,使其调用 GetCategoriesForFeed 方法,提供 Windows 博客 RSS feed 的 URL:

    using OutputBlogCategories;
    Console.WriteLine("Hello, World!");
    await FeedCategoryTransformer.GetCategoriesForFeed
        ("https://blogs.windows.com/feed");
    Console.ReadLine();
    
  6. 运行程序并检查输出中的分类列表:

图 7.3 – 显示来自 Windows 博客 feed 的去重分类列表

图 7.3 – 显示来自 Windows 博客 feed 的去重分类列表

现在你已经了解了如何使用一系列数据流块创建数据管道,我们将查看一个使用 JoinBlock 将多个来源的数据组合的示例。

操作来自多个数据源的数据

JoinBlock 可以配置为从两个或三个数据源接收不同的数据类型。当每一组数据类型完成时,该块将使用包含要操作的所有三个对象类型的 Tuple 完成。在这个例子中,我们将创建一个接受 stringint 对的 JoinBlock,并将 Tuple(string, int) 传递给一个 ActionBlock,该 ActionBlock 将它们的值输出到控制台。按照以下步骤操作:

  1. 首先,在 Visual Studio 中创建一个新的控制台应用程序

  2. 向项目中添加一个名为 DataJoiner 的新类,并在该类中添加一个名为 JoinData 的静态方法:

    public static void JoinData()
    {
    }
    
  3. 添加以下代码以创建两个 BufferBlock 对象、一个 JoinBlock<string, int> 和一个 ActionBlock<Tuple<string, int>>

    var stringQueue = new BufferBlock<string>();
    var integerQueue = new BufferBlock<int>();
    var joinStringsAndIntegers = new JoinBlock<string, 
        int>(
        new GroupingDataflowBlockOptions
        {
            Greedy = false
        });
    var stringIntegerAction = new ActionBlock
        <Tuple<string, int>>(data =>
    {
        Console.WriteLine($"String received: 
            {data.Item1}");
        Console.WriteLine($"Integer received: 
            {data.Item2}");
    });
    

将块设置为非贪婪模式意味着它将在执行块之前等待每种类型的一个项目。

  1. 现在,创建块之间的链接:

    stringQueue.LinkTo(joinStringsAndIntegers.Target1);
    integerQueue.LinkTo(joinStringsAndIntegers.Target2);
    joinStringsAndIntegers.LinkTo(stringIntegerAction);
    
  2. 接下来,向两个 BufferBlock 对象推送一些数据,等待一秒钟,然后将它们都标记为完成:

    stringQueue.Post("one");
    stringQueue.Post("two");
    stringQueue.Post("three");
    integerQueue.Post(1);
    integerQueue.Post(2);
    integerQueue.Post(3);
    stringQueue.Complete();
    integerQueue.Complete();
    Thread.Sleep(1000);
    Console.WriteLine("Complete");
    
  3. 将以下代码添加到 Program.cs 中以运行示例代码:

    using JoinBlockExample;
    DataJoiner.JoinData();
    Console.ReadLine();
    
  4. 最后,运行应用程序并检查输出。你会看到 ActionBlock 为每一组提供的值输出一个 stringinteger 对:

图 7.4 – 运行 JoinBlockExample 控制台应用程序

图 7.4 – 运行 JoinBlockExample 控制台应用程序

这就是使用 JoinBlock 数据流块的全部内容。尝试自己做一些更改,比如更改 Greedy 选项或向每个 BufferBlock 添加数据的顺序。这会如何影响输出?

在我们完成本章之前,让我们回顾一下我们所学到的所有内容。

摘要

在本章中,我们学习了 TPL Dataflow 库中的各种块。我们首先简要了解了每种块类型,并为每个块提供了一个简短的代码片段。接下来,我们创建了一个实际示例,实现了生产者/消费者模式,从三个不同的微软博客中获取博客数据。我们还更详细地考察了 .NET 控制台应用程序中的 TransformBlockTransformManyBlockJoinBlock`。现在,你应该对自己的能力有信心,能够在你的应用程序中使用一些数据流块来自动化一些复杂的数据工作流。

如果你想了解更多关于 TPL Dataflow 库的阅读材料,你可以从微软下载中心下载 TPL Dataflow 简介www.microsoft.com/en-us/download/details.aspx?id=14782

在下一章(第八章)中,我们将更深入地研究 System.Collections.Concurrent 命名空间中的集合。我们还将发现 PLINQ 在现代 .NET 应用程序中的实际用途。

问题

回答以下问题以测试你对本章知识的掌握:

  1. 哪种类型的数据流块可以聚合来自两个或三个数据源的数据?

  2. BufferBlock 是哪种类型的块?

  3. 在生产者/消费者模式中,哪种类型的块由生产者填充?

  4. 哪种方法将两个块的完成状态连接起来?

  5. 调用哪种方法来表示我们的代码已经完成向源块添加数据?

  6. 调用 Post() 的异步等价方法是什么?

  7. 调用 Receive() 的异步等价方法是什么?

第八章:第八章:并行数据结构和并行 LINQ

.NET 为将并行性引入其项目的开发者提供了许多有用的功能和数据结构。本章将探讨这些功能,包括 SpinLock<T> 同步原语并行 LINQPLINQ)。这些功能可以在保持安全线程实践的同时提高应用程序的性能。

大多数 .NET 开发者熟悉 LINQ 框架,包括 LINQ to Objects、LINQ to SQL 和 LINQ to XML。甚至还有开源的 .NET LINQ 库,例如 LINQ to Twitter (github.com/JoeMayo/LinqToTwitter)。我们将利用这些 LINQ 技巧,在 PLINQ 的并行编程中发挥其作用。阅读本章后,每个 LINQ 开发者都可以成为 PLINQ 开发者。继续阅读以获取一些使用 C# 操作 PLINQ 的有用示例。

在本章中,您将学习以下内容:

  • 介绍 PLINQ

  • 将 LINQ 查询转换为 PLINQ

  • 使用 PLINQ 保留数据顺序和合并数据

  • .NET 中用于并行编程的数据结构

到本章结束时,您将对 LINQ 在并行编程方面的应用有新的认识。

技术要求

为了跟随本章中的示例,以下软件是 Windows 开发者推荐的:

  • Visual Studio 2022 版本 17.0 或更高版本。

  • .NET 6.

  • 要完成 WPF 示例,您需要为 Visual Studio 安装 .NET 桌面开发工作负载。

虽然这些是推荐的,但如果您已安装 .NET 6,您可以使用您喜欢的编辑器。例如,macOS 10.13 或更高版本的 Visual Studio 2022 for Mac、JetBrains Rider 或 Visual Studio Code 都可以正常工作。

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter08

让我们从讨论 LINQ、PLINQ 以及为什么查询语言可以成为改进 C# 并行编程的绝佳方式开始。

介绍 PLINQ

PLINQ 是一组 .NET 扩展,用于 LINQ,它允许部分 LINQ 查询通过利用线程池并行执行。PLINQ 实现提供了所有可用的 LINQ 查询操作并行版本。

与 LINQ 查询类似,PLINQ 查询提供延迟执行。这意味着对象只有在需要枚举时才会被查询。如果您不熟悉 LINQ 的延迟执行,我们将通过一个简单的示例来阐述这个概念。考虑以下两个 LINQ 查询:

internal void QueryCities(List<string> cities)
{
    // Query is executed with ToList call
    List<string> citiesWithS = cities.Where(s => 
        s.StartsWith('S')).ToList();
    // Query is not executed here
    IEnumerable<string> citiesWithT = cities.Where(s => 
        s.StartsWith('T'));
    // Query is executed here when enumerating
    foreach (string city in citiesWithT)
    {
        // Do something with citiesWithT
    }
}

在示例中,由于调用了ToList(),填充citiesWithS的 LINQ 查询立即执行。而填充citiesWithT的第二个查询并没有立即执行。执行被延迟,直到需要IEnumerable值。citiesWithT的值在我们遍历foreach循环时才需要。这个原则同样适用于 PLINQ 查询。

注意

如果你对 LINQ 概念或 LINQ 方法语法不熟悉,由马克·J·普赖斯所著的《C# 10 和.NET 6 – 现代跨平台开发 – 第六版》一书有一个章节专门用于解释 LINQ 语法及其几种实现方式。这是一本非常适合.NET 开发者的优秀书籍。你可以在以下链接中了解更多关于这本书的信息:subscription.packtpub.com/product/mobile/9781801077361

PLINQ 在其他方面也与 LINQ 相似。你可以在实现IEnumerableIEnumerable<T>的任何集合上创建 PLINQ 查询。你可以使用所有熟悉的 LINQ 操作,如WhereFirstOrDefaultSelect等。主要区别在于 PLINQ 试图通过跨多个线程的部分或全部查询来利用并行编程的力量。内部,PLINQ 将内存中的数据分割成多个段,并在每个段上并行执行查询。

使用 PLINQ 获得性能提升受多种因素影响。让我们接下来探讨这些因素。

PLINQ 与性能

当决定哪些 LINQ 查询适合利用 PLINQ 的力量时,你必须考虑许多因素。主要考虑的因素是执行的工作量的大小或复杂性是否足够大,以抵消线程的开销。你应该在大型数据集上操作,并对集合中的每个项目执行昂贵的操作。检查字符串第一个字母的 LINQ 示例并不是 PLINQ 的良好候选,尤其是如果源集合只包含少量项目。

PLINQ 可能带来的性能提升的另一个因素是查询将在其上运行的系统上可用的核心数量。PLINQ 可以利用的核心越多,潜在的提升就越好。PLINQ 可以将大型数据集分解成更多的工作单元,以便在多个核心上并行执行。

对数据进行排序和分组可能比在传统的 LINQ 查询中产生更大的开销。PLINQ 数据是分段的,但分组和排序必须在整个集合上执行。PLINQ 最适合于数据序列不重要的情况。

我们将在“使用 PLINQ 保留数据顺序和合并数据”部分讨论影响查询性能的其他因素。现在,让我们开始创建我们的第一个 PLINQ 查询。

创建 PLINQ 查询

PLINQ 的大多数功能都通过 System.Linq.ParallelEnumerable 类的成员公开。这个类包含所有可用于内存对象查询的 LINQ 操作的实现。这个类中还有一些特定于 PLINQ 查询的附加操作。理解最重要的两个操作是 AsParallelAsSequentialAsParallel 操作指示所有后续的 LINQ 操作都应尝试并行执行。相比之下,AsSequential 操作指示 PLINQ,随后的 LINQ 操作应以顺序执行。

让我们看看一个使用这两个 PLINQ 操作的示例。我们的查询将在以下定义的 List<Person> 上操作:

internal class Person
{
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public int Age { get; set; }
}

让我们假设我们正在处理包含数千甚至数百万人的数据集。我们希望利用 PLINQ 仅从数据中查询年龄为 18 岁或以上的成年人,并按他们的姓氏对他们进行分组。我们只想并行执行查询的 Where 子句。GroupBy 操作将按顺序执行。这种方法将正好做到这一点:

internal void QueryAndGroupPeople(List<Person> people)
{
    var results = people.AsParallel().Where(p => p.Age > 17)
        .AsSequential().GroupBy(p => p.LastName);
    foreach (var group in results)
    {
        Console.WriteLine($"Last name {group.Key} has 
            {group.Count()} people.");
    }
    // Sample output:
    // Last name Jones has 4220 people.
    // Last name Xu has 3434 people.
    // Last name Patel has 4798 people.
    // Last name Smith has 3051 people.
    // Last name Sanchez has 3811 people.
    // ...
}

GroupBy LINQ 方法将返回 IEnumerable<IGrouping<string, Person>>,其中每个 IGrouping<string, Person> 实例都包含具有相同 LastName 的所有人。是否将此 GroupBy 操作并行运行或顺序运行更快取决于数据的组成。你应该始终测试你的应用程序,以确定在处理生产数据时引入并行化是否提高了性能。我们将在 第十章 中介绍测试代码性能的方法。

接下来,让我们看看如何使用我们迄今为止使用的 方法语法 或通过使用 LINQ 查询语法 来编写 PLINQ 查询。

查询语法与方法语法

LINQ 查询可以通过使用方法语法或查询语法进行编码。方法语法是将多个方法串联起来构建查询的地方。这是我们一直在本节中做的事情。查询语法略有不同,类似于 T-SQL 查询语法。让我们检查以两种方式编写的相同的 PLINQ 查询。

这里是一个简单的 PLINQ 查询,使用方法语法从人员列表中返回仅包含成年人的查询:

var peopleQuery1 = people.AsParallel().Where(p => p.Age > 17);

这里是使用查询语法编写的完全相同的 PLINQ 查询:

var peopleQuery2 = from person in people.AsParallel()
                    where person.Age > 17
                    select person;

你应该使用你喜欢的任何一种语法。在本章的其余部分,我们将使用方法语法进行示例。

在下一节中,我们将继续探讨 PLINQ 中可用的操作,并创建一些 LINQ 查询的并行版本。

将 LINQ 查询转换为 PLINQ

在本节中,我们将探讨一些额外的 PLINQ 操作符,并展示你如何利用它们将现有的 LINQ 查询转换为 PLINQ 查询。你的现有查询可能需要保留数据顺序。也许你的现有代码根本不使用 LINQ。那里可能有机会将 foreach 循环中的某些逻辑转换为 PLINQ 操作。

将 LINQ 查询转换为 PLINQ 查询的方法是在查询中插入一个 AsParallel() 语句,就像我们在前一个部分中所做的那样。在 AsParallel() 之后的所有内容都将并行运行,直到遇到一个 AsSequential() 语句。

如果你的查询需要保留对象的原始顺序,你可以包含一个 AsOrdered() 语句:

var results = people.AsParallel().AsOrdered()
    .Where(p => p.LastName.StartsWith("H"));

然而,这不会像不保留数据顺序的查询那样高效。要明确告诉 PLINQ 不保留数据顺序,请使用 AsUnordered() 语句:

var results = people.AsParallel().AsUnordered()
    .Where(p => p.LastName.StartsWith("H"));

如果你的数据顺序不重要,无序版本的查询将表现得更好;你永远不应该与 PLINQ 一起使用 AsOrdered()

让我们考虑另一个例子。我们将从一个使用 foreach 循环遍历人员列表的方法开始,并为每个 18 岁或以上的人调用名为 ProcessVoterActions 的方法。我们假设这个方法计算密集型,并且还需要一些 I/O 操作来将选民信息保存到数据库中。以下是起始代码:

internal void ProcessAdultsWhoVote(List<Person> people)
{
    foreach (var person in people)
    {
        if (person.Age < 18) continue;
        ProcessVoterActions(person);
    }
}
private void ProcessVoterActions(Person adult)
{
    // Add adult to a voter database and process their 
        data.
}

这将根本不会利用并行处理。我们可以通过使用 LINQ 过滤出 18 岁以下的儿童,然后使用 Parallel.ForEach 循环调用 ProcessVoterActions 来改进这一点:

internal void ProcessAdultsWhoVoteInParallel(List<Person> 
    people)
{
    var adults = people.Where(p => p.Age > 17);
    Parallel.ForEach(adults, ProcessVoterActions);
}

如果 ProcessVoterActions 对每个人运行需要一些时间,这将肯定能提高性能。然而,使用 PLINQ,我们甚至可以进一步提高性能:

internal void ProcessAdultsWhoVoteWithPlinq(List<Person> 
    people)
{
    var adults = people.AsParallel().Where(p => p.Age > 17);
    adults.ForAll(ProcessVoterActions);
}

现在,Where 查询将在并行中运行。如果我们预计 people 集合中有成千上万或数百万个对象,这将肯定有助于性能。ForAll 扩展方法是另一个并行运行的 PLINQ 操作。它旨在用于在查询结果的每个对象上并行执行操作。

ForAll 的性能也将优于前一个例子中的 Parallel.ForEach 操作。一个区别是 PLINQ 的延迟执行。这些对 ProcessVoterActions 的调用将不会执行,直到迭代 IEnumerable 结果。另一个优势是,在完成对数据的 PLINQ 查询后,与使用 IEnumerable 执行标准 foreach 循环相比,数据必须从多个线程合并回来才能被 foreachParallel.ForEach 枚举。使用 ForAll 操作,数据可以由 PLINQ 分段,并在最后合并一次。此图说明了 Parallel.ForEachForAll 之间的区别:

图 8.1 – PLINQ、数据分段和 ForAll 的优势

图 8.1 – PLINQ 的优势、数据分段和 ForAll

在我们探讨更多关于数据顺序和合并数据细节之前,让我们讨论一下在处理 PLINQ 时如何处理异常。

使用 PLINQ 查询处理异常

在你的 .NET 项目中实现良好的异常处理非常重要。这是软件开发的基本实践之一。在一般并行编程中,异常处理可能会更复杂。这对于 PLINQ 也是如此。当 PLINQ 查询内部的并行操作中发生任何未处理的异常时,查询将抛出一个类型为 AggregateException 的异常。因此,至少你的所有 PLINQ 查询都应该在一个捕获 AggregateException 异常类型的 try/catch 块中运行。

让我们添加一些异常处理到带有 ProcessVoterActions 的 PLINQ ForAll 示例中:

  1. 我们将在一个 .NET 控制台应用程序中运行这个示例,所以请在 Visual Studio 中创建一个新的项目并添加一个名为 Person 的类:

    internal class Person
    {
        public string FirstName { get; set; } = "";
        public string LastName { get; set; } = "";
        public int Age { get; set; }
    }
    
  2. 接下来,添加一个名为 PlinqExceptionsExample 的新类。

  3. 现在向 PlinqExceptionsExample 添加一个名为 ProcessVoterActions 的私有方法。我们将为任何年龄超过 120: 的人抛出 ArgumentException

    private void ProcessVoterActions(Person adult)
    {
        if (adult.Age > 120)
        {
            throw new ArgumentException("This person is 
                too old!", nameof(adult));
        }
        // Add adult to a voter database and process their 
    data.
    }
    
  4. 接下来,添加 ProcessAdultsWhoVoteWithPlinq 方法:

    internal void ProcessAdultsWhoVoteWithPlinq
        (List<Person> people)
    {
        try
        {
            var adults = people.AsParallel().Where(p => 
                p.Age > 17);
            adults.ForAll(ProcessVoterActions);
        }
        catch (AggregateException ae)
        {
            foreach (var ex in ae.InnerExceptions)
            {
                Console.WriteLine($"Exception encountered 
                    while processing voters. Message: 
                        {ex.Message}");
            }
        }
    }
    

这个方法的逻辑保持不变。它使用 PLINQ 的 Where 子句过滤出儿童,并将 ProcessVoterActions 作为 ForAll 的委托调用。

注意

如果你正在跟随 GitHub 上本章的示例代码(github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter08/LINQandPLINQsnippets),你需要取消注释 步骤 5 中的代码行。你还应该注释掉 Main 方法中那些代码行后面的代码,以防止其他示例执行。

  1. 最后,打开 Program.cs 并在名为 GetPeople 的内联函数中添加一些代码来创建一个 List<Person> 实例。它可以包含任意多的人,但至少需要一个人年龄大于 120。调用 ProcessAdultsWhoVoteWithPlinq,传递 GetPeople 的数据:

    using LINQandPLINQsnippets;
    var exceptionExample = new PlinqExceptionsExample();
    exceptionExample.ProcessAdultsWhoVoteWithPlinq
        (GetPeople());
    Console.ReadLine();
    static List<Person> GetPeople()
    {
        return new List<Person>
        {
            new Person { FirstName = "Bob", LastName = 
                "Jones", Age = 23 },
            new Person { FirstName = "Sally", LastName = 
                "Shen", Age = 2 },
            new Person { FirstName = "Joe", LastName = 
                "Smith", Age = 45 },
            new Person { FirstName = "Lisa", LastName = 
                "Samson", Age = 98 },
            new Person { FirstName = "Norman", LastName = 
                "Patterson", Age = 121 },
            new Person { FirstName = "Steve", LastName = 
                "Gates", Age = 40 },
            new Person { FirstName = "Richard", LastName = 
                "Ng", Age = 18 }
        };
    }
    
  2. 现在,运行程序并观察控制台输出。如果 Visual Studio 在异常处中断,只需点击 继续

图 8.2 – 控制台中接收异常

图 8.2 – 控制台中接收异常

处理 PLINQ 查询外部异常的问题在于整个查询会被停止。它无法运行到完成。如果你有一个不应该停止整个过程的异常,你应该在查询内部的代码中处理它,并继续处理剩余的项目。

如果你能在 ProcessVoterActions 中处理异常,你就有机会优雅地处理它们并继续执行。

接下来,我们将探索一些示例,说明如何保留数据的顺序以及处理合并段的不同选项。

保留数据顺序和合并数据使用 PLINQ

当对应用程序中的 PLINQ 查询进行微调时,有一些扩展方法会影响数据的排序,你可以利用这些方法。保留项目的原始顺序可能是一个需要考虑的因素。我们在本章中提到了AsOrdered方法,我们将在本节中对其进行实验。当 PLINQ 操作完成后,项目作为最终枚举的一部分返回时,数据将从为多线程操作而创建的段中合并。可以通过设置ParallelMergeOptionsWithMergeOptions扩展方法来控制合并行为。我们将讨论三个可用合并选项的行为。

让我们从使用AsOrderedAsUnordered扩展方法创建一些示例开始。

PLINQ 数据排序示例

在本节中,我们将创建五种方法,每种方法都接受相同的数据集并对输入数据进行相同的过滤。然而,每个 PLINQ 查询中的排序处理将不同。我们将使用上一节中的相同Person类。因此,你可以使用相同的项目,或者创建一个新的.NET 控制台应用程序项目,并添加上一示例中的People类。让我们开始吧:

  1. 首先,打开Person类并添加一个名为IsImportant的新bool属性。我们将使用这个属性在 PLINQ 查询中添加第二个过滤数据点:

    internal class Person
    {
        public string FirstName { get; set; } = "";
        public string LastName { get; set; } = "";
        public int Age { get; set; }
        public bool IsImportant { get; set; }
    }
    
  2. 接下来,向项目中添加一个名为OrderSamples的新类。

  3. 现在是时候开始添加查询了。在这个第一个查询中,我们没有指定AsOrderedAsUnordered。默认情况下,PLINQ 不应该尝试保留数据的原始顺序。在这些查询中的每一个,我们都在返回年龄小于 18 岁且IsImportant设置为true的每个Person对象:

    internal IEnumerable<Person> 
        GetImportantChildrenNoOrder(List<Person> people)
    {
        return people.AsParallel()
            .Where(p => p.IsImportant && p.Age < 18);
    }
    
  4. 在第二个示例中,我们在AsParallel之后显式添加了IsUnordered到查询中。行为应该与第一个查询相同,PLINQ 不会关心项目的原始顺序:

    internal IEnumerable<Person> 
        GetImportantChildrenUnordered(List<Person> people)
    {
        return people.AsParallel().AsUnordered()
            .Where(p => p.IsImportant && p.Age < 18);
    }
    
  5. 第三个示例将过滤器拆分为两个单独的Where子句;IsSequential在第一个Where子句之后添加。你认为这会如何影响项目序列?我们将在运行程序时找到答案:

    internal IEnumerable<Person> 
        GetImportantChildrenUnknownOrder(List<Person> 
            people)
    {
        return people.AsParallel().Where(p => 
            p.IsImportant)
            .AsSequential().Where(p => p.Age < 18);
    }
    
  6. 在第四个示例中,我们使用AsParallel().AsOrdered()来通知 PLINQ 我们希望保留项目的原始顺序:

    internal IEnumerable<Person> 
        GetImportantChildrenPreserveOrder(List<Person> 
            people)
    {
        return people.AsParallel().AsOrdered()
            .Where(p => p.IsImportant && p.Age < 18);
    }
    
  7. 在第五个和最后一个示例中,我们在AsOrdered之后添加了一个Reverse方法。这应该会以相反的顺序保留项目的原始顺序:

    internal IEnumerable<Person> 
        GetImportantChildrenReverseOrder(List<Person> 
            people)
    {
        return people.AsParallel().AsOrdered().Reverse()
            .Where(p => p.IsImportant && p.Age < 18);
    }
    
  8. 接下来,打开Program.cs并添加两个局部函数。一个将创建一个Person对象列表,传递给每个方法。另一个将遍历List<Person>并将每个FirstName输出到控制台:

    static List<Person> GetYoungPeople()
    {
        return new List<Person>
        {
            new Person { FirstName = "Bob", LastName = 
                "Jones", Age = 23 },
            new Person { FirstName = "Sally", LastName = 
                "Shen", Age = 2, IsImportant = true },
            new Person { FirstName = "Joe", LastName = 
                "Smith", Age = 5, IsImportant = true },
            new Person { FirstName = "Lisa", LastName = 
                "Samson", Age = 9, IsImportant = true },
            new Person { FirstName = "Norman", LastName = 
                "Patterson", Age = 17 },
            new Person { FirstName = "Steve", LastName = 
                "Gates", Age = 20 },
            new Person { FirstName = "Richard", LastName = 
                "Ng", Age = 16, IsImportant = true }
        };
    }
    static void OutputListToConsole(List<Person> list)
    {
        foreach (var item in list)
        {
            Console.WriteLine(item.FirstName);
        }
    }
    
  9. 最后,我们将添加调用每个方法的代码。在每次方法调用之前和之后,都会将包括毫秒在内的时间戳输出到控制台。您可以多次运行应用程序以检查每个方法调用的性能。尝试在具有更多或更少核心的 PC 上运行它,以及在不同大小的数据集上运行,以查看这对输出有何影响:

    using LINQandPLINQsnippets;
    var timeFmt = "hh:mm:ss.fff tt";
    var orderExample = new OrderSamples();
    Console.WriteLine($"Start time: {DateTime.Now.ToString
        (timeFmt)}. AsParallel children:");
    OutputListToConsole(orderExample.GetImportantChildrenN
        oOrder(GetYoungPeople()).ToList());
    Console.WriteLine($"Start time: {DateTime.Now
        .ToString(timeFmt)}. AsUnordered children:");
    OutputListToConsole(orderExample.GetImportantChildrenU
        nordered(GetYoungPeople()).ToList());
    Console.WriteLine($"Start time: {DateTime.Now
        .ToString(timeFmt)}. Sequential after Where 
            children:");
    OutputListToConsole(orderExample.GetImportantChildren
        UnknownOrder(GetYoungPeople()).ToList());
    Console.WriteLine($"Start time: {DateTime.Now
        .ToString(timeFmt)}. AsOrdered children:");
    OutputListToConsole(orderExample.GetImportantChildrenP
        reserveOrder(GetYoungPeople()).ToList());
    Console.WriteLine($"Start time: {DateTime.Now
        .ToString(timeFmt)}. Reverse order children:");
    OutputListToConsole(orderExample.GetImportantChildrenR
        everseOrder(GetYoungPeople()).ToList());
    Console.WriteLine($"Finish time: {DateTime.Now
        .ToString(timeFmt)}");
    Console.ReadLine();
    
  10. 现在,运行程序并检查输出:

图 8.3 – 比较五个 PLINQ 查询的项目顺序

图 8.3 – 比较五个 PLINQ 查询的项目顺序

您可以从输出中看到,只有在指定了AsOrdered()AsOrdered().Reverse()的最后两个示例中,项目的顺序才是可预测的。在如此小的数据集上,不同 PLINQ 操作的影响很难衡量。如果您多次运行此程序,您可能会在时间上看到不同的结果。尝试添加更大的数据集以进行性能实验。

接下来,让我们讨论合并段并测试样本中的不同选项。

在 PLINQ 查询中使用 WithMergeOptions

当我们在 PLINQ 中讨论合并数据时,是指每个操作段完成其操作后发生的合并,并将结果合并回调用线程的结果。大多数时候,您不需要指定任何合并选项。在您可能需要这样做的时候,了解每个选项的行为非常重要。让我们回顾一下ParallelMergeOptions枚举的每个成员。

ParallelMergeOptions.NotBuffered

NotBuffered选项视为流式数据。每个项目在完成处理后会立即从查询中返回。有一些 PLINQ 操作不支持此选项,并将忽略它。例如,OrderByOrderByDescending操作必须在合并数据上完成排序后才能返回项目。这些总是FullyBuffered。然而,使用AsOrdered的查询可以使用此选项。如果您的应用程序需要以流式方式消费项目,请使用此选项。

ParallelMergeOptions.AutoBuffered

AutoBuffered选项以收集到的项目集的形式返回项目。项目集的大小以及它返回以清除缓冲区的时间间隔是不可配置的,也不会被您的代码所知道。如果您想以这种方式使数据可用,此选项可能适合您的需求。再次提醒,OrderByOrderByDescending操作不接受此选项。这是大多数 PLINQ 操作默认的选项,并且在大多数情况下是最快的。AutoBuffered选项允许 PLINQ 根据当前系统条件灵活地根据需要缓冲项目。

ParallelMergeOptions.FullyBuffered

FullyBuffered选项不会在所有结果都被查询处理和缓冲之前提供任何结果。该选项将花费最长时间来提供第一个项目,但很多时候,它提供整个数据集的速度最快。

ParallelMergeOptions.Default

还有ParallelMergeOptions.Default值,它将表现得与根本未调用WithMergeOptions一样。你应该根据数据需要如何被消费来选择合并选项。如果你没有严格的要求,通常最好不设置合并选项。

WithMergeOptions 的实际应用

让我们创建使用每个合并选项以及没有任何合并选项设置的Person查询的示例:

  1. 首先,向之前创建的控制台应用程序项目中添加一个MergeSamples类。首先,添加以下三个方法来测试合并的类型:

    internal IEnumerable<Person> 
        GetImportantChildrenNoMergeSpecified(List<Person> 
            people)
    {
        return people.AsParallel()
            .Where(p => p.IsImportant && p.Age < 18)
                .Take(3);
    }
    internal IEnumerable<Person> GetImportantChildren
        DefaultMerge(List<Person> people)
    {
        return people.AsParallel().WithMergeOptions
             (ParallelMergeOptions.Default)
                 .Where(p => p.IsImportant && p.Age < 
                     18).Take(3);
    }
    internal IEnumerable<Person> GetImportant
        ChildrenAutoBuffered(List<Person> people)
    {
        return people.AsParallel().WithMergeOptions
           (ParallelMergeOptions.AutoBuffered).Where(p => 
               p.IsImportant && p.Age < 18).Take(3);
    }
    
  2. 接下来,向MergeSamples类中添加以下两个方法:

    internal IEnumerable<Person> GetImportant
        ChildrenNotBuffered(List<Person> people)
    {
        return people.AsParallel().WithMergeOptions
            (ParallelMergeOptions.NotBuffered)
                .Where(p => p.IsImportant && p.Age < 
                    18).Take(3);
    }
    internal IEnumerable<Person> GetImportantChildren
        FullyBuffered(List<Person> people)
    {
        return people.AsParallel().WithMergeOptions
           (ParallelMergeOptions.FullyBuffered).Where(p => 
               p.IsImportant && p.Age < 18).Take(3);
    }
    

最后两个步骤中的每个方法都执行一个 PLINQ 查询,该查询筛选IsImportant等于trueAge小于18。然后执行Take(3)操作,只返回查询中的前三个项目。

  1. Program.cs中添加代码以调用每个方法,并在每次调用之前输出时间戳,以及在最后输出一个最终时间戳。这与我们在上一节中调用方法以测试排序时使用的过程相同:

    using LINQandPLINQsnippets;
    var timeFmt = "hh:mm:ss.fff tt";
    var mergeExample = new MergeSamples();
    Console.WriteLine($"Start time: {DateTime.Now.ToString
        (timeFmt)}. NoMerge children:");
    OutputListToConsole(mergeExample.GetImportantChildrenN
        oMergeSpecified(GetYoungPeople()).ToList());
    Console.WriteLine($"Start time: 
        {DateTime.Now.ToString(timeFmt)}. DefaultMerge 
            children:");
    OutputListToConsole(mergeExample.GetImportantChildren
        DefaultMerge(GetYoungPeople()).ToList());
    Console.WriteLine($"Start time: {DateTime.Now.ToString
        (timeFmt)}. AutoBuffered children:");
    OutputListToConsole(mergeExample.GetImportantChildren
       AutoBuffered(GetYoungPeople()).ToList());
    Console.WriteLine($"Start time: 
        {DateTime.Now.ToString(timeFmt)}. NotBuffered 
            children:");
    OutputListToConsole(mergeExample.GetImportantChildren
        NotBuffered(GetYoungPeople()).ToList());
    Console.WriteLine($"Start time: 
        {DateTime.Now.ToString(timeFmt)}. FullyBuffered 
            children:");
    OutputListToConsole(mergeExample.GetImportantChildren
        FullyBuffered(GetYoungPeople()).ToList());
    Console.WriteLine($"Finish time: {
            DateTime.Now.ToString(timeFmt)}");
    Console.ReadLine();
    
  2. 现在,运行程序并检查输出:

图 8.4 – 查看 PLINQ 合并选项方法的输出

图 8.4 – 查看 PLINQ 合并选项方法的输出

没有指定合并选项的第一个选项运行时间最长,但通常,第一次运行 PLINQ 查询会比后续执行慢。其余的查询都非常快。你应该在自己的数据库中的一些大型数据集上测试这些查询,看看不同 PLINQ 运算符和不同合并选项的时间差异。你甚至可以测量每个项目输出之间的时间,看看NotBufferedFullyBuffered返回第一个项目有多快。

在我们回顾本章所学内容之前,让我们讨论一些补充并行编程和 PLINQ 查询的.NET 对象和数据结构。

.NET 中的并行编程数据结构

在.NET 中处理并行编程和 PLINQ 时,你应该利用.NET 提供的数据结构、类型和原语。在本节中,我们将简要介绍并发集合和同步原语

并发集合

并行集合在处理并行编程时非常有用。我们将在第九章中详细讨论它们,但让我们快速讨论一下如何在处理 PLINQ 查询时利用它们。

如果你只是使用 PLINQ 选择和排序数据,那么没有必要承担System.Collections.Concurrent命名空间中集合增加的开销。然而,如果你调用带有ForAll的方法来修改源数据中的项,你应该使用这些当前集合之一,例如BlockingCollection<T>ConcurrentBag<T>ConcurrentDictionary<TKey, TValue>。它们还可以防止对集合的任何同时AddRemove操作。

同步原语

如果你无法将并发集合引入现有的代码库,另一种提供并发性和性能的选项是同步原语。我们在第一章中介绍了许多这些类型。这些位于System.Threading命名空间中的类型,包括BarrierCountdownEventSemaphoreSlimSpinLockSpinWait,提供了线程安全和性能的正确平衡。其他锁定机制,如lockMutex,可能更昂贵,从而造成更大的性能影响。

如果我们想使用SpinLock来保护使用ForAll的 PLINQ 查询,我们只需将方法包裹在try/finally块中,并在SpinLock上使用EnterExit调用。让我们以我们检查一个人是否有大于120岁的年龄的例子为例。让我们想象代码也修改了年龄:

private SpinLock _spinLock = new SpinLock();
internal void ProcessAdultsWhoVoteWithPlinq2(List<Person> 
    people)
{
    var adults = people.AsParallel().Where(p => p.Age > 17);
    adults.ForAll(ProcessVoterActions2);
}
private void ProcessVoterActions2(Person adult)
{
    var hasLock = false;
    if (adult.Age > 120)
    {
        try
        {
            _spinLock.Enter(hasLock);
            adult.Age = 120;
        }
        finally
        {
            if (hasLock) _spinLock.Exit();
        }
    }
}

要了解更多关于同步原语的信息,请查看 Microsoft Docs 中的这一部分:docs.microsoft.com/dotnet/standard/threading/overview-of-synchronization-primitives.

现在,让我们通过回顾本章关于并行编程和 PLINQ 的内容来结束本章。

摘要

在本章中,我们学习了 PLINQ 的强大功能,它可以将并行处理引入我们的 LINQ 查询。我们首先了解了 PLINQ 与标准 LINQ 查询的不同之处。接下来,我们探讨了如何通过转换一些标准 LINQ 查询将 PLINQ 引入现有代码。了解 PLINQ 如何影响应用程序的性能非常重要,我们在示例应用程序中检查了一些计时。(稍后,在第十章中,我们将讨论一些在本地测试时测试应用程序性能的工具。)我们介绍了你可以通过合并选项和数据排序对查询进行的一些优化。最后,我们简要介绍了其他.NET 数据结构和类型,以帮助为你的应用程序提供类型安全和性能。

在下一章中,我们将深入探讨System.Collections.Concurrent命名空间中的每个并发集合。并发集合对于确保在操作共享数据时并行和并发代码保持类型安全至关重要。

问题

  1. 哪个 PLINQ 方法表示查询应开始并行处理?

  2. 哪个 PLINQ 方法表示查询不应再并行处理?

  3. 哪个方法告诉 PLINQ 保留源数据的原始顺序?

  4. 哪个 PLINQ 方法会在查询中的每个项目上并行执行一个委托?

  5. AsOrdered() 对 PLINQ 查询的性能有何影响?

  6. 哪些 PLINQ 操作不能与 ParallelMergeOptions.NotBuffered 一起使用?

  7. PLINQ 是否总是比等效的 LINQ 查询更快?

  8. 如果你想在查询结果可用时流式传输回来,你会选择哪个 PLINQ 合并选项?

第九章:第九章:在 .NET 中使用并发集合

本章将深入探讨 System.Collections.Concurrent 命名空间中的一些内容。这些专用集合有助于在使用并发和并行性时保持数据完整性。本章的每一节都将提供使用 .NET 提供的特定并发集合的实用示例。

我们已经看到了 .NET 中并行数据结构的一些基本用法。我们已经在 第二章并发简介 部分介绍了每个并发集合的基础。因此,我们将快速进入本章中它们用法的示例,并进一步了解它们的应用和内部工作原理。

在本章中,我们将进行以下操作:

  • 使用 BlockingCollection

  • 使用 ConcurrentBag

  • 使用 ConcurrentDictionary

  • 使用 ConcurrentQueue

  • 使用 ConcurrentStack

到本章结束时,您将更深入地了解这些集合如何保护您的共享数据在多线程中不被错误处理。

技术要求

要跟随本章中的示例,以下软件是推荐给 Windows 开发者的:

  • Visual Studio 2022 版本 17.0 或更高版本。

  • .NET 6。

  • 要完成任何 WinForms 或 WPF 示例,您需要安装 Visual Studio 的 .NET 桌面开发工作负载。这些项目只能在 Windows 上运行。

虽然这些是推荐的,但如果您已安装 .NET 6,您可以使用您喜欢的编辑器。例如,macOS 10.13 或更高版本的 Visual Studio 2022 for Mac、JetBrains Rider 或 Visual Studio Code 都可以同样工作。

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter09

让我们从学习更多关于 BlockingCollection<T> 的知识开始,并浏览一个利用该集合的示例项目。

使用 BlockingCollection

BlockingCollection<T> 是最实用的并发集合之一。正如我们在 第七章 中所看到的,BlockingCollection<T> 是为了实现 .NET 的 生产者/消费者模式 而创建的。在创建不同类型的示例项目之前,让我们回顾一下这个集合的一些具体细节。

BlockingCollection 详细信息

对于使用并行代码实现的开发者来说,BlockingCollection<T> 的一个主要吸引力是它可以替换 List<T> 而不需要太多额外的修改。您可以使用 Add() 方法为两者。与 BlockingCollection<T> 的区别在于,如果另一个读取或写入操作正在进行中,调用 Add() 添加项目将阻塞当前线程。如果您想指定操作的超时时间,可以使用 TryAdd()TryAdd() 方法可选地支持超时和取消令牌。

BlockingCollection<T> 中使用 Take() 方法移除项目有一个等效的 TryTake() 方法,它允许定时操作和取消。Take()TryTake() 方法将获取并移除添加到集合中的第一个剩余项目。这是因为 BlockingCollection<T> 内部的默认底层集合类型是 ConcurrentQueue<T>。或者,您可以指定集合使用 ConcurrentStack<T>ConcurrentBag<T> 或任何实现 IProducerConsumerCollection<T> 接口的集合。以下是一个将 BlockingCollection<T> 初始化为使用 ConcurrentStack<T> 的示例,其容量限制为 100 项:

var itemCollection = new BlockingCollection<string>(new 
    ConcurrentStack<string>(), 100);

如果您的应用程序需要遍历 BlockingCollection<T> 中的项目,可以在 forforeach 循环中使用 GetConsumingEnumerable() 方法。然而,请注意,对集合的这种迭代也会移除项目,并且如果枚举继续到集合为空,它将完成集合。这是 GetConsumingEnumerable() 方法名称中的 消费 部分。

如果您需要使用多个相同类型的 BlockingCollection<T> 类,您可以通过将它们添加到一个数组中来将它们作为一个整体添加或移除。BlockingCollection<T> 的数组使 TryAddToAny()TryTakeFromAny() 方法可用。如果数组中的任何集合都处于适当的状态以接受或提供对象给调用代码,则这些方法将成功。Microsoft Docs 有一个如何在一个管道中使用 BlockingCollection<T> 数组的示例:https://docs.microsoft.com/dotnet/standard/collections/thread-safe/how-to-use-arrays-of-blockingcollections。

现在我们已经涵盖了理解 BlockingCollection<T> 所需的详细信息,让我们深入一个示例项目。

使用 BlockingCollection 与 Parallel.ForEach 和 PLINQ

我们已经在 第七章 中介绍了一个实现生产者/消费者模式的示例,所以在这个部分让我们尝试一些不同的内容。我们将创建一个 WPF 应用程序,从 1.5 MB 的文本文件中加载书籍内容,并搜索以特定字母开头的单词:

注意

此示例使用从最初基于 .NET Framework 4.0 构建的 Microsoft 扩展示例创建的 .NET Standard NuGet 包。该扩展名为 ParallelExtensionsExtras,原始源代码可在 GitHub 上找到:https://github.com/dotnet/samples/tree/main/csharp/parallel/ParallelExtensionsExtras。我们将从包中使用的扩展方法可以使 Parallel.ForEach 操作和 PLINQ 查询在并发集合上运行得更高效。要了解更多关于扩展的信息,你可以查看 .NET 并行编程 博客上的这篇文章:https://devblogs.microsoft.com/pfxteam/parallelextensionsextras-tour-4-blockingcollectionextensions/。

  1. 首先,在 Visual Studio 中创建一个新的 WPF 应用程序。将项目命名为 ParallelExtras.BlockingCollection

  2. 在 NuGet 包管理器页面,搜索并添加 ParallelExtensionsExtras.NetFxStandard 包的最新稳定版本到你的项目中:

图 9.1 – ParallelExtensionsExtras.NetFxStandard NuGet 包

图 9.1 – ParallelExtensionsExtras.NetFxStandard NuGet 包

  1. 我们将读取詹姆斯·乔伊斯所著的书籍《尤利西斯》中的文本。这本书在美国以及世界上的大多数国家都是公共领域的。你可以以 UTF-8 纯文本格式从 ulysses.txt 下载它,并将其放置在与你的其他项目文件相同的文件夹中。

  2. 在 Visual Studio 中,右键单击 ulysses.txt 并选择 属性。在 属性 窗口中,将 复制到输出目录 属性更新为 如果较新则复制

  3. 打开 Grid.RowDefinitionsGrid.ColumndefinitionsGrid 控制器,如下所示:

    <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition/>
        <ColumnDefinition/>
    </Grid.ColumnDefinitions>
    
  4. Grid.ColumnDefinitions 元素之后的 Grid 定义内添加 ComboBoxButton。这些控件将位于 Grid 的第一行:

    <ComboBox x:Name="LettersComboBox"
                Grid.Row="0" Grid.Column="0"
                Margin="4">
        <ComboBoxItem Content="A"/>
        <ComboBoxItem Content="D"/>
        <ComboBoxItem Content="F"/>
        <ComboBoxItem Content="G"/>
        <ComboBoxItem Content="M"/>
        <ComboBoxItem Content="O"/>
        <ComboBoxItem Content="A"/>
        <ComboBoxItem Content="T"/>
        <ComboBoxItem Content="W"/>
    </ComboBox>
    <Button Grid.Row="0" Grid.Column="1"
            Margin="4" Content="Load Words"
            Click="Button_Click"/>
    

ComboBox 将包含九个不同的字母,你可以从中选择。你可以添加你喜欢的这些字母中的任意多个。Button 包含一个 Click 事件处理程序,我们将在稍后的 MainWindow.xaml.cs 中添加它。

  1. 最后,将名为 WordsListViewListView 添加到 Grid 的第二行。它将跨越两个列:

    <ListView x:Name="WordsListView" Margin="4"
                Grid.Row="1" Grid.ColumnSpan="2"/>
    
  2. 现在,打开 MainWIndow.xaml.cs。在这里,我们首先将创建一个名为 LoadBookLinesFromFile() 的方法,该方法将从 ulysses.txt 读取每一行文本到 BlockingCollection<string> 中。由于只有一个线程从文件中读取,因此使用 Add() 方法而不是 TryAdd() 是最好的选择:

    private async Task<BlockingCollection<string>> 
        LoadBookLinesFromFile()
    {
        var lines = new BlockingCollection<string>();
        using var reader = File.OpenText(Path.Combine(
            Path.GetDirectoryName(Assembly
                .GetExecutingAssembly().Location), 
                    "ulysses.txt"));
        string line;
        while ((line = await reader.ReadLineAsync()) != 
            null)
        {
            lines.Add(line);
        }
        lines.CompleteAdding();
        return lines;
    }
    

注意

记住,在方法结束前调用 lines.CompleteAdding() 非常重要。否则,后续对这个集合的查询将会挂起,并继续等待更多项目被添加到流中。

  1. 现在,创建一个名为 GetWords() 的方法,该方法接受文本文件的行并使用 BlockingCollection<string>。在此方法中,我们使用 Parallel.ForEach 循环同时解析多行。GetConsumingPartitioner() 扩展方法告诉 Parallel.ForEach 循环 BlockingCollection 将执行自己的阻塞,因此循环不需要执行任何操作。这使得整个过程更加高效:

    private BlockingCollection<string> 
        GetWords(BlockingCollection<string> lines)
    {
        var words = new BlockingCollection<string>();
        Parallel.ForEach(lines.GetConsumingPartitioner(), 
            (line) =>
        {
            var matches = Regex.Matches(line, 
                @"\b[\w']*\b");
            foreach (var m in matches.Cast<Match>())
            {
                if (!string.IsNullOrEmpty(m.Value))
                {
                    words.TryAdd(TrimSuffix(m.Value, 
                        '\''));
                }
            }
        });
        words.CompleteAdding();
        return words;
    }
    private string TrimSuffix(string word, char 
        charToTrim)
    {
        int charLocation = word.IndexOf(charToTrim);
        if (charLocation != -1)
        {
            word = word[..charLocation];
        }
        return word;
    }
    

TrimSuffix() 方法将从单词的末尾删除特定字符;在这种情况下,我们传递要删除的撇号字符。

注意

如果你对正则表达式不熟悉,可以在 Microsoft Docs 上阅读有关如何在 .NET 中使用它们的说明:docs.microsoft.com/dotnet/standard/base-types/regular-expressions。它们是解析文本的极其高效的方法。

  1. 接下来,创建一个名为 GetWordsByLetter() 的方法来调用我们刚刚创建的其他方法。一旦获取到包含书中所有单词的 BlockingCollection<string>,此方法将使用 PLINQ 和 GetConsumingPartitioner() 来查找所有以所选字母的大写或小写版本开头的单词:

    private async Task<List<string>> GetWordsByLetter(char 
        letter)
    {
        BlockingCollection<string> lines = await 
            LoadBookLinesFromFile();
        BlockingCollection<string> words = 
            GetWords(lines);
        // 275,506 words in total
        return words.GetConsumingPartitioner()
            .AsParallel()
            .Where(w => w.StartsWith(letter) || 
                w.StartsWith(char.ToLower(letter)))
            .ToList();
    }
    
  2. 最后,我们将添加 Button_Click 事件来启动加载、解析和查询书籍文本。不要忘记将事件处理程序标记为 async

    private async void Button_Click(object sender, 
        RoutedEventArgs e)
    {
        if (LettersComboBox.SelectedIndex < 0)
        {
            MessageBox.Show("Please select a letter.");
            return;
        }
        WordsListView.ItemsSource = await 
            GetWordsByLetter(
            char.Parse(GetComboBoxValue(LettersComboBox
                .SelectedValue)));
    }
    private string GetComboBoxValue(object item)
    {
        var comboxItem = item as ComboBoxItem;
        return comboxItem.Content.ToString();
    }
    

GetComboBoxValue() 辅助方法将获取 LettersComboBox.SelectedValue 中的对象,并找到包含所选字母的 string

  1. MainWindow.xaml.cs 中需要以下 using 声明才能编译和运行项目:

    using System.Collections.Concurrent;
    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Reflection;
    using System.Text.RegularExpressions;
    using System.Threading.Tasks;
    using System.Windows;
    using System.Windows.Controls;
    
  2. 现在,运行项目,选择一个字母,然后点击 加载单词

图 9.2 – 从 ulysses.txt 显示以 T 开头的单词

图 9.2 – 从 ulysses.txt 显示以 T 开头的单词

考虑到书中包含超过 275,000 个总单词,整个过程运行得非常快。尝试向 PLINQ 查询添加一些排序,看看性能如何受到影响。

让我们继续学习 ConcurrentBag<T>

使用 ConcurrentBag

ConcurrentBag<T> 是一个无序的对象集合,可以安全地并发添加、查看或删除。请注意,与所有并发集合一样,ConcurrentBag<T> 提供的方法是线程安全的,但任何扩展方法都不保证是安全的。始终在利用它们时实现自己的同步。要查看安全方法的列表,您可以查看此 Microsoft Docs 页面:https://docs.microsoft.com/dotnet/api/system.collections.concurrent.concurrentbag-1#methods。

我们将创建一个示例应用程序,模拟与对象池一起工作。如果你有一些利用内存密集型状态对象的处理,这种场景可能很有用。你希望最小化创建的对象数量,但不能在之前的迭代完成并返回到池中之前重用它们。

在我们的例子中,我们将使用一个假设为内存密集型的模拟 PDF 处理类。实际上,文档处理库可能相当庞大,并且它们通常依赖于每个实例中的文档状态。控制台应用程序将并行迭代 15 次来创建这些假 PDF 对象,并将一些文本附加到每个对象上。每次通过循环时,我们将输出文本内容和池中 PDF 处理器的当前计数。如果当前计数保持较低,则应用程序按预期工作:

  1. 首先在 Visual Studio 中创建一个新的.NET 控制台应用程序,命名为ConcurrentBag.PdfProcessor

  2. 添加一个新的类来表示模拟的 PDF 数据。将这个类命名为ImposterPdfData

    public class ImposterPdfData
    {
        private string _plainText;
        private byte[] _data;
        public ImposterPdfData(string plainText)
        {
            _plainText = plainText;
            _data = System.Text.Encoding.ASCII.GetBytes
                (plainText);
        }
        public string PlainText => _plainText;
        public byte[] PdfData => _data;
    }
    

我们正在存储纯文本及其 ASCII 编码版本,我们假装它是 PDF 格式。这避免了在我们的示例应用程序中实现任何第三方库。如果你熟悉任何 PDF 库,你欢迎将此示例修改为使用它们。

  1. 接下来,添加一个名为PdfParser的新类。这个类将是从ConcurrentBag<PdfParser>中取出并返回的类。我们将在接下来的步骤中创建该集合的宿主:

    public class PdfParser
    {
        private ImposterPdfData? _pdf;
        public void SetPdf(ImposterPdfData pdf) => 
            _pdf = pdf;
        public ImposterPdfData? GetPdf() => _pdf;
        public string GetPdfAsString()
        {
            if (_pdf != null)
                return _pdf.PlainText;
            else
                return "";
        }
        public byte[] GetPdfBytes()
        {
            if (_pdf != null)
                return _pdf.PdfData;
            else
                return new byte[0];
        }
    }
    

此状态类持有ImposterPdfData对象的一个实例,并且可以返回数据作为一个字符串或 ASCII 编码的字节数组。

  1. PdfParser添加一个名为AppendString的方法。此方法将在ImposterPdfData的新行上添加一些附加文本:

    public void AppendString(string data)
    {
        string newData;
        if (_pdf == null)
        {
            newData = data;
        }
        else
        {
            newData = _pdf.PlainText + Environment.NewLine 
                + data;
        }
        _pdf = new ImposterPdfData(newData);
    }
    
  2. 现在,添加一个名为PdfWorkerPool的类:

    public class PdfWorkerPool
    {
        private ConcurrentBag<PdfParser> _workerPool = 
            new();
        public PdfWorkerPool()
        {
            // Add initial worker
            _workerPool.Add(new PdfParser());
        }
        public PdfParser Get() => _workerPool.TryTake(out 
            var parser) ? parser : new PdfParser();
        public void Return(PdfParser parser) => 
            _workerPool.Add(parser);
        public int WorkerCount => _workerPool.Count();
    }
    

确保还将using System.Collections.Concurrent;语句添加到PdfWorkerPool.cs中。池存储名为_workerPoolConcurrentBag<PdfParser>。当PdfWorkerPool初始化时,它将向_workerPool添加一个新的实例。Get方法将返回池中的一个现有实例,如果存在的话,使用TryTake。如果池为空,则创建一个新的实例并返回给调用者。Return方法在消费者完成时将PdfParser添加回池中。我们将使用WorkerCount属性来跟踪任何时间点池中的对象数量。

  1. 最后,用以下代码替换Program.cs的内容:

    using ConcurrentBag.PdfProcessor;
    Console.WriteLine("Hello, ConcurrentBag!");
    var pool = new PdfWorkerPool();
    Parallel.For(0, 15, async (i) =>
    {
        var parser = pool.Get();
        var data = new ImposterPdfData($"Data index: {i}");
        try
        {
            parser.SetPdf(data);
            parser.AppendString(DateTime.UtcNow
                .ToShortDateString());
            Console.WriteLine($"
               {parser.GetPdfAsString()}");
            Console.WriteLine($"Parser count: 
                {pool.WorkerCount}");
            await Task.Delay(100);
        }
        finally
        {
            pool.Return(parser);
            await Task.Delay(250);
        }
    });
    Console.WriteLine("Press the Enter key to exit.");
    Console.ReadLine();
    

创建一个新的PdfWorkerPool后,我们使用Parallel.For循环迭代 15 次。每次通过循环时,我们获取PdfParser,设置文本,附加DateTime.UtcNow,并将内容写入控制台,同时附带池中解析器的当前计数。

  1. 运行应用程序并检查输出:

图 9.3 – 运行 PdfProcessor 控制台应用程序

图 9.3 – 运行 PdfProcessor 控制台应用程序

在我的情况下,解析计数达到了七个的最大值。如果你调整 Task.Delay 间隔或完全删除它们,你可能会看到计数永远不会超过一个。这种池可以配置得非常高效。

在这个应用程序中,我们不在乎集合的哪个实例被返回,所以 ConcurrentBag<T> 是一个完美的选择。在下一节中,我们将创建一个使用 ConcurrentDictionary<TKey, TValue> 的药物查找示例。

使用 ConcurrentDictionary

在本节中,我们将创建一个 WinForms 应用程序来加载美国的 ConcurrentDictionary,我们可以使用 product.txt 文件进行快速查找,并将大约一半的记录移动到 product2.txt 文件中,在第二个文件中重复标题行。您可以在 GitHub 仓库中找到这些文件,网址为 github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter09/FdaNdcDrugLookup

  1. 首先,在 Visual Studio 中创建一个新的 WinForms 项目,目标为 .NET 6。将项目命名为 FdaNdcDrugLookup

  2. 打开 Form1.cs 的 WinForm 设计器。布局两个 TextBox 控件、两个 Button 控件和 Label

图 9.4 – Form1.cs 的布局

图 9.4 – Form1.cs 的布局

btnLoadtxtNdcbtnLookuptxtDrugName只读True

  1. 接下来,通过在 解决方案资源管理器 中右键单击项目并选择 添加 | 现有项,将 product.txtproduct2.txt 文件添加到您的项目中。

  2. 属性 面板中,将两个我们刚刚添加的文本文件的 复制到输出目录 更改为 如果较新则复制

  3. 向项目中添加一个名为 Drug 的新类,并添加以下实现:

    public class Drug
    {
        public string? Id { get; set; }
        public string? Ndc { get; set; }
        public string? TypeName { get; set; }
        public string? ProprietaryName { get; set; }
        public string? NonProprietaryName { get; set; }
        public string? DosageForm { get; set; }
        public string? Route { get; set; }
        public string? SubstanceName { get; set; }
    }
    

这将包含从 NDC 药物文件加载的每个记录的数据。

  1. 接下来,向项目中添加一个名为 DrugService 的类,并从以下实现开始。首先,我们只有 private ConcurrentDictionary<string, Drug>。我们将在下一步添加一个方法来加载数据:

    using System.Collections.Concurrent;
    using System.Data;
    using System.Reflection;
    namespace FdaNdcDrugLookup
    {
        public class DrugService
        {
            private ConcurrentDictionary<string, Drug> 
                _drugData = new();
        }
    }
    
  2. 接下来,向 DrugService 添加一个名为 LoadData 的公共方法:

    public void LoadData(string fileName)
    {
        using DataTable dt = new();
        using StreamReader sr = new(Path.Combine(
            Path.GetDirectoryName(Assembly
                .GetExecutingAssembly().Location), 
                   fileName));
        var del = new char[] { '\t' };
        string[] colheaders = sr.ReadLine().Split(del);
        foreach (string header in colheaders)
        {
            dt.Columns.Add(header); // add headers
        }
        while (sr.Peek() > 0)
        {
            DataRow dr = dt.NewRow(); // add rows
            dr.ItemArray = sr.ReadLine().Split(del);
            dt.Rows.Add(dr);
        }
        foreach (DataRow row in dt.Rows)
        {
            Drug drug = new(); // map to Drug object
            foreach (DataColumn column in dt.Columns)
            {
                switch (column.ColumnName)
                {
                    case "PRODUCTID":
                        drug.Id = row[column].ToString();
                        break;
                    case "PRODUCTNDC":
                        drug.Ndc = row[column].ToString();
                        break;
    ...
    // REMAINING CASE STATEMENTS IN GITHUB
                }
            }
            _drugData.TryAdd(drug.Ndc, drug);
        }
    }
    

注意

https://github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter09/FdaNdcDrugLookup.

在此方法中,我们从提供的 fileName 加载数据到 StreamReader,将列标题添加到 DataTable,从文件中填充其行,然后遍历 DataTable 的行和列来创建 Drug 对象。每个 Drug 对象都通过调用 TryAdd 添加到 ConcurrentDictionary 中,使用 Ndc 属性作为键。

  1. 现在,向 DrugService 添加一个 GetDrugByNdc 方法以完成类。此方法将返回提供的 ndcCode 对应的 Drug,如果找到的话:

    public Drug GetDrugByNdc(string ndcCode)
    {
        bool result = _drugData.TryGetValue(ndcCode, out 
            var drug);
        if (result && drug != null)
            return drug;
        else
            return new Drug();
    }
    
  2. 打开 Form1.cs 的代码,为 DrugService 添加一个私有变量:

    private DrugService _drugService = new();
    
  3. 打开 Form1.cs 的设计器,双击 btnLoad_Click 事件处理程序。添加以下实现。请注意,我们创建了一个 async 事件处理程序,以便我们可以使用 await 关键字:

    private async void btnLoad_Click(object sender, 
        EventArgs e)
    {
        var t1 = Task.Run(() => _drugService.LoadData
            ("product.txt"));
        var t2 = Task.Run(() => _drugService.LoadData
            ("product2.txt"));
        await Task.WhenAll(t1, t2);
        btnLookup.Enabled = true;
        btnLoad.Enabled = false;
    }
    

为了加载两个文本文件,我们在使用 Task.WhenAll 等待它们之前创建了两个并行运行的任务。然后,我们可以安全地启用 btnLookup 按钮并禁用 btnLoad 按钮以防止第二次加载。

  1. 接下来,切换回 Form1.cs 的设计视图,双击 btnLookup_Click 事件处理程序。向该处理程序添加以下实现以根据在 UI 中输入的 NDC 代码查找药物名称:

    private void btnLookup_Click(object sender, 
        EventArgs e)
    {
        if (!string.IsNullOrWhiteSpace(txtNdc.Text))
        {
            var drug = _drugService.GetDrugByNdc
               (txtNdc.Text);
            txtDrugName.Text = drug.ProprietaryName;
        }
    }
    
  2. 现在,运行应用程序并点击 70518-1120 NDC 代码。点击 查找药物

图 9.5 – 通过 NDC 代码查找药物 Prednisone

图 9.5 – 通过 NDC 代码查找药物 Prednisone

  1. 尝试一些其他的 NDC 代码,看看每条记录加载得有多快。这里有一些从每个文件中随机选取的 NDC 代码。如果它们都成功,你就知道两个文件已经并行成功加载:0002-08000002-411243063-825,和 51662-1544

就这些!你现在有了自己的快速且简单的药物查找应用程序。尝试将药物名称 TextBox 替换为 DataGrid 来显示整个 Drug 记录。

在下一节中,我们将处理 ConcurrentQueue<T> 中的客户订单。

使用 ConcurrentQueue

在本节中,我们将创建一个示例项目,这是一个现实场景的简化版本。我们将使用 ConcurrentQueue<T> 创建一个订单排队系统。这个应用程序将是一个控制台应用程序,并行为两位客户排队订单。我们将为每位客户创建五个订单,并且为了混合队列的顺序,每位客户的排队过程将在调用 Enqueue 之间使用不同的 Task.Delay。最终的输出应显示为第一位客户和第二位客户退出的订单混合。请记住,ConcurrentQueue<T> 采用 先进先出 (FIFO) 逻辑:

  1. 让我们从打开 Visual Studio 并创建一个名为 ConcurrentOrderQueue 的 .NET 控制台应用程序开始。

  2. 向项目中添加一个名为 Order 的新类:

    public class Order
    {
        public int Id { get; set; }
        public string? ItemName { get; set; }
        public int ItemQty { get; set; }
        public int CustomerId { get; set; }
        public decimal OrderTotal { get; set; }
    }
    
  3. 现在,创建一个名为 OrderService 的新类,其中包含一个名为 _orderQueue 的私有 ConcurrentQueue<Order>。这个类是我们将在此处为两位客户排队和退队订单的地方:

    using System.Collections.Concurrent;
    namespace ConcurrentOrderQueue
    {
        public class OrderService
        {
            private ConcurrentQueue<Order> _orderQueue = 
                new();
        }
    }
    
  4. 让我们从实现 DequeueOrders 方法开始。在这个方法中,我们将使用一个 while 循环来调用 TryDequeue,直到集合为空,将每个订单添加到 List<Order> 中以返回给调用者:

    public List<Order> DequeueOrders()
    {
        List<Order> orders = new();
        while (_orderQueue.TryDequeue(out var order))
        {
            orders.Add(order);
        }
        return orders;
    }
    
  5. 现在,我们将创建公共和私有 EnqueueOrders 方法。公共的无参数方法将调用私有方法两次,一次为每个 customerId。这两个调用将并行进行,然后通过 Task.WhenAll 调用来等待它们:

    public async Task EnqueueOrders()
    {
        var t1 = EnqueueOrders(1);
        var t2 = EnqueueOrders(2);
        await Task.WhenAll(t1, t2);
    }
    private async Task EnqueueOrders(int customerId)
    {
        for (int i = 1; i < 6; i++)
        {
            var order = new Order
            {
                Id = i * customerId,
                CustomerId = customerId,
                ItemName = "Widget for customer " + 
                    customerId,
                ItemQty = 20 - (i * customerId)
            };
            order.OrderTotal = order.ItemQty * 5;
            _orderQueue.Enqueue(order);
            await Task.Delay(100 * customerId);
        }
    }
    

私有方法EnqueueOrders迭代五次,为给定的customerId创建和Enqueue订单。这也用于改变ItemNameItemQtyTask.Delay的持续时间。

  1. 最后,打开Program.cs并添加以下代码以入队和出队订单,并将结果列表输出到控制台:

    using ConcurrentOrderQueue;
    Console.WriteLine("Hello, World!");
    var service = new OrderService();
    await service.EnqueueOrders();
    var orders = service.DequeueOrders();
    foreach(var order in orders)
    {
        Console.WriteLine(order.ItemName);
    }
    
  2. 运行程序并查看输出中的订单列表。你的结果是如何的?

图 9.6 – 查看订单队列的输出

图 9.6 – 查看订单队列的输出

尝试改变延迟因子或更改EnqueueOrders方法中的customerId,以查看输出顺序如何变化。

接下来,在本章的最后部分,我们将对ConcurrentStack<T>集合进行快速实验。

使用 ConcurrentStack

在本节中,我们将对BlockingCollection<T>ConcurrentStack<T>进行实验。在本章的第一个例子中,我们使用了BlockingCollection<T>来读取从书《尤利西斯》中开始的特定字母的单词。我们将复制该项目并更改读取文本行代码,使其在BlockingCollection<T>内部使用ConcurrentStack<T>。这将使行以相反的顺序输出,因为栈使用后进先出LIFO)逻辑。让我们开始吧!

  1. 从本章复制ParallelExtras.BlockingCollection项目,或者如果你更喜欢,修改现有的项目。

  2. 打开MainWindow.xaml.cs并修改LoadBookLinesFromFile方法,将其传递给BlockingCollection<string>构造函数的新ConcurrentStack<string>

    private async Task<BlockingCollection<string>> 
        LoadBookLinesFromFile()
    {
        var lines = new BlockingCollection<string>(new 
            ConcurrentStack<string>());
        ...
        return lines;
    }
    

注意,前面的方法被截断以强调修改后的代码。在 GitHub 上查看完整方法:github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter09/ParallelExtras.ConcurrentStack

  1. 现在,当你运行应用程序并搜索与之前相同的字母(在我们的例子中是T)时,你将在列表的开头看到一组不同的单词:

图 9.7 – 在《尤利西斯》中搜索以 T 开头的单词

图 9.7 – 在《尤利西斯》中搜索以 T 开头的单词

如果你滚动到列表的底部,你应该能看到从本书开头开始的单词。注意,列表并没有完全反转,因为我们没有在解析每一行的单词时使用ConcurrentStack<string>。你可以自己尝试这个实验作为另一个实验。

这就结束了我们对.NET 并发集合的探索。让我们总结一下本章学到的内容。

摘要

在本章中,我们深入探讨了 System.Collections.Concurrent 命名空间中的五个集合。我们在本章创建了五个示例应用程序,以获得对 .NET 6 中可用的每个并发集合类型的实际操作经验。通过 WPF、WinForms 和 .NET 控制台应用程序项目的混合,我们研究了在您自己的应用程序中利用这些集合的一些实际方法。

在下一章中,我们将探讨 Visual Studio 为多线程开发和调试提供的丰富工具集。我们还将讨论一些分析和改进并行 .NET 代码性能的技术。

问题

  1. 哪个并发集合可以在底层实现不同类型的集合?

  2. 问题 1 中的集合默认实现了哪种内部集合类型?

  3. 哪种集合类型经常被用作生产者/消费者模式的实现?

  4. 哪个并发集合包含键/值对?

  5. 用于向 ConcurrentQueue<T> 添加值的哪种方法?

  6. 用于在 ConcurrentDictionary 中添加和获取项的哪种方法?

  7. 扩展方法与并发集合一起使用时是否线程安全?

第三部分:高级并发概念

在本部分的章节中,你将获得调试和测试你的并行和并发代码所需的高级技能。你还将获得一些关于安全取消异步工作的实用建议。

本部分包含以下章节:

  • 第十章使用 Visual Studio 调试多线程应用程序

  • 第十一章取消异步工作

  • 第十二章单元测试异步、并发和并行代码

第十章:第十章:使用 Visual Studio 调试多线程应用程序

Visual Studio 2022 是 Mac 和 Windows 上最新的 Visual Studio 版本。在本章中,我们将学习如何利用 Visual Studio 的强大功能来调试多线程.NET 应用程序。

Visual Studio 为需要调试并行和并发.NET 应用程序的开发人员提供了几个极其有用的工具。本章将通过具体的示例详细探讨这些工具。

在本章中,我们将涵盖以下主题:

  • 介绍多线程调试

  • 调试线程和进程

  • 切换和标记线程

  • 调试并行应用程序

到本章结束时,您将拥有调试并行和并发 C#代码中线程问题的工具和知识。

技术要求

要跟随本章中的示例,以下软件是推荐给 Windows 开发人员的:

  • Visual Studio 2022 版本 17.2 或更高版本

  • .NET 6

  • 要完成任何 WinForms 或 WPF 示例,您需要为 Visual Studio 安装.NET 桌面开发工作负载。这些项目只能在 Windows 上运行。

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter10

注意

本章中的概念和工具仅适用于 Windows 上的 Visual Studio。如果您在 Mac 上构建.NET 应用程序,JetBrains 的Rider IDE 提供了一些多线程调试工具——一个线程面板,一个用于查看选定线程的视图,以及一个并行堆栈面板。Visual Studio for Mac 目前还没有为调试多线程应用程序提供此类支持。您可以在 JetBrains Rider 的文档中了解更多关于多线程调试的信息:https://www.jetbrains.com/help/rider/Debugging_Multithreaded_Applications.xhtml。本章不涉及在 Mac 上的调试。

让我们从学习 Visual Studio 2022 的多线程调试基础知识开始。

介绍多线程调试

调试是每个.NET 开发人员技能集的关键组成部分。没有人能写出没有错误的代码,将多线程结构引入项目只会增加引入错误的机会。随着.NET 和 C#增加了对并行编程和并发的更多支持,Visual Studio 也增加了更多调试功能来支持这些结构。

今天,Visual Studio 为现代.NET 开发人员提供了以下多线程调试功能:

  • 线程:此窗口显示在调试过程中应用程序使用的线程列表。它还指示在代码中的断点处停止时哪个线程是活动的。

  • 并行堆栈:此窗口允许开发者在一个视图中可视化应用程序中每个线程的调用堆栈。在窗口中选择一个线程将显示所选线程在调用堆栈窗口中的调用堆栈信息。

  • 并行监视:此窗口的工作方式类似于监视窗口,不同之处在于您可以看到应用程序中每个活动线程上监视表达式的值。

  • 调试位置:此工具栏允许你在调试多线程应用程序时缩小关注范围。它有字段用于选择进程线程堆栈帧。工具栏上还有按钮,可以用来标记取消标记要监视的线程。

  • 任务:此窗口显示应用程序中每个正在运行的任务,并提供有关运行任务的线程、任务状态及其调用堆栈的信息。您还可以看到每个任务的起始点(传递给要运行的任务的方法或委托)。

  • 附加到进程:此窗口允许您将 Visual Studio 调试器附加到本地机器或远程机器上的进程。远程调试在处理多线程 UI 应用程序时非常有用。它允许开发者在其机器上的处理器核心数量与系统上的不同时调试他们的应用程序。他们还可以附加到在运行其他进程的系统上运行的远程进程,这些进程将在生产环境中存在。

  • GPU 线程:此窗口显示在 GPU 上运行的线程信息。这用于 C++应用程序,超出了本书的范围。要了解更多信息,您可以阅读来自 Microsoft 的文档:docs.microsoft.com/visualstudio/debugger/how-to-use-the-gpu-threads-window

在接下来的章节中,我们将使用这些调试工具来逐步调试本书前几章项目中的多线程代码。让我们先了解线程附加到进程窗口以及调试位置工具栏。

调试线程和进程

在本节中,我们将调试BackgroundPingConsoleApp,见第一章。您可以使用您在第一章中完成的工程,见第一章,或者从本章的 GitHub 仓库中获取项目:github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter10。我们将调试应用程序,并在过程中发现调试位置工具栏和线程窗口的一些功能。

调试具有多个线程的项目

我们将要工作的项目是一个简单的项目,它创建一个后台线程来检查网络是否可用。

注意

本章中的示例将在 Visual Studio 的调试配置下运行。当你编译并运行一个 .NET 项目时,你可以选择运行调试发布构建。在调试时,你将想要选择调试模式,以便项目编译时包含符号调试信息。这不会包含在发布构建中。有关构建配置的更多信息,请参阅 Microsoft Docs:https://docs.microsoft.com/visualstudio/ide/understanding-build-configurations。

让我们从调试示例开始:

  1. 首先,在 C# 编辑器中打开Program.cs

  2. while循环内的Thread.Sleep(100)语句上设置断点。

  3. 选择视图 | 工具栏 | 调试位置以显示调试位置工具栏:

图 10.1 – Visual Studio 中的调试位置工具栏

图 10.1 – Visual Studio 中的调试位置工具栏

我们将在开始调试时使用此工具栏。在 Visual Studio 中没有活动调试会话时,所有字段都将禁用。

  1. 开始调试项目。当 Visual Studio 在你的断点处中断时,注意调试位置工具栏的状态:

图 10.2 – 使用调试位置工具栏进行调试

图 10.2 – 使用调试位置工具栏进行调试

工具栏提供了一些下拉控件,用于在作用域内选择进程线程堆栈帧属性。除非你使用附加到进程窗口显式调试多个进程,否则进程下拉菜单中只会包含一个进程。你还可以在 Visual Studio 中设置多个启动项目来实现这一点。

线程下拉菜单包含属于所选进程的所有线程。在此控件中选中的线程是我们创建的后台线程,因为断点是在该后台线程执行的代码中添加的。

堆栈帧下拉菜单包含当前线程调用堆栈中的帧列表。

线程下拉菜单的右侧有一个切换当前线程标记状态按钮。我们将在切换和标记线程部分学习标记线程。

  1. 接下来,选择调试 | 窗口 | 线程以打开线程窗口:

图 10.3 – 线程窗口激活时的调试

图 10.3 – 线程窗口激活时的调试

默认情况下,线程窗口将在左下角面板中打开,包含输出局部变量监视调试窗口。

  1. 最后,展开线程窗口,以便我们可以探索和讨论其功能:

图 10.4 – 仔细查看线程窗口

图 10.4 – 仔细查看线程窗口

探索线程窗口

线程窗口在小型窗口中提供了大量有用的信息。我们将从讨论列表中每个线程显示的数据开始:

  • 进程 ID:默认情况下,线程列表按进程 ID分组。此分组可以通过窗口工具栏中的下拉菜单进行控制。进程 ID分组还会显示其组中的线程数量。当处理大量线程时,这可能很有用。

  • ID:这是列表中每个线程的 ID

  • Thread.ManagedThreadId属性是每个线程的

  • 类别:这描述了线程的类型(主线程工作线程等)

  • Thread.Name属性是每个线程的。如果一个线程没有名称,则在此字段中显示无名称

  • 位置:此字段包含每个线程调用栈中的当前堆栈帧。您可以通过单击此字段中的下拉菜单来显示线程的完整调用栈。

一些额外的字段默认隐藏。您可以通过在线程窗口工具栏中选择按钮来隐藏或显示列。以下是要隐藏的列:

  • 优先级:此操作显示系统分配给线程的优先级

  • 亲和掩码:亲和掩码确定线程可以在哪些处理器上运行。这是由系统决定的

  • 挂起计数:此值由系统用于决定线程是否可以运行

  • 进程名称:这是线程所属进程的名称

  • 进程 ID:这是线程所属进程的 ID

  • 传输限定符:这标识了连接到调试器的机器。这对于远程调试很有用

现在,让我们回顾一下线程窗口中可用的工具栏项:

  • 搜索:此功能允许您搜索线程。如果您想使搜索结果包括所有调用栈信息,可以切换包括调用栈在搜索中按钮

  • 标记:使用此下拉按钮,您可以选择标记我的代码标记自定义模块选择

  • :此下拉菜单允许您根据不同的字段对线程进行分组。默认情况下,它们按进程 ID分组

  • :此操作将打开选择窗口,以便您自定义在线程窗口中显示的列

  • 展开/折叠调用栈:这两个按钮用于展开或折叠位置列中的调用栈

  • 展开/折叠组:这两个按钮用于展开或折叠线程分组

  • 冻结线程:此操作将窗口中所有选定的线程冻结

  • 解冻线程:此操作将窗口中所有选定的线程解冻

让我们在搜索字段中尝试使用Anon来找到包含我们的匿名方法的线程的调用栈:

图 10.5 – 在线程窗口中搜索

图 10.5 – 在线程窗口中搜索

现在,“线程”窗口应仅包含具有 Anon 部分突出显示为黄色 AnonymousMethodWorker Thread 行。

现在您对“线程”窗口有了些了解,让我们学习如何使用它来切换和标记线程。

切换和标记线程

当调试多线程应用程序时,“线程”窗口提供了很多功能。在前一节中,我们提到了一些这些功能。在本节中,我们将学习如何切换线程、标记线程以及冻结或解冻线程。让我们从在 BackgroundPingConsoleApp 项目中切换线程开始。

切换线程

您可以通过在 Console.ReadLine() 语句中的上下文菜单中切换上下文来切换到不同的线程。这是主线程等待用户在控制台中按下任何键的地方:

图 10.6 – 在 Visual Studio 调试器中切换线程

图 10.6 – 在 Visual Studio 调试器中切换线程

您可以看到,当调试具有六个或更多活动线程的并行操作时,这个功能可能非常有用。接下来,我们将学习如何使用“标记线程”功能来监视特定线程。

标记线程

在本节中,您将学习如何在“线程”窗口中缩小您的视野。通过仅标记我们关心的线程,我们可以减少窗口中的杂乱。以下是标记线程的方法:

  1. 如果您还没有调试 BackgroundPingConsoleApp 项目,请现在开始调试它并等待它停止在断点处。

  2. 当调试器在应用程序中暂停时,右键单击 Main Thread 行并选择“标记”。现在,该行的标记图标应变为橙色。

  3. 对包含 Worker Thread 和调用堆栈中的 AnonymousMethod 的行执行相同的操作

  4. 接下来,在窗口的工具栏中点击“仅显示标记的线程”按钮:

图 10.7 – 仅在“线程”窗口中显示标记的线程

图 10.7 – 仅在“线程”窗口中显示标记的线程

这使得仅跟踪对我们当前调试会话重要的线程变得更加简单。您还可以再次单击按钮来切换按钮关闭,并查看所有线程。您还可以在“并行监视”和“并行堆栈”窗口中标记线程。它们的标记状态将跨所有这些窗口和“调试位置”工具栏保持一致。

在我们的应用程序中,标记这两个线程有一个更简单的方法。这两个线程是应用程序代码的唯一两个部分。因此,我们可以使用工具栏中的“仅标记我的代码”按钮来标记它们。

  1. 取消选择“仅显示标记的线程”工具栏按钮

  2. 在窗口中右键单击一个标记的行,并选择“取消标记所有”

  3. 现在,在工具栏中点击“仅标记我的代码”。相同的两个线程将被再次标记:

图 10.8 – 仅标记属于我们代码的线程

图 10.8 – 仅标记属于我们代码的线程

这比在列表中逐个选择线程要容易得多。可能并不总是那么明显哪些线程是我们代码的一部分。在下一节中,我们将学习如何冻结线程。

冻结线程

SuspendThreadResumeThread Windows 函数中冻结或解冻线程。如果冻结的线程尚未执行任何代码,则它将永远不会启动,除非它被解冻。如果线程正在执行,则在 Visual Studio 中调用 Freeze 线程时它将暂停。

让我们尝试在我们的 BackgroundPingConsoleApp 项目中冻结和解冻工作线程,看看在调试器中会发生什么:

  1. 在运行应用程序之前,在 while (true)Console.ReadKey() 语句处添加新的断点。保留现有的 Thread.Sleep(100) 断点。

  2. 开始调试应用程序

  3. 当调试器在 while (true) 行上中断时,右键单击包含 AnonymousMethod 的工作线程并选择 Freeze

  4. 继续调试;它应该在 Console.ReadKey() 行上中断,而不是在 Thread.Sleep(100)。这是因为工作线程当前没有运行:

图 10.9 – 在线程窗口中冻结工作线程

图 10.9 – 在线程窗口中冻结工作线程

  1. 再次右键单击工作线程并选择 Thaw

  2. 现在,再次继续调试。Visual Studio 在匿名方法内的 Thread.Sleep(100) 行上中断。

这显示了在调试多线程应用程序时 Threads 窗口的函数可能非常有用。

现在我们已经学会了如何通过切换、冻结和标记线程来使用 Threads 窗口调试我们的多线程应用程序,让我们学习如何在调试时利用 Parallel StacksParallel Watch 窗口等附加功能。

调试并行应用程序

Visual Studio 为并行调试提供了几个窗口。当我们的应用程序中的 Task 对象。

我们将首先从 Parallel Stacks 窗口开始浏览这些功能。

使用 Parallel Stacks 窗口

Parallel Stacks 窗口提供了应用程序中线程或任务的视觉表示。这些是窗口中的两种不同视图。您可以通过在 View 下拉框中选择 ThreadsTasks 在它们之间切换。以下屏幕截图显示了在调试 BackgroundPingConsoleApp 项目时 Threads 视图的一个示例:

图 10.10 – 在线程视图中查看 Parallel Stacks 窗口

图 10.10 – 在线程视图中查看 Parallel Stacks 窗口

Parallel Stacks 窗口包含一个工具栏,从左到右有以下项目。您可以通过检查 Visual Studio 窗口中工具栏项的工具提示来跟随:

  • 搜索:此选项允许在线程窗口中可用的相同类型的搜索功能。它具有位于搜索字段右侧的查找上一个查找下一个按钮。

  • 视图:此下拉菜单在线程视图和任务视图之间切换。

  • 仅显示标记:此切换将隐藏任何未标记的线程。

  • 切换方法视图:此选项将切换到当前选定方法及其调用堆栈的视图。

  • 自动滚动到当前堆栈帧:在通过调试器逐步执行时,此选项将当前堆栈帧滚动到图中。此选项默认开启。

  • 切换缩放控制:此选项将隐藏或显示图表面上的缩放控制。此选项默认开启。

  • 反向布局:此选项将当前视图的布局进行镜像。

  • .png文件

要检查Task对象。让我们通过打开书中前一章的项目来与任务视图一起工作:

  1. 第五章打开您的TaskSamples项目,或者从本章源代码在 GitHub 上的此项目获取副本:github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter10

  2. 打开Examples.cs并在ProcessOrders方法的第一行设置断点。

  3. 开始调试。当调试器在断点上停止时,选择调试 | 窗口 | 并行堆栈

  4. 并行堆栈窗口中切换到任务视图:

图 10.11 – 任务视图中的并行堆栈窗口

图 10.11 – 任务视图中的并行堆栈窗口

尚未启动任何任务,因此这里没有太多可看的。有一个看起来准备开始分析一些异步工作的单个异步逻辑堆栈块。

  1. Tasks.WaitAll语句上设置断点并点击继续

注意

您可以通过在 Visual Studio 中右键单击要修改的断点并单击ThreadIdThreadName值来配置断点。这将确保当所需的线程(线程)执行该行代码时,调试器仅在当前断点上停止。要了解更多关于断点条件和筛选器的信息,请查看 Microsoft Docs 上的这篇文章:docs.microsoft.com/visualstudio/debugger/using-breakpoints#set-a-filter-condition

  1. 现在,再次检查并行堆栈窗口:

图 10.12 – 任务活动时的并行堆栈窗口

图 10.12 – 任务活动时的并行堆栈窗口

注意

如果是快速运行的方法,在它们仍在执行时捕捉这个窗口中的任务可能会很具挑战性。如果你有一个或多个Task对象尚未完成,你可能需要运行应用程序几次才能触碰到这个断点。

在这种情况下,Parallel Stacks窗口已捕获了一个正在运行的任务和另一个准备运行的任务的执行。与本章中我们进行的某些线程分析相比,这个Tasks视图有一些不同:

  • Tasks视图中只显示正在运行的任务

  • Tasks视图的堆栈尝试仅显示相关的调用堆栈信息。如果堆栈帧不相关,则可能从顶部和底部进行裁剪。如果你需要查看整个调用堆栈,请切换回Threads视图。

  • Tasks视图中为每个活动任务显示一个单独的块,即使它们被分配到同一个线程。

你可以悬停在任务调用堆栈中的一行上,以查看有关其线程和堆栈帧的更多信息:

图 12.13 – 查看调用堆栈帧的更多信息

图 12.13 – 查看调用堆栈帧的更多信息

如果你想要将Tasks视图旋转到特定的方法,你可以使用切换方法视图按钮:

  1. TaskSamples项目中启动一个新的调试会话

  2. PrepareOrders方法中的return orders语句上设置一个新的断点

  3. 点击PrepareOrders方法。

  4. 点击PrepareOrders方法以获取更多的调用堆栈和线程信息:

图 10.14 – 利用并行堆栈窗口的方法视图区域

图 10.14 – 利用并行堆栈窗口的方法视图区域

接下来,我们将学习如何通过使用并行监视窗口来查看不同线程中变量的状态。

使用并行监视窗口

Parallel Watch窗口类似于 Visual Studio 中的监视窗口,但它显示有关监视表达式的值在具有访问表达式中数据的线程中的额外信息。

在这个例子中,我们将修改TaskSamples项目中的Examples类以添加一个将可供多个线程使用的状态:

  1. 首先,向Examples类添加一个私有变量:

    private List<Order> _sharedOrders;
    
  2. ProcessOrders中添加一行代码以将订单分配给_sharedOrders

    private List<Order> PrepareOrders(List<Order> orders)
    {
        // TODO: Prepare orders here
        _sharedOrders = orders;
        return orders;
    }
    
  3. 保持上一个示例中的断点并开始调试。继续直到调试器在ProcessOrders内部的return orders语句上中断。

  4. 选择调试 | 窗口 | 并行监视 1以打开并行监视 1窗口。你可以打开多达四个并行监视窗口来分离你的监视表达式。

  5. _sharedOrders私有变量中:

图 10.15 – 在并行监视 1 窗口中添加监视表达式

图 10.15 – 在并行监视 1 窗口中添加监视表达式

窗口指示作用域内的_sharedOrders以及变量中订单的数量为0

  1. 线程窗口中右键单击主线程并选择切换到线程。在并行监视 1窗口中,任务不再在作用域内,因此标题标签已从任务更改为线程,并且主线程ID属性将显示:

图 10.16 – 在主线程上查看监视的变量

图 10.16 – 在主线程上查看监视的变量

  1. 最后,选择调试 | 窗口 | 任务以打开任务窗口:

图 10.17 – 在调试时查看任务窗口

图 10.17 – 在调试时查看任务窗口

任务窗口将显示调试会话中作用域内的任务信息。以下列在窗口中显示:

  • 标记:一个图标表示当前任务是否已被标记。您可以单击此字段来标记或取消标记任务。

  • ID:任务的 ID

  • Task.Status任务属性

  • 开始时间(秒):这表示任务在调试会话中开始的时间

  • 持续时间(秒):这表示任务运行了多长时间

  • 位置:这显示了任务在线程上的调用栈位置

  • 任务:任务开始的初始方法。在此字段中也会显示已传递的任何参数。

通过在窗口中右键单击并选择,可以显示几个其他隐藏字段:

图 10.18 – 在任务窗口中添加或删除列

图 10.18 – 在任务窗口中添加或删除列

你可以在任务窗口中对任务进行排序和分组,类似于线程窗口的工作方式。区别在于任务窗口没有工具栏。所有操作都通过右键单击上下文菜单执行。

在调试并行.NET 代码时,你可以使用的另一个工具是调试位置工具栏。如果它尚未在 Visual Studio 中显示,你可以通过转到视图 | 工具栏 | 调试位置来打开它。在调试过程中,工具栏功能会亮起:

图 10.19 – 在调试时查看调试位置工具栏

图 10.19 – 在调试时查看调试位置工具栏

从工具栏中,你可以选择活动的进程线程堆栈帧。切换当前所选线程的标记状态也很容易。

这完成了我们对.NET 并行程序员可用的调试窗口的游览。让我们通过回顾本章所学内容来结束。

摘要

在本章中,我们学习了多线程应用程序开发者可用的 Visual Studio 功能。我们首先通过Thread对象处理线程。

接下来,我们学习了如何在调试时切换、标记和冻结我们的线程。最后,我们查看了一些针对使用代码中的Task对象或async/await的开发者的高级调试工具。并行堆栈并行监视窗口将任务调试提升到了新的水平。最后,我们快速浏览了任务窗口和调试位置工具栏。

在下一章,第十一章,我们将深入探讨使用.NET 取消并发和并行工作的不同方法。

问题

  1. 你如何在 Visual Studio 中调试多个进程?

  2. 线程窗口中线程的默认分组是什么?

  3. 你如何向任务线程窗口添加更多列?

  4. 哪个调试窗口显示当前线程或任务的视觉表示?

  5. 你可以从并行堆栈窗口导出哪种文件格式?

  6. 你可以打开多少个并行监视窗口?

  7. 哪个 Visual Studio 工具栏提供有关你当前正在调试的进程和线程的信息?

  8. 你如何过滤线程窗口,只显示为你的代码创建的线程?

第十一章:第十一章:取消异步工作

在前面的章节中,我们查看了一些如何取消线程和任务的示例。本章将探讨更多使用 C# 和 .NET 取消并发和并行工作的方法。本章中的方法将提供使用回调、轮询和等待句柄取消后台操作的替代方法。您将通过一些实际场景深入了解如何使用各种方法安全地取消异步工作。

在本章中,您将学习以下主题:

  • 取消托管线程

  • 取消并行工作

  • 发现线程取消的模式

  • 处理多个取消源

到本章结束时,您将了解如何取消不同类型的异步和并行任务。

技术要求

要跟随本章中的示例,以下软件对 Windows 开发者来说是推荐的:

  • Visual Studio 2022 版本 17.2 或更高版本。

  • .NET 6.

  • 要完成任何 WinForms 或 WPF 示例,您需要为 Visual Studio 安装 .NET 桌面开发工作负载。这些项目只能在 Windows 上运行。

本章的所有代码示例都可以在 GitHub 上找到,网址为 github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter11

取消托管线程

在 .NET 中取消异步工作基于使用 CancellationTokenSource 对象来管理这些请求并包含一个令牌。如果您想使用相同的触发器取消多个操作,应将相同的令牌提供给所有要取消的线程。

CancellationTokenSource 实例有一个 Token 属性,用于访问 CancellationToken 属性并将它传递给一个或多个异步操作。取消请求只能从 CancellationTokenSource 对象发出。提供给其他操作的 CancellationToken 属性接收取消信号但不能启动取消操作。

CancellationTokenSource 实现了 IDisposable 接口,因此在释放您的托管资源时,请务必调用 Dispose 方法。如果对您的流程来说可行,使用 using 语句或块来自动处理令牌源会更受欢迎。

理解这一点很重要,取消并不是强制施加在监听代码上。接收取消请求的异步代码必须确定它是否可以当前取消其工作。它可能决定立即取消,在完成一些中间任务后取消,或者完成其工作并忽略请求。可能有合理的理由让一个例程忽略取消请求。可能工作几乎完成,或者当前状态取消会导致某些数据损坏。取消的决定必须在请求者和监听者之间是相互的。

让我们看看如何在 ThreadPool 线程上如何协同取消一些正在处理的后台线程工作示例:

  1. 在 Visual Studio 中,创建一个名为 CancelThreadsConsoleApp 的新 .NET 6 控制台应用程序。

  2. 在 Visual Studio 中创建一个名为 ManagedThreadsExample 的新类。

  3. ManagedThreadsExample 类中创建一个名为 ProcessText 的方法:

    public static void ProcessText(object? cancelToken)
    {
        var token = cancelToken as CancellationToken?;
        string text = "";
        for (int x = 0; x < 75000; x++)
        {
            if (token != null && token.Value
                .IsCancellationRequested)
            {
                Console.WriteLine($"Cancellation request 
                    received. String value: {text}");
                break;
            }
            text += x + " ";
            Thread.Sleep(500);
        }
    }
    

此方法将迭代变量 x 的值追加到 textstring 变量中,直到收到取消请求。有一个 Thread.Sleep(500) 语句,以便调用方法有足够的时间取消操作。

  1. 接下来,在 Program.cs 中创建一个名为 CancelThread 的方法:

    private static void CancelThread()
    {
        using CancellationTokenSource tokenSource = new();
        Console.WriteLine("Starting operation.");
        ThreadPool.QueueUserWorkItem(new 
            WaitCallback(ManagedThreadsExample
                .ProcessText), tokenSource.Token);
        Thread.Sleep(5000);
        Console.WriteLine("Requesting cancellation.");
        tokenSource.Cancel();
        Console.WriteLine("Cancellation requested.");
    }
    

此方法调用 ThreadPool.QueueUserWorkItemProcessText 方法排队到 ThreadPool 线程。该方法还从 tokenSource.Token 接收一个取消令牌。等待五秒后,调用 tokenSource.CancelProcessText 将收到取消请求。

注意到 tokenSource 是在 using 语句中创建的。这确保了当它超出作用域时,它将被正确地释放。

  1. Program.csMain 方法中添加对 CancelThread 的调用:

    static void Main(string[] args)
    {
        CancelThread();
        Console.ReadKey();
    }
    
  2. 最后,运行应用程序并观察控制台输出:

图 11.1 – 运行 CancelThreadsConsoleApp 项目

图 11.1 – 运行 CancelThreadsConsoleApp 项目

for 循环应该在收到取消请求之前有足够的时间执行 9 或 10 次。你的输出是如何匹配的?

现在我们已经覆盖了一些取消的基础知识,并使用了一个常见的取消令牌使用方法,让我们创建一些取消并行循环和 PLINQ 查询的示例。

取消并行工作

在本节中,我们将通过一些取消并行操作的示例来进行操作。有几个操作属于这个范畴。有静态并行操作,它们是 System.Threading.Tasks.Parallel 类的一部分,还有 PLINQ 操作。这两种类型都使用了一个 CancellationToken 属性,就像我们在上一节中使用的托管线程示例一样。然而,处理取消请求的方式略有不同。让我们通过一个示例来了解这些差异。

取消并行循环

在本节中,我们将创建一个示例,说明如何取消 Parallel.For 循环。相同的取消方法也用于 Parallel.ForEach 方法。执行以下步骤:

  1. 打开上一节中的 CancelThreadsConsoleApp 项目。

  2. ManagedThreadsExample 类中,创建一个新的 ProcessTextParallel 方法,其实现如下:

    public static void ProcessTextParallel(object? 
        cancelToken)
    {
        var token = cancelToken as CancellationToken?;
        if (token == null) return;
        string text = "";
        ParallelOptions options = new()
        {
            CancellationToken = token.Value,
            MaxDegreeOfParallelism = 
                Environment.ProcessorCount
        };
        try
        {
            Parallel.For(0, 75000, options, (x) =>
            {
                text += x + " ";
                Thread.Sleep(500);
            });
        }
        catch (OperationCanceledException e)
        {
            Console.WriteLine($"Text value: {text}. 
                {Environment.NewLine} Exception 
                    encountered: {e.Message}");
        }
    }
    

实际上,前面的代码与我们的上一个示例中的 ProcessText 方法做的是同样的事情。它将一个数值追加到 text 变量中,直到收到取消请求。让我们看看它们之间的区别:

  • 首先,我们将 token.Value 设置为 ParallelOptions 对象的 CancellationToken 属性。这些选项作为 Parallel.For 方法的第三个参数传递。

  • 第二个主要区别是我们通过捕获 OperationCanceledException 类型来处理取消请求。当我们的 Program.cs 中的其他代码请求取消时,将抛出此异常类型。

  1. 接下来,向 Program.cs 中添加一个名为 CancelParallelFor 的方法:

    private static void CancelParallelFor()
    {
        using CancellationTokenSource tokenSource = new();
        Console.WriteLine("Press a key to start, then 
            press 'x' to send cancellation.");
        Console.ReadKey();
        Task.Run(() =>
        {
            if (Console.ReadKey().KeyChar == 'x')
                tokenSource.Cancel();
            Console.WriteLine();
            Console.WriteLine("press a key");
        });
        ManagedThreadsExample.ProcessTextParallel
            (tokenSource.Token);
    }
    

在这个方法中,用户被指示按下一个键以启动操作,并在他们准备好取消操作时按下 X 键。处理从控制台接收 x KeyChar 并发送 Cancel 请求的代码在另一个线程上执行,以便保持当前线程可以调用 ProcessTextParallel

  1. 最后,更新 Main 方法以调用 CancelParallelFor 并注释掉对 CancelThread 的调用:

    static void Main(string[] args)
    {
        //CancelThread();
        CancelParallelFor();
        Console.ReadKey();
    }
    
  2. 现在运行项目。按照提示取消 Parallel.For 循环,并检查输出:

图 11.2 – 从控制台取消 Parallel.For 循环

图 11.2 – 从控制台取消 Parallel.For 循环

注意数字根本不是按顺序排列的。在这种情况下,看起来 Parallel.For 操作使用了两个不同的线程。第一个线程从 0 开始,而第二个线程在以 37500 开始的整数上操作。这是提供给方法参数的最大值 75000 的中点。

在下一节中,我们将简要介绍如何取消 PLINQ 查询。

取消 PLINQ 查询

取消 PLINQ 查询也可以通过捕获 OperationCanceledException 类型来实现。然而,与用于并行循环的 ParallelOptions 对象不同,您可以在查询中调用 WithCancellation

要了解如何取消 PLINQ 查询,让我们通过一个示例来操作:

  1. 通过向 ManagedThreadsExample 类中添加一个名为 ProcessNumsPlinq 的方法来开始这个示例:

    public static void ProcessNumsPlinq(object? 
        cancelToken)
    {
        int[] input = Enumerable.Range(1, 
            25000000).ToArray();
        var token = cancelToken as CancellationToken?;
        if (token == null) return;
        int[]? result = null;
        try
        {
            result =
                (from value in input.AsParallel()
                    .WithCancellation(token.Value)
                    where value % 7 == 0
                    orderby value
                    select value).ToArray();
        }
        catch (OperationCanceledException e)
        {
            Console.WriteLine($"Exception encountered: 
                {e.Message}");
        }
    }
    

此方法创建了一个包含 2500 万个整数的数组,并使用 PLINQ 查询来确定其中哪些可以被七整除。token.Value 被传递到查询中的 WithCancellation 操作。当取消请求抛出异常时,异常详细信息将被写入控制台。

  1. 接下来,向 Program.cs 中添加一个名为 CancelPlinq 的方法:

    private static void CancelPlinq()
    {
        using CancellationTokenSource tokenSource = new();
        Console.WriteLine("Press a key to start.");
        Console.ReadKey();
        Task.Run(() =>
        {
            Thread.Sleep(100);
            Console.WriteLine("Requesting cancel.");
            tokenSource.Cancel();
            Console.WriteLine("Cancel requested.");
        });
        ManagedThreadsExample.ProcessNumsPlinq
            (tokenSource.Token);
    }
    

这次,取消将在 100 毫秒后自动触发。

  1. 更新 Main 方法以调用 CancelPlinq,并运行应用程序:

图 11.3 – 在控制台应用程序中取消 PLINQ 操作

图 11.3 – 在控制台应用程序中取消 PLINQ 操作

与前面的示例不同,没有查询输出可以检查。您无法从 PLINQ 查询中获取部分输出。result 变量将为 null

在下一节中,我们将探讨一些不同的取消方法。

发现线程取消的模式

有不同的方法来监听来自线程或任务的取消请求。到目前为止,我们已经看到了通过处理 OperationCanceledException 类型或检查 IsCancellationRequested 的值来管理这些请求的示例。通常在循环内部检查 IsCancellationRequested 的模式被称为 ManualResetEventManualResetEventSlim

让我们先尝试另一个通过轮询处理取消请求的示例。

通过轮询取消

在本节中,我们将创建另一个示例,该示例使用轮询来取消后台任务。前面的轮询示例是在 ThreadPool 线程上的后台线程中运行的。此示例也将启动一个 ThreadPool 线程,但它将利用 Task.Run 来启动后台线程。我们将创建和处理一百万个 System.Drawing.Point 对象,找到 Point.X 值小于 50 的对象。用户可以选择通过按 X 键来取消处理:

  1. 首先创建一个名为 CancellationPatterns 的新 .NET 控制台应用程序项目

  2. 向项目中添加一个名为 PollingExample 的新类

  3. PollingExample 类中添加一个名为 GeneratePoints 的私有静态方法。这将生成我们所需的具有随机 X 值的 Point 对象数量:

    private static List<Point> GeneratePoints(int count)
    {
        var rand = new Random();
        var points = new List<Point>();
        for (int i = 0; i <= count; i++)
        {
            points.Add(new Point(rand.Next(1, count * 2), 
                100));
        }
        return points;
    }
    
  4. 不要忘记添加一个 using 语句来使用 Point 类型:

    using System.Drawing;
    
  5. 接下来,向 PollingExample 类中添加一个名为 FindSmallXValues 的私有静态方法。此方法遍历点列表,并输出 X 值小于 50 的点。每次循环时,它会检查令牌是否被取消,并在发生取消时退出循环:

    private static void FindSmallXValues(List<Point> 
        points, CancellationToken token)
    {
        foreach (Point point in points)
        {
            if (point.X < 50)
            {
                Console.WriteLine($"Point with small X 
                    coordinate found. Value: {point.X}");
            }
            if (token.IsCancellationRequested)
            {
                break;
            }
            Thread.SpinWait(5000);
        }
    }
    

在循环的末尾添加了一个 Thread.SpinWait 语句,以给用户一些取消操作的时间。

  1. PollingExample 类中添加一个名为 CancelWithPolling 的公共静态方法:

    public static void CancelWithPolling()
    {
        using CancellationTokenSource tokenSource = new();
        Task.Run(() => FindSmallXValues(GeneratePoints
            (1000000), tokenSource.Token), tokenSource
                .Token);
        if (Console.ReadKey(true).KeyChar == 'x')
        {
            tokenSource.Cancel();
            Console.WriteLine("Press a key to quit");
        }
    }
    

前面的方法创建了 CancellationTokenSource 对象,并将其传递给 FindSmallXValuesTask.Run。如果你想要取消 Task,当 IsCancellationRequested 变为 true 时,不是从循环中跳出,而是调用 token.ThrowIfCancellationRequested。这将在 Task 中抛出异常。CancelWithPolling 方法将需要围绕 Task.Run 调用添加 try/catch 块。无论如何,使用异常处理来处理所有多线程代码都是一个最佳实践。在这种情况下,你将有两个异常处理程序:一个用于处理 OperationCanceledException,另一个用于处理 AggregateException

此外,CancelWithPolling 方法中还有代码来确定当用户按下 X 键取消操作时。

  1. 最后,打开 Program.cs 并添加一些代码来执行示例:

    using CancellationPatterns;
    Console.WriteLine("Hello, World! Press a key to start, 
        then press 'x' to cancel.");
    Console.ReadKey();
    PollingExample.CancelWithPolling();
    Console.ReadKey();
    
  2. 现在运行应用程序,并检查输出:

图 11.4 – 运行取消轮询示例

图 11.4 – 运行取消轮询示例

根据你取消等待的时间长度,你可能会找到不同数量的点。

在下一节中,我们将学习如何注册一个回调方法来处理取消请求。

使用回调取消

一些 .NET 代码支持注册一个回调方法来取消处理。支持使用回调进行取消的一个类是 System.Net.WebClient。在这个例子中,我们将使用 WebClient 来开始下载一个文件。下载将在三秒后取消。为了确保文件下载足够大,在三个秒后尚未完成,我们将从 Internet Archive (https://archive.org/) 下载一个大型无损有声书文件。我们将下载荷马史诗《奥德赛》的第一部分。此文件大小为 471.1 MB。你可以在 https://archive.org/details/lp_the-odyssey_homer-anthony-quayle 查看这本书的所有免费下载。执行以下步骤:

  1. 打开 CallbackExample

  2. 首先添加一个名为 GetDownloadFileName 的方法来构建文件下载的路径。我们将将其下载到我们的程序集执行的同一文件夹中:

    private static string GetDownloadFileName()
    {
        string path = System.Reflection.Assembly
           .GetAssembly(typeof(CallbackExample)).Location;
        string folder = Path.GetDirectoryName(path);
        return Path.Combine(folder, "audio.flac");
    }
    
  3. 接下来,添加一个名为 DownloadAudioAsyncasync 方法。此方法将处理文件下载和取消操作。有几个异常处理程序来捕获 DownloadFileTaskAsync 方法可能抛出的任何类型的异常。反过来,它们都会抛出一个 OperationCanceledException 类型的异常,由父方法处理:

    private static async Task DownloadAudioAsync
        (CancellationToken token)
    {
        const string url = "https://archive.org/download/
            lp_the-odyssey_homer-anthony-quayle/disc1/
                lp_the-odyssey_homer-anthony-quayle
                    _disc1side1.flac";
        using WebClient webClient = new();
        token.Register(webClient.CancelAsync);
        try
        {
            await webClient.DownloadFileTaskAsync(url, 
                GetDownloadFileName());
        }
        catch (WebException we)
        {
            if (we.Status == WebExceptionStatus
                .RequestCanceled)
                throw new OperationCanceledException();
        }
        catch (AggregateException ae)
        {
            foreach (Exception ex in ae.InnerExceptions)
            {
                if (ex is WebException exWeb &&
                    exWeb.Status == WebExceptionStatus
                        .RequestCanceled)
                    throw new OperationCanceled
                        Exception();
            }
        }
        catch (TaskCanceledException)
        {
            throw new OperationCanceledException();
        }
    }
    
  4. WebClient 类型添加一个 using 语句:

    using System.Net;
    
  5. 现在添加一个名为 CancelWithCallback 的公共 async 方法。此方法调用 DownloadAudioAsync,等待三秒钟,然后在 CancellationTokenSource 对象上调用 Cancel。在 try 块中等待任务意味着我们可以直接处理 OperationCanceledException 类型。如果你使用了 task.Wait,你将不得不捕获 AggregateException 并检查是否有一个 InnerException 对象是 OperationCanceledException 类型:

    public static async Task CancelWithCallback()
    {
        using CancellationTokenSource tokenSource = new();
        Console.WriteLine("Starting download");
        var task = DownloadAudioAsync(tokenSource.Token);
        tokenSource.Token.WaitHandle.WaitOne
            (TimeSpan.FromSeconds(3));
        tokenSource.Cancel();
        try
        {
            await task;
        }
        catch (OperationCanceledException ex)
        {
            Console.WriteLine($"Download canceled. 
                Exception: {ex.Message}");
        }
    }
    

在这一步,可能需要调整 tokenSource.Token.WaitHandle.WaitOne 调用中的秒数。时间可能会根据你的计算机的下载速度和处理速度而变化。如果你在控制台输出中没有看到 Download canceled 消息,请尝试调整此值。

  1. 最后,注释掉 Program.cs 中的现有代码,并添加以下代码来调用 CallbackExample 类:

    using CancellationPatterns;
    await CallbackExample.CancelWithCallback();
    Console.ReadKey();
    
  2. 现在运行应用程序,并检查输出:

图 11.5 – 使用 CancellationToken 和回调取消下载

图 11.5 – 使用 CancellationToken 和回调取消下载

你可以通过查看你的程序集运行所在的文件夹来验证下载已经开始但未完成。你应该会看到一个名为 audio.flac 的文件,文件大小为 0 KB。你可以安全地删除此文件,因为它如果在尝试再次下载时可能会引发异常。

现在我们已经看到了如何使用回调方法取消后台任务,让我们通过一个使用等待句柄的例子来结束本节。

使用等待句柄取消操作

在本节中,我们将使用 ManualResetEventSlim 来取消一个原本不会对用户输入做出响应的后台任务。此对象有 SetReset 事件来启动/恢复或暂停操作。当操作尚未开始或已暂停时,调用 ManualResetEventSlim.Wait 将导致操作在该语句上暂停,直到另一个线程调用 Set 以启动或恢复处理。

此示例将遍历 100,000 个整数,并将每个偶数输出到控制台。由于 ManualResetEventSlim 对象和 CancellationToken 的存在,此过程可以启动、暂停、恢复或取消。让我们在我们的项目中尝试此示例:

  1. 首先向 CancellationPatterns 项目中添加一个名为 WaitHandleExample 的类。

  2. 在新类中添加一个名为 resetEvent 的私有变量:

    private static ManualResetEventSlim resetEvent = 
        new(false);
    
  3. 在类中添加一个名为 ProcessNumbers 的私有静态方法。此方法遍历数字,并且只有在 resetEvent.Wait 允许它继续时才继续处理:

    private static void ProcessNumbers(IEnumerable<int> 
        numbers, CancellationToken token)
    {
        foreach (var number in numbers)
        {
            if (token.IsCancellationRequested)
            {
                Console.WriteLine("Cancel requested");
                token.ThrowIfCancellationRequested();
            }
            try
            {
                resetEvent.Wait(token);
            }
            catch (OperationCanceledException)
            {
                Console.WriteLine("Operation canceled.");
                break;
            }
            if (number % 2 == 0)
                Console.WriteLine($"Found even number: 
                    {number}");
            Thread.Sleep(500);
        }
    }
    
  4. 接下来,向类中添加一个名为 CancelWithResetEvent 的公共静态异步方法。此方法创建要处理的数字列表,在 Task.Run 调用中调用 ProcessNumbers,并使用 while 循环来监听用户输入:

    public static async Task CancelWithResetEvent()
    {
        using CancellationTokenSource tokenSource = new();
        var numbers = Enumerable.Range(0, 100000);
        _ = Task.Run(() => ProcessNumbers(numbers, 
            tokenSource.Token), tokenSource.Token);
        Console.WriteLine("Use x to cancel, p to pause, or 
            s to start or resume,");
        Console.WriteLine("Use any other key to quit the 
            program.");
        bool running = true;
        while (running)
        {
            char key = Console.ReadKey(true).KeyChar;
            switch (key)
            {
                case 'x':
                    tokenSource.Cancel();
                    break;
                case 'p':
                    resetEvent.Reset();
                    break;
                case 's':
                    resetEvent.Set();
                    break;
                default:
                    running = false;
                    break;
            }
            await Task.Delay(100);
        }
    }
    
  5. 最后,更新 Program.cs 以包含以下代码:

    using CancellationPatterns;
    await WaitHandleExample.CancelWithResetEvent();
    Console.ReadKey();
    
  6. 运行程序进行测试。遵循控制台提示以启动、暂停、恢复和取消过程:

图 11.6 – 在控制台中测试 CancelWithResetEvent 方法

图 11.6 – 在控制台中测试 CancelWithResetEvent 方法

你应该在控制台输出中看到在操作取消之前已经找到了几个事件编号。完成处理的数量可能因你的计算机处理器而异。

在下一节中,我们将通过学习如何处理来自多个来源的取消请求来总结取消。

处理多个取消来源

后台任务可以利用 CancellationTokenSource 从所需的所有来源接收取消请求。静态 CancellationTokenSource.CreateLinkedTokenSource 方法接受一个 CancellationToken 对象数组来创建一个新的 CancellationTokenSource 对象,如果任何源令牌收到取消请求,它将通知我们。

让我们看看如何在我们的 CancellationPatterns 项目中实现这个快速示例:

  1. 首先,打开 PollingExample 类。我们将创建一个接受 CancellationTokenSource 参数的 CancelWithPolling 方法的重载。CancelWithPolling 的两个重载将看起来像这样:

    public static void CancelWithPolling()
    {
        using CancellationTokenSource tokenSource = new();
        CancelWithPolling(tokenSource);
    }
    public static void CancelWithPolling
        (CancellationTokenSource tokenSource)
    {
        Task.Run(() => FindSmallXValues(GeneratePoints
            (1000000), tokenSource.Token), 
                tokenSource.Token);
        if (Console.ReadKey(true).KeyChar == 'x')
        {
            tokenSource.Cancel();
            Console.WriteLine("Press a key to quit");
        }
    }
    
  2. 接下来,添加一个名为 MultipleTokensExample 的新类。

  3. MultipleTokensExample 类中创建一个名为 CancelWithMultipleTokens 的方法。此方法接受 parentToken 作为参数,创建自己的 tokenSource,然后将它们组合成一个 combinedSource 对象以传递给 CancelWithPolling 方法:

    public static void CancelWithMultipleTokens
        (CancellationToken parentToken)
    {
        using CancellationTokenSource tokenSource = new();
        using CancellationTokenSource combinedSource =  
            CancellationTokenSource.CreateLinked
               TokenSource(parentToken, tokenSource
                   .Token);
        PollingExample.CancelWithPolling(combinedSource);
        Thread.Sleep(1000);
        tokenSource.Cancel();
    }
    

我们调用 tokenSource.Cancel,但如果在三个 CancellationTokenSource 对象中的任何一个上调用 CancelCancellWithPolling 中的处理将接收到一个取消请求。

  1. Program.cs 中添加一些代码来调用 CancelWithMultipleTokens

    using CancellationPatterns;
    CancellationTokenSource tokenSource = new();
    MultipleTokensExample.CancelWithMultipleTokens
        (tokenSource.Token);
    Console.ReadKey();
    
  2. 运行程序,你应该会看到一个类似于在 发现线程取消模式 部分的 使用轮询取消 子部分中看到的结果。

尝试更改用于调用 CancelCancellationTokenSource 对象。无论取消请求的来源如何,输出都应该保持不变。

如果你在 Task 中抛出异常,后台 Task 也会结束。这具有结束后台处理的效果,但 TaskStatus 将是 Faulted 而不是 Canceled

这完成了我们对来自多个来源的取消请求的回顾,以及使用 C# 和 .NET 取消任务和线程的游览。让我们回顾一下本章学到的内容。

摘要

在本章中,我们学习了许多取消后台线程和任务的新方法。为用户提供一种取消长时间运行任务或当用户或操作系统关闭或挂起您的应用程序时自动取消它们的方法是很重要的。

在完成本章的示例后,你现在理解了如何使用轮询、回调和等待句柄来协同取消后台任务。此外,你还学习了如何处理来自多个来源的取消请求。

在下一章中,我们将探讨.NET 开发者如何对使用多线程结构的代码进行单元测试。

问题

  1. CancellationToken对象的哪个属性指示是否已发出取消请求?

  2. 哪种数据类型提供了CancellationToken对象?

  3. 当调用ThrowIfCancellationRequested时,会抛出哪种异常类型?

  4. .NET 中的WebClient对象使用了哪种取消模式?

  5. 哪种.NET 类型可以使用CancellationToken对象暂停或恢复操作?

  6. 哪个重置事件用于暂停处理?

  7. CancellationTokenSource中的哪个静态方法可以将多个CancellationToken对象组合成一个单一来源?

第十二章:第十二章:单元测试异步、并发和平行代码

对于 .NET 开发者来说,单元测试异步、并发和平行代码可能是一个挑战。幸运的是,您可以采取一些步骤来帮助减轻难度。本章将提供一些具体的建议和有用的示例,说明开发者如何对利用多线程结构的代码进行单元测试。这些示例将说明,即使在执行多线程操作时,单元测试仍然可以保持可靠性。此外,我们还将探讨一个第三方工具,该工具有助于创建自动化的单元测试,以监控您的代码中可能存在的内存泄漏。

为您的 .NET 项目创建单元测试对于维护代码库的健康成长和演变至关重要。当开发人员对具有单元测试覆盖的代码进行更改时,他们可以运行现有测试,以确信没有现有功能因代码更改而被破坏。Visual Studio 使您在整个代码生命周期中创建、运行和维护单元测试项目变得简单。

Visual Studio 中的 测试资源管理器 窗口可以检测并运行使用微软的 MSTest 框架创建的单元测试,以及 NUnit 和 xUnit.net 等第三方框架。无论您是开发 Windows 应用程序、移动设备还是云应用程序,您都应该始终计划为您的项目开发一系列单元测试,并定义测试覆盖率的目标。

注意

本章假设您对单元测试和良好的单元测试实践有所了解。如果您想了解使用 xUnit.net 进行单元测试项目的良好入门指南,可以查看微软的文档,网址为 docs.microsoft.com/dotnet/core/testing/unit-testing-with-dotnet-testdocs.microsoft.com/visualstudio/test/getting-started-with-unit-testing

在本章中,我们将涵盖以下内容:

  • 单元测试异步代码

  • 单元测试并发代码

  • 单元测试并行代码

  • 使用单元测试检查内存泄漏

到本章结束时,您将拥有工具和建议,帮助您自信地编写具有单元测试覆盖的现代多线程代码。

注意

本章中的单元测试是使用 xUnit.net 单元测试框架创建的。您可以使用您选择的任何单元测试框架实现相同的结果,包括 MSTestNUnit。我们将在本章后面展示的内存单元测试框架使用 xUnit.net,但也支持 MSTest 和 NUnit。

技术要求

为了跟随本章中的示例,以下软件被推荐给 Windows 开发者:

  • Visual Studio 2022 版本 17.2 或更高版本

  • .NET 6

  • 一个 JetBrains dotMemory Unit 独立控制台运行器

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter12

让我们首先看看如何编写覆盖async C#方法的单元测试。

单元测试异步代码

单元测试异步代码需要与编写良好的异步 C#代码相同的方法。如果你需要关于如何使用async方法的复习,你可以查看第五章

当编写一个针对async方法的单元测试时,你将使用await关键字来等待方法完成。这要求你的单元测试方法是async并返回Task。就像其他 C#代码一样,不允许创建async void方法。让我们看看一个非常简单的测试方法:

[Fact]
private async Task GetBookAsync_Returns_A_Book()
{
    // Arrange
    BookService bookService = new();
    var bookId = 123;
    // Act
    var book = await bookService.GetBookAsync(bookId);
    // Assert
    Assert.NotNull(book);
    Assert.Equal(bookId, book.Id);
}

这可能看起来像你为同步代码编写的多数测试。只有几个不同之处:

  • 首先,测试方法是async并返回Task

  • 其次,调用GetBookAsync时使用了await关键字来等待结果。否则,这个测试遵循典型的安排-行动-断言模式,并像通常那样测试结果。

让我们在 Visual Studio 中创建一个简单的项目来尝试这个,并查看结果:

  1. 开始创建一个新的AsyncUnitTesting

图 12.1 – 创建一个新的类库项目

图 12.1 – 创建一个新的类库项目

  1. 接下来,我们将向AsyncUnitTesting.Tests添加一个测试项目:

图 12.2 – 将 xUnit 测试项目添加到解决方案中

图 12.2 – 将 xUnit 测试项目添加到解决方案中

  1. BookOrderService.cs中。当 Visual Studio 询问你是否想要重命名所有对Class1的使用时,选择

  2. 打开BookOrderService类,并添加一个名为GetCustomerOrdersAsyncasync方法:

    public async Task<List<string>> 
        GetCustomerOrdersAsync(int customerId)
    {
        if (customerId < 1)
        {
            throw new ArgumentException("Customer ID must 
                be greater than zero.", nameof
                    (customerId));
        }
        var orders = new List<string>
        {
            customerId + "1",
            customerId + "2",
            customerId + "3",
            customerId + "4",
            customerId + "5",
            customerId + "6"
        };
        // Simulate time to fetch orders
        await Task.Delay(1500);
        return orders;
    }
    

此方法接受customerId作为参数,并返回包含订单号的List<string>。如果提供的customerId小于1,则抛出ArgumentException。否则,创建一个包含六个订单号且以customerId为前缀的列表。在注入Task.Delay1500毫秒后,将orders返回给调用方法。

  1. 接下来,右键单击AsyncUnitTesting.Tests项目,然后点击添加 | 项目引用。在引用管理器对话框中,勾选AsyncUnitTesting项目的复选框,然后点击确定

  2. 现在,将UnitTest1类重命名为BookOrderServiceTests,并在 Visual Studio 编辑器中打开该文件。

  3. 是时候开始添加测试了。让我们先测试一下快乐路径。添加一个名为GetCustomerOrdersAsync_Returns_Orders_For_Valid_CustomerId的测试方法:

    [Fact]
    public async Task GetCustomerOrdersAsync_Returns_
        Orders_For_Valid_CustomerId()
    {
        var service = new BookOrderService();
        int customerId = 3;
        var orders = await service.GetCustomerOrdersAsync
            (customerId);
        Assert.NotNull(orders);
        Assert.True(orders.Any());
        Assert.StartsWith(customerId.ToString(), 
            orders[0]);
    }
    

在使用customerId3调用GetCustomerOrdersAsync之后,我们的代码有三个断言:

  • 首先,我们检查订单列表不是null

  • 第二,我们检查列表中包含一些项目。

  • 最后,我们检查第一个订单以customerId开头。

  1. 点击测试 | 运行所有测试以确保这个测试通过。

  2. 让我们用一个新的customerId编写相同的测试,但不使用asyncawait。假设你有一些无法重构的遗留测试代码,你必须测试GetCustomerOrdersAsync方法。这段代码看起来是这样的:

    [Fact]
    public void GetCustomerOrdersAsync_Returns_Orders
        _For_Valid_CustomerId_Sync()
    {
        var service = new BookOrderService();
        int customerId = 5;
        List<string> orders = service.GetCustomer
            OrdersAsync(customerId).GetAwaiter()
                .GetResult();
        Assert.NotNull(orders);
        Assert.True(orders.Any());
        Assert.StartsWith(customerId.ToString(), 
            orders[0]);
    }
    

测试方法不是async并返回void。我们不是使用await来允许GetCustomerOrdersAsync运行到完成,而是调用GetAwaiter().GetResult()。代码的设置和断言部分保持不变。

  1. 点击测试 | 运行所有测试以确保我们的两个测试都是绿色(通过)。

  2. 最后,我们将测试异常情况。创建另一个测试,但向被测试的方法传递一个负的customerId。对GetCustomerOrdersAsync的整个调用将包裹在Assert.ThrowsAsync<ArgumentException>调用中:

    [Fact]
    public async Task GetCustomerOrdersAsync_
        Throws_Exception_For_Invalid_CustomerId()
    {
        var service = new BookOrderService();
        await Assert.ThrowsAsync<ArgumentException>(async 
            () => await service.GetCustomerOrdersAsync
                (-2));
    }
    
  3. 最后再执行一次运行所有测试并确保它们都通过:

图 12.3 – 在测试资源管理器中查看三个通过测试

图 12.3 – 在测试资源管理器中查看三个通过测试

现在我们有三个通过单元测试针对GetCustomerOrdersAsync方法。前两个基本上在测试同一件事,但它们展示了两种不同的编写测试方式。在大多数情况下,你将使用async方法。最后的测试提供了抛出ArgumentException的代码的测试覆盖率。如果你使用 Visual Studio Enterprise 版本或像 dotCover 这样的第三方工具,你可以使用它们的可视化工具来查看你的代码哪些部分被单元测试覆盖,哪些没有被覆盖。

现在我们对测试async方法有了些熟悉,让我们继续在测试系统中使用并发数据结构。

并发代码的单元测试

在本节中,我们将从第九章的一个示例中进行适配,以添加单元测试覆盖率。当你的代码使用asyncawait时,添加可靠的测试覆盖率非常简单。在示例的末尾,我们将检查使用SpinLock结构等待执行断言的替代方法。

让我们为ConcurrentOrderQueue项目创建一个 xUnit.net 单元测试项目并添加几个测试:

  1. 首先从第九章复制ConcurrentOrderQueue项目。如果你还没有这个项目的副本,你可以从 GitHub 仓库获取源代码:github.com/PacktPublishing/Parallel-Programming-and-Concurrency-with-C-sharp-10-and-.NET-6/tree/main/chapter09/ConcurrentOrderQueue

  2. 在 Visual Studio 中打开 ConcurrentOrderQueue 解决方案。

  3. ConcurrentOrderQueue.Tests 中的解决方案文件上右键点击。确保将新项目添加到 ConcurrentOrderQueue 文件夹内。

  4. 如果你的新测试项目也作为文件夹出现在 ConcurrentOrderQueue 项目下,右键点击 ConcurrentOrderQueue.Tests 文件夹并选择 Exclude from Project

  5. 添加 UnitTest1 类到 OrderServiceTests

  6. 为了控制用于生成订单列表的 CustomerId 值,我们将在 OrderService 类中创建一个新的 EnqueueOrders 公共方法重载:

    public async Task EnqueueOrders(List<int> customerIds)
    {
        var tasks = new List<Task>();
        foreach (int id in customerIds)
        {
            tasks.Add(EnqueueOrders(id));
        }
        await Task.WhenAll(tasks);
    }
    

此方法接受一个 customerId 列表,并为每个 customerId 调用私有的 EnqueueOrders 方法,将每个调用中的 Task 添加到 List<Task> 中,在退出方法之前等待这些 Task 完成。

  1. 现在,我们可以通过调用这个新重载来优化无参数版本的 EnqueueOrders

    public async Task EnqueueOrders()
    {
        await EnqueueOrders(new List<int> { 1, 2 });
    }
    
  2. OrderServiceTests 类中创建一个新的单元测试方法来测试 EnqueueOrders

    [Fact]
    public async Task EnqueueOrders_Creates_Orders_For_
        All_Customers()
    {
        var orderService = new OrderService();
        var orderNumbers = new List<int> { 2, 5, 9 };
        await orderService.EnqueueOrders(orderNumbers);
        var orders = orderService.DequeueOrders();
        Assert.NotNull(orders);
        Assert.True(orders.Any());
        Assert.Contains(orders, o => o.CustomerId == 2);
        Assert.Contains(orders, o => o.CustomerId == 5);
        Assert.Contains(orders, o => o.CustomerId == 9);
    }
    

测试将使用三个客户 ID 调用 EnqueueOrders。在 EnqueueOrdersDequeueOrders 完成后,我们断言 orders 集合不是 null,包含一些订单,并且包含包含我们所有三个客户 ID 的订单。

  1. 运行新的测试并确保它通过。

这涵盖了使用 ConcurrentQueue 的系统测试的基本知识。让我们考虑另一个场景,其中我们正在处理代码,但在测试中不能使用 asyncawait。也许被测试的方法不是 async。我们可用的工具之一是 SpinWait 结构体。这个结构体包含一些提供非锁定等待机制的方法。我们将使用 SpinWait.WaitUntil() 等待直到所有订单都已入队。

以下步骤将演示如何可靠地测试无法显式等待其完成的方法的结果:

  1. 首先,向 OrderService 类中添加一个新的公共变量,以公开已入队订单的客户数量:

    public int EnqueueCount = 0;
    
  2. 接下来,在私有方法 EnqueueOrders 的末尾增加 EnqueueCount

    private async Task EnqueueOrders(int customerId)
    {
        for (int i = 1; i < 6; i++)
        {
            ...
        }
        EnqueueCount++;
    }
    
  3. 现在,创建一个 EnqueueOrdersSync 公共方法,以便从我们的新测试中调用。它将类似于公共的 EnqueueOrders 方法。与前一个示例相比,不同之处在于它不是 async,它将 EnqueueCount 重置为 0,并且不等待任务完成:

    public void EnqueueOrdersSync(List<int> customerIds)
    {
        EnqueueCount = 0;
        var tasks = new List<Task>();
        foreach (int id in customerIds)
        {
            tasks.Add(EnqueueOrders(id));
        }
    }
    
  4. 接下来,我们将创建一个新的同步测试方法来测试 EnqueueOrdersSync

    [Fact]
    public void EnqueueOrders_Creates_Orders_For_All
        _Customers_SpinWait()
    {
        var orderService = new OrderService();
        var orderNumbers = new List<int> { 2, 5, 9 };
        orderService.EnqueueOrdersSync(orderNumbers);
    SpinWait.SpinUntil(() => orderService.EnqueueCount 
            == orderNumbers.Count);
        var orders = orderService.DequeueOrders();
        Assert.NotNull(orders);
        Assert.True(orders.Any());
        Assert.Contains(orders, o => o.CustomerId == 2);
        Assert.Contains(orders, o => o.CustomerId == 5);
        Assert.Contains(orders, o => o.CustomerId == 9);
    }
    

差异在前面的代码片段中突出显示。SpinWait.SpinUntil 将在没有锁定的情况下等待,直到 orderService.EnqueueCount 的值与 orderNumbers.Count 匹配。如果你想确保它不会无限期地旋转,有提供超时周期的重载,可以是 TimeSpan 或以毫秒为单位。

  1. 再次运行测试,并确保它们都通过。我们现在有了测试单元测试方法,这些方法正在测试OrderService类中可用的两个方法来排队订单。在你的项目中,你应该添加更多场景来增加类的测试覆盖率。你应该始终测试事情,例如你的代码如何处理无效输入。

在对多线程代码进行单元测试时,重要的是要记住,如果你没有使用asyncawait或某些其他同步方法,你的测试将不可靠。拥有不可靠的测试和没有测试一样糟糕。务必仔细设计和开发你的单元测试。尽可能使用async/await以获得最大的可靠性。

在下一节中,我们将为使用Parallel.ForEachParallel.ForEachAsync方法的代码构建一些单元测试。

单元测试并行代码

为使用Parallel.InvokeParallel.ForParallel.ForEachParallel.ForEachAsync的代码创建单元测试相对简单。虽然它们在条件合适时可以并行运行进程,但它们相对于调用代码是同步运行的。除非你在Parallel.ForEach中包装Task.Run语句,否则代码流将不会继续,直到循环的所有迭代都已完成。

在测试使用并行循环的代码时需要考虑的一个注意事项是预期的异常类型。如果在这些构造函数的任何构造函数体中抛出异常,则周围的代码必须捕获AggregateException。这个Exception规则的例外是Parallel.ForEachAsync。因为它使用async/await调用,所以你必须处理Exception而不是AggregateException。让我们创建一个示例来阐述这些场景:

  1. 创建一个新的ParallelExample

  2. Class1重命名为TextService,并在该类中创建一个名为ProcessText的方法:

    public List<string> ProcessText(List<string> 
        textValues)
    {
        List<string> result = new();
        Parallel.ForEach(textValues, (txt) => 
        {
            if (string.IsNullOrEmpty(txt))
            {
                throw new Exception("Strings cannot be 
                    empty");
            }
            result.Add(string.Concat(txt, 
                Environment.TickCount));
        });
        return result;
    }
    

此方法接受一个字符串列表,并在Parallel.ForEach循环中将Environment.TickCount追加到每个值中。如果任何字符串为null或空,将抛出Exception

  1. 接下来,创建ProcessTextasync版本,并将其命名为ProcessTextAsyncasync版本使用Parallel.ForEachAsync执行相同的操作:

    public async Task<List<string>> 
        ProcessTextAsync(List<string> textValues)
    {
        List<string> result = new();
        await Parallel.ForEachAsync(textValues, async 
            (txt, _) =>
        {
            if (string.IsNullOrEmpty(txt))
            {
                throw new Exception("Strings cannot 
                    be empty");
            }
            result.Add(string.Concat(txt, 
                Environment.TickCount));
            await Task.Delay(100);
        });
        return result;
    }
    
  2. 添加一个新的ParallelExample.Tests

  3. UnitTest1类重命名为TextServiceTests,并将项目引用添加到ParallelExample项目中。

  4. 接下来,我们将添加两个单元测试来测试ProcessText方法:

    [Fact]
    public void ProcessText_Returns_Expected_Strings()
    {
        var service = new TextService();
        var fruits = new List<string> { "apple", "orange", 
            "banana", "peach", "cherry" };
        var results = service.ProcessText(fruits);
        Assert.Equal(fruits.Count, results.Count);
    }
    [Fact]
    public void ProcessText_Throws_Exception_For
        _Empty_String()
    {
        var service = new TextService();
        var fruits = new List<string> { "apple", "orange", 
            "banana", "peach", "" };
        Assert.Throws<AggregateException>(() => 
            service.ProcessText(fruits));
    }
    

第一个测试使用包含五个字符串值(水果名称)的列表调用ProcessText。断言检查results.Count是否与fruits.Count匹配。

第二个测试执行相同的调用,但其中一个fruits字符串值是空的。这个测试将确保在测试的方法中Parallel.ForEach循环抛出AggregateException

  1. 再添加两个测试。这两个测试将对 ProcessTextAsync 方法运行相同的断言。这里的区别是,Assert.ThrowsAsync 必须检查 Exception 而不是 AggregateException,因为我们使用了 async/await

    [Fact]
    public async Task ProcessTextAsync_Returns_Expected
        _Strings()
    {
        var service = new TextService();
        var fruits = new List<string> { "apple", "orange", 
            "banana", "peach", "cherry" };
        var results = await service.ProcessTextAsync
             (fruits);
        Assert.Equal(fruits.Count, results.Count);
    }
    [Fact]
    public async Task ProcessTextAsync_Throws_Exception
        _For_Empty_String()
    {
        var service = new TextService();
        var fruits = new List<string> { "apple", "orange", 
            "banana", "peach", "" };
        await Assert.ThrowsAsync<Exception>(async () => 
            await service.ProcessTextAsync(fruits));
    }
    
  2. 使用 Text Explorer 窗口中的 Run All Tests in View 按钮运行所有四个测试。如果该窗口在 Visual Studio 中不可见,您可以从 View | Test Explorer 打开它。所有测试都应该通过:

图 12.4 – TextServiceTests 类中的四个测试通过

图 12.4 – TextServiceTests 类中的四个测试通过

您现在对 TextService 类中处理文本的方法有了两个测试。它们成功测试了有效和无效的输入数据。花些时间自己检查测试覆盖率可以如何扩展。可以使用哪些其他类型的输入?

在本章的最后部分,我们将探讨如何将内存泄漏检测集成到您的自动化单元测试套件中。

使用单元测试检查内存泄漏

内存泄漏绝非仅限于多线程代码,但它们确实可能发生。在您的应用程序中执行的应用程序代码越多,某些对象泄漏的可能性就越大。制作流行 .NET 工具 ReSharperRider 的公司还制作了一个名为 dotMemory 的工具,用于分析内存泄漏。虽然这些工具不是免费的,但 JetBrains 提供了其内存单元测试工具,名为 dotMemory Unit

在本节中,我们将创建一个 dotMemory 单元测试来检查我们是否泄漏了其中一个对象。您可以通过下载此处提供的独立测试运行器在命令行上免费运行这些 dotMemory 单元测试:https://www.jetbrains.com/dotmemory/unit/。

注意

更多关于使用免费工具的信息,您可以在此处了解:www.jetbrains.com/help/dotmemory-unit/Using_dotMemory_Unit_Standalone_Runner.xhtml。JetBrains 还在其 ReSharper 和 Rider 工具中集成了 dotMemory Unit。如果您拥有这些工具的许可证,这将大大简化运行这些测试的过程。

让我们创建一个示例,展示如何创建一个单元测试,以确定被测试代码是否在内存中泄漏对象:

  1. 首先创建一个新的 MemoryExample

  2. Class1 重命名为 WorkService 并添加另一个名为 Worker 的类。将以下代码添加到 Worker 类中。这个类中的 DoWork 方法将处理 WorkService 中的 TimerElapsed 事件:

    public class Worker : IDisposable
    {
        public void Dispose()
        {
            // dispose objects here
        }
        public void DoWork(object? sender, 
            System.Timers.ElapsedEventArgs e)
        {
            Parallel.For(0, 5, (x) =>
            {
                Thread.Sleep(100);
            });
        }
    }
    

这个类实现了 IDisposable 接口,因此我们可以在其他地方使用 using 语句。

  1. WorkService 类添加一个 WorkWithTimer 方法:

    public void WorkWithTimer()
    {
        using var worker = new Worker();
        var timer = new System.Timers.Timer(1000);
        timer.Elapsed += worker.DoWork;
        timer.Start();
        Thread.Sleep(5000);
    }
    

这段代码存在一些问题,将阻止 worker 对象从内存中释放。timer 对象既没有被停止也没有被处置,并且 Elapsed 事件从未被取消绑定。当我们检查泄漏时,我们应该找到一些。

  1. 添加一个新的 MemoryExample.Tests

  2. 将项目引用添加到 MemoryExample,并将 NuGet 包引用 添加到 JetBrains.dotMemoryUnit

图 12.5 – 引用 dotMemoryUnit NuGet 包

图 12.5 – 引用 dotMemoryUnit NuGet 包

  1. WorkServiceMemoryTests 中的 UnitTest1 类重命名,并添加以下代码:

    using JetBrains.dotMemoryUnit;
    [assembly: SuppressXUnitOutputExceptionAttribute]
    namespace MemoryExample.Tests
    {
        public class WorkServiceMemoryTests
        {
            [Fact]
            public void WorkWithSquares_Releases_Memory_
                From_Bitmaps()
            {
                var service = new WorkService();
                service.WorkWithTimer();
                GC.Collect();
                // Make sure there are no Worker 
                    objects in memory
                dotMemory.Check(m => Assert.Equal(0, 
                    m.GetObjects(o =>
                        o.Type.Is<Worker>())
                            .ObjectsCount));
            }
        }
    }
    

在前面的代码片段中,有几行被突出显示。必须添加一个 assembly 属性来抑制在控制台运行器中使用 xUnit.net 和 dotMemory Unit 时出现的错误。在调用测试中的方法 WorkWithTimer 之后,我们调用 GC.Collect 尝试从内存中清理所有未使用的托管对象。最后,调用 dotMemory.Check 以确定内存中是否还有 Worker 类型的对象。

  1. .\ 字符之前运行以下命令:

    .\dotMemoryUnit.exe "c:\Program Files\dotnet\dotnet.exe" – test "c:\dev\net6.0\MemoryExample.Tests.dll"
    

.NET 的路径在您的系统上应该是相同的。您需要将 MemoryExample.Tests.dll 的路径替换为您自己的输出路径,其中此 DLL 存在。测试应该失败,有一个 Worker 对象留在内存中,并且您的输出将类似于以下内容:

图 12.6 – 检查失败的 dotMemoryUnit 测试运行

图 12.6 – 检查失败的 dotMemoryUnit 测试运行

  1. 为了修复问题,请对您的 WorkService.WorkWithTimer 方法进行以下更改:

    public void WorkWithTimer()
    {
        using var worker = new Worker();
        using var timer = new System.Timers.Timer(1000);
        timer.Elapsed += worker.DoWork;
        timer.Start();
        Thread.Sleep(5000);
        timer.Stop();
        timer.Elapsed -= worker.DoWork;
    }
    

为了确保 worker 对象实例被释放,我们在 using 语句中初始化 timer,在完成时停止 timer,并取消绑定 timer.Elapsed 事件处理器。

  1. 现在,再次执行 dotMemory 单元命令。测试应该现在成功:

图 12.7 – dotMemoryUnit 测试运行成功

图 12.7 – dotMemoryUnit 测试运行成功

这就结束了本例和关于内存单元测试的部分。如果您想了解更多关于 dotMemory Unit 的信息,可以在此处找到其文档:www.jetbrains.com/help/dotmemory-unit/Introduction.xhtml。命令行工具也可以部署到持续集成(CI)构建服务器,以作为 CI 构建过程的一部分执行这些测试。

让我们通过回顾本书最后一章所学的内容来结束。

摘要

在本章中,我们了解了一些用于单元测试包含不同多线程结构的 .NET 项目的工具和技术。我们首先讨论了测试使用 async/await 的 C# 代码的最佳方法。这在现代应用程序中很常见,并且拥有覆盖您 async 代码的自动化单元测试套件非常重要。

我们还探讨了几个单元测试的例子,这些测试方法利用了并行构造和并发数据结构。在章节的最后部分,我们学习了 JetBrains 的 dotMemory Unit。这个免费的单元测试工具增加了检测测试方法中泄漏的对象的能力。它是一个强大的自动化工具,用于同步和异步 .NET 代码。

这是最后一章。感谢您跟随我们走过这段多线程之旅。希望您在旅途中没有遇到任何死锁或竞态条件。这本书为您在 .NET 和 C# 的现代多线程世界中找到了一条路径。您现在应该已经了解了异步、并发和并行方法和结构,以构建快速可靠的应用程序。如果您想了解更多关于这些主题的信息,我建议阅读 .NET 并行编程 博客 (devblogs.microsoft.com/pfxteam/) 并依赖 .NET 文档 (docs.microsoft.com/dotnet/)。您可以在本书的任何主题上搜索文档以了解更多信息。

问题

  1. 在 .NET 属性中,用于装饰 xUnit.net 测试方法的关键字是什么?

  2. 您可以使用什么方法在不使用锁的情况下将 await 添加到您的代码中?

  3. 当测试的方法包含 Parallel.ForEach 循环时,在单元测试断言中你应该期望哪种异常?

  4. 当测试的方法包含 Parallel.ForEachAsync 循环时,在单元测试断言中你应该期望哪种异常?

  5. 你如何在 xUnit.net 断言中检查一个对象是否不是 null

  6. 在 Visual Studio 中,可以管理并运行单元测试的窗口叫什么名字?

  7. .NET 中最受欢迎的三个单元测试框架是什么?

  8. 哪些 JetBrains 产品提供了运行 dotMemory Unit 测试的工具?

第十三章:评估

本节包含所有章节的问题答案。

第一章,托管线程概念

  1. 托管线程是在 .NET 托管代码中使用 System.Threading.Thread 对象创建的线程。

  2. 在调用 Thread.Start() 之前将 Thread.IsBackground 属性设置为 true

  3. .NET 将抛出 ThreadStateException 异常。

  4. .NET 主要根据线程的 Thread.Priority 值来优先处理托管线程。

  5. ThreadPriority.Highest

  6. .NET 6 不支持 Thread.Abort()。代码将无法编译。

  7. 将对象参数添加到新线程启动的方法中,并在调用 Thread.Start(data) 时传递数据。

  8. 将委托传递给取消令牌的 Register 方法。

第二章,.NET 中多线程编程的演变

  1. ThreadPool 

  2. C# 5.0

  3. .NET Framework 4.5

  4. .NET Core 3.0

  5. TaskTask<T>ValueTaskValueTask<T>

  6. ConcurrentDictionary<TKey, TValue>

  7. BlockingCollection<T>

  8. 并行 LINQ (PLINQ)

第三章,托管线程的最佳实践

  1. 单例。

  2. ThreadStatic

  3. 当多个线程都在等待访问一个被锁定的资源且无法继续时,会发生死锁。

  4. Monitor.TryEnter

  5. Interlocked

  6. Interlocked.Add

  7. MaxDegreeOfParallelism

  8. 使用 WithDegreeOfParallelism 扩展方法。

  9. ThreadPool.GetMinThreads()

第四章,用户界面响应性和线程

  1. TaskTask<T>

  2. Task.WhenAll

  3. Task.Factory.StartNew

  4. ThreadPool 的后台线程。

  5. Application.Current.Dispatcher.Invoke

  6. this.BeginInvoke

  7. 检查 this.InvokeRequired 属性。

第五章,使用 C# 进行异步编程

  1. Task.Result

  2. Task.WhenAll()

  3. Task.WaitAll()

  4. TaskTask<TResult>ValueTaskValueTask<TResult>

  5. I/O 密集型操作,如文件或网络访问,最适合异步方法。

  6. 错误。始终以 Async 后缀异步方法是一种最佳实践。

  7. Task.Run

第六章,并行编程概念

  1. Parallel.For

  2. Parallel.ForEachAsync

  3. Parallel.Invoke

  4. TaskCreationOptions.AttachToParent

  5. TaskCreationOptions.DenyAttach

  6. Task.Run 将始终拒绝子任务附加。此外,Task.Run 没有重载方法来提供 TaskCreationOptions

  7. 不,如果每个循环迭代运行得快,并且/或者循环迭代次数很少,则常规 forforeach 循环可能会更快。

第七章,任务并行库 (TPL) 和数据流

以下是该章节问题的答案:

  1. JoinBlock

  2. BufferBlock 是一个传播块。

  3. BufferBlock

  4. JoinTo()

  5. Complete()

  6. SendAsync()

  7. ReceiveAsync()

第八章,并行数据结构和并行 LINQ

  1. AsParallel().

  2. AsSequential().

  3. AsOrdered().

  4. ForAll().

  5. AsOrdered() 可以显著降低查询的性能。

  6. OrderByOrderByDescending。它们将默认为 ParallelMergeOptions.FullyBuffered

  7. No. PLINQ 有额外的开销,这可能导致对较小数据集或简单查询的查询速度变慢。

  8. ParallelMergeOptions.NotBuffered.

第九章,在 .NET 中使用并发集合

  1. BlockingCollection<T>.

  2. ConcurrentQueue<T>.

  3. BlockingCollection<T>.

  4. ConcurrentDictionary<TKey, TValue>.

  5. Enqueue().

  6. TryAdd()TryGetValue()

  7. No. 在使用并发集合的扩展方法时,始终添加自己的同步机制,包括标准 LINQ 操作符。

第十章,使用 Visual Studio 调试多线程应用程序

  1. 使用附加到进程窗口或在解决方案文件中设置多个启动项目。

  2. 它们按进程分组。

  3. 在窗口中右键单击并选择

  4. 并行堆栈窗口。

  5. .PNG 文件。

  6. 四.

  7. 调试位置工具栏。

  8. 点击仅我的代码按钮。

第十一章,取消异步工作

  1. CancellationToken.IsCancellationRequested

  2. CancellationTokenSource

  3. OperationCanceledException

  4. 注册回调

  5. ManualResetEventSlim

  6. ManualResetEventSlim.Reset

  7. CancellationTokenSource.CreateLinkedTokenSource

第十二章,单元测试异步、并发和并行代码

  1. Fact

  2. SpinLock.WaitUntil

  3. AggregateException

  4. Exception

  5. Assert.NotNull

  6. **测试资源管理器 **

  7. MSTest、NUnit 和 xUnit .NET

  8. ReSharper、Rider 和 dotMemory 单独控制台运行器

![](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prl-proh-cncr-csp10-dn6/img/Packt_Logo1.png)

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7000 本书籍和视频,以及行业领先的工具,帮助你规划个人发展并推进你的职业生涯。更多信息,请访问我们的网站。

第十四章:为什么订阅?

  • 通过来自 4000 多名行业专业人士的实用电子书和视频,节省学习时间,多花时间编码

  • 通过为你量身定制的技能计划提高你的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于快速访问关键信息

  • 复制粘贴、打印和收藏内容

你知道 Packt 为每本书都提供电子书版本,并提供 PDF 和 ePub 文件吗?你可以在packt.com升级到电子书版本,并且作为印刷书客户,你有权获得电子书副本的折扣。有关更多信息,请联系我们 customercare@packtpub.com。

www.packt.com,你还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能还会喜欢的其他书籍

如果你喜欢这本书,你可能还会对 Packt 出版的这些其他书籍感兴趣:

使用 C# 10 和.NET 6 进行企业级应用程序开发 - 第二版

拉温德拉·阿凯拉,阿伦·库马尔·塔米里斯,苏尼尔·库马尔·库纳尼,布普什·古普塔·穆蒂亚卢

ISBN:9781803232973

  • 通过充分利用.NET 6 的最新功能来设计企业应用程序

  • 发现应用程序的不同层,如数据层、API 层和 Web 层

  • 通过实现使用.NET 和 C# 10 的企业级 Web 应用程序并在 Azure 上部署来探索端到端架构

  • 专注于 Web 应用程序开发的核心理念,并在.NET 6 中实现它们

  • 将新的.NET 6 健康和性能检查 API 集成到你的应用程序中

  • 探索 MAUI 并构建针对多个平台的应用程序 - 安卓、iOS 和 Windows

C#和.NET 中的高性能编程

贾森·奥尔斯

ISBN:9781800564718

  • 使用正确的类型和集合来提高应用程序性能

  • 分析代码库,基准测试和识别性能问题

  • 探索如何在 LINQ 中执行最佳查询以提高应用程序的性能

  • 通过异步编程有效地利用多个 CPU 和核心

  • 使用 WinForms、WPF、MAUI 和 WinUI 构建响应式用户界面

  • 对 ADO.NET、Entity Framework Core 和 Dapper 进行基准测试以进行数据访问

  • 实现 CQRS 和事件源,构建和部署微服务

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已经与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球科技社区。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

分享你的想法

现在你已经完成了《使用 C# 10 和 .NET 6 进行并行编程和并发》这本书,我们非常想听听你的想法!如果你在亚马逊购买了这本书,请点击此处直接跳转到该书的亚马逊评论页面,分享你的反馈或在该购买网站上留下评论。

你的评论对我们和科技社区都非常重要,它将帮助我们确保我们提供高质量的内容。

你可能还会喜欢以下书籍

你可能还会喜欢以下书籍

posted @ 2025-10-22 10:33  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报