C--和--NET-高性能编程-全-
C# 和 .NET 高性能编程(全)
原文:
zh.annas-archive.org/md5/8645d6a51d3a382c96bdbcf75e987ece译者:飞龙
前言
在构建应用程序的同时编写高性能代码至关重要,多年来,Microsoft 一直专注于在.NET 生态系统中提供各种与性能相关的改进。本书将帮助你理解使用 C#和.NET 的新版本设计响应性、弹性和高性能应用程序所涉及到的各个方面。
你将从理解高性能代码的基础以及 C# 10.0 和.NET 6 的最新性能相关改进开始。接下来,你将学习如何使用跟踪和诊断来追踪性能问题和内存泄漏的原因。接下来的章节将向你展示如何提高网络应用程序的性能以及提高目录任务、文件任务等多种方式的性能。你将继续提高数据查询性能并编写响应式用户界面。你还将发现如何使用云提供商,如 Microsoft Azure,来构建可扩展的分布式解决方案。最后,你将探索各种同步、异步和并行处理代码的方法,以减少处理一系列任务所需的时间。
在本 C#编程书籍结束时,你将拥有构建高度弹性、高性能应用程序所需的信心,以满足客户的需求。
本书面向对象
这本书是为软件工程师、专业软件开发人员、性能工程师和应用性能分析人员编写的,他们希望提高代码的速度或提升技能以获得竞争优势。你应该是一位熟练的 C#程序员,能够熟练地使用该语言,并且对使用 Microsoft Visual Studio 2022 感到舒适。
本书涵盖内容
第一章,介绍 C# 10.0 和.NET 6,讨论了公共语言运行时(CLR)。你将从了解 C# 10.0 和.NET 6 中的新特性开始。然后,你将学习.NET 原生运行时和 CoreCLR。接下来,你将学习统一的 BCL,然后是 Windows Store 性能。最后,你将学习 ASP.NET 5 的性能。
第二章,实现 C#互操作性,介绍了 Microsoft .NET 互操作性。你将学习如何调用和释放不安全代码。你还将学习如何使用 COM 互操作性将遗留 COM 程序迁移到.NET。在本章中,你将学习如何创建.NET 库和组件,并在遗留 COM 应用程序中使用它们。到本章结束时,你将学会如何在.NET 中消费 COM 组件,以及如何在 COM 组件中消费.NET 应用程序。这将帮助你将 COM 应用程序迁移到.NET 平台。
第三章, 预定义数据类型和内存分配,探讨了 C#的原始类型和 C#对象类型。你将了解栈和堆,以及按引用和按值传递数据。然后你将学习装箱和拆箱及其对应用程序性能的影响。你还将刷新对 C#原始类型的了解,以及如何构建性能良好的对象。
第四章, 内存管理,讨论了垃圾回收器。你将学习如何使用跟踪和诊断来追踪性能问题和内存泄漏的原因。然后你将了解对象代和垃圾回收器如何决定要丢弃什么。你还将了解弱引用以及如何正确地处理对象以防止内存泄漏。
第五章, 应用程序性能分析和跟踪,教你如何对你的应用程序进行性能分析以识别性能较差的区域。你将了解代码指标以及如何执行静态代码分析。在你努力编写更高效代码的过程中,你将学习如何利用内存转储、加载的模块查看器、调试、跟踪和 dotnet-counters。当你完成本章时,你将拥有进行应用程序性能分析所需的技术和经验。
第六章, .NET 集合,探讨了集合框架。你将了解不同的集合以及如何最佳地使用它们以获得最佳性能。你将访问System.Collection、System.Collection.Concurrent和System.Collections.Generic命名空间中的各种集合。你还将创建自己的自定义异常,并学习如何使用 LINQ 查询集合。
第七章, LINQ 性能,解释了如何考虑性能进行 LINQ 查询。根据你如何使用 LINQ,返回相同结果的不同方法可能会有不同的行为和性能。因此,在本章中,你将学习如何最佳地执行 LINQ 查询以改善你应用程序的性能。
第八章, 文件和流 I/O,解释了如何提高文件和目录的性能。你将学习提高目录任务、文件任务、内存任务和隔离存储任务的方法。在这本书中,你将学习如何异步写入文件和异步从文件中读取。
第九章, 增强网络应用程序的性能,详细介绍了如何加快网络应用程序的性能。你将学习如何使用 TCP 和 UDP 网络协议在网络中进行通信。然后,你将学习如何使用 OSI 网络层参考模型和一系列 TCP 和 UDP 网络协议进行网络跟踪过程。还将涵盖缓存管理,以便你可以提高资源检索的效率。
第十章**,设置我们的数据库项目,在 SQL Server 上设置了 Northwind 数据库项目,因为我们将在下一节中使用此数据库来基准测试数据访问方法。
第十一章, 基准测试关系数据访问框架,基准测试了三种不同的 SQL Server 数据库数据操作方法。我们将对 Entity Framework、ADO.NET 和 Dapper.NET 进行并行比较。在为这些数据访问和对象映射器运行基准测试之后,你将能够对适合你项目的最佳数据访问和对象映射形式做出明智的判断。
第十二章, 响应式用户界面,解释了如何编写响应式用户界面。你将编写响应式的Windows 窗体(WinForms)、Windows 表现基础(WPF)、ASP.NET、.NET MAUI 和 WinUI 应用程序。使用后台工作线程,你将看到如何通过在后台运行长时间运行的任务来实时更新和操作用户界面。
第十三章, 分布式系统,描述了分布式应用程序并解释了如何提高它们的性能。你将学习如何使用命令查询责任分离(CQRS)软件设计模式、事件溯源和微服务来构建高性能的分布式应用程序。你将看到如何使用云提供商,如 Microsoft Azure,利用 Cosmos DB、Azure Functions 和开源 Pulumi 基础设施工具构建可扩展的分布式解决方案。
第十四章, 多线程编程,探讨了线程和线程的概念,并讨论了后台和前台线程。然后,你将学习在运行线程之前如何将数据传递到线程中。你还将学习如何暂停、中断、销毁、调度和取消线程。
第十五章, 并行编程,解释了如何利用现代计算机中可用的多个 CPU 核心。你将学习如何通过在进程之间并发分配工作来处理你的代码。
第十六章, 异步编程,揭示了 async、await 和 WhenAll 的神秘面纱。你还将了解不同的返回类型,如何提取所需的结果,以及如何正确取消异步操作和执行异步文件读写操作。
为了充分利用本书
你需要精通 C#,并知道如何使用 Visual Studio 2022 创建、运行和调试 C#程序以及安装 NuGet 包。如果你跟随本书的步骤,编写代码并使用指定的工具,你将能从本书中获得最大收益。但如果您太忙,请遵循 Microsoft 提供的获取和安装以下软件的指南。

如果你使用的是本书的数字版,我们建议你亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将有助于避免与代码复制粘贴相关的任何潜在错误。
请尝试回答问题,阅读每章末尾提供的外部资源,并将你所学应用到自己的编程和性能训练练习中。这将有助于巩固你在本书中学到的知识。
下载示例代码文件
你可以从 GitHub 下载本书的示例代码文件,链接为github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表的彩色 PDF 文件。你可以从这里下载:packt.link/hQmsb。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“enum数据类型大小为 4 字节(32 位),可空,最小值为 0。你可以使用sizeof(Type type)来测量值类型的大小。”
代码块设置如下:
static void Main(string[] _)
{
Console.WriteLine(“Chapter 3: Strings are immutable”);
var greeting1 = “Hello, world!”;
var greeting2 = greeting1;
Console.WriteLine($”greeting1={greeting1}”);
Console.WriteLine($”greeting2={greeting2}”);
greeting1 += “ Isn’t life grand!”;
Console.WriteLine($”greeting1={greeting1}”);
Console.WriteLine($”greeting1={greeting2}”);
}
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
git clone https://github.com/dotnet/roslyn.git
任何命令行输入或输出都按照以下方式编写:
csc /help
csc -langversion:10.0 /out:HelloWorld.exe Program.cs
csc HelloWorld
cd css
粗体:表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“确保项目设置为调试模式,然后逐步执行代码。”
小贴士或重要提示
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上发现我们作品的任何非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送至 copyright@packt.com 并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
现在您已经完成了《C#和.NET 高性能编程》,我们非常想听听您的想法!如果您在亚马逊购买了这本书,请点击此处直接进入该书的亚马逊评论页面并分享您的反馈或在该购买网站上留下评论。
您的评论对我们和科技社区都非常重要,它将帮助我们确保我们提供高质量的内容。
第一部分:高性能代码基础
第一部分涵盖了高性能代码的基础。我们介绍了 C# 10.0 和.NET 6 的新特性,包括性能改进。接下来,我们探讨了可用的互操作性,这允许逐步将 Python 系统迁移到 C#,随后是垃圾回收器。你将学习到类型如何对性能产生负面影响,以及手动调用垃圾回收器的影响。最后,我们将探讨如何使用性能分析工具来识别和解决性能问题。
本部分包含以下章节:
-
第一章, 实现 C# 10.0 和.NET 6
-
第二章, 介绍 C#互操作性
-
第三章, 预定义数据类型和内存分配
-
第四章, 内存管理
-
第五章, 应用程序性能分析和跟踪
第一章:第一章:介绍 C# 10.0 和.NET 6
Microsoft .NET 6 和 C# 10.0 是.NET 平台和 C#编程语言的最新版本。它们为 C#和.NET 程序员社区带来了许多性能提升。我们将从对 C#和.NET 新版本的概述开始这本书。
在本章中,你将首先下载、还原、构建和测试.NET 编译器Roslyn的最新版本。然后,你将回顾.NET 6 的新特性,包括性能得到极大提升的领域。然后,你将通过查看一些演示这些功能的代码示例来回顾 C# 10.0 的新特性。
在原生编译部分,你将构建一个项目,并以多个二进制文件的形式作为 MSIL 项目运行它,然后将其编译并作为单个本地二进制文件运行。最后,你将学习如何提高 Windows Store 应用程序和 ASP.NET 网站的性能。
在本章中,我们将涵盖以下主题:
-
.NET 6 概述:在本节中,我们将从高层次上介绍.NET 6 的新特性。你将了解将成为.NET 6 一部分的各种性能提升。
-
C# 10.0 概述:在技术要求部分学习了如何获取最新的 Roslyn 代码后,在本节中,你将了解将成为 C# 10.0 一部分的各种功能。这包括代码示例。
-
原生编译:在本节中,你将学习如何将.NET Core 应用程序编译成一个单一的本地可执行文件。你将编写一个简单的控制台应用程序,递归地将音频文件从一种格式转换为另一种格式。
-
提高 Windows Store 性能:这是一个简短的章节,提供了一些提高针对 Windows Store 的应用程序性能的标准指南。
-
提高 ASP.NET 性能:这是一个简短的章节,提供了一些提高 ASP.NET 应用程序的标准指南。
到本章结束时,你将具备以下技能:
-
你将了解 Microsoft .NET 6 的新特性。
-
你将能够在源代码中应用新的 C# 10.0 代码功能。
-
你将能够将源代码编译成原生程序集(也称为二进制文件)。
-
你将了解如何查找有关提高针对 Windows Store 的应用程序性能的信息,包括什么、如何以及在哪里查找。
-
你将了解如何查找有关提高 ASP.NET 应用程序性能的信息,包括什么、如何以及在哪里查找。
让我们从了解 Microsoft .NET 6 开始这一章。
技术要求
你需要以下先决条件来完成本章:
-
Visual Studio Community Edition 的最新预览版本或更高版本。
-
Microsoft .NET 6 SDK。
-
本书源代码:
github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH01。 -
可选:从源代码构建的最新 Roslyn 编译器。源代码可在 GitHub 上找到
github.com/dotnet/roslyn。在安装 Visual Studio 的最新预览版本时,这应该会自动安装。注意
你可以在
github.com/dotnet/roslyn/blob/master/docs/Language%20Feature%20Status.md找到最新的完整和最新的 C# 10.0 功能集。在撰写本文时,C# 10.0 仍在经历许多发展和变化。因此,本书中的内容可能无法按预期工作。如果出现这种情况,请参阅前面的 URL 获取最相关的信息,以帮助您开始工作。
从源代码获取和构建最新的 Roslyn 编译器
注意
所有 .NET 相关存储库的构建系统已经变动了几年。我们将在此处提供编译 Roslyn 的说明;这些说明在撰写本文时是正确的。对于最新的说明,请阅读位于 github.com/dotnet/roslyn 的 README.md 文件。
以下说明是关于在 Windows 10 上下载和构建 Roslyn 编译器最新版本源代码的:
-
在
C:\驱动的根目录下,使用以下命令在 Windows 命令提示符中克隆 Roslyn 源代码:git clone https://github.com/dotnet/roslyn.git -
然后,运行以下命令:
cd Roslyn -
通过运行以下命令恢复 Roslyn 依赖项:
restore.cmd -
通过运行以下命令构建 Roslyn 源代码:
build.cmd -
通过运行以下命令测试 Roslyn 构建:
test.cmd -
一旦所有测试都已完成,请检查新计算机可访问的 C# 版本。通过打开命令提示符窗口并导航到
C:\roslyn\artifacts\bin\csc\Debug\net472来执行此操作。 -
然后,运行以下命令:
csc /langversion:?注意
我总是以管理员身份运行我的命令提示符。因此,截图将显示管理员模式的命令提示符。但运行命令提示符作为管理员对于这项练习不是必需的。当需要作为管理员执行命令提示符时,这将在需要时明确说明。
您应该看到以下类似的内容:


图 1.1 – 编译器支持的 C# 编程语言版本
如您所见,在撰写本文时,C# 语言的 10.0 版本可以通过 C# 编译器获得。C# 10.0 被设置为默认版本。预览版本仍在开发中。默认版本可能在您的计算机上不同。
注意
Visual Studio 2022 的最新版本应允许您使用最新的 C# 10.0 代码功能。如果不行,则编译最新的源代码并覆盖位于 C:\Program Files (x86)\Microsoft Visual Studio\2022\Preview\MSBuild\Current\Bin\Roslyn 的文件。
以下三组指令提供了针对特定 C# 版本编译程序并运行程序的编译器帮助。这些命令仅用于演示目的,您现在不需要运行它们:
csc /help
csc -langversion:10.0 /out:HelloWorld.exe Program.cs
csc HelloWorld
现在您可以从命令行和 Visual Studio 2022 内部构建 C# 10.0,让我们了解在 Microsoft .NET 6 中正在进行的新开发。
Microsoft .NET 6 概述
Microsoft .NET 6 是 .NET 的最新版本。您可以在 dotnet.microsoft.com/download/dotnet/6.0 访问下载。下载适用于 Windows、macOS 和 Linux 用户。
注意事项
要充分利用 .NET 6 和 C# 10.0,最好您已安装 Visual Studio 2022 或更高版本。
.NET 6 API 文档可在 docs.microsoft.com/dotnet/api/?view=net-6.0 获取。
根据以下文章,Microsoft .NET 5 及以后的版本将不再携带 Core 或 Framework 后缀:redmondmag.com/articles/2019/12/31/coming-in-2020-net-5.aspx。微软希望通过 .NET 平台的 5 版本及以后的版本创建一个单一的平台,用于 WinForms、WPF、Xamarin.Forms、ASP.NET Core 以及所有其他形式的 .NET 开发。Xamarin.Forms 变成了 Microsoft MAUI,版本之间的主要区别在于新的 Microsoft MAUI 将仅使用一个项目来针对所有操作系统和设备。
转向一个统一平台
.NET 6 的基础设施包括运行时组件、编译器和语言。Microsoft .NET SDK 将位于此基础设施之上。可用的工具包括命令行界面、Visual Studio Code、Visual Studio for Mac,以及当然,Visual Studio。
使用统一平台,您可以使用 WinForms、WPF 和 UWP 编写桌面应用程序。可以使用 ASP.NET 编写 Web 应用程序。云应用程序将针对 Microsoft Azure。移动应用程序将使用 Microsoft MAUI 编写。游戏、虚拟现实(VR)、增强现实(AR)应用程序将在 Unity 中开发,使用 Visual Studio 2022 或更高版本作为 C# 代码编辑器。物联网将针对 ARM32 和 ARM64 架构。最后,您将能够使用 ML.NET 和 .NET for Apache Spark 开发 人工智能(AI)应用程序。
微软计划生产一个单一的 .NET 运行时和框架,它在应用程序和设备之间的开发者体验和运行时行为上是一致的。这将通过构建一个单一的代码库来实现,该代码库结合了 .NET Framework、.NET Core、Mono 和 Xamarin.Forms 的最佳元素。
.NET 6 的主要功能如下:
-
统一的开发者体验,无论开发的应用程序和目标设备是什么。
-
在所有设备和平台之间提供统一的运行时体验。
-
Java 兼容性将在所有平台上可用。这在 Redmond Magazine 的文章《2020 年即将到来:.NET 5,微软 .NET 框架的下一阶段》中有说明:
redmondmag.com/articles/2019/12/31/coming-in-2020-net-5.aspx。 -
将支持 Objective-C 和 Swift 的多个操作系统。
-
CoreFX 将支持 AOT(Ahead-Of-Time)编译,以提供静态 .NET 编译,支持多个操作系统,并生成更小的程序集。
现在,让我们从高层次的角度看看 .NET 6 的一些新特性。
垃圾回收
垃圾回收器在标记和窃取方面的性能得到了改进。当一个线程完成其标记配额后,它可以从其他线程那里窃取未完成的标记工作。这加快了收集要回收垃圾的项目的过程。在具有更多核心的计算机上减少了锁竞争,改进了取消提交,避免了昂贵的内存重置,以及向量排序,这些都是 .NET 6 中新的垃圾回收性能改进的例子。
即时编译器
在 .NET 6 中,即时编译器(JIT)也得到了改进。您可以对 JIT 应用各种优化,并且它有无限的时间来实现这些优化。提前编译(AOT)只是提供给 JIT 的多种技术之一,以便它在执行应用程序之前尽可能多地编译代码。JIT 现在将数组长度视为无符号数,这提高了对数组长度执行数学运算的性能。还有很多变化正在进行中。
仅仅说,在 JIT 和 GC(垃圾回收器)之间,针对内存和编译优化所进行的性能改进,仅仅是迁移到 .NET 6 的两个原因之一。
JIT 现在识别了超过一千种新的硬件内建方法。这些方法允许您从 C# 中针对各种硬件指令集进行操作。您不再仅限于 x86_x64 硬件指令集。
JIT(Just-In-Time)编译器中提供了几个运行时辅助函数。这些辅助函数使 JIT 编译器能够操作源代码,从而使代码运行速度必须更快。现在,泛型查找速度更快,因为它们不再需要使用较慢的查找表。
基于文本的处理
在.NET 6 的文本处理元素中也进行了性能提升。这包括(但不限于)在System.Char类中处理空白字符,这需要更少的分支和更少的参数。由于这个类在.NET 6 中的各种文本处理对象和方法中被使用,因此.NET 6 处理文本的速度将普遍提高。DateTime处理也因为从原始滴答计数中提取日期和时间组件的优化而至少快了 30%。由于对StartsWith和EndsWith的本地化修改,字符串操作的性能也得到了提升。通过利用堆分配和 JIT 去虚拟化,数据编码(如UTF8和Latin1编码)的性能也得到了增强。
CharInClass方法在确定字符是否出现在指定的字符类中时更加智能。字符和数字比较使用查找表,各种方法调用被内联,提供了改进的正则表达式处理。各种表达式的生成代码也得到了改进。正则表达式的搜索是通过基于 span 的搜索和向量化方法来执行的。在节点树优化阶段分析正则表达式时消除了回溯的需要,并添加了不改变语义但防止回溯的原子组。这些只是正则表达式性能改进的一部分。但还有更多。
注意
想了解更多关于.NET 5 对正则表达式性能改进的深入知识,请阅读 Stephen Toub 发布的以下非常详细的帖子:devblogs.microsoft.com/dotnet/regex-performance-improvements-in-net-5/。
线程和异步操作
在.NET 5 中,线程和异步操作也因实验性地添加了async ValueTask池化而得到了性能提升。你可以通过将DOTNET_SYSTEM_THREADING_POOLASYNCVALUETASK设置为true或1来开启池化。池化会创建实现接口IvalueTaskSource和IValueTaskSource<TResult>的状态机框对象,并将这些对象添加到池中。在ConcurrentDictionary中,波动性也得到了性能提升,在某些 ARM 架构上性能提升了高达 30%。
集合和 LINQ
集合也看到了几个性能增强,主要是对Dictionary<TKey, TValue>、HashSet<T>、ConcurrentDictionary<TKey, TValue>和System.Collections.Immutable的改进。HashSet<T>集合的实现已被重写,并与Dictionary<TKey, TValue>实现重新同步,并进一步向下移动到堆栈。在迭代ImmutableArray<T>时,foreach的性能得到了改善,并且通过将[MethodImpl(MethodImplOptions.AggressiveInlining)]注解添加到ImmutableArray<T>的GetEnumerator方法,减少了生成的代码大小。.NET 集合的其他元素,如BitArray,也看到了性能提升。
在.NET 5 中,LINQ 也看到了进一步的性能提升,包括OrderBy、Comparison<T>、Enumerable.SkipLast,以及通过使实现Enumerable.Any与Enumerable.Count更加一致。这些只是对集合进行的一些性能改进。
网络和 Blazor
网络性能改进方面已经做了大量工作,特别是对System.Uri类(尤其是在其构造过程中)的改进。System.Net.Sockets和System.Net.Http命名空间也看到了性能的提升。在.NET 的System.Text.Json库中,对 JSON 的处理方式通过JsonSerializer也进行了许多改进。
由于 Blazor 使用.NET mono 运行时和.NET 5 库,增加了一个链接器,它可以从程序集级别裁剪掉未使用的代码。要裁剪的代码通过静态代码分析来识别。在 Blazor Web Assembly 应用程序中,用户界面响应时间也得到了改善,因为客户端代码在执行之前被下载,并且表现得就像桌面应用程序一样——但这是在浏览器内部。
此外,.NET 5 中还包括了多项通用改进,包括更快的程序集加载、更快的数学运算、更快的加密和解密、更快的互操作性、更快的反射生成、更快的 I/O 以及各种库中的各种分配。
新的性能相关 API 和分析器
.NET 5 增加了一些新的以性能为重点的 API。内部,一些这些 API 已经被用于减少代码大小并提高.NET 5 本身的性能。它们专注于帮助程序员专注于编写高性能的代码,并移除之前难以完成的任务的复杂性。这些新的 API 和现有 API 的改进包括Decimal、GC、MemoryExtensions、StringSplitOptions、BinaryPrimitives、MailAddress、MemoryMarshall、SslStream、HttpClient等。
.NET 5 SDK 还增加了一些基于性能的分析器。这些分析器可以检测范围索引中的意外分配,并提供消除分配的方法。分析器将检测Stream.Read/WriteAsync方法的旧重载,并提供修复方案以启用自动切换到更新的重载方法,这些方法更倾向于使用Memory重载。在StringBuilder中,使用类型重载来追加非字符串值(如int和long值)更高效。当分析器遇到程序员对一个存在类型重载的、被追加的类型调用ToString()的情况时,修复器将检测这些情况并自动切换到使用正确的类型重载。使用 LINQ,现在使用(!collection.IsEmpty)语法检查(collection.Count != 0)更高效。旧的方法将被分析器检测并修复为使用更高效的新的方法。最后,当您努力使代码更快时,代码也会变得正确,因为分析器会标记使用stackalloc从堆栈分配内存的循环使用情况。这有助于防止引发堆栈溢出异常。
要了解.NET 新开发的路线图,您可以查看位于github.com/dotnet/core/blob/master/roadmap.md的.NET Core 路线图。
现在,让我们看看 C# 10.0。
C# 10.0 概述
您可以在 Roslyn GitHub 页面上找到将成为 C# 10.0 一部分的功能,该页面位于github.com/dotnet/roslyn/blob/master/docs/Language%20Feature%20Status.md。
并非所有这些功能在撰写本文时都可用。然而,我们将探讨一些可用的功能。让我们从顶层程序开始。
编写顶层程序
在 C# 9.0 之前,Program.cs文件是这样的。在这个文件中,您会有以下类似的内容:
using System;
namespace HelloWorld
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
}
如您所见,首先,我们导入我们的System库。然后,我们有一个命名空间定义,后面是我们的类定义。然后,在类定义中,我们有Main方法,在这个方法中,我们将短语"Hello, World!"输出到控制台窗口。
在 C#编程语言的 10.0 版本中,这可以简化为单行:
System.Console.WriteLine("Hello, World");
在这里,我们删除了 10 行代码。运行程序将输出以下内容:

Figure 1.2 – 控制台窗口显示输出"Hello World!"
如果我们打开生成的 DLL 文件在 ILDASM 中,我们将看到以下内容:

Figure 1.3 – ILDASM 显示 hello world 程序的内部结构
您将从反编译中看到编译器在编译时添加了Main方法。我们将要查看的 C# 10.0 的下一个新增功能是只初始化属性。
使用只初始化属性
只初始化属性允许您使用具有不可变字段的对象初始化器。在我们的简单演示中,我们将使用一个包含书籍名称及其作者的Book类:
namespace CH01_Books
{
internal class Book
{
public string Title { get; init; }
public string Author { get; init; }
}
}
属性可以在创建书籍时初始化。但一旦创建,它们只能读取,不能更新,使得Book类型不可变。现在,让我们看看只初始化属性。在Program类中,将其内容替换为以下内容:
using System;
using CH01_Books;
var bookName = new Book { Title = "Made up book name",
Author = "Made Up Author" };
Console.WriteLine($"{bookName.Title} is written by
{bookName.Author}. Well worth reading!");
在这里,我们导入了System和CH01_Books命名空间。然后,我们声明了一个新的Book类型的不可变变量。之后,我们使用插值字符串输出了该Book类型的内容。运行程序;您应该看到以下输出:
![图 1.4 – 我们只初始化属性示例的输出]

图 1.4 – 我们只初始化属性示例的输出
现在我们已经介绍了只初始化属性,让我们看看记录。
使用记录
在更新数据时,您不希望数据被另一个线程更改。因此,在多线程应用程序中,您将希望在使用更新时使用线程安全的对象。记录允许完整的对象不可变,并作为值行为。使用记录而不是结构体的优点是它们需要的内存分配更少。这种内存分配的减少是通过将记录编译为引用类型来实现的。然后通过引用而不是副本来访问它们。因此,除了原始记录的分配之外,不需要进一步分配内存。
让我们学习如何使用记录。启动一个新的控制台应用程序。
为了演示记录的使用,我们将使用以下Book示例:
internal record Book
{
public string Title { get; init; }
public string Author { get; init; }
}
对Book类的唯一更改是将类替换为record。其他一切保持不变。现在,让我们将记录投入使用:
-
将
Program类的内容替换为以下代码:using System; using CH01_Records; var bookOne = new Book { Title = "Made Up Book", Author = "Made Up Author }; var bookTwo = bookOne with { Title = "And Another Made Up Book" }; var bookThree = bookTwo with { Title = "Yet Another Made Up Book" }; var bookFour = bookThree with { Title = "And Yet Another Made Up Book: Part 1", }; var bookFive = bookFour with { Title = "And Yet Another Made Up Book: Part 2" }; var bookSix = bookFive with { Title = "And Yet Another Made Up Book: Part 3" }; Console.WriteLine($"Some of {bookThree.Author}'s books include:\n"); Console.WriteLine($"- {bookOne.Title}"); Console.WriteLine($"- {bookTwo.Title}"); Console.WriteLine($"- {bookThree.Title}"); Console.WriteLine($"- {bookFour.Title}"); Console.WriteLine($"- {bookFive.Title}"); Console.WriteLine($"- {bookSix.Title}"); Console.WriteLine($"\nMy favourite book by {bookOne. Author} is {bookOne.Title}."); -
如您所见,我们正在创建不可变的记录类型。我们可以从它们创建新的不可变类型,并使用
with表达式更改任何我们喜欢的字段。原始记录不会被任何方式修改。运行代码;您将看到以下输出:
![图 1.5 – 显示其不可变性的只初始化属性]

图 1.5 – 显示其不可变性的只初始化属性
尽管在分配过程中更改了标题,但原始记录根本未发生任何修改。
-
记录也可以使用继承。让我们添加一个新的包含出版商名称的记录:
internal record Publisher { public string PublisherName { get; init; } } -
现在,让我们让我们的
Book类继承这个Publisher记录:internal record Book : Publisher { public string Title { get; init; } public string Author { get; init; } } -
Book现在将包括PublisherName。当我们初始化一本新书时,我们现在可以设置其PublisherName:var bookOne = new Book { Title = "Made Up Book", Author = "Made Up Author", PublisherName = "Made Up Publisher Ltd." }; -
这里,我们创建了一个新的
Book,其中包含Publisher.PublisherName。让我们打印出版者的名字。将以下行添加到Program类的末尾:Console.WriteLine($"These books were originally published by {bookSix.PublisherName}."); -
运行代码;你应该看到以下输出:

图 1.6 – 使用继承的仅初始化属性
-
如您所见,我们从未为
bookTwo到bookSix设置出版者的名字。然而,继承是从我们为bookOne设置它的时候开始的。 -
现在,让我们执行对象等式检查。将以下代码添加到
Program类的末尾:var book = bookThree with { Title = "Made Up Book" }; var booksEqual = Object.Equals(book, bookOne) ? "Yes" : "No"; Console.WriteLine($"Are {book.Title} and {bookOne.Title} equal? {booksEqual}"); -
这里,我们从一个
bookThree创建了一个新的Book,并将标题设置为Made Up Book。然后,我们执行了等式检查,并将结果输出到控制台窗口。运行代码;你会看到以下输出:

图 1.7 – 仅初始化属性显示等式检查的结果
很明显,等式检查在两个书实例相等时都起作用。
-
我们对记录的最终审视考虑了位置记录。位置记录通过构造函数设置数据,并通过解构函数提取数据。理解这一点最好的方式是代码。添加一个名为
Product的类,并用以下代码替换类:public record Product { readonly string Name; readonly string Description; public Product(string name, string description) => (Name, Description) = (name, description); public void Deconstruct(out string name, out string description) => (name, description) = (Name, Description); } -
这里,我们有一个不可变的记录。记录有两个私有和
readonly字段。它们在构造函数中设置。Deconstruct方法用于返回数据。将以下代码添加到Program类中:var ide = new Product("Awesome-X", "Advanced Multi- Language IDE"); var (product, description) = ide; Console.WriteLine($"The product called {product} is an {description}.");
在此代码中,我们创建了一个新的产品,具有名称和描述的参数。然后,我们声明了两个名为 product 和 description 的字段。字段通过分配产品来设置。然后,我们将产品及其描述输出到控制台窗口,如下所示:

图 1.8 – 仅初始化位置记录
现在我们已经完成了对记录的查看,让我们看看 C# 10.0 改进的模式匹配功能。
使用新的模式匹配功能
现在,让我们看看 C# 10.0 中模式匹配的新特性,从简单的模式开始。在简单的模式匹配中,你不再需要丢弃 (_) 操作符来仅声明类型。在我们的例子中,我们将对订单应用折扣:
-
在新的控制台应用程序中,向名为
Product.cs的新文件中添加一个名为Product的新记录,并添加以下代码:internal record Product { public string Name { get; init; } public string Description { get; init; } public decimal UnitPrice { get; init; } } -
我们的
Product记录有三个仅初始化属性,用于Name、Description和UnitPrice。现在,添加一个继承自Product的OrderItem记录:internal record OrderItem : Product { public int QuantityOrdered { get; init; } } -
我们的
OrderItem记录继承了Product记录,并添加了QuantityOrdered仅初始化属性。在Program类中,我们将添加三个OrderItem类型的变量并初始化它们。这是第一个OrderItem:var orderOne = new OrderItem { Name = "50-80mm Scottish Cobbles", Description = "These rounded stones are frequently used for edging paths and to add interest to gardens", QuantityOrdered = 4, UnitPrice = 199 };
如您所见,正在订购的数量是 4。
-
添加
orderTwo,具有相同的值,但OrderQuantity为7。 -
然后,添加
orderThree,具有相同的值,但OrderQuantity为31。我们将在GetDiscount方法中演示简单模式匹配:static int GetDiscount(object order) => order switch { OrderItem o when o.QuantityOrdered == 0 => throw new ArgumentException("Quantity must be greater than zero."), OrderItem o when o.QuantityOrdered > 20 => 30, OrderItem o when o.QuantityOrdered < 5 => 10, OrderItem => 20, _ => throw new ArgumentException("Not a known OrderItem!", nameof(order)) }; -
我们的
GetDiscount方法接收一个订单。然后评估QuantityOrdered。如果订单数量为0或者传入的对象类型不是OrderItem类型,则会抛出参数异常。否则,返回一个int类型的折扣,对于订单数量。注意,我们在 20%折扣的行上没有使用丢弃运算符。 -
最后,我们必须将以下行添加到
Program类的末尾:Console.WriteLine($"The discount for Order One is {GetDiscount(orderOne)}%."); Console.WriteLine($"The discount for Order Two is {GetDiscount(orderTwo)}%."); Console.WriteLine($"The discount for Order Three is {GetDiscount(orderThree)}%."); -
这些行将每个订单的折扣打印到控制台窗口。现在,让我们修改我们的代码,使其使用关系模式匹配。向
Program类添加以下方法:static int GetDiscountRelational(OrderItem orderItem) => orderItem.QuantityOrdered switch { < 1 => throw new ArgumentException("Quantity must be greater than zero."), > 20 => 30, < 5 => 10, _ => 20 }; -
使用关系模式匹配,我们得到了与简单模式匹配相同的结果,但代码更少。它也非常易于阅读,这使得它很容易维护。将以下三行代码添加到
Program类的末尾:Console.WriteLine($"The discount for Order One is {GetDiscountRelational(orderOne)}%."); Console.WriteLine($"The discount for Order Two is {GetDiscountRelational(orderTwo)}%."); Console.WriteLine($"The discount for Order Three is {GetDiscountRelational(orderThree)}%."); -
在这三行中,我们只是将每个订单的折扣输出到控制台窗口。运行程序;你会看到以下输出:

图 1.9 – 简单和关系模式匹配输出显示相同的结果
从前面的屏幕截图可以看出,两种折扣方法都得到了相同的结果。
-
逻辑
AND、OR和NOT方法可用于逻辑模式匹配。让我们添加以下方法:static int GetDiscountLogical(OrderItem orderItem) => orderItem.QuantityOrdered switch { < 1 => throw new ArgumentException("Quantity must be greater than zero."), > 0 and < 5 => 10, > 4 and < 21 => 20, > 20 => 30 }; -
在
GetDiscountLogical方法中,我们使用逻辑 AND 运算符来检查一个值是否在该范围内。将以下三行添加到Program类的末尾:Console.WriteLine($"The discount for Order One is {GetDiscountLogical(orderOne)}%."); Console.WriteLine($"The discount for Order Two is {GetDiscountLogical(orderTwo)}%."); Console.WriteLine($"The discount for Order Three is {GetDiscountLogical(orderThree)}%."); -
在这三行代码中,我们将订单的折扣值输出到控制台窗口。运行代码;你会看到以下输出:


图 1.11 – 使用目标类型与 new 表达式
从前面的截图,你可以看到我们正确地打印出了学生姓名。现在,让我们看看协变返回。
使用协变返回
使用协变返回,基类方法可以用返回更具体类型的函数覆盖。看看以下数组声明:
object[] covariantArray = new string[] { "alpha", "beta",
"gamma", "delta" };
在这里,我们声明了一个object数组。然后,我们将一个string数组赋值给它。这是一个协变的例子。object数组是最不具体的数组类型,而string数组是更具体的数组类型。
在这个例子中,我们将实例化协变类型,并将它们传递给接受更不具体和更具体类型的函数。将以下类和接口声明添加到Program类中:
public interface ICovariant<out T> { }
public class Covariant<T> : ICovariant<T> { }
public class Person { }
public class Teacher : Person { }
public class Student : Person { }
在这里,我们有一个实现了协变接口的协变类。我们声明了一个通用的Person类型,该类型被具体的Teacher和Student类型继承。添加CovarianceClass,如下所示:
public class CovarianceExample
{
public void CovariantMethod(ICovariant<Person> person)
{
Console.WriteLine($"The type of person passed in is
of type {person.GetType()}.");
}
}
在CovarianceExample类中,我们有一个可以接受ICovariant<Person>类型对象的CovariantMethod参数。现在,让我们通过将CovarianceAtWork方法添加到CovarianceExample类中来使用协变:
public void CovarianceAtWork()
{
ICovariant<Person> person = new Covariant<Person>();
ICovariant<Teacher> teacher = new Covariant<Teacher>();
ICovariant<Student> student = new Covariant<Student>();
CovariantMethod(person);
CovariantMethod(teacher);
CovariantMethod(student);
}
在这个方法中,我们有通用的Person类型和更具体的Teacher和Student类型。我们必须将每个类型传递给CovariantMethod。此方法可以接受更不具体的Person类型和更具体的Teacher和Student类型。
要运行CovarianceAtWork方法,请在using语句之后和covariantArray示例之前放置以下代码:
CovarianceExample.CovarianceAtWork();
现在,让我们看看本地编译。
本地编译
当.NET 代码编译时,它被编译成微软中间语言(MSIL)。当需要时,JIT 编译器会解释 MSIL。然后,JIT 编译器将必要的 MSIL 代码编译成本地二进制代码。随后的对同一代码的调用将调用代码的二进制版本,而不是 MSIL 版本。这意味着 MSIL 代码始终比本地代码慢,因为它在第一次运行时被编译成本地代码。
JIT 代码的优点是跨平台代码,但代价是较长的启动时间。正在运行的 MSIL 程序集的代码由 JIT 编译器编译成本地代码。JIT 编译器针对其运行的硬件优化本地代码。
默认情况下,UWP 应用程序使用 .NET Native 编译成本地代码,而 iOS 应用程序通过 Xamarin/Xamarin.Forms 编译成本地代码。Microsoft .NET Core 也可以编译成本地代码。
执行 .NET Core 应用程序的本地编译
当使用 dotnet 编译程序集到本地代码时,您需要指定一个目标框架。有关支持的目标框架列表,请参阅 docs.microsoft.com/en-us/dotnet/standard/frameworks。您还需要指定一个 运行时标识符(RID)。有关支持 RID 的列表,请参阅 docs.microsoft.com/en-us/dotnet/core/rid-catalog。
注意
在撰写本文时,针对 .NET 5.0 的本地编译确实存在一些问题。因此,为了简化问题,我们将演示将本地编译成单个可执行文件的过程,针对 netcoreapp3.1 和 win10-x64。
为了演示将 Microsoft .NET Core 应用程序编译成本地编译的单个可执行文件,我们将编写一个简单的演示应用程序,它遍历目录结构并将音频文件从一种格式转换成另一种格式:
-
开始一个新的控制台应用程序,并针对 .NET 6。
-
访问
ffmpeg.org/download.html并下载适用于您操作系统的ffmpeg。我的系统是 Windows 10。 -
在 Windows 10 上,将
ffmpeg文件解压到C:\Tools\ffmpeg文件夹。将以下using语句添加到Program.cs文件的顶部:using System; using System.Diagnostics; using System.IO; -
我们将在本地系统上的文件夹层次结构中批量处理音频文件。在这里,列出的
using语句将帮助我们调试代码并在文件系统上执行 I/O。现在,在Program类的顶部添加以下三个字段:private static string _baseDirectory = string.Empty; private static string _sourceExtension = string.Empty; private static string _destinationExtension = string .Empty; -
BaseDirectory成员保存将要处理的起始目录。sourceExtension保存文件类型的扩展名,例如.wav,我们希望将其转换成,而destinationExtension保存我们希望转换成的文件类型的扩展名,例如.ogg。更新您的Main方法,使其看起来如下所示:static void Main(string[] args) { Console.Write("Enter Source Directory: "); _baseDirectory = Console.ReadLine(); Console.Write("Enter Source Extension: "); _sourceExtension = Console.ReadLine(); Console.Write("Enter Destination Extension: "); _destinationExtension = Console.ReadLine(); new Program().BatchConvert(); } -
在我们的
Main方法中,我们要求用户输入源目录、源扩展名和目标扩展名。然后,我们设置成员变量并调用BatchConvert方法。让我们添加我们的BatchConvert方法:private void BatchConvert() { var directory = new DirectoryInfo(_baseDirectory); ProcessFolder(directory); } -
BatchConvert方法创建一个新的名为directory的DirectoryInfo对象,然后将directory对象传递给ProcessFolder方法。现在让我们添加这个方法:private void ProcessFolder(DirectoryInfo directoryInfo) { Console.WriteLine($"Processing Directory: {directoryInfo.FullName}"); var fileInfos = directoryInfo.EnumerateFiles(); var directorieInfos = directoryInfo. EnumerateDirectories(); foreach (var fileInfo in fileInfos) if (fileInfo.Extension.Replace(".", "") == sourceExtension) ConvertFile(fileInfo); foreach (var dirInfo in directorieInfos) ProcessFolder(dirInfo); } -
ProcessFolder方法将消息输出到屏幕,以便用户知道正在处理哪个文件夹。然后,它从directoryInfo参数中获取FileInfo和DirectoryInfo对象的枚举。之后,它将具有所需源文件扩展名的该文件夹中的所有文件进行转换。一旦所有文件都处理完毕,每个DirectoryInfo对象将通过递归调用ProcessFolder方法进行处理。最后,让我们添加我们的ConvertFile方法:private void ConvertFile(FileInfo fileInfo) { } -
我们的
ConvertFile方法接受一个FileInfo参数。该参数包含要转换的文件。剩余的代码将添加到这个ConvertFile方法中。添加以下三个变量:var timeout = 10000; var source = $"\"{fileInfo.FullName}\""; var destination = $"\"{fileInfo.FullName.Replace (_sourceExtension, _destinationExtension)}\""; -
timeout变量设置为 10 秒。这给每个文件的处理提供了 10 秒的时间。source变量包含要转换的文件的完整名称,而destination变量包含新转换文件的完整路径。现在,添加检查以查看转换文件是否存在:if (File.Exists(fileInfo.FullName.Replace (_sourceExtension, _destinationExtension))) { Console.WriteLine($"Unprocessed: {fileInfo.FullName}"); return; } -
如果
destination文件已存在,则转换已经发生,因此我们不需要处理该文件。所以,让我们向用户输出一条消息,通知他们该文件未处理,然后从方法中返回。让我们添加执行转换的代码:Console.WriteLine($"Converting file: {fileInfo.FullName} from {_sourceExtension} to {_destination Extension}."); using var ffmpeg = new Process { StartInfo = { FileName = @"C:\Tools\ffmpeg\bin \ffmpeg.exe", Arguments = $"-i {source} {destination}", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true } }; ffmpeg.EnableRaisingEvents = false; ffmpeg.OutputDataReceived += (s, e) => Debug.WriteLine ($"Debug: e.Data"); ffmpeg.ErrorDataReceived += (s, e) => Debug.WriteLine ($@"Error: {e.Data}"); ffmpeg.Start(); ffmpeg.BeginOutputReadLine(); ffmpeg.BeginErrorReadLine(); ffmpeg.WaitForExit(timeout); -
在这里,我们向窗口输出一条消息,通知用户正在处理的文件。然后,我们实例化一个新的进程,执行
ffmpeg.exe并将音频文件从一种格式转换为用户指定的另一种格式。转换后的文件随后被保存在原始文件相同的目录中。 -
这样,我们就完成了我们的示例项目。那么,让我们看看它运行的情况。在一个外置硬盘上,我有一些我拥有的 Ghosthack 音频样本。这些文件是
.wav格式。但是,它们需要转换成.ogg文件,以便用于我使用的 Android 程序。你可以使用你自己的音频文件或音乐文件夹。注意
如果你没有音频文件来测试这个小程序,你可以从
www.bensound.com下载一些免版税的声音。你可以查看以下页面以获取链接到各种公共音乐域:www.lifewire.com/public-domain-music-3482603。 -
填写问题并按Enter键:

图 1.12 – 我们的文件转换器显示目录和文件转换格式
程序现在将处理指定父文件夹下的所有文件和文件夹。
程序以 MSIL 形式运行正常。然而,我们可以看到文件转换过程中的延迟。让我们将我们的文件转换器编译成一个单一的本地可执行文件,然后看看它是否明显更快:
-
以管理员身份打开 Visual Studio 开发者命令提示符,并导航到包含您的解决方案和项目文件的文件夹。在发布文件时,请注意,项目中的
TargetFramework属性也应更新为 netcoreapp3.1;否则,这可能不起作用——也就是说,如果它设置为net5.0。输入以下命令,然后按Enter:dotnet publish --framework netcoreapp3.1 - p:PublishSingleFile=true --runtime win10-x64 -
当命令运行完成后,您的命令窗口应如下所示:

图 1.13 – 以管理员模式显示原生编译输出的开发者命令提示符
- 如果您导航到发布目录,您将看到以下输出:

图 1.14 – Windows 资源管理器显示原生编译产生的输出文件
- 运行
CH01_NativeCompilation.exe文件。您将看到.wav文件被快速转换成.ogg文件。
在本节中,我们学习了如何编写控制台应用程序。我们将控制台应用程序编译成 MSIL,然后将控制台应用程序编译成一个单一的本地可执行文件。从用户的角度来看,文件以本地形式处理批处理音频文件比以 MSIL 形式快得多。
现在,让我们学习如何提高 Windows Store 应用程序的性能。
提升 Windows Store 性能
这里有一些提高 Windows Store 应用程序性能的基本技巧:
-
执行 Microsoft Store 应用程序性能评估:有关如何执行此操作的更多信息,请访问
docs.microsoft.com/en-us/windows-hardware/test/assessments/microsoft-store-app-performance。 -
理解 Microsoft Store 应用程序性能评估的结果:为了帮助您理解 Windows Store 应用程序性能评估的结果,请访问
docs.microsoft.com/en-us/windows-hardware/test/assessments/results-for-the-microsoft-store-app-performance-assessment -
解决 Microsoft Store 应用程序性能评估结果中突出的问题:主要关注区域是任何在深紫色中突出显示的问题,其次是中等紫色标记的问题。主要指标将是启动:Warm、启动:Cold、启动后、空闲和挂起。您还需要注意处理器和存储使用情况,以及处理器和存储 I/O 延迟、注册表刷新、时间会计、缺失符号、长时间运行的延迟过程调用(DPCs)和中断服务例程(ISRs),这些可能会被最终用户感知为性能问题。
在下一节中,我们将学习如何使用 ASP.NET 提高性能。
提高 ASP.NET 性能
这里有一些提高 Web 应用程序和 API 性能的基本技巧:
-
进行基准测量:在更改您的 Web 应用程序或 API 的性能之前,先对程序的性能进行基准测试。这样,您可以测量任何调整,看看它们是否提高了性能或减慢了速度。
-
从优化影响最大的代码开始:当您完成基准测量后,开始对性能最差且对程序性能影响最大的代码片段进行性能调整。这将为您带来最大的收益。
-
启用 HTTP 压缩:为了减少通过 HTTP/HTTPS 传输的文件大小并提高网络性能,请启用压缩。有两种类型的压缩。GZIP 压缩已经存在很多年了,是事实上的压缩机制;它可以减少文件大小的三分之一。另一种压缩机制是 Brotli。自 2016/2017 年以来,大多数主要浏览器都支持这种压缩机制。
-
减少 TCP/IP 连接开销:减少 HTTP 请求严重提高了 HTTP 通信性能。每个请求都使用网络和硬件资源。当建立了特定于硬件和软件的连接数时,性能将开始显示出下降的迹象。这可以通过减少 HTTP 请求的数量来缓解。
-
使用 SSL 上的 HTTP/2:HTTP/2 over SSL 提供了使用 HTTP 的各种性能改进。多路复用流提供了双向的文本格式帧序列。服务器推送允许服务器在预期客户端可能会使用它的情况下,将可缓存的推送到客户端。二进制协议在解析数据时具有更低的开销,并且它们更不容易出错。二进制协议提供了更多的安全性,并且有更好的网络利用率。当您切换到 SSL 上的 HTTP/2 时,您还可以获得许多其他优化。
-
采用最小化技术:最小化是消除 HTML、CSS 或 JavaScript 网页文件中的空白和注释的过程。通过减小文件大小并启用压缩,您可以大大加快文件的网络传输速度,尤其是在差的 Wi-Fi 环境下。
-
将 CSS 放在头部以便首先加载:为了高效渲染网页,最好在渲染之前加载完整的 CSS,以防止回流。
-
body标签。对于基于重框架的应用程序,引导将是有益的,因为只需加载所需的 JavaScript。另一种选择是在客户端和服务器上渲染页面的同构 JavaScript。同构应用程序可以提高 SEO、性能和可维护性。 -
减少图像大小:图像的大小可能相差很大。减少页面上使用的图像的大小。当与最小化和压缩一起使用时,这种技术可以帮助看起来华丽的网页快速加载。
你可以在进一步阅读部分了解更多关于提高 ASP.NET 性能的其他技术。现在,让我们总结一下本章所学的内容。
摘要
在本章的开始,你下载了 C#编程语言的最新源代码。然后,你恢复了它,构建了它,并运行了各种测试。之后,你构建了一个演示 C# 9.0 特性的 Hello, World!程序。
然后,你学习了.NET 5 中的新内容。本节涵盖了垃圾回收、即时编译、基于文本的处理、线程和异步操作、集合、LINQ、网络和 Blazor 等主题。我们还介绍了基于性能的新 API 和分析器。从所涵盖的内容来看,你现在对微软和第三方为.NET 编程语言新版本所做的众多性能改进有了高层次的认识。这些性能改进是迁移到.NET 5 的坚实理由。但另一个令人信服的理由也是从单一代码库迁移到.NET 进行真正的跨平台开发。
在回顾.NET 5 的性能改进和新增功能后,我们看到了新的 C#10.0 特性。你学习了如何使用顶层语句仅用一行代码编写程序。然后,你学习了如何实现只读属性、记录、新的模式匹配功能、具有目标类型的表达式和协变返回。通过回顾 C# 9.0 语言的新增功能,你学习了如何在 MSIL 中编译和运行代码,然后在一个可执行文件中编译和运行原生代码。从视觉上看,当使用原生二进制而不是 MSIL 汇编时,最终用户体验更好。对于示例,我们使用了一个简单的音频文件格式转换器。
您已经获得了一些关于如何提高 Windows Store 应用程序性能的指导。向您展示了官方微软文档的链接,以帮助您生成性能报告,以及如何理解性能评估的结果。此指导还突出了需要注意的主要指标。最后,我们考虑了一些可以提高您的 ASP.NET 网站和 API 性能的方法。在 进一步阅读 部分中,您可以找到指向官方微软 ASP.NET 文档的链接。此文档将帮助您设计和构建高质量的网站。
此外,在 进一步阅读 部分中,您将找到一些指向文档和 .NET MAUI 的 GitHub 仓库的链接,该仓库预计将与 .NET 6 一起在 2021 年发布。这种用户界面技术是 Xamarin.Forms 的发展,基于客户研究进行了进化性改变。它看起来相当有前景。
在下一章中,我们将探讨 .NET 互操作性。但在那之前,请完成本章的问题,看看一切是否已经深入人心。
问题和练习
回答本章的相关问题:
-
.NET 6 正在改进 .NET 的哪些领域?
-
C# 10.0 有哪些新特性?
-
用于 .NET 本地编译的工具有哪些?
-
如何提高 Windows Store 应用程序的性能?
-
如何加速 ASP.NET?
-
调查 .NET MAUI 的状态,这是仍在开发中的前端桌面和移动开发的未来。
-
编写一些控制台应用程序,并练习使用 .NET 6 和 C# 10.0 的新特性。
-
使用 Benchmark.NET 对您的小型应用程序进行基准测试,然后升级到使用 .NET 6 和 C# 10.0。如果可能的话,在不做任何更改的情况下测量其性能,然后再次测量其性能。看看您是否通过仅升级到 C# 10.0 和 .NET 6 就注意到了性能改进。
注意
问题 4 和 5 的答案可以在各自章节提供的外部参考源中找到。
进一步阅读
要了解更多关于本章所涵盖的主题,请查看以下资源:
-
下载 .NET 6:
dotnet.microsoft.com/download/dotnet/6.0. -
下载 Visual Studio 预览版:
visualstudio.microsoft.com/vs/preview/. -
介绍 .NET 多平台应用 UI:
devblogs.microsoft.com/dotnet/introducing-net-multi-platform-app-ui/. -
.NET MAUI GitHub 页面:
github.com/dotnet/maui. -
从微软学习如何构建反映您品牌的优质 Windows 10 应用程序:
docs.microsoft.com/en-us/windows-hardware/get-started/. -
从微软学习如何使用微软技术设计和构建高质量网站:https://dotnet.microsoft.com/apps/aspnet.
-
.NET 6 中的文件 I/O 改进:
devblogs.microsoft.com/dotnet/file-io-improvements-in-dotnet-6/.
第二章:第二章:实现 C# 互操作性
本章是针对那些希望或需要使用 C# 与 Excel、Python、C++ 和 Visual Basic 6 (VB6) 进行互操作的用户的一个可选章节。
在最近几个月中,Python 已经成为一门非常流行的编程语言,现在在数据科学和机器学习领域扮演着非常重要的角色。由于大数据需要各种技术,这些技术需要在各种业务场景下相互协作,在本章中,您将学习如何从 C# 中执行 Python 脚本和代码。您还可以在 .NET 平台上使用 IronPython.NET,但由于本书是为 C# 程序员编写的,因此我们不会在本章中考虑 IronPython.NET。
有时候,访问用 C++ 编写的库是必要的——尤其是在性能是问题的时候,您需要额外的性能来开发高级游戏。
在本章中,您将了解 Microsoft .NET 互操作性。将您的整个代码库迁移到使用您整个开发团队都熟悉的单一语言的代码库是有利的。但有时,一次性完成这一操作往往不切实际、成本效益低,甚至不安全。这就是互操作性的作用所在。
在本章中,您将学习如何与托管和非托管代码进行交互。您将了解使用不安全代码、使用 平台调用 (P/Invoke) 的非托管代码、COM 互操作性和处理不安全代码。
注意
在 C# 中使用非托管代码并不总是能提高性能。有时,它甚至会降低性能。但将本章包含在这本关于高性能的书中,是为了提供您将需要了解的知识和工具,以逐步将您的非托管代码库替换为托管代码库。通过这样做,所有开发人员只需使用一种语言及其支持的语言(在这种情况下,是 C#)。您的软件可以使用 Azure 或任何其他 .NET 云提供商的高性能和高度可扩展的功能来构建世界级的基于云的系统。这样做的好处之一是它使代码管理和维护变得更加容易。
在本章中,我们将涵盖以下主题:
-
使用不安全代码:C# 有效地保护程序员免于处理指针。但有时,使用指针来提高性能是必要的。因此,在本节中,我们将探讨不安全代码是什么以及如何实现它们。
-
使用平台调用公开静态入口点:P/Invoke 允许您从您的托管 C# 代码中访问未托管库中的代码。在本节中,我们将学习如何访问未使用 .NET 构建的代码。
-
执行 COM 互操作性:在本节中,我们将学习如何使 COM 组件和库对 C# 项目可见,以便使用。我们还将探讨如何使我们的组件和库对 COM 组件可见,以便使用。
-
安全地处理不安全代码:C# 在代码执行完毕后执行垃圾回收以释放资源方面做得非常好,但当您处理非托管代码时,您需要负责清理非托管资源。因此,在本节中,您将了解如何进行此操作。
完成本章后,您将能够做到以下事项:
-
理解 C# 中不安全代码的使用
-
从托管代码调用本地代码
-
在托管和非托管代码中使用 COM 库和组件
-
在不再需要时释放不安全资源
技术要求
在本章中,一些代码包括 C# 托管程序集与基于 COM 的 ActiveX 用户控件、DLL 和可执行文件之间的互操作性。
要编写本章的代码和构建项目,您需要以下内容:
-
Visual Studio 2022
-
.NET 6 的最新 x86 预览版
-
.NET 6 的最新 x64 预览版
-
可选:Visual C++
-
可选:Microsoft Office 的 Visual Studio 工具
-
可选:Visual Basic 6
本章的代码文件可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH02。
注意
虽然 Visual Basic 6 已过时,不再由 Microsoft 支持,但它仍然在各种业务和行业中的生产代码中被广泛使用,例如汽车软件提供商和教育行业。与 VB6 和 .NET 的互操作使得从 VB6 到 .NET 的逐步迁移成为可能。通过现代化使用旧技术构建的应用程序,您可以使用各种云提供商(如 Azure)使它们在时区上具有高度的可扩展性。
我们将首先查看不安全代码。
使用平台调用(P/Invoke)
P/Invoke 是 通用语言基础设施(CLI)的一个功能,它允许托管应用程序调用本地代码。本地代码不受 通用语言运行时(CLR)的管理,因此,代码的安全性牢牢掌握在程序员手中。
在托管代码中,垃圾回收器自动清理内存中的对象,并负责为对象分配代数。我们将在 第四章,内存管理中更详细地介绍垃圾回收器。新对象在大小小于 80,000 字节时总是以零代开始其生命周期,并将放置在小型对象堆上。大小等于或大于 80,000 字节的对象放置在大型对象堆上。在零代中存活的对象会被垃圾回收器提升到一代。最后,在一代中存活的对象会被提升到二代。
注意
实例化对象的大小等于或大于 80,000 字节可能最初是零代,但可能会提升,因此它们不会被看作是零代。
当垃圾回收器将对象从一个代提升到另一个代时,其内存地址会改变。这会破坏任何指向该地址的指针。为了防止垃圾回收器修改地址,必须使用fixed关键字声明指针代码。
现在,让我们来看看如何使用unsafe和fixed关键字。
使用不安全和固定代码
为了提醒程序员确保代码安全性的责任,未管理代码被包裹在一个使用unsafe关键字标记的代码块中。不安全代码利用指针来引用内存中的位置。
不安全代码为程序员提供了访问 C#中指针类型的权限,这在他们与底层操作系统、系统驱动程序或需要以最短时间执行的时间关键代码工作时是必要的。
尽管我们说处理指针的代码是不安全代码,但它是安全的。这样的代码用unsafe关键字标记。尽管被称为不安全,但这种代码在托管代码中是安全的——它只是没有经过 CLR 的验证。因此,可能会引入安全风险和/或指针错误。你可以有一个不安全的pointer_type、value_type或reference_type。
注意
不安全代码的主题很深,所以如果你想了解更多,请查看讨论不安全代码的语言规范,网址为 https://docs.microsoft.com/dotnet/csharp/language-reference/language-specification/unsafe-code。
在本节中,我们将编写一个控制台应用程序,将各种不安全代码机制付诸实践。你可以在github.com/PacktPublishing/C-9-and-.NET-5-High-Performance/tree/master/CH02/CH02_UnsafeCode查看项目的源代码。
考虑以下计算机程序:
namespace CH02_UnsafeCode
{
using System;
class Program
{
static void Main(string[] args)
{
int[] array = new int[5] { 5, 4, 3, 2, 1 };
Console.WriteLine(array[4]);
unsafe
{
int* pointer = stackalloc int[5];
int* cpointer = pointer;
cpointer += 50;
Console.WriteLine(*cpointer);
}
}
}
}
在前面的代码中,你可以看到我们使用new关键字为包含五个int值的数组分配内存空间。我们也可以使用不安全代码来完成同样的操作。但是,我们不是使用new关键字,而是使用stackalloc,并将代码包裹在一个标记为unsafe的代码块中。
当处理像数组指针这样的不安全代码时,使用fixed关键字是必要的。要理解为什么fixed关键字很重要,你需要了解垃圾回收。
当对象被创建时,它们是零代对象。垃圾回收器将删除任何未引用的一代对象。如果分配零代对象的空間变满,垃圾回收器会将零代对象移动到一代。然后,新的对象可以添加到零代。如果一代和二代对象变满,并且所有对象都在使用中,那么垃圾回收器会将一代对象移动到二代。这反过来又会将零代对象移动到一代。
新对象随后被添加到零代。此时,如果第二代、第一代和零代存储空间已满,这意味着无法添加新对象,那么你最终会得到一个内存不足异常。以下图表显示了这一点:


图 2.1 – 对象代际的垃圾回收管理
由于垃圾回收器正在将项目从一个代际移动到另一个代际,内存位置会发生变化。然而,代码中指向这些对象的指针不会改变。因此,当从指针地址检索信息时,数据将是错误的。
为了防止这种情况发生,我们可以使用 fixed 关键字。fixed 关键字告诉垃圾回收器不要单独处理 arrayPointer 指向的地址空间。这意味着我们可以确保指针将指向正确的地址空间和数据。以下代码展示了如何使用 unsafe 和 fixed 关键字来处理数组:
unsafe
{
fixed (int* arrayPointer = array)
{
// Code omitted.
}
}
在前面的代码中,因为我们使用了不安全代码,所以我们使用了 unsafe 代码块。由于我们不希望数组受到垃圾回收器的影响,我们通过使用 fixed 代码块来保持对象在其当前代际。
当使用不安全代码时,你必须注意访问超出范围的数组的影响。在托管代码中访问超出范围的数组时,你会得到 IndexOutOfBoundsException。在非托管代码中,你没有这样的奢侈。你必须确保访问正确的索引。如果你意外地访问了数组范围之外的索引,那么你将不会抛出 IndexOutOfBoundsException。相反,你会得到该内存地址上的任何内容返回给你。在这种情况下,你可能或可能不会抛出某种类型的异常。以下代码演示了这一点:
int* pointerToArray = stackalloc int[100];
Console.WriteLine(pointerToArray[99]);
Console.WriteLine(pointerToArray[100]);
在这里,数组被添加到栈上。数组在位置 99 的值是正确的,但数组位置 100 超出了范围,因此返回了一个错误值。这意味着会抛出 IndexOutOfBoundsException。这就是为什么在处理索引时必须小心处理非托管代码。
注意
unsafe 关键字的原因是提醒程序员对代码安全性负责。在处理指针时,不会引发运行时异常。相反,返回该内存位置上的任何内容。这就是为什么在编写不安全代码时必须格外小心。当无法承受垃圾回收器切换对象代际并移动它们时,你也必须使用 fixed 关键字。
在 C# 中,您只能使用具有 unsafe 和 fixed 代码的结构体和原始类型。不允许访问堆的类和字符串。这意味着任何将被垃圾回收的内容都不能使用 unsafe 代码引用。因此,当使用 C# 指针时,您可以使用值类型,但不能使用引用类型。
例如,以下代码将无法编译:
unsafe
{
fixed (TestObject* testObject = new TestObject()) { }
fixed (string* text = "Hello, World!") { }
}
testObject 变量是一个引用类型指针,如果构建代码,编译器会抛出异常。此代码返回以下异常:
CS0208:无法获取托管类型('TestObject')的地址、大小或声明指针
text 变量是一个字符串指针,如果构建代码,编译器会抛出异常。此代码返回以下异常:
-
CS0208:无法获取托管类型('string')的地址、大小或声明指针注意
使用固定对象可能会导致内存碎片化。因此,除非您真的需要,否则请避免使用
fixed关键字,并且只使用您需要的最长时间。
现在,让我们看看如何使用 P/Invoke 暴露静态入口点。
使用 P/Invoke 暴露静态入口点
P/Invoke 允许您使静态入口点对其他应用程序可用。如果您曾经使用过 WinAPI,那么您已经通过它们的公共静态入口点访问了 DLL 中的代码。这些访问点是通过 P/Invoke 提供的。
要使用 P/Invoke,您需要导入 System.Runtime.InteropServices 命名空间。然后,您必须使用 DllImportAttribute 来进行静态入口调用:
注意
要识别文件的静态入口点,您可以使用位于 C:\Program Files (x86)\Microsoft Visual Studio\2019\Preview\VC\Tools\MSVC\14.28.29115\bin\Hostx64\x64 文件夹中的 dumpbin.exe 文件。在写作时,14.28.29.115 版本正确。当您执行以下代码时,此版本将已更改。使用您在电脑上安装的最新版本。
现在,让我们学习如何使用 dumpbin 通过命令行查看 User32.dll 系统库导出的方法和属性:
-
打开命令行或开发者命令提示符。然后,输入以下命令(注意,您电脑上可能版本不同——使用您拥有的最新版本号):
" C:\Program Files (x86)\Microsoft Visual Studio\2019\Preview\VC\Tools\MSVC\14.28.29304 \bin\Hostx64\x64\dumpbin.exe /exports User32.dll
您应该看到以下类似的内容:

图 2.2 – 命令行显示在 User32.dll 上执行 dumpbin 的结果
- 让我们编写一个 C++ 库,并将其命名为
Product,然后从 C# 使用 P/Invoke 调用它。首先,我们必须创建一个新的空 C++ 项目,如下截图所示:

图 2.3 – 创建一个新的空 C++ 项目
-
删除
Header Files、Resource File和Source Files文件夹。添加一个名为Product的新类。删除具有.h文件扩展名的头文件。 -
修改
Product.cpp文件,使其包含以下代码:#include <string> #include <iostream> #include <comdef.h> struct Product { int Id; BSTR Name; void BuyProduct() { std::wcout << "Product.BuyProduct(" << Name << ");\n"; std::cout << "Id: " << Id; std::cout << "\n"; } }; extern "C" __declspec(dllexport) Product CreateProduct() { Product product = Product(); product.Id = 1; product.Name = SysAllocString(L"New Product"); return product; } extern "C" __declspec(dllexport) void BuyProduct(Product product) { product.BuyProduct(); } -
现在,我们必须导入三个库:
string、iostream和comdef.h。然后,我们必须声明一个包含Id和Name值的结构体。在 C++ 中,字符串通常使用std::string定义,但在 .NET 中,我们按照惯例将字符串声明为 OLE/自动化中的 BSTR 类型。BSTR API 使用CoTask*内存分配器,这是 Windows 上本机隐含的互操作性合同。在非 Windows 系统上,.NET 5 使用malloc/free。我们还有一个名为BuyProduct()的 void 方法,它将Id和Name值以及换行符打印到控制台输出窗口。 -
我们接下来必须导出两个方法,分别命名为
CreateProduct()和BuyProduct(Product product)。现在,CreateProduct()方法创建一个新的Product对象并将其返回给调用者,而BuyProduct(Product product)方法则调用传入的Product结构体的BuyProduct()方法。 -
添加一个名为
Greeting的新类。删除Greeting.h文件。更新Greeting.cpp文件,使其包含以下源代码:#include <iostream> #include <comdef.h> extern "C" __declspec(dllexport) void SendGreeting(); extern "C" __declspec(dllexport) int Add(int, int); extern "C" __declspec(dllexport) bool IsLengthGreaterThan5(const char*); extern "C" __declspec(dllexport) BSTR GetName(); void SendGreeting() { std::cout << "Dear C#, C++ says hello!\n"; } int Add(int x, int y) { return x + y; } bool IsLengthGreaterThan5(const char* value) { return strlen(value) > 5; } BSTR GetName() { return SysAllocString(L"Packt Publishing"); }
在这里,我们包含了 iostream 和 comdef.h。我们有四个方法:SendGreeting()、Add(int x, int y)、IsLengthGreaterThan5(const char* value) 和 GetName()。我们将这些方法暴露给外部调用者。
SendGreeting() 不接受任何参数,并将字符串输出到标准输出窗口。Add(int x, int y) 将调用者传入的两个整数相加并返回结果。IsLengthGreaterThan5(const char* value) 检查调用者传入的字符串的长度是否大于 5。如果是,则返回 true。否则,返回 false。GetName() 返回一个字符串。字符串的返回类型必须是 BSTR。要在方法中返回字符串,必须调用 SysAllocString(L"the string you want returning")。这将正确地将字符串初始化为宽字符数组并初始化计数。
这就是我们的 C++ 库的全部内容。现在,我们只需要配置它。但在做之前,我们将编写我们的 C# 客户端,该客户端将使用 C++ 库。这样做的原因是,一旦我们有了 C# 客户端的构建文件夹,我们就会将 C++ 库输出的 DLL 文件放入 C# 构建文件夹中。按照以下步骤操作:
-
在您的解决方案中添加一个新的 .NET Core 3.1 控制台应用程序项目,并将其设置为启动项目。添加一个名为
Product的类。更新Product.cs文件的内容,如下所示:using System.Runtime.InteropServices; [StructLayout(LayoutKind.Sequential)] public struct Product { public int Id; [MarshalAs(UnmanagedType.BStr)] public string Name; }
在这里,我们已经在我们的 C# 客户端中创建了一个 C++ 结构的镜像,并包含了 System.Runtime.InteropServices 库。我们的 C# 结构与我们的 C++ 结构具有相同的两个字段,并且它们的顺序相同。结构本身使用 [StructLayout(LayoutKind.Sequential)] 进行了注释,这表示字段顺序必须按顺序处理。这确保了 C++ 库中的字段与 C# 库中的字段相匹配。此外,Name 属性是一个字符串,因此需要使用 [MarshalAs(UnmanagedType.Bstr)] 注释。这告诉编译器将 C# 字符串视为 C++ BSTR。
-
按照以下方式修改
Program.cs文件:namespace CH02_Pinvoke { using System; using System.Runtime.InteropServices; class Program { static void Main(string[] _) { } } }
在这里,我们导入了 System 和 System.Runtime.InteropServices 库,然后通过将 args 参数的名称替换为默认操作符来修改 Main(string[] args) 方法。
-
将构建配置设置为 x64。
-
将以下行添加到你的 C++ 项目文件的
PropertyGroup部分:<AppendTargetFrameworkToPath>false</AppendTargetFrame workToPath> -
构建项目。这将生成我们的输出文件夹,我们将在此处放置编译后的 C++ 库。
-
右键单击 C++ 项目并选择 属性。你应该会看到 CH02_NativeLibrary 属性页 对话框:

图 2.4 – CH02_NativeLibrary 属性页
-
将 输出目录 更改为你的 C# 项目的输出目录。然后,将 配置类型 更改为 动态库 (.dll)。构建 C++ 库。
-
在你的 C# 项目中,通过在 C# 构建文件夹中浏览来添加 COM 库。
-
在
Program类中,在Main方法之上添加以下 DLL 导入:[DllImport("CH02_NativeLibrary.dll", CallingConvention = CallingConvention.StdCall )] [DllImport("CH02_NativeLibrary.dll", EntryPoint = "Add",CallingConvention = Calling Convention.StdCall )] public static extern int AddIntegers(int x, int y); [DllImport("CH02_NativeLibrary.dll", CallingConvention = CallingConvention.StdCall )] public static extern bool IsLengthGreaterThan5(string value); [DllImport("CH02_NativeLibrary.dll", CallingConvention = CallingConvention.StdCall )] [return: MarshalAs(UnmanagedType.BStr)] public static extern string GetName(); [DllImport("CH02_NativeLibrary.dll", CallingConvention = CallingConvention.StdCall )] public static extern void BuyProduct(Product product); [DllImport("CH02_NativeLibrary.dll")] public static extern Product CreateProduct(); -
这些
DllImport语句使我们的CH02_NativeLibrary.dll方法对 C# 可用。按照以下方式更新Main方法:static void Main(string[] _) { SendGreeting(); Console.WriteLine($"1 + 2 = {AddIntegers(1, 2)}"); var answer = IsLengthGreaterThan5("C# is awesome!") ? "Yes." : "No."; Console.WriteLine($"Is \"C# is awesome!\" > than 5? {answer}"); Console.WriteLine($"Publisher Name: {GetName()}"); var product = CreateProduct(); Console.WriteLine($"Product: {product.Name}"); BuyProduct(product); Console.ReadKey(); }
我们的 Main 方法调用从我们的 CH02_NativeLibrary.dll 二进制文件中导入的方法。我们传递值并接收值和结构返回。
现在你已经知道了什么是非安全代码和固定代码,让我们学习如何在 C# 中与 Python 代码交互。
与 Python 代码交互
Python 是世界上最顶尖的编程语言之一,是数据科学家和人工智能与机器学习领域的程序员的宠儿。基础设施专业人员使用 Python 编程语言自动化日常的平凡基础设施任务。
Python 代码的设计使得程序员可以比在 C# 中更快地编码任务。因此,Python 的编程编写体验可能比 C# 更快。一些程序员表示,Python 可能比 C# 更易读,尽管我认为与 Python 相比,C# 更容易阅读和理解。这意味着可读性相当主观,但使用 Python 编写程序的程序员比使用 C# 的更多。
在编译代码性能方面,C# 优于 Python。Python 可以更快地编写,但需要大量的测试,其垃圾回收器和解释器可能会影响 Python 应用程序的性能。C# 使用 JIT、AOT 和 Ngen,这些技术也适用于 VB.NET、C#、F# 和其他 .NET 语言,以执行各种类型的编译。结果是,C# 在目标机器上生成原生代码,从而提供比 Python 更快的执行代码。随着 Microsoft 为 .NET 5 和 C# 9.0 添加更多性能改进,C# 将比之前的版本更快。
在 Python 领域取得如此多的成就之后,对于 C# 程序员来说,能够通过在 C# 中使用 Python 代码来利用 Python 是很有好处的。同时,一些公司正努力将所有代码放在单个代码库中,因此他们希望摆脱 Java 和 Python 等语言,完全转向 C#。将现有的 Python 代码迁移到 C# 的另一个优点是,相同的任务在 C# 中的执行速度将比在 Python 中快得多。从 Python 迁移到 C# 的第一步是能够在 C# 编程语言中使用现有的 Python 代码。
在本节中,您将学习如何在 C# 中执行 Python 代码。您还将学习如何调用和执行外部 Python 脚本。按照以下步骤操作:
-
首先,请确保您在 Visual Studio 安装程序中添加了 Python 负载,并将 Python 添加到您的
PATH环境变量中。 -
启动一个新的 .NET Core 3.1 控制台应用程序。然后,添加
IronPythonNuGet 包。这仅适用于 Python 2.x 代码。如果您需要 Python 3.x 支持,则使用 Python.NET,可在 http//pythonnet.github.io 获取。您需要以下using语句:using System; using IronPython.Hosting;
我们需要 System,因为我们将在控制台窗口中输出文本。需要 IronPython.Hosting 库来在 C# 中托管和执行 Python 代码。
-
将名为
welcome.py的文件添加到项目中,将其设置为始终Copy,并添加以下代码:print("Welcome to the world of Python integration with C#!") -
此 Python 代码将在我们的控制台窗口中打印出文本。将以下代码添加到
Main方法中:Console.WriteLine("Enter a string to be printed from Python: "); var input = Console.ReadLine(); var python = Python.CreateEngine(); try { python.Execute("print('From Python: " + input + "')"); python.ExecuteFile("welcome.py"); } catch (Exception ex) { Console.WriteLine(ex.Message); } finally { Console.ReadKey(); }
在这里,我们正在提示用户输入一些文本。然后,我们读取用户输入的文本行。创建一个变量,可以用来执行 Python 代码。然后使用 try/catch/finally 块来执行 Python 代码。首先,我们从 C# 中直接执行纯 Python 代码。然后,我们执行在 Python 脚本中执行的代码。任何异常都会捕获到写入控制台窗口的异常消息。最后,我们在退出之前等待用户按下任意键。
那就是直接在 C# 中执行 Python 代码以及通过外部 Python 脚本执行的全部内容。现在,让我们学习 COM 接口。
执行组件对象模型 (COM) 互操作性
组件对象模型 (COM) 是微软在 1993 年引入的一个接口标准。它使得用相同或不同语言编写的组件能够相互通信,并且 COM 组件可以在彼此之间传递数据。通信是通过 进程间通信 (IPC) 和动态对象创建来完成的。COM 不是一个编程语言;它提供了一个由二进制和网络标准组成的软件架构。
许多商业员工使用工作表,因为它们是结合和操作数据的简单方式,出于各种原因。工作表也是统计分析的完美工具。许多公司通过使用 C# 和其他语言构建有用的附加组件来扩展工作表的功能。但工作表对于将数据摄入数据库以进行日常操作和报告目的也是很有用的。在本节中,你将学习如何使用 C# 创建和操作工作表,以及编写 Excel 插件。
注意
Visual Studio Tools for Office (VSTO) 仅在 .NET 4.8 及以下版本中可用。它将不支持 C# 9 和 .NET 5.0。因此,我们将使用 .NET 4.8 进行 C# 互操作性。由于微软已经从 VSTO 和 COM 模型转向使用 JavaScript 进行 Excel 的跨平台扩展,因此我们将专注于 .NET 4.8 中的 VSTO。要了解更多关于使用 JavaScript API 扩展 Microsoft Office 的信息,请阅读以下文档:docs.microsoft.com/office/dev/add-ins/develop/understanding-the-javascript-api-for-office。
在本节中,我们将提供两个演示。第一个演示将从现有工作表中读取数据。了解如何做这一点是有用的,因为程序员经常有与工作表数据一起工作的业务需求。之后,我们将添加一个 Excel VSTO 扩展程序。为最终用户提供扩展程序,使他们的工作更加便捷和愉快是非常有用的。
从 Excel 工作表中读取数据
在本节中,我们将编写一个小程序来读取 Excel 文件,计算行数,然后使用 C# 从内部更新 Excel 工作表中的使用行数。请按照以下步骤操作:
-
在
C:\Temp中添加一个名为C:\Temp的文件夹。然后,在该文件夹中创建一个新的工作表,命名为LineCount.xlsx。在第一列中添加 10 行文本。保存并关闭工作表。 -
添加一个新的 .NET 4.8 控制台应用程序。使用 NuGet 包管理器添加以下引用以安装最新版本:
Microsoft.Office.Interop.Excel Microsoft.VisualStudio.Tools.Applications.Runtime -
将以下命名空间添加到
Program类中:using System; using Microsoft.Office.Interop.Excel; -
通过这样,我们就可以从 C# 与 Excel 进行交互。现在,修改
Main方法,如下所示:var excel = new Application(); var workbook = excel.Workbooks.Open ("C:\\Temp\\LineCount.xlsx"); var worksheet = excel.ActiveSheet as Worksheet; Range userRange = worksheet.UsedRange; int countRecords = userRange.Rows.Count; int add = countRecords + 1; worksheet.Cells[add, 1] = $"Total Rows: {countRecords}"; workbook.Close(true, Type.Missing, Type.Missing); excel.Quit();
上述代码创建了一个新的 Excel 应用程序。我们之前创建和修改的工作簿已打开。此时,我们可以获取活动工作表上正在使用的范围以及行数。然后将计数保存在新行上,之后我们可以关闭工作簿并退出 Excel。
- 代码可以运行多次,然后打开电子表格。你应该会看到以下类似的内容:

图 2.5 – Excel 显示由 C# 添加的行
如您所见,与 Excel 文件一起工作很简单。
提示
从数据库结果集填充 Excel 工作表的最高效方式是使用 Worksheet.Range.CopyFromRecordset(Object, Object, Object)。请参阅官方 Microsoft 文档 docs.microsoft.com/dotnet/api/microsoft.office.interop.excel.range.copyfromrecordset?view=excel-pia。
现在,让我们创建一个 Excel 外接程序。
创建 Excel 外接程序
创建 Excel 外接程序与 .NET 高性能有什么关系?嗯,通过实施以下策略可以提高 VSTO 的性能:
-
按需加载 VSTO 外接程序。
-
使用 Windows Installer 发布 Office 解决方案。
-
跳过功能区反射。
-
在单独的线程中执行昂贵的操作。
在本节中,我们将编写一个 Excel 外接程序,该程序将出现在 Excel 的 外接程序 选项卡中。当按钮被点击时,它将读取当前选中单元格中的文本,并在消息框中显示内容。按照以下步骤操作:
-
创建一个新的 Excel VSTO 外接程序项目。这将针对 .NET 4.8。您不能使用 VSTO 与 .NET 5.0。
-
添加一个新的功能区(Visual Designer)并命名为
CsRibbonExtension。 -
将
group1重命名为CsGroup并将标签更改为C# Group。 -
向
CsGroup添加一个按钮。 -
将按钮的名称更改为
GetCellValueButton并将其标签更改为Get Cell Value。 -
双击按钮以生成点击事件。更新点击事件如下:
private void GetCellValueButton_Click(object sender, RibbonControlEventArgs e) { CultureInfo originalLanguage = Thread.CurrentThread .CurrentCulture; Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US"); var activeCell = Globals.ThisAddIn.Application .ActiveCell; if (activeCell.Value2 != null) MessageBox.Show(activeCell.Value2 .ToString()); Thread.CurrentThread.CurrentCulture = originalLanguage; } -
在我们的点击事件中,我们保存当前语言并将其更改为美式英语。然后,我们获取活动单元格。
Value2属性是动态类型。我们检查活动单元格的值是否为 null。如果单元格不是 null,则我们在消息框中显示活动单元格的值。最后,我们将语言恢复到其原始语言。 -
构建项目。
-
然后,按 F5 部署解决方案。
-
打开 Excel 并创建一个新的空白工作簿。
-
在功能区上,如果 外接程序 选项卡不可见,请单击 自定义快速访问工具栏,然后单击 更多命令… 以打开 Excel 选项 对话框,如以下截图所示:

图 2.6 – Excel 选项对话框
-
确保已勾选 外接程序 选项,如前面的截图所示。
-
点击确定关闭对话框。在单元格中输入任何内容,然后点击添加插件选项卡。你应该看到以下类似的内容:

图 2.7 – Excel 显示“插件”选项卡
- 确保你的文本单元格被选中。然后,点击获取单元格值功能区项。你应该看到以下类似的消息:

图 2.8 – Excel 显示活动单元格中的文本
按需加载我们的 VSTO 插件
现在,让我们通过仅在客户需求时加载而不是在启动时加载来为我们的 Excel 插件添加性能改进。按照以下步骤操作:
-
右键单击 Excel 插件项目并选择属性。
-
然后,选择发布页面。
-
在发布页面上,点击选项按钮。
-
在发布选项对话框中,选择Office 设置。
-
选择按需加载选项,然后点击确定按钮。
跳过功能区反射
你可以通过覆盖 Microsoft.Office.Core.IRibbonExtensibility.CreateRibbonExtensibleObject() 来跳过功能区反射。而不是让 VSTO 反射要加载的 Ribbon 对象,你必须使用条件语句来显式加载正确的 Ribbon。
在单独的执行线程中执行昂贵的操作
任何耗时任务,如数据库操作和网络上的对象传输,都应该在单独的线程中执行。
注意
你必须在主线程中执行对 Office 对象模型的调用。
进一步的性能改进
有关您可以对 VSTO 插件进行的性能改进的进一步指导,请参阅官方 Microsoft 文档:docs.microsoft.com/en-us/visualstudio/vsto/improving-the-performance-of-a-vsto-add-in?view=vs-2019。
到目前为止,我们已经探讨了与其他程序和编程语言交互的各种方法。现在,让我们学习如何安全地处理未托管代码。
安全地处理未托管代码
当处理未托管资源时,你必须显式地释放它们以释放资源。如果不这样做,可能会导致异常被抛出,或者更糟的是,你的应用程序可能会完全崩溃。你必须确保你的应用程序在遇到异常时不会继续运行并提供错误数据。如果在应用程序继续运行的情况下数据会变得无效,那么退出程序会更好。你还必须确保如果应用程序遇到无法恢复的灾难性异常,则在关闭之前显示消息或进行某种类型的记录。
在 C#中,有两种处理非托管资源的方法:使用可处置模式和终结器。我们将通过代码示例在本节中讨论这两种方法。
理解 C#终结化
终结器是 C#中的析构函数,用于执行任何必要的手动清理操作。你可以在类中使用终结器,但不能在结构体中使用。一个类可以有一个终结器,但不能继承或重载终结器。你不能显式调用终结器,因为它们在类被销毁时自动调用。此外,修饰符不接受修饰符或没有参数。
注意
你无法控制终结器何时运行。如果 GC 运行得太频繁,那么你可能会遇到OutOfMemory异常。与其依赖终结器,你应该实现 Dispose 设计模式的最佳实践,这将在最后作为后备调用终结器。当你正在处理托管和非托管对象时,将终结器代码视为一个错误。
在 C#中有两种编写终结器的语法方式。第一种是经典方法,如下所示:
public class Third : Second
{
~Third() // Destructor/Finalizer
{
// Clean-up code goes here …
}
}
编写终结器的第二种方式如下:
public class Third : Second
{
~Third() => Console.WriteLine("Clean-up goes
here …");
}
作为一名程序员,你必须知道,尽管使用终结器来清理代码,但你无法控制垃圾回收器何时以及是否调用它们。
注意
作为一条经验法则,你的大部分代码是托管代码。这意味着你永远不需要触摸终结器。只有在你需要清理非托管对象时才使用它们。
使用可处置模式释放托管和非托管资源
当你处理托管和非托管对象时,实现可处置设计模式是必要的。可处置模式实现了Dispose(bool disposing)方法,如 GitHub 上CH02_ObjectCleanup项目的源代码所示。这就是我们在本次演示中要做的。按照以下步骤操作:
-
启动一个新的.NET 控制台应用程序。然后,添加一个名为
DisposableBase的类,如下所示:public abstract class DisposableBase : IDisposable { protected bool _disposed = false; } -
在这里,我们声明了该类为抽象类并实现了
IDisposable接口。我们的_disposed布尔值将被子类访问,因此我们需要声明它是受保护的。添加Dispose()方法,如下所示:public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } -
此方法调用
Dispose(bool disposing)方法,它清理了托管和非托管资源。然后,它停止终结器的执行。让我们添加终结器:~DisposableBase() { Dispose(false); } -
如果我们的终结器运行了——并且它并不保证一定会运行——当程序员未能调用
Dispose()方法时,它将调用Dispose(bool disposing)方法。现在,让我们添加DisposableBase类的最后一部分——即Disposable(bool disposing)方法:protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) { // Free up any managed objects here. } // Free up any unmanaged objects here. // Set large fields to null. _disposed = true; } -
如果我们的类已经被处置,那么我们可以退出方法。如果类尚未被处置,那么我们必须释放托管资源。一旦托管资源被清理,我们可以清理非托管对象并将大字段设置为 null。最后,我们必须将
_disposed布尔值设置为true。
当一个类继承我们的抽象类时,其终结器将调用Dispose(false)。子类将重写Dispose(bool disposing)方法。
要创建对象和销毁它,你可以使用以下代码:
var objectThree = new ObjectThree();
objectThree.Dispose();
这里,ObjectThree类被实例化,然后通过调用Dispose()方法被处置。
这就结束了本章关于 C#互操作性的内容。让我们总结一下我们学到了什么。
摘要
在本章中,我们通过使用指针代码来探讨 C#互操作性方面的 P/Invoke。我们研究了不安全代码和固定代码。不安全代码是.NET 平台未管理的代码,而混合代码是内存中固定的对象,由于使用指针访问,因此不会被垃圾回收器提升。
然后,我们学习了如何调用 C++ DLL 中的方法,包括传递参数和返回结构体。
接下来,我们学习了如何与 Python 代码交互。我们学习了如何安装 Python,然后添加 IronPython NuGet 包。这使我们能够直接在 C#类中执行 Python 2.x 代码,并执行位于 Python 脚本中的 Python 代码。IronPython 2.7.10 库仅支持 Python 2.x 版本。
然后,我们通过从 Excel 电子表格中读取数据学习了如何执行 COM 互操作性。我们还创建了一个 Excel 插件,该插件能够读取活动单元格的数据并显示一个消息框。
最后,我们学习了如何安全地处理托管和非托管对象。我们创建了一个可重用的抽象类,名为DisposableBase。此时,你知道在子类终结器中调用Disposable(false),如果未调用Dispose(),以及如何在基类中重写Disposable(bool disposing)。
现在,是时候回答一些问题来巩固你的学习,然后再进入进一步阅读部分。在下一章中,我们将学习关于基本类型和对象类型的内容。
问题
回答以下问题以测试你对本章知识的掌握:
-
P/Invoke 的缩写是什么?
-
解释 P/Invoke 是什么。
-
unsafe关键字用于什么? -
解释对象生成。
-
fixed关键字用于什么? -
C++的字符串类型是什么?
-
你需要导入哪个 NuGet 包来处理 Python 代码?
-
你使用什么模式来安全地处理托管和非托管对象?
-
你如何处置大字段?
进一步阅读
要了解更多关于本章所涉及的主题,请查看以下资源:
-
非安全代码语言规范:
docs.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/unsafe-code. -
C#入门教程:什么是非安全代码?
www.youtube.com/watch?v=oIqEBMw_Syk. -
与未管理代码交互:
docs.microsoft.com/en-us/dotnet/framework/interop/. -
互操作整理:
docs.microsoft.com/en-us/dotnet/framework/interop/interop-marshaling. -
使用平台调用整理数据:
docs.microsoft.com/en-us/dotnet/framework/interop/marshaling-data-with-platform-invoke. -
P/Invoke 技巧:
benbowen.blog/post/pinvoke_tips/. -
调试终结器:
docs.microsoft.com/en-us/archive/msdn-magazine/2007/november/net-matters-debugging-finalizers. -
.NET 内存性能分析:
github.com/Maoni0/mem-doc/blob/master/doc/.NETMemoryPerformanceAnalysis.md#The-effect-of-a-generational-GC. -
提高 VSTO 插件性能:
docs.microsoft.com/en-us/visualstudio/vsto/improving-the-performance-of-a-vsto-add-in?view=vs-2019. -
当你知道的一切都是错误的时候,第一部分:
ericlippert.com/2015/05/18/when-everything-you-know-is-wrong-part-one/. -
.NET 内存性能分析:
github.com/Maoni0/mem-doc/blob/master/doc/.NETMemoryPerformanceAnalysis.md. -
OLE/Automation BSTR (字符串操作函数):
docs.microsoft.com/previous-versions/windows/desktop/automat/string-manipulation-functions -
如何从 C#传递对象数组到 C++:
alekdavis.blogspot.com/2012/07/how-to-pass-arrays-of-objects-from-c-to.html.
第三章:第三章:预定义数据类型和内存分配
在本章中,你将了解 C# 预定义(即 内置)数据类型和 C# 对象类型,以及不同类型的 内存分配。
提高应用程序性能的最基本要求是理解预定义数据类型及其大小。在某些情况下,应用程序的内存使用可能至关重要。了解数据类型的大小和它们所持有的值可以帮助你做出准确的内存使用估计,正如内存分析工具如 dotTrace 和 dotMemory 所做的那样,这些工具由 JetBrains 开发。我们将在下一章讨论 dotTrace 和 dotMemory 的使用。了解不同类型的内存分配及其对代码性能的影响也是有意义的。在这里,我们将使用 BenchmarkDotNet 对各种操作的性能进行基准测试。
在本章中,我们将涵盖以下主题:
-
理解预定义的 .NET 数据类型:在本节中,我们将回顾 C# 编程语言中内置的 C# 值类型和对象类型。理解这些类型及其字节大小在需要提供内存使用估计时很有用。
-
理解 C# 中使用的各种内存类型:在本节中,我们将深入了解 C# 中使用的不同类型的内存,包括 栈、堆、小对象堆 和 大对象堆。了解数据存储在内存中的方式和位置很有用,这可能会对应用程序的性能产生重大影响。例如,你知道值类型并不总是存储在栈上吗?
-
按值传递和按引用传递:在本节中,我们将介绍按值传递和按引用传递之间的区别,以及这对原始变量的影响。你还将了解按值传递和按引用传递在内存中的工作方式。
-
装箱和拆箱:在本节中,我们将讨论当我们对变量进行 装箱 和 拆箱 时内存中发生的情况,并探讨装箱和拆箱如何对程序性能产生负面影响。你将使用反汇编器查看执行装箱和拆箱的中间语言命令。
到本章结束时,你将具备以下技能:
-
你将理解不同值类型的大小。
-
你将理解不同的引用类型。
-
你将理解不同类型的内存及其分配方式。
-
你将理解按值传递和按引用传递之间的区别。
-
你将理解装箱和拆箱如何对性能产生负面影响以及原因。
我们将首先查看跟随本章的技术要求,然后,我们将继续探讨各种预定义的 C# 数据类型。
技术要求
-
必需:Microsoft Visual Studio 2022,最新版本 – 预览版
-
必需:BenchmarkDotNet
本章的代码文件可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH03
您需要克隆 Git 仓库并执行发布构建。编译后的可执行文件将在 C:\Development\perfview\src\PerfView\bin\Release\net45 下找到。
理解预定义 .NET 数据类型
预定义数据类型有两种:
-
引用类型
-
值类型
引用类型是对象和字符串。值类型包括枚举和结构类型。结构类型是由简单类型组成的聚合。简单类型包括布尔型、字符型和数值类型。
有三种主要的数值类型:十进制类型、浮点类型和整数类型。浮点类型包括十进制、双精度和单精度。整数类型包括字节、短整型、整型、长整型、值元组和字符。
我们将在本章后面更详细地讨论栈和堆。但就目前而言,我们应该了解栈是 非托管 内存,而堆是 托管 内存。
值类型位于栈上。数组中的值类型位于堆上。而引用类型位于堆上,它们的指针位于栈上。
注意
即使数组在某些场景下不是理想的选择,但在大多数情况下,数组通常会比列表和其他数据结构运行得更快。数组的内容会连续地放置在堆上。数组变量将放置在栈上,其内容在栈上将是堆上数组内存地址的指针。
栈和堆是 .NET 中的两种主要内存类型,正如之前提到的,我们将在本章后面详细讨论它们。
现在,让我们看看 C# 中的预定义值类型。
理解 C# 中的预定义值类型
在本节中,我们将描述每个预定义值类型及其字节数。这对于选择正确的数据类型以改善应用程序的内存性能非常重要。对于 C# 新手来说,应该知道 有符号 数据类型是可以有 正 值和 负 值的数据类型,而 无符号 数据类型是只能有 正 值的数据类型。
表 3.1 描述了不同的值类型、它们的内存大小、是否可以为空,以及它们的默认值、最小值和最大值,并在适用的情况下提供注释:

表 3.1 – C# 中的预定义值数据类型
注意
enum 数据类型的大小为 4 字节(即 32 位),是可空的,并且最小值为 0。你可以使用 sizeof(Type type) 来测量值类型的大小。自定义结构体可以使用 Marshal.SizeOf(typeof(NameOfCustomStruct)) 来测量。ValueTuple 数据类型的大小为 1 字节(8 位),并且随着每个类型参数的增长而增长。例如,ValueTuple<double, double, double> 的大小为 24 字节(192 字节)。
我们现在将探讨理解 C# 中的预定义引用类型。
理解 C# 中的预定义引用类型
引用类型是一种放置在称为托管堆的托管内存中的类型。C# 中预定义的四种引用类型是对象类型、字符串类型、委托类型和动态类型。
注意
不幸的是,对于引用类型,你不能使用 sizeof(它属于对象类型)来获取引用类型的大小,并且 BinaryFormatter 类已经被弃用。这意味着你不能将对象序列化为二进制,将其保存到内存流中,并从内存流的位置获取其大小。
然而,我们建议使用 JSON 来序列化和反序列化对象。然后我们可以将 JSON 赋值给内存流,这样做的话,内存流的长度将给出我们的对象在内存中的大小。
让我们逐一查看这些类型在内存使用方面的表现。
描述对象引用类型
.NET 的 System.Object 类型在 C# 中被别名为 object。C# 中的所有类型要么直接要么间接继承自 System.Object。这包括预定义类型和用户类型(如类、枚举和结构体)、引用类型和值类型。对象可以是可空的。
要以编程方式获取对象的内存大小,可以将它们序列化为 XML 或 JSON,并将它们加载到内存流中,内存流的长度将给出你的对象大小(以字节为单位)。或者,你可以使用像 dotMemory 这样的工具来分析你的应用程序的内存使用情况。
描述字符串引用类型
string 类型为每个字符使用 2 个字节(16 位)。因此,我们著名的简短 string,Hello, World!,它使用了 13 个字符,长度为 13 x 2 字节,相当于 26 字节(208 位)的内存。字符串可以是可空的,也可以是空的。
在 .NET 中,字符串是不可变的。但我们的意思是什么?
当你创建一个 string 类型时,它会被添加到堆中。一个变量会被添加到栈上,该变量包含一个指向堆上字符串位置的地址指针。如果你将 string 类型添加到另一个变量中,该变量将被放置在栈上,并且它将持有堆上相同字符串的地址的副本。但是,如果你将一个现有的 string 类型与另一个 string 类型连接,内存中会创建一个新的 string 类型来保存现有的 string 类型,以及要连接的 string 类型。string 类型的地址指针在栈上被更新,以指向这个新位置。
构建不可变字符串示例程序
我们将编写一个简单的 CH03_StringsAreImmutable。然后,按照以下方式更新 Main(string[] _) 方法:
static void Main(string[] _)
{
Console.WriteLine("Chapter 3: Strings are immutable");
var greeting1 = "Hello, world!";
var greeting2 = greeting1;
Console.WriteLine($"greeting1={greeting1}");
Console.WriteLine($"greeting2={greeting2}");
greeting1 += " Isn't life grand!";
Console.WriteLine($"greeting1={greeting1}");
Console.WriteLine($"greeting1={greeting2}");
}
我们将输出一个标题到控制台,然后我们将 greeting1 string 类型设置为 "Hello, world!"。然后,我们将 greeting1 赋值给 string 类型的 greeting2。两个 string 变量的内容都输出到控制台窗口。然后,我们在 greeting1 的末尾追加 " Isn't life grand!" 来修改 greeting1。接下来,我们输出 greeting1 和 greeting2 的内容。运行程序,你应该看到以下内容:


Figure 3.1 – 不可变字符串示例
如你所见,尽管我们将 greeting1 赋值给 greeting2 并更新了 greeting1,但 greeting2 保持不变。因此,我们现在在堆上有两个字符串。我们有 "Hello, world!",我们还有 "Hello, world! Isn't life grand!"。因此,从我们的小例子中,我们可以看到字符串确实是不可变的。现在,我们将描述 delegate 引用类型。
描述委托引用类型
delegate 类型必须具有相同的签名和返回类型。当你编译使用委托的代码时,会创建一个继承自 System.MulticastDelegate 的私有密封类。
注意
请查看以下链接中的 第 I.8.9.3 节 以获取有关委托的更多信息:www.ecma-international.org/publications/files/ECMA-ST/ECMA-335.pdf。
我们现在将描述 dynamic 引用类型。
描述动态引用类型
类型检查是在编译时执行的。这确保了在应用程序在运行时执行时的类型安全。类型安全旨在防止由类型之间的差异引起的错误或不希望的程序行为。
定义为 dynamic 的类型在编译时绕过类型检查,因为它们和成员在运行时解析。dynamic 类型的优点是它简化了我们访问 COM API(如 Office Automation API)到动态 API(如 IronPython 库)以及到 HTML 文档对象模型(DOM)的访问。
动态类型在编译时作为对象存在,并在运行时作为对象存在。dynamic 类型仅在编译时存在,而不是在运行时。当 dynamic 类型被编译时,它成为 object 类型。在本节稍后,在我们编写并构建我们的控制台应用程序之后,我们将使用 ILDASM 来显示编译后的动态变量的 IL 类型。
当对象首次运行时,它会被运行时正确解析。这种解析会产生一个性能惩罚,这取决于正在解析的类型,可能会相当大。由于 dynamic 被编译成对象,因此会发生装箱和拆箱。正如你所知,装箱会消耗处理器周期。
让我们演示在声明变量和为它们赋值时使用不同的 var 和 dynamic 变体与使用正确的类型并无需进行转换赋值时的性能差异。
启动一个新的 .NET 6 控制台应用程序,名为 CH03_DynamicPerformance。你需要以下引用:
using System;
using System.Diagnostics;
using System.Security.Cryptography;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
在 Program 类的顶部添加一个新的成员变量:
dynamic _dynamicType;
在我们运行基准测试后,将使用 ILDASM 对这个变量声明进行调查。接下来,更新 Main(string[] _) 方法如下:
static void Main(string[] _)
{
BenchmarkRunner.Run<BenchmarkTests>();
}
我们在名为 BenchmarkTests 的类中运行基准测试。使用与前面示例相同的语句添加一个新类 BenchmarkTests。然后,添加 MeasureVarUsage() 方法:
[Benchmark]
public void MeasureVarUsage()
{
var x = 3.14159;
}
这个方法将一个 double 对象分配给运行时解析类型的 x 变量。接下来,添加 MeasureVarDynamicUsage() 方法:
[Benchmark]
public void MeasureVarDynamicUsage()
{
var x = (dynamic)3.14159;
}
这里,我们仍然在运行时解析类型的 x 变量上分配一个数字。但这次,我们在数字前加上 (dynamic) 转换。记住,dynamic 关键字只存在于编译时。当编译时,dynamic 类型变为 object 类型。现在,添加 MeasureTypeDynamicUsage() 方法:
[Benchmark]
public void MeasureTypeDynamicUsage()
{
double x = (dynamic)3.14159;
}
这次,我们将变量声明为 double 并将分配的数字转换为 (dynamic)。在运行时,这个数字将被包装在 object 类型中,因此需要解包。并且为我们的最终方法,添加 MeasureTypeTypeUsage() 方法:
[Benchmark]
public void MeasureTypeTypeUsage()
{
double x = 3.14159;
}
在这个方法中,我们声明一个 double 类型并分配一个 double 类型。以发布模式编译项目。然后,打开命令行并导航到您的发布文件夹。输入可执行文件名并按 Enter。这将导致 BenchmarkDotNet 检测项目中的基准测试并依次运行它们。你应该会看到一个类似于以下摘要的结果,尽管平均时间可能不同:

图 3.2 – 变量类型声明和赋值的基准平均时间
图 3.2 展示了当我们根据使用的方法声明变量和赋值时,性能存在差异。声明和赋值最快组合是 var variableName = (dynamic)value。
好的,我们已经运行了基准测试。那么,让我们查看动态变量的 IL 代码。打开开发者命令提示符,然后输入 ildasm.exe 并按 Enter。这将启动 ILDASM 应用程序。
注意
.NET Core 和 .NET 6 应用程序与之前的 .NET Framework 版本编译方式不同。之前,ILDASM 会打开编译后的可执行文件。但 .NET Core 和 .NET 6 应用程序被编译成 动态链接库(DLL),并生成一个本地可执行文件来运行结果 DLL 中的代码。
打开您的编译好的 DLL。展开 CH03_DynamicPerformance 节点,然后展开 CH03_DynamicPerformance.Program 节点。然后,找到如 图 3.3 所示的 _dynamicType : private object 行调用:


图 3.3 – ILDASM 显示编译器在编译时将动态类型转换为对象类型
如您所见,我们的 dynamic 类型在编译时被编译成 object 类型。作为一个小练习,您可以调整 ILDASM 设置并查看 BenchmarkTests 类的代码。现在,让我们看看静态类型。
理解静态类型
在 .NET Core 和 .NET 5.0 之前的 .NET 版本中,当您编译和运行应用程序时,它们将在自己的应用程序域中运行。如果您多次运行应用程序,每个运行实例都将有自己的应用程序域。在 ASP.NET 中,您为单个应用程序使用多个应用程序域。当在 ASP.NET 应用程序中使用静态类型时,这变得很重要。在单个应用程序域中,将只有一个静态类型的实例。在可以使用之前,运行时必须创建静态类型的实例。
AppDomain 对象拥有自己的静态堆。静态值和引用类型将被放置在静态堆上,并由应用程序域管理。静态类型会被垃圾回收器考虑,但它们永远不会被回收。垃圾回收器考虑它们的原因是它们可能引用其他堆上的对象。其他应用程序域中的静态类型和变量彼此隔离。
在 AssemblyLoadContext 类中用于动态加载程序集。通过 进程和/或容器,Microsoft 意味着您应该将单个应用程序/模块拆分为单独的、相互交互的应用程序/模块/进程/容器。因此,Microsoft 鼓励您使用微服务重构代码,这样您就不再需要使用应用程序域。
System.Runtime.Loader.AssemblyLoadContext 对象表示一个加载上下文。一个 加载上下文 为加载、解析和卸载程序集创建了一个作用域。有关 AssemblyLoadContext 类的更多信息,请参阅官方 Microsoft 文档:docs.microsoft.com/dotnet/api/system.runtime.loader.assemblyloadcontext?view=net-5.0。
静态类仅由运行时实例化一次。您不能自己实例化一个静态类。静态构造函数在类被加载到内存时执行。如果一个非静态类有一个静态构造函数和一个实例构造函数,静态构造函数将在实例构造函数之前被调用。静态构造函数是无参的,并且每个类只能有一个静态构造函数。静态构造函数没有访问修饰符。当类加载时为静态变量分配内存,当类卸载时释放内存。变量、构造函数和方法属于类,而不是实例化的对象。因此,修改变量将修改类的所有实例中的变量。
在调用栈上,静态方法通常比实例方法调用更快。编译器会发出非虚拟调用站点静态成员。非虚拟调用站点防止了运行时检查,这些检查确保当前对象指针非空。尽管你可能看不到任何可视的性能改进,但对于性能敏感的代码,性能提升是可以衡量的。
现在我们已经涵盖了各种预定义的 C#数据类型,是时候看看 C#的内存以及它是如何工作的了。
理解 C#中使用的各种内存类型
C#中有两种主要的内存类型:栈和堆。堆进一步分为小对象堆和大对象堆。在物理内存方面,栈和堆之间没有区别,因为它们都存储在物理内存中。它们的不同之处在于它们的实现。
当你的应用程序启动时,它会被分配一部分内存。一个指针将被分配给你的应用程序,这将是你应用程序的内存起始点。指针上方是栈,指针下方是堆。堆向下增长,栈向上增长,如图 3.4所示:
![Figure 3.4 – The stack, heap, and application starting point memory address]
![Figure 3.4_B16617.jpg]
图 3.4 – 栈、堆和应用启动点内存地址
以下图表直观地表示了一个简单程序中的栈和堆:
![Figure 3.5 – The stack and heap at work]
![Figure 3.5_B16617.jpg]
图 3.5 – 栈和堆在工作
要理解 C#中的不同类型的内存,首先,我们将看看栈以及它是如何操作的。
栈
栈用于存储值类型和指向堆上内存位置的指针。当你调用一个方法时,它会被添加到栈上的一个栈帧中。然后,在该帧内,值类型被添加到栈上。如果有任何引用类型在方法中,这些类型将被放置在堆上,并且一个变量将被放置在栈上,并分配一个指向堆上引用类型内存地址的指针。
注意
尽管我们可以声明值类型被添加到栈上,但这并不总是正确的。例如,如果你有一个整数数组,由于数组是一个引用类型,它将被添加到堆上,并且数组中的每个整数将连续添加到堆上。
如果一个struct对象具有引用类型,该结构体将被放置在栈上,引用类型将被放置在堆上,并且将引用类型在堆上的地址的指针存储在堆上的变量中。
栈比堆快。它像栈数据结构一样排列。当你执行一个方法时,该方法被添加到栈上的栈帧中。然后,局部变量依次添加到栈帧的顶部。当方法执行完成后,内存立即被回收。然而,堆必须跟踪内存分配、指针和引用计数器,而栈则不需要以这种方式管理自己。
小贴士
使用栈,你可以简单地从栈上弹出和添加东西。为了提高应用程序的性能,查找应用程序中的堆使用情况。测量使用栈和使用堆时的性能。如果栈更快,那么用栈使用替换堆使用。
请记住,使用内存的成本不是在分配时,而是在释放时。栈上项的释放比堆上项的释放更可预测。在某些情况下,垃圾收集器在释放 0 代或 1 代内存时执行类似的指针运算。
内存调用也很昂贵,因为它们被放置在栈上,但可能也引用堆。方法性能受未执行代码的影响。因此,你应该重构你的方法,使其尽可能小,并删除任何不会执行的代码,例如不再使用的死代码。这将减少正在使用的局部变量数量,从而减少栈大小。这样,你将消除性能损失。
堆
堆用于存储引用类型。它们被称为引用类型,因为它们是引用计数的。引用计数意味着运行时会保持引用分配的引用类型的变量计数。当引用计数减少到零时,引用类型将由垃圾收集器释放。例如,如果我在内存中有一个产品对象,并且有两个在栈上指向该对象的变量,那么产品对象有一个引用计数为二。
你可能会惊讶地发现,在 C# 中分配对象有时可能比在 C++ 中更快。但在垃圾回收方面,C# 需要付出代价。因此,实例化许多对象并不会给我们带来太多成本,但这些对象的清理却会。这意味着你创建的对象越多,垃圾收集器的工作就越困难,这会负面影响你的应用程序性能。因此,如果可以使用值类型,请避免使用引用类型。如果你不需要,请不要创建对象。
当创建一个新的对象时,它会被放置在堆上。变量会被放置在栈上,并分配一个指向堆上对象地址的指针。
引用类型数组被放置在堆上。引用数组的变量将被放置在栈上,并分配给堆上数组的内存地址。数组本身将包含一个连续的内存地址列表,如图 图 3.5 所示:

图 3.6 – 堆中显示对象及其在数组中的内存地址
这些内存地址是指向堆上引用类型地址位置的指针。这是因为当一个包含引用类型的数组放置在堆上时,数组中的每个引用类型都被分配到它自己的内存区域。然后,引用类型的内存地址被放置在数组内部。
注意
优先考虑了数组性能,其次是字符串性能。数组通常比列表和其他数据结构更快。但最好使用基准测试来决定哪种情况更适合你,并选择最适合你的数据结构。
当谈到最大化内存使用性能时,你需要确保堆上的对象尽可能靠近它们的引用指针。这样做的原因是为了减少定位指针所引用的内存所需的 CPU 周期。关于内存性能的经验法则是:内存距离其指针越远,它在你 CPU 性能上的成本就越高。尽管如此,必须指出的是,预测性内存访问大大减少了这一点,内存使用可能取决于系统页面文件设置。
注意
你实例化数组、实例化对象、为对象赋值以及为数组和对象赋值和分配值的顺序会影响你应用程序的性能。这取决于这些项目在内存中的放置。记住,堆上的项目应该靠近它们的内存指针,这些指针可能存储在堆上或栈上。
如前所述,堆上的对象释放比栈上的释放慢。你添加到堆上的对象越多,性能就会越慢。这是因为频繁的分配和释放导致垃圾收集器有更多的工作要做。正是这种分配和释放的循环导致了性能问题。
主堆中有两个堆:
-
小对象堆:当一个新对象被实例化时,如果其大小小于 80,000 字节,它将被放置在小对象堆上作为第 0 代。
-
大对象堆:当一个新对象被实例化,其大小为 80,000 字节或更大时,它将被添加到大对象堆上。大对象总是分配在第 2 代,因为它们只在第 2 代收集期间进行垃圾回收。
当我们查看垃圾收集时,我们将更详细地查看堆,见 第四章,内存管理。
构建栈与构建堆(示例项目)
现在,我们将编写一个简单的项目,该项目将获取具有和没有引用类型属性的实例化和结构体的 tick 数。首先,添加一个新的 .NET 6 控制台应用程序,名为 CH03_StackAndHeap。然后,添加 BenchmarkDotNet nuget 包。你需要使用以下 using 语句:
using System;
using System.Diagnostics;
using System.Security.Cryptography;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
然后,更新 Main(string[] _) 方法,如下所示:
static void Main(string[] _)
{
BenchmarkRunner.Run<BenchmarkTests>();
}
在该方法中,我们正在调用包含我们的基准测试的 BenchmarkTests 类。现在,添加 ClassNoReference 类:
internal class ClassNoReferences
{
public ClassNoReferences(
int id,
decimal price,
DateTime purchaseDate
)
{
Id = id;
Price = price;
PurchaseDate = purchaseDate;
}
public int Id { get; private set; }
public decimal Price { get; private set; }
public DateTime PurchaseDate { get; private set; }
}
这个类有三个值类型属性而没有引用类型属性。在 BenchmarkTests 类中添加 ProcessClassNoReferences() 方法:
[Benchmark]
public void ProcessClassNoReferences()
{
var _ = new ClassNoReferences()
{
1,
1.50M
DateTime.Now
};
}
ProcessClassNoReferences() 方法声明了一个新的 ClassNoReferences 类实例。它将被用作基准测试方法。添加 StructNoReferences 类:
internal class StructNoReferences
{
public StructNoReferences(
int id,
decimal price,
DateTime purchaseDate
)
{
Id = id;
Price = price;
PurchaseDate = purchaseDate;
}
public int Id { get; private set; }
public decimal Price { get; private set; }
public DateTime PurchaseDate { get; private set; }
}
这个结构有三个值类型属性而没有引用类型。让我们向 BenchmarkTests 类添加 ProcessStructNoReferences() 方法:
[Benchmark]
public void ProcessStructNoReferences()
{
var _ = new StructNoReferences()
{
1,
1.50M,
DateTime.Now
};
}
ProcessStructNoReferences() 方法将被用作基准,并创建一个新的 StructNoReferences 结构体。接下来,添加 ClassWithReferences 类:
class ClassWithReferences
{
public ClassWithReferences(
int id,
string name,
decimal price,
DateTime purchaseDate,
Dictionary<string, string> keyValueData
)
{
Id = id;
Name = name;
Price = price;
PurchaseDate = purchaseDate;
KeyValueData = keyValueData;
}
public int Id { get; private set; }
public string Name { get; private set; }
public decimal Price { get; private set; }
public DateTime PurchaseDate { get; private set; }
public Dictionary<string, string> KeyValueData
{ get; private set; }
}
这个类具有值类型和引用类型属性。现在,我们将添加 ProcessClassWithReferences() 方法:
[Benchmark]
public void ProcessClassWithReferences()
{
var _ = new ClassWithReferences(
Id = 1,
"The quick brown fox jumped over the lazy dog.",
1.50M,
DateTime.Now,
);
}
ProcessClassWithReferences() 方法将被用作基准,并创建一个 ClassWithReferences 类的实例。接下来,我们将添加 StructWithReferences 结构体:
internal struct StructWithReferences
{
public StructWithReferences(
int id,
string name,
decimal price,
DateTime purchaseDate,
Dictionary<string, string> keyValueData
)
{
Id = id;
Name = name;
Price = price;
PurchaseDate = purchaseDate;
KeyValueData = keyValueData;
}
public int Id { get; private set; }
public string Name { get; private set; }
public decimal Price { get; private set; }
public DateTime PurchaseDate { get; private set; }
public Dictionary<string, string> KeyValueData
{ get; private set; }
}
这个结构体具有值类型和引用类型。现在,我们将添加我们的最终方法,ProcessStructWithReferences():
[Benchmark]
public void ProcessStructWithReferences()
{
var _ = new StructWithReferences()
{
Id = 1,
Name = "Discard",
Price = 1.50M
};
}
ProcessStructWithReferences() 方法将被用作基准,并创建一个新的 StructureWithReferences 结构体。
以发布模式编译代码。然后,运行可执行文件。你的代码将被基准测试,你将看到以下基准报告:
![图 3.7 – 比较带和不带引用的结构体和类的基准报告
![img/Figure_3.7.jpg]
图 3.7 – 比较带和不带引用的结构体和类的基准测试报告
基准测试结果揭示了以下见解:
-
没有引用的类的处理比没有引用的结构体的处理更快
-
使用引用处理类比使用引用处理结构体慢
如基准测试结果所示,根据场景的不同,结构体可能比类更快,反之亦然。这是基准测试代码的好理由,因为你可能会认为你的代码是最佳的,而实际上它很慢。
那么,你如何选择使用结构体还是类呢?
在结构体和类之间进行选择
按照经验法则,微软建议我们将我们的类型定义为类。如果一个类型嵌入在其他对象中,或者如果它是短暂的,那么考虑使用结构体。在定义结构体时,它应该具有以下特征:
-
从逻辑上讲,结构体代表一个单一值。
-
结构体实例的大小小于 16 字节。
-
结构体是不可变的。
-
结构体不经常进行装箱和拆箱。
结构体 是一种 值类型。值类型是在栈上分配或在包含类型内内联分配的。值类型将在栈展开或包含类型的释放期间被释放。值类型不会被垃圾回收。在栈上分配和释放值类型被认为是廉价的。然而,当一个值类型装箱时,它会被包裹在一个引用类型中或被转换为接口,这会导致性能下降。当值类型从引用类型内部解包时,也会经历性能下降,这被称为 拆箱。出于性能原因,你应该尽可能避免对值类型进行装箱和拆箱。当你分配值类型时,值的完整副本会被传递到分配中。大型值类型的分配可能比大型引用类型的分配更昂贵。
类 是一种 引用类型。引用类型是在堆上分配的对象,其内存位置通过指针放置在栈上。当引用类型的生命周期结束时,它会被垃圾回收。与在栈上分配和释放值类型相比,在堆上分配和释放引用类型被认为是昂贵的。与值类型不同,在转换引用类型时不会发生装箱。当你分配一个引用类型时,引用的副本会被传递给分配的变量。大型引用类型的分配可能比大型值类型的分配更便宜。
引用类型数组的数组包含指向堆上实际类型的指针。值类型数组的数组包含那些引用类型的实际值。值类型数组的分配和释放被认为是廉价的,并且与引用类型数组相比,它们具有更好的局部性,因为值类型的值是内联的。
让我们继续看看 按值传递 和 按引用传递。
按值传递和按引用传递
当将值传递到方法或构造函数时,有两种方式可以这样做。它们是按值传递和按引用传递:
-
按值传递:默认情况下,所有值类型都是通过使用复制语义按值传递到构造函数和方法的。这意味着会复制传递的值。原始值保持不变,并且使用构造函数或方法使用的是副本。
-
按引用传递:当一个引用类型被传递到构造函数或方法中时,在堆栈上创建一个变量,该变量指向堆上相同对象。因此,传入的变量和构造函数或方法内部使用的副本变量都在内存中操作同一个对象。
现在我们已经知道了按值传递和按引用传递是什么,让我们写一个简单的程序来演示我们所学的。
构建按引用传递的示例程序
我们将编写一个非常简单的程序来演示按值传递和按引用传递的效果。添加一个新的.NET 6 控制台应用程序,名为CH03_PassByValueAndReference。然后,按照以下方式修改Main(string[] _)方法:
static void Main(string[] args)
{
int x = 0;
Console.WriteLine("Chapter 3: Pass by value and reference");
Console.WriteLine($"=====================================");
Console.WriteLine($"int x = 0;");
AddByValue(x);
Console.WriteLine($" AddByValue(x): {x}");
AddByReference(ref x);
Console.WriteLine($"AddByReference(x): {x}");
}
在这里,我们声明了一个名为x的整数并给它赋值为0。一些文本被输出到控制台窗口,我们调用两个方法并在它们被调用后输出x的值。让我们添加第一个被调用的方法——AddByValue(int x)方法:
static void AddByValue(int x)
{
x++;
}
如您所见,这是一个非常简单的函数,它增加了传入变量的值。现在,让我们重复同样的过程,但这次我们将按引用传递值:
static void AddByReference(ref int x)
{
x++;
}
运行程序,你应该看到以下输出:

图 3.8 – 使用按值传递和按引用传递增加 x 后的值
我们可以看到,当我们按值传递时,原始值不会被更新。但是当我们按引用传递时,它会更新。现在我们将扩展应用以涵盖in参数修饰符。
使用in关键字传递的参数是按引用传递的。然而,in参数不能被修改。让我们演示这一点——添加一个名为InParameterModifier()的新方法:
static void InParameterModifier()
{
int argument = 13;
InParameterModifier(argument);
Console.WriteLine(argument);
}
在InParameterModifier()方法中,我们创建了一个整数并将其赋值为13。然后我们调用一个同名的函数并将变量作为参数传入。然后,我们将值打印到控制台窗口。现在,我们将编写InParameterModifier(in int argument)方法:
static void InParameterModifier(in int argument)
{
// Error CS8331: Cannot assign to variable 'in int'
// because it is a readonly variable.
// argument = 47;
}
代码被注释掉了,因为如果我们给参数赋值,你将看到注释中提到的编译器警告。从Main(string[] _)对象中调用该方法并运行程序。你会看到变量保持在13,因为编译器阻止我们在被调用的方法中更改它。最后,在我们程序的下一部分,我们将探讨out关键字。
out参数在传递之前不需要初始化。这与在传递之前必须初始化的ref值不同。所有out参数都是通过引用传递的。在方法内部对参数进行的任何操作都对外部代码可见,这些代码可以看到参数。以下是一个示例,这将使理解更容易。
我们将添加两个方法来演示out参数的工作方式。向Program类添加一个名为OutParameterModifier()的新方法:
static void OutParameterModifier()
{
int x;
OutParameterModifier(out x);
Console.WriteLine($"The value of x is: {x}.");
}
在前面的代码中,我们声明了一个整数变量。然后,我们调用一个具有out参数的方法,并将我们的整数及其默认值0传递给它。接下来,在方法返回后,我们打印出整数的值。现在,添加outParameter(out x)方法:
static void OutParameterModifier(out int argument)
{
argument = 123;
}
这里,我们只是将参数设置为123并退出。从Main(string[] _)中调用OutParameterModifier()方法。如果你运行代码,你会看到我们调用的方法中的整数被更新为123。这如图3.9所示:
![Figure 3.9 – Our integer has been updated inside the method we passed it into
![Figure 3.9 – Our integer has been updated inside the method we passed it into
图 3.9 – 我们传递的方法中的整数已被更新
在下一节中,我们将探讨装箱和解箱。
装箱和解箱
装箱和解箱变量会对应用程序的性能产生负面影响。为了提高应用程序的代码质量,你应该尽力避免装箱和解箱 - 尤其是在代码是关键任务时。在本节中,我们将探讨当你包装(即装箱)类型时会发生什么。
执行装箱
当一个变量被装箱时,你是在将其包装在一个将被存储在堆上的对象中。正如你所知,堆上的对象会产生成本,因为它们必须由运行时来管理。除此之外,你还会增加变量使用的内存,以及处理该变量所需的 CPU 周期数。
在 32 位操作系统上,一个空的class定义占用 12 字节,而在 64 位操作系统上占用 24 字节。这听起来可能并不多。但如果将不需要装箱的值类型装箱,你将无谓地浪费 12 或 24 字节的内存。
现在,我们将探讨当你解箱一个变量时会发生什么
执行解箱
一个变量被复制到评估堆栈,它引用堆上的一个对象。然后,该变量被解封装(即,解包),并将变量放置在评估堆栈上。然后,可以对解封装的变量执行所需的任何操作。一旦完成对该变量的所有操作,它就必须再次封装并放置在堆上。这将创建堆上的新对象,并且堆栈上的变量将更新为其内存位置。
构建封装与解封装示例程序
现在,我们将编写一个简单的 .NET 6 控制台应用程序,使用 BenchmarkDotNet 来展示不封装与封装/解封装在性能上的差异。首先,启动一个新的 .NET 6 控制台应用程序,并将其命名为 CH03_BoxingAndUnboxing。您需要添加 BenchmarkDotNet 包和以下两个命名空间:
using System;
using System.Diagnostics;
using System.Security.Cryptography;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
我们需要这些命名空间来执行基准测试。在 Main(string[] _) 方法中,添加以下行:
BenchmarkRunner.Run<BoxingAndUnboxingBenchmarkTests>();
这行代码开始运行基准测试。接下来,添加一个名为 BoxingAndUnboxingBenchmarkTests 的新类:
public class BoxingAndUnboxingBenchmarkTests { }
此类将包含两个基准测试方法,分别称为 NonBoxingUnboxingTest() 和 BoxingUnboxingTest()。添加 NonBoxingUnboxingTest() 方法:
[Benchmark]
public void NonBoxingUnboxingTest()
{
int z = 0, a = 4, b = 4;
z = a + b;
}
在此方法中,我们声明并赋值三个整数:z = 0、a = 1 和 b = 6。然后我们将 a 和 b 相加,并将结果赋值给 z。现在,添加 BoxingUnboxingTest() 方法:
[Benchmark]
public void BoxingUnboxingTest()
{
object a = 4, b = 4;
int z;
z = (int)a + (int)b;
}
这次,我们声明并赋值两个对象:a = 4 和 b = 4。我们还声明了一个整数:z。然后,我们将 a 和 b 转换为整数,将它们相加,并将结果赋值给 z 整数变量。
执行您的代码的发布构建。然后,打开命令行并导航到您的可执行文件。从命令行运行您的可执行文件,您应该会看到以下摘要:

图 3.10 – 封装与解封装示例项目添加输出
如您从 图 3.10 中的截图所看到的,解封装确实会增加您应用程序的性能开销。
如果您打开 ILDASM,这将加载中间语言反汇编器。打开您的构建文件夹中的 DLL 文件,展开树形结构,直到您看到 Main : void(string[]) 行,如图 图 3.11 所示:

图 3.11 – 中间语言反汇编器 (ILDASM)
双击 Main 方法。这将打开一个窗口,显示我们的 Main(string[] _) 方法的反汇编中间语言,如图 图 3.12 所示:
![图 3.12 – 我们 Main(string[] _) 方法的反汇编中间语言
图 3.12 – 我们 Main(string[] _) 方法的反汇编中间语言
研究反汇编代码。当你看到box命令时,值类型被封装在一个对象中,这是一个引用类型,它会被放置在堆上。而当你看到unbox.any命令时,值类型被从对象中解包并分配给一个属于栈的 int 值类型。
现在,你理解了为什么装箱和拆箱会影响应用程序的性能,现在我们来到了本章的结尾。在下一章中,我们将关注垃圾回收器的工作原理以及我们可以做些什么来提高其性能。但首先,让我们总结一下我们已经学到的内容。然后,鼓励你回答以下问题,并进一步阅读这个主题。
摘要
我们在本章开始时探讨了各种预定义的.NET 数据类型。首先,我们描述了各种值类型,然后转向预定义的引用类型。然后,我们通过探索静态类型来结束对预定义.NET 数据类型的讨论。
你了解到值类型位于栈上。但如果它们是数组的一部分,它们会与数组一起放置在堆上,而数组是一个引用类型。你还了解到引用类型位于堆上,并且它们有指向它们的指针,这些指针以栈上变量的形式存在。
接下来,我们探讨了 C#中使用的不同类型的内存。首先,我们看到了栈。然后,我们看到了堆,它由小对象堆和大对象堆组成。在查看栈和堆之间的差异后,我们发现栈的执行速度比堆快得多。原因是栈内存不需要由运行时管理。当需要时,它简单地被推入栈中,当不再需要时,它被弹出栈。相比之下,堆必须由运行时管理,它分配对象——它跟踪所有引用这些对象的变量的引用计数,然后在它们不再需要时释放对象。
我们接着探讨了按值传递和按引用传递。按值传递时,会复制一个值并将其传递给构造函数或方法。这个副本被使用,而原始值保持不变。当按引用传递时,会创建一个值的副本并将其放置在栈上,并分配给堆上对象的内存位置。
最后,我们探讨了变量的装箱和拆箱以及为什么这会对应用程序的性能产生负面影响。
通过本章所学的内容,你可以通过使用正确的类型来减少应用程序使用的内存量,并且可以通过避免装箱和拆箱来减少每操作的 tick 数。现在,既然你知道了内存分配的工作原理,你可以在实际可行的情况下通过保持方法小和优先使用栈来提高性能。
在下一章中,我们将学习更多关于垃圾回收的内容。
问题
-
列出预定义的.NET 值类型。
-
列出预定义的引用类型。
-
在静态类型可以被访问和使用之前,运行时必须做什么?
-
使用栈而不是堆的内存在物理上有什么区别,使得栈比堆运行得更快?
-
为什么栈比堆运行得更快?
-
解释为什么字符串是不可变的。
-
放置在小对象堆上的对象的大致大小是多少?
-
放置在大对象堆上的对象的大致大小是多少?
进一步阅读
-
C#类型系统
-
docs.microsoft.com/en-us/dotnet/csharp/programming-guide/types/ -
C#不同类型的堆内存
-
深入.NET 框架内部以查看 CLR 如何创建运行时对象
-
web.archive.org/web/20140724084944/http://msdn.microsoft.com/en-us/magazine/cc163791.aspx -
传递参数(C#编程指南)
-
docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/passing-parameters -
装箱与拆箱(C#编程指南)
-
docs.microsoft.com/en-us/dotnet/csharp/programming-guide/types/boxing-and-unboxing -
Windows 系统上的大对象堆
-
docs.microsoft.com/en-us/dotnet/standard/garbage-collection/large-object-heap -
.NET 内存分配和性能
-
在.NET Core 中替换 AppDomain
第四章:第四章:内存管理
在本章中,我们将探讨对象生成以及如何避免内存问题,然后讨论强引用和弱引用。接着,我们将探讨终结器以及如何通过实现 IDisposable 模式来抑制终结器,以清理托管和非托管资源。最后,我们将从高层次上探讨避免内存泄漏的方法。
在本章中,我们将涵盖以下主题:
-
System.OutOfMemoryException. 我们学习如何在内存耗尽错误发生之前预测它们,通过使用System.Runtime.MemoryFailPoint类。 -
理解长弱引用和短弱引用:在本节中,我们了解长弱引用和短弱引用以及它们如何受到垃圾收集器的影响。
-
终结器:在本节中,我们探讨如何使用终结器来清理资源,并理解为什么我们对它们何时以及是否运行没有控制权。
-
IDisposable模式。 -
防止内存泄漏:在本节中,我们探讨使用 组件对象模型(COM)和托管事件可能成为产生内存泄漏的来源,以及我们可以采取哪些措施来避免产生内存泄漏。在本节中,我们将使用 Microsoft Excel 和 JetBrains dotMemory 来查看如何产生泄漏,以及使用内存分析器如何非常有用,可以帮助识别内存泄漏及其来源。
到本章结束时,你将在以下领域获得技能:
-
理解对象生成
-
理解对象如何被销毁
-
理解为什么最好避免终结器并实现
IDisposable -
理解如何防止由于使用非托管 COM 库和组件以及使用事件而产生的内存泄漏
-
使用匿名方法、长弱引用和短弱引用来提高垃圾收集
技术要求
要完成本章中的步骤,有一些技术要求,如下所述:
-
Visual Studio 2022
-
JetBrains dotMemory
-
源代码:
github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH04
对象生成和避免内存问题
在 .NET 运行时中,有三个对象生成,如下所示:
-
生成 0
-
生成 1
-
生成 2
生成 0 是最年轻的生成,包含短生命周期的对象。小于 80,000 字节的对象是生成 0 对象,当它们被实例化时会被放置在 小型对象堆(SOH)上。80,000 字节或更大的对象通常是生成 2 对象,它们存在于 大型对象堆(LOH)上。生成 1 对象是那些在生成 0 垃圾收集中存活下来的对象,并晋升到生成 1。
生成 0 是垃圾回收主要发生的地方。当对象在生成 0 时没有被回收,它们将被提升到生成 1 以腾出空间,让更多的生成 0 对象能够添加到堆中。如果生成 0 和 1 都满了,那么生成 1 的对象将被提升到生成 2,而生成 0 的对象将被提升到生成 1。如果生成 0、1 和 2 都满了,以至于不能再向堆中添加更多对象,那么你最终会得到一个System.OutOfMemoryException类型的异常。
我们现在将编写一个非常简单的程序,该程序将抛出一个System.OutOfMemoryException类型的异常。请按照以下步骤操作:
-
开始一个新的.NET 6 控制台应用程序项目,命名为
CH04_OutOfMemoryExceptions。将以下using语句添加到Program.cs文件中:using System.Text.RegularExpressions; using System; using System.Collections.Generic; using System.IO; using System.Runtime; using System.Text; -
将以下方法调用添加到
Main方法中:DataExportToCsv(); ReadCsvBroken(); ReadCsvPredictive(); Console.ReadKey(); -
DataExportToCsv()方法构建一个非常大的数据文件。ReadCsvBroken()读取System.OutOfMemoryException类型的异常。在ReadCsvPredictive()方法中避免了异常,因为该方法实例化了MemoryFailPoint类以确保读取的文件数据不会生成异常。如果操作生成了System.OutOfMemoryException类型的异常,那么MemoryFailPoint对象将引发一个OutOfMemoryException类型的异常。这节省了内存和时间,Program类:private static string _filename = @"G:\Temp\SampleData.csv"; -
这将是我们将写入和读取的文件。添加以下
DataExportToCsv()方法:private static void DataExportToCsv() { int row = 0; try { File.Delete(_filename); using (FileStream fs = new FileStream(_filename, FileMode.OpenOrCreate)) { fs.Write(Encoding.Unicode.GetBytes("Id, Name, Description\n")); for (int i = 0; i <= 491616373; i++) { row = i; Console.WriteLine($"Writing row {row} to CSV data. There are {491616373-row} rows remaining."); fs.Write(Encoding.Unicode.GetBytes ($"{i}, Name {i}, Description {i}\n")); } } } catch (Exception ex) { Console.WriteLine($"DataExportToCsv: {ex.GetBaseException().Message}") } } -
此代码将 491,616,373 行数据写入 CSV 文件。添加以下
ReadCsvBroken()方法:private static void ReadCsvBroken() { int row = 0; try { string csv = File.ReadAllText(_filename); } catch (OutOfMemoryException oomex) { Console.WriteLine($"ReadCsvBroken: {oomex.GetBaseException().Message}"); } } -
ReadCsvBroken()方法尝试读取巨大的 44.2string变量。此操作抛出一个System.OutOfMemoryException类型的异常。添加以下ReadCsvPredictive()方法:private static void ReadCsvPredictive() { int row = 0; try { string alphabet = "abcdefghijklmnopqrstuvwxyz"; using (new MemoryFailPoint(alphabet.length)) { string alpha = alphabet; } FileInfo fi = new FileInfo(_filename); Int length = unchecked((int)fi.length); using (new MemoryFailPoint(length)) { string csv = File.ReadAllText(_filename); } } catch (OutOfMemoryException oomex) { Console.WriteLine($"ReadCsvPredictive: {oomex.GetBaseException().Message}"); } } -
此代码使用
MemoryFailPoint类进行预测性内存检查。我们展示了它对alphabet字符串的工作情况,并展示了当文件内容的长度被分配给传递给MemoryFailPoint构造函数的length变量时,它会突出显示一个错误并以OutOfMemoryException类型的异常失败。我们使用未检查的结构体,因为文件长度是一个长值,而这个值太大,无法分配给int数据类型。如果我们使用检查的结构体,则会抛出一个ArithmeticOverflowException类型的异常。 -
编译和运行代码需要数小时。我建议你在
Release模式下编译代码,然后从命令窗口运行可执行文件。代码将成功构建 CSV 文件并将其保存。当一次性读取文件内容时,将生成一个OutOfMemoryException类型的异常。然后,程序将在加载文件之前进行预检查,并在尝试读取文件之前失败,并抛出一个更详细的OutOfMemoryException类型的异常。
预测内存异常可以节省时间并提高应用程序性能,因为你不会浪费 CPU 周期和内存来执行最终会失败的操作。
我们已经看到应用程序如何容易耗尽内存,以及我们如何预测和预防内存异常。现在,让我们继续讨论强引用和弱引用。
理解长弱引用和短弱引用
在 .NET 运行时,有两种类型的引用:长弱引用和短弱引用。这些在这里有更详细的描述:
-
当一个对象的
Finalize()方法被调用时,一个长弱引用会被保留在内存中。你可以在WeakReference构造函数中指定true来定义一个长引用。长弱引用可以被重新创建,尽管其状态可能不可预测。当对象的类型没有Finalize()方法时,将应用短弱引用。弱引用将仅保持到其目标在最终化器运行后某个时间被回收。如果你想创建一个将被重用的强弱引用,你需要将WeakReference构造函数的目标属性强制转换为对象的类型。当对象被回收时,Target属性将是null。如果它不是null,那么你可以继续使用该对象,因为应用程序已经重新获得了对该对象的强引用。 -
WeakReference是一个短弱引用。当垃圾回收器回收一个短弱引用时,其目标变为null。
长弱引用可以保护引用对象免受垃圾回收的影响,而短弱引用则不能。这意味着当垃圾回收执行时,长弱引用的对象将不会被垃圾回收,但短弱引用的对象将会被垃圾回收。我们将通过代码示例来演示这一点。
我们的代码示例将展示长弱引用和短弱引用的工作原理。按照以下步骤进行:
-
首先添加一个新的 .NET 6 控制台应用程序,名为
CH04_WeakReferences。添加以下名为ReferenceObject的类:internal class ReferenceObject { public int Id { get; set; } public string Name { get; set; } }
此类将是我们将要添加到两个不同对象管理器中的引用对象。
-
添加一个名为
LongWeakReferenceObjectManager的新类。然后,添加以下列表字段:private readonly List<ReferenceObject> Objects = new List<ReferenceObject>(); -
我们的只读
Objects列表将包含几种ReferenceObject类型。现在,添加以下方法来向列表中添加项:public void Add(ReferenceObject o) { Objects.Add(o); } -
此方法将一个
ReferenceObject对象添加到引用对象列表中。接下来的任务是添加一个将打印存储对象列表到控制台的方法,如下所示:public void ListObjects() { Console.WriteLine("Long Weak Reference Objects: "); foreach (var reference in Objects) Console.WriteLine($"- {reference.Name}"); }
ListObjects() 方法将列表的内容打印到控制台窗口。这就完成了我们的 LongWeakReferenceObjectManager 类。
-
现在,添加一个名为
ShortWeakReferenceObjectManager的类。在类的顶部添加以下列表字段:private readonly List<WeakReference<ReferenceObject>> Objects = new List<WeakReference<ReferenceObject>>();
注意,列表中的 ReferenceObject 对象被包装在一个 WeakReference 对象中。
-
现在,添加一个方法来向列表中添加项目,如下所示:
public void Add(ReferenceObject o) { Objects.Add(new WeakReference<ReferenceObject>(o)); }
此方法将传入的 ReferenceObject 对象包装在 WeakReference 对象中,并将其分配到列表中。
-
我们现在添加
ListObjects()方法,如下所示:public void ListObjects() { Console.WriteLine("Short Weak Reference Objects: "); foreach (var reference in Objects) { reference.TryGetTarget( out ReferenceObject referenceObject ); if (referenceObject != null) Console.WriteLine($"- {referenceObject.Name}"); } }
ListObjects() 方法将打印出存储在列表中的所有弱引用对象。我们的关注点现在转向 Program 类。
-
将以下两个字段添加到
Program类的顶部:private static readonly StrongReferenceObjectManager StrongReferences = new StrongReferenceObjectManager(); private static readonly WeakReferenceObjectManager WeakReferences = new WeakReferenceObjectManager();
这些是我们将用于演示有关垃圾回收器的强引用和弱引用的只读强和弱对象管理器。
-
通过添加以下三个方法调用来更新
Main(string[] _)方法:TestLongWeakReferences(); TestStrongReferences(); TestShortWeakReferences(); ProcessReferences();
TestLongWeakreferences()、TestStrongReferences() 和 TestWeakReferences() 方法分别构建我们的强引用对象列表和弱引用对象列表。
-
添加
TestStrongReferences()方法,如下所示:private static void TestStrongReferences() { var o1 = new ReferenceObject() { Id = 1, Name = "Object 1" }; var o2 = new ReferenceObject() { Id = 2, Name = "Object 2" }; var o3 = new ReferenceObject() { Id = 3, Name = "Object 3" }; StrongReferences.Add(o1); StrongReferences.Add(o2); StrongReferences.Add(o3); }
此方法向 StrongReferences 列表添加三个 ReferenceObject 对象。
-
接下来,添加
TestWeakReferences()方法,如下所示:private static void TestWeakReferences() { var o1 = new ReferenceObject() { Id = 1, Name = "Object 4" }; var o2 = new ReferenceObject() { Id = 2, Name = "Object 5" }; var o3 = new ReferenceObject() { Id = 3, Name = "Object 6" }; WeakReferences.Add(o1); WeakReferences.Add(o2); WeakReferences.Add(o3); o1 = null; o2 = null; o3 = null; }
此方法向 WeakReferences 列表添加三个弱引用对象,然后将其实例化的对象设置为 null,以便它们将被垃圾回收。
-
最后,添加
ProcessReferences()方法,如下所示:private static void ProcessReferences() { int x = 0; while(x < 10) { StrongReferences.ListObjects(); WeakReferences.ListObjects(); Thread.Sleep(2000); GC.Collect(); x++; } }
ProcesseReferences() 方法循环 10 次。在每次迭代中,对 StrongReferences 和 WeakReferences 字段调用 ListObjects() 方法。程序休眠 2 秒,然后手动执行垃圾回收器。
- 现在是运行程序的时候了。当你运行程序时,你应该看到以下输出:

图 4.1 – 弱引用的项目输出
如 图 4.1 所示,在循环的第一迭代中,存在强引用和弱引用对象,并且那些对象的名称在控制台窗口中打印出来。然而,在调用垃圾回收后,弱引用被垃圾回收,因此,从第二次迭代开始,只有强引用对象保留在内存中。
弱引用对象的生命周期不会像强引用那样延长。这意味着一旦所有强引用超出作用域,它们就可以被垃圾回收。
大但按需重新加湿成本低的对象受益于弱引用。
注意
为了提高应用程序的性能,避免在许多小对象上使用弱引用,因为它们可能比它们包装的对象占用更多的内存空间,从而增加性能开销。但是,如果你正在处理许多大而昂贵的对象,使用缓存的弱引用可能有助于提高应用程序的性能。
这就结束了我们对强引用和弱引用的探讨。让我们将我们的关注点转向 C# 中的清理。
清理
在 C# 中,没有直接销毁对象的方法。我们最接近的方法是 终结化。C# 中的终结器是 C++ 中析构函数的等价物。但在 C# 中,您无法控制它是否以及何时运行,直到垃圾回收器做出决定。
注意
在 C# 中,终结器 和 析构函数 可以互换使用。终结器是用户定义的终结器代码运行的地方。在对象中的终结器运行之后,它再次被认为是活跃的,垃圾回收器随后将最终收集该对象。这意味着如果对象定义了终结器,它实际上会被标记为“可收集”两次。
对象使用终结化来释放资源并在对象被垃圾回收之前执行其他清理操作。可以通过重写受保护的 Finalize() 方法来执行释放对象持有的非托管资源的清理操作。
您必须重写 Finalize() 方法,以便垃圾回收器标记从 Object 派生的类型以进行终结化。当您重写 Finalize() 方法时,将为实例放置一个终结化队列中的条目。在回收内存之前,将为终结化队列中的每个对象实例调用 Finalize() 方法。一旦对象的 Finalize() 方法运行完毕,垃圾回收器就可以回收其内存。
如果在处理对象资源时调用了 GC.SupressFinalize(),则不会调用 Finalize() 方法,但如果发现对象不可访问,或者在 应用程序域(AppDomain)关闭期间(即使对象是可访问的),Finalize() 方法将自动调用。
注意
AppDomains 将应用程序彼此隔离,但它们的用法非常昂贵。在 .NET 5+ 中,有一些用于动态加载程序集的 AssemblyLoadContext 类。
Finalize() 方法仅在未调用 GC.SuppressFinalize() 且调用 GC.ReRegisterForFinalize() 时才会运行一次;然后,Finalize() 方法可以再次被调用。
当重写 Finalize() 时,有一些事情需要记住,如下所述:
-
您无法控制
Finalize()方法何时被调用。 -
为了确保在您的实例中释放托管和非托管资源,请使用
IDisposable模式实现IDisposable.Dispose()方法。无法保证终结化运行的顺序。 -
终结化在未指定的线程上运行,并且它们隐式调用基类的
Finalize()方法。
为了避免需要重写 Finalize() 方法,并确保我们清理托管和非托管资源,我们将探讨实现 IDisposable 模式。
使用终结化
我们将编写一个示例应用程序来演示Finalize()的使用。然后,我们将修改程序以实现IDisposable模式并抑制对Finalize()的调用,同时确保我们的托管和非托管资源得到确定性的释放。按照以下步骤操作:
-
启动一个新的.NET 6 控制台应用程序,命名为
CH04_Finalization。添加一个新的内部类Product。然后添加以下属性:public int Id { get; set; } public string Name { get; set; } public string Description { get; set; } public decimal UnitPrice { get; set; } -
我们已创建了四个属性—
Id、Name、Description和UnitPrice。现在,添加构造函数,如下所示:public Product() { Console.WriteLine("Product constructor."); } -
构造函数向控制台窗口写入一条消息,以便我们知道我们已经进入了构造函数。接下来,添加终结器,如下所示:
~Product() { Console.WriteLine("Product finalizer."); } -
在我们的终结器中,我们向控制台窗口写入一条消息,以便我们知道我们的终结器已被调用。对于我们的
Product类中的最后一部分代码,我们将重写ToString()方法,如下所示:public override string ToString() { return $"Id: {Id}, Name: {Name}, Description: {Description}, Unit Price: {UnitPrice}"; } -
我们的
ToString()方法返回一个字符串,输出Product类每个属性的值。目前,除非另有说明,以下代码应添加到Program类中。添加以下变量:private static Product _product; -
_product变量将用于存储我们的Product类的一个实例。更新Main方法,如下所示:static void Main(string[] _) { InstantiateObject(); PrintObjectData(); RemoveObjectReference(); RunGarbageCollector(); InstantiateLocalObject(); RunGarbageCollector(); DisplayGeneration(_product); RemoveObjectReference(); RunGarbageCollector(); } -
如您所见,我们有几个方法用于实例化对象、打印对象数据、删除对象引用、显示对象生成和运行垃圾收集器。我们现在将逐一添加每个方法。添加
InitiateObject()方法,如下所示:private static void InstantiateObject() { Console.WriteLine("Instantiating Product."); _product = new Product() { Id = 1, Name = "Polly Parrot", Description = "Cudly child's toy.", UnitPrice = 7.99M }; } -
在这个方法中,我们向控制台窗口写入消息,创建一个新的产品,并将其分配给
_product成员变量。现在,我们将添加PrintObjectData()方法,如下所示:private static void PrintObjectData() { Console.WriteLine(_product.ToString()); } -
在这里,我们将
Product类的内容打印到控制台窗口。接下来,我们将编写RemoveObjectReference()方法,如下所示:private static void RemoveObjectReference() { _product = null; } -
我们将
Product对象设置为null。这移除了对该对象的引用,使其有资格进行垃圾收集。我们现在添加一个调用垃圾收集的方法,如下所示:private static void RunGarbageCollector() { GC.Collect(); } -
在这个方法中,我们调用垃圾收集器,如下所示:
private static void InstantiateLocalObject() { var product = new Product() { Id = 2, Name = "Cute Kittie", Description = "Cudly child's toy.", UnitPrice = 5.75M }; DisplayGeneration(product); _product = product; GC.Collect(); } -
在这个方法中,我们创建了一个局部对象。然后,我们调用显示当前生成的方法。我们将局部产品分配给成员产品,然后调用垃圾收集器。我们的最终方法,目前是
DisplayGeneration(Product product)方法,如下面的代码片段所示:private static void DisplayGeneration(Product product) { Console.WriteLine($"local product: generation {GC.GetGeneration(product)}"); } -
此方法打印传入的产品生成。运行代码。您应该看到以下输出:

图 4.2 – 终结化项目输出
如您所见,我们的代码展示了构造和终结。我们既有 0 代代码也有 2 代代码,我们的构造函数和终结器方法都得到了调用。现在,我们将探讨实现IDisposable以使代码的清理更加确定,这样Finalize()就不需要被垃圾回收器调用。
实现 IDisposable 模式
在本节中,我们将实现一个可重用的IDisposable模式。我们将有一个实现IDisposable的基类。这个基类将提供两个子类可以重写的方法。一个方法用于清理托管资源,另一个方法用于释放非托管资源。为了实现IDisposable模式,请按照以下步骤操作:
-
添加一个名为
DisposableBase的新类,该类实现IDisposable,如下所示:public class DisposableBase : IDisposable { public void Dispose() { Dispose(true); } private void Dispose(bool disposing) { if (disposing) GC.SuppressFinalize(this); ReleaseManagedResources(); ReleaseUnmanagedResources(); } protected virtual void ReleaseManagedResources(){} protected virtual void ReleaseUnmanagedResources(){} }
这个类作为一个可以被继承的基类。它实现了IDisposable接口,并调用两个名为ReleaseManagedResources()和ReleaseUnmanagedResources()的虚拟方法,这些方法将在子类中被重写。
-
将
Main中的代码移动到一个名为Finalization()的新方法中。然后修改Main,如下所示:static void Main(string[] _) { Finalization(); Disposing(); }
我们调用了两个方法。Finalization()方法展示了使用终结来清理在垃圾回收器调用终结时你无法控制的资源。Disposing()展示了确定性地释放托管和非托管资源,并抑制了终结,以便垃圾回收器不会调用它。您的Finalization()方法应如下所示:
private static void Finalization()
{
Console.WriteLine("--- Finalization ---");
InstantiateObject("Finalization");
PrintObjectData();
RemoveObjectReference();
RunGarbageCollector();
InstantiateLocalObject("Finalization");
RunGarbageCollector();
DisplayGeneration(_product);
RemoveObjectReference();
RunGarbageCollector();
}
我们将“Finalization”传递给InstantiateObject(string cleanUpMethod)和InstantiateLocalObject(string cleanUpMethod)方法,这样我们就可以知道正在终结的对象是在我们的Finalization()方法中实例化的。
-
添加一个名为
Disposing()的新方法,如下所示:private static void Disposing() { Console.WriteLine("--- Disposing ---"); InstantiateObject("Disposing"); PrintObjectData(); DisposeOfObject(); InstantiateLocalObject("Disposing"); DisplayGeneration(_product); DisposeOfObject(); RunGarbageCollector(); } -
在
Disposing()方法中,我们向控制台写入一条消息,标识Disposing()方法正在运行。然后我们调用InstantiateObject(“Disposing”)。接下来,我们打印对象数据并销毁对象。然后,我们实例化一个将分配给成员变量的本地对象。本地和成员变量的生成被打印到控制台窗口,然后我们销毁对象并调用垃圾回收。 -
添加
DisposeofObject()方法,如下所示:private static void DisposeOfObject() { _product.Dispose(); } -
DisposeOfObject()方法调用_product对象的Dispose()方法来释放资源。更新Product类,如下所示:private string _cleanUpMethod; public Product(string cleanUpMethod) { Console.WriteLine("Product constructor."); _cleanUpMethod = cleanUpMethod; } ~Product() { Console.WriteLine($"Product destructor: {_ cleanUpMethod}."); } -
我们存储了我们正在使用的清理方法的名称,这样当终结器被调用时,我们将知道对象使用的清理方法。修改
InstantiateObject()方法,如下所示:private static void InstantiateObject(string cleanUpMethod) { Console.WriteLine("Instantiating Product."); _product = new Product(cleanUpMethod) { Id = 1, Name = "Polly Parrot", Description = "Cudly child's toy.", UnitPrice = 7.99M }; } -
我们将清理方法分配给
Product对象。同样修改InstantiateLocalObject()方法,使代码看起来如下:private static void InstantiateLocalObject(string cleanUpMethod) { var product = new Product(cleanUpMethod) { Id = 2, Name = "Cute Kittie", Description = "Cudly child's toy.", UnitPrice = 5.75M }; DisplayGeneration(product); _product = product; } -
再次强调,我们将清理方法分配给
Product对象。将Product更新为从DisposableBase继承。然后,将ReleaseManagedResources()方法添加到Product类中,如下所示:protected override void ReleaseManagedResources() { base.ReleaseManagedResources(); Console.WriteLine("Releasing managed resources."); } -
此方法将用于释放托管资源。现在,将
ReleaseUnmanagedResources()方法添加到Product类中,如下所示:protected override void ReleaseUnmanagedResources() { base.ReleaseUnmanagedResources(); Console.WriteLine("Releasing unmanaged resources."); }
此方法将用于清理非托管资源。
- 运行代码,您应该看到如下所示的输出:

图 4.3 – 终结化和释放代码的输出
如您所见,终结化代码调用了终结器,但用于显式释放托管和非托管资源的代码方法并没有被调用。对象也存活在 0 代垃圾回收中。相反,释放代码显式释放了托管和非托管资源,并且由于抑制了终结化,垃圾回收器没有调用。在我们的示例中,没有对象存活在 0 代垃圾回收中。
另一种在可处置类中隐式调用 Dispose() 的方法是使用 using 语句。以下是一个示例,如 Program 类中所示:
private static void UsingDispose()
{
Console.WriteLine("--- UsingDispose() ---");
using (var product = new Product("using")
{
Id = 2,
Name = "Cute Kittie",
Description = "Cudly child's toy.",
UnitPrice = 5.75M
}
)
{
DisplayGeneration(product);
}
}
using 语句与可处置对象一起使用。当代码块完成时,对象将被自动处置。对象的代数为 0。在 Main 方法中添加对 UsingDispose() 的调用。
好吧,您已经看到了如何使用终结器和实现与垃圾回收器相关的 IDisposable 模式。现在,让我们看看我们如何避免在 C# 中的内存泄漏。
防止内存泄漏
在本节中,我们将了解 COM 对象周围的问题以及使用 COM 对象可能导致内存泄漏的原因。我们将查看我们的示例代码与 Excel COM 库的互操作性。我们将看到在代码退出后 Excel 实例是如何保持活跃的。通过使用 Windows 任务管理器,我们将能够看到 Excel 实例的生成。我们的 Excel 代码将以避免内存泄漏并确保在代码完成运行后关闭每个 Excel 实例的方式开发,这样就不会在内存中留下 Excel 实例。
然后,我们将继续探讨使用事件如何成为运行时内存泄漏的常见来源,以及我们如何避免它们。使用 JetBrains dotMemory,我们将分析我们程序代码的运行时构建可执行文件。当代码运行时,我们将生成快照。当分析器运行时,您将看到内存使用量逐渐上升。点击快照将显示我们运行配置的详细内存信息。我们还将能够查看是否存在内存泄漏,并会发现存在基于事件的内存泄漏。在本节中,我们还将探讨匿名方法和弱引用。
本节的结果将是您了解 COM 和事件的使用,如果处理不当,可以引入内存异常,您将看到如何编写代码以避免生成内存异常。
理解使用 Marshal.ReleaseComObject 的风险
Visual Studio 团队遇到了 Visual Studio 2010 的问题。他们的问题是由于将原生 C++ 组件重写为托管 C# 代码而引起的。被重写为托管 C# 代码的组件包括窗口管理器、命令栏和文本编辑器。
随着 Visual Studio 2010 的发布,有两个扩展启用器——现有的使用 COM 接口为旧扩展提供支持的扩展机制,以及一个新的托管编程模型。
为了使 Runtime Callable Wrapper 或 RCW。RCW 在 COM 和托管代码的世界之间充当桥梁。
所有 COM 组件至少必须实现 IUnknown 接口。当一个实现 IUnknown 接口的对象进入托管运行时,它会被包装在一个 RCW 中。因此,RCW 是一个引用实现 IUnknown 接口的原生代码的常规托管对象。
在托管 .NET 计算机程序中,有两种类型的对象可以引用 RCW:COM 对象和托管对象。这就是可能出现问题的起点。
在这一点上,我们将考虑一个典型的场景,该场景可能导致 COM 对象和托管对象之间出现内存问题。
DatabaseSearch 组件通过询问 DatabaseManager 服务来开始 Find 操作。它返回给 DatabaseSearch 组件一个有效的 IDatabaseManager 实例。返回给 DatabaseSearch 组件的 DatabaseManager 组件是一个本机 COM 组件。由于 DatabaseManager 组件是一个本机 COM 组件,它被运行时封装在一个 RCW 中。DatabaseSearch 组件不知道或不在乎 DatabaseManager 组件是本机 COM 组件还是托管代码组件,因为它所看到的是 IDatabaseManager 接口。Find 操作通过 DatabaseSearch 组件通过 IDatabaseManager 进行各种调用来完成其任务。一旦 Find 操作完成,它就会退出。由于 IDatabaseManager 是一个 RCW,它具有与托管对象相同的生命周期语义。因此,当垃圾收集器运行时,IDatabaseManager 组件将被清理。如果没有大量的内存压力,垃圾收集器可能不会运行很长时间,甚至可能根本不会运行。在这种情况下,由于它们管理系统内存的方式不同,我们最终会因本机和托管内存冲突而结束。托管 DatabaseSearch 组件在需要 DatabaseManager 组件之前完成与 DatabaseManager 组件的交互。如果没有对 DatabaseManager 组件的引用,那么这就是垃圾收集器运行并删除 DatabaseManager 的好时机。任何用本机代码编写的组件,一旦 Find 方法退出,就会在 IDatabaseManager 上调用 Release。这表明对 IDatabaseManager 的引用不再需要。由于最后的 Release 调用直到下一次垃圾收集才会进行,因此看起来 IDatabaseManager 存在内存泄漏。这是一个非确定性终止的例子。无法确定何时应该进行垃圾收集被称为非确定性终止。当对象所属的对象正在被垃圾收集且存在非托管资源需要释放时,垃圾收集器会为该对象分配一个特殊线程来执行 Finalize() 方法。
我们所考虑的这种场景会导致昂贵的对象在应用程序关闭时被报告为泄漏对象。
自然的选择是调用 Marshal.ReleaseComObject(object)。这个调用会在昂贵的对象不再需要时立即进行。在我们的场景中,这将是 DatabaseManager 不再需要时。这个调用会导致 RCW 被释放,并且内部引用计数减一。此时,底层的 COM 对象通常会释放。
然而,调用 Marshal.ReleaseComObject(object) 可能是危险的。
考虑到作为从 COM 迁移的一部分,DatabaseManager已被编写为托管代码。DatabaseSearch托管组件通过 GSP 请求DatabaseManager组件。返回给DatabaseSearch组件的是一个IDatabaseManager实例。返回的实例是一个包装 COM 对象的 RCW。因此,我们有了双重包装,即 RCW 包装在Find操作退出时出现问题的外围。在终止时,DatabaseSearch组件仍然对DatabaseManager的 RCW 调用Marshall.ReleaseComObject(object)。
这会导致引发一个ArgumentException类型的异常。生成的异常信息是:“对象的类型必须是 _ComObject 或从 _ComObject 派生。”当这种情况发生时,请移除对Marshal.ReleaseComObject(object)的调用。另一种选择是在调用ReleaseComObject之前调用Marshal.IsComObject。
调用Marshal.IsComObject会导致更多问题。DatabaseManager RCW 已被声明为不再需要,但问题是DatabaseManager RCW 仍然是一个有效的对象,这意味着它可能仍然可以被托管对象访问。下次访问该对象时,如果可以从托管代码中访问,CLR 将引发一个InvalidComObjectException类型的异常,指出:“与底层 RCW 分离的 COM 对象不能使用。”
如果我们DatabaseManager RCW 使用的 COM 组件被托管代码缓存,而不是每次请求DatabaseManager组件时都返回给 GSP,我们的缓存 COM 组件将首先被检查。这样做是为了避免在托管和非托管代码之间的边界上进行昂贵的调用。如果随后有多个组件请求相同的 COM 组件,它们将各自接收到相同的 RCW。
这里的问题是,调用过ReleaseComObject的 RCW 的组件通常会被指责为生成异常的组件。但事实并非如此——调用ReleaseComObject的组件才是有问题的组件,在我们的场景中,这个组件将是DatabaseSearch组件。
注意
微软的开发者,尤其是 Visual Studio 团队的开发者推荐,除非你 100%确定没有托管代码项可以访问 RCW,否则不要调用Marshal.ReleaseComObject。
我们将通过查看一个 Excel 示例来更深入地探讨我们刚才讨论的内容。
在.NET 6 中使用 Microsoft Excel 16.0 对象库
在本节中,我们将通过引用 Microsoft Excel 16.0 对象库来探讨.NET 6 中的 COM 互操作性。这个库是一个 COM 库。您将了解如何使用 Excel 创建新应用程序、修改它并保存它。当第一个示例运行几次后,您会发现代码没有失败。但在任务管理器中,每次运行该方法时,都会打开另一个 Excel 实例,就像在 Windows 任务管理器中看到的那样。然后,我们将继续探讨如何正确地释放 COM 对象,以便在应用程序完成后 Excel 实例不会保持打开状态。让我们首先查看当我们不释放 Excel COM 对象时会发生什么。
调查当 Excel COM 对象未释放时会发生什么
在本节中,我们将创建一个电子表格,向其中添加数据,然后保存文件。这将揭示使用 Excel 时产生的内存问题,以及我们在使用 Excel 后没有正确清理时可能出现的内存问题。我们还将了解如何使用 Excel 并清理以防止通过使用 Excel 产生的内存问题。
将Microsoft Excel 16.0 Object Library的 COM 引用添加到CH04_PreventingMemoryLeaks项目中。
注意
如果您向项目中添加 COM 引用,您将获得 IntelliSense 支持。但当您运行成功编译的程序时,当它尝试创建 Excel 应用程序时,将引发FileNotFoundException类型的异常。因此,您需要将EmbedInteropTypes和Private的值设置为true。
由于我们最不希望遇到的是FileNotFoundException类型的异常,请编辑您的项目文件,然后更新COMReference部分,如下所示:
<ItemGroup>
<COMReference Include="Microsoft.Office.Excel.dll">
<WrapperTool>tlbimp</WrapperTool>
<VersionMinor>9</VersionMinor>
<VersionMajor>1</VersionMajor>
<Guid>00020813-0000-0000-c000-000000000046</Guid>
<Lcid>0</Lcid>
<Isolated>false</Isolated>
<EmbedInteropTypes>True</EmbedInteropTypes>
<Private>true</Private>
</COMReference>
</ItemGroup>
这将确保我们不会遇到FileNotFoundException类型的异常。向项目中添加一个新的UsingExcel类,然后添加以下using语句:
using Microsoft.Office.Interop.Excel;
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using Excel = Microsoft.Office.Interop.Excel;
现在,添加RunExcelExamples()方法,如下所示:
public void RunExcelExamples()
{
for (int i = 0; i < 10; i++)
NotReleasingExcelComObjects();
for (int i = 0; i < 10; i++)
ReleasingExcelComObjects();
}
此方法调用两个方法。它调用每个方法 10 次然后退出。让我们添加NotReleasingExcelComObjects()方法,如下所示:
private static void NotReleasingExcelComObjects()
{
string filename = @"C:\Temp\BucketList.xlsx";
Excel.Application application = new Excel.Application();
application.Visible = false;
Excel.Workbook workbook = application.Workbooks.Add();
Excel.Sheets sheets = workbook.Sheets;
Excel.Worksheet worksheet =(Worksheet)sheets
.Add(sheets[1], Type.Missing, Type.Missing,
Type.Missing);
worksheet.Range["A1"].Value = "Bucket List";
worksheet.Range["A2"].Value = "Visit New Zealand";
worksheet.Range["A1"].Value = "Visit Australia";
if (File.Exists(filename))
File.Delete(filename);
workbook.SaveAs(filename);
workbook.Close();
application.Quit();
}
此方法声明了一个filename字符串。然后它实例化一个新的不可见的 Excel 应用程序。接着它添加一个名为“Bucket List”的列标题,并在下面的行中添加两个项目到该列表列。然后它检查文件是否存在。如果文件确实存在,则将其删除。然后保存并关闭工作簿,并退出 Excel 应用程序。从RunExcelExamples()方法中注释掉以下行:
for (int i = 0; i < 10; i++)
ReleasingExcelComObjects();
如果您保存项目并运行它,您会发现程序退出后,您会留下多个 Excel 进程。这些进程中的每一个都会占用内存。以下截图显示了程序退出后仍然留在内存中的 Excel 进程:

图 4.4 – Windows 任务管理器显示不再使用的 Excel 进程正在占用内存
如你所见,这些在我们程序完成后仍然留在内存中的 Excel 进程正在消耗 367.6 兆字节(MB)的 RAM,这是所有 Excel 进程 RAM 的总和。如果这个程序以当前的形式多次运行,你最终会耗尽内存,因为留在内存中运行的 Excel 进程构成了内存泄漏。每次程序运行,你都会消耗另外 367 MB 或大约的 RAM。最终,可用的内存将不足以满足需求,你将遇到内存不足异常。
以下截图显示了程序运行一次后的任务管理器显示:

图 4.5 – 程序运行一次后的 Windows 任务管理器
从图 4.5中,我们可以看到我们正在使用 7.4 GB(793 MB),而我们还有 8.5 GB 的 RAM 可用。连续多次运行程序。每次运行程序,你都会看到压缩内存增加,可用内存减少。内存似乎从未被回收,如下面的截图所示:

图 4.6 – 多次运行程序后,Windows 任务管理器显示内存使用增加和可用内存减少
在我们的程序多次连续运行之后,我们可以看到我们的使用中(压缩)内存已从 7.4 GB(793 MB)增加到 10.9 GB(799 MB),而我们的可用内存已从 8.5 GB 减少到 4.9 GB。这显然是一个需要解决的问题,但该如何解决呢?
这就是这里显示的ReleasingExcelComObjects()方法的作用:
[System.Diagnostics.CodeAnalysis SuppressMessage
("Interoperability","CA1416:Validate platform compatibility",
Justification = "Windows only code.")]
private static void ReleasingExcelComObjects()
{
Excel.Application application = null;
Excel.Workbooks workbooks = null;
Excel.Workbook workbook = null;
Excel.Sheets worksheets = null;
Excel.Worksheet worksheet = null;
Excel.Range range = null;
Try
{
string filename = @"C:\Temp\BucketList.xlsx";
application = new Excel.Application();
application.Visible = false;
workbooks = application.Workbooks;
workbook = workbooks.Add();
worksheets = workbook.Sheets;
worksheet = (Worksheet)worksheets.Add(worksheets[1],
Type.Missing, Type.Missing, Type.Missing);
range = worksheet.Range["A1"];
range.Value = "Bucket List";
range = worksheet.Range["A2"];
range.Value = "Visit New Zealand";
range = worksheet.Range["A3"];
range.Value = "Visit Australia";
if (File.Exists(filename))
File.Delete(filename);
workbook.SaveAs(filename);
workbook.Close();
application.Quit();
}
Finally
{
if (range != null)
Marshal.FinalReleaseComObject(range);
if (worksheet != null)
Marshal.FinalReleaseComObject(worksheet);
if (worksheets != null)
Marshal.FinalReleaseComObject(worksheets);
if (workbook != null)
Marshal.FinalReleaseComObject(workbook);
if (workbooks != null)
Marshal.FinalReleaseComObject(workbooks);
if (application != null)
Marshal.FinalReleaseComObject(application);
range = null;
worksheet = null;
worksheets = null;
workbook = null;
worksheets = null;
application = null;
GC.Collect();
GC.WaitForPendingFinalizers();
Process[] processes =
Process.GetProcessesByName("EXCEL");
foreach (Process process in processes)
process.Kill();
}
}
这个相当长的方法做了我们需要 Excel 做的——释放 Excel COM 对象,将托管对象设置为null,运行垃圾收集器,然后终止所有运行的 Excel 进程。如果你取消注释RunExcelExamples()方法中的代码,然后运行一次代码,你将看到我们的代码运行完成后,内存中不再有任何 Excel 进程在运行。如果你查看 Windows 任务管理器的性能选项卡,你也会看到我们已经回收了内存。
我们通过终止 COM 组件并将托管对象设置为null以移除托管引用,成功地修复了内存泄漏。然后,我们杀死了所有名为EXCEL的进程。
注意
在使用process.Kill()方法杀死具有特定名称(如EXCEL)的所有进程时请小心。可能有其他程序也使用该进程,可能会因这种终止而受到严重影响。如果您在服务器上执行批处理,应在此代码在隔离环境中运行,或者安排在您能保证其他进程不会因运行此代码而受到影响的时间进行操作。
现在是时候看看使用事件如何成为内存泄漏的来源了。
使用事件如何成为内存泄漏的来源
在本节中,我们将探讨在您的计算机程序中使用事件如何成为内存泄漏的来源。我们将使用我们将编写的非常简单的 Windows Forms 应用程序来演示这一点。然后,我们将使用 JetBrains dotMemory 分析我们的内存使用情况。我们将采用两种方法来展示事件的使用。一种方法将产生内存泄漏,而另一种则不会。
那么,使用事件如何会产生内存泄漏呢?
除非你使用匿名方法,否则订阅事件会保留对持有该事件的类的引用,直到事件被取消订阅。考虑以下类:
internal class EventSubscriber
{
public EventSubscriber(Control control)
{
Control.TextChanged += OnTextChanged
}
private void OnTextChanged(
object sender,
EventArgs eventArgs
)
{
Text ((Control)sender).Text;
}
}
如果控制对象比EventSubscriber类存活时间更长,那么EventSubscriber的所有实例将不会被垃圾回收器回收。最终结果是内存泄漏。以下是一些避免基于事件的内存泄漏的不同方法:
-
订阅匿名方法。
-
在完成事件后取消事件订阅。
-
实现弱处理程序模式。
在我们查看避免内存泄漏的这些方法之前,我们将编写我们的 Windows Forms 应用程序,演示避免内存泄漏和产生内存泄漏的方法。按照以下步骤操作:
-
开始一个新的.NET Core Windows Forms 项目,然后在项目设置中将目标框架从.NET Core 3.1 更改为.NET 5。
-
将
Form1重命名为MainForm。 -
添加一个名为
InformationLabel的标签,其文本为“Information”,一个名为RaiseEventsButton的按钮,其文本为“Raise Events”,以及另一个名为ProgressLabel的标签,其文本为“Progress:”。您可以根据自己的喜好布局和样式化这些组件。 -
双击
RaiseEventsButton按钮。这将生成一个点击事件处理方法。 -
在项目中添加一个名为
EventOne的类。您需要以下using语句:using System; using System.Threading; -
将以下代码添加到
EventOne类的顶部:public event EventHandler OnEventRaised; private static int _count; public static int Count { get { return _count; } } -
这些元素是处理事件并记录仍被保持活跃的实例数量的必需元素。添加构造函数,如下所示:
public EventOne() { Interlocked.Increment(ref _count); } -
构造函数代码以原子和线程安全的方式为类的每个实例递增
_count成员变量。添加RaiseEvent(EventArgs e)`方法,如下所示:public void RaiseEvent(EventArgs e) { EventHandler eventHandler = OnEventRaised; if (eventHandler != null) eventHandler(this, e); } -
此方法由客户端调用,负责在请求时触发事件。现在,添加最终的终结器,如下所示:
~EventOne() { Interlocked.Decrement(ref _count); } -
每当类的实例被终止并由垃圾回收器收集时,终结器以线程安全的方式递减
_count成员变量。将新的EventTwo类添加到项目中。你需要以下using语句:using System; using System.Threading; using System.Windows.Forms; -
在
EventTwo类的顶部添加以下代码:private static int _count; public static int Count { get { return _count; } } public string Text { get; private set; } -
代码存储了活动实例的数量和订阅控件的当前文本。添加以下构造函数:
public EventTwo(Control control) { Interlocked.Increment(ref _count); control.TextChanged += OnTextChanged; } -
构造函数接受一个 Windows Forms 控件作为参数。它以线程安全的方式将
_count成员变量增加一。然后它订阅由OnTextChanged方法处理的事件TextChanged。添加以下OnTextChanged方法:private void OnTextChanged(object sender, EventArgs eventArgs) { Text = ((Control)sender).Text; } -
此方法在订阅的控件
Text属性更改时触发。它将控件的Text内容分配给EventTwo类的Text属性。添加以下Finalizer()方法:~EventTwo() { Interlocked.Decrement(ref _count); } -
每当实例被垃圾回收时,终结器以线程安全的方式递减
_count成员变量。我们现在已经设置了我们的表单将用于引发事件的两个类。切换回MainForm类。 -
在
MainForm类的顶部添加以下成员变量:private int _eventsGeneratedCount; private int _eventSubscriberCount; -
这两个值将存储已生成的事件数量。添加以下
SetTitleText()方法:private void SetTitleText() { Text = $"{_eventsGeneratedCount}/{EventOne.Count} – {_eventSubscriberCount}/{EventTwo.Count}"; } -
此方法为引发事件的方法设置控件的
Text属性。文本显示引发的事件数量以及非内存泄漏方法中仍然存活的事件数量,以及内存泄漏方法中的相同内容。添加以下SetInformationLabelText()方法:private void SetInformationLabelText() { StringBuilder sb = new StringBuilder(); sb.AppendLine($"Raised Events (No Memory Leak): {_eventsGeneratedCount}, Alive Events: {EventOne.Count}"); sb.AppendLine($"Raised Events (Memory Leak): {_eventSubscriberCount}, Alive Events: {EventTwo.Count}"); InformationLabel.Text = sb.ToString(); } -
SetInformationLabelText()方法更新InformationLabel文本,以显示每个方法中引发的事件数量以及两种方法执行完毕后内存中剩余的事件数量。添加以下RaiseEvent方法:private void RaiseEvent(object sender, EventArgs e) { ProgressLabel.Text = $"Event Raised: {DateTime.Now}"; ProgressLabel.Invalidate(); ProgressLabel.Update(); } -
RaiseEvent方法更新ProgressLabel.Text属性,但为了实时更新,需要调用Invalidate()和Update()方法。现在,添加以下MemoryLeakMethod方法:private void MemoryLeakMethod(EventArgs e) { int count = 10000; for (int x = 0; x < count; x++) { var eventTwo = new EventTwo(this); } _eventTwoCount += count; } -
此方法声明一个 10,000 项的计数。然后它循环 10,000 次。使用传递给
MainForm的引用订阅一个新的EventTwo对象。一旦循环完成,_eventTwoCount变量增加 10,000。接下来,我们将添加以下NoMemoryLeakedMethod方法:private void NoMemoryLeakMethod(EventArgs e) { int count = 10000; for (int x = 0; x < count; x++) { EventOne eventOne = new EventOne(); eventOne.OnEventRaised += RaiseEvent; eventOne.RaiseEvent(e); } _eventOneCount += count; } -
此方法声明一个计数为 10,000。它迭代 10,000 次。在这 10,000 次迭代中,它实例化一个新的
EventOne对象,添加一个名为RaisedEvent的事件处理程序,然后引发事件。一旦循环完成,_eventOneCount变量增加 10,000。更新点击事件处理程序,如下所示:NoMemoryLeakMethod(e); MemoryLeakMethod(e); SetInformationLabelText(); SetTitleText(); -
将构建模式更改为
Release并构建项目。 -
打开 JetBrains dotMemory。选择 本地 | .NET Core 应用程序,选择由构建过程生成的可执行文件,然后勾选 从启动时收集内存分配和流量复选框。您的屏幕应该看起来像这样:

图 4.7 – JetBrains dotMemory 配置屏幕
- 点击运行按钮。这将启动您的应用程序和性能分析会话,如下两个屏幕截图所示:

图 4.8 – JetBrains dotMemory 分析我们的 Windows Forms 应用程序

图 4.9 – 在运行任何事件之前我们的 Windows Forms 应用程序
- 几次点击触发事件按钮。每次您点击按钮时,内存配置文件应该会改变,内存使用量应该会增加,如下面的屏幕截图所示:

图 4.10 – 显示 50,000 个活跃事件的我们的 Windows Forms 应用程序,表明我们存在内存泄漏
-
如您所见,我们有一个内存泄漏。我们的
NoMemoryLeakMethod方法不会产生内存泄漏。如您所见,在触发 50,000 个事件后,内存中保持活跃的对象为 0。但我们的MemoryLeakMethod方法确实产生了内存泄漏。在触发 50,000 个事件中,有 50,000 个对象保持活跃。 -
运行程序几次,并注意 dotMemory 中的情况。当您看到感兴趣的点时,点击该区域,然后点击 获取快照。这将捕捉到那一刻的快照,用户可以分析以查看是否存在任何问题。您应该得到类似以下的内容:

图 4.11 – 当触发事件并获取快照时 JetBrains dotMemory 对我们的 Windows Forms 应用程序的配置
- 点击任何一个快照。您应该看到类似以下的内容:

图 4.12 – 已识别 EventTwo 类的内存泄漏
- JetBrains dotMemory 在
EventTwo类中检测到内存泄漏。这是因为该类订阅了另一个对象的事件,但从未取消订阅。然而,您会看到EventOne类的所有对象都已最终化。
您已经看到了如何使用事件以产生内存泄漏的方式,以及以防止内存泄漏的方式。让我们回顾一下使用事件时防止内存泄漏的三种方法,如下:
-
订阅匿名方法。
-
当你完成事件时,取消订阅事件。
-
实现弱处理器模式。
让我们看看如何订阅匿名方法,然后取消订阅
使用局部方法
在 C# 7.0 之前,你会使用匿名方法作为处理事件的方式,以避免引入内存泄漏。从 C# 7.0 开始,你可以使用局部方法。在这个例子中,我们将使用局部方法来处理事件。遵循以下步骤:
-
加载
CH04_PreventingMemoryLeaks项目。 -
添加一个名为
Website的类,如下所示:internal class Website { public event EventHandler<EventArgs> Login; public event EventHandler<EventArgs> Logout; } -
这个类有两个事件用于网站的登录和注销。添加一个名为
AnonymousEventSubscription的新类。添加Login()方法,如下所示:public void Login() { Website website = new Website(); void LoginHandler(object sender, EventArgs args) { Debug.WriteLine("Anonymous login event handler using a local method."); website.Login -= LoginHandler; }; website.Login += LoginHandler; LoginHandler(this, new EventArgs()); } -
Login()方法实例化一个新的Website对象。然后它有一个名为LoginHandler的局部方法,该方法将消息写入调试窗口,然后取消订阅Website.Login事件。然后,在局部方法外部,它订阅Website.Login事件并引发事件。让我们添加Logout()方法,如下所示:public void Logout() { Website website = new Website(); void LogoutHandler(object sender, EventArgs args) { Debug.WriteLine("Anonymous logout event handler using a local method."); website.Logout -= LogoutHandler; }; website.Logout += LogoutHandler; LogoutHandler(this, new EventArgs()); } -
Logout()方法实例化一个新的Website对象。然后它有一个名为LogoutHandler的局部方法,该方法将消息写入调试窗口,然后取消订阅Website.Logout事件。然后,在局部方法外部,它添加Website.Logout事件的处理器,然后引发事件。 -
在
Main方法中,注释掉RunExcelExamples()行。然后,添加UseAnonymousEventSubscription()方法调用,如下所示:private static void UseAnonymousEventSubscriptions() { for (int x = 0; x < 1000000; x++) { AnonymousEventSubscription aes = new AnonymousEventSubscription(); aes.Login(); aes.Logout(); } } -
此代码运行了 1,000,000 次迭代。对于每次迭代,都会实例化一个新的
AnonymousEventSubscription,并调用Login()和Logout()。这两个调用将各自有一个事件订阅,一个通过局部方法执行的事件,以及,当局部方法执行时,它将取消订阅该事件。 -
如果你构建并运行代码,你应该在你的调试窗口中看到以下行打印了 1,000,000 次:

图 4.13 – 显示登录和注销事件触发的调试窗口
- 如果你执行发布构建并运行 dotMemory,你会看到我们没有内存泄漏,考虑到我们刚刚生成了 2,000,000 个事件订阅和取消订阅——即
Login()有 1,000,000 个,Logout()也有 1,000,000 个。
我们已经看到了如何使用局部方法有效地使用匿名事件,而不会造成内存泄漏。现在,让我们看看本章的最后一个主题——弱引用。
使用弱引用事件
我们使用弱引用事件模式,允许一个对象如果其唯一剩余的链接是事件处理器,则可以被垃圾回收。我们将在本节中的 CH04_PreventingMemoryLeaks 项目中实现弱引用事件模式。遵循以下步骤:
-
在包管理器控制台中,输入以下命令:
install-package WeakEventListener。System.Windows.WeakEventManager包仅适用于 .NET 4.8 及更早版本,这就是我们安装此包的原因。 -
添加以下
SampleClass类:internal class SampleClass { public event EventHandler<EventArgs> RaiseEvent; public void DoSomething() { OnRaiseEvent(); } protected virtual void OnRaiseEvent() { RaiseEvent?.Invoke(this, EventArgs.Empty); } } -
在这个类中,我们声明了一个名为
RaiseEvent的事件。DoSomething()方法调用OnRaiseEvent()方法。OnRaiseEvent()方法检查事件是否为null;如果不是null,则事件被调用。添加一个名为UsingWeakreferences的新类。你需要以下引用:using System; using System.Diagnostics; using WeakEventListener; -
添加
RaiseWeakReferenceEvents()方法,如下所示:public void RaiseWeakReferenceEvents() { bool isOnEventTriggered = false; bool isOnDetachTriggered = false; SampleClass sample = new SampleClass(); WeakEventListener<SampleClass, object, EventArgs> weak = new WeakEventListener<SampleClass, object, EventArgs>(sample); weak.OnEventAction = (instance, source, eventArgs) => { isOnEventTriggered = true; }; weak.OnDetachAction = (listener) => {isOnDetachTriggered = true; }; sample.Raisevent += weak.OnEvent; sample.DoSomething(); Debug.Assert(isOnEventTriggered); weak.Detach(); Debug.Assert(isOnDetachTriggered); } -
我们有两个变量,当事件被触发和解除时它们为
true。我们实例化一个新的SampleClass类实例。然后我们声明一个引用SampleClass类的WeakEventListener包。使用匿名方法来处理OnEventAction和OnDetachAction方法。然后将WeakReferenceListener.OnEvent方法分配为SampleClass.RaiseEvent事件的处理器。然后我们调用引发事件的DoSomething()方法。然后,我们断言事件已被触发,解除事件,然后断言事件已被解除。 -
确保项目设置为 调试 模式,然后逐步执行代码。它应该按预期工作,事件被正确触发和解除。
现在我们来总结一下本章所学的内容。
摘要
我们研究了对象生成,并看到了如何容易地生成 System.OutOfMemoryException 类型的异常。我们看到了如何使用预测性内存不足异常检查来节省时间,通过防止运行将导致此异常的代码。
然后,我们转向讨论长弱引用和短弱引用。我们了解到强引用不会被垃圾回收,而弱引用会被垃圾回收。
我们接着探讨了终结化,并看到了在未释放的对象上调用 Finalize() 方法的情况,以及我们没有控制 Finalize() 方法何时运行的情况。然后,我们探讨了如何实现 IDisposable 模式,并抑制垃圾回收调用 Finalize() 的需求。
最后,我们探讨了防止内存泄漏的各种方法,例如正确释放托管资源和非托管资源。我们还看到了如何正确处理事件,以避免内存泄漏。
通过本章所学的内容,你将能够克服内存不足异常,提高内存性能,并改善应用程序中的垃圾回收,你将能够正确使用事件和事件处理器,而不会产生内存泄漏,并且能够有效地释放 COM 对象和分配的内存。这将导致质量更高、更稳定的程序,并能够充分利用内存。
在下一章中,我们将探讨应用程序分析。
问题
-
有多少个对象生成?
-
哪些大小的对象会被放置在 SOH 上?
-
哪些大小的对象会被放置在 LOH 上?
-
什么是强引用?
-
什么是弱引用?
-
我们如何在不依赖终结的情况下清理对象?
-
我们如何在使用事件时避免内存泄漏?
-
我们使用哪种方法来释放 COM 对象?
-
在分配内存时我们如何防止内存泄漏?
进一步阅读
-
ComWrappers类: https://docs.microsoft.com/dotnet/api/system.runtime.interopservices.comwrappers?view=net-5.0 -
Marshal.ReleaseComObject 被认为是危险的:
devblogs.microsoft.com/visualstudio/marshal-releasecomobject-considered-dangerous/ -
WeakEventManager 类: https://docs.microsoft.com/dotnet/api/system.windows.weakeventmanager?view=net-5.0
-
如何正确释放 Excel COM 对象:
www.add-in-express.com/creating-addins-blog/2013/11/05/release-excel-com-objects/ -
通过事件处理程序和事件聚合器理解和避免内存泄漏:
www.markheath.net/post/understanding-and-avoiding-memory-leaks -
为什么以及如何避免事件处理程序内存泄漏:
stackoverflow.com/questions/4526829/why-and-how-to-avoid-event-handler-memory-leaks -
.NET Framework 技术在 .NET Core 和 .NET 5+ 上不可用:
docs.microsoft.com/en-us/dotnet/core/porting/net-framework-tech-unavailable
第五章:第五章:应用程序性能分析和跟踪
应用程序性能分析是对计算机程序内部运作的内部检查。我们使用应用程序性能分析来衡量程序内部性能。这有助于我们识别任何性能瓶颈和内存问题。然后,我们可以使用这些信息来重构和改进程序的性能。
应用程序跟踪用于监控计算机程序在运行时的内部性能。你可以在开发、测试以及将程序发布到生产时跟踪你的计算机程序的执行。
当结合使用时,应用程序性能分析和应用程序跟踪可以非常强大且有用,有助于识别为什么计算机程序运行缓慢。
在本章中,你将学习如何对应用程序进行性能分析以识别任何性能不佳的区域。你将了解代码指标以及如何进行静态代码分析。在你努力编写更高效代码的过程中,你将学习如何利用内存转储、已加载模块查看器、调试、跟踪和dotnet-counters。当你完成本章时,你将具备对自身应用程序进行性能分析和跟踪所需的技能和经验。
在本章中,我们将涵盖以下主要主题:
-
理解代码指标:在本节中,我们将探讨各种工具可以为我们提供哪些应用程序、程序集、命名空间、类型、方法和字段指标。
-
执行静态代码分析:在本节中,我们将探讨使用 Visual Studio 2022 执行静态代码分析。我们将为我们的软件生成指标,包括可维护性指数、循环复杂度、继承深度、类耦合、源代码单元和可执行代码行数。
-
生成和查看内存转储:在本节中,我们将探讨在代码中遇到断点或应用程序遇到时如何生成和查看内存转储。
-
查看已加载模块:在本节中,我们将展示 Visual Studio 中的模块窗口,以便我们可以查看由我们的应用程序加载到内存中的模块,并查看有关这些模块的信息。
-
调试你的应用程序:本节突出了我们可用的各种调试选项。
-
使用跟踪和诊断工具:在本节中,我们将介绍可以帮助我们在软件应用程序上执行跟踪和诊断的工具。具体来说,我们将考虑 Visual Studio 2022、JetBrains dotMemory 和 JetBrains dotTrace。
-
使用
dotnet-counters并使用它们来列出可监控的.NET 进程、列出我们可以使用的可用计数器以收集性能数据、监控.NET 进程,并将该进程的数据收集到 CSV 文件中,以便在 Excel 中进行后续分析。 -
使用 dotMemory 跟踪和修复内存泄漏:在本节中,我们将使用 dotMemory 来追踪 WPF 应用程序中的内存泄漏并修复它。
-
使用 dotTrace 查找 UI 冻结的原因:在本节中,我们将使用 dotTrace 来追踪 WPF 应用程序中 UI 冻结的原因并修复它。
-
优化应用程序性能和内存流量:在本节中,我们将使用 dotTrace 来识别改进 WPF 应用程序性能和内存流量的机会。
完成本章后,您将掌握以下技能:
-
理解代码度量指标并能够使用它们来提高代码质量和性能
-
通过执行静态代码分析来提高代码质量和性能
-
使用加载的模块来识别您的代码使用了哪些模块
-
有效调试软件
-
有效跟踪软件
-
使用
dotnet-counters进行初步的性能调查 -
使用 JetBrains dotMemory 跟踪内存泄漏并修复它们
-
使用 JetBrains dotTrace 跟踪 UI 冻结的原因并修复它们
-
使用 JetBrains dotTrace 跟踪性能和内存流量问题并修复它们
注意
如果您被要求访问前几章的代码以进行某些示例,请不要感到惊讶。由于章节的页面限制,为那些练习添加代码示例将超过本章的计数限制。
技术要求
遵循本章内容所需的技术要求如下:
-
Visual Studio 2022 或更高版本
-
JetBrains dotMemory
-
JetBrains dotTrace
-
源代码:
github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH05 -
可选:Microsoft Excel 或其他 CSV 文件查看器
理解代码度量指标
在本节中,我们将探讨使用各种付费、免费和开源工具可以收集到的代码度量指标。源代码度量指标是从源代码中提取出来的,用于衡量我们源代码的质量和性能。
注意
不同的工具可以测量和计算不同的度量指标。由于每个工具都不同,了解哪些工具和度量指标可以满足您自己的项目需求是个好主意。
在接下来的子章节中,我们将了解我们可以用来衡量我们的代码并提高性能的不同代码度量指标。
应用程序度量指标
应用程序度量指标涵盖了您的应用程序在整个程序集的完整源代码。它们为您提供了关于应用程序有多少行代码以及有多少行代码被测试覆盖的总体情况。
在本节中,我们将从高层次介绍某些工具(如 ndepends 工具)提供的各种指标。作为你自己的研究的一部分,确定不同的应用指标收集工具。然后,查看它们提供的指标。选择最适合你需求的工具。在下一节中,将使用 Visual Studio 的内置静态代码分析工具演示代码指标的生成,包括以下指标:可维护性指数、循环复杂度、继承深度、类耦合、源代码行数和执行代码行数。接下来将描述这些和其他指标。
尽管工具供应商之间的指标不同,但可用的应用指标可能包括以下内容:
-
代码行数(LOC):有两种类型的 LOC 测量。它们包括逻辑 LOC 和物理 LOC。逻辑 LOC 指的是可以跨越一行或多行,并以闭合花括号或分号结束的代码行。物理 LOC 指的是包括注释和空白符的实际代码行。
-
注释行数:用于注释的行数。
-
注释百分比:此指标用于识别代码中注释所占的百分比。它是通过以下公式计算的:100 x 注释行数/(注释行数 + 代码行数)。
-
IL 指令:当你的代码编译时,它会被转换成中间语言(IL)代码。根据你如何编写 C# 代码,这可能导致生成大量或少量 IL 指令。测量你的代码生成的 IL 指令数量是有意义的。这是因为即使代码很小,它也可能生成许多 IL 指令。相反,一个方法可能很大,但与代码的小版本相比,生成的代码行数更少。IL 指令数量越少,该方法越容易维护。
注意
公司 ndepend 在他们的文档代码指标页面上有一个建议,指出生成 IL 指令超过 100 的方法难以理解和维护。此外,他们还表示,除非方法是代码生成工具自动生成的,否则生成 200 行或更多 IL 指令的方法非常复杂,应该拆分成更小的方法。
-
应用汇编:应用汇编计数。
-
应用命名空间:应用命名空间计数。
-
应用方法:应用方法计数。
-
应用字段:应用字段计数。
-
代码行数覆盖:被测试覆盖的代码行数。
-
未覆盖的代码行数:未由测试覆盖的代码行数。
现在我们将介绍什么是汇编指标以及可以收集哪些类型的指标。
汇编指标
程序集指标更侧重于衡量单个程序集的质量和稳定性。由于一个应用程序可以由许多程序集组成,因此任何单个或多个程序集中都可能出现问题。如果多个程序集依赖于一个性能不佳的程序集,那么整个应用程序都将受到影响。此外,能够在不同的项目中重用程序集是很好的,因此耦合应保持在绝对最小。
收集程序集指标使你能够了解你的程序集是如何耦合在一起的,你还可以看到它们是抽象和稳定的还是不稳定。此外,你可以根据这些指标确定它们是否以当前形式可重用。可用于衡量程序集源代码的各种指标包括以下内容:
-
输入耦合:这是其他程序集中依赖于当前程序集中类的类的数量。
-
输出耦合:这是当前程序集中依赖于其他包中类的类的数量。
-
关联内聚性:一个程序集中每个类型内部关系的平均数量。
-
不稳定性:输出耦合与总耦合的比率。
-
抽象性:内部抽象类和接口与内部类型的比率。
-
主序列距离:一个表示抽象性和稳定性之间平衡的数字。
现在,让我们看看命名空间指标是什么以及可以收集哪些类型的指标。
命名空间指标
命名空间是任何专业质量 API 的重要组成部分。正确地将你的代码分区到相关命名的命名空间有助于程序员理解你的 API,并更容易找到他们想要的东西。命名空间指标帮助你了解你是否存在依赖循环,以及你的程序集是高级、中级还是低级。
关于命名空间代码质量的可用指标包括以下内容:
-
输入耦合:直接依赖于当前命名空间的命名空间的数量。
-
输出耦合:当前命名空间所依赖的不同命名空间的数量。
-
级别:命名空间的水平值。这个指标可以帮助你识别依赖循环。此外,它还有助于你客观地将你的程序集、命名空间、方法和类型分类为高级、中级或低级。
是时候看看有哪些类型的指标以及可以收集哪些类型的指标了。
类型指标
类型指的是类类型、接口类型、数组类型、值类型、枚举类型、类型参数、泛型类型定义以及开放或封闭构造的泛型类型。
类型及其编码和使用方式是我们作为程序员和最终用户所遇到的所有问题的背后。理解它们在我们程序中的使用方式是识别我们代码中各种问题的有效方法。当问题被识别出来时,它们可以被纠正。
类型代码质量指标包括以下内容:
-
类型排名:基于对类型依赖图应用排名算法计算得出的值,类似于谷歌的 PageRank 算法。
-
输入耦合:依赖于当前类型的类型数量。
-
输出耦合:当前类型直接依赖的类型数量。
-
缺乏内聚的方法:为了使代码遵循单一职责原则(SRP),它将只有一个变更的理由,没有更多。
-
循环复杂度:通过方法路径的数量。
-
IL 循环复杂度:通过 IL 代码的路径数量。
-
实例大小:指定类型的实例的大小,以字节为单位。
-
实现的接口:实现的接口数量。
-
类之间的关联:在其他类型的成员中直接用于当前类型方法体内的成员数量。
-
子类数量:类的子类数量,或实现接口的类型数量。
-
继承树深度:类或结构的基础类数量。
现在我们将探讨方法指标是什么以及可以收集到的方法指标类型。
方法指标
通常,方法背后隐藏着大多数性能问题。是类中的方法执行指令,可能会给客户带来任何数量的问题。这些问题可能包括运行时错误、数据错误和性能问题。能够看到和理解方法如何与其他方法交互,对于解决包括性能问题在内的各种问题非常有帮助。可用于分析方法代码质量的指标包括以下内容:
-
方法排名:基于对方法依赖图应用排名算法计算得出的值,类似于谷歌的 PageRank 算法。
-
输入耦合:直接依赖于当前方法的方法数量。
-
输出耦合:当前方法直接依赖的方法数量。
-
IL 嵌套深度:从 IL 代码中计算出的方法体内封装的作用域的最大数量。
-
参数:在方法签名中使用的参数数量。
-
变量:方法体内的变量数量。
-
重载:方法重载的数量。
-
分支覆盖率百分比:由操作码生成的测试覆盖的分支百分比。
我们将要查看的最终指标是字段指标。
字段指标
用于在字段级别测量耦合的指标是输入耦合。这指的是直接使用变量的方法数量。数量越高,软件的稳定性越低。因此,这个指标可以用于提高软件的稳定性。
实例大小指标衡量的是指定类型实例的大小,以字节为单位。
在下一节中,我们将探讨如何通过执行静态代码分析来改进架构和代码质量。
执行静态代码分析
静态代码分析的目的在于通过以下方式帮助您提高整体架构质量、代码质量和性能:
-
可视化软件架构及其软件依赖关系
-
强制执行有关布局、子系统、调用规则等方面的指定架构规则
-
识别使用剪切、复制和粘贴方式克隆和修改的代码
-
识别可以删除的无效代码
-
计算各种软件度量
-
执行代码风格检查并标记违规项
许多公司将静态代码分析作为其持续集成(CI)流程的一部分。问题可能在以下各个阶段显现。这些阶段如下列出:
-
当在 IDE 中编译源代码时
-
当运行单元测试和端到端系统测试时
-
当将源代码推送到版本控制并提交拉取请求时
-
当提交拉取请求并将代码发送到构建管道时
在编码阶段执行静态代码分析有助于防止问题在开发发布流程的后期被标记。
在 Visual Studio 中,您可以通过项目属性 | 代码分析页面运行构建和实时分析的分析器。您可以启用 .NET 分析器并将分析级别设置为预览、最新、5.0和无。此外,您可以在构建上强制执行 CodeStyle。图 5.1显示了代码分析页面:

图 5.1 – 项目属性选项卡上的 Visual Studio Code 分析页面
代码度量结果窗口可通过视图菜单中的视图 | 其他窗口 – 代码度量结果访问。代码度量结果窗口如图 5.2 所示:

图 5.2 – 代码度量结果窗口
右键单击 CH04_Finalization 项目,并在上下文弹出菜单中选择分析和代码清理 | 计算代码度量。代码度量结果窗口将更新为分析结果:

图 5.3 – CH04_WeakReference 项目的 Visual Studio 2022 代码度量结果
CH04_Finalization。
详细了解度量信息
如果你想了解更多关于指标(可维护性指数、圈复杂度、继承深度、类耦合度、源代码行数和可执行代码行数)的信息,那么你可以在我的另一本书中找到一个专门的章节(第十二章),这本书名为 Clean Code in C# (www.packtpub.com/product/clean-code-in-c/9781838982973)),由 Packt 出版。
从 可维护性指数 列的交通灯指示器中,你可以看到我们的项目一路绿灯。这意味着我们的项目是可维护的。
我们方法的圈复杂度在 1 到 2 之间,所以我们的单个方法代码不包含任何风险。然而,我们项目的整体圈复杂度为 31,属于中等风险。这个值是项目中每个类整体圈复杂度的总和。我们每个类的圈复杂度是每个方法圈复杂度的总和。由于没有哪个类的圈复杂度超过 13,我们的代码复杂但只对我们项目构成低风险。因为项目的整体复杂度为 31,我们应该检查代码是否可以重构以降低圈复杂度。有时,你会发现代码已经尽可能简单,并且无法进一步降低圈复杂度。这是可以的。当你遇到这样的代码时,只需运用你的常识和更好的判断力即可。
我们项目中继承的最大深度是 FreeAllocateMemory 类继承自我们的 DisposableBase 类,而 DisposableBase 类又继承自 System.Object 类。如果我们研究一下 DisposableBase 类的功能,我们可以看到它不会给我们带来任何问题。
我们项目中的代码行数总计约为 200。有 50 行可执行代码。这是因为我们有效地使用了空白,使得代码易于阅读。易于阅读的代码更容易理解、扩展和维护。
打开 CH06_Collections:

图 5.4 – CH04_Finalization 项目的 Visual Studio 2022 代码分析结果
在前面的屏幕截图中,我们可以看到我们有 0 个错误,4 个警告和 62 条消息。三条信息性消息告诉我们,有三个不同的方法没有访问实例数据,可以标记为静态。
在 CH04_Finalization.DisposableBase 类中,我们实现了 IDisposable 接口。在这个类中,代码分析会为代码分析规则 CA1816 产生两个信息性消息。这个代码分析规则告诉我们,Dispose 方法应该调用 SuppressFinalize。尽管调用了 GC.SuppressFinalize,但我们仍然收到这个代码分析规则的信息性消息。因此,为了移除(抑制)警告,我们将代码包裹在 #pragma 编译器指令中。这可以手动完成,或者通过右键单击消息并选择以下方式选择 DisposableBase 源文件:
#pragma warning disable CA1816
// Dispose methods should call SuppressFinalize
public void Dispose()
#pragma warning restore CA1816
// Dispose methods should call SuppressFinalize
{
Dispose(true);
}
private void Dispose(bool disposing)
{
if (disposing)
#pragma warning disable CA1816
// Dispose methods should call SuppressFinalize
GC.SuppressFinalize(this);
#pragma warning restore CA1816
// Dispose methods should call SuppressFinalize
ReleaseManagedResources();
ReleaseUnmanagedResources();
}
现在,DisposableBase 类已经通过这些 #pragma 警告禁用 CA1816 语句进行了更新,请注意,这些消息不再在错误列表中显示。
好吧,我们已经了解了如何使用 Visual Studio 2022 生成代码度量值并对 CH04_Finalization 项目进行代码分析。现在,让我们看看如何生成内存转储并分析它们。
生成和查看内存转储
在 Visual Studio 中进行调试时,如果你的程序在断点或异常处停止,那么在 Debug 菜单中就会出现 Save Dump As 菜单选项。
包含堆文件的 minidump 提供了应用程序内存的快照,显示了当时正在运行的过程,并列出了在某个时间点加载的模块。转储文件使您能够检查在转储保存时应用程序和内存中的堆栈、线程和变量。
当在测试软件时遇到崩溃,并且无法在您的计算机上重现客户程序崩溃时,您会保存包含堆文件的 minidump。
让我们通过以下步骤保存和加载包含堆文件的 minidump:
-
使用我们的
CH04_WeakReferences项目,在program.cs文件中的以下行设置断点:Console.WriteLine("Press any key to continue."); -
运行项目到断点。然后,当断点被触发时,选择
CH04_WeakReference.dmp。这是一个包含堆文件的 minidump 文件。 -
要读取文件,请选择 文件 | 打开 | 文件。然后,选择您刚刚保存的文件。您应该看到以下窗口:
![Figure 5.5 – 在 Visual Studio 2022 中加载堆文件的 minidump
![img/Figure_5.05_B16617.jpg]
图 5.5 – 在 Visual Studio 2022 中加载堆文件的 minidump
上述截图显示我们可以看到文件最后更新的时间、进程名称、计算机架构、异常代码和信息、堆信息以及错误信息。然后,我们有 CLR 和 OS 版本。最后,有一个模块列表,包括它们的名称、版本和路径。
您刚刚学习了如何在 Visual Studio 2022 中生成和读取内存转储。现在,我们将看看如何使用 Visual Studio 2022 中的 Modules 窗口查看我们的项目加载了哪些模块。
查看已加载的模块
为了确定可能引起性能问题,如过度的内存加载,或可能生成运行时错误的原因,查看已加载到内存中的模块可能会有所帮助。在本节中,您将学习如何查看加载的模块以及了解有关这些模块提供的信息项。
当您在 Visual Studio 2022 中进行调试时,调试 | 窗口 菜单包含以下菜单,如图 图 5.6 所示:

图 5.6 – 调试会话期间的 Windows 菜单
如 图 5.6 所示的先前菜单中,您可以在调试会话期间选择 模块。这将加载 模块 窗口,如图 图 5.7 所示:

图 5.7 – 显示当前进程加载模块的模块窗口
如 图 5.7 所示,CH04_WeakReferences.exe 进程在 clrhost 应用域中运行,并加载以下模块:
-
System.Private.CoreLib.dll -
CH04_WeakReference.dll -
System.Runtime.dll -
System.Console.dll
在 模块 窗口中显示的字段列表如下:
-
名称:已加载的汇编(加载的模块)的名称
-
路径:已加载模块的路径
-
优化:是/否
-
用户代码:是/否
-
符号状态:跳过加载符号/符号已加载
-
符号文件:已加载符号文件的路径和文件名
-
顺序:汇编加载的顺序
-
版本:汇编版本
-
地址:已加载模块的内存地址
-
进程:负责将模块加载到内存中的进程标识符和可执行文件名
-
应用域:模块正在运行的程序域的名称。在 .NET Core 和 .NET 5 或更高版本中,这没有任何意义。它被显示出来是因为调试器 UI 没有区分 .NET Framework 和 .NET Core。
您可以使用这些信息来查看已加载的模块,了解有关这些模块提供的信息项,包括它们在内存中的位置、符号是否已加载、代码是系统代码还是用户代码,以及代码是否已优化或未优化。如果您发现未优化的用户代码,则可以应用优化以提高性能。
在下一节中,我们将简要介绍如何通过介绍您应该已经熟悉的工具来进一步调试您的应用程序。
调试您的应用程序
假设您知道如何通过运行代码、单步执行和跳过代码、运行到光标处以及设置断点来调试您的代码。然而,当使用调试器时,还有其他有用的工具可用。以下是一些工具:

图 5.8 – 调试 | 窗口菜单
如您所见,有相当数量的不同窗口可供帮助调试您的应用程序。立即窗口在程序暂停时执行命令非常出色。局部变量窗口用于查看变量的当前状态,调用堆栈对于查找异常发生的位置非常有用,尤其是如果它是在您不熟悉的代码中!花些时间在打开这些窗口的情况下运行您的源代码。例如,XAML 绑定失败这样的窗口仅在处理基于 XAML 的代码时使用。但其他窗口,如立即、局部变量、输出、自动和调用堆栈,可以与所有项目类型一起使用。充分利用这些工具的最好方法是亲自使用它们,并在编写代码的过程中了解它们。接下来,我们将探讨使用跟踪和诊断工具。
使用跟踪和诊断工具
在本节中,我们将探讨一些分析工具,以帮助您跟踪和诊断代码中的任何问题。通过跟踪和诊断程序,您可以识别性能关注点并解决它们。这些关注点可能包括内存分配的数量和它们使用的字节数,以及识别垃圾回收后存活的对象数量。此类信息可用于提高内存使用率和性能,以及防止和移除内存泄漏。
我们将探讨来自 JetBrains 的两个产品,称为 dotMemory 和 dotTrace,这些工具在这方面非常有价值。但首先,我们将从 Visual Studio 2022 内置的名为 性能分析器 的分析器开始。
使用 Visual Studio 2022 性能分析器
现在,我们将查看我们项目的性能配置文件。这将显示随时间推移的对象数量以及垃圾回收在我们的项目中的使用方式,以及垃圾回收后存活的对象数量。我们可以将此配置文件深入到程序集和方法级别。这使得我们可以看到方法内的对象分配数量以及这些分配使用的总字节数。正因为有了这些信息,我们可以识别出程序中生成最多内存使用的区域。有了这样的信息,我们可以考虑对重分配代码进行重构以改善内存性能。
要访问 Visual Studio 2022 性能配置文件,请从 Visual Studio 2022 调试菜单中选择性能分析器。这将打开一个标签页,如图 图 5.9 所示:

图 5.9 – Visual Studio 2022 性能分析器
现在,我们将对 CH04_Finalization 项目进行分析:
-
选择你的启动项目。
-
然后,选择您想要使用的工具。在我们的例子中,我们选择了
CH04_Finalization。我们选择的工具是用于跟踪.NET 对象分配的工具。这使我们能够看到.NET 对象在哪里分配以及何时被回收。 -
点击开始按钮以开始分析应用程序。分析器将运行,并在代码停止时停止。您将看到一个类似于图 5.10的报告:
![Figure 5.10 – 完整的 Visual Studio 2022 性能分析器报告
显示活动对象随时间变化
![img/Figure_5.10_B16617.jpg]
图 5.10 – 显示活动对象随时间变化的完整 Visual Studio 2022 性能分析器报告
主要图表区域显示了随着时间的推移活动对象的数量。此外,还有四个标签页,包含分配、调用树、函数和收藏集数据。
- 在分配标签页上,您可以看到使用的类型及其分配数量。点击一个类型将显示该类型的回溯。您可以看到该类型的分配数量以及在您的函数中分配的字节数,如图图 5.11所示:
![Figure 5.11 – Visual Studio 2022 性能分析器中 System.Sbyte[]的分配]
![img/Image87475.jpg]
图 5.11 – Visual Studio 2022 性能分析器中 System.Sbyte[]的分配
在图 5.11中,我们可以看到在我们的Main方法中,有 19 次System.Sbyte[]类型的分配,分配大小为952字节。
- 选择
DisplayGeneration(Product product)方法,有一个大小为24字节的System.Int32分配,如图图 5.12所示:
![Figure 5.12 – Visual Studio 2022 性能分析器调用树标签页
![img/Figure_5.12_B16617.jpg]
图 5.12 – Visual Studio 2022 性能分析器调用树标签页
- 选择
Main方法总共有347次分配,27次自我分配,总大小为1,438字节,如图图 5.13所示:
![Figure 5.13 – Visual Studio 2022 性能分析器函数标签页显示各种方法的分配和大小
![img/Figure_5.13_B16617.jpg]
图 5.13 – Visual Studio 2022 性能分析器函数标签页显示各种方法的分配和大小
- 点击收藏集标签页。然后,点击一行。您将看到两个饼图,分别显示顶级收集类型和顶级存活类型,如图图 5.14所示:
![Figure 5.14 – Visual Studio 2022 性能分析器显示垃圾回收的分解
![img/Figure_5.14_B16617.jpg]
图 5.14 – Visual Studio 2022 性能分析器显示垃圾回收的分解
在图 5.14中,我们可以看到随着时间的推移,活动对象的数量以及对象变化量(百分比变化)。此外,我们还可以在两个饼图中看到顶级收集类型和顶级存活类型。
Visual Studio 2022 性能分析器是一个非常实用的工具,它使您能够查看分配、字节大小以及垃圾回收和存活的对象。您还可以看到随时间变化的存活对象数量。现在您已经了解了分析器及其功能,让我们将注意力转向 JetBrains 的工具 dotMemory。
使用 JetBrains dotMemory
我们使用 dotMemory 来分析和优化内存,并帮助我们识别内存泄漏和其他内存相关的问题。在本节中,我们将讨论 JetBrains dotMemory 内存分析器。
内存分析器将在 x 轴上提供以毫秒为单位的图表,在 y 轴上提供以兆字节为单位的图表,该图表显示了您的应用程序随时间变化的内存使用情况。以下项目在图表上显示:
-
总使用量:使用的内存总量。
-
未托管内存:放置在堆栈上的内存总量。
-
堆生成 0:新对象占用的内存量。这些对象的大小将小于 80,000 字节。
-
堆生成 1:在生成 0 垃圾回收中存活的对象。
-
堆生成 2:在 1 级垃圾回收中存活的长期对象。
-
大对象堆 (LOH):大小为 80,000 字节或更大的对象使用的内存量。
-
自垃圾回收以来分配到 LOH:垃圾回收发生后在 LOH 上使用的内存量。
让我们看看 dotMemory 内存分析器的实际应用。如果您还没有这样做,请从 JetBrains 下载并安装 dotMemory,以及从 GitHub 页面获取 第四章 的代码。打开 dotMemory,您将看到一个类似于 图 5.15 所示的屏幕:

图 5.15 – 准备分析 .NET Core 应用程序的 dotMemory 内存分析器
在 图 5.15 中,我们选择了分析 CH04_PreventingMemoryLeaks.dll。点击 运行 按钮。这将使分析器开始运行并分析您的应用程序。一旦应用程序被分析,将显示一个报告,以图形形式显示结果,如图 图 5.16 所示:

图 5.16 – CH04_PreventingMemoryLeaks.dll 的分析报告
如前一个屏幕截图所示,我们的应用程序总共使用了 8.16 MB 的内存。这并不多。大部分内存都放在堆栈上,如未托管内存使用量所示,为 8.06 MB。其余的内存位于堆上。在堆上,0 代分配了 24 KB,1 代分配了 77.6 KB,2 代分配了 1.3 KB。最多堆内存 19.2 KB 被放置在 LOH 上,并且在垃圾回收后没有保留。
在看到 dotMemory 工具的实际操作后,我们现在可以将注意力转向 JetBrains dotTrace 工具在跟踪和性能分析方面能为我们提供什么。
使用 JetBrains dotTrace
在本节中,我们将探讨 JetBrains dotTrace。您将学习如何使用 JetBrains dotTrace 工具在程序运行时进行应用程序跟踪。这将帮助您识别可执行程序中的瓶颈和内存问题。
dotTrace 中可用的分析器选项包括以下内容:
-
采样:对调用时间的精确测量。这对于大多数用例是最优的。
-
跟踪:对调用次数的精确测量。这对于分析算法复杂度是最优的。
-
逐行分析:仅适用于高级用例。
-
时间线:对时间性能数据的测量。这对于大多数用例是最优的,包括多线程应用程序的分析:


图 5.17 – JetBrains dotTrace 准备分析我们的应用程序
图 5.17 展示了 dotTrace 的初始状态。我们已将 CH03_PassByValueAndReference.exe 作为我们的分析应用程序。在我们的分析选项中,我们选择了默认的 采样 设置。确保已选中 从开始收集分析数据。然后,点击 运行 按钮开始跟踪。
当跟踪完成后,dotTrace 性能查看器将自动打开,如 图 5.18 所示:


图 5.18 – JetBrains dotTrace 性能查看器
对 CH03_PassByValueAndReference.exe 文件进行性能分析的结果显示在 图 5.18 的默认视图中。如果您点击 Main 行,您将看到程序代码。Main 方法的分解显示,19 毫秒(43.20%)的时间用于执行系统代码,13 毫秒(29.56%)的时间用于执行文件 I/O,12 毫秒(27.24%)的时间用于执行 String 子系统,如 图 5.19 所示:


图 5.19 – 主要方法的分解
图 5.19 展示了 Main 方法的源代码以及从 Main 到 InParameterModifier 之间,Main 方法花费了最多时间进行处理的实际情况。这些信息有助于识别并处理瓶颈。
我们已经看到了两个用于内存分析和跟踪的工具,可以用来衡量性能、识别瓶颈和问题。现在,让我们将注意力转向安装和使用 dotnet-counters。
安装和使用 dotnet-counters
在本节中,我们将安装和使用 dotnet-counters。这些计数器是非常有用的数据收集工具,帮助我们监控程序的健康状况。
打开 Visual Studio 2022 的开发者命令提示符。然后,输入以下命令并按 Enter 键:
dotnet tool install --global dotnet-counters --version 3.1.141901
这将下载并安装 dotnet-tools。成功安装将显示,如图 图 5.20 所示:

开发者命令提示符

图 5.20 – 使用开发者命令提示符成功安装 dotnet-tools 版本 3.1.141901
使用 dotnet-counters 的目的是对您的应用程序进行健康监控和初步的性能调查。如果在使用此程序时发现潜在的性能问题,则可以使用 PerfView 或 dotnet-trace 等工具进行更深入的性能调查:
-
要定期收集所选计数器值并将它们导出到文件以进行后期处理,请使用
dotnet-counters collect命令。 -
dotnet-counters list命令显示按提供程序分组的计数器名称和描述列表。 -
要显示可以监控的 .NET 进程列表,可以使用
dotnet-counters ps命令。 -
使用
dotnet-counters monitor命令,您可以定期显示所选计数器的刷新值。
要获取每个命令的可用选项列表,请附加 -h 或 –help。让我们使用这些命令中的每一个。在我们这样做之前,将以下行添加到 CH04_WeakRefereces Main 方法在 Program 类的末尾:
Console.WriteLine("Press any key to continue.");
Console.ReadKey();
运行程序。程序将暂停并等待你按下一个键后继续。
收集数据并将其保存到文件以供后期分析
现在我们将使用 dotnet-counters 将数据保存到文件中,以便我们的程序运行完成后进行分析:
-
删除
Program类中CH04_WeakReferences的断点。 -
将
Program类中的ProcessReferences()方法更新如下:private static void ProcessReferences() { int x = 0; while(x < 10000) { StrongReferences.ListObjects(); WeakReferences.ListObjects(); Thread.Sleep(2000); GC.Collect(); x++; } } -
在
Program类的while (x < 10000)循环中添加一个断点。 -
然后,运行程序。运行程序将需要一些时间 - 大约 10,000 次迭代 x 2 秒 = 5.5 小时。
-
当程序在 步骤 3 中添加的断点处停止时,以管理员身份打开命令提示符,并输入
dotnet-counters ps然后按 Enter。如果您不以管理员身份运行,您将遇到计数器访问错误。 -
获取程序的进程 ID。
-
将命令提示符中的目录更改为指向
C:\Temp。如果不存在,则创建该目录。 -
输入
dotnet-counters collect --process-id 1234命令(将 1234 替换为你的 .NET 进程 ID),然后按 Enter。 -
现在将收集性能数据。
-
删除在 步骤 3 中添加的断点并继续程序。当你让程序运行了一段时间后,按 q 键。你的命令提示符屏幕应该类似于 图 5.21:


图 5.21 – 完成收集后开发者命令提示符
- 在 Excel 中打开名为
C:\Temp\counter.csv的文件。Figure 5.22 显示了电子表格中的数据摘录:

Figure 5.22 – counter.csv 的摘录
如你所见,dotnet-counters 收集过程记录了各种项目。这些项目包括 CPU 使用率、垃圾收集数据、堆信息、异常信息、加载的程序集数量和 JIT 编译信息。
列出可监控的 .NET 进程
要列出可监控的 .NET 进程,请打开开发者命令提示符窗口并输入 dotnet-counters ps 命令。你应该会看到类似以下输出:

Figure 5.23 – 可监控的 .NET 进程列表
如 Figure 5.23 所示,唯一可监控的进程是进程 5364。进程 5364 是我们目前正在调试的程序。如果有更多的 .NET 程序在运行,那么这个列表中会有更多。
列出可用的已知 .NET 计数器列表
要列出可用的 .NET 计数器,请运行以下命令:
dotnet-counters list
你将在控制台看到计数器和它们的描述列表。对于 Microsoft.AspNetCore.Hosting,可用的计数器如下所示:
-
requests-per-second: 请求速率
-
total-requests: 请求总数
-
current-requests: 当前请求数量
-
failed-requests: 请求失败的数量
以下列出 System.Runtime 可用的已知计数器:
-
cpu-usage: 进程已使用的 CPU 时间(以毫秒为单位)
-
working-set: 进程使用的内存工作集大小(以兆字节为单位)
-
gc-heap-size: 垃圾收集器报告的总堆大小(以兆字节为单位)
-
gen-0-gc-count: 每分钟生成 0 收集器的垃圾收集次数
-
gen-1-gc-count: 每分钟生成 1 收集器的垃圾收集次数
-
gen-2-gc-count: 每分钟生成 2 收集器的垃圾收集次数
-
loh size: 大对象堆大小
-
alloc-rate: 每秒在托管堆中分配的字节数
-
assembly-count: 加载的程序集数量
-
exception-count: 每秒异常数量
-
threadpool-thread-count: 线程池线程数量
-
monitor-lock-contention-count: 每秒尝试获取监视器锁时的争用次数
-
threadpool-queue-length: 线程池队列中的工作项数量
-
threadpool-completed-items-count: 线程池中完成的工作项数量
-
active-timer-count: 当前活动的计时器数量
监控 .NET 进程
我们将运行 CH04_WeakReferences 项目。一旦项目运行,请运行以下命令以获取进程 ID:
dotnet-counters ps
然后,一旦你有了.NET 程序的进程 ID,运行以下命令:
dotnet-counters monitor –process-id 6719
对于我来说,该进程的 ID 为6719。将6719替换为你的进程 ID。结果应该是你看到.NET 计数器实时显示和更新,如图图 5.24所示:

图 5.24 – dotnet-counters 正在实时列出和更新我们的 CH04_WeakReferences 项目
按q键退出。如你所见,我们有19.042%的垃圾收集碎片。在 LOH 上有19,640字节,80,864字节分配给了第2代。我们已加载9个程序集,24字节分配给了第0代和第1代。我们观察到内存碎片发生在19.042%,因此可以进一步调查为什么有碎片,以及我们是否可以避免这种情况。
在下一节中,我们将查看一个示例,该示例追踪一个 WPF 应用程序中的内存泄漏。
使用 dotMemory 追踪和修复内存泄漏
在本节中,我们将通过一个示例来演示如何追踪和修复内存泄漏。应用程序抛出的OutOfMemoryException异常。
我们的示例将是一个名为CH05_GameOfLife的 WPF 应用程序。为了节省时间和空间,下载 WPF 应用程序的源代码。这将帮助你专注于当前的任务,即追踪内存泄漏并修复它。
注意
在分析和跟踪时,你最好使用发布模式构建你的项目。原因是调试构建包含可能影响分析结果的编译指令。
执行以下步骤:
-
下载并编译
CH05_GameOfLife项目,以发布模式编译。 -
打开dotMemory。本例中使用的版本是2020.3.4
-
在新会话下,选择本地。然后,在分析应用程序下,选择.NET Core 应用程序。在.NET Core 应用程序下选择CH05_GameOfLife.exe文件,对于分析器选项,选择从开始收集内存分配和流量数据。图 5.25显示了 dotMemory 准备分析我们的应用程序:

图 5.25 – dotMemory 准备分析我们的.NET 6.0 应用程序 CH05_GameOfLife.exe
- 点击运行以开始分析我们的应用程序。你将看到 dotMemory 中出现一个新的分析标签,如图图 5.26所示:

图 5.26 – dotMemory 在分析我们的应用程序时显示分析标签
- 当分析器启动时,它也会启动我们的应用程序。点击应用程序的开始按钮,如图图 5.27所示:

图 5.27 – 运行 CH05_GameOfLife
-
在 生命游戏 运行一段时间后,点击 获取快照 按钮以拍摄内存快照。这将捕捉到该时刻应用程序的托管堆。
-
关闭广告。
-
再次拍摄一个快照,以便我们有两个快照。然后,关闭 生命游戏 应用程序以停止分析器。图 5.28 显示了已拍摄两个快照的 dotMemory 分析 选项卡:

图 5.28 – 显示两个内存快照的 dotMemory 分析选项卡
- 下一步是我们比较两个不同的快照。图 5.29 显示了两个快照并排的特写:

图 5.29 – dotMemory 快照 1 和 2
- 点击 比较 打开两个快照的详细并排比较。您应该看到如图 图 5.30 所示的比较:

图 5.30 – 并排快照比较屏幕
如您所见,此视图显示了创建的新对象数量,垃圾收集器收集的对象数量(已死亡的对象),以及经过垃圾收集后存活的对象数量。这是可以用来识别内存泄漏的良好信息来源。
- 点击 命名空间 列表。然后,展开 CH05_GameOfLife 命名空间并突出显示 AdWindow 条目,如图 图 5.31 所示:

图 5.31 – 使用 CH05_GameOfLife 高亮的命名空间分析
- 在 存活对象 列表中,点击 AdWindow 行中的数字 1。这将弹出对话框,如图 图 5.32 所示:

图 5.32 – dotMemory 对话框提示打开快照
-
选择较新的快照选项。
-
然后,点击 关键保留路径 选项卡。JetBrains dotMemory 视图将更改为类似于 图 5.33 的视图:

图 3.33 – 关键保留路径选项卡
您可以看到 EventHandler 正在保持 AdWindow 活跃,并且 EventHandler 被类 DispatcherTimer 引用。DispatcherTimer 类被 Tick 事件引用。
- 点击
DispatcherTimer框。这将带您到DispatcherTimer类,如图 图 3.34 所示:

图 3.34 – 显示 DispatcherTimeruse 详细信息的输出引用表
此选项卡确实显示 Tick EventHandler 正在保留字节,这导致我们的 DispatcherTimer 对象在内存中保持活跃。
- 点击
EventHandler创建。方法出现在顶部,如图 图 3.35 所示:
![图 3.35 – 显示创建堆栈跟踪选项卡中的 AdWindow 构造函数,该构造函数创建计时器]
图 3.35 – 显示创建堆栈跟踪选项卡中的 AdWindow 构造函数,该构造函数创建计时器
-
在 CH05_GameOfLife 项目的
AdWindow类中定位AdWindow构造函数:public AdWindow(Window owner) { ... _adTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) }; _adTimer.Tick += ChangeAds; _adTimer.Start(); }
如前述代码片段所示,我们正在订阅 Tick 事件,该事件由 ChangeAds 方法处理。但我们没有做的一件事是在我们不再需要它时取消订阅事件。这就是内存泄漏的原因。
-
为了纠正我们的内存泄漏,我们只需要在我们不再需要它时取消订阅事件。为此,我们更新
OnClosed方法,如下面的代码所示:protected override void OnClosed(EventArgs e) { _adTimer.Tick -= ChangeAds; base.OnClosed(e); }
我们现在通过在关闭 AdWindow 构造函数时取消订阅 Tick 事件来纠正我们的内存泄漏。重复这些步骤来分析这个内存泄漏,你会看到它现在已经被修复,如图 图 5.36 所示:

图 5.36 – dotMemory 显示内存泄漏已被修复
注意
我们已经有效地追踪并修复了一个内存泄漏,这是由于我们没有取消订阅我们订阅的事件。这是 C# 中内存泄漏的非常常见的原因。要了解更多关于 dotMemory 以及如何在各种场景中使用它的信息,请访问 JetBrains 的官方 How-To 文档,网址为 www.jetbrains.com/help/dotmemory/Examples.html。
在下一节中,我们将探讨如何使用 dotTrace 追踪和修复 UI 冻结。
使用 dotTrace 寻找 UI 冻结的原因
在本节中,我们将使用 dotTrace 来追踪 UI 冻结的原因,以便我们可以修复它。再次强调,为了节省时间,我们将使用已经为你提供的项目。从 技术要求 部分指定的 URL 获取书籍的源代码。在 CH05 的源代码中,你会找到一个名为 CH05_BatchFileProcessing 的项目。
此项目打开用户指定的多个文本文件,然后反转它找到的每个字符串。当用户点击 BackgroundWorker 线程时,它将在一个单独的线程上运行。在左上角,显示文件处理的进度。这会变为 所有文件已成功处理。然而,存在一个问题,即在文件处理过程中 UI 会冻结。
为了找到这个 UI 冻结的源头并修复它,我们将使用时间线分析,这是 dotTrace 提供的功能:
-
在 发布 模式下构建 CH05_BatchFileProcessing 项目。
-
打开 dotTrace。
-
选择配置文件本地应用 | .NET Core 应用程序 | 时间线,并选择您刚刚编译的可执行文件。请确保勾选从开始收集分析数据。图 5.37 展示了在运行 dotTrace 之前对其进行配置的情况:

图 5.37 – 在我们运行时间线分析器之前 dotTrace
- 点击运行按钮开始时间线分析。分析器将被打开,如图 图 5.38 所示:

图 5.38 – dotTrace 时间线分析器
分析器将启动 CH05_BatchFileProcessor 程序,如图 图 5.39 所示:

图 5.39 – 批处理文件处理器
当应用程序完成文件处理后,UI 将显示,如图 图 5.40 所示:

图 5.40 – CH05_BatchFileProcessor
- 点击时间线分析器上的 获取快照 和 等待 按钮。这将保存快照并在 dotTrace 时间线查看器应用程序中打开,如图 图 5.41 所示:

图 5.41 – 加载了时间线快照的 dotTrace 时间线查看器应用程序
-
您可以关闭 CH05_BatchFileProcessor 和 dotTrace 分析器应用程序。但请保持 dotTrace 时间线查看器应用程序打开。
-
所有过滤器值都是针对所有当前可见的线程计算的。我们只对有活动的线程感兴趣。因此,通过选择它们,右键单击并选择隐藏所选线程来隐藏所有没有活动的线程。
-
我们的 BackgroundWorker 线程是具有 ID 12764 的 .NET ThreadPoolWorker 线程,如图 图 5.42 所示:

图 5.42 – 带有突出显示的 BackgroundWorker 线程的 dotTrace 时间线查看器应用程序
- 将时间线放大到 .NET ThreadPool Worker。您可以看到时间线由三个状态组成。这些状态是运行中、等待 CPU 和 等待。您可以在 图 5.43 中看到我们的线程时间线:

图 5.43 – 我们线程在时间线跟踪中的活动
在屏幕的左侧,您将在 过滤器 面板中看到 线程状态 部分。依次选择每个状态,您将看到相应的时线被突出显示。尝试所有可用的不同过滤器。调查每个选项提供的内容。这是一种很好的学习方法。收起的 过滤器 面板在 图 5.44 中显示:

图 5.44 – 收起的 dotTrace 过滤器面板
- 在屏幕的右侧,您将看到 调用栈 面板和 源视图 面板。如果您在线程的时间线上点击任何位置,您将看到该时间点的调用栈。对于该堆栈跟踪,将显示调用树。如果您在调用栈中点击一个条目,代码将被反编译并在 源视图 选项卡中显示。此功能使您能够看到在什么时间点运行什么代码。此外,此视图还显示了您正在查看的代码的完整程序集名称、命名空间和类名。图 5.45 显示了 调用栈 面板:

图 5.45 – 显示 Backtraces 选项卡的 dotTrace 调用栈面板
图 5.46 显示了 源视图 面板:

图 5.46 – 显示反编译的 C# 和 IL 源代码的 dotTrace 源视图屏幕
注意
横跨 String 的彩色条。根据特定时间点的发生情况,如果使用了多个子系统,则此行可能为多色。此条形也用于显示线程锁定等功能。
- 现在我们准备调查为什么我们的 UI 会冻结。图 5.47 中的紫色线条代表我们的 UI 冻结的时刻:

图 5.47 – 显示我们的线程并突出显示 UI 冻结的 dotTrace 过滤视图
我们感兴趣的紫色线条是最后非常长的一条。
-
在 过滤器 部分选择 事件 | .NET 内存分配。
-
然后,选择 线程状态 | 运行。
-
选择 子系统 | 用户代码,并取消选择其他所有选项。您应该在 方法和子系统 下看到以下内容:

图 5.48 – 突出显示有问题的用户代码的 dotTrace 方法和子系统屏幕
查看前面高亮的方法ProcessInProgress,我们在 UI 冻结发生的时间段内 100%地调用它。点击ProcessInProgress将显示MainWindow.xaml.cs文件的內容。我们的违规代码如下:
private void ProcessInProgress(
object sender,
ProgressChangedEventArgs e
)
{
var upd = (ProgressUpdater)e.UserState;
lblProgress.Content = $"File {upd.CurrentFileNmb} of {upd.
TotalFiles}: {e.ProgressPercentage}%";
}
我们的代码正在更新进度标签的值,该值是传递给方法的ProgressChangedEventArgs类型。那么,调用这个方法的是谁呢?它是FileProcessor类中的ProcessFiles方法:
...
for (var i = 0; i < FilePaths.Count; i++)
{
...
for (var j = 0; j < _lines.Length; j++)
{
var line = _lines[j];
var stringReverser = new StringReverser(line);
_lines[j] = stringReverser.Reverse();
if (j % 5 == 0)
{
var p = (float)(j + 1) / _lines.Length * 100;
Worker.ReportProgress((int)p, _updater);
}
}
File.WriteAllLines(path, _lines);
}
此方法遍历用户选择的文件。每个文件逐行读取,一行一行地读取。每行文本都被反转。问题是,我们调用这个方法太频繁了。所以,解决方案是将(j % 5 == 0)改为(j% 1000 == 0)。
- 对代码进行更改后重新编译并重新运行分析器。这次,将不会有延迟。您将看到 UI 冻结已被修复。
现在您已经使用了 dotTrace 和 Timeline 配置文件来跟踪并修复了 UI 冻结。在最后一节中,我们将探讨如何使用 dotTrace 来优化应用程序性能和内存流量。
使用 dotTrace 优化应用程序性能和内存流量
在本节中,我们将继续跟踪我们的CH05_BatchFileProcessing项目。我们已经修复了 UI 冻结,并将运行另一个跟踪以查看是否可以识别任何其他问题。在分析跟踪时,我们将看到产生了大量影响应用程序性能的内存流量。因此,我们将解决这个问题并修复它:
-
打开 dotTrace。您的前一个会话应该已保存。选择它,然后点击Run按钮以开始跟踪。然后,将启动示例应用程序。
-
选择文本文件,然后点击Process Files按钮。
-
一旦处理完文件,终止应用程序。这将刷新数据并将我们的跟踪加载到跟踪查看器中。然后,关闭 dotTrace。
-
一旦将跟踪快照加载到Timeline Viewer中,点击按钮以显示快照。
-
在Filters视图中,选择Events | .NET Memory Allocations and Thread State | Running。
-
隐藏除我们的.NET ThreadPool Worker线程之外的所有线程。
-
在
System.String类中。这将是我们CH05_BatchFileProcessing.StringReverse.Reverse()调用的结果。图 5.49显示了我们的跟踪结果,我们可以看到我们的方法和它们产生的内存流量百分比:
![Figure 5.49 – The dotTrace Timeline Viewer Call Stack screen showing our methods and memory traffic percentage]
![img/Figure_5.49_B16617.jpg]
![Figure 5.49 – The dotTrace Timeline Viewer Call Stack screen showing our methods and memory traffic percentage]
在这个方法中,两个不同的 MB 大小是我们自己的内存分配,不包括从该方法中调用的子方法中的内存分配/该方法或任何从该方法中调用的子方法分配的内存量。正如你所看到的,内存分配是Reverse()方法和ProcessFiles()方法。
-
在 Visual Studio 中打开这个类。
Reverse()方法的代码如下:public string Reverse() { char[] charArray = _original.ToCharArray(); string stringResult = null; for (int i = charArray.Length; i > 0; i--) { stringResult += charArray[i - 1]; } return stringResult; }
如你所见,这种方法通过将字符串分配给数组来反转字符串。然后,数组以反向迭代,每个字符通过字符串连接分配给字符串。这正是我们应用程序性能的问题所在。
有充分的文档记录表明,构建字符串的最高效方式是使用StringBuilder类。我们在这里也可以这样做。然而,还有另一种方法可以提高这个方法的表现。将现有的Reverse()字符串方法替换为以下版本:
public string Reverse()
{
char[] charArray = _original.ToCharArray();
Array.Reverse(charArray);
return new string(charArray);
}
在我们修改后的代码中,我们反转数组,并从反转后的数组返回一个新的字符串。
- 在发布模式下构建你的项目,然后运行一个新的跟踪。图 5.50显示了新跟踪的结果:

图 5.50 – 显示我们改进性能的新跟踪
从我们的跟踪中我们可以看到,ProcessFiles方法的内存分配从2.9 MB/255 MB,生成1.2%的内存流量,到3.8 MB/37 MB的内存分配,生成10.1%的内存流量。
此外,我们的Reverse()方法从分配73 MB/252 MB,生成28.5%的内存流量,到分配0 MB/19 MB的内存,生成0%的内存流量。
那是一个很好的性能提升!
在本章中,我们介绍了各种代码测量和分析的方法。通过我们获得的数据,我们成功地修复了由于未取消订阅事件处理程序而导致的内存泄漏,修复了由于 UI 更新过于频繁而导致的 UI 冻结,并改进了由于我们批量处理字符串反转的方式而引起的应用程序性能和内存流量。现在,是时候总结我们所学的知识了。
摘要
我们从查看我们可用的各种代码指标开始,进行应用程序分析和跟踪。不同的工具有不同的指标可用。这些指标涵盖了应用程序、程序集、命名空间、类型、方法和字段。
然后,我们继续研究我们如何执行静态代码分析。我们使用 Visual Studio 2022 内置的代码分析工具演示了静态代码分析。我们看到了如何生成以下指标:可维护性指数、循环复杂度、继承深度、类耦合、源代码行数和可执行代码行数。
我们接下来探讨了内存转储的生成以及如何在 Visual Studio 2022 中查看它们。我们可以查看转储时间、转储位置、进程名称、处理器架构、任何异常信息、操作系统版本和 CLR 版本。此外,我们还可以查看已加载的模块名称及其版本和物理路径。
接下来,我们探讨了如何在调试会话期间打开 模块 窗口。模块 窗口显示了模块的名称和路径,模块是否已优化,是否为用户代码或系统代码,其符号状态、顺序、版本、进程和 AppDomain。我们还看到了 调试 | 窗口 菜单中其他可用的选项,这些选项增加了我们的调试能力。
然后,我们探讨了名为 Visual Studio 2022、JetBrains dotMemory 和 JetBrains dotTrace 的跟踪和诊断工具。这些工具提供了全面的优秀调试体验,为我们提供了追踪任何类型错误所需的所有信息,包括导致内存泄漏和其他内存相关问题的错误。
接下来,我们探讨了 dotnet-counters 的使用方法。我们学习了如何列出可监控的 .NET 进程。然后,我们看到了如何列出可用的已知 .NET 计数器。在我们的结论部分,我们收集数据并将数据保存到文件中,以便进行后续分析。
最后,我们通过三个示例来演示如何使用 JetBrains dotMemory 和 JetBrains dotTrace 修复内存泄漏和 UI 冻结问题,提高性能,并减少内存流量。
在下一章中,我们将详细探讨 集合 框架。然而,在此之前,请花时间进一步阅读并回答以下问题,以巩固您所学的内容。
问题
-
代码指标覆盖了我们计算机程序的哪些方面?
-
Visual Studio 2022 静态代码分析产生哪些指标?
-
我们可以从 Visual Studio 生成的 minidumps 中查看哪些类型的事物?
-
模块 窗口中有哪些可用的列?
-
执行我们之前提到的各种诊断操作的四款调试、分析和跟踪工具的名称是什么?
-
我们使用 .NET 计数器执行了哪些操作?
进一步阅读
-
调试 Visual Studio 2019:
docs.microsoft.com/en-us/visualstudio/get-started/csharp/tutorial-debugger?view=vs-2019. -
Visual Studio 调试器中的转储文件:https://docs.microsoft.com/visualstudio/debugger/using-dump-files?view=vs-2019.
-
dotnet-counters: https://docs.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-counters. -
.NET Core 计数器内部机制:如何在监控管道中集成计数器:
medium.com/criteo-engineering/net-core-counters-internals-how-to-integrate-counters-in-your-monitoring-pipeline-5354cd61b42e#:~:text=dotnet-counters%3A%20collect%20the%20metrics%20corresponding%20to%20some%20performance,how%20to%20fetch%20them%20via%20the%20EventPipe%20infrastructure. -
JetBrains dotTrace: https://www.jetbrains.com/profiler/.
-
JetBrains dotMemory:
www.jetbrains.com/dotmemory/. -
ndepend:
www.ndepend.com/. -
.NET 源代码分析概述:
docs.microsoft.com/dotnet/fundamentals/code-analysis/overview.
第二部分:编写高性能代码
第二部分涵盖了通过编写高性能代码来使用框架。我们首先查看集合。然后我们继续查看 LINQ 性能,接着是文件和流。接下来,我们查看网络,然后是数据处理。之后,我们学习如何在长时间操作期间保持用户界面活跃。然后我们通过查看可扩展的分布式系统来完成。
本部分包含以下章节:
-
第六章, .NET 集合
-
第七章, LINQ 性能
-
第八章, 文件和流 I/O
-
第九章, 增强网络应用程序的性能
-
第十章, 设置我们的数据库项目
-
第十一章, 关系数据访问框架基准测试
-
第十二章, 响应式用户界面
-
第十三章, 分布式系统
第六章:第六章: .NET 集合
集合是 .NET 的一个重要组成部分。使用这些集合的方式有很多种。Microsoft .NET 在处理数据集、数组、列表、字典、栈和队列等事物时,大量使用了数组和集合。您很难编写一个不使用集合框架的 C# 程序。使用集合和数组的不同方式在性能下降和性能提升方面有所不同。因此,了解何时使用数组以及何时使用集合将成为您 C# 和 .NET 编程技能的一个重要方面。
在本章中,您将学习如何提高您的集合操作性能。通过使用 BenchmarkDotNet 与代码的不同版本,您将能够看到性能差异,并处于选择最适合您需求的最佳方法的位置。
我们将在本章中涵盖以下主题:
-
System.Collections、System.Collections.Generic、System.Collections.Concurrent和System.Collections.Specialized命名空间。 -
IEnumerable和IQueryable。本节将向您展示如何使用样本数据开发我们的示例数据库,这些数据将在本章的后续部分使用。 -
决定使用接口还是具体类: 在本节中,您将基准测试使用类和接口的性能,然后您将能够决定最适合您需求的方法。
-
决定使用数组还是集合: 使用数组和集合各有优缺点。在本节中,您将基准测试数组和集合的性能,并根据性能需求决定使用哪种。
-
使用索引器访问对象: 在本节中,我们将讨论通过使用索引器以与访问数组项相同的方式访问对象。
-
比较 IEnumerable 和 IEnumerator: 在本节中,我们将使用 IEnumerable 和 IEnumerator 进行迭代基准测试。您将看到这两种枚举方式之间确实存在性能差异。
-
数据库查询性能: 在本节中,我们将使用五种不同的方法查询数据库,基准测试它们的性能,以查看哪种方法产生最快的性能。
-
yield关键字及其与您应用程序性能的关系,尤其是在迭代集合和数组时。 -
学习并发与并行之间的区别: 在本节中,您将了解并发与并行之间的区别,并学习何时使用一种而非另一种。
-
学习 Equals() 和 == 之间的区别: 在本节中,您将了解不同相等运算符之间的区别,并学习何时使用一种而非另一种。
-
研究 LINQ 性能:LINQ 是一种 C#查询语言,在处理集合时被广泛使用,但它的速度可能快或慢,这取决于你编写查询的方式。在本节中,你将学习如何基准测试执行相同类型查询的不同方式。通过这样做,你将看到不同方式编写相同查询的性能差异。
到本章结束时,你将能够做到以下几点:
-
描述可用的不同集合及其用途
-
在使用接口和集合之间进行选择
-
理解数组和集合之间的权衡
-
编写索引器
-
选择最适合你特定需求的迭代形式
-
使用
yield关键字 -
了解用于不同类型相等性检查的相等运算符
-
提高 LINQ 查询性能
技术要求
要跟随本章内容,你需要访问以下工具:
-
Visual Studio 2022
-
SQL Server(任何版本)Express 或更高版本
-
SQL Server Management Studio
-
本书源代码:
github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH06
理解不同的集合提供
集合是一组可以作为一个逻辑单元处理的记录。逻辑记录组的例子包括人、国家、产品、成分、书籍、作者等等。
主要有四种类型的集合,如下所示:
-
基于索引的集合,例如数组或列表。基于索引的集合包含一个内部索引。索引可以是数字或基于字符串的。基于索引的集合通常使用数字索引来访问。数字索引是从零开始的。这意味着集合的索引将从零开始,对于每个后续记录,其值将按顺序增加一。可以使用数字索引访问的集合包括数组和列表。
-
Hashtable和SortedList使用键来查找存储在集合中的值。例如,如果你有一个产品集合,你可以通过使用在将产品添加到键/值对集合时分配的产品代码作为键来访问所需的产品。 -
优先级集合,例如栈或队列。优先级集合允许你以特定顺序存储和提取记录。队列使用先进先出(FIFO)顺序,而栈使用后进先出(LIFO)顺序。
-
CollectionsUtil类,它创建忽略字符串大小写的集合,以及ListDictionary类,它适用于包含少于 10 个项目的集合。它使用单链表实现IDictionary。
.NET 集合框架由传统的 System.Collections 命名空间以及较新的 System.Collections.Generic、System.Collections.Concurrent 和 System.Collections.Specialized 命名空间组成。在我们深入研究集合的性能之前,重新熟悉上述每个命名空间中可用的不同集合是个好主意。
System.Collections 命名空间
System.Collections 命名空间包含各种类、结构和接口。在本节中,我们将简要介绍可用的内容。此命名空间中的集合不是线程安全的。如果您需要线程安全的集合,最好使用 System.Collections.Concurrent 命名空间中的集合,正如微软所建议的!
ICollection 接口定义了所有非泛型集合的大小、枚举器和同步方法。要比较两个对象,您可以实现 IComparer 接口。您可以使用 Idictionary 来表示非泛型键/值对集合。要枚举非泛型字典,您可以使用 IDictionaryEnumerator 接口。非泛型集合的简单迭代由 IEnumerator 接口提供,而对象之间的相等性是通过 IEqualityComparer 接口实现的。IList 接口用于实现可以通过索引单独访问的对象的非泛型集合。对象的结构比较和对象的结构相等比较分别使用 IStructuralComparable 和 IStructuralEquatable 接口实现。
-
ArrayList类使用可以按需增长和缩小的动态数组实现IList接口。 -
在(
0)和关闭(1),分别由布尔值false和true表示,由BitArray类管理。 -
要在忽略字符串大小写的情况下比较两个对象,您可以使用
CaseInsensitiveComparer类。使用CaseInsensitiveHashCodeProvider生成忽略字符串大小写的算法的哈希码。 -
在构建强类型集合时,从
CollectionBase类继承。 -
Comparer类用于对两个对象进行大小写敏感的字符串比较以确定它们是否相等。 -
在开发强类型键/值对集合时,使用
DictionaryBase作为抽象类。 -
通过基于键的哈希码组织键/值对的集合由
Hashtable类表示。 -
Queue类提供了一个具有先进先出(FIFO)访问的集合。 -
ReadOnlyCollectionBase抽象类被用作强类型非泛型、只读集合的基类。 -
使用
SortedList类来保存按键排序且可以通过键或索引访问的键/值对集合。 -
如果您需要为您的集合提供后进先出(LIFO)访问,请使用
Stack类。 -
要结构性地比较两个集合对象,您可以使用
StructuralComparisons类。 -
DictionaryEntry结构定义了一个可以设置或检索的字典键/值对。注意
IHashCodeProvider现已标记为过时,并且不再由 Microsoft 推荐用于新开发。Microsoft 建议您使用IEqualityComparer和IEqualityComparer<T>接口。
我们现在知道了 System.Collections 命名空间中有什么可用。现在,让我们看看 System.Collections.Generic 命名空间中有什么可用。
System.Collections.Generic 命名空间
System.Collections.Generic 命名空间中提供的类和接口提供了强类型集合,其性能优于 System.Collections 命名空间中的类。此命名空间包含许多类、结构和接口。
CollectionExtensions 类为泛型集合提供了扩展方法。要比较两个对象,您可以使用实现 IComparer<T> 接口的 Comparer<T> 类。IComparer<T> 接口定义了实现比较两个对象的方法类型。
IDictionary<TKey, TValue> 接口提供了实现泛型字典的方法。要使字典为只读,它必须实现 IReadOnlyDictionary<TKey, TValue> 接口。键值对的集合由 Dictionary<TKey, TValue> 类表示。Dictionary<TKey, TValue>.KeyCollection 不能被继承,并代表 Dictionary<TKey, TValue> 集合中的键集合。最后,Dictionary<TKey, TValue>.ValueCollection 不能被继承,并代表 Dictionary<TKey, TValue> 集合中的值集合。
IEqualityComparer<T> 接口定义了您可以使用的方法来比较对象的相等性。为 IEqualityComparer<T> 接口的实现提供了一个基类,称为 EqualityComparer<T>。
HashSet<T> 表示一组值。当用于访问集合的键在正在搜索的集合中找不到时,将引发 KeyNotFoundException。使用 KeyValuePair 类生成键/值对实例。对于双向链表,请使用 LinkedList<T> 类。不可继承的 LinkedListNode<T> 类代表 LinkedList<T> 类型集合中的一个节点。
IList<T> 表示一个对象集合,用于实现可以通过索引访问的列表。只读列表实现了 IReadOnlyList<T> 接口。当您需要一个强类型集合,它支持搜索、排序和操作列表时,请使用 List<T> 类。对于先进先出(FIFO)集合,请使用 Queue<T> 类。
ReferenceEqualityComparere 是一个 IEqualityComparer<T>,它在比较两个对象实例时通过调用 ReferenceEquals(Object, Object) 而不是通过调用 Equals(Object) 来使用引用相等性。
按键排序的键/值对集合由 SortedDictionary<TKey, TValue> 类表示。此类集合由 SortedDictionary<TKey, TValue>.KeyCollection 表示,不能被继承。收集到的值由 SortedDictionary<TKey, TValue>.ValueCollection 表示,也不能被继承。
SortedList<TKey, TValue> 类表示一个按键排序的键/值对集合,基于关联的 IComparer<T> 实现进行排序。维护在排序顺序中的对象集合由 SortedSet<T> 类表示。Stack<T> 类为同一类型的实例提供 LIFO 操作。
对于各种通用集合类,有几种结构可供使用,允许您遍历集合中的元素。这些结构被称为枚举器。
通过实现 IAsyncEnumerable<T> 接口,可以异步遍历特定类型的值。IAsyncEnumerator<T> 提供了遍历泛型集合所需的必要支持。ICollection<T> 定义了操作泛型集合所需的方法。只读的强类型集合实现了 IReadOnlyCollection<T> 接口。集合实现了 ISet<T> 接口,而只读集合实现了 IReadOnlySet<T> 接口。
既然我们已经了解了 System.Collections.Generic 命名空间提供的内容,让我们将注意力转向 System.Collections.Concurrent 命名空间。
System.Collections.Concurrent 命名空间
System.Collections.Concurrent 命名空间中的集合是线程安全的。当多个线程并发访问集合时,应使用此命名空间中的集合,而不是 System.Collections 和 System.Collections.Generic 命名空间中的集合。
注意
这些集合的扩展方法和显式接口实现不一定保证是线程安全的。为了确保线程安全,在这些情况下可能需要同步。
IProducerConsumerCollection<T> 定义了在生产者/消费者使用(也称为发布者/订阅者使用)中形成线程安全集合操作基础的方法。高级抽象,如 BlockingCollection<T> 类,可以使用此集合作为其底层存储机制。
BlockingCollection<T> 类为实现了 IProducerConsumerCollection<T> 接口的线程安全集合提供了阻塞和边界能力。
通过 EnumerablePartitionerOptions 枚举指定了控制分区器缓冲行为的选择项。
Partitioner 类提供了数组、列表和可枚举分区策略。Partitioner<Tsource> 类提供了一种将数据源分割成多个分区的方式,而 OrderablePartioner<Tsource> 则将可排序的数据源分割成多个分区。
Concurrent<T>类包含一个线程安全的对象无序列表。线程安全的 FIFO 集合使用ConcurrentQueue<T>类,而线程安全的 LIFO 集合使用ConcurrentStack<T>类。要线程安全地访问键/值对,请使用ConcurrentDictionary<Tkey, Tvalue>类。
有了这些,我们已经涵盖了System.Collections.Concurrent命名空间。现在,让我们看看System.Collections.Specialized命名空间。
System.Collections.Specialized命名空间
System.Collections.Specialized命名空间包含专用和强类型的集合。让我们看看它有什么可以提供的。
CollectionChangedEventManager类提供了一个WeakEventManager实现。通过使用WeakEventListener模式,您可以附加集合更改事件的监听器。
要构建一个忽略字符串大小写的字符串集合,您可以使用CollectionUtils类。
当集合较小时,HybrdDictionary类会改变其行为;当集合增长并变得较大时,它也会改变行为。它是通过在集合较小时使用ListDictionary实现IDictionary,在集合增长并变得较大时使用Hashtable来实现的。
对于少于 10 个项的情况,您可以使用ListDictionary,它通过使用单链表实现IDictionary。
要保存集合的字符串键集合,请使用NameObjectCollectionBase.KeysCollection。
当您需要为CollectionChanged事件提供数据时,请使用NotifyCollectionChangedEventArgs类。
当您有一个需要通过键或索引访问的有序键/值对集合时,请使用OrderedDictionary。
您可以使用StringCollection类来保存字符串集合,并且可以使用StringEnumerator类对StringCollection类进行简单迭代。
要获取键和强类型字符串值的哈希表,请使用StringDictionary类。
要在 32 位内存中存储布尔值或小整数,您可以使用BitVector32结构。您可以使用向量的BitVector32.Section存储整数。
键/值对的索引集合由IOrderedDictionary接口表示。INotifyCollectionChanged接口用于通知监听器集合的动态更改,例如当项目被添加、修改或删除时。NotifyCollectionChangedAction枚举描述了导致CollectionChanged事件被触发的行为。
现在,让我们看看自定义集合并编写一个。
创建自定义集合
要创建自定义集合,你必须从 CollectionBase 继承。CollectionBase 类有一个只读的 ArrayList 属性,称为 InnerList,并且实现了 IList、ICollection 和 IEnumerable 接口。然后,你可以添加自己的 Add、Remove、Clear 和 Count 方法。我们将在我们的项目中这样做。我们将创建一个非常简单的自定义集合,它继承自 CollectionBase,这样你就可以看到创建自定义集合是多么容易。按照以下步骤操作:
-
在
CustomCollections文件夹下添加一个名为CustomCollections的新类,使其继承自CollectionBase。 -
将
Add(object item)方法添加到类中:public void Add(object item) { InnerList.Add(item); }
此方法将一个项目添加到 InnerList 中,这是从 CollectionBase 类继承而来的。
-
将
Remove(object item)方法添加到类中:public void Remove(object item) { InnerList.Remove(item); }
此方法从继承的 InnerList 中移除一个项目。
-
添加
Clear()方法:public new void Clear() { InnerList.Clear(); }
此方法清除 InnerList 中的所有项目。
-
添加
Count()方法:public new int Count() { return InnerList.Count; }
此方法返回 InnerList 中项目数量的计数。
如你所见,创建自定义集合并不一定困难。我们的实现非常简单和基础。然而,这样的类可以被设计为只持有特定类型,而不是泛型对象类型。你也可以使你的类成为泛型,以便它接受实现特定接口的类。
以下是由微软撰写的关于通过实现 ICollection 来实现自定义集合的详细文章:docs.microsoft.com/troubleshoot/dotnet/csharp/implement-custom-collection.
随着你阅读本章内容,你将看到集合的不同方面。你还将测量它们的性能。这样,当你创建自定义集合时,你可以为当前任务选择最有效的操作方式。
现在我们已经简要介绍了 .NET 集合框架中不同集合的提供情况,让我们来看看什么是 Big O 表示法。
理解 Big O 表示法
Big O 表示法用于确定算法效率。它决定了与输入相关的时间尺度。常数时间等于 Big O 表示法值 O(1)。随时间线性扩展的数据操作,根据操作的大小,具有 Big O 表示法值 (N),其中 N 等于正在处理的数据量。
例如,如果你正在遍历数组或集合中的几个元素,你会使用 O(N),这是一个线性时间,其中N是数组或集合的大小。如果一个迭代包含如x和y这样的成对元素,其中你在迭代中遍历x,然后是y,那么你的大 O 表示法将是 O(N2)。另一个场景是确定收获一块正方形土地所需的时间。这可以写成 O(a),其中a是土地面积。或者,你也可以将大 O 表示法写成 O(s2),其中s是一个尺寸的长度。
使用大 O 表示法时需要考虑一些规则:
-
你的算法中的不同步骤被相加。因此,如果步骤 1 需要 O(a)时间,步骤 2 需要 O(b)时间,那么你的算法的大 O 表示法将是 O(a+b)。
-
丢弃常数。例如,如果你算法中有两个都是常数的操作,你不需要写 O(2N)。表示法仍然是 O(N)。
-
如果你有不同的输入,这些输入是不同的变量,例如集合 a 和集合 b,那么你的大 O 表示法将是 O(ab*)。
-
丢弃非主导项。所以,O(n2)等同于 O(n + n2),等同于(n2+n*2)。
现在我们已经了解了大 O 表示法是什么以及我们可用的各种集合,让我们看看如何为我们的工作项选择正确的集合。
选择正确的集合
在内存中处理多个数据项时,性能的关键是选择正确的存储机制,以提供满足你要求的最快处理时间。以下是一系列不同类型的集合及其优势,以帮助你为正确的任务选择正确的集合:
-
Dictionary是一个无序的集合,具有连续的存储,并且可以通过键直接访问。字典使用键的查找效率为 O(1),其操作效率也为 O(1)。字典最适合用于高性能查找。 -
HashSet是无序的,具有连续的存储,并且可以通过键直接访问。使用键的查找效率为 O(1),操作效率为 O(1)。HashSet是一个独特的无序集合,称为Dictionary,除了键和值是同一个对象。 -
LinkedList允许用户完全控制其顺序,没有连续的存储,并且不能直接访问。它的查找效率值为 O(n),操作效率为 O(1)。当你需要插入或删除项目且不需要直接访问时,最好使用列表。 -
List允许用户完全控制其顺序,具有连续的存储,并且可以通过索引直接访问。使用索引的查找效率为 O(1),使用值的查找效率为 O(n)。其操作效率为 O(n)。当需要直接访问、列表较小且不需要排序时,最好使用此列表。 -
Queue根据 FIFO 排序,具有连续存储,并且只能从队列的前端直接访问。它在队列前端具有 O(1)的查找效率,操作索引为 O(1)。它基本上与List<T>相同,只是它只使用 FIFO 进行处理。 -
SortedDictionary是有序的,没有连续存储,并且可以使用键直接访问。它使用键的查找效率为 O(log n),操作效率为 O(log n)。这个集合在速度和排序之间做出了权衡,并使用二叉搜索树。 -
SortedList是有序的,具有连续存储,并且可以通过键直接访问。它使用键的查找效率为 O(log n),操作效率为 O(n)。树作为数组实现,这使得在预加载数据上的查找更快,但在加载时较慢。 -
SortedSet是有序的,没有连续存储,并且可以通过键直接访问。它使用键的查找效率为 O(log n),操作效率为 O(log n)。它是一个独特的有序集合,类似于SortedDictionary,但键和值是相同的对象。 -
Stack根据 LIFO 排序,具有连续存储,并且只能从堆栈的顶部直接访问。它具有 O(1)的顶部项查找效率,操作效率为 O(1)*。它基本上与List<T>相同,只是它只使用 LIFO 进行处理。注意
对于关键任务代码,建议您避免在
System.Collection命名空间中使用类。相反,您应该使用System.Collections.Generic命名空间中的类。尽管这听起来像是一条经过验证的建议,但建议您运行基准测试以查看哪种方法最适合您的特定场景。
现在您已经了解了数组和集合,在我们从性能角度继续查看集合之前,我们将设置我们的示例数据库。
设置我们的示例数据库
在本章中,我们将演示不同的集合接口如何处理数据之间的差异。为了我们的演示,我们需要访问数据库数据。为此,我们将创建一个数据库,向其中添加一个表,并用数据填充它。我们将使用 SQL Server 作为我们的数据库引擎,并使用 SQL Server Management Studio 来开发我们的示例数据库。
要添加我们的数据库,请按照以下步骤操作:
-
打开SQL Server Management Studio并连接到您的数据库引擎。
-
在对象资源管理器中的数据库文件夹上右键单击,如下面的截图所示:

图 6.1 – SQL Server Management Studio – 对象资源管理器
- 从上下文菜单中选择新建数据库。这将显示新建数据库对话框,如下面的截图所示:
![图 6.2 – SQL Server Management Studio – 新建数据库对话框
![img/B16617_Figure_6.2.jpg]
图 6.2 – SQL Server Management Studio – 新数据库对话框
-
在 数据库名称 下输入
SampleData,然后点击 确定 按钮创建数据库。 -
通过展开
Products定位数据库,如下所示:
![Table 6.1 – The Products table's design]
![img/Table_1.1.jpg]
表 6.1 – 产品表的设计
-
保存 表,然后展开 表 文件夹。右键单击 产品 表,选择 编辑前 n 条记录,其中 n 将是配置要编辑的记录数。默认情况下为 200。
-
将以下表中的数据添加到 产品 表:
![Table 6.2 – The Product table's row data]
![img/Table_6.2.jpg]
表 6.2 – 产品表的行数据
现在我们有一个包含数据的单个表数据库,我们将在本章后面使用。现在,让我们从性能的角度理解集合。让我们首先看看我们如何决定使用数组还是集合。
在接口和具体类之间做出决定
在本节中,我们将展示使用接口声明而不是具体类声明来声明集合可以提供更好的基于时间的性能。我们将通过基准测试使用 IList 接口生成的集合以及使用 List 具体类,以便您可以看到不同方法性能的差异。按照以下步骤操作:
-
在
CH06_Collections项目中,添加一个名为ConcreteVsInterface的新文件夹。 -
在
ConcreteVsInterface文件夹中,添加ITax接口:internal interface ITax { int Id { get; set; } TaxType TaxType { get; set; } TaxRate TaxRate { get; set; } decimal LowerLimit { get; set; } decimal UpperLimit { get; set; } decimal Percentage { get; set; } decimal Calculate(decimal amount); }
此接口定义了一个合同,各种具体税类都必须遵守。它强制执行影响分析,因为此接口的更改将影响所有实现它的类。
-
接下来,添加
BaseTax类:internal abstract class BaseTax : ITax { public int Id { get; set; } public TaxType TaxType { get; set; } public TaxRate TaxRate { get; set; } public decimal LowerLimit { get; set; } public decimal UpperLimit { get; set; } public decimal Percentage { get; set; } public abstract decimal Calculate(decimal amount); }
这个抽象类实现了 ITax 接口,但将 Calculate(decimal amount) 标记为抽象,因此其实现留给子类。
-
现在,添加
TaxRate枚举:using System; [Flags] internal enum TaxRate { TaxFreePersonalAllowance, StarterRate, BasicRate, IntermediateRate, HigherRate, AdditionalRate }
TaxRate 枚举提供了英国所得税的不同税率类型。
-
添加
TaxType枚举:[Flags] internal enum TaxType { CorporationTax, ValueAddedTax, IncomeTax, NationInsuranceContributions, ExciseDuties, RoadTax, StampDuty }
TaxType 接口提供了不同种类的英国税收。添加 BaseRate 类。这个类将继承自 BaseTax 类。
-
然后,添加以下构造函数:
public BasicRate() { this.LowerLimit = 14550M; this.UpperLimit = 24944M; this.TaxType = TaxType.IncomeTax; this.TaxRate = TaxRate.BasicRate; this.Percentage = 0.2M; }
此构造函数将 BaseClass 中包含的属性设置为适用于基本税率所得税的值。
-
现在,实现
Calculate(decimal amount)方法:public override decimal Calculate(decimal amount) { if (Percentage > 1) throw new Exception("Invalid percentage. Percentage must be between 0 and 1."); if (amount < LowerLimit & amount > UpperLimit) return 0; return Percentage * amount; }
此方法检查百分比是否小于一,如果不小于一则抛出异常。检查个人应税收入的下限和上限。如果金额超出此范围,则返回零。然后返回应税收入的税额,并退出方法。
-
添加一个名为
TaxMan的新类:using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Engines; using BenchmarkDotNet.Order; using CH06_Collections.Linq; using System.Collections.Generic; using System.Threading; [MemoryDiagnoser] [Orderer(SummaryOrderPolicy.FastestToSlowest)] [RankColumn] public class TaxMan { }
我们班级现在已配置为使用 BenchmarkDotNet 进行基准测试。
-
添加以下方法:
[Benchmark] public void BasicRateInterface() { IList<BasicRate> basicRate = new List<BasicRate>(); }
BasicRateInterface() 方法使用 IList 接口声明了一个 BasicRate 对象的列表。
-
添加
BasicRateConcrete()方法:[Benchmark] public void BasicRateConcrete() { List<BasicRate> basicRate = new List<BasicRate>(); }
BasicRateConcrete() 方法使用具体的 List 类声明了一个 BasicRate 对象的列表。
-
在
Program类中,注释掉Main方法中的代码,并添加以下代码行:BenchmarkRunner.Run<TaxMan>();
这行代码将运行我们的基准测试。进行发布构建,然后从命令行运行可执行文件。您应该看到以下输出或类似内容:


图 6.3 – BenchmarkDotNet 汇总报告显示分配 IList
从报告中我们可以看出,接口和具体类实现之间的内存利用率是相同的。但是,通过分配 IList<T> 而不是 List<T> 可以获得更快的实例化时间。尽管这个值对肉眼来说可能不明显,但如果存在大量的赋值操作,比如在大数据迭代过程中,这个差异将变得更加明显。
现在,让我们看看数组和集合的性能。
在使用数组或集合之间做出决定
在本节中,我们将讨论使用数组和集合的优缺点。我们还将执行各种基准测试,以衡量数组和集合的性能。有了基准信息,您就可以做出明智的决定,确定数组或集合最适合您的特定需求。我们将首先查看数组。
使用数组的缺点如下:
-
数组的大小是固定的,这意味着一旦数组的尺寸被改变,其尺寸就不能再改变。
-
由于数组的大小是固定的,因此它们不推荐用于高效内存使用。
-
数组只能持有异构数据类型,数据类型可以是原始类型和对象类型。
-
object类型的数据元素可以持有不同类型的数据元素。 -
数组缺少许多有用的方法。
使用数组的优点如下:
-
数组具有较小的内存占用,并且在 C# 9.0 和 .NET 5 中经历了某些严重的性能改进。
-
然而,由于数组速度快且经过速度改进,当性能很重要时,它们是推荐的。
使用集合的缺点如下:
- 在性能方面,它们不建议用于替代数组。
使用数组的优点如下:
-
集合有效地封装了数组;
generic List<T>是一个很好的例子。 -
它们是可增长的,这意味着我们可以根据需要缩小和扩大我们的集合。正因为如此,在高效内存利用方面,集合比数组更受欢迎。
-
集合中的数据元素(项目数据)可以是同构的也可以是异构的。
-
集合类为大多数操作提供了现成的方法支持,并且可以轻松扩展。这里的含义是,数组缺少一些在使用集合时我们免费获得的有用方法。
注意
建议您不要使用
System.Collections命名空间中的集合。相反,鼓励您使用System.Collections.Generic命名空间中的集合。
大多数程序员都熟悉的标准集合是泛型 List<T> 类。在本节中,我们将创建一个新的项目。然后,我们将构建一个 uint 数组和 List<uint> 集合,并通过它们进行迭代。这个过程将使用 BenchmarkDotNet 进行基准测试。
我们将对添加项、迭代和从数组和集合中检索项进行基准测试。所以,让我们开始吧:
-
在项目根目录下添加一个名为
ArraysVsCollections的新类,并包含以下using语句:using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; using System; using System.Collections; using System.Collections.Generic; using System.Linq;
这些 using 语句为我们提供了与数组和集合一起工作以及基准测试它们所需的内容。
-
添加以下成员变量:
private int[] array; private List<int> collection;
int 数组和 int 列表将被用来基准测试添加、获取和迭代数组和集合。
-
接下来,添加
GlobalSetup()方法:[GlobalSetup] public void GlobalSetup() { array = new int[1000]; collection = new List<int>(1000); for (int i = 0; i < 1000; i++) { array[i] = i; collection.Add(i); } }
GlobalSetup() 方法被 [GlobalSetup] 属性标记。这通知 BenchmarkDotNet 在所有其他基准方法之前运行此方法。它使用大小为 1000 的数组集合初始化,并将当前迭代中的 i 值添加到数组和集合中。
-
虽然我们不会使用
GlobalCleanup()方法,但我们添加它以保持完整性,这样您就知道如何在基准测试时执行清理操作:[GlobalCleanup] public void GlobalCleanup() { // Disposing logic }
GlobalCleanup() 方法是您提供清理逻辑的地方,如果需要的话。
-
现在,添加
ArrayAdd1000Logic()方法:[Benchmark] public void ArrayAdd1000Logic1() { int[] list = new int[1000]; for (int i = 0; i < 1000; i++) { list[i] = i; } }
ArrayAdd1000Logic() 方法声明了一个包含 1000 个 int 值的数组,随后对数组中的每个元素添加整数值。
-
添加
CollectionAdd1000Logic()方法:[Benchmark] public void CollectionAdd1000Logic() { Ilist<int> list = new new List<int>(); for (int i = 0; i < 1000; i++) list.Add(i) }
CollectionAdd1000Logic() 方法声明了一个 int 元素列表。然后,它使用 for 循环循环 1,000 次,并将当前值添加到集合中。
-
添加
ArrayIterationLogic()方法:[Benchmark] public int ArrayIterationLogic() { int res = 0; for (int i = 0; i < 1000; i++) res += array[i]; return res; }
ArrayIterationLogic() 方法声明了一个 int 变量,并将其赋值为 0。使用 for 循环迭代 1,000 次,并将数组在索引位置上的值添加到 res 值中。一旦迭代完成,返回 res 变量。
-
现在,添加
CollectionIterationLogic()方法:[Benchmark] public int CollectionIterationLogic() { int res = 0; for (int i = 0; i < 1000; i++) res += collection[i]; return res; }
CollectionIterationLogic() 声明了一个 int 变量,并将其赋值为 0。使用 for 循环迭代 1,000 次,并将数组在索引位置上的值添加到 res 值中。一旦迭代完成,返回 res 变量。
-
添加
ArrayGetElement500Logic()方法:[Benchmark] public int ArrayGetElement500Logic() { return array[500]; }
ArrayGetElement500Logic() 方法返回数组在位置 500 的值。
-
现在,添加
CollectionGetElement500Logic()方法:[Benchmark] public int CollectionGetElement500Logic() { return collection[500]; }
CollectionGetElement500Logic()方法返回集合在位置500的值。
-
将
Main方法中的代码替换为以下代码行:BenchmarkRunner.Run<ArraysVsCollections>();
这个调用将运行我们的基准测试。发布构建您的代码,并在控制台运行它。您应该会看到一个与以下截图相似的报告:

图 6.4 – BenchmarkDotNet 对数组和集合操作的总结报告
从时间性能的角度来看,向数组中添加项目比向集合中添加项目更快。遍历集合比遍历数组更快,并且通过索引从数组中获取项目比通过索引从集合中获取集合更快。基于这些发现,您需要决定您的需求是什么,然后根据这些需求选择最佳类型。
现在,让我们看看索引器。
使用索引器访问对象
索引使类中的对象可以像访问数组中的项目一样访问。索引器将有一个修饰符、一个返回类型、一个this关键字来指示当前类的对象,以及一个参数列表。您在创建索引器时始终会使用this关键字。索引器是参数化属性的术语。索引是通过get和set访问器创建的。不允许使用ref或out关键字来修改索引器参数。应指定至少一个参数。索引器不能是静态的,因为它是一个实例成员。然而,索引器属性可以是静态的。如果您需要操作一组元素,则可以实现索引器。属性和索引器之间的主要区别在于,您通过其名称识别和访问属性。另一方面,使用索引器时,它通过其签名识别,并通过索引访问。此外,您可以重载索引器。
现在,让我们写一个简单的索引器示例。在这个例子中,我们将有一个类,它有一个构造函数,该构造函数接受一个大小。这个大小将设置一个内部字符串数组的大小。我们将能够通过名称获取数组中字符串的索引,并通过索引器使用索引从数组中获取项目。按照以下步骤操作:
-
添加一个名为
Indexers的新类,并将using语句添加到System命名空间。然后,在类的顶部添加以下数组和构造函数:private string[] _items; public Indexers(int size) { _items = new string[size]; }
_items数组将包含几个字符串。数组的大小由传递给构造函数并初始化数组的值设置。
-
添加索引器以通过索引获取字符串:
public string this[int index] { get { if (IsValidIndex(index)) return _items[index]; else return string.Empty; } set { if (IsValidIndex(index)) _items[index] = value; } }
此索引器使用一个int值从数组中获取一个项目并设置给定索引处的数组值。只有当索引有效时,才会设置和检索项目。
-
我们可以通过将其传递到
IsValidIndex(int index)方法中来检查索引,该方法返回一个bool。让我们添加IsValidIndex(int index)方法:private bool IsValidIndex(int index) { return index > -1 && index < _items.Length; }
此方法如果索引大于-1 且小于数组的长度,则返回true。否则,返回false。
-
现在,添加一个接受
string并返回字符串索引的索引:public int this[string item] { get { return Array.IndexOf(_items, item); } }
此索引器接受一个string。然后,它查找字符串的索引并返回索引。此索引没有设置器。
-
在
Program类中添加IndexerExample()方法:public static void IndexerExample() { Indexers indexers = new Indexers(1000); for (int i = 0; i < 1000; i++) indexers[i] = $"Item {i}"; Console.WriteLine($"The item at position 500 is \"{indexers[500]}\"."); Console.WriteLine($"The index of \"Item 500\" is {indexers["Item 500"]}."); }
此方法创建一个新的Indexer对象,其内部数组大小为1000。然后,它循环 1,000 次并设置数组中每个元素的值。之后,它打印出数组位置 500 的值,并打印出Item 500的值。
-
在
Main方法中注释掉代码,然后添加以下行:IndexerExample();
这个语句调用了执行我们的Indexer方法的函数。你应该看到以下输出:
The item at position 500 is "Item 500".
The index of "Item 500" is 500.
这就结束了我们对索引器的探讨。正如你所见,它们相当简单。你可以为索引器使用任何你喜欢的数据项。然而,如何评估这些索引器的性能将取决于你。现在,让我们看看IEnumerable和IEnumerator接口之间的区别。
比较IEnumerable和IEnumerator
IEnumerable和IEnumerator接口都可以用于迭代,但方式不同。让我们简要了解每个接口。
IEnumerable类型的对象将知道如何遍历它所持有的集合,无论其内部结构如何。有一个方法构成了可枚举:GetEnumerator()。它返回一个实现IEnumerable接口的类的实例。迭代通常使用foreach循环进行。可枚举的迭代使用foreach循环进行。然而,可枚举在迭代时不会记住其位置。
Ienumerator类型的对象声明了两个方法:MoveNext()和Reset()。有一个名为Current的属性,用于获取正在枚举的列表中的当前项。MoveNext()方法将移动到集合中的下一个记录,并返回一个布尔值,指示集合的结束。Reset()将位置重置为集合中的第一个项。Current属性通过实现IEnumerable接口的对象调用,该接口返回集合中的当前元素。枚举器会记住其当前位置,并在迭代时使用while循环。
让我们看看哪种枚举方法最快。是使用可枚举的循环,还是使用迭代器的循环?
-
添加一个名为
IEnumerableVsIEnumerable的新类,并包含以下using语句:using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics;
这些using语句提供了我们将需要构建和测试IEnumerable和IEnumerator性能的元素。
-
将以下代码添加到类中:
private List<int> _years; public IEnumerableVsIEnumerator() { _years = new List<int> { 1970, 1971, 1972, 1973, 1974, 1975, 1976, 1977, 1978, 1979 }; }
在这里,我们声明了一个int值的列表,它将包含几个年份值。然后我们的构造函数初始化数组,包含年份1970到1979。
-
添加
IterateEnumerator1970to1975()方法:public void IterateEnumerator1970To1975() { var years = _years.GetEnumerator(); while (years.MoveNext()) { Debug.WriteLine(years.Current); if (years.Current > 1975) IterateEnumberator1976To1979(years); } }
此方法遍历值1970到1975并将值打印到调试窗口。
-
如果当前年份大于
1975,则枚举器将被传递到IterateEnumerator1976To1979(IEnumerator<int> years)方法中,我们将在下面添加它:public void IterateEnumberator1976To1979 (IEnumerator<int> years) { while (years.MoveNext()) { Debug.WriteLine(years.Current); } }
此方法接受一个枚举器并遍历它。在每次迭代中,它将当前年份打印到调试窗口。
-
在
Program类的Main方法末尾添加以下行:IEnumerableVsIEnumeratorExample();
这行代码调用一个方法,该方法将运行我们的示例并展示枚举器如何记住迭代中的位置。
-
将
IEnumerableVsIEnumeratorExample()方法添加到Program类中:private static void IEnumerableVsIEnumeratorExample() { IEnumerableVsIEnumerator eve = new IEnumerableVsIEnumerator(); eve.IterateEnumerator1970To1975(); }
此方法运行我们的代码。如果你进行调试构建并运行代码,那么你应该看到年份1970到1979被打印到输出窗口。
现在你已经看到了枚举器的实际应用,我们将向IEnumerableVsIEnumerator类添加两个方法。
-
添加
BenchmarkIEnumerabled()方法:[Benchmark] public void BenchmarkIEnumerable() { IEnumerable<int> enumerable = IEnumerable<int>)_years; foreach (int i in enumerable) Debug.WriteLine(i); }
此方法使用可枚举和foreach循环遍历年份并将它们写入调试窗口。
-
添加
BenchmarkIEnumerator()方法:[Benchmark] public void BenchmarkIEnumerator() { IEnumerator<int> enumerator = _years.GetEnumerator(); while (enumerator.MoveNext()) Debug.WriteLine(enumerator.Current); }
此方法使用枚举器和while循环遍历年份并将它们写入调试窗口。
-
在
Program类的Main方法中注释掉代码,然后添加以下行:BenchmarkRunner.Run<IEnumerableVsIEnumerator>();
这行代码检测我们的基准测试并运行它们,以生成性能总结报告。进行发布构建并从命令提示符运行程序。你应该看到以下输出:

is faster than IEnumerable
图 6.5 – BenchmarkDotNet 总结报告显示 IEnumerator 比 IEnumerable 快
如我们所见,尽管IEnumerable和IEnumerator都在相同的集合上执行迭代,但它们以不同的方式执行。通过查看基准测试总结报告,我们可以看到在性能方面,IEnumerator接口是明显的赢家。现在,让我们看看IEnumerable、IEnumerator和IQueryable之间的区别,以及这些差异在执行数据库上的 LINQ 查询时的性能影响。
数据库查询性能
在前面的部分中,我们看到了IEnumerator在遍历内存集合时与IEnumerable的不同之处,以及它的性能更快。现在,让我们查询数据库并使用各种基准测试技术遍历结果集合。为此,我们将遵循以下步骤:
-
添加一个名为
IEnumeratorVsIQueryable的新类。 -
我们将连接到 SQL Server 数据库,并且我们将拥有需要保密的信息。我们的
secret.json文件不会提交到版本控制中。因此,右键单击项目并从上下文菜单中选择 管理用户密钥。 -
将会弹出一个对话框,告知你需要额外的包来管理用户密钥。点击 是:


图 6.6 – 一个对话框,告知你需要额外的包来管理用户密钥
-
Visual Studio 将在新标签页中打开
secrets.json文件。这就是你添加用户密钥的地方。 -
打开包管理控制台并添加以下包:
-
Microsoft.EntityFrameworkCore -
Microsoft.EntityFrameworkCore.SqlServer -
Microsoft.EntityFrameworkCore.Tools -
Microsoft.Extensions.Configuration -
Microsoft.Extensions.Configuration.EnvironmentVariables -
Microsoft.Extensions.Configuration.UserSecrets -
Microsoft.Extensions.OptionsConfigurationExtensions
-
这些包允许你连接到并从我们的 SQL Server 数据库中提取数据。
-
更新你的
secrets.json文件,包含我们在这章开头创建的数据库的连接字符串:{ "DatabaseSettings": { "ConnectionString": "YOUR_CONNECTION_STRING" } }
这个连接字符串将用于连接到我们的数据库,执行返回一些数据的查询,并允许我们遍历这些数据并对它进行操作。
-
添加一个名为
Configuration的文件夹。在该文件夹中,添加一个名为SecretsManager的类,它有一个空的静态构造函数和以下using语句:using Microsoft.Extensions.Configuration; using System; using System.IO;
我们需要这些 using 语句来进行文件 I/O 和系统配置,例如从 secrets.json 文件中获取密钥。
-
在
SecretsManager类的顶部添加以下行:public static IConfigurationRoot Configuration { get; set; }
这行代码声明了我们的静态配置属性,它用于在我们的应用程序中获取配置数据。
-
现在,添加以下代码:
public static T GetSecrets<T>(string sectionName) where T : class { var devEnvironmentVariable = Environment .GetEnvironmentVariable("NETCORE_ENVIRONMENT"); var isDevelopment = string.IsNullOrEmpty (devEnvironmentVariable) || devEnvironmentVariable .ToLower() == "development"; var builder = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddEnvironmentVariables(); if (isDevelopment) //only add secrets in development { builder.AddUserSecrets<T>(); } Configuration = builder.Build(); return Configuration.GetSection(sectionName).Get<T>(); }
这段代码获取 .NET Core 环境的环境变量。然后,它获取代码以查看它是否在软件开发环境中运行。配置是为它将要运行的环境构建的。如果我们处于开发状态,那么我们必须添加由 T 变量定义的 secrets 类。切换到 Models 文件夹中的 Product 类。
-
为
System.ComponentModel.DataAnnotations添加一个using语句。将结构更改为类,并将[Key]属性添加到Id属性。我们需要这些更改,因为我们正在使用 Entity Framework 连接到数据库并提取数据。 -
将
DatabaseSettings类添加到Configuration文件夹:public class DatabaseSettings { public string ConnectionString { get; set; } }
这个类有一个名为 ConnectionString 的单个属性,它将保存我们到 SampleData 数据库的连接字符串。注意,类的名称和属性的名称与 JSON 部分的名称和属性名称相匹配!
-
现在,将
appsettings.json添加到项目的根目录,并包含以下内容:{ "DatabaseSettings": { "ConnectionString": "Set in Azure. For development, set in User Secrets" } }
此文件包含与secrets.json文件和DatabaseSettings类相同的布局。此文件用于存储我们的连接字符串。在开发中,它设置在我们的secrets文件中,而在生产中,它设置在 Azure 中。现在我们已经设置了数据库配置,我们可以添加我们的基准测试代码。
-
在项目的根目录中添加一个名为
DatabaseQueryAndIteration的新类,该类实现IDisposable,代码如下:using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; using CH06_Collections.Configuration; using CH06_Collections.Data; using CH06_Collections.Models; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; [MemoryDiagnoser] [Orderer(SummaryOrderPolicy.Declared)] [RankColumn] public class DatabaseQueryAndIteration : IDisposable { }
此代码声明我们的类并定义了它实现IDisposable的事实。它也被配置为可进行基准测试。
-
在我们的类中实现
IDisposable接口:private bool disposedValue; protected virtual void Dispose(bool disposing) { if (!disposedValue) { if (disposing) _context.Dispose(); disposedValue = true; } } public void Dispose(){ Dispose(disposing: true); GC.SuppressFinalize(this); }
此代码释放我们的托管资源并抑制对类终结器方法的调用。
-
我们已经准备好对类中的方法进行基准测试,访问数据库资源,并在自己之后进行清理。将以下代码添加到类中:
private DatabaseContext _context; [GlobalSetup] public void GlobalSetup() { var connectionString = SecretsManager. GetSecrets<DatabaseSettings>(nameof (DatabaseSettings)).ConnectionString; _context = new DatabaseContext(connectionString); } [GlobalCleanup] public void GlobalCleanup() { Dispose(true); }
_变量context为我们提供了数据库访问。GlobalSetup()方法从我们的秘密文件中获取连接字符串,并使用安全存储的连接字符串创建一个新的DatabaseContext。GlobalSetup()方法将在基准测试之前运行。GlobalCleanup()方法在基准测试完成后调用Dispose(disposing)方法来清理我们的托管资源。
-
接下来,添加
QueryDb()方法:[Benchmark] public void QueryDb() { var products = (from p in _context.Products where p.Id > 1 select p); foreach (var product in products) Debug.WriteLine(product.Name); }
QueryDb()方法通过选择 ID 大于1的产品对数据库执行简单的 LINQ 查询。然后,它迭代lQueryable<Product>列表中的每个产品,并将产品名称写入调试窗口。
-
现在,添加
QueryDbAsList()方法:[Benchmark] public void QueryDbAsList() { List<Product> products = (from p in _context.Products where p.Id > 1 select p).ToList<Product>(); foreach (var product in products) Debug.WriteLine(product.Name); }
QueryDbAsList()执行与QueryDb()相同的查询,但处理类型为List<Product>类型。
-
添加
QueryDbAsIEnumerable()方法:[Benchmark] public void QueryDbAsIEnumerable() { var products = (from p in _context.Products where p.Id > 1 select p).AsEnumerable<Product>(); foreach (var product in products) Debug.WriteLine(product.Name); }
QueryDbAsIEnumerable()方法执行与QueryDbAsList相同的查询,但处理类型为Ienumerable<Product>类型。
-
添加
QueryDbAsIEnumerator()方法:[Benchmark] public void QueryDbAsIEnumerator() { var products = (from p in _context.Products where p.Id > 1 select p).GetEnumerator(); while (products.MoveNext()) Debug.WriteLine(products.Current.Name); }
QueryDbAsIEnumerator()与前面的方法执行相同,但操作IEnumerator<Product>类型,并使用while循环而不是foreach循环进行迭代。
-
本类中的最后一个方法是
QueryDbAsIQueryable()方法:[Benchmark] public void QueryDbAsIQueryable() { var products = (from p in _context.Products where p.Id > 1 select p).AsQueryable<Product>(); foreach (var product in products) Debug.WriteLine(product.Name); }
此方法与QueryDb相同,但明确操作IQueryable<Product>类型。
-
将
Program类中的Main方法中的代码替换为以下代码:BenchmarkRunner.Run<DatabaseQueryAndIteration>();
此代码运行我们的基准测试。进行代码的发布构建并从命令行运行可执行文件。你应该看到类似于以下摘要报告:

图 6.7 – 使用 LINQ 的各种数据库查询类型的不同时间和内存分配
在内存使用方面,表现最差的是 QueryDb() 方法,其次是 QueryDbAsList() 方法。QueryDbAsIEnumerable() 和 QueryDbAsIQueryable() 都比前两种略好。然而,在所有五种方法中,就内存分配而言,表现最好的方法是 QueryDbAsIEnumerator() 方法。
在速度方面,QueryDb() 方法再次表现最差,其次是 QueryDbAsIEnumerable(),然后是 QueryDbAsList(),最后是 QueryDbAsIQueryable()。再次强调,就速度而言,表现最好的方法是 QueryDbAsIEnumerator()。
在这里,我们可以看到,在速度和内存使用方面,查询和迭代数据库表现最好的方法是 QueryDbAsIEnumerator() 方法。现在,让我们看看 yield 关键字。
探索 yield 关键字
yield 关键字:
-
yield return <expression>;:这将返回表达式的值。 -
yield break;:这将退出迭代
当使用 yield 关键字时,有一些限制需要注意。具体如下:
-
你不能在
unsafe代码块中使用yield关键字。 -
你不能在方法、运算符或访问器中使用
ref或out参数。 -
在
try-catch块中,你不能使用yield关键字来返回。 -
你不能在匿名方法中使用
yield关键字。 -
如果
try块后面跟着finally块,你可以在try块中使用yield。 -
你可以在
try-catch块中使用yield break,但不能在finally块中使用。
在本节中,我们将添加一个显示 yield 关键字实际用法的类。然后,我们将基准测试两种返回包含 1 百万项的 IEnumerable<long> 的方法,并展示它们之间在性能上的巨大差异。让我们开始:
-
将一个名为
Yield的新类添加到项目的根目录:using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; using System; using System.Collections.Generic; [MemoryDiagnoser] [Orderer(SummaryOrderPolicy.Declared)] [RankColumn] public class Yield { }
这个类将基准测试 yield 关键字的用法。
-
现在,添加
YieldSample()方法:public void YieldSample() { DoCountdown(); PrintMonthsOfYear(); DoBreakIteration(); }
YieldSample() 方法将从我们的 Program 类中调用。它将运行所有三个方法。
-
添加
Countdown()方法:private IEnumerable<int> Countdown() { for (int x = 10; x >= 0; x--) yield return x; }
这个方法从 10 循环到 0。每次迭代都使用 yield 关键字返回。
-
添加
DoCountdown()方法:private void DoCountdown() { foreach (int x in Countdown()) Console.WriteLine(x); }
DoCountdown() 方法将 10 到 0 的倒计时打印到控制台窗口。
-
添加一个名为
Month的类:internal class Month { public string Name { get; set; } public int MonthOfYear { get; set; } }
这个类包含一年中月份的名称及其编号。
-
现在,添加
Months类:internal class Months { public IEnumerable<Month> MonthsOfYear { get { yield return new Month { Name = "January", MonthOfYear = 1 }; yield return new Month { Name = "February", MonthOfYear = 2 }; yield return new Month { Name = "March", MonthOfYear = 3 }; yield return new Month { Name = "April", MonthOfYear = 4 }; yield return new Month { Name = "May", MonthOfYear = 5 }; yield return new Month { Name = "June", MonthOfYear = 6 }; yield return new Month { Name = "July", MonthOfYear = 7 }; yield return new Month { Name = "August", MonthOfYear = 8 }; yield return new Month { Name = "September", MonthOfYear = 9 }; yield return new Month { Name = "October", MonthOfYear = 10 }; yield return new Month { Name = "November", MonthOfYear = 11 }; yield return new Month { Name = "December", MonthOfYear = 12 }; } } }
这个类使用 yield 关键字返回 Month 对象的集合。切换回 Yield 类。
-
添加
PrintMonthsOfYear()方法:private void PrintMonthsOfYear() { foreach (Month month in new Months().MonthsOfYear) Console.WriteLine($"{month.Name} is month {month.MonthOfYear} of the year."); }
这个方法遍历年份中的月份并将它们打印到控制台窗口。
-
添加
BreakIteration()方法:private IEnumerable<int> BreakIteration() { int x = 0; while (x < 20) { if (x < 15) yield return x; else yield break; x++; } }
这个方法迭代 20 次。每次迭代都会进行检查。如果值小于 15,则产生结果并增加变量。否则,退出迭代。
-
添加
DoBreakIteration()方法:private void DoBreakIteration() { foreach (int x in BreakIteration()) Console.WriteLine($"Line {x}:"); }
DoBeakIteration() 方法遍历 BreakIteraton() 并将值写入控制台窗口。
-
在
Program类中,添加一个名为Yield()的方法,并在你的Main方法中调用它:private static void Yield() { var yieldToMe = new Yield(); yieldToMe.YieldSample(); }
此方法运行我们的yield关键字示例。进行调试构建并逐步执行代码,以便你可以看到它的行为。你会看到每次遇到yield关键字时,它都会返回到调用方法。然后,它从上次离开的地方继续迭代。
-
现在,让我们添加基准测试来测试
yield关键字的性能。添加GetValues()方法:public IEnumerable<long> GetValues() { List<long> list = new List<long>(); for (long i = 0; i < 1000000; i++) list.Add(i); return list; }
此方法使用泛型List创建一个long值的集合。它迭代 1 百万个项并将它们添加到集合中。一旦完成,集合作为IEnumerable<long>集合返回给调用者。
-
添加
GetValuesYield()方法:public IEnumerable<long> GetValuesYield() { for (long i = 0; i < 1000000; i++) yield return i; }
此方法遍历 1 百万个项,并返回一个IEnumerable<long>集合。迭代使用yield关键字,因此每次迭代都会返回给调用者。
-
添加
GetValuesBenchmark()方法:[Benchmark] public void GetValuesBenchmark() { var data = GetValues(); }
此方法对GetValues()方法进行基准测试。
-
添加
GetValuesYieldBenchmark()方法:[Benchmark] public void GetValuesYieldBenchmark() { var data = GetValuesYield(); }
此方法对GetValuesYield()方法进行基准测试。
-
将
Program类中的Main方法中的代码替换为以下行代码:BenchmarkRunner.Run<Yield>();
这行代码运行我们的基准测试。进行发布构建,然后从命令行运行可执行文件。你应该会看到以下摘要报告:

图 6.8 – BenchmarkDotNet 摘要报告显示了使用yield关键字的性能优势
如报告所示,构建包含 1 百万个long值的列表比使用yield关键字慢得多。yield关键字显著提高了集合的处理速度。这相当于性能提高了 13,102,611.27 ns / 14.50 ns = 903,628.26 倍!所以,你可以看到使用yield关键字对你的计算机程序的性能是非常有益的。
在下一节中,我们将探讨并发和并行之间的区别以及它们对性能的影响。
学习并发和并行之间的区别
并发和并行经常被误认为是同一件事,但它们是不同的。并发使用多线程同时执行许多任务。多线程根据时间/上下文切换为各种线程分配时间。这给人一种计算机同时在做很多事情的错觉。但实际上,它只做了一件事。另一方面,并行性同时做很多事情。
并发用于同时管理多个计算。它通过交错操作来实现这一点。并发的优点是增加了在一段时间内可以完成的工作量。它使用上下文切换来执行交错操作。并发可以与单个处理器一起工作。你已经了解并发在工作中的应用,因为你可能已经同时运行了多个应用程序。所有这些程序都在使用并发。
并发的主要用途是拥有非阻塞的可用应用程序。例如,如果你有一个执行长时间运行操作的应用程序,这个操作可以在后台线程上运行,以便用户仍然可以使用该应用程序并完成工作。因此,并发不一定关乎性能——它更多的是关于不让用户在使用你的应用程序时受阻。
并行化在彼此之间并行执行多个计算。为了实现并行化,需要多个处理器。使用并行化的好处是提高了计算处理速度。在集群上运行文档爬虫和执行并行查询以及大数据都是使用并行化的例子。
并行化的主要目标是性能。换句话说,使用并行的目的是在最短的时间内完成操作。并行化使用的一个例子是对报告生成进行数据密集型数值计算。
你永远不应该将并发与性能混合使用。如果你这样做,你的设计要么是糟糕的,要么是过度设计的。所以,如果你想用户界面非阻塞,使用并发。然而,如果你想非 UI 任务尽可能快地完成,使用并行化。在本书的后续章节中,我们将专门讨论并发、并行化和异步处理。但就目前而言,让我们将注意力转向Equals()和==之间的区别。
学习Equals()和==之间的区别
==运算符比较对象引用,称为浅比较,而Equals()方法比较对象内容,称为深比较。这两个运算符和方法都可以被重载。
注意
如果你重载了==运算符,那么你应该重载Equals()方法,反之亦然。
在以下情况下,==运算符返回true:
-
Value Type Value == Value Type Value -
Reference Type Instance == Reference Type Instance -
String == String
在以下情况下,Equals()方法返回true:
-
ReferenceType.Equals(ReferenceType)都指向相同的对象引用 -
ValueType.Equals(ValueType)都是同一类型且具有相同的值
现在,让我们向CH06_Collections项目的根目录添加一个名为Equality的新类,以演示==运算符和Equals()方法之间的性能差异。让我们开始吧:
-
添加
Equality类,如下所示:using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Linq; [MemoryDiagnoser] [Orderer(SummaryOrderPolicy.Declared)] [RankColumn] public class Equality { }
这样,我们的类已经配置好以执行基准测试。
-
将以下代码添加到类的顶部:
private List<string> _listOne; private List<string> _listTwo; private int _value1; private int _value2; private string _string1; private string _string2;
在这里,我们已经准备好了我们的值类型、引用类型和字符串类型,它们将进行相等性测试。
-
现在,添加
GlobalSetup()方法:[GlobalSetup] public void GlobalSetup() { _listOne = new List<string> { "Alpha", "Beta", "Gamma", "Delta", "Eta", "Theta" }; _listTwo = _listOne; _value1 = 123; _value2 = _value1; _string1 = "Hello, world!"; _string2 = _string1; }
此方法分配我们的变量,为我们的相等性基准测试做准备。
-
添加
ValueOperatorValue()方法:[Benchmark] public void ValueOperatorValue() { bool value = _value1 == _value2; }
ValueOperatorValue() 方法使用 equality 运算符对两个值进行相等性检查的基准测试。
-
添加
ValueEqualsValue()方法:[Benchmark] public void ValueEqualsValue() { bool value = _value1.Equals(_value2); }
ValueEqualsValue() 方法使用 Equals(value) 方法对两个值进行相等性检查的基准测试。
-
添加
ReferenceOperatorReference()方法:[Benchmark] public void ReferenceOperatorReference() { bool value = _listOne == _listTwo; }
ReferenceOperatorReference() 方法使用相等运算符对两个引用值进行相等性检查的基准测试。
-
添加
ReferenceEqualsReference()方法:[Benchmark] public void ReferenceEqualsReference() { bool value = _listOne.Equals(_listTwo); }
ReferenceEqualsReference() 方法使用 Equals(reference) 方法对两个值进行相等性检查的基准测试。
-
添加
StringOperatorString()方法:[Benchmark] public void StringOpertatorString() { bool value = _string1 == _string2; }
StringOperatorString() 方法使用 == 运算符对两个字符串进行相等性测试的基准测试。
-
接下来,添加
StringEqualsString()方法:[Benchmark] public void StringEqualsString() { bool value = _string1.Equals(_string2); }
StringEqualsString() 方法使用 Equals() 方法对两个字符串进行相等性测试的基准测试。
- 将
BenchmarkRunner.Run<Equality>();添加到Program类的Main方法中,进行Release构建并从命令行运行您的可执行文件。你应该会得到以下基准测试报告:

图 6.9 – BenchmarkDotNet 对各种相等性检查的总结报告
如我们所见,使用 == 运算符测试值类型相等性更快,使用 == 运算符测试引用类型相等性更快,而在比较字符串时使用 Equals(string) 更快。
这样,我们就完成了这一章。但在我们继续进入 第七章,LINQ 性能 之前,让我们总结一下本章学到的内容。
摘要
在本章中,我们学习了不同类型集合及其用法。我们了解到应该优先使用泛型集合而非非泛型集合。然后,我们简要介绍了大 O 表示法及其如何用于确定算法效率。之后,我们探讨了根据需要选择合适的集合类型。
之后,我们设置了一个示例数据库来测试本章后面部分的数据查询和迭代。然后,我们探讨了如何选择使用接口和具体类,以及选择使用数组还是集合。接下来,我们看了索引器,然后转向查看 IEnumerable<T>、IEnumerator<T> 和 IQueryable<T> 及其性能。
我们接下来探讨的是使用yield关键字。我们讨论了并发和并行之间的差异,并提到这些将在后面的章节中更深入地探讨。最后,我们探讨了在性能方面==运算符和Equals()方法之间的差异。
在下一章中,我们将探讨 LINQ 的性能。但到目前为止,尝试回答以下问题,并查看“进一步阅读”部分以巩固本章所学内容。
问题
回答以下问题以测试你对本章知识的掌握:
-
列出不同的命名空间集合。
-
大 O 表示法用于什么?
-
算法效率衡量的是什么?
-
在实例化速度方面,使用
IList<T>还是List<T>更可取? -
我们应该使用集合还是数组?
-
索引器的作用是什么?
-
在
IEnumerable<T>和IEnumerator<T>之间,哪种迭代方法在内存集合中速度最快? -
在内存和速度性能方面,哪种数据库查询方法表现最佳?
-
当使用迭代构建集合时,最快的方式是构建集合并返回结果是什么?
进一步阅读
要了解更多关于本章所涉及主题的信息,请查看以下资源:
-
索引器:
docs.microsoft.com/dotnet/csharp/programming-guide/indexers/. -
ConsoleSecrets:
github.com/jasonshave/ConsoleSecrets. -
等式运算符:
docs.microsoft.com/dotnet/standard/design-guidelines/equality-operators. -
C# 9 记录等性检查的有趣性能影响:
gmanvel.medium.com/interesting-performance-implications-of-c-9-records-equality-check-f0d0a3612919. -
改进 C#中结构体等性性能:
dontcodetired.com/blog/post/Improving-Struct-Equality-Performance-in-C. -
C#中的字符串等性和性能:
rhale78.wordpress.com/2011/05/16/string-equality-and-performance-in-c/. -
C#中默认结构体等性的性能影响:
devblogs.microsoft.com/premier-developer/performance-implications-of-default-struct-equality-in-c/. -
C#中的性能最佳实践:
kevingosse.medium.com/performance-best-practices-in-c-b85a47bdd93a. -
8 技巧避免 C# .NET 中的 GC 压力并提高性能:
michaelscodingspot.com/avoid-gc-pressure/.
第七章:第七章: LINQ 性能
LINQ 以其速度慢而闻名。但与人们的观点相反,有方法可以确保使用 LINQ 的最佳性能。
在本章中,你将学习如何以性能为导向执行 LINQ 查询。根据你如何使用 LINQ,返回相同结果的不同方法可能会有不同的行为和性能。因此,在本章中,你将学习如何最好地执行 LINQ 查询以提高你应用程序的性能。
在这里,你将对确定 LINQ 查询中最后一个元素的最有效方法进行基准测试。你将了解在 LINQ 语句中使用 let 关键字的性能惩罚,以及为什么你应该避免使用它。通过基准测试不同的 Group By 方法,你将深入了解使用 LINQ 执行 GroupBy 查询的最有效方式。在执行查询和数据操作时,你可能会需要使用闭包。通过编写参数化和非参数化闭包,你会发现参数化闭包的性能远优于非参数化闭包。
在本章中,我们将涵盖以下主题:
-
设置我们的示例数据库
-
设置我们的内存中示例数据
-
使用 LINQ 查询数据库
-
获取集合的最后一个值
-
避免在 LINQ 查询中使用 let 关键字
-
提高 LINQ 查询中 Group By 的性能
-
过滤列表
-
理解闭包
到本章结束时,你将具备使用高效的 LINQ 安全存储秘密、查询数据库和内存中数据所需的技能。你还将能够理解在查询中使用 let 关键字对性能的影响,以及如何使用 LINQ 进行有效的过滤和分组数据。
技术要求
为了跟上本章的内容,你需要访问以下工具:
-
Visual Studio 2022
-
SQL Server 2019
-
SQL Server Management Studio
-
书籍的源代码:
github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH07
设置示例数据库
在本章中,我们将演示不同的集合接口如何处理数据,为了演示,你需要访问数据库数据。为此,你将创建一个数据库,添加一个表,并用数据填充它。你将使用 SQL Server 作为你的数据库引擎,并使用 SQL Server Management Studio 来开发你的示例数据库。
注意
在 CH07_LinqPerformance.Data 源代码文件夹中,你可以找到一个名为 SampleData.Product.sql 的数据库创建脚本,该脚本创建数据库并用数据填充。你可以在 SQL Server Management Studio 中运行此脚本。这将让你免于在本节中设置数据库。但如果你是 SQL Server 新手,你可能想运行这一节。
要添加你的数据库,请按照以下步骤操作:
-
打开 SQL Server Management Studio 并连接到你的数据库引擎。
-
在对象资源管理器中右键单击数据库文件夹,如图 7.1 所示:

图 7.1:SQL Server Management Studio 对象资源管理器选项卡
- 从上下文菜单中选择新建数据库。这将显示如图 7.2 所示的新建数据库对话框:

图 7.2:SQL Server Management Studio 新数据库对话框
-
一旦你为数据库名称输入了
SampleData,点击确定按钮来创建数据库。 -
通过以下图示展开
Products来定位数据库:

表 7.1:产品表设计
-
保存表,然后展开表文件夹。右键单击产品表,选择编辑前 n 条记录,其中 n 将是配置的记录数,默认为 200。
-
将以下图中的数据添加到产品表中:

表 7.2:产品表行数据
我们现在有一个数据库,其中只有一个表,表里填充了我们将要在本章后面使用的数据。在下一节中,我们将添加我们的内存样本数据。
设置我们的内存样本数据
由于你将研究 LINQ 性能,因此你需要一个集合来工作。你将使用一个Person对象的集合。每个人将被命名为希腊字母。一个Person对象将包含一个FirstName、LastName和FullName属性。FullName属性将是一个插值字符串,它结合了人的名字和姓氏。
让我们现在开始编写我们的 LINQ 代码,并结合基准测试,以便我们可以测量我们的 LINQ 语句的性能:
-
创建一个名为
CH07_LinqPerformance的新 .NET 6.0 控制台应用程序。 -
安装 NuGet 包
BenchmarkDotNet。 -
添加以下
Person结构体:public struct Person { public string FirstName { get; set; } public string LastName { get; set; } public string FullName { get { return $"{FirstName} {LastName}"; } } public Person(string firstName, string lastName) { FirstName = firstName; LastName = lastName; } }
此结构定义了具有 FirstName、LastName 和计算出的 FullName 的 Person。
-
现在,添加一个名为
LinqPerformance的新类,并包含以下using语句:using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; using System.Collections.Generic; using System.Linq;
这些 using 语句为你提供了访问基准测试、泛型集合和 LINQ 类的权限。
-
将以下代码添加到类的顶部:
private List<Person> _people = new List<Person>(); private string[] _group1 = new string[] { "iota", "epsilon", "sigma", "upsilon" }; private string[] _group2 = new string[] { "alpha", "omega" };
你已声明了一个人员列表和两个数组。这两个数组都包含属于那些组的人员的姓氏,且均为小写。
-
现在,添加一个全局设置类,该类将为基准测试各种 LINQ 查询准备你的集合:
[GlobalSetup] public void PrepareBenchmarks() { _people.Add(new Person("Alpha", "Beta")); _people.Add(new Person("Chi", "Delta")); _people.Add(new Person("Epsilon", "Phi")); _people.Add(new Person("Gamma", "iota")); _people.Add(new Person("Kappa", "Lambda")); _people.Add(new Person("Mu", "Nu")); _people.Add(new Person("Omicron", "Pi")); _people.Add(new Person("Theta", "Rho")); _people.Add(new Person("Sigma", "Tau")); _people.Add(new Person("Upsilon", "Omega")); _people.Add(new Person("Xi", "Psi")); _people.Add(new Person("Zeta", "Iota")); _people.Add(new Person("Alpha", "Omega")); _people.Add(new Person("Omega", "Chi")); _people.Add(new Person("Sigma", "Tau")); }
现在,你已经为我们将在本章中讨论的主题设置了示例数据库和内存中的示例数据。因此,让我们首先调查查询数据库的各种方法及其对 LINQ 查询性能的影响。
数据库查询性能
在 第六章,“.NET 集合”中,我们看到了 IEnumerator 与 IEnumerable 的区别,以及当遍历内存中的集合时,IEnumerator 的性能如何优于 IEnumerable。现在,我们将查询数据库,并使用各种基准技术遍历结果集合。为此,我们将遵循以下步骤:
-
添加一个名为
IEnumeratorVsIQueryable的新类。 -
你将连接到 SQL Server 数据库,并将需要保持秘密的信息。你的
secret.json文件不会提交到版本控制。因此,右键单击项目,并从上下文菜单中选择 管理用户秘密。 -
将弹出一个对话框,提示需要安装额外的包。单击 是。

图 7.3:提示需要安装额外包以管理用户秘密的对话框
-
Visual Studio 将在新的标签页中打开
secrets.json文件。这是你添加用户秘密的地方。 -
打开 包管理控制台 并添加以下包:
Microsoft.EntityFrameworkCore Microsoft.EntityFrameworkCore.SqlServer Microsoft.EntityFrameworkCore.Tools Microsoft.Extensions.Configuration Microsoft.Extensions.Configuration.EnvironmentVariables Microsoft.Extensions.Configuration.UserSecrets Microsoft.Extensions.OptionsConfigurationExtensions
这些包使你能够连接到并从 SQL Server 数据库中提取数据。
-
使用以下代码更新你的
secrets.json文件,其中包含你在本章开头创建的数据库的连接字符串:{ "DatabaseSettings": { "ConnectionString": "YOUR_CONNECTION_STRING" } }
此连接字符串将用于连接到你的数据库,执行返回一些数据的查询,并允许你遍历这些数据并对其执行操作。
-
添加一个名为
Configuration的文件夹,并在该文件夹中添加一个名为SecretsManager的类,该类具有空的静态构造函数和以下using语句:using Microsoft.Extensions.Configuration; using System; using System.IO;
你需要这些 using 语句来处理文件 I/O 和系统配置,例如从 secrets.json 文件中获取秘密。
-
在
SecretsManager类的顶部添加以下行:public static IConfigurationRoot Configuration { get; set; }
此行声明了你的静态配置属性,该属性用于在应用程序中获取配置数据。
-
现在添加以下代码:
public static T GetSecrets<T>(string sectionName) where T : class { var devEnvironmentVariable = Environment .GetEnvironmentVariable("NETCORE_ENVIRONMENT"); var isDevelopment = string.IsNullOrEmpty (devEnvironmentVariable) || devEnvironment Variable.ToLower() == "development"; var builder = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddEnvironmentVariables(); if (isDevelopment) //only add secrets in development { builder.AddUserSecrets<T>(); } Configuration = builder.Build(); return Configuration.GetSection(sectionName).Get<T>();
此代码获取 .NET Core 环境的环境变量。然后获取代码以查看它是否在软件开发环境中运行或在生产环境中运行。然后为将要运行的环境构建配置。因此,如果我们处于调试模式,配置将为开发环境构建。如果我们处于发布模式,配置将为生产环境构建。如果我们处于开发模式,则添加由 T 变量定义的 secrets 类。
-
创建一个新的文件夹,命名为
Models,并使用以下代码添加Product类:using System.ComponentModel.DataAnnotations; public class Product { public Product() { } public Product(int id) { Id = id; Name = $"Item {Id} Name"; Description = $"Item {Id} description."; } [Key] public int Id { get; private set; } public string Name { get; private set; } public string Description { get; private set; } public override string ToString() { return $"Id: {Id}, Name: {Name}, Description: {Description}"; } }
我们的 Product 类通过 Id、Name 和 Description 属性提供产品数据模型,这些属性通过构造函数设置。我们还重写了 ToString 方法以返回属性值的文本表示。
-
为
System.ComponentModel.DataAnnotations添加一个using语句。将结构体更改为类,并将[Key]属性添加到Id属性。我们需要这些更改,因为我们正在使用 Entity Framework 连接到数据库并提取数据。 -
在
CH07_LinqPerformance.Data文件夹中添加DatabaseContext类:using Microsoft.EntityFrameworkCore; using CH07_LinqPerformance.Models; public class DatabaseContext : DbContext { }
我们已经声明了我们的 DatabaseContext 类,它继承自 DbContext 类。现在我们需要添加其内部实现。
-
将以下项添加到
DatabaseContext类中:public DbSet<Product> Products { get; set; } public DatabaseContext(string connectionString) : base(GetOptions(connectionString)) { }
在此代码中,我们声明了我们的产品 DbSet 属性,它将包含我们的 Product 类的集合,以及一个连接字符串成员变量,它将包含连接到我们的数据库的字符串。然后声明构造函数,它接受一个连接字符串,我们将其传递给 GetOptions 方法,然后传递给基类构造函数。
-
将
GetOptions方法添加到DatabaseContext类中:private static DbContextOptions GetOptions(string connectionString) { return SqlServerDbContextOptionsExtensions .UseSqlServer( new DbContextOptionsBuilder(), connectionString) .Options; }
此方法返回用于我们的 SQL Server 数据库连接的 DbContextOptions。所使用的连接字符串是在开发时存储在我们的 secrets.json 文件中,在生产时存储在 appsettings.json 中。
-
添加
OnModelCreating方法:protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Product>(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.Name) .HasMaxLength(50); Entity.Property(e => e.Description) .HasMaxLength(255); }); }
在这里,我们正在配置将在我们的 DbSet 中使用的 Product 类。我们声明 Id 字段是主键,而 Name 字段的最大长度为 50,Description 字段的最大长度为 255。
-
将
DatabaseSettings类添加到Configuration文件夹中:public class DatabaseSettings { public string ConnectionString { get; set; } }
此类有一个名为 ConnectionString 的单个属性,它将保存到我们的 SampleData 数据库的连接字符串。请注意,类的名称和属性的名称与 JSON 部分和属性的名称相匹配!
-
现在,将
appsettings.json添加到项目的根目录,并包含以下内容:{ "DatabaseSettings": { "ConnectionString": "Set in Azure. For development, set in User Secrets" } }
此文件与 secrets.json 文件和 DatabaseSettings 类具有相同的布局。此文件用于存储您的连接字符串。在开发中,它设置在秘密文件中,在生产中设置在 Azure 中。现在您已经设置了数据库配置,可以添加基准测试代码。
-
在项目的根目录中添加一个名为
DatabaseQueryAndIteration的新类,该类实现IDisposable并具有以下代码:using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; using CH07_Collections.Configuration; using CH07_Collections.Data; using CH07_Collections.Models; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; [MemoryDiagnoser] [Orderer(SummaryOrderPolicy.Declared)] [RankColumn] public class DatabaseQueryAndIteration : IDisposable { }
此代码声明了我们的类并定义了它实现了 IDisposable 接口。它也被配置为可进行基准测试。
-
在我们的类中实现
IDisposable接口:private bool disposedValue; protected virtual void Dispose(bool disposing) { if (!disposedValue) { if (disposing) _context.Dispose(); disposedValue = true; } } public void Dispose(){ Dispose(disposing: true); GC.SuppressFinalize(this); }
此代码释放了我们的托管资源并抑制了对类终结器的调用。
-
我们已经为在这个类中的方法进行基准测试、访问数据库资源以及清理工作做好了准备。将以下代码添加到类中:
private DatabaseContext _context; [GlobalSetup] public void GlobalSetup() { var connectionString = SecretsManager .GetSecrets<DatabaseSettings>(nameof (DatabaseSettings)).ConnectionString; _context = new DatabaseContext(connectionString); } [GlobalCleanup] public void GlobalCleanup() { Dispose(true); }
_context 变量为我们提供了数据库访问权限。GlobalSetup() 方法从我们的密钥文件中获取连接字符串,并使用安全存储的连接字符串创建一个新的 DatabaseContext。GlobalSetup() 方法将在我们的基准测试之前运行。GlobalCleanup() 方法在基准测试完成后调用 Dispose(disposing) 方法来清理我们的托管资源。
-
接下来,添加
QueryDb()方法:[Benchmark] public void QueryDb() { var products = (from p in _context.Products where p.Id > 1select p); foreach (var product in products) Debug.WriteLine(product.Name); }
QueryDb() 方法通过选择具有大于 1 的 ID 的产品来在数据库上执行一个简单的 LINQ 查询。然后它遍历 IQueryable<Product> 列表中的每个产品,并将产品名称写入调试窗口。
-
现在,添加
QueryDbAsList()方法:[Benchmark] public void QueryDbAsList() { List<Product> products = (from p in _context.Products where p.Id > 1select p).ToList<Product>(); foreach (var product in products) Debug.WriteLine(product.Name); }
QueryDbAsList() 执行与 QueryDb() 相同的查询,但处理的是 List<Product> 类型。
-
添加
QueryDbAsIEnumerable()方法:[Benchmark] public void QueryDbAsIEnumerable() { var products = (from p in _context.Products where p.Id > 1 select p).AsEnumerable<Product>(); foreach (var product in products) Debug.WriteLine(product.Name); }
QueryDbAsIEnumerable() 方法执行与 QueryDbAsList 相同的查询,但处理的是 IEnumerable<Product> 类型。
-
添加
QueryDbAsIEnumerator()方法:[Benchmark] public void QueryDbAsIEnumerator() { var products = (from p in _context.Products where p.Id > 1 select p).GetEnumerator(); while (products.MoveNext()) Debug.WriteLine(products.Current.Name); }
QueryDbAsIEnumerator() 与前述方法执行相同,但操作的是 IEnumerator<Product> 类型,并使用 while 循环而不是 foreach 循环进行迭代。
-
我们需要添加的这个类中的最后一个方法是
QueryDbAsIQueryable()方法:[Benchmark] public void QueryDbAsIQueryable() { var products = (from p in _context.Products where p.Id > 1 select p).AsQueryable<Product>(); foreach (var product in products) Debug.WriteLine(product.Name); }
这种方法与 QueryDb 相同,但明确地操作 IQueryable<Product> 类型。
-
将
Program类中的Main方法中的代码替换为以下内容:BenchmarkRunner.Run<DatabaseQueryAndIteration>();
此代码运行您的基准测试。进行代码的发布构建,并从命令行运行可执行文件。您应该看到类似于以下摘要报告:
![图 7.4:使用 LINQ 的各种数据库查询类型的不同时间和内存分配]

图 7.4:使用 LINQ 的各种数据库查询类型的不同时间和内存分配
让我们总结一下,在运行查询基准测试后我们从摘要报告中学习到的东西:
-
在内存使用方面,性能最差的是
QueryDb()方法,其次是QueryDbAsList()方法。QueryDbAsIEnumerable()和QueryDbAsIQueryable()都比前两种方法略好。但所有五种方法中,内存分配性能最好的方法是QueryDbAsIEnumerator()方法。 -
在速度方面,
QueryDb()方法再次表现最差。其次是QueryDbAsIEnumerable(),然后是QueryDbAsList(),然后是QueryDbAsIQueryable()。再次,速度方面的最佳表现者是QueryDbAsIEnumerator()方法。
因此,我们可以看到,在速度和内存使用方面,查询和迭代数据库的最佳方法是我们在选择调查的所有方法中性能最好的 QueryDbAsIEnumerator() 方法。
在下一节中,我们将研究获取集合中最后一个项的最快方法。
获取集合的最后一个值
现在您将看到,与直接通过索引访问项目相比,LINQ 方法获取集合中的最后一个元素实际上非常慢。这将通过基准测试来测量不同方法的性能来完成:
-
更新
Main方法如下:static void Main(string[] args) { BenchmarkRunner.Run<LinqPerformance>(); } -
打开
LinqPerformance类。 -
添加
GetLastPersonVersion1()方法:[Benchmark] public void GetLastPersonVersion1() { var lastPerson = _people.Last(); }
此方法使用 LINQ 提供的Last()方法获取集合中的最后一个人员。
-
添加
GetLastPersonVersion2()方法:[Benchmark] public void GetLastPersonVersion2() { var lastPerson = _people[_people.Count - 1]; } -
在这里,我们使用列表的索引来提取列表中的最后一个人员。此时,值得注意的是,两种方法之间的区别在于,在第一种方法中,这个
Last()方法调用实际上是在System.Linq.Enumerable中声明的。方法签名如下:public static TSource Last<TSource>(this IEnumerable<TSource> source);
因此,GetLastPersonVersion1()方法在返回最后一个值之前执行了各种检查。但GetLastPersonVersion2()方法不执行这些检查,并立即返回最后一个位置上的值。这解释了为什么在GetLastPersonVersion1()中使用的方法比在GetLastPersonVersion2()中通过索引访问元素的方法慢得多,您将在下面的屏幕截图中看到:


图 7.5:使用 Last()方法和直接索引访问获取最后一个人员的示例性能
查看我们刚刚运行的基准测试的总结报告,很明显,使用索引进行直接访问比使用Last()方法调用在性能提升方面更好。
我们已经看到如何快速访问集合中的最后一个元素。现在让我们考虑为什么我们应该避免在 LINQ 查询中使用let关键字。
避免在 LINQ 查询中使用let关键字
如果在查询中要多次使用某个值,您可以使用let关键字声明一个变量并为其赋值以在您的 LINQ 查询中使用。乍一看,这似乎表明您正在提高性能,因为您只执行一次赋值,然后在查询中多次使用相同的变量。但实际上并非如此。在您的 LINQ 查询中使用let关键字实际上可能会降低您的 LINQ 查询性能。
让我们通过一些基准测试示例来分析。在LinqPerformance类中,执行以下操作:
-
添加
ReadingDataWithoutUsingLet()方法:[Benchmark] public void ReadingDataWithoutUsingLet() { var result = from person in _people where person.LastName.Contains("Omega") && person.FirstName.Equals("Upsilon") select person; }
在这个方法中,我们使用 LINQ 而没有使用let关键字从_people列表中选择具有姓氏Omega和名字Upsilon的人员。
-
现在,添加
ReadingDataUsingLet()方法:[Benchmark] public void ReadingDataUsingLet() { var result = from person in _people let lastName = person.LastName.Contains("Omega") let firstName = person.FirstName.Equals("Upsilon") where lastName && firstName select person; }
在这个方法中,我们也在选择具有姓氏Omega和名字Upsilon的人员从_people列表中。但这次,我们使用let关键字对两个过滤器进行操作,并在where子句中使用它们。
- 构建项目并从命令行运行可执行文件。您应该看到与图 7.6中显示的结果类似:


图 7.6:使用和不使用let关键字读取数据的 BenchmarkDotNet 结果
如您从这些结果中可以看到,在我们的查询中使用let关键字降低了性能。处理时间增加,内存分配也是如此。
注意
您会看到一些网站宣传在 LINQ 查询中使用let关键字以提高性能和可读性。但正如我们在例子中所看到的,使用let关键字会严重减慢查询的性能并增加内存使用。因此,作为一个经验法则,请测量您特定查询的性能,并选择最适合您查询任务的执行方法。
在本节中,我们看到了使用let关键字如何增加使用 LINQ 执行简单select查询所需的时间和内存。当处理大量数据时,这种性能下降可能成为一个真正的问题。在下一节中,我们将探讨几种分组数据的方法,并查看哪种方法表现最好。
提高 LINQ 查询中的 Group By 性能
在本节中,我们将探讨执行相同的Group By操作的三种不同方式。每种方式都提供不同的性能级别。您将在本节结束时看到哪种方法最适合执行快速的Group By查询。在本节中添加的方法将被添加到LinqPerformance类中。
对于我们的场景,我们想要从一个所有人员都拥有相同名字的集合中获取人员列表。为了提取这些人,我们将执行一个Group By操作。然后,我们将提取那些组计数大于一的所有人员,并将他们添加到人员列表中。
让我们添加使用GroupBy子句返回人员列表的三个方法:
-
添加
GroupByVersion1()方法:[Benchmark] public void GroupByVersion1() { List<Person> People = _people.GroupBy(x => x.LastName) .Where(x => x.Count() > 1) .SelectMany(group => group) .ToList(); }
如您所见,我们是根据人的姓氏进行分组的。然后我们过滤这些组,只包括那些计数大于1的组。然后选择这些组,并将它们作为人员列表返回。
-
现在,添加
GroupByVersion2()方法:[Benchmark] public void GroupByVersion2() { IEnumerator<IGrouping<string, Person>> test = _people.GroupBy(p => p.LastName) .Where(p => p.Count() > 2).GetEnumerator(); List<Person> people = new List<Person>(); while (test.MoveNext()) { IGrouping<string, Person> current = test.Current; foreach (Person person in current) { people.Add(person); } } }
在这种方法中,我们通过按姓氏分组人群,然后过滤这些组,只包括那些计数为2或更多的组,来获得一个枚举器。然后我们声明一个新的人员列表。接着我们遍历枚举器,获取当前的IGrouping<string, Person>。然后遍历分组,并将组中的每个人添加到人员列表中。
-
添加
GroupByVersion3()方法:[Benchmark] public void GroupByVersion3() { IEnumerator<IGrouping<string, Person>> test = _people.ToArray().GroupBy(p => p.LastName) .Where(p => p.Count() > 2).GetEnumerator(); List<Person> people = new List<Person>(); while (test.MoveNext()) { var current = test.Current; foreach (var person in current) { people.Add(person); } } }
GroupByVersion3()方法与GroupByVersion2()方法相同,行为也相同,但有一个主要区别。我们在执行Group By之前将人员列表转换为数组。
-
在
LinqPerformance类的顶部添加以下注释:[MemoryDiagnoser] [Orderer(SummaryOrderPolicy.FastestToSlowest)] [RankColumn]
这些注释将扩展总结报告中的数据,您很快就会看到。进行项目的发布构建,然后从命令行运行项目以基准测试这三种方法。您应该会看到以下基准测试总结报告:


图 7.7:BenchmarkDotNet Group By 总结报告
如我们所见,我们执行Group By操作的第一次尝试需要2.204微秒,第二次尝试需要2.011微秒,第三次和最后一次尝试需要2.204微秒。因此,我们可以看到在执行Group By之前将列表转换为数组可以加快速度。我们的最终版本比原始版本快0.243微秒,尽管涉及更多的代码!
下一个部分将带您了解五种不同的提供列表过滤方式。您将看到不同的方法如何影响 LINQ 查询的性能。
过滤列表
在本节中,我们将探讨使用 LINQ 过滤列表的各种方法。我们将看到各种方法的表现都不相同。在本节结束时,您将知道如何过滤列表以获得更高的性能。您将编写两个不同的基准测试,以展示使用和不使用let关键字时查询性能的差异。让我们开始编写我们的基准测试:
-
添加
FilterGroupsVersion1()方法:[Benchmark] public List<Person> FilterGroupsVersion1() { return (from p in _people where _group1.Contains(p.LastName.ToLower()) || _group2.Contains(p.LastName.ToLower()) select p).ToList( }
我们的第一个基准测试过滤属于_group1和_group2的人。由于数组是小写的,因此LastName也被转换为小写。然后,过滤的人作为人的列表返回。
-
添加
FilterGroupsVersion2()基准测试:[Benchmark] public List<Person> FilterGroupsVersion2() { return (from p in _people let lastName = p.LastName.ToLower() where _group1.Contains(lastName) || _group2.Contains(lastName) select p).ToList(); }
这与我们的第一个基准测试做的是同样的事情。主要区别在于我们使用let关键字引入了lastName变量,并将其分配给人的小写LastName。
- 以发布模式编译项目并从命令行运行。将生成基准测试,您应该会看到一个类似于图 7.8的基准测试报告:


图 7.8:使用和不使用 let 关键字的 LINQ 基准测试报告
我们可以在总结报告中看到,使用let关键字会显著减慢速度。因此,我们现在将调查为什么let关键字会减慢速度。
-
打开
CH07_LinqPerformance.dll。 -
展开
FilterGroupsVersion1和FilterGroupsVersion2。 -
双击
FilterGroupsVersion1方法以查看编译器生成的中间语言。 -
现在,使用
FilterGroupsVersion2方法做同样的操作。当您比较两种方法的 IL 时,您将清楚地看到FilterGroupsVersion2的 IL 比FilterGroupsVersion1的 IL 包含更多的代码行。
这就解释了为什么使用let关键字的代码版本比不使用let关键字的原始代码版本执行速度慢。但我们在性能方面能否做得比FilterGroupsVersion1更好?答案是,是的,我们可以。
-
添加
FilterGroupsVersion3方法:[Benchmark] public List<Person> FilterGroupsVersion3() { List<Person> people = new List<Person>(); for (int i = 0; i < _people.Count; i++) { var person = _people[i]; var lastName = person.LastName.ToLower(); if ( _group1.Contains(lastName) || _group2.Contains(lastName) ) people.Add(person); } return people; }
如您所见,我们创建了一个新的人员列表。然后我们遍历_people列表。对于每个人,我们从_people列表中获取他们。然后我们将他们名字的小写形式赋值给一个局部变量。使用这个变量,我们检查_group1或_group2是否包含这些名字。如果包含,则将这个人添加到_people列表中。一旦迭代完成,_people集合将被返回。
- 再次构建和运行代码。您应该看到以下报告:
![图 7.9:BenchmarkDotNet 性能摘要报告显示 FilterGroupsVersion3 的性能
![图 7.10:BenchmarkDotNet 性能摘要报告显示 FilterGroupsVersion4 的性能
![图 7.9:BenchmarkDotNet 性能摘要报告显示 FilterGroupsVersion3 的性能
如您所见,我们有三种不同的代码版本产生相同的输出,但每个版本的执行时间不同。在这三种不同的方法中,FilterGroupsVersion3是达到预期结果最快的方法。
-
我们将再次尝试改进 LINQ 过滤器查询的性能。添加
FilterGroupsVersion4方法:[Benchmark] public List<Person> FilterGroupsVersion4() { List<Person> people = new List<Person>(); for (int i = 0; i < _people.Count; i++) { var person = _people[i]; var lastName = person.LastName.ToLower(); if ( _group2.Contains(lastName) || _group1.Contains(lastName) ) people.Add(person); } return people; }
可以看出,FilterGroupsVersion3和FilterGroupsVersion4之间的唯一区别是if条件检查的顺序。
- 构建项目并运行基准测试。图 7.10显示了性能摘要:
![图 7.10:BenchmarkDotNet 性能摘要报告显示 FilterGroupsVersion4 的性能
![图 7.10:BenchmarkDotNet 性能摘要报告显示 FilterGroupsVersion4 的性能
![图 7.10:BenchmarkDotNet 性能摘要报告显示 FilterGroupsVersion4 的性能
从基准报告中可以看出,我们过滤器的第 4 版在性能方面是获胜的方法。那么,为什么第 4 版比第 3 版更好?_group2数组包含的项目比_group1少。如果您理解业务领域,您将能够以这种方式排序过滤检查,即首先检查项目较少的数组。
您已经看到使用let关键字会减慢速度。但您也看到了条件语句中检查的顺序如何影响性能。在条件检查语句中将具有最少元素的检查放在第一位将提高性能。
在下一节中,我们将探讨 LINQ 语句中的闭包以及它们如何影响查询性能。
理解闭包
在本节中,我们将从 C#的角度理解闭包,并将其应用于 LINQ 查询。让我们从维基百科上关于计算机编程闭包的定义开始。
维基百科:“在编程语言中,闭包,也称为词法闭包或函数闭包,是一种在具有一等函数的语言中实现词法作用域名称绑定技术。”
在操作上,闭包是一个记录,它存储了一个函数及其环境。
环境是一个映射,它将函数的每个自由变量(在局部使用但在封装作用域中定义的变量)与在创建闭包时名称所绑定到的值或引用关联起来。
与普通函数不同,闭包允许函数通过闭包对其值的副本或引用的捕获变量进行访问,即使函数在其作用域之外被调用。
为了理解这里所说的内容,我们将首先理解一等函数是什么。
一等函数是 C# 将其视为一等数据类型的函数。这意味着你可以将方法分配给变量并传递它,你可以像调用普通方法一样调用它。一等函数可以使用匿名方法和 lambda 表达式创建。
自由变量是那些不是方法参数变量的变量,它们也不是那个方法局部变量,换句话说,它们是存在于方法之外的变量,但在方法的作用域内被引用。
我们将把闭包应用到 LINQ 表达式并对其进行基准测试。第一个将使用带参数的闭包的 LINQ,第二个将使用使用自由变量的闭包的 LINQ。按照以下步骤进行:
-
在
LinqPerformance类中,注释掉当前[Benchmark]注解的方法。 -
添加
LinqClosureUsingParameters方法:[Benchmark] public void LinqClosureUsingParameters() { Func<string, char, char, bool> Between() { Func<string, char, char, bool> IsBetween = delegate ( string param1, char param2, char param3) { var character = param1[0]; return ( (character >= param2) && (character <= param3) ); }; return IsBetween; } var IsBetween = Between(); var data = (from p in _people.ToList() where IsBetween(p.LastName, 'A', 'G') select p).ToList(); }
在 LinqClosureUsingParameters 方法中,我们使用带有参数的委托声明闭包。我们声明一个名为 IsBetween 的变量并将 Between 方法分配给它。然后我们执行 LINQ 查询并通过调用 IsBetween 来过滤结果。结果是,我们只会得到那些姓氏首字母在 A 和 G 之间的人。
-
我们也可以使用自由变量。因此,现在让我们看看一个使用自由变量的不同示例。添加
LinqClosureUsingVariables方法:[Benchmark] public void LinqClosureUsingVariables() { Func<string, bool> Between() { char first = 'A'; char last = 'G'; Func<string, bool> IsBetweenAG = delegate (string param1) { var character = param1[0]; return ((character >= first) && (character <= last)); }; return IsBetweenAG; } var IsBetweenAG = Between(); var data = (from p in _people.ToList() where IsBetweenAG(p.LastName) select p).ToList(); }
在 LinqClosureUsingVariables 方法中,我们使用自由变量来声明用于过滤数据集的第一个和最后一个字符。然后,我们将 Between 方法分配给 IsBetweenAG 变量。然后,我们执行 LINQ 查询并通过将每个个人的姓氏传递给 IsBetweenAG 方法来过滤结果。
-
添加一个名为
NonLinqFilter的方法:[Benchmark] public void NonLinqFilter() { var data = _people.FindAll( x => x.LastName[0] >= 'A' && x.LastName[0] <= 'G'); }
在这个方法中,我们只是使用自己的 FindAll 方法过滤列表。
- 确保你处于发布模式,然后运行你的项目。你应该得到以下截图中的类似结果:


图 7.11:带参数和不带参数的闭包基准测试
如图 7.11的基准测试所示,我们可以清楚地看到,带有参数的闭包比不带参数的闭包更快,并且分配的内存更少。但使用列表自己的FindAll方法进行过滤更好,因为它比 LINQ 和闭包更快,并且使用的分配内存更少。
当你需要在自己的 LINQ 查询中使用自定义闭包时,可能的情况是,你有复杂的数据操作和查询生成,这些操作无法用正常的 LINQ 轻松处理。在这种情况下,闭包将对你有所帮助。在进行了闭包的基准测试后,你现在知道在使用 LINQ 时,为了获得最佳性能,应该使用带有参数的闭包。但如果你不需要使用 LINQ,那么使用列表自己的方法可能更有利。而且如果你确实需要在列表上工作,那么首先使用非 LINQ 方法过滤数据集可能是有益的,然后在对过滤后的列表执行 LINQ 查询。
本章现在已完成。但在我们继续进入第八章,文件和流 I/O之前,让我们总结一下本章学到的内容。
摘要
在本章中,我们通过基准测试了查询、分组、过滤和迭代从数据库和内存集合中获取数据的各种方法,研究了 LINQ 的性能。发现查询数据库的最有效方法是使用IEnumerator接口。通过反汇编代码,我们看到let关键字可能会由于编译器产生的额外 IL 代码行而降低性能。我们还看到,使用索引访问集合中的最后一个元素比调用Last()方法更快。我们还了解到,首先过滤具有最少项的对象来过滤列表可以提高过滤操作的性能。与不传递参数相比,传递参数的闭包提供了更好的整体性能。
在下一章中,我们将探讨文件和流 I/O 性能。但到目前为止,看看你是否能回答以下问题,并查看进一步阅读材料,以巩固本章学到的内容。
问题
-
提出一些提高 LINQ 性能的方法。
-
在 LINQ 查询中使用
let关键字有什么问题? -
提高分组查询性能的最佳方法是什么?
-
带参数的闭包和不带参数的闭包哪个性能更好?
进一步阅读
)
-
提高 LINQ to SQL 性能的五个技巧:
visualstudiomagazine.com/articles/2010/06/24/five-tips-linq-to-sql.aspx. -
使用 LINQ 连接使您的 C#应用程序更快:
timdeschryver.dev/blog/make-your-csharp-applications-faster-with-linq-joins. -
LINQ 很糟糕 – 您 LINQ 中的代码异味:
markheath.net/post/linq-stinks. -
如何使用 LINQ 表达式树从 Span
中获取值? :stackoverflow.com/questions/52112628/how-to-get-a-value-out-of-a-spant-with-linq-expression-trees. -
C#中的 Linq ToLookup 方法:
dotnettutorials.net/lesson/linq-tolookup-operator/. -
LINQ (C#) – ToLookup 运算符示例和教程:
www.completecsharptutorial.com/linqtutorial/tolookup-operator-example-csharp-linq-tutorial.php. -
C#闭包的简单解释:
www.simplethread.com/c-closures-explained/.
第八章:第八章:文件和流 I/O
在本章中,您将学习如何提高目录、文件和流性能。您还将学习如何高效地枚举目录、处理小文件和大文件、执行异步操作、使用本地存储、处理异常以及高效地与内存协同工作。
本章我们将涵盖以下主题:
-
理解各种 Windows 文件路径格式:本节提供了关于您将在 Windows 操作系统上遇到的不同文件路径格式的信息。还涵盖了 Windows 上的 256 个字符文件路径限制,以及如何去除这一限制的技术。
-
考虑改进 I/O 性能:在本节中,我们将对一些代码进行基准测试,以查看在计算目录大小和移动文件时哪种编码方法性能最快。此外,我们还将探讨如何异步读取和写入文件。
-
处理 I/O 操作异常:在本节中,我们将介绍如何处理 I/O 异常。您将学习如何处理异常,以确保性能不受负面影响。您还将学习何时从异常中恢复,以及何时退出异常以在无法优雅恢复时保护数据完整性。
-
高效执行内存任务:在本节中,您将学习如何在处理字符串和处理对象时高效使用内存。我们还将讨论如何对大对象堆进行碎片整理。
-
理解本地存储任务:在本节中,我们将讨论本地文件存储的各种选项、网络环境中可能出现的某些问题,以及当多个人在同一台计算机上使用相同的软件时,用户只为自身安装软件的情况。
到本章结束时,您将能够做到以下事项:
-
了解不同的 Windows 文件路径格式。
-
超越 Windows 上的 256 个字符文件路径限制。
-
了解硬件如何影响您代码的性能。
-
选择计算目录大小的最佳选项。
-
选择移动文件的最佳选项。
-
异步读取和写入文件。
-
有效地处理 I/O 和其他异常。
-
提高基于内存的任务性能。
-
了解您可用的本地文件存储选项。
-
理解在网络环境中可能出现的问题,例如当应该为单台机器上的所有用户安装的应用程序仅安装给当前用户时,以及如何有效地解决这些问题。
技术要求
本章的技术要求如下:
-
Visual Studio 2022
-
本书源代码:
github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH08
理解各种 Windows 文件路径格式
您可能已经知道 .NET 提供了管理代码,它隐藏了与 Windows API 的交互。因此,当 System.IO 命名空间将文件路径信息传递给 Windows API 以处理时,这并不会令人惊讶。Windows API 执行所需的任务,然后将控制权交还给 .NET。
.NET 中的文件路径可以是绝对路径、相对路径、UNC 路径或 DOS 设备路径。非 Windows 文件和目录是区分大小写的。但在 Windows 上,文件和目录是不区分大小写的。以下表格提供了不同 Windows 文件路径格式的示例:

表 7.1 – Windows 路径格式示例
默认情况下,Windows 只能接受长度为 256 的路径。作为一名程序员,你可能遇到过在备份文件或移动文件时出现的“目标路径太长”警告。这种情况通常发生在使用 NPM 通过节点模块开发 Web 项目时。NPM 软件包可能具有特别长的文件路径,超过 256 个字符,这将导致此异常被触发。
您可以通过编辑注册表或编辑组策略来移除最大路径长度限制。首先,您将学习如何使用注册表来移除此限制。然后,您将学习如何使用组策略来移除此限制。
使用注册表移除最大路径长度限制
注意
在修改注册表时,请始终谨慎行事。
在本节中,您将学习如何通过修改注册表来移除 260 个字符的文件路径限制。
在性能方面,Windows 上的 MAX_PATH 问题可能会浪费您的时间。复制大量数据可能非常耗时。如果在您在不同磁盘之间移动文件 28 分钟后文件复制失败,这可能会使问题变得更糟。
因此,在使用文件管理应用程序时,例如,如果用户打算在两个位置之间复制文件,这可能会引发文件长度异常,最好提醒用户,并在他们执行复制操作之前提供重新组织文件的选择,或者为他们提供更新注册表的选项。这样,您可以节省最终用户大量的时间。
要手动移除 MAX_PATH 文件路径限制,请按照以下步骤操作:
-
打开
regedit。 -
一旦打开注册表编辑器,导航到以下键:
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\ FileSystem -
识别
1。 -
如果键不存在,则将其添加为
1。 -
可能没有必要,但重启计算机以使更改生效是个好主意。
现在,您应该能够处理路径长度超过 260 个字符的文件。如果在执行前面的步骤后遇到权限问题,请以管理员身份打开注册表编辑器。如果您仍然有问题,请联系系统管理员。
现在,让我们学习如何使用本地组策略编辑器来完成这项操作。
使用组策略移除最大路径长度限制
您还可以通过修改计算机的策略来移除 260 个字符的文件路径限制。您可以使用gpedit.msc工具完成此操作。在某些 Windows 版本上,这可能不可用,或者由于已实施的集团策略而不可用。如果您发现这种情况,请咨询系统管理员。否则,请按照以下步骤操作:
-
打开
gpedit.msc。 -
在计算机配置下,导航到管理模板 | 系统 | 文件系统。
-
默认情况下将有一个名为“未配置”的设置。通过将其设置为“启用”来编辑此设置。
-
可能没有必要,但为了使更改生效,重启计算机是个好主意。
通过编辑注册表和本地组策略,我们已经学会了如何通过编辑注册表和本地组策略来克服 Windows 上的限制路径情况。
注意
移除文件路径限制非常重要。有实例表明,由于存在此限制,客户端和服务器计算机上的关键备份失败。当您与第三方库一起工作时,这也可能破坏您的开发项目。
现在我们将探讨一些有助于提升 I/O 操作的考虑因素。
考虑提高 I/O 性能
我们经常执行一些常见的 I/O 任务,例如遍历目录查找文件、添加、重命名、移动和删除目录、添加、重命名、移动和删除文件、对文件和目录进行密码保护、加密和解密文件和目录,以及压缩文件和目录。我们还同步、异步以及通过文件流和内存流等方式传输和加载文件。然后,还有所有 NoSQL 和 SQL 数据操作,这些操作将在企业网络中频繁发生,以及在工作场所和家中传输数据和音频/视频内容。
在处理 I/O 时,很容易完全减慢系统速度,以至于在文件读取和写入过程中变得无法使用。因此,如果您将要执行大量的 I/O 操作,您必须确保执行工作的地方的系统对最终用户和其他进程保持完全运行和响应。
如果您的硬件性能不佳,那么无论您的软件有多好,它很可能都会很慢!
注意
在考虑优化软件以提高 I/O 操作的速度和性能之前,您需要确保现有的硬件适合您将要执行的类型 I/O。否则,您可能会浪费时间试图改进软件!
当您处理硬件以加快输入和输出操作时,需要考虑的事项包括网卡的速度、是否使用 SSD 硬盘、CPU 的数量以及正在使用的 RAM 量。
您还需要考虑目标计算机上将要运行的其他软件进程。当涉及到应用程序速度减慢时,正在执行实时扫描的安全软件往往会被忽视。在这种情况下,您可以将应用程序添加为防病毒软件的例外,以便实时扫描不再减慢您的软件。
在野外遇到的一个问题是,在操作的关键时刻通过网络运行一个或多个备份。无论您的程序多么高效,如果它在备份服务器上运行,其性能可能会受到正在运行的备份软件和过程的严重影响。如果您的软件不在备份服务器上,但需要通过网络运行并发送接收文件和数据,这也可能发生。以下是需要考虑的事项:
-
将备份计划更改为在非关键时间运行。
-
在性能更好的服务器上安装您的软件。
-
检查您的网络是否存在瓶颈,并缓解这些瓶颈。
-
确保您的网络卡足够快并且配置得当。
-
确保您的以太网电缆是最新的。Cat-5 电缆适用于典型的互联网流量,但如果您在网络中进行大量的文件和数据操作,那么您可能需要升级到 Cat-6a/Cat-7 电缆以提高性能。然而,使用 Cat-7 电缆时,您需要小心不要在弯曲电缆时损坏箔屏蔽。
对于 Web 项目,减少文件大小以加快文件在互联网上的传输和接收速度非常重要。这有助于减少整体页面加载时间,并使客户更加满意。为了提高 Web 应用程序的加载性能,启用 Windows 动态内容压缩功能。这将减小数据的大小,从而从用户的角度提高响应时间。数据压缩的需求也适用于客户端/服务器应用程序,尤其是当传输的文件和数据大小非常大时。
使用缓存来提高网络性能。缓存将资源存储在本地或将其保留在内存中一段时间。如果再次请求这些资源,则将检查本地存储的资源并使用它们,而不是网络资源。这增加了资源的访问和加载时间,同时也减少了网络流量。如果资源已更新、缓存周期已过期或用户已清除其缓存,则缓存的资源将被更新。
两种最常见的数据传输机制是 XML 和 JSON。这些是存储结构化信息的文本文件。需要解析器从这些文件中提取信息,以便提取的数据可以在应用程序中使用。但并非所有 XML 和 JSON 解析器都表现相同。明智的做法是基准测试各种 XML 和 JSON 解析器的性能,以帮助你选择最适合你数据处理需求的最有效和性能最好的一个。
当你在序列化和反序列化数据时,你的对象及其层次结构应与你的 JSON 和 XML 格式相匹配,这样处理速度会更快。
微软建议开发者不要使用 BinaryFormatter 来传输二进制数据,因为它是不安全的,可能导致拒绝服务(DOS)攻击。.NET 提供了一些内置序列化器,可以安全地处理不受信任的数据:
-
XmlSerializer和DataContractSerializer可以将对象图序列化到 XML 中,也可以从 XML 中反序列化。不要将DataContractSerializer与NetDataContractSerializer混淆。 -
BinaryReader和BinaryWriter用于 XML 和 JSON。 -
System.Text.JsonAPI 可以将对象图序列化到 JSON 中。
数据类型的大小可能不同,因为它们可以存储不同的数据值,数据值长度也可能不同。数值和字符串值都是可变长度的。数值或字符串越大,保存到文件中的字节数就越多。数值或字符串越小,保存到文件中的字节数就越少。同样,对于数据类型名称,名称越长,使用的字节数就越多,名称越短,使用的字节数就越少。
当偶尔移动一两个文件时,字节数量可能对最终用户或应用程序性能不是问题。但是,当你进入批量文件处理的领域时,每个文件需要写入的字节数越多,批量处理完成所需的时间就越长。
根据你的操作系统版本、驱动程序、磁盘和网络硬件,复制或移动小文件可能比移动大文件更消耗性能。你可以在操作系统级别通过利用突发复制或类似技术来优化文件传输。
例如,在移动媒体文件(照片/音频/视频)或 AI/ML 数据集(通常是文本)时,可能会遇到许多性能问题。如果文件很小(从几个 KB 到几个 MB 不等),你可以将它们分组到 ZIP 文件中(如果它们是媒体文件则不进行压缩),这样可以得到更大的文件,可以更快地传输。
在下一节中,我们将对三种不同的文件移动方法进行基准测试。我们将使用File.Copy、FileInfo.MoveTo以及从内存缓存中获取FileInfo并使用FileInfo.MoveTo。这将帮助我们确定在应用程序中使用最快的移动方法,尤其是在需要移动大量文件时。
文件移动
在各种企业应用程序中,一个常见的功能是需要移动大量文件。例如,一个报告功能可能需要将来自各个团队的上一月的销售数据合并到数据仓库中,以便进行报告处理。这些销售数据可能位于各种位置的电子表格中。每个电子表格都需要被移动到一个中央文件存储位置以进行进一步处理。在文件移动操作中,你拥有的文件越多,所需的处理时间就越多。因此,了解在 C#中移动大量文件哪种方法性能最佳是有益的。
考虑到这一点,我们将编写一个简单的应用程序来基准测试三种不同的文件移动方式。我们编写的每个方法在性能上都会有所不同。我们选择的方法将是执行最快的,一旦我们运行了编译后的可执行文件,这将在我们的基准测试总结报告中识别出来。让我们开始编写我们的基准测试:
-
启动一个新的 C# .NET 5 控制台应用程序,并将其命名为
CH08_FileAndStreamIO。 -
安装
BenchmarkDotNetNuGet 包。 -
将名为
MovingFiles的新类添加到项目的根目录:using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; using System; using System.Collections.Generic; using System.IO; using System.Text; [MemoryDiagnoser] [Orderer(SummaryOrderPolicy.Declared)] [RankColumn] public class MovingFiles { }
我们现在的类已经设置好了,可以用来基准测试我们的方法并报告内存使用情况。
-
在类顶部添加以下代码(你可以用你自己的文件替换
Moonshine-3.0.0.exe文件):private Dictionary<string, FileInfo> _cache; private const string SOURCE_DIRECTORY = @"C:\Temp\Source\"; private const string DESTINATION_DIRECTORY = @"C:\Temp\Destination\"; private const string FILENAME = "Moonshine-3.0.0.exe";
在这里,我们声明了一个FileInfo对象的字典,它将作为我们的内存缓存,以及三个常量用于我们的源目录、目标目录和文件名。我们将在我们将要编写的其他方法中需要这些常量。
-
我们需要有一个程序来准备我们的代码,以便在没有抛出异常的情况下进行基准测试。如果没有,我们的基准测试将无法执行多次,因为文件已经被移动。每次基准测试运行时,移动的文件需要被移回到其原始位置。因此,我们需要一个
[GlobalSetup]方法和一个[GlobalCleanup]方法。首先,将[GlobalSetup]方法添加到MovingFiles类中。我们将在那里调用PreloadFilesAndCacheThem()方法:[GlobalSetup] public void PreloadFilesAndCacheThem() { var files = new DirectoryInfo(SOURCE_DIRECTORY) .GetFileSystemInfos(); _cache = new Dictionary<string, FileInfo>(); foreach (var f in files) { _cache.Add(f.FullName, f as FileInfo); } }
此方法通过SOURCE_DIRECTORY字符串标识的源目录中的每个文件获取FileSystemInfo。然后,它将_cache实例化为一个FileInfo对象的字典。之后,遍历文件列表,并将当前文件的FileInfo对象添加到_cache中。
-
添加
PreMoveCheck() [GlobalCleanup]方法:[GlobalCleanup] public void PreMoveCheck() { if (File.Exists($"{SOURCE_DIRECTORY}{FILENAME}")) if ( File.Exists( $"{DESTINATION_DIRECTORY}{FILENAME}") ) { File.Delete( $"{DESTINATION_DIRECTORY}{FILENAME}"); } if ( !File.Exists($"{SOURCE_DIRECTORY}{FILENAME}") && File.Exists( $"{DESTINATION_DIRECTORY}{ FILENAME}") ) { FileInfo fileinfo = new FileInfo( $"{DESTINATION_DIRECTORY}{FILENAME}") fileinfo.MoveTo( $"{SOURCE_DIRECTORY}{FILENAME}"); } } -
清理代码检查文件是否已存在于
SOURCE_DIRECTORY中。如果存在,则检查DESTINATION_DIRECTORY中的文件。如果存在,则将其删除。如果文件不在SOURCE_DIRECTORY中但存在于DESTINATION_DIRECTORY中,则将文件从DESTINATION_DIRECTORY移回到SOURCE_DIRECTORY。 -
我们需要
[GlobalSetup]和[GlobalCleanup]方法,因为如果它们不在适当的位置执行它们的功能,基准测试将失败,因为文件找不到。 -
将
FileCopy()方法添加到MovingFiles类中:[Benchmark] public void FileCopy() { PreMoveCheck(); File.Copy( $"{SOURCE_DIRECTORY}{FILENAME}" , $"{DESTINATION_DIRECTORY}{FILENAME}" ); } -
FileCopy()方法执行PreMoveCheck()以确保文件已就位,准备进行基准测试而不会失败。然后,它继续将文件从SOURCE_DIRECTORY复制到DESTINATION_DIRECTORY。 -
现在,添加
FileInfoMoveTo()方法:[Benchmark] public void FileInfoMoveTo() { PreMoveCheck(); FileInfo fileinfo = new FileInfo( $"{SOURCE_DIRECTORY}{FILENAME}" ); fileinfo.MoveTo( $"{DESTINATION_DIRECTORY}{FILENAME}" ); } -
FileInfoMoveTo()方法也执行PreMoveCheck(),确保文件已就位,准备移动。然后,它为指定的文件创建一个FileInfo对象,并使用MoveTo(string destinatation)方法将文件从SOURCE_DIRECTORY移动到DESTINATION_DIRECTORY。 -
将
FileInfoReadCacheAndMoveTo()方法添加到MovingFiles类中:[Benchmark] public void FileInfoReadCacheAndMoveTo() { PreMoveCheck(); FileInfo fileInfo = _cache[$"{SOURCE_DIRECTORY}{FILENAME}"]; if (fileInfo.Exists) fileInfo.MoveTo( $"{DESTINATION_DIRECTORY}{FILENAME}" ); } -
FileInfoReadCacheAndMoveTo()方法执行PreMoveCheck()。然后,它从存储在_cache中的FileInfo对象创建一个FileInfo对象。如果FileInfo对象存在,它随后被移动到DESTINATION_DIRECTORY。 -
在
Program类的Main方法中添加以下代码行:BenchmarkRunner.Run<MovingFiles>(); -
在
Release模式下构建项目,然后从命令行运行可执行文件。你应该会看到以下基准摘要报告:

图 7.1 – BenchmarkDotNet 对各种文件移动操作的摘要报告
从时间统计中,我们可以看到File.Copy(string source, string destination)方法是移动文件中最慢的方法,其次是FileInfo.MoveTo(string destination)方法。
最快的文件移动操作是从内存缓存中提取FileInfo,然后使用FileInfo.MoveTo(string destination)方法执行移动操作。
在下一节中,我们将探讨两种不同的方法来计算目录中所有文件的大小。然后我们可以使用最快的方法来计算目录的大小,例如在企业中进行批量文件移动之前。
计算目录大小
当你对文件和目录进行批量处理时,在将它们移动到新位置之前了解文件总和的大小是有益的。这可以帮助你确定复制文件所需的时间,以及目标位置是否有足够的空间来存储所有文件。
当你复制或移动文件时,弹出的某些对话框示例是 Windows 资源管理器对话框。它遍历要移动或复制的文件和目录。在这个过程中,它记录文件和目录使用的总字节数。然后,它提供一个时间估计,关于移动或复制这些字节需要多长时间。有时这个过程可能非常耗时,对最终用户来说可能很令人沮丧。
另一个了解目录大小的理由是当您有紧急且时间敏感的业务需求时。长时间的文件移动操作可能会损害业务的进度计划。在本节中,我们将通过基准测试两种不同的方法来计算目录大小。执行最快的那个方法是我们计算目录大小时会选择的方法。让我们开始吧:
-
向项目中添加一个名为
GettingFileSizes的新类,并像对MovingFiles类所做的那样对其进行基准测试配置。然后,将DIRECTORY常量添加到类的顶部:public const string DIRECTORY = @"C:\Windows\System32\"; -
添加
GetDirectorySizeUsingGetFileSystemInfos()方法:[Benchmark] public int GetDirectorySizeUsingGetFileSystemInfos() { DirectoryInfo directoryInfo = new DirectoryInfo(DIRECTORY); FileSystemInfo[] fileSystemInfos = directoryInfo.GetFileSystemInfos(); int directorySize = 0; for (int i = 0; i < fileSystemInfos.Length; i++) { FileInfo fileInfo = fileSystemInfos[i] as FileInfo; if (fileInfo != null) directorySize += (int)fileInfo.Length; } return directorySize; } -
GetDirectorySizeUsingGetFileSystemInfos()方法基于在DIRECTORY常量中定义的目录创建一个新的DirectoryInfo对象。然后,它从DirectoryInfo变量中获取一个FileSystemInfo数组。然后遍历FileSystemInfo数组,并将directorySize变量递增。一旦directorySize被计算出来,该值将被返回给调用者。 -
将
GetDirectorySizeUsingArrayAndFileInfo()方法添加到MovingFiles类中:[Benchmark] public int GetDirectorySizeUsingArrayAndFileInfo() { string[] files = Directory.GetFiles(DIRECTORY); int directorySize = 0; for (int i = 0; i < files.Length; i++) { directorySize += (int)(new FileInfo(files[i]).Length); } return directorySize; } -
GetDirectorySizeUsingArrayAndFileInfo()方法获取给定目录的文件名字符串数组。然后遍历数组,并将directorySize通过当前文件大小递增。一旦迭代完成,directorySize被返回。 -
将
benchmark运行器方法添加到Program类的Main方法中,执行Release构建并从命令行运行可执行文件。您将看到以下报告:


图 7.2 – 获取目录大小的基准总结报告
如您所见,我们使用了两种不同的方法来计算 System32 目录的大小。计算目录大小的最慢方法是我们的第二种方法。因此,出于性能考虑,计算目录大小的最佳方法是获取相关目录的 DirectoryInfo。然后,您可以调用 GetFileSystemInfos() 并遍历结果,累加 FileInfo 对象的长度。
在下一节中,我们将探讨异步文件操作。
异步访问文件
为什么你应该异步访问文件?好吧,这里有一些原因,您可能在使用异步文件访问时考虑:
-
当文件操作需要几秒钟或更长的时间才能完成时,您的用户界面线程将更加响应,因为文件操作不会阻塞用户交互。
-
异步进程减少了手动管理的线程需求,使应用程序更具可扩展性。ASP.NET 和服务器端应用程序是具体的应用程序示例,它们将从异步文件处理中受益。
-
文件访问延迟也是你必须考虑的因素。计算机资源,如硬盘类型、网络上传和下载速度、安全软件的实时扫描,以及文件大小,都是可能影响文件访问时间的因素。
-
使用异步任务而不是线程只有很小的开销。
-
你可以并行运行异步任务。
FileStream类让你对文件访问操作有最大的控制权。你可以配置该类以在操作系统级别执行 I/O 操作。通过这样做,你可以避免阻塞线程池线程。要在构造函数调用中指定在操作系统级别执行 I/O 操作,你必须指定以下之一:
-
useAsync=true -
options=FileOptions.Asynchronous注意
这个选项只能与
StreamReader和StreamWriter类一起使用,当提供给它们的流是由FileStream类打开的流时。
现在,让我们看看一个执行异步文件读写操作非常简单的例子。让我们先异步地将一些文本写入一个文本文件。然后,我们将异步地从同一个文件中读取文本。
异步写入文件
在本节中,我们将异步地将一些文本写入一个文本文件。虽然有一个更简单的方法来完成这个任务,但我们将使用的方法提供了最大的控制权,并且是在操作系统级别上操作的:
-
将一个新文件添加到
CH08_FileAndStreamIO项目,名为AsyncFileAccess。 -
将一个名为
WriteTextToFileAsync(string text, string path)的新方法添加到AsyncFileAccess类中:public async Task WriteTextToFileAsync( string text, string path ) { byte[] encodeText = Encoding.Unicode.GetBytes(text); using var fileStream = new FileStream( path, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true ); await fileStream.WriteAsync( encodeText, 0, encodeText.Length ); }
在这里,我们传递一个文本字符串和一个要写入文本的文件名。然后,我们将所有文本读入一个字节数组。接下来,我们声明一个具有 4,096 字节缓冲区的异步FileStream变量,将文本异步写入指定的文件,并等待操作完成。使用 4,096 字节的原因是因为它是 2 的幂,并且是内存页面大小。页面,内存页面,或虚拟页面是由页面表中的单个条目描述的固定长度连续的虚拟内存块。因此,当系统选择将页面交换到磁盘时,它可以一次性完成,而不涉及任何开销。
-
将
ReadTextFromFileAsync(string path)方法添加到AsynFileAccess类中:public async Task<string> ReadTextFromFileAsync(string path) { StringBuilder sb = new StringBuilder(); byte[] buffer = new byte[0x1000]; int numberOfBytesToDecode; using var fileStream = new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true ); while ( (numberOfBytesToDecode = await fileStream. ReadAsync(buffer, 0, buffer.Length)) != 0 ) { sb.AppendLine(Encoding.Unicode.GetString( buffer, 0, numberOfBytesToDecode )); } return sb.ToString(); }
在这个方法中,我们声明一个StringBuilder以实现高效的字符串连接。然后,我们声明并初始化一个新的字节数组,它将成为我们的缓冲区,并声明一个numberOfBytesToDecode变量。创建一个新的FileStream对象。
numberOfBytesToDecode变量通过等待对ReadAsync方法的调用来设置。这个变量在For循环的每次迭代中都会设置。对于循环的每次迭代,我们获取要解码的字节数。然后,我们向输出追加一行,其中包含从缓冲区中取出的项。最后,我们返回结果字符串。
-
将
DemonstrateAsyncFileOps()方法添加到AsyncFileAccess类:public async Task DemonstrateAsyncFileOps() { await WriteTextToFileAsync( "Supercalifragilisticexpialidocious", @"C:\Temp\File\film.txt" ); string text = await ReadTextFromFileAsync( @"C:\Temp\File\film.txt" ); Console.WriteLine($"The Text written was: {text}"); }
DemonstrateAsynFileOps() 方法通过调用异步写入操作将一些文本异步写入文件。然后,它通过调用异步读取操作异步读取文本。结果随后打印到控制台窗口。
-
按照以下方式修改您的
Program类的Main方法:static async Task Main(string[] args) { AsyncFileAccess afa = new AsyncFileAccess(); await afa.DemonstrateAsyncFileOps(); }
此代码创建了我们 AsyncFileAccess 类的新实例,然后调用 DemonstrateAsyncFileOps() 方法。
-
构建并运行您的代码。在您的控制台窗口中,您应该看到以下行被打印出来:
The Text written was: Supercalifragilisticexpialidocious
如我们的简单示例所示,异步文件访问相当直接。在下一节中,我们将探讨如何处理 I/O 异常。
处理 I/O 操作异常
在处理 I/O 操作时,您可能会遇到几种不同的异常。基本的 I/O 异常是 IOException。区分不同的 I/O 异常并将它们记录下来是有益的,因为这有助于加快问题解决的速度。
以下表格提供了由您的 I/O 操作可能引发的各种 I/O 异常的分解。通过捕获这些特定的异常,您可以提供更详细的异常日志条目,这有助于更容易地识别问题的根本原因:
![表 7.2 – Microsoft .NET I/O 异常
![img/B16617_Table_8.2.jpg]
表 7.2 – Microsoft .NET I/O 异常
现在您已经了解了可能引发的 I/O 异常的类型,您还需要了解正确处理、记录和显示这些异常的方法。
作为程序员,我们需要编写能够检测代码故障的代码。故障代码会使计算机程序处于未定义的状态。这可能导致意外和不可预测的副作用。处于不可预测状态的计算机程序可能导致各种问题,如性能降低、应用程序挂起和无效数据,从而导致错误信息。这可能导致严重的商业和消费者问题,这是不好的。
因此,您的代码需要具有容错性,并且应该能够适当地处理故障。异常应该被处理,以确保数据完整性保持不变。您还应该记住,您的计算机程序应该了解两种异常类别:
-
预期异常是您的计算机程序可以从中恢复的异常。
-
意外异常是您的计算机程序无法从中恢复的异常。
预期异常需要静默处理。您知道什么有可能失败以及为什么,因此您可以在一开始就放置防御性代码来应对可能引发这些异常的代码。这很重要,因为您不希望异常冒泡,因为这会降低应用程序的性能。反过来,应用程序性能的降低会影响用户体验。
允许异常在您的计算机程序中传播会消耗大量的性能。考虑到这一点,最佳实践规定,最好在代码中异常发生的位置处理异常,以提高应用程序的性能。
当您使用 try/catch 块捕获错误时,拥有多个 catch 块也是一个好的做法。唯一会形成 catch 块的异常是当前方法可以抛出的异常。您应该将异常 catch 块按顺序放置,最具体的异常在顶部,然后逐渐减少到最不具体的,这将是您的底部 catch 块。这有助于使您的代码对其他程序员更易读,并且也使得针对特定异常调试您的代码变得更加容易。
您可以使用异常过滤器来处理在特定条件下出现的异常。如果异常过滤器返回 true,则异常被处理。但如果它返回 false,则继续搜索异常处理器。与捕获和重新抛出相比,使用异常过滤器更可取,因为过滤器不会损害调用栈。如果稍后的处理器清空了调用栈,您可以看到异常最初来自哪里,而不仅仅是它被重新抛出的最后位置。
当发生意外的异常时,它必须被抛出,因为它可能会对您计算机程序的预测性产生严重影响。当发生意外的异常时,您应该记录异常并退出以保护数据的完整性。
这就是为什么使用 System.Exception 是一个坏主意,因为它会吞没所有异常。您的方法应该只捕获它们预期会引发的异常。所有意外的异常都应该以记录异常并退出程序的方式由应用程序处理。在主应用程序的 try/catch 块中,您会有您的 System.Exception 捕获块来捕获意外的异常。这个块将处理所有允许冒泡回主应用程序代码的意外异常。
当意外的异常传播回主应用程序代码的异常 catch 块时,您可以通过调用 Exception.GetBaseException() 来提取基本异常。这将获取最初引发的异常,导致任何后续异常也被引发。
根据我的经验,我发现 IT 专业人士在故障排除时往往会忽略审查事件日志和应用程序日志。然而,当他们一无所获并寻求我的帮助时,这通常是我的首要任务。可能是在事件查看器中没有记录任何内容,应用程序也没有记录任何内容。但有时确实记录了有价值的信息,这可以在问题解决和以更稳定的方式重新使应用程序运行方面节省时间。
实际上,异常可以记录在三个不同的位置:
-
应用程序日志文件:当遇到异常时,应用程序会将异常记录到文本文件、JSON 文件或 XML 文件中。
-
事件查看器:当遇到预期的异常时,应用程序会将此异常记录到命名事件日志中。当遇到意外的异常,如应用程序挂起时,系统会将此异常记录在 Windows 应用程序日志或 Windows 系统日志中。
-
数据库:当遇到应用程序时,应用程序会将异常记录到数据库表中。
无论你选择哪种机制或机制,都取决于你和你应用程序的需求。然而,你必须确保日志格式良好,并且提供的数据是有意义的。如果日志难以阅读且包含大量噪声,那么日志就没有价值了!
注意
使用最佳实践,规定托管和非托管资源应正确释放,特别是如果应用程序崩溃的话。在提供技术支持时,我经常遇到应用程序崩溃并锁定资源的情况,以及资源在内存中保持活跃的情况。这会导致糟糕的用户体验,并可能导致文件、目录和其他资源不可访问,以及应用程序本身无法启动。在这些情况下,通常唯一的选择是使用任务管理器结束应用程序或重启计算机。
高效执行内存任务
当基准测试 C#程序时,你会看到有时分配最多内存的对象会比分配较少对象的方程序更快。一个例子是字符串。使用格式化字符串可以分配较少的内存插值字符串。然而,格式化字符串可能比使用插值字符串更慢。我们将用一个非常简单的代码片段来演示这一点:
-
将一个类添加到
Memory中,并配置它使用BenchmarkDotNet。 -
添加
ReturnFormattedString()方法:[Benchmark] public string ReturnFormattedString() { return string.Format("{0} {1} {2} {3} {4} {5} {6} {7} {8} {9}", "The", "quick", "brown", "fox", "jumped", "over", "the", "lazy", "dog", "." ); }
此方法返回一个格式化字符串。它基本上是一行,不包含命名变量。
-
将
ReturnInterpolatedString()方法添加到Memory类中:[Benchmark] public string ReturnInterpolatedString() { string thep = "The"; string quick = "quick"; string brown = "brown"; string fox = "fox"; string jumped = "jumped"; string over = "over"; string thel = "the"; string lazy = "lazy"; string dog = "dog"; string period = "."; return $"{thep} { quick } { brown } { fox } {jumped} {over} {thel} {lazy} {dog} {period}"; }
此方法声明了几个字符串并将值赋给它们。然后它返回一个插值字符串。此方法覆盖多行,看起来可能会更慢并使用最多的内存。然而,唯一确定的方法是运行基准测试。
- 将
BenchmarkRunner.Run<Memory>();调用添加到Main方法中,进行Release构建,然后从命令行运行可执行文件。以下截图显示了分配的内存以及执行每个方法所需的时间:

图 7.3 – 比较 String.Format 与可互操作字符串的基准测试总结报告
如你所见,尽管我们可以声明多个变量并使用我们的字符串互操作性方法分配最多的内存,但它比使用String.Format执行相同操作要快得多。如果你有很多字符串处理要做,比如在批量报告生成或文档处理中,那么你可以几乎将执行字符串操作所需的时间减半,使用字符串互操作性。内存也永远不会达到第 1 代,因此它被垃圾回收器有效地处理。
此外,你需要减少你进行的装箱和拆箱的数量。每次将值类型转换为引用类型时,它都会存储在堆上。每次将引用类型转换为值类型时,你都会将其放置在栈上。那么,这样做对性能有什么影响呢?装箱和拆箱是计算密集型的过程。需要执行函数的计算越多,过程就越慢。因此,通过消除由装箱和拆箱引起的非必要计算,你可以加快应用程序的速度,并最终使用更少的内存。所以,当你能这样做的时候,尽量使用栈上的值类型而不是堆上的引用类型。
避免在对象中重复代码。如果你有多个构造函数重写,那么将公共代码放在公共构造函数中,并对你自己的方法做同样的事情。具有重复代码的类将比正确编写的没有重复的相同类使用更多的内存。你应该始终寻找方法来重构你的对象以减少代码膨胀,移除代码重复和重用代码是这样做的一种简单方法。
内存碎片化可能是 C#程序性能问题的重大原因。当对象被添加到堆中,垃圾回收,然后其他对象填充可用空间时,就会发生内存碎片化。如果你在内存中的对象之间有空闲空间,那么你的内存已经碎片化了。GC 将在最有效的时候执行压缩收集。手动执行此操作应在仔细调查了相关场景之后进行。
在 C#中,你可以使用可用的垃圾回收设置来对大型对象堆(LOH)进行碎片整理,如下所示:
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
此代码确保 LOH 上的对象占用连续的内存区域。所有位于内存中对象之间的空闲空间都被移除并放置在分配内存的末尾。
你还应该考虑不使用终结器。如果对象使用终结器,它将在内存中保持更长时间。这将导致内存使用量增加。内存使用量的增加会导致应用程序性能降低。
当你完成对象和资源时,销毁它们是一种最佳实践。这有助于防止未使用的对象留在内存中,并且还释放了文件和目录等资源上的锁。
当使用一次性对象时,你应该始终尝试使用using语句。这是因为当代码块执行完毕后,对象将自动被销毁。当你编写一个使用各种可处置资源的类时,即使它不拥有这些可处置资源,你也应该实现可处置模式。
到目前为止,我们已经讨论了文件和内存操作以及性能可能受到影响的情况。现在,让我们将注意力转向本地存储任务。
理解本地存储任务
在 Windows 10 上,有几个位置可以用来本地存储数据。具体如下:
-
AppData文件夹,这个文件夹可以包含设置、文件和文件夹。这个文件夹用于那些不容易重新创建或下载的数据。如果你有可以备份用户AppData文件夹的备份应用程序,那么存储在Local文件夹中的任何内容都将被备份。 -
ApplicationData.LocalCacheFolder属性可以存储在本地缓存中。使用本地缓存存储的项目将在会话之间持久化。 -
漫游:网络用户可以使用漫游配置文件在服务器上存储他们的本地数据。这有一个优点,那就是谨慎的网络管理员会确保配置文件定期备份,这样如果用户意外丢失数据,他们总有一个恢复点。
-
AppData\Temp文件夹用于临时数据。当你完成对临时文件夹的数据操作后,清理数据是一个好主意。应用程序的初始化和关闭是执行系统维护的好时机。 -
C:\ProgramData:这是一个存储应用程序数据的最佳实践位置。然而,这个位置并不总是会被备份。因此,始终提供一个应用程序内的方式以确保数据定期备份并存储在安全的位置是一个好主意,以防你的电脑出现故障,这种情况确实会发生!
关于如何以及在哪里存储你的数据,这取决于你。根据我提供学校 IT 支持的丰富经验,他们可能有一些极其复杂且在安全方面非常坚固的系统。你不能假设你的应用程序将安装在C:\驱动器上,也不能假设你将能够访问C:\ProgramData文件夹。
学校在尝试在如此复杂的系统上安装和运行教育供应商软件时,已经失去了许多商业和评估时间。这通常会导致远程技术支持会议。
另一个问题经常出现是使用微软虚拟存储。当用户安装软件并遇到问题,为使用此计算机的任何人安装或仅为我安装时,他们往往会选择后者。在 Windows 10 计算机上,选择仅为我安装会将已安装应用程序的存储数据放入用户的虚拟存储中。但选择为使用此计算机的任何人安装通常会将应用程序数据存储在C:\ProgramData\YOUR_APPLICATION文件夹中。
当多人登录到办公室计算机上,并且每个人都有一份数据副本时,一个明显的迹象是用户只为自己的使用安装了软件。当这种情况发生时,数据存在多个副本。这些副本可以在每个人的虚拟存储中找到。
这正是我和我的同事所经历的情况。我们开发的教育软件有独立、网络和在线格式。对于我们的独立客户,我们提供单用户许可证。应用程序的数据存储在 Microsoft Access 数据库中。最初是 Windows 7 上的一个问题,现在在 Windows 10 上仍然是一个潜在的问题,即用户被提示安装给自己或所有用户。当他们为所有用户安装时,Microsoft Access 数据库可以在 C:\ProgramData\CompanyName\ProductName 下找到。所有登录到计算机使用我们软件的用户将看到相同的数据集。但如果用户选择仅为自己安装,那么我们的软件数据将存储在用户配置文件的 VirtualStore 下
虚拟存储的位置是 C:\Users\%USERNAME%\AppData\Local\VirtualStore。了解这一点很有用,因为它可以减少你在各个用户配置文件下定位数据所需的时间。当客户要求将数据合并并存储在中央位置时,困难就出现了。在这种情况下,卸载软件并重新安装它,确保选择“为所有用户安装”选项。然后,要求用户停止使用软件,直到你为他们提供合并后的数据。此类信息可能不会提高你的 C#和.NET 程序的性能,但它确实在你提供技术支持时提高了你的效率。这可以成为你的优势,正如我发现的对我有益的那样!并且作为程序员/技术支持人员/软件开发人员,我们都会进行个人绩效评估,以了解我们在各自角色中的表现如何。
现在我们已经完成了本章的内容,让我们总结一下我们学到了什么。
摘要
在本章中,我们首先查看了几种不同的文件路径。有四种不同类型的文件路径——绝对路径、相对路径、UNC 路径和 DOS 设备路径。
在讨论了各种路径类型之后,我们了解到,默认情况下,Windows 和 Windows Server 被限制在 256 个字符的完整文件路径长度。在当今开源和基于 Web 的软件跨平台工作的世界中,Windows 计算机上的这个最大标准长度可能非常有限。这在进行磁盘到磁盘备份时可能会引起备份问题,并且深度嵌套的项目可能会超出最大文件路径长度。为了克服这一限制,我们学习了如何通过访问和修改注册表来移除限制。
我们接下来关注的是提高磁盘 I/O 的各种考虑因素。我们通过考虑可能影响性能的不同硬件设备开始考虑 I/O 性能考虑因素。然后,我们对一些代码进行了基准测试,以找到计算目录大小、移动文件和执行异步文件操作的最有效方法。
我们接下来关注的是异常处理。我们认识到,不必要地向上冒泡异常会影响性能,并且它们应该在源头被捕获和处理。我们还认识到,我们不应该通过捕获通用异常来吞没异常。通用异常只应在关闭应用程序以处理不可恢复异常之前,作为日志记录的最后一资源。
我们接下来关注的是内存任务。在基准测试string.Format和插值字符串之后,我们了解到使用插值字符串几乎将我们的performane.Next提高了两倍,然后我们考虑了内存碎片化,这可能会在添加和删除各种大小的对象时发生。我们还学习了如何压缩碎片化内存以提高其运行效率。
最后,我们探讨了本地存储任务。我们讨论了可用的各种本地存储类型及其用途。此外,我们还讨论了我们的产品的最终用户安装,这可能导致不同登录用户拥有自己的数据集。当用户选择为自己安装而不是为所有用户安装时,就会出现这个问题。因此,每个用户都有其应用程序数据的副本存储在C:\Users\%USERNAME%\AppData\Local\VirtualStore的配置文件中。
在下一章中,我们将探讨网络。但在我们这样做之前,看看你是否能回答以下问题。然后,通过查看进一步阅读部分来提高你对 I/O 性能主题的了解。
问题
回答以下问题以测试你对本章知识的了解:
-
你需要了解哪些各种 Windows 文件路径格式?
-
如何移除 Windows 文件路径的 256 字符限制?
-
哪种方法是最有效的计算目录大小的方法?
-
哪种方法是最有效的移动文件的方法?
-
应该在何时使用
Exception类来捕获异常? -
基础 I/O
Exception类是什么? -
对于本地存储,你有哪些文件位置选项?
-
用户安装你的软件时可能会遇到哪些潜在陷阱?
-
微软虚拟存储是什么?
-
微软虚拟存储位于何处?
进一步阅读
关于本章涵盖的主题的更多信息,请参阅以下资源:
-
文件和流 I/O:
docs.microsoft.com/dotnet/standard/io/. -
除了 File.Move 之外更快的文件移动方法:
stackoverflow.com/questions/18968830/faster-file-move-method-other-than-file-move. -
C# GetFileSystemInfos 可以快速获取文件大小:
thedeveloperblog.com/getfilesysteminfos. -
C# 写入文件的性能:
stackoverflow.com/questions/9437265/performance-of-writing-to-file-c-sharp. -
如何使用 PLINQ 遍历文件目录:
docs.microsoft.com/bs-cyrl-ba/dotnet/standard/parallel-programming/how-to-iterate-file-directories-with-plinq?view=dynamics-usd-3. -
在 .NET 中处理 I/O 异常:
docs.microsoft.com/dotnet/standard/io/handling-io-errors. -
从桌面应用程序调用 Windows 10 API: https://blogs.windows.com/windowsdeveloper/2017/01/25/calling-windows-10-apis-desktop-application/#vZiZ96PlZUqTduts.97.
-
.NET 6 的性能改进:
devblogs.microsoft.com/dotnet/performance-improvements-in-net-6/. -
页面(计算机内存):
en.wikipedia.org/wiki/Page_(computer_memory).
第九章:第九章:提高网络应用程序的性能
当你打开电脑时,很难找到一个不使用某种网络应用程序的应用程序。你的操作系统连接到网络以下载和安装 Windows 更新。已安装的应用程序将通过互联网定期轮询(检查)应用服务器,以查看是否有可下载的新版本。
浏览器通过互联网下载音频和视觉数据,网站允许你上传和下载文件。商业应用程序与数据库服务器进行通信。通信应用程序通过网络发送大量的文本、音频和视觉数据——通常涉及来自世界各地多个地区的人们参与在线视频会议和培训课程。你的金融科技应用程序通过互联网与你的金融提供商进行通信。这只是冰山一角。
我们的世界通过技术紧密相连,是网络使得所有这一切成为可能。我相信,作为网站或应用程序的用户,当它出现减速、应用程序挂起或应用程序在完成某些其他任务之前暂时冻结,阻止你进行任何工作,你一定感受到了一些挫败感。
由于这个原因,在当今快节奏的世界中,拥有在网络中表现优异的应用程序至关重要。这就是为什么微软正忙于不断提高他们软件的效率和速度。在舞台上相对较新的一个软件是Google 远程过程调用(gRPCs)。这是一个用于进行远程过程调用(RPCs)的软件框架,gRPC/gRPC-Web 获得了性能提升。
在本章中,你将学习如何提高网络应用程序的性能。你还将学习如何使用System.IO.Pipelines在网络中进行通信,以提供高性能的流式传输能力。
本章将涵盖以下主题:
-
理解网络层和协议:为了编写可工作的网络软件,你不必了解任何关于网络及其工作原理的知识——除非你正在编写用于提高网络应用程序性能的低级软件。在本节中,我们将从查看网络的各个层以及存在于这些层中的协议开始,来探讨如何提高软件的网络性能。
-
优化基于 Web 的网络流量:我们中的许多人每天都在工作、家庭、教育和休闲时间使用互联网。互联网通过覆盖全球的基于 Web 的网络运行。这个网络由非常慢的铜线网络到超快的光纤网络组成,以及具有不同处理能力的许多计算机。在本节中,我们将学习如何改善互联网上的流量,以提高互联网资源传输。你还将学习如何使用 Microsoft Edge 监控 Web 应用程序的性能。
-
使用 gRPC 进行高性能通信:在本节中,我们将学习如何使用 gRPC 和 gRPC-Web 进行高速网络进程间通信。当涉及到 gRPC-Web 时,我们将使用 Blazor Server 进行服务器端代码,使用 Blazor WebAssembly 进行客户端代码。
-
优化互联网资源:为了提高资源上传和下载时间,花时间进行正确的资源优化是值得的。在本节中,我们将学习如何优化图像、文本字符和数据传输。
-
使用管道进行内容流传输:在本节中,你将学习如何将数据处理、数据传输和数据接收阶段分解为几个原子任务,这些任务通过管道协同工作。
-
在内存中缓存资源:在本节中,你将学习如何将资源缓存到内存中,以减少页面传输和显示时间。这可以帮助减少其他用户的网络负载,并防止瓶颈和限制。
完成本章后,你将能够做到以下事项:
-
理解并应用基于 UDP 和基于 TCP 的网络协议
-
监控和识别网络流量问题
-
使用缓存提高资源网络检索性能
-
安全地发出网络请求并处理响应
-
使用管道在网络上高效地传输内容,例如互联网
注意
与所有性能敏感型工作一样,本章以及整本书中提到的所有技术和示例,都应在你的应用程序上下文中进行衡量。某些技术可能带来的开销可能不是必要的,这取决于你的网络应用程序需要处理的规模。
技术要求
要跟随本章的内容,你需要以下条件:
-
Visual Studio 2022 或更高版本
-
Microsoft Edge
-
本书源代码:
github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH09
理解网络层和协议
当编写与网络交互的应用程序时,了解所使用的网络协议非常有用。网络协议是一组定义了如何在网络上的不同设备和应用程序之间格式化、传输和接收数据的规则。不同的网络协议用于不同的任务。一些协议本质上是安全的,而另一些协议本质上是不可靠的。OSI 网络层参考模型是了解设备网络能力和相关协议的好起点。
OSI代表开放标准研究所。OSI 网络层参考模型是一个概念模型,它定义和标准化了计算机和电信设备之间的通信。它独立于支撑这种通信的技术,因此它是对设备网络层和协议的技术无关表示:

表 9.1 – OSI 网络层参考模型
如您所见,网络有七个操作层。这些层如下:
-
应用层:应用层提供了一个用户界面,允许用户通过网络发送和接收数据。该层包含所有您使用的应用程序,以及那些在幕后操作以与表示层交互的应用程序。例如,您的互联网浏览器使用 HTTP、HTTPS 和 FTP 协议在互联网上传输和接收文件,而电子邮件客户端使用 POP3、SMTP 和 IMAP 发送和接收电子邮件数据。
-
表示层:表示层加密、格式化和压缩准备用于网络传输的数据。在此层使用的协议包括 POP/SMTP、Usenet、HTTP、FTP、Telnet、DNS、SNMP 和 NFS。
-
会话层:会话层启动和终止与远程系统的会话。这是分配网络通信端口的层级。例如,端口 25 用于 POP/SMTP,端口 532 用于 Usenet,端口 80 用于 HTTP,端口 443 用于 HTTPS,端口 20 和 21 用于 FTP,端口 23 用于 Telnet,端口 53 用于 DNS,端口 161 和 162 用于 SNMP,以及使用 RPC 端口映射器来支持 NFS。
-
传输层:传输层使用 TCP 或 UDP 将数据流分解成较小的数据交付段。
-
网络层:网络层使用原始的 IPv4 或更新的 IPv6 提供逻辑寻址。
-
数据链路层:数据链路层为传输准备数据。该层将网络层的信息转换为可以使用 SLIP、PPP、802.2 SNAP 和以太网 II 协议轻松在所需网络类型上传输的格式。
-
物理层:物理层负责在设备位置之间移动数据。该层可以提供的物理网络连接类型包括 RS-X、CAT1 到 CAT8、ISDN、ADSL、ATM、FDDI 和同轴电缆。
以下协议在这些不同级别中使用:
-
域名系统 (DNS):DNS 协议的目的是通过域名解析将主机名转换为 IP 地址,反之亦然。DNS 的默认端口是 53。
-
动态主机配置协议 (DHCP):DHCP 的目的是动态地将 IP 地址相关信息分配给网络设备。DHCP 的默认端口是 67 和 68。
-
超文本传输协议 (HTTP):HTTP 的目的是使网页及其支持材料能够在互联网上传输。HTTP 协议的默认端口是 80。
-
超文本传输协议安全 (HTTPS):HTTPS 的目的是在互联网上安全地传输网页及其支持材料。HTTPS 的默认端口是 443。
-
安全外壳 (SSH):SSH 协议的目的是安全地连接到远程计算机,移动文件并执行各种命令。SSH 的默认端口是 22。
-
安全套接字层 (SSL):SSL 协议的目的是确保服务器和网页浏览器之间传输的数据安全。SSL 的默认端口是 443。
-
文件传输协议 (FTP):FTP 的目的是在互联网上传输文件。FTP 的默认端口是 20 和 21。
-
Telnet:Telnet 通过虚拟终端连接提供两个计算机之间不安全的双向交互式文本通信。Telnet 的默认端口是 23。
-
** trivial 文件传输协议** (TFTP):TFTP 的默认端口是 69。
-
简单邮件传输协议 (SMTP):SMTP 的目的是确保电子邮件在网络上的安全传输。SMTP 协议的默认端口是 25。
-
邮局协议版本 3 (POP3):POP3 的目的是从电子邮件服务器下载和阅读电子邮件。POP3 的默认端口是 110。
-
互联网消息访问协议 4 (IMAP4):IMAP 的目的是在不下载的情况下访问远程电子邮件服务器上的电子邮件。IMAP 的默认端口是 143。
-
远程桌面协议 (RDP):RDP 的目的是建立到计算机的远程连接并控制它。RDP 的默认端口是 3389。
-
传输控制协议 (TCP):TCP 的目的是提供可靠的保证,确保传输的数据将被接收。TCP 允许数据发送和接收。不同的协议属于 TCP 的范畴,每个 TCP 协议都有一个默认端口号。
-
用户数据报协议 (UDP): UDP 的目的在于提供不受信任的数据传输,而不保证数据会被接收。UDP 只允许数据传输。不同的协议属于 UDP 的范畴,每个 UDP 协议都有一个默认端口号。
-
互联网协议 (IP): IP 的目的是在 TCP/IP 网络上指定如何在主机计算机之间路由数据包。
-
以太网:以太网协议的目的是根据 IEEE 802.3 协议,控制数据如何在局域网中传输。
-
点对点 (PPP): PPP 协议的目的是通过身份验证、传输加密和数据压缩,在两个路由器之间建立数据链路连接。
-
网络时间协议 (NTP): NTP 的目的是在具有可变延迟的分组交换数据网络上,在计算机系统之间提供时钟同步。
-
网络新闻传输协议 (NNTP): NNTP 的目的是在新闻服务器之间传输 Usenet 文章(netnews)。它也被终端用户客户端应用程序用于阅读和发布文章。
这些只是当今世界使用的各种网络协议中的一小部分。如果您进行大量需要网络访问的编程,鼓励您进一步研究各种协议。您可以在“进一步阅读”部分找到一些有用的文章来帮助您进步。
一旦您了解了网络协议的用途,您就可以选择最适合您需求的协议。这有助于减少开销。例如,如果您只想传输数据,而不希望接收它或关心它是否被接收,那么您将使用 UDP 网络协议。然而,如果您必须保证数据被发送和接收,那么您必须使用 TCP。
互联网工程任务组 (IETF) 定义了两个成为互联网标准的网络传输协议的请求评论 (RFCs)。RFC 768 (UDP) 定义了 UDP,而 RFC 793 (TCP) 定义了 TCP。以下是这些 RFC 的官方链接,供您查阅:
-
RFC 768 (UDP):
tools.ietf.org/html/rfc768 -
RFC 793 (TCP):
tools.ietf.org/html/rfc793
TCP 是一种面向连接的协议,负责确保通过会话在网络中可靠地传输数据。发送方和接收方就将要传输的数据达成一致。对接收到的数据进行数据包错误检查。如果有错误,则提交重传失败数据包的请求。TCP 通常与IP一起使用。IP 使数据包知道它们要去哪里以及如何到达。当 TCP 和 IP 协议协同工作时,这种组合被定义为 TCP/IP。
UDP 与 TCP 不同,因为它是无连接的。UDP 接收器监听 UDP 数据包,会话无需建立。UDP 不执行错误检查。因此,数据包可能会丢失,接收器可能没有意识到这些数据包的丢失。UDP 在接收到数据或数据包丢失时不会向发送方确认。
TCP 通过建立通信会话、执行错误检查和重新提交丢失或损坏的数据包,通常被认为比 UDP 慢。UDP 比 TCP 快,因为它不建立会话连接或执行错误检查。因此,当数据必须无错误接收时,例如在金融交易中,TCP 是最佳选择。然而,当涉及到流式传输实时图像,例如观看电影时,UDP 是最佳选择。这就是为什么电影有时会显得有点颗粒感。
在现实世界中,OSI 模型并非在所有实际情况下都存在。相反,普遍接受的、在实用层面上有形的网络模型是 TCP/IP 模型。
TCP/IP 模型
TCP/IP 模型与 OSI 模型的不同之处在于,TCP/IP 模型只由四个层组成。这些层如下:
-
应用层
-
传输层
-
互联网层
-
网络接口层
那么,TCP/IP 模型的层是如何映射到 OSI 模型的?以下表格展示了这两个模型及其层并列比较:

表 9.2 – TCP/IP 模型与 OSI 模型的比较
让我们描述 TCP/IP 模型中的每一层:
-
应用层使用户能够在网络上启动应用程序和系统之间的通信。这可以是发送电子邮件、打开网页、通过网络运行应用程序、从数据库访问应用程序信息以及通过网络执行文件传输。
-
传输层解决主机间的通信。
-
互联网层连接不同的网络。
-
网络接口层是物理硬件,它使服务器与其主机之间能够进行网络通信。
现在我们已经了解了 TCP/IP 模型,在下一节中,我们将编写一个简单的电子邮件应用程序,并讨论它与 TCP/IP 模型的关系。
使用 TCP/IP 模型编写一个示例电子邮件应用程序
在本节中,我们将编写一个简单的控制台应用程序,使用 SMTP 发送电子邮件。然后,我们将讨论这封电子邮件是如何通过 TCP/IP 模型发送的。要编写一个简单的控制台应用程序,请按照以下步骤操作:
-
创建一个新的.NET 6.0 控制台应用程序,并将其命名为
CH09_OsiReferenceModel。 -
添加一个名为
EmailServer的新类,并包含以下using语句:using System; using System.Net.Mail;
我们需要这两个命名空间来处理异常和发送电子邮件。
-
添加以下方法:
public static void SendEmail( string from, string to, string title, string message ) { try { MailMessage mailMessage = new MailMessage(); mailMessage.From = new MailAddress(from); mailMessage.To.Add(to); mailMessage.Subject = title; mailMessage.Body = message; SmtpClient smtpServer = new SmtpClient(); smtpServer.DeliveryMethod = SmtpDeliveryMethod.Network; smtpServer.Host = “smtp-mail.outlook.com”; smtpServer.Port = 587; smtpServer.UseDefaultCredentials = false; smtpServer.Credentials = new System.Net.NetworkCredential(“EMAIL_ADDRESS”, “PASSWORD”); smtpServer.EnableSsl = true; smtpServer.Send(mailMessage); } catch (Exception ex) { throw ex.GetBaseException(); } }
上述代码为发送电子邮件提供了必要的参数。从这些参数中构建了一个MailMessage。然后,我们初始化并配置一个SmtpClient来连接到网络化主机电子邮件服务器,发送我们的电子邮件。
-
更新
Program类,如下所示:using CH09_OsiReferenceModel; Console.WriteLine(“Hello World!”); SendMail(); Console.WriteLine(“Email has been sent.”);
在这里,我们正在向控制台窗口写入问候语。然后,我们调用SendMail()来发送我们的电子邮件,并以一条消息结束。
-
现在,添加
SendMail()方法:static void SendMail() { EmailServer.SendEmail( “FROM_EMAIL” , “TO_EMAIL” , “Test Message” , “Test Body. You can delete!” ); }
将电子邮件地址替换为有效的地址。此方法调用EmailServer类中的SendMail方法。
运行程序;你应该在你的电子邮件账户中收到一封电子邮件。
当你的项目运行正常时,是时候讨论你的项目如何与 TCP/IP 网络模型相连接了。让我们先看看以下图表:

图 9.1 – 通过 TCP/IP 协议使用 SMTP 在网络中发送和接收电子邮件
首先,使用电子邮件客户端组合一封电子邮件,并让用户点击发送。当数据到达应用层时,SMTP 协议开始发挥作用。在这一层,联系收件人,并对数据进行格式化,并加上 SMTP 头部。
然后,电子邮件被传递到传输层。在这一层使用 TCP,用于将消息分解成带有 TCP 头部的小数据包。
从传输层开始,电子邮件被传递到互联网层。IP 对电子邮件数据包进行格式化,以便它们可以传输到互联网,并在其前面加上 IP 头部。这些格式化的 TCP/IP 数据包随后被传递到网络接口层。
在网络接口层,发送者和接收者的 IP 地址被添加到电子邮件前面加上头的头部中。然后,电子邮件被发送到接收者。
当电子邮件数据包到达接收者时,它首先到达网络层。网络层的头部被移除,电子邮件数据包被传递到互联网层。IP 头部被移除,电子邮件数据包被传递到传输层。
在传输层,电子邮件数据包被重新组装。一旦所有数据包都组装完成,并移除了 TCP 头部,它们被传递到应用层,在那里 SMTP 协议移除 SMTP 头部,将纯电子邮件数据传递给客户端,并关闭会话。
有了这些,我们已经涵盖了概念性的 OSI 模型和实用的四层 TCP/IP 模型。发送电子邮件是我们用来讨论从发送者到接收者在四层 TCP/IP 层中旅程的例子。
现在,你已经了解了构成网络的不同层以及一些不同的网络协议及其用途,让我们来看看网络跟踪。
提高基于 Web 的网络流量
关注您的 Web 应用程序的性能是一个好主意。这有助于您了解您的应用程序如何从我们所熟知的互联网或日益被称作云的网络中传输和接收信息。您甚至可以追踪那些耗时较长的调用,从而提高您应用程序的响应性能。
完成此任务有多种方法。但我们将只关注一种方法,那就是使用内置的开发工具性能分析器在 Web 浏览器中记录您应用程序的性能。具体来说,我们将探讨使用 Microsoft Edge 的开发工具。这将是下一节的主题。
使用 Microsoft Edge 记录您的 Web 应用程序性能
在本节中,您将使用 Microsoft Edge 浏览器来分析您的 Web 应用程序性能。互联网是我们每天用来浏览网页的广域网(WAN)的名称。有时,Web 应用程序可能会很慢,而且它们通常比它们的桌面版本慢得多。这就是各种浏览器提供的开发者工具发挥作用的地方。
使用浏览器开发者工具,您有一些强大的功能来查看您的应用程序在幕后做了什么。各种浏览器提供的主要功能如下:
-
能够导航当前加载的网站元素以查看 HTML 结构、使用的样式、计算样式、布局、事件监听器、DOM 断点、属性和可访问性。
-
您可以查看控制台消息,包括任何抛出的错误消息。
-
您可以使用资源查看构成页面的所有内容,与本地文件系统同步更改,使用来自本地文件夹的文件覆盖页面资源,查看由扩展提供的内联脚本,以及创建和保存代码片段以供以后重用。
-
您可以使用网络选项卡记录和查看页面生成的网络流量,包括名称、状态、类型、发起者、大小、时间和瀑布图等信息。
-
您可以记录一个过程。这些信息可以非常详细,您可以保存屏幕截图,记录内存使用情况,并通过性能选项卡查看页面的网络核心指标。
-
您可以分析内存使用情况,并可以选择记录堆快照,按时间分配仪表,以及分配样本。
-
您可以在应用程序选项卡上查看和调试您应用程序的后台服务,包括它们的存储和缓存。
-
安全性,这使您能够查看您应用程序的主要来源和受保护来源,以及其安全信息,例如它是否有有效的SSL 证书。
来自不同厂商的每个浏览器都以微妙不同的方式工作。开发者们各自有他们偏好的浏览器和开发者工具集。在本节中,我们将使用Microsoft Edge 网络和性能标签页来分析网页的性能。要这样做,请按照以下步骤操作:
- 打开Microsoft Edge并按F12键打开开发者工具。应该会出现以下屏幕:

图 9.2 – Microsoft Edge 开发者工具显示默认标签页
-
点击网络标签页。
-
在地址框中,输入
docs.microsoft.com。
网站现在将开始加载。在加载过程中,您将看到生成的网络流量被记录下来。以下截图显示了按处理时间最长的资源排序的数据的一部分:

图 9.3 – Microsoft Edge 开发者工具的“网络”标签页显示网络流量数据
如您所见,网络标签页有助于查看已请求的资源(名称)、请求的状态和类型值、发起请求的发起者、请求的大小和处理时间,以及其在瀑布图上的视觉表示。这些信息可以应用于您的页面及其资源,以减少完整请求的整体大小并缩短完成请求所需的时间。
既然我们已经看到了网络标签页的实际应用,让我们看看性能标签页的实际应用。要这样做,请按照以下步骤操作:
-
点击性能标签页,然后点击记录按钮。
-
在地址栏中输入
docs.microsoft.com并按Enter键。 -
页面完全加载后,通过点击弹出对话框的停止按钮来停止记录。
刚刚捕获的配置文件现在将加载并展示给您。这个过程需要多长时间取决于您记录了多长时间以及产生了多少流量。
一旦配置文件加载完成,您应该会看到以下屏幕:

图 9.4 – docs.microsoft.com 的 Microsoft Edge 性能配置文件
您可能无法阅读前一个截图的内容。没关系 – 这个截图只是表示您可以使用性能分析器获取的数据量。您有截图、瀑布图、加载 URL 所使用的所有方法和属性的分解,以及按时间汇总的流量类型,如加载时间、脚本时间、渲染时间、绘制时间、系统时间和空闲时间。
你可以使用这些信息来找出请求中大部分时间被占用的地方,并识别消耗时间的方法。这将帮助你确定你的 Web 项目中可能需要性能改进的区域。
使用浏览器工具可以收集有关应用程序性能的大量信息。这里并没有涵盖所有这些信息。例如,由于本章的页面长度限制,我们甚至没有触及 Microsoft Edge 开发者工具中的内存分析选项卡。然而,你被积极鼓励亲自尝试网络浏览器开发工具中所有不同的功能,以帮助你分析和改进 Web 应用程序及其网络利用率。
现在我们已经学习了如何使用浏览器开发工具来分析由我们的应用程序请求和响应产生的互联网流量,让我们来看看性能增强的gRPC 远程过程调用(gRPC)框架,用于高速网络数据传输和通信。
使用 gRPC 进行高性能通信
什么是 gRPC?它是一个开源的RPC框架。应用程序使用 RPC 进行相互通信。gRPC 基于 HTTP/2 的现代技术构建,用于传输协议层,以及协议缓冲区(Protobuf)用于消息的序列化技术。Protobuf 还提供了一种语言中立的合同语言。
gRPC 的设计考虑了现代高性能和跨平台应用程序。有各种编程语言的实现。这使得在操作系统和不同编程语言上开发的应用程序能够相互通信。
gRPC 是一个有观点的合同优先框架,合同在proto 文件中定义。这个 proto 文件包含了你的 API 定义以及它们将发送和接收的消息。然后使用代码生成来为你的语言和平台生成强类型客户端和消息,在我们的案例中将是 C#和.NET。gRPC 的语言是二进制的,专为计算机设计。这使得 gRPC 比基于文本的 HTTP API 表现更好。在 gRPC 框架中,远程的复杂性被隐藏起来,程序员不需要手动完成的大部分工作都由代码生成工具完成。因此,你所需要做的就是调用客户端上的方法并等待结果。为了提高开发者的生产力和应用程序的性能,你最好使用 gRPC 而不是 HTTP API。
HTTP API 是内容优先,考虑 URL 的形状、HTTP 方法、JSON 和 XML 等。REST API 是代码优先。通常,您会先编写代码,然后生成 Swagger 或 RAML 合约。REST API 基于文本,因此可读性强。这使得它们在适当的工具的帮助下易于调试,但与 gRPC 相比,这些 API 的性能较慢。REST API 处理低级 HTTP,因此在 HTTP 请求、响应和路由方面需要考虑更多。这比使用 gRPC 更复杂,但最终您将获得高度的控制。因此,尽管 HTTP API 在性能上不是特别突出,但它们将吸引最广泛的开发者群体。它们可能更容易上手。然而,当您在开发复杂的企业软件时,它们可能会变得极其复杂和深层路由。
现在您已经了解了 gRPC 和 HTTP,您会欣赏到最快的网络和应用程序间通信将由 gRPC 而不是 HTTP 来完成。鉴于这本书是关于性能的,我们将现在通过一个简单的演示来展示 gRPC 的工作原理。
编程简单的 gRPC 客户端/服务器应用程序
在本节中,我们将构建一个返回单个消息的 gRPC 服务。然后,我们将编写一个客户端来调用 gRPC 服务,并更新我们的客户端和服务器,以便我们可以流式传输消息。让我们先编写我们的 gRPC 服务。
构建 gRPC 服务
在本节中,我们将使用Visual Studio构建一个 gRPC 服务。在本章的后面部分,我们将使用这个服务。要在 Visual Studio 中构建 gRPC 服务,请按照以下步骤操作:
-
打开 Visual Studio 并选择创建新项目。
-
搜索并选择 ASP.NET Core gRPC 服务模板,然后点击下一步。
-
在
CH09_GrpcService上点击创建。 -
接下来将显示附加信息页面。请确保从下拉菜单中选择最新的.NET Framework 版本;这应该是.NET 6.0。
-
点击
appsettings.json文件。 -
确保项目设置为启动项目,然后运行它。您应该会看到一个信任 ASP.NET Core SSL 证书对话框。点击是。
-
现在将弹出一个安全对话框,告知您即将安装安全证书。点击是以安装它。一旦证书安装完成,您的服务应该正在运行。gRPC服务的 URL 是
localhost:5000和localhost:5001。注意
如果 5000 和 5001 端口已被占用,您的系统上的端口可能不同。
-
在浏览器中输入 https://localhost:5001;您应该会收到以下消息:与 gRPC 端点的通信必须通过 gRPC 客户端进行。要了解如何创建客户端,请访问
go.microsoft.com/fwlink/?linkid=2086909。此消息通知我们下一步是为我们编写一个能够与该服务通信的客户端。
如此简单,就可以开始使用 gRPC 服务了。在Proto文件夹中打开greet.proto文件,并输入以下代码:
syntax = “proto3”;
option csharp_namespace = “CH09_GrpcService”;
package greet;
// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply);
}
// The request message containing the user’s name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings.
message HelloReply {
string message = 1;
}
如您所见,proto 语言很简单。在这个文件中,我们声明了语言的语法、程序集命名空间和包名。然后,我们提供了一个服务定义,它定义了 RPC 请求和响应,随后是请求和响应消息。
注意
在底层进行了大量的代码生成。因此,如果您想知道某些文件的位置,您将在您的Obj\Debug\net6.0\Protos文件夹中找到它们隐藏起来。
由于我们使用 gRPC 为我们提供服务,我们需要一个客户端。因此,在下一节中,我们将构建我们的客户端。
构建 gRPC 客户端
在本节中,我们将添加一个 gRPC 客户端项目,该项目将消费我们的 gRPC 服务。此外,对于我们的客户端项目,我们将编写一个简单的控制台应用程序。要添加客户端项目,请按照以下步骤操作:
-
开始一个新的名为
CH09_GrpcServiceClient的.NET 6.0 控制台应用程序项目,并将目标框架更改为.NET 6.0。 -
在解决方案资源管理器中,右键单击项目的服务依赖项节点,并选择添加连接服务菜单选项。这将向您展示以下选项卡:
![图 9.5 – Visual Studio 中的连接服务选项卡]()
图 9.5 – Visual Studio 中的连接服务选项卡
- 在服务引用(OpenAPI,gRPC)部分下点击添加按钮。这将弹出添加服务引用对话框,如图所示:

图 9.6 – Visual Studio 中的添加服务引用对话框
- 点击gRPC选项,然后点击下一步按钮。前面截图中的向导对话框将移动到添加新 gRPC 服务引用页面,如图所示:

图 9.7 – 添加服务引用对话框中的添加新 gRPC 服务引用页面
-
在您的 gRPC 服务项目中点击
greet.proto文件,并选择它。确保从下拉列表中选择客户端选项。然后,点击完成。 -
对话框将变为 服务引用配置进度。当你收到显示 成功添加服务引用 的消息时,点击 关闭 按钮。你的 gRPC 连接服务现在将出现在 连接服务 选项卡的 服务引用 部分中,如图所示:
![图 9.8 – 显示我们连接的 gRPC 服务的“连接服务”选项卡]

图 9.8 – 显示我们连接的 gRPC 服务的“连接服务”选项卡
这样,你就已经将一个客户端项目添加到了你的 gRPC 服务中。客户端项目添加后,我们现在可以编写控制台应用程序。按照以下步骤操作:
-
通过在解决方案资源管理器中选择它来打开
CH09_GrpcServiceClient.csproj文件。你应该会看到以下 XML:<Project Sdk=”Microsoft.NET.Sdk”> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net6.0</TargetFramework> </PropertyGroup> <ItemGroup> <PackageReference Include=”Google.Protobuf” Version=”3.13.0” /> <PackageReference Include=”Grpc.Net.ClientFactory” Version=”2.32.0” /> <PackageReference Include=”Grpc.Tools” Version=”2.32.0”> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> </ItemGroup> <ItemGroup> <Protobuf Include=”..\CH09_GrpcService\Protos\greet.proto” GrpcServices=”Client”> <Link>Protos\greet.proto</Link> </Protobuf> </ItemGroup> </Project>
在前面的 XML 代码中,你可以看到对 Google 的 Protobuf 库和 gRPC 库的引用。你还会看到一个指向 gRPC 服务中你的 proto 文件的 Protobuf 链接,这表明你的项目正在承担客户端的角色。
-
在你的客户端项目中打开
Program类。 -
更新
Main(string[] args)方法,如下所示:static async void Main(string[] args) { await ExecuteGrpcClient();}
在我们的入口点方法中,我们调用异步的 ExecuteGrpClient() 方法。然而,因为我们不能将我们的主方法标记为异步,所以我们不得不在 ExecuteGrpcClient() 方法上调用 Wait():
tatic async Task ExecuteGrpcClient()
{
GrpcChannel grpcChannel =
GrpcChannel.ForAddress(“https://localhost:5001”);
Greeter.GreeterClient greeterClient =
new Greeter.GreeterClient(grpcChannel);
HelloReply helloReply =
await greeterClient.SayHelloAsync(new HelloRequest
{
Name = “gRPC Demonstration!”
});
Console.WriteLine(
$”Message From gRPC Server: {helloReply.Message}”);
}
因为我们将等待异步调用,所以我们必须使用异步修饰符将 ExecuteGrpcClient() 方法声明为异步。此方法不返回任何内容。然而,它不能声明为 void,因此我们必须提供 Task 作为返回类型。然后,我们必须通过指向我们的 gRPC HTTPS 地址来声明我们的 gRPC 通道。然后,我们必须通过传递我们刚刚声明和初始化的 gRPC 通道来声明我们的客户端。接下来,我们必须通过等待对服务器方法的异步调用并传递一个消息请求(我们根据需要设置属性)来获取一个回复。最后,我们必须将服务器的响应打印到控制台窗口。
-
在终端中打开服务器项目,并输入
dotnet run。服务器将本地运行在端口 5001 上。 -
然后,在终端窗口中打开客户端项目,并输入
dotnet run。它将在控制台窗口中打印以下消息:
Message From gRPC Server: Hello gRPC Demonstration!
通过这样,你已经成功编写了一个 gRPC 服务器,并通过编写和运行 gRPC 客户端来消费其消息。那么这又意味着什么呢?这对你意味着你现在有了在不同应用程序之间使用通用协议进行通信的跨平台方式。在这方面有什么大不了的?嗯,假设你有一些用各种语言编写的遗留应用程序,并且你想要将它们全部迁移到一个通用的平台和编程语言,比如 .NET 或 C# – 你现在有了一种直接完成此任务的方法。
通过使用 gRPC,你可以提供从遗留平台到.NET 5 及以上平台和 C# 9 及以上编程语言的逐步迁移。你会通过为你的.NET 客户端和遗留客户端编写 gRPC 客户端来实现这一点。这将使你能够在逐步替换旧系统的同时开始使用.NET 和 C#。然后,随着旧系统被一个现代系统逐步取代,你可以充分利用.NET 和 C#,并从微软团队对语言和框架所做的所有性能改进中受益。此外,你可以利用使用微软生态系统的所有业务和性能优势,这包括以安全性、可扩展性和性能为设计目标的微软 Azure 云服务。
在这一点上,值得注意 gRPC 官方支持的各种语言。官方支持的语言、操作系统、编译器和 SDK 如下表所示:

表 9.3 – gRPC 官方支持的语言
如我们所见,gRPC 在多种语言、操作系统、SDK 和编译器方面都得到了良好的支持。因此,gRPC 是使用一个和谐的消息框架将不同的系统结合起来的完美网络技术。
到目前为止,你已经消费了一个单一请求,并且知道 gRPC 可以与各种操作系统和编程语言一起使用。但是,如果你需要处理一整批 gRPC 请求呢?我们该如何做?这是一个好问题。我们将在下一节中学习如何做到这一点。
流式传输多个 gRPC 请求
在本节中,我们将修改我们的客户端和服务器 gRPC 项目以发送和处理消息流。到项目结束时,你将从服务器发送 10 条消息到客户端。在客户端,你将处理每个到达的消息并将其写入控制台窗口。为此,请按照以下步骤操作:
-
更新
CH09_GrpcService项目的greet.proto文件,如下所示:// The greeting service definition. service Greeter { // Sends a greeting rpc SayHello (HelloRequest) returns (HelloReply); rpc SayHelloStream(HelloRequest) returns (stream HelloReply); }
你将看到你已经为我们服务的定义添加了一个新的消息流。而不是返回一个单一的HelloReply消息,消息流返回一个HelloReply类型的消息流。
-
在
CH09_GrpcServer项目的GreeterService类中,添加以下方法:public override async Task SayHelloStream(HelloRequest request, IServerStreamWriter<HelloReply> responseStream, ServerCallContext context) { for (int i = 0; i < 10; i++) { await responseStream.WriteAsync(new HelloReply { Message = $”Response Stream Message: {i}” }); await Task.Delay(TimeSpan.FromSeconds(1)); } }
在这个方法中,你将迭代 10 次。对于每次迭代,你等待responseStream.WriteAsync(HelloReply)方法。在这个异步调用中,你将消息设置在HelloReply对象上。由于每次迭代只需毫秒级,你将故意减慢任务的处理速度,以便人类眼睛可以看到一个方法接一个方法地写入。这种延迟将你的任务减慢了 10 秒——每次迭代一秒。在正常的应用程序中,你通常不会设置这样的延迟。
-
现在你已经更新了你的服务器项目,请重新构建两个项目以查看更改,并移动到你的
CH09_GrpcServiceClient项目。 -
在
Program类中,将ExecuteGrpcClient()方法内的代码移动到其自己的方法SingleGrpcMessageClient()中。然后,将以下两行代码添加到ExecuteGrpcClient()方法中:await SingleGrpcMessageResponse(); await GrpcMessageResponseStream();
上述代码包含两个异步调用:一个用于单个消息,另一个用于流式传输多个消息。
-
添加
GrpcMessageResponseStream()方法:static async Task GrpcMessageResponseStream() { GrpcChannel grpcChannel = GrpcChannel.ForAddress(“https://localhost:5001”); Greeter.GreeterClient greeterClient = new Greeter.GreeterClient(grpcChannel); AsyncServerStreamingCall<HelloReply> helloReply = greeterClient.SayHelloStream(new HelloRequest { Name = “gRPC Streaming Demonstration!” }); await foreach (HelloReply item in helloReply.ResponseStream.ReadAllAsync()) { Console.WriteLine(item.Message); } }
GrpcMessageResponseStream() 创建一个 GrpcChannel 并将其分配给一个新的客户端。然后调用一个 gRPC 流。这会遍历从服务器发送回客户端的所有流项目,并将每个项目的消息打印到控制台窗口。
-
在各自的终端中打开每个项目,并输入
dotnet run命令。这将启动服务器并运行客户端。你应该看到以下控制台窗口输出:Message From gRPC Server: Hello gRPC Demonstration! Response Stream Message: 0 Response Stream Message: 1 Response Stream Message: 2 Response Stream Message: 3 Response Stream Message: 4 Response Stream Message: 5 Response Stream Message: 6 Response Stream Message: 7 Response Stream Message: 8 Response Stream Message: 9
你现在知道如何将 gRPC 用于桌面应用程序。在下一节中,你将学习如何将 gRPC 用于 Blazor。
编写一个简单的 gRPC Blazor 应用程序
Blazor 是一种网络编程模型。使用 Blazor,你可以拥有服务器端 Blazor 项目,这些项目是你需要编写以保护敏感信息不被泄露时使用的。当应用程序性能至关重要时,你可以拥有客户端 Blazor 项目。作为组织的企业应用程序的一部分,你会有许多不同的 Blazor 服务器端和客户端应用程序协同工作。
为了使 gRPC 能够与网络项目一起工作,已经开发了一个名为 gRPC-Web 的包装器。这使得你可以拥有 gRPC-Web 服务和 gRPC-Web 客户端。使用 gRPC-Web,可以构建与 HTTP/1.1 和 HTTP/2 协议兼容的端到端管道。这为无法调用 gRPC HTTP/2 的浏览器 API 提供了竞争优势,特别是当你考虑到并非所有 .NET 平台都通过 HttpClient 类支持 HTTP/2 时。gRPC-Web 的另一个好处是,你不必仅使用 TCP 进行进程间通信(IPC)。对于 IPC,你也可以使用命名管道(UDP)和Unix 域套接字(UDS)。
注意
Blazor 的默认模板应用程序有一个使用 JSON 作为其数据后端的数据获取页面。该 JSON 文件的数据大小为 627 字节。但是当 JSON 被替换为 gRPC 时,数据大小减少到 309 字节。这个例子表明,使用 gRPC-Web 进行数据传输比使用 JSON 快,因为网络传输和接收的数据量较少。使用 gRPC-Web 传输数据大小的减少意味着在请求需要被节流之前,可以在网络上发送更多的请求。
在 .NET 6.0 中,应用程序通过积极的剪枝来减小体积。您可以积极剪枝基于 gRPC 的应用程序以减小其体积并提高其性能,尤其是在发送网络数据时。这是因为 gRPC 内置的代码生成功能。
在 Web 项目中,无法直接访问 gRPC。因此,引入了一个名为 gRPC-Web 的代理项目,以使 Web 项目能够使用 gRPC。
在以下章节中,我们将编写一个 Blazor 客户端和服务器 gRPC 应用程序,该应用程序由一个 Blazor 服务器应用程序和一个 Blazor WebAssembly 应用程序组成。让我们开始。
这是我们的空白解决方案
我们需要从一个空白解决方案开始:
-
打开 Visual Studio 并搜索
Blank Solution。 -
创建一个空白解决方案并将其命名为
CH09_BlazorGrpc。
这将提供一个空白解决方案,我们可以向其中添加我们的客户端和服务器 Blazor 应用程序。接下来,我们将处理我们的客户端项目。
Blazor 客户端项目
在本节中,我们将构建我们的 Blazor 客户端 gRPC 应用程序。按照以下步骤操作:
-
添加一个新的
CH09_BlazorGrpc.Client。 -
添加以下 NuGet 包:
-
Google.Protobuf -
Grpc.Net.Client -
Grpc.Net.Client.Web -
Grpc.Tools
-
-
添加一个名为
Protos的文件夹,并在该文件夹中添加一个名为person.proto的文件。 -
打开
person.proto文件并添加以下代码:syntax = “proto3”; option csharp_namespace = “CH09_BlazorGrpc.Client”; package grpcpeople; service Person { rpc GetPeople (PeopleRequest) returns (PeopleResponse); } message PeopleRequest { } message PeopleResponse{ repeated PersonResponse people = 1; } message PersonResponse { string name = 1; }
我们的 proto 文件定义了 proto 定义版本为 proto3。因此,将使用 proto3 语法。我们的服务定义的命名空间是 CH09_BlazorGrpc.Client。我们给我们的包取的名字是 grpcpeople。有三个消息名为 PeopleRequest、PeopleResponse 和 PersonResponse。最后,我们定义我们的服务为 Person,并有一个名为 GetPeople 的 RPC,它接受一个 PeopleRequest 并返回一个 PeopleResponse。
-
将以下导入添加到
_Imports.razor文件中:@using CH09_BlazorGrpc.Client @using CH09_BlazorGrpc.Client.Shared @using Grpc.Net.Client; @using Grpc.Net.Client.Web;
这些导入将适用于所有我们的文件。
-
定位到
Pages/Index.razor页面,并用以下代码替换其内容:@page “/” @using CH09_BlazorGrpc.Client <PageTitle>Index</PageTitle> <h1>People from Grpc Service</h1> @foreach(var person in model.People) { <p>Name : @person.Name</p> } @code{ private PeopleResponse model = new PeopleResponse(); protected override async Task OnInitializedAsync() { using var channel = GrpcChannel.ForAddress (“https://localhost:7272/”, new GrpcChannelOptions { HttpHandler = new GrpcWebHandler(new HttpClientHandler()) }); var client = new Person.PersonClient (channel); model = await client.GetPeopleAsync( new PeopleRequest { }); } }
上述代码将调用由服务应用程序定位的 gRPC 服务,并列出返回的人员。
那就是我们的客户端应用程序完成了。现在,让我们编写我们的服务器应用程序。
Blazor 服务器项目
在本节中,我们将编写我们的服务器应用程序,它将包含负责向客户端返回请求数据的服务的应用程序。让我们开始:
-
添加一个新的 Blazor 服务器应用程序,名为
CH09_BlazorGrpc.Server。 -
添加
Grpc.AspNetCore和Grpc.AspNetCore.WebNuGet 包。 -
从客户端项目复制
Protos文件夹及其内容,并将其粘贴到服务器项目中。 -
将
PeopleService类添加到服务器项目的根目录。 -
将
PeopleService类的内容替换为以下代码:namespace CH09_BlazorGrpc.Server; using Grpc.Core; using CH09_BlazorGrpc.Client; public class PeopleService : Person.PersonBase { public override async Task<PeopleResponse> GetPeople(PeopleRequest request, ServerCallContext context) { PeopleResponse response = new PeopleResponse(); response.People.Add(new PersonResponse { Name = “Person One” }); response.People.Add(new PersonResponse { Name = “Person Two” }); response.People.Add(new PersonResponse { Name = “Person Three” }); return response; } }
此服务有一个返回人员列表的单个方法。
-
将
Program.cs文件中的代码替换为以下内容:using CH09_BlazorGrpc.Server; var builder = WebApplication.CreateBuilder(args); builder.Services.AddGrpc(options => { options.EnableDetailedErrors = true; options.MaxReceiveMessageSize = 2 * 1024 * 1024; // 2 MB options.MaxSendMessageSize = 5 * 1024 * 1024; // 5 MB }); builder.Services.AddCors(setupAction => { setupAction.AddDefaultPolicy(policy => { policy.AllowAnyHeader().AllowAnyOrigin() .AllowAnyMethod() .WithExposedHeaders(“Grpc-Status”, “Grpc-Message”, “Grpc-Encoding”, “Grpc-Accept-Encoding”); }); }); var app = builder.Build(); app.UseCors(); app.UseRouting(); app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true }); app.UseEndpoints(endpoints => { endpoints.MapGrpcService<PeopleService>(); }); app.Run();
上述代码配置了我们的 Blazor 应用程序以使用 gRPC,并设置了我们的PeopleService类,以便我们的客户端应用程序可以使用它。我们还配置了Cors,以便我们的 gRPC 请求和响应不会被阻止。
-
右键单击解决方案并选择属性。
-
在启动项目下,选择多个启动项目,并将客户端和服务器项目的操作都更改为启动。
-
点击确定关闭属性对话框。
运行项目。应该打开两个浏览器窗口和两个控制台窗口。如果一切顺利,您应该看到以下浏览器窗口:

图 9.9 – 客户端 Blazor 应用程序显示服务器应用程序中 gRPC 服务的响应
注意
端口号取决于系统上可用的端口号。因此,如果端口 5000 和 5001 已被占用,将使用备用端口。这里就是这样,服务器应用程序正在使用端口 7272,客户端应用程序正在使用端口 7108。
通过这些,您已经了解了使用 gRPC 和 gRPC-Web 在桌面和基于 Web 的网络数据传输和通信,这些都与 C#和.NET Framework 一起获得了多项性能提升。您还使用了 Blazor 服务器和 Blazor WebAssembly 来执行 Web 数据传输并接收数据。
您可以使用此信息将使用 JSON 数据格式的代码替换为 gRPC 的二进制格式。这应该会减少您的数据传输大小,并减少数据传输和接收所需的时间,从而提高您网络应用程序的性能——尤其是处理大量数据的应用程序。
优化互联网资源
最佳网页是完成所需的最小工作来展示您希望用户查看的必要数据的网页。嘈杂的网页加载时间更长,可能会成为您最终用户的烦恼来源。
当您使用广告服务、分析和健康监控服务时,这些服务可能会产生不必要的网络流量并增加页面加载时间。因此,您需要简洁地收集有关正在加载的页面的数据。您还需要减少页面下载的资源数量。其中一些资源将在下面进行解释。
图片
图片是能够显著增加页面加载时间的资源之一。因此,使用正确的图片格式和压缩对图片进行优化非常重要。通常需要减小图片的文件大小。图片通常有三种文件格式:JPEG/JPG、PNG和GIF/动画 GIF。在图像优化方面,最好根据您网站的需求进行实验。这是因为您需要根据具体需求权衡图像质量和图像大小之间的折衷。
您可以使用 Ben Hollis 的 PNGGauntlet 工具进行 PNG 优化:pnggauntlet.com/。该工具通过结合 PNGOUT、OptiPNG 和 DeflOpt,在不损失图像质量的情况下创建小的 PBGs。它还可以将 JPG、GIF、TIFF 和 BMP 文件格式转换为 PNG。您可以根据自己的喜好配置该工具。
文本字符
在互联网上传输文本时,字符越多,文件就越大。随着页面的增长,加载该页面的时间也会增加。您可以通过启用 deflate 或gzip压缩来减小每个请求和响应的大小。大多数,如果不是所有,Web 服务器都提供 Web 压缩。您需要查看如何启用您所使用的 Web 服务器中的 Web 压缩。
您还可以通过使用压缩来减小生产中 HTML、CSS 和 JavaScript 文件的大小。在开发过程中,当您达到准备部署应用程序的阶段时,您可以采用 webpack 等工具,通过删除不必要的空白、注释和未使用的代码来压缩您的文件。像 webpack 这样的工具可以显著减小文件的大小。
这种尺寸减小导致通过网络传输的数据量减少,这意味着用户请求的文件将更快地下载到他们的设备上。请求的文件下载到用户设备越快,请求的页面渲染给用户查看的速度就越快。
数据传输
在网络上传输数据需要时间。这个时间可以根据多个不同的因素而变化,例如网络流量和所选择的路径。并非所有网络都使用光纤,互联网上仍然有一些位置仍然使用慢速铜线连接。
减少网络流量和网络资源加载时间的一种方法是在请求资源的用户计算机上对其进行缓存。当请求网络资源时,应用程序将检查它是否存在于缓存中。如果存在,则将从用户计算机上的缓存中检索该项。但如果项不在缓存中,它将通过网络下载并存储在用户的缓存中。当从缓存中检索项时,将检查资源的过期日期和时间。如果过期日期和时间已到达,则资源将从网络上下载。
此外,当处理大量数据时,最好在服务器上过滤数据,并且只返回所需的数据子集。如果您需要的数据量相当大,则应使用数据分页,即将数据分成页。然后,您只需在请求时下载一页。这减少了在请求后接收数据所需的时间。
使用管道进行内容流式传输
System.IO.Pipelines 是一个高性能的 I/O .NET 库,它首次随 .NET Core 2.1 一起发布,并起源于 Kestrel 团队进行性能工作时的成果。管道背后的目的是减少正确解析流和套接字数据的复杂性。
在本节中,我们将学习如何使用管道与套接字。我们将编写小型控制台应用程序。第一个控制台应用程序将监听端口 7000 上的传入请求并将内容输出到控制台窗口。第二个控制台应用程序将监听换行键。当检测到该键时,它将命令行内容发送到端口 7000 上的服务器。通过完成此项目,您将看到使用管道和套接字编写网络通信应用程序是多么简单,只需极少的代码行。
让我们从编写我们的服务器控制台应用程序开始。
编写和运行 TCP 服务器控制台应用程序
在本节中,我们将使用套接字和管道编写一个控制台应用程序,该程序监听端口 7000 上的传入数据。当接收到数据时,它将被处理并输出到控制台窗口。要编写 TCP 服务器控制台应用程序,请按照以下步骤操作:
-
启动一个新的 .NET 6.0 控制台应用程序,名为
CH09_TcpServer。 -
添加
System.IO.PipelinesNuGet 包。 -
添加一个名为
SocketExtensions的新类:using System; using System.Net.Sockets; using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; internal static class SocketExtensions { }
这是我们的 SocketExtensions 类,我们将通过扩展方法构建它以简化我们的套接字代码。
-
添加
ReceiveAsync扩展方法:public static Task<int> ReceiveAsync(this Socket socket, Memory<byte> memory, SocketFlags socketFlags) { ArraySegment<byte> arraySegment = GetArray(memory); return SocketTaskExtensions.ReceiveAsync(socket, arraySegment, socketFlags); }
此方法扩展套接字以界定一维数组的一部分。它从连接的套接字接收数据并返回一个 Task,该 Task 表示异步接收操作。
-
添加
GetString扩展方法:public static string GetString(this Encoding encoding, ReadOnlyMemory<byte> memory) { ArraySegment<byte> arraySegment = GetArray(memory); return encoding.GetString(arraySegment.Array, arraySegment.Offset, arraySegment.Count); }
此方法扩展套接字以界定一维数组的一部分。然后,它将一系列字节解码成字符串并返回解码后的字符串。
-
添加
GetArray方法:private static ArraySegment<byte> GetArray(Memory<byte> memory) { return GetArray((ReadOnlyMemory<byte>)memory); }
此方法获取连续的内存并返回一维数组的一个分隔部分。
-
添加最终的扩展方法 – 即
GetArray:private static ArraySegment<byte> GetArray (ReadOnlyMemory<byte> memory) { if (!MemoryMarshal.TryGetArray(memory, out var result)) { throw new InvalidOperationException(“Buffer backed by array was expected”); } return result; }
此方法尝试从底层内存缓冲区获取一个段。返回值表示操作的成功。返回一个一维数组的分隔段。
-
切换到
Program类。 -
将
Program.cs文件的源代码替换为以下代码:using CH09_TcpServer; using System; using System.Buffers; using System.IO.Pipelines; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading.tasks; Socket listenSocket = new Socket(SocketType.Stream, ProtocolType.Tcp); listenSocket.Bind(new IPEndPoint(IPAddress.Loopback, 7000)); Console.WriteLine(“Listening on port 7000”); listenSocket.Listen(120); while (true) { Socket socket = await listenSocket.AcceptAsync(); _ = ProcessLinesAsync(socket); }
我们的最顶层代码在端口 7000 上创建一个套接字。然后,它监听端口 7000 上的传入数据并处理数据。
-
添加
ProcessLinesAsync方法:tatic async Task ProcessLinesAsync(Socket socket) { Console.WriteLine($”[{socket.RemoteEndPoint}]: connected”); NetworkStream stream = new NetworkStream(socket); PipeReader reader = PipeReader.Create(stream); while (true) { ReadResult result = await reader.ReadAsync(); ReadOnlySequence<byte> buffer = result.Buffer; while (TryReadLine(ref buffer, out ReadOnlySequence<byte> line)) ProcessLine(line); reader.AdvanceTo (buffer.Start, buffer.End); if (result.IsCompleted) break; } await reader.CompleteAsync(); Console.WriteLine($”[{socket.RemoteEndPoint}]: disconnected”); }
使用此方法,我们传递一个套接字。套接字被分配给一个新的 NetworkStream 对象。然后,新的 NetworkStream 对象被传递给一个新的 PipeReader 对象。当有数据要读取时,我们依次读取并处理流中的每一行。一旦从开始到结束完全读取了流,我们将读取器标记为完成,这样就不会再从它那里读取更多数据。
-
现在,添加
TryReadLine方法:static bool TryReadLine(ref ReadOnlySequence<byte> buffer, out ReadOnlySequence<byte> line) { SequencePosition? position = buffer.PositionOf ((byte)’\n’); if (position == null) { line = default; return false; } line = buffer.Slice(0, position.Value); buffer = buffer.Slice(buffer.GetPosition (1, position.Value)); return true; }
此方法尝试读取一个 ReadOnlySequence 字节的行。如果不能,它将返回 false。但如果可以,它将设置的行作为 ReadOnlySequence 字节返回并返回 true。
-
添加我们 TCP 服务器最后的
ProcessLine方法:static void ProcessLine(in ReadOnlySequence<byte> buffer) { foreach (ReadOnlyMemory<byte> segment in buffer) { Console.Write(Encoding.UTF8.GetString(segment.Span)); } Console.WriteLine(); }
我们在这里所做的只是逐行将流的内容打印到控制台窗口。
- 运行程序。你应该看到以下类似的内容:


图 9.11 – TCP 客户端监听 7000 端口
- 输入
Hello, World!并按Enter。你的 TCP 客户端控制台应用程序应该看起来如下:
![图 9.12 – 显示用户输入的 TCP 客户端控制台窗口
![图 9.13 – 显示 TCP 客户端响应的 TCP 服务器控制台窗口
图 9.12 – 显示用户输入的 TCP 客户端控制台窗口
- 观察 TCP 服务器控制台窗口。你会看到自你在 TCP 客户端窗口中输入相同的信息并按Enter以来,消息Hello, World!已经出现,如下所示:
![图 9.13 – 显示 TCP 客户端响应的 TCP 服务器控制台窗口
![图 9.13 – 显示 TCP 客户端响应的 TCP 服务器控制台窗口
图 9.13 – 显示 TCP 客户端响应的 TCP 服务器控制台窗口
因此,你已经完成了 TCP 客户端和服务器控制台应用程序的编写和运行,你也看到了使用套接字和管道编写控制台应用程序是多么简单。代码非常简洁,你可以将多个管道链接在一起。例如,在客户端,一个链式管道可以是对象的序列化,然后是加密。然后,在服务器端,数据可以被解密和反序列化,然后生成的对象可以传递给 LINQ,这将保存对象中包含的数据到数据库。我们可以使用套接字和管道与大多数 C#项目类型一起使用,并鼓励你通过自己的小项目进行实验,以进一步扩展你的知识。
在内存中缓存资源
缓存内存中的项目需要分配 RAM,以便它们可以高效地存储和检索。将频繁访问的资源存储在内存中可以显著提高应用程序的性能。
从缓存中受益的典型应用程序是网站。一个传统的网站将包括 HTML 页面,它定义了显示给最终用户的视觉网页的结构,CSS,它为页面添加样式并使其看起来很漂亮,以及 JavaScript,它使网站变得动态和交互式。
网站的许多页面可以使用相同的资源,例如数据、图像、声音、文件和对象。缓存(暂时存储某些项目以便可以高效检索)可以使用数据库、文件系统或内存来完成。
在本节中,我们将学习如何在内存中存储项目。Microsoft 建议使用他们的Microsoft.Extensions.Caching.Memory NuGet 包来在内存中缓存项目。因此,我们将遵循他们的指导,并在我们的示例项目中使用这个库。
我们将创建一个非常简单的 ASP.NET Core 网站,该网站显示当前时间和缓存时间。当缓存时间已过期时,我们将重置缓存。每次调用主页视图时,我们将在显示当前时间、缓存时间和秒数差异的即时窗口中输出一些文本。
在每个指定的时间周期结束后,你会看到缓存被重置,以及页面刷新后屏幕上输出的时间。要编写我们的 ASP.NET Core MVC 网络应用程序,请按照以下步骤操作:
-
启动一个新的空 ASP.NET Core MVC 网络应用程序,确保你的目标框架是
net6.0,并命名为CH09_AspNetCoreCaching。 -
添加
Microsoft.Extensions.Caching.MemoryNuGet 包,并将此包的using语句添加到HomeController类中。 -
添加一个
IMemoryCache成员变量,并按如下方式更新HomeController构造函数:private IMemoryCache _memoryCache; public HomeController(ILogger<HomeController> logger, IMemoryCache memoryCache) { _logger = logger; _memoryCache = memoryCache; }
我们的 _memoryCache 变量将持有我们的内存缓存。用作我们内存缓存的对象被注入到 HomeController 构造函数中作为参数,并分配给我们的变量。
-
接下来,添加
SetCache方法:private void SetCache(string key, object value) { var cachedEntryOptions = new MemoryCacheEntryOptions() .SetSlidingExpiration(TimeSpan.FromSeconds(20)); _memoryCache.Set(key, value, cachedEntryOptions); }
此方法接受一个键和一个值。我们使用滑动过期时间为 20 秒的 MemoryCacheEntryOptions,然后设置缓存条目的值,该值将在 20 秒后过期。
-
下一步需要做的是更新
HomeController构造函数的Index方法,如下所示:public IActionResult Index() { DateTime whenCached; bool exists = _memoryCache.TryGetValue(“WhenCached”, out whenCached); if (!exists) { Debug.WriteLine(“Creating cached entry...”); whenCached = DateTime.Now; SetCache(“WhenCached”, whenCached); } else { DateTime now = DateTime.Now; double differenceInSeconds = now.Subtract(whenCached).TotalSeconds; if (differenceInSeconds < 20) { Debug.WriteLine($”Now: {now}, When Cached: {whenCached}, Time Difference (Seconds): {differenceInSeconds}”); return View(whenCached); } else { Debug.WriteLine(“Resetting cache...”); whenCached = DateTime.Now; SetCache(“WhenCached”, whenCached); } } return View(whenCached); }
之前的代码声明了一个名为 whenCached 的 DateTime 变量。它检查该值是否存在。如果存在,其值将被设置为变量被缓存时的时间。如果变量不存在,则将其添加到缓存中。如果它确实存在,那么将计算现在和变量被缓存时的时间差,如果缓存未过期,则结果将输出到调试窗口。如果缓存已过期,则缓存变量将使用当前时间进行更新。
-
现在,我们需要更新我们的主页的 HTML 代码,如下所示:
@model DateTime? @{ ViewData[“Title”] = “Index”; } <h1>Index</h1> <div class=”row”> <span> When Cached: @Model.Value.ToString(); </span> <span> Current Time: @DateTime.Now.ToString(); </span> </div>
之前的代码定义了我们的 Razor 页面的模型。我们的页面标题被设置为 Index。我们的主页标题是 Index。最后,我们有一行定义了变量被缓存的时间和当前时间。
-
现在,我们需要更新我们的
Program.cs文件,以通知我们的网站使用内存缓存:builder.Services.AddControllersWithViews(); builder.Services.AddMemoryCache();
这样,我们的服务已经配置为使用内存缓存。
这样,我们已经配置了我们的 MVC 应用程序以使用具有滑动过期的内存缓存。这意味着我们现在可以运行我们的项目。运行项目,在 20 秒内刷新几次,然后观察发生了什么。你会看到缓存的和当前的时间开始相同。然后,当你刷新页面时,你会看到缓存的时间保持不变,但当前时间领先于缓存时间。然后,当 20 秒过后,缓存时间将与当前时间同步更新,如下所示:

图 9.14 – ASP.NET Core MVC 内存缓存示例在实际中的应用
如前述截图和运行代码所示,我们现在有了一种在计算机内存缓存中存储项目的方法,并且可以确定其缓存值何时过期并需要更新。这是一种真正简单的方法来提高网络应用程序的网络性能。它还减少了通过网络传输的数据量。这反过来有助于减少带宽问题,并降低云托管操作的事务和网络流量成本。
这就结束了本章的内容。现在,让我们总结一下通过本章的学习我们所获得的知识。
摘要
在本章中,你学习了 OSI 参考模型,以了解网络的不同层次以及每个层次可用的各种协议。你还了解到,各种协议可以分为两大类:TCP 和 UDP。
然后,你学习了关于网页浏览器开发工具的内容,这些工具允许你监控你网站的各项活动,例如内存使用和网络流量。你还可以通过控制台窗口看到它抛出的错误。这有助于识别问题并解决它们。
从那里,你学习了如何为桌面客户端和服务器添加 gRPC,以及为基于 Web 的客户端和服务器添加 gRPC-Web。你了解到,与 JSON 数据格式相比,gRPC 有助于减少数据大小,从而减少页面加载时间。
之后,你学习了如何优化互联网资源。这包括使用正确的文件格式,减少图像大小,缓存项目以减少网络流量和加载时间,减少正在运行的背景服务数量,以及限制页面加载的资源数量。你还考虑了在服务器上过滤数据并将其分成请求返回的页面。
最后,在学习内存缓存之前,你学习了如何编写和运行 TCP 客户端和服务器控制台应用程序,其中你可以使用 ASP.NET Core MVC 作为你的宿主项目。
在下一章中,我们将通过基准测试不同的数据插入、更新和删除方法来处理数据。这将帮助我们根据基准测试结果选择最佳的数据操作方法。但在我们这样做之前,花些时间浏览进一步阅读部分,以进一步了解提高网络性能的知识。同时,尝试回答问题以了解你保留了多少知识。
问题
回答以下问题以测试你对本章知识的掌握:
-
请列出 OSI 参考模型的七个层次。
-
请列举一些网络协议。
-
TCP/IP 和 UDP 之间有什么区别?
-
你如何查看你的网页产生的错误,它产生的网络流量以及它使用的内存量?
-
gRPC 和 gRPC-Web 是什么?
-
你如何优化互联网资源?
进一步阅读
要了解更多关于本章所涉及主题的信息,请查看以下资源:
-
TCP/IP 模型:
ipcisco.com/lesson/tcp-ip-model/ -
TCP 和 UDP 端口号列表:
en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers -
dotnet-trace 指令:
github.com/dotnet/diagnostics/blob/master/documentation/dotnet-trace-instructions.md -
如何在 Mac OS X 的终端中查看和终止进程:
www.chriswrites.com/how-to-view-and-kill-processes-using-the-terminal-in-mac-os-x/ -
在 Linux 中使用 PID 号查找进程名称:
www.tecmint.com/find-process-name-pid-number-linux/ -
使用 gRPC 的高性能服务 – .NET 5 中的新功能:
www.youtube.com/watch?v=EJ8M2Em5Zzc -
gRPC-Web 与 .NET:
www.youtube.com/watch?v=UV-VnlcpDhU -
.NET Conf 2021 .NET 6 中新的 Blazor WebAssembly 功能:[
www.youtube.com/watch?v=kesUNeBZ1Os&list=PLdo4fOcmZ0oVFtp9MDEBNb A2sSqYvXSXO&index=20](https://www.youtube.com/watch?v=kesUNeBZ1Os&list=PLdo4fOcmZ0oVFtp9MDEBNb A2sSqYvXSXO&index=20) -
.NET Conf 2021 使用 gRPC 的高性能服务 – .NET 6 中的新功能:
www.youtube.com/watch?v=CXH_jEa8dUw&list=PLdo4fOcmZ0oVFtp9MDEBNbA2sSqYvXSXO&index=31 -
关于 Blazor 的一切:
codewithmukesh.com/blog/category/dotnet/blazor/
第十章:第十章:设置我们的数据库项目
在本章和接下来的两章中,我们将提高基于数据库的应用程序的性能。在本章中,我们将设置我们的关系数据库和访问该数据库的代码。在下一章中,我们将编写基准测试来测试不同框架的性能,这些框架包括 Entity Framework、Dapper 和 ADO.NET。最后,在第十二章 响应式用户界面中,我们将学习如何提高 SQL Server 和 Cosmos DB 的性能。
数据在我们的日常生活中被广泛使用。在当今大数据的世界中,收集和存储用于各种分析的数据量是巨大的。当处理数据时,随着数据量的增长,性能会呈指数级下降。而且,根据你需要处理的数据量,时间往往是关键因素。
本章中,我们将创建一个数据库并填充它,并编写代码以访问数据库并执行插入、更新、选择和删除操作。我们的数据库访问代码将包括 Entity Framework、Dapper.NET 和 ADO.NET。
注意
本章将不讨论代码性能改进。我们只关注为下一章将要进行的基准测试设置数据库和源代码。
本章将涵盖以下主题:
-
创建和填充 SQL Server 数据库
-
编写使用 Entity Framework 访问数据库的代码
-
编写使用 Dapper.NET 访问数据库的代码
-
编写使用 ADO.NET 访问数据库的代码
完成本章后,你将能够做到以下:
-
登录 SQL Server Management Studio 并执行数据库创建和初始化脚本
-
在开发时将秘密存储在
secrets.json中,以确保秘密不会存储在版本控制中 -
使用 Entity Framework 访问 SQL Server 数据库并执行 创建/插入、读取/选择、更新和删除 (CRUD)操作
-
使用 Dapper.NET 访问 SQL Server 数据库并执行 CRUD 操作
-
使用 ADO.NET 访问 SQL Server 数据库并执行 CRUD 操作
技术要求
要跟随本章内容,你需要确保以下条件:
-
SQL Server 2019 Express Edition 或更高版本
-
SQL Server Management Studio
-
Visual Studio 2022
-
本书源代码:
github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH10
设置我们的数据库
在本节中,我们将设置我们的数据库并使我们的项目准备好进行基准测试。我们将基准测试不同的插入、更新、选择和删除数据的方法。让我们从设置数据库开始:
-
访问
github.com/Microsoft/sql-server-samples/tree/master/samples/databases/northwind-pubs。 -
下载
instnwnd.sql文件。 -
一旦文件下载完成,在 SQL Server Management Studio 中打开它。
-
执行文件。这将安装数据库。
-
打开一个新的查询窗口,并输入以下 SQL 代码:
USE [Northwind] GO SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE PROCEDURE [dbo].[InsertProduct] @ProductName NVARCHAR(40), @CategoryID INT, @SupplierID INT, @Discontinued BIT AS BEGIN SET NOCOUNT ON; INSERT INTO Products ( ProductName, CategoryID, SupplierID, Discontinued, QuantityPerUnit ) VALUES ( @ProductName, @CategoryID, @SupplierID, @Discontinued, '1' ) END GO
一旦输入代码,执行脚本。此代码生成 InsertProduct 存储过程。此存储过程将产品插入到 Northwind 数据库的 Products 表中。
-
将现有的 SQL 替换为以下 SQL:
USE [Northwind] GO SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE PROCEDURE [dbo].[GetProductName] @ProductName NVARCHAR(40) AS BEGIN SET NOCOUNT ON; SELECT Top 1 ProductName FROM Products WHERE ProductName LIKE @ProductName END GO
执行 SQL 生成 GetProductName 存储过程。产品名称可能有不同的变体。此存储过程获取给定产品的顶级 1 个名称。
-
将现有的 SQL 代码替换为以下 SQL:
USE [Northwind] GO SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE PROCEDURE [dbo].[FilterProducts] @ProductName NVARCHAR(40) AS BEGIN SET NOCOUNT ON; SELECT * FROM Products WHERE ProductName LIKE @ProductName END GO
执行 SQL 生成 FilterProducts 存储过程。该存储过程返回所有名称包含搜索词的产品。
-
现在,将现有的 SQL 替换为以下 SQL:
USE [Northwind] GO SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE PROCEDURE [dbo].[UpdateProductName] @OldProductName NVARCHAR(40), @NewProductName NVARCHAR(40) AS BEGIN SET NOCOUNT ON; UPDATE Products SET ProductName = @NewProductName WHERE ProductName = @OldProductName END GO
执行此 SQL 生成 UpdateProductName 存储过程。此过程将产品名称从当前名称更新为新名称。
-
将现有的 SQL 替换为以下内容:
USE [Northwind] GO SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE PROCEDURE [dbo].[DeleteProduct] @ProductName NVARCHAR(40) AS BEGIN SET NOCOUNT ON; DELETE FROM Products WHERE ProductName = @ProductName END GO
执行此代码以生成 DeleteProduct 存储过程。此过程从数据库中删除与给定产品名称匹配的产品。
- 一旦数据库安装完成,所有存储过程都已编写并执行,您可以关闭 SQL Server Management Studio。
现在我们已经设置了数据库,我们将设置数据库访问项目。
设置我们的数据库访问项目
在本节中,我们将创建我们的数据库访问项目和类。在下一章中,我们将编写一些基准测试,这些基准测试将引用我们在本章中编写的类。按照以下方式创建项目:
-
打开 Visual Studio 并创建一个名为
CH10_DataAccessBenchmarks的 .NET 6.0 控制台应用程序。 -
添加
Microsoft.EntityFrameworkCore.SqlServerNuGet 包的最新版本。 -
添加
DapperNuGet 包的最新版本。 -
添加
System.Data.SqlClientNuGet 包的最新版本。 -
添加一个名为
Configuration的新文件夹,并添加两个类名为DatabaseSettings和SecretsManager。 -
添加一个名为
Data的文件夹,并添加三个类名为AdoDotNetData、DapperDotNet和EntityFrameworkCoreData。 -
添加一个名为
Models的文件夹,并添加三个类名为Product、SqlCommandModel和SqlCommandParameterModel。 -
添加一个名为
Reflection的文件夹,并添加一个名为Properties的类。 -
在主根目录下添加一个名为
BenchmarkTests的类。 -
保存项目。
因此,我们已经创建并更新了我们的数据库,其中包含了我们将要调用的存储过程,我们还建立了项目、文件夹和类文件,我们将使用这些文件来基准测试我们在数据库上从代码中通常执行的各种数据操作。让我们先从编写Properties类开始。
编写Properties类
作为基准测试的一部分,我们需要获取DbDataRecord的FieldCount值。但是,没有使用反射,该属性无法直接访问。因此,为了使我们的工作更简单,我们将编写一个名为Properties的类,帮助我们通过反射轻松获取属性的值。按照以下步骤操作:
-
打开
Properties类并添加以下using语句:using System.Data.Common; using System.Reflection; internal class Properties { }
我们需要导入这两个命名空间,因为我们使用反射并需要访问DbDataRecord类。
-
添加
GetProperty方法:public static PropertyInfo GetProperty<T>(string name) { return typeof(T).GetProperty(name); }
此方法接受一个泛型类型和一个属性名称。然后,它获取该属性并将其作为PropertyInfo实例返回。
-
现在,添加
GetValue方法:public static T GetValue<T, U>(U source, string name) { return (T)GetProperty<U>(name).GetValue(source); }
此方法接受一个泛型对象类型、返回类型和属性名称。然后,它通过传递泛型对象类型和属性名称调用GetProperty方法。然后调用GetValue方法,传递源对象。结果被转换为泛型返回类型并返回给调用者。
-
添加
GetFieldCount方法:public static int GetFieldCount(DbDataRecord record) { return GetValue<int, DbDataRecord>( record, "FieldCount" ); }
此方法接受一个DbDataRecord对象。它通过传递返回类型、我们的DbDataRecord和我们的FieldCount属性名称调用我们的GetValue方法。返回一个整数,包含我们的DbDataRecord对象具有的字段数。
因此,我们已经创建了我们的Properties类。作为基准测试的一部分,我们将从 SQL Server 数据库中插入、读取、编辑和删除数据。因此,在下一节中,我们将更新我们的DatabaseSettings类。
编写DatabaseSettings类
我们的DatabaseSettings类非常简单:它包含一个单一属性。打开数据库并添加以下属性:
public string ConnectionString { get; set; }
此属性持有我们的 SQL Server 数据库的连接字符串。我们将在每个基准测试方法中设置此属性。然后,它将被传递到我们的数据访问类的构造函数中。
由于数据库连接字符串是一种敏感的数据形式,应该非常私密地保存,我们在开发过程中将数据库连接字符串存储在secrets.json文件中。但在生产中,我们将从appsettings.json文件中获取连接字符串。因此,在下一节中,我们将编写一个SecretsManager类。
编写SecretsManager
在本节中,我们将更新我们的SecretsManager类,以便我们可以安全地获取秘密。
注意
我们的开发环境将使用secrets.json文件。这非常重要,因为之前已经在源代码托管网站上发现了并访问了私有凭证,我们不希望成为检查包含应保持私密的密钥的代码的责任人。
按照以下步骤操作:
-
添加以下 NuGet 包:
Microsoft.Extensions.Configuration Microsoft.Extensions.Configuration.JsonFile Microsoft.Extensions.Configuration.EnvironmentVariables Microsoft.Extensions.Configuration.UserSecrets
我们需要这些包,以便我们可以为用户密钥和appsettings.json配置项目。
-
打开
SecretsManager类,并添加以下using语句:using Microsoft.Extensions.Configuration; using System; using System.IO;
我们需要这些using语句来访问我们的属性、文件系统、环境变量以及访问 Microsoft 的IConfiguration接口。
-
添加
Configuration属性:public static IConfiguration Configuration { get; private set; }
此属性将保存正确的配置对象,这取决于我们是在开发模式还是生产模式。
-
现在,添加
GetSecrets方法:public static string GetSecrets<T>(string sectionName) where T : class { var devEnvironmentVariable = Environment .GetEnvironmentVariable("NETCORE_ENVIRONMENT"); var isDevelopment = string.IsNullOrEmpty(devEnvironmentVariable) || devEnvironmentVariable.ToLower() == "development"; var builder = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile( "appsettings.json", optional: true, reloadOnChange: true ) .AddEnvironmentVariables(); //only add secrets in development if (isDevelopment) { builder.AddUserSecrets<T>(); } Configuration = builder.Build(); return Configuration.GetSection($"{typeof(T).Name} :{sectionName}").Value; }
此方法确定我们是在开发模式还是非开发模式。如果我们处于开发模式,则使用密钥配置模式。否则,我们从appsettings.json文件中获取密钥。该方法接受一个部分名称,这是我们想要检索的密钥的名称,并返回该密钥的值。
这样,我们已经完成了secrets类的编写。对于我们的数据操作基准,我们将专注于单个表——Northwind数据库的Products表。我们需要一个充当数据模型的类。因此,在下一节中,我们将编写Product类。
编写 Product 类
在本节中,我们将更新我们的Product类。它是一个用于数据操作基准的简单对象,包含与Northwind数据库中Products表相对应的属性。按照以下步骤操作:
-
打开
Product类,并按以下方式更新它:using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; [Table("Products")] public class Product { }
在这里,我们使用Table注解注解了我们的类,将此类映射到Northwind数据库中表的名称传递给注解。
-
添加以下属性和注解:
[Key] public int ProductID { get; set; } public string ProductName { get; set; } [ForeignKey("Suppliers")] public int SupplierID { get; set; } [ForeignKey("Categories")] public int CategoryID { get; set; } public string QuantityPerUnit { get; set; } = "1" public decimal UnitPrice { get; set; } public Int16 UnitsInStock { get; set; } public Int16 UnitsOnOrder { get; set; } public Int16 ReorderLevel { get; set; } public bool Discontinued { get; set; }
这些属性与Northwind数据库中Product表的列相对应。[Key]注解将ProductID属性标识为表的主键。两个外键通过[ForeignKey]注解进行标识。我们将表名传递给此注解,其中包含主键。
就这样——我们已经完成了Product类的编写。在访问数据时,我们将使用多个命令和参数。为了简化操作,我们将有一个SqlCommandModel类来定义我们的命令,以及一个SqlCommandParameterModel类来定义我们的命令参数。让我们先编写SqlCommandModel类。
编写 SqlCommandModel 类
在本节中,我们将编写一个简单的类来模拟 SQL 命令。按照以下步骤操作:
-
打开
SqlCommandModel类,将其定义为公共类,并添加System.Data命名空间。 -
现在,添加以下三个属性:
public string CommandText { get; set; } public CommandType CommandType { get; set; } public SqlCommandParameterModel[] CommandParameters { get; set; }
CommandText 属性包含我们的 SQL 命令。这可能是一个存储过程的名称或 SQL 语句。CommandType 属性确定命令是 Text 命令还是 StoredProcedure 命令,而 CommandParameters 属性包含 SQL 命令参数的数组。
现在我们已经编写了 SqlCommandModel,接下来让我们编写 SqlCommandParameterModel 类。
编写 SqlCommandParameterModel 类
在本节中,我们将编写我们的 SqlCommandParameterModel 类。这个类仅仅是一个 SQL 参数定义模型。
打开 SqlCommandParameterModel 类,将其设置为公共类,并添加 System.Data 命名空间。
现在,添加以下三个参数:
public string ParameterName { get; set; }
public DbType DataType { get; set; }
public dynamic Value { get; set; }
此类模拟了一个标准参数,包括参数的名称、其数据库类型和其值。
通过这样,我们已经创建了数据访问类中所需的核心功能。在接下来的章节中,我们将编写数据访问类,以使用 Entity Framework、Dapper 和 ADO.NET 访问数据。
选择 SQL Server 作为数据库服务器的原因是它是最常见的数据库服务器之一,并且在全球许多商业场景中被使用。在采用 SQL Server 的专业环境中,最常用的三种数据访问方法是 Entity Framework、Dapper 和 ADO.NET。这就是为什么我们将在本章中对其进行基准测试。让我们先编写我们的 ADO.NET 数据访问类。
编写 AdoDotNet 类
在本节中,我们将编写我们的数据插入方法。但是,我们不会运行基准测试,这些基准测试将在下一章中进行分析时进行。请按照以下步骤操作:
-
更新
AdoDotNetData类,如下所示:using CH10_DataAccessBenchmarks.Models; using CH10_DataAccessBenchmarks.Reflection; using System; using System.Collections; using System.Collections.Generic; using System.Data.Common; using System.Data.SqlClient; using System.Reflection; internal class AdoDotNetData : IDisposable { private readonly SqlConnection _sqlConnection; private bool _isDisposed; public AdoDotNetData(string connectionString) { _sqlConnection = new SqlConnection(connectionString); } public void Dispose() { Dispose(_isDisposed); } public void Dispose(bool disposing) { if (disposing) { _sqlConnection.Dispose(); _isDisposed = true; } } }
在前面的代码中,我们实现了 IDisposable 模式。当我们完成我们的类时,我们销毁我们的类,这也销毁了它所持有的内存中的可处置对象。
-
添加
ExecuteNonQuery方法:internal void ExecuteNonQuery(SqlCommandModel model) { SqlCommand sqlCommand = new (model.CommandText, _sqlConnection); sqlCommand.CommandType = model.CommandType; foreach (SqlCommandParameterModel parameter in model.CommandParameters) sqlCommand.Parameters.Add(new SqlParameter() { ParameterName = parameter.ParameterName, DbType = parameter.DataType, Value = parameter.Value }); _sqlConnection.Open(); sqlCommand.ExecuteNonQuery(); _sqlConnection.Close(); }
此方法接受一个 SqlCommandModel 对象。在实例化过程中创建了一个新的 SqlCommand 对象实例。我们将 SQL 命令和 SQL 连接传递给构造函数。然后,我们遍历命令参数,为每个 model.CommandParameter 实例化并添加一个 SqlParameter 到 sqlCommand 对象中。接下来,我们打开数据库连接,执行查询,并关闭连接。
-
添加以下代码:
internal int ExecuteNonQuery(string sql) { try { _sqlConnection.Open(); return new SqlCommand(sql, _sqlConnection) .ExecuteNonQuery(); } finally { _sqlConnection.Close(); } }
上述代码执行通过 sql 字符串传入的非查询 SQL 代码。
-
添加以下通用标量方法:
internal T ExecuteScalar<T>(string sql) { try { _sqlConnection.Open(); return (T)new SqlCommand(sql, _sqlConnection) .ExecuteScalar(); } finally { _sqlConnection.Close(); } }
此方法接受一个 SQL 命令作为字符串。打开数据库连接,并实例化一个新的 SqlCommand。执行 ExecuteScalar 命令,从数据库返回单个值。在返回值之前,将其转换为调用者指定的泛型类型,并以该类型返回。然后关闭连接。
-
添加以下标量方法:
internal T ExecuteScalar<T>(SqlCommandModel model) { SqlCommand sqlCommand = new( model.CommandText, _sqlConnection); sqlCommand.CommandType = model.CommandType; foreach (SqlCommandParameterModel parameter in model.CommandParameters) sqlCommand.Parameters.Add(new SqlParameter() { ParameterName = parameter.ParameterName, DbType = parameter.DataType, Value = parameter.Value }); _sqlConnection.Open(); T data = (T)sqlCommand.ExecuteScalar(); _sqlConnection.Close(); return data; }
此方法接收一个 SqlCommandModel 并使用它来构建一个 SqlCommand。通过调用 ExecuteScalar 方法执行 SqlCommand 类,并在返回之前将其转换为泛型类型。
-
添加以下读取方法:
internal IEnumerator<T> ExecuteReader<T>(string sql) { Type TypeT = typeof(T); ConstructorInfo ctor = TypeT.GetConstructor(Type.EmptyTypes); if (ctor == null) { throw new InvalidOperationException($"Type {TypeT.Name} does not have a default constructor."); } _sqlConnection.Open(); IEnumerator data = new SqlCommand(sql, _sqlConnection) .ExecuteReader().GetEnumerator(); while (data.MoveNext()) { T newInst = (T)ctor.Invoke(null); DbDataRecord record = (DbDataRecord) data.Current; int fieldCount = Properties .GetFieldCount((DbDataRecord) data.Current); for (int i = 0; i < fieldCount; i++) { string propertyName = record.GetName(i); PropertyInfo propertyInfo = TypeT .GetProperty(propertyName); if (propertyInfo != null) { object value = record[i]; if (value == DBNull.Value) propertyInfo .SetValue(newInst, null); else propertyInfo .SetValue(newInst, value); } } yield return newInst; } }
此方法接收一个 SQL 语句并通过调用 ExecuteReader 方法来执行它。一旦方法执行完毕,我们就获得读者的枚举器。然后,我们遍历枚举器并构建当前迭代的对象,并产生结果。
-
添加以下读取方法:
internal IEnumerator<T> ExecuteReader<T> (SqlCommandModel model) { Type TypeT = typeof(T); ConstructorInfo ctor = TypeT.GetConstructor(Type.EmptyTypes); if (ctor == null) { throw new InvalidOperationException($"Type {TypeT.Name} does not have a default constructor."); } SqlCommand sqlCommand = new(model.CommandText, _sqlConnection); sqlCommand.CommandType = model.CommandType; foreach (SqlCommandParameterModel parameter in model.CommandParameters) sqlCommand.Parameters.Add(new SqlParameter() { ParameterName = parameter.ParameterName, DbType = parameter.DataType, Value = parameter.Value}); _sqlConnection.Open(); SqlDataReader reader = sqlCommand.ExecuteReader(); if (reader.HasRows) { while (reader.Read()) { T newInst = (T)ctor.Invoke(null); for (int i = 0; i < reader.FieldCount; i++) { string propertyName = reader.GetName(i); PropertyInfo propertyInfo = TypeT.GetProperty(propertyName); if (propertyInfo != null) { object value = reader[i]; if (value == DBNull.Value) propertyInfo.SetValue(newInst, null); else propertyInfo.SetValue(newInst, value); } } yield return newInst; } } _sqlConnection.Close(); }
此读取方法接收一个 SqlCommandModel 并构建一个 SqlCommand。它执行读取操作并获取 SqlDataReader。它遍历读取器并构建一个泛型类型的实例,然后将其传递给用户。
这样,我们的 ADO.NET 数据访问类就完成了。现在,让我们学习如何编写 Entity Framework 数据访问类。
编写 EntityFrameworkCoreData 类
在本节中,我们将编写我们的 Entity Framework 数据访问类的相关方法。在本节中编写的代码将在下一章中执行。请按照以下步骤操作:
-
打开
EntityFrameworkCoreData类并按以下方式编辑它:using CH10_DataAccessBenchmarks.Models; using Microsoft.EntityFrameworkCore; using System.Collections.Generic; using Microsoft.Data.SqlClient; using System.Linq; using Microsoft.EntityFrameworkCore.SqlServer .Infrastructure.Internal; public class EntityFrameworkCoreData : DbContext { private string _connectionString = string.Empty; public DbSet<Product> Products { get; set; } public EntityFrameworkCoreData(string connectionString) : base(GetOptions (connectionString)) { _connectionString = connectionString; } private static DbContextOptions GetOptions(string connectionString) { return SqlServerDbContextOptionsExtensions .UseSqlServer(new DbContextOptionsBuilder(), connectionString).Options; }
我们的类继承自 Microsoft.EntityFrameworkCore 库中的 DbContext 类。我们声明一个变量来保存我们的数据库连接字符串,以及一个变量来保存 Products 集合。在我们的构造函数中,我们设置连接字符串并调用基类构造函数。
-
添加
OnConfiguring方法:protected override void OnConfiguring (DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlServer(_connectionString); }
此方法确定我们将使用 SQL Server,并传递我们将使用的 SQL Server 连接字符串。
-
添加以下方法,它执行原始 SQL:
public int ExecuteSQL(string sql) { return Database.ExecuteSqlRaw(sql, null); }
此方法接收一个 SQL 语句并将其作为原始 SQL 在数据库中执行。返回值是受该语句执行影响的记录数。
-
添加以下方法以执行存储过程作为非查询:
public int ExecuteNonQuerySP(SqlCommandModel model) { SqlParameter[] parameters = new SqlParameter[model.CommandParameters .Length]; for (int i = 0; i < parameters.Length; i++) { parameters[i] = new SqlParameter( model.CommandParameters[i].ParameterName, model.CommandParameters[i].Value ); } if (parameters.Length == 4) return Database.ExecuteSqlRaw( model.CommandText, parameters[0], parameters[1], parameters[2], parameters[3] ); else if (parameters.Length == 2) return Database.ExecuteSqlRaw( model.CommandText, parameters[0], parameters[1] ); else return Database.ExecuteSqlRaw( model.CommandText, parameters[0] ); }
在此方法中,我们从 SqlCommandModel 构建一个 SqlParameter 数组。然后,通过将每个参数传递给存储过程来执行原始 SQL。此执行是非查询操作,并返回运行该过程影响的行数。
-
以下方法将执行并返回一个
string类型的标量值:public string ExecuteScalarSP(string productName) { return Products.FromSqlRaw( "EXEC FilterProducts @ProductName={0}", new SqlParameter() { ParameterName = "@ProductName", Value = productName }) .AsEnumerable().FirstOrDefault() .ProductName; }
此方法执行一个带有单个参数的存储过程。我们获取可枚举的返回对象并过滤它以获取第一条记录。然后返回产品名称作为字符串。
-
向我们的类中添加最终的方法,该方法返回一个枚举器:
public IEnumerator<Product> ExecuteReaderSP(string productName) { return Products.FromSqlRaw( "EXEC FilterProducts @ProductName={0}", new SqlParameter() { ParameterName = "@ProductName", Value = productName } ).GetEnumerator(); }
此操作执行一个带有单个参数的存储过程,并返回一个充满过滤产品的枚举器。
有了这些,我们已经编写了所有的 Entity Framework 类。现在,是时候编写我们的 Dapper.NET 方法了。
编写 DapperDotNet 类
在本节中,我们将编写我们的 Dapper.NET 方法。这是在编写基准测试方法之前的最后一节。我们将运行本节中编写的代码。请按照以下步骤操作:
-
打开
DapperDotNet类,添加SimpleCRUD包,并按如下方式修改:public class DapperDotNet : IDisposable { private bool isDisposed = false; private IDbConnection _dbConnection; public DapperDotNet(string connection) { SimpleCRUD .SetDialect(SimpleCRUD.Dialect.SQLServer); _dbConnection = new SqlConnection (connection); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (isDisposed) return; if (disposing) _dbConnection.Dispose(); isDisposed = true; } }
我们在这个类中实现了 IDisposable 模式,并将 SQL 方言设置为 SQL Server。
-
添加以下非查询方法:
public int ExecuteNonQuery(string sql) { try { _dbConnection.Open(); return _dbConnection.Execute(sql); } finally { _dbConnection.Close(); } }
此方法执行原始 SQL 并返回受 SQL 语句影响的记录数。
-
添加以下方法以执行非查询操作:
public void ExecuteNonQuery(SqlCommandModel model) { try { _dbConnection.Open(); var parameters = new DynamicParameters(); foreach ( SqlCommandParameterModel parameter in model.CommandParameters ) parameters.Add( parameter.ParameterName, parameter.Value ); _dbConnection.Query( model.CommandText, parameters, commandType: CommandType.StoredProcedure ); } finally { _dbConnection.Close(); } }
此方法接受一个 SqlCommandModel 实例,并构建一个 DynamicParameter 包。然后,它执行由模型 CommandText 定义的存储过程。
-
添加以下泛型标量方法:
public T ExecuteScalar<T>(string sql) { try { _dbConnection.Open(); return _dbConnection.ExecuteScalar <T>(sql); } finally { if (_dbConnection != null && _dbConnection.State == ConnectionState.Open) _dbConnection.Close(); } }
此方法接受一个 SQL 语句并执行它,返回所需类型的单个值。
-
添加以下方法,该方法执行存储过程并返回字符串:
public string ExecuteScalarSP(SqlCommandModel model) { try { _dbConnection.Open(); var parameters = new DynamicParameters(); parameters.Add( model.CommandParameters[0] .ParameterName, model.CommandParameters[0].Value ); return _dbConnection.Query<Product>( model.CommandText, parameters, commandType: CommandType.StoredProcedure ).First().ProductName; } finally { if ( _dbConnection != null && _dbConnection.State == ConnectionState.Open) _dbConnection.Close(); } }
此方法接受一个 SqlCommandModel 实例,并使用它来执行存储过程。请记住将缺少的 using 语句 SqlCommandModel 添加到类中。存储过程执行返回 IEnumerable<Product> 类型的数据。因此,我们获取列表中的第一个产品并返回其 ProductName。
-
添加以下方法,该方法执行原始 SQL 并返回
IEnumerator<T>类型的数据:public IEnumerator<T> ExecuteReader<T>(string sql) where T : class { try { _dbConnection.Open(); return _dbConnection.Query<T>(sql) .GetEnumerator(); } finally { if (_dbConnection != null && _dbConnection.State == ConnectionState.Open) _dbConnection.Close(); } }
此方法执行原始 SQL 字符串并返回 IEnumerable<T> 类型的数据。
-
添加以下方法,该方法执行存储过程并返回
IEnumerator<Product>类型的数据:public IEnumerator<Product> ExecuteReaderSP <Product>( SqlCommandModel model ) { try { _dbConnection.Open(); var parameters = new DynamicParameters(); foreach (SqlCommandParameterModel parameter in model.CommandParameters) parameters.Add( parameter.ParameterName, parameter.Value ); return _dbConnection.Query<Product>( model.CommandText, parameters, commandType: CommandType.StoredProcedure ).GetEnumerator(); } finally { if (_dbConnection != null && _dbConnection.State == ConnectionState.Open) _dbConnection.Close(); } }
此方法接受一个 SqlCommandModel 实例,并构建一个要执行的参数化存储过程。返回 IEnumerator<Product> 类型的数据。
-
添加我们的最终 dapper 方法,该方法将获取与
productName参数匹配的第一个产品名称:public string GetProductNameSP(string productName) { try { _dbConnection.Open(); var parameters = new DynamicParameters(); parameters.Add("@ProductName", productName); return _dbConnection.Query<Product>( $"GetProductName", parameters, commandType: CommandType.StoredProcedure ).First().ProductName; } finally { if (_dbConnection != null && _dbConnection.State == ConnectionState.Open) _dbConnection.Close(); } }
此方法接受一个产品名称并执行 GetProductName 存储过程。存储过程匹配所有数据库中产品名称与产品名称参数相似的产品。然后,它获取返回列表中的第一个产品并返回其产品名称。
这就完成了我们为下一章将要进行的基准测试工作所做的数据库和数据访问项目设置。让我们回顾一下本章我们取得了哪些成果。
摘要
在本章中,我们下载了 Products 表。
在确保我们已设置好所需的存储过程后,我们启动了一个 .NET 6.0 控制台应用程序。我们添加了我们的模型类和数据访问类,以在 Entity Framework、Dapper 和 ADO.NET 中执行数据访问操作。
在下一章中,我们将对每个框架的数据访问方法进行基准测试。在 进一步阅读 部分中,您可以通过提供的链接进一步了解 Entity Framework、Dapper 和 ADO.NET。
进一步阅读
要了解更多关于本章所涉及的主题,请查看以下资源:
- Entity Framework Core:
docs.microsoft.com/ef/core/
)
- Dapper:
dapper-tutorial.net/dapper
)
第十一章:第十一章:基准测试关系型数据访问框架
数据在我们的日常生活各个方面都得到了广泛的应用。在当今的大数据时代,收集和存储用于各种分析的数据量是惊人的。当处理数据时,随着数据量的增长,性能会呈指数级下降。根据您需要处理的数据量,时间因素往往是关键的。
在专业开发环境中,程序员并不总是能够访问数据库服务器。数据库服务器的访问通常仅限于数据库开发人员和数据库管理员使用。考虑到这一点,本章是关于基准测试代码以最短时间执行数据库插入、更新、读取和删除操作。在 进一步阅读 部分中,有一些链接到数据库服务器性能文档的链接,这将帮助您进一步提高通过本章获得的效果。
在本章中,我们将基准测试三种不同的操作 SQL Server 数据库数据的方法。我们将对 Entity Framework、ADO.NET 和 Dapper 进行并行比较。在运行这些数据访问和对象映射器的基准测试后,您将能够对您项目的最佳数据访问和对象映射形式做出明智的判断。
在本章中,我们将涵盖以下主题:
-
基准测试数据插入方法:在本节中,我们编写了使用 ADO.NET、Entity Framework Core 和 Dapper.NET 插入数据的基准测试,包括使用和不使用存储过程的情况。
-
基准测试数据选择方法:在本节中,我们编写了使用 ADO.NET、Entity Framework Core 和 Dapper.NET 选择数据的基准测试,包括使用和不使用存储过程的情况。
-
基准测试数据编辑方法:在本节中,我们编写了使用 ADO.NET、Entity Framework Core 和 Dapper.NET 应用数据更新的基准测试,包括使用和不使用存储过程的情况。
-
基准测试数据删除方法:在本节中,我们编写了使用 ADO.NET、Entity Framework Core 和 Dapper.NET 删除数据的基准测试,包括使用和不使用存储过程的情况。
-
基准测试结果及其分析:在本节中,我们运行了之前章节中编写的基准测试。然后,我们分析基准测试结果,以得出执行各种高效数据访问和操作任务的最佳方式。
在完成本章内容后,您将具备使用 ADO.NET、Entity Framework 和 Dapper.NET 访问和操作数据的技能。您还将能够对您自己的项目选择合适的数据访问方法。
注意
本章主要涉及你跟随编写大量代码,为在最后一节运行我们的数据访问基准方法做准备。如果你不想编写代码,只想查看结果,那么请跳转到本章的最后一节,查看基准测试结果及其分析。你还可以跳转到本章中对你最有兴趣的领域,帮助你形成自己关于最适合你需求的数据访问方法的观点。源代码也已在 GitHub 上提供,供你自己研究。
技术要求
要掌握本章中介绍的技能,获取以下内容将非常有用:
-
Visual Studio 2022 或更高版本
-
SQL Server 2019 或更高版本
-
SQL Server Management Student 2019 或更高版本
-
本书源代码:
github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH10
基准测试数据插入方法
在本节中,我们将继续我们在 第十章,设置我们的数据库项目,所做的工作,通过编写将使用 ADO.NET、Entity Framework Core 和 Dapper.NET 对插入方法性能进行基准测试的方法。所以,如果你还没有阅读 *第十章**,或者查看源代码,现在是一个很好的时机去做这些。
本章编写的基准测试将在最后一节运行,并分析结果。由于章节和页面的限制,我将省略对 using 语句的引用。因此,你需要使用 Visual Studio 的快速提示来添加缺失的 using 语句。按照以下步骤编写我们的插入方法基准测试:
-
添加
BenchmarkDotNetNuGet 包。 -
打开
BenchmarkTests类,并按以下方式修改:[MemoryDiagnoser] [Orderer(SummaryOrderPolicy.Declared)] [RankColumn] public class BenchmarkTests { [GlobalSetup] public void GlobalSetup() { InsertProductADNSP(); InsertProductEFSP(); InsertProductDDN(); } }
我们已经设置了类来执行基准测试,并按声明的顺序总结它们,同时诊断内存使用情况,并提供基准测试方法的性能排名。然后,我们提供了 GlobalSetup,它在基准测试之前运行。这是为了为我们的基准测试提供选择、更新和删除的数据。
-
添加
InsertProductADN方法:[Benchmark] public void InsertProductADN() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); AdoDotNetData adnData = new(connectionString); adnData.ExecuteNonQuery("INSERT INTO Products (ProductName, CategoryID, SupplierId, Discontinued) VALUES('ADO.NET Product', 1, 1, 0)"); adnData.Dispose(); }
此方法从密钥文件中获取连接字符串,并通过将连接字符串传递给其构造函数来创建一个新的 AdoDotNetData 实例。然后,它调用 ExecuteNonQuery 方法,将原始 SQL 插入方法传递给该方法。一旦查询运行,实例将被释放。
-
添加
InsertProductADNSP方法:[Benchmark] public void InsertProductADNSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); AdoDotNetData aaa = new(connectionString); SqlCommandModel model = new() { CommandText = "InsertProduct", CommandType = CommandType.StoredProcedure, CommandParameters = new SqlCommandParameterModel[] { new SqlCommandParameterModel() { ParameterName = "@ProductName", DataType = DbType.String, Value = "Dapper Product Edited" }, new SqlCommandParameterModel() { ParameterName = "@CategoryID", DataType = DbType.Int32, Value = 1 } , new SqlCommandParameterModel() { ParameterName = "@SupplierID", DataType = DbType.Int32, Value = 1 }, new SqlCommandParameterModel() { ParameterName = "@Discontinued", DataType = DbType.Boolean, Value = false } } }; aaa.ExecuteNonQuery(model); aaa.Dispose(); }
此方法从密钥文件中获取连接字符串,并将字符串传递给 AdoDotNetData 类的构造函数。然后,它创建一个新的 SqlCommandModel,用于在产品表上构建存储过程插入操作的属性。接着,它调用 ExecuteNonQuery 方法,传入用于生成和执行存储过程调用的模型。然后,AdoDotNetData 类被释放。
-
添加
InsertProductEF方法: -
添加
InsertProductEF方法:[Benchmark] public void InsertProductEF() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); EntityFrameworkCoreData efData = new(connectionString); Product product = new() { ProductName = "EF Product", CategoryID = 1, SupplierID = 1, Discontinued = false, QuantityPerUnit = "1" }; efData.Products.Add(product); efData.SaveChanges(); efData.Dispose(); }
此方法从密钥文件中获取连接字符串,并将其传递给 EntityFrameworkCoreData 类的构造函数。然后,它创建一个新的产品并将其添加到 Products 集合中。然后保存更改,并释放 EntityFrameworkCoreData 类。
-
现在,添加
InsertProductEFSP方法:[Benchmark] public void InsertProductEFSP() { string connectionString = SecretsManager. GetSecrets<DatabaseSettings> ("ConnectionString"); EntityFrameworkCoreData efData = new(connectionString); SqlCommandModel model = new() { CommandText = "EXEC InsertProduct @ProductName = {0}, @CategoryID = {1}, @SupplierID = {2}, @Discontinued = {3}", CommandType = CommandType.StoredProcedure, CommandParameters = new SqlCommandParameterModel[] { new SqlCommandParameterModel() { ParameterName = "@ProductName", DataType = DbType.String, Value = "EF Product Edited" } , new SqlCommandParameterModel() { ParameterName = "@CategoryID", DataType = DbType.Int32, Value = 1 } , new SqlCommandParameterModel() { ParameterName = "@SupplierID", DataType = DbType.Int32, Value = 1 } , new SqlCommandParameterModel() { ParameterName = "@Discontinued", DataType = DbType.Boolean, Value = false } } }; efData.ExecuteNonQuerySP(model); efData.Dispose(); }
此方法从密钥文件中获取连接字符串,并创建 EntityFrameworkCoreData 类的新实例。然后,它通过 SqlCommandModel 构建存储过程插入所需的属性。接着,它执行 ExecuteNonQuerySP 模型,传入执行插入存储过程的模型,然后释放 EntityFrameworkCoreData 类。
-
添加
InsertProductDDN方法:[Benchmark] public void InsertProductDDN() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); DapperDotNet ddnData = new(connectionString); int recordsAffected = ddnData .ExecuteNonQuery("INSERT INTO Products (ProductName, CategoryID, SupplierId, Discontinued) VALUES('Dapper.NET Product', 1, 1, 0)"); ddnData.Dispose(); }
此方法从密钥文件中获取连接字符串,创建 DapperDotNet 类的新实例,并通过调用 ExecuteNonQuery 方法执行原始 SQL 插入语句。然后,它释放 DapperDotNet 类。
-
添加
InsertProductDDNSP方法:[Benchmark] public void InsertProductDDNSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); DapperDotNet ddnData = new(connectionString); SqlCommandModel model = new() { CommandText = "InsertProduct", CommandType = CommandType.StoredProcedure, CommandParameters = new SqlCommandParameterModel[] { new SqlCommandParameterModel() { ParameterName = "@ProductName", DataType = DbType.String, Value = "Dapper Product" } , new SqlCommandParameterModel() { ParameterName = "@CategoryID", DataType = DbType.Int32, Value = 1 } , new SqlCommandParameterModel() { ParameterName = "@SupplierID", DataType = DbType.Int32, Value = 1 } , new SqlCommandParameterModel() { ParameterName = "@Discontinued", DataType = DbType.Boolean, Value = false } } }; ddnData.ExecuteNonQuery(model); ddnData.Dispose(); }
此方法从密钥文件中获取连接字符串,并创建一个新的 DapperDotNet 类。然后,它构建执行产品插入存储过程所需的 SqlCommandModel 属性。接着,它调用 ExecuteNonQuery 过程,传入将执行存储过程的模型。然后,它释放 DapperDotNet 类。
这就结束了我们对插入基准测试方法的探讨。现在,我们将开始编写我们的选择基准测试方法。
数据选择方法基准测试
在本节中,我们将编写我们的基准测试方法,以测试各种数据选择方法的性能。这些基准测试将在本章的最后部分运行和分析:
-
添加
ReadScalarProductADN方法:[Benchmark] public void ReadScalarProductADN() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); AdoDotNetData adnData = new(connectionString); string productName = adnData .ExecuteScalar<string>("SELECT TOP 1 ProductName FROM Products WHERE Product Name LIKE 'ADO.NET Product%'"); adnData.Dispose(); }
此方法从 secrets 文件中获取连接,创建一个新的 AdoDotNetData 类,并执行 ExecuteScalar 方法,传入一个返回字符串的原始 SQL 语句。然后,它释放 AdoDotNet 类。
-
添加
ReadScalarADNSP方法:[Benchmark] public void ReadScalarProductADNSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); AdoDotNetData aaa = new(connectionString); SqlCommandModel model = new SqlCommandModel() { CommandText = "GetProductName", CommandType = CommandType.StoredProcedure, CommandParameters = new SqlCommandParameterModel[] { new SqlCommandParameterModel() { ParameterName = "@ProductName", DataType = DbType.String, Value = "ADO.NET Product" } } }; string productName = aaa.ExecuteScalar<string>(model); aaa.Dispose(); }
此方法从密钥文件中获取连接字符串并创建 AdoDotNetData 类的新实例。然后,它构建 SqlCommandModel,其中包含执行标量存储过程的必要属性。然后,它调用 ExecuteScalar 方法,传入执行存储过程的模型,并返回产品名称。然后,它释放 AdoDotNetData 类。
-
添加
ReadFilteredProductADN方法:[Benchmark] public void ReadFilteredProductADN() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); AdoDotNetData adnData = new(connectionString); IEnumerator<Product> data = adnData.ExecuteReader<Product>("SELECT * FROM Products WHERE ProductName LIKE 'ADO.NET Product'"); adnData.Dispose(); }
此方法从密钥文件中获取连接字符串并创建 AdoDotNetData 类的新实例。然后,它执行 ExecuteReader 方法,该方法接受一个原始 SQL 语句并返回 Product 类型的枚举器,然后释放 AdoDotNetData 类。
-
添加
ReadFilteredProductADNSP方法:[Benchmark] public void ReadFilteredProductADNSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); AdoDotNetData aaa = new(connectionString); SqlCommandModel model = new SqlCommandModel() { CommandText = "FilterProducts", CommandType = CommandType.StoredProcedure, CommandParameters = new SqlCommandParameterModel[] { new SqlCommandParameterModel() { ParameterName = "@ProductName", DataType = DbType.String, Value = "ADO.NET Product" } } }; var data = aaa.ExecuteReader<dynamic>(model); aaa.Dispose(); }
此方法从密钥文件中获取连接字符串并创建 AdoDotNetData 类的新实例。然后,它构建包含执行读取存储过程所需属性的 SqlCommandModel。然后,它执行返回枚举器的 ExecuteReader 方法,然后释放 AdoDotNetData 类。
-
添加
ReadScalarProductEF方法:[Benchmark] public void ReadScalarProductEF() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); EntityFrameworkCoreData efData = new(connectionString); string productName = efData.Products.FirstOrDefault( p => p.ProductName .Contains("EF Product") ).ProductName; efData.Dispose(); }
此方法从密钥文件中获取连接字符串并创建 EntityFrameworkCore 类的新实例。然后,它获取与过滤器匹配的 Product 集合中的第一个项目并分配 ProductName。然后,它释放 EntityFrameworkCore 类。
-
添加
ReadScalarProductEFSP:[Benchmark] public void ReadScalarProductEFSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); EntityFrameworkCoreData efData = new(connectionString); string productName = efData .ExecuteScalarSP("EF Product"); efData.Dispose(); }
此方法从密钥文件中获取连接字符串并创建 EntityFrameworkCoreData 类的新实例。然后,它调用 ExecuteScalarSP 方法,传入过滤器的名称,返回匹配过滤器的第一个 ProductName,然后释放 EntityFrameworkCoreData 类。
-
添加
ReadFilteredProductsEF方法:[Benchmark] public void ReadFilteredProductsEF() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); EntityFrameworkCoreData efData = new(connectionString); IEnumerator<Product> products = efData.Products .Where(p => p.ProductName .Contains("EF Product")).GetEnumerator(); efData.Dispose(); products.Dispose(); }
此方法从密钥文件中获取连接字符串并创建 EntityFrameworkCoreData 类的新实例。然后,它过滤产品并返回产品的枚举器。然后,该方法释放 EntityFrameworkCoreData 类和枚举器。
-
添加
ReadFilteredProductsEFSP方法:[Benchmark] public void ReadFilteredProductsEFSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); EntityFrameworkCoreData efData = new(connectionString); IEnumerator<Product> products = efData .ExecuteReaderSP("EF Product"); efData.Dispose(); products.Dispose(); }
此方法从密钥文件中获取密钥并创建 EntityFrameworkCoreData 类的新实例。然后,它调用 ExecuteReaderSP 方法,该方法执行返回 Products 类型枚举器的存储过程。然后,该方法释放 EntityFrameworkCoreData 类和枚举器。
-
添加
ReadScalarProductDDN方法:[Benchmark] public void ReadScalarProductDDN() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); DapperDotNet ddnData = new(connectionString); string productName = ddnData .ExecuteScalar<string>("SELECT TOP 1 ProductName FROM Products WHERE Product Name LIKE 'Dapper.NET Product%'"); ddnData.Dispose(); }
此方法从密钥文件中获取连接字符串并创建 DapperDotNet 类的新实例。然后,它执行 ExecuteScalar 方法,传入一个返回匹配过滤器的顶级 ProductName 的原始 SQL 语句。然后,它释放 DapperDotNet 类。
-
添加
ReadScalarProductDDNSP方法:[Benchmark] public void ReadScalarProductDDNSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); DapperDotNet ddnData = new(connectionString); SqlCommandModel model = new() { CommandText = "GetProductName", CommandType = CommandType.StoredProcedure, CommandParameters = new SqlCommandParameterModel[] { new SqlCommandParameterModel() { ParameterName = "@ProductName", DataType = DbType.String, Value = "Dapper Product" } } }; string productName = ddnData.ExecuteScalarSP(model); ddnData.Dispose(); }
此方法从密钥文件中获取连接字符串,并创建 DapperDotNet 类的新实例。然后构建包含执行存储过程所需属性的 SqlCommandModel。然后调用 ExecuteScalarSP 方法,传入模型。返回第一个匹配产品的 ProductName。然后释放 DapperDotNet 类。
-
添加
ReadFilteredProductsDDN类:[Benchmark] public void ReadFilteredProductsDDN() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); DapperDotNet ddnData = new(connectionString); IEnumerator<Product> data = ddnData.ExecuteReader<Product>("SELECT * FROM Products WHERE ProductName LIKE 'Dapper.NET Product%'"); ddnData.Dispose(); data.Dispose(); }
此方法从 secrets 文件中获取连接字符串,然后创建 DapperDotNet 类的新实例。然后调用 ExecuteReader 方法,传入一个原始 SQL 语句。返回 Product 类型的枚举器。然后释放 DapperDotNet 和枚举器。
-
添加
ReadFilteredProductsDDNSP方法:[Benchmark] public void ReadFilteredProductsDDNSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); DapperDotNet ddnData = new(connectionString); SqlCommandModel model = new() { CommandText = "GetProductName", CommandType = CommandType.StoredProcedure, CommandParameters = new SqlCommandParameterModel[] { new SqlCommandParameterModel() { ParameterName = "@ProductName", DataType = DbType.String, Value = "Dapper.NET Product" } } }; IEnumerator<Product> products = ddnData.ExecuteReaderSP<Product>(model); ddnData.Dispose(); }
此方法从密钥文件中获取连接字符串,然后创建 DapperDotNet 类的实例。接着构建一个 SqlCommandModel,其中包含执行存储过程所需的属性。然后调用 ExcuteReaderSP 方法,传入返回 Product 类型枚举器的模型。
我们现在已经完成了选择基准的编写。接下来,我们将继续编写更新基准。
基准测试数据编辑方法
在本节中,我们将编写测试各种更新语句性能的基准。这些基准将在本章的最后部分运行和分析:
-
添加
UpdateProductADN方法:[Benchmark] public void UpdateProductADN() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); AdoDotNetData adnData = new(connectionString); int recordsAffected = adnData.ExecuteNonQuery("UPDATE Products SET ProductName = 'ADO.NET Product - Edited' WHERE ProductName = 'ADO.NET Product'"); adnData.Dispose(); }
此方法从 secrets 文件中获取 connection 字符串,然后创建 AdoDotNetData 类的新实例。然后调用 ExecuteNonQuery 产品,传入一个原始 SQL 语句,然后返回受影响的记录数,并释放 AdoDotNetData 类。
-
添加
UpdateProductADNSP方法:[Benchmark] public void UpdateProductADNSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); AdoDotNetData aaa = new(connectionString); SqlCommandModel model = new() { CommandText = "UpdateProductName", CommandType = CommandType.StoredProcedure, CommandParameters = new SqlCommandParameterModel[] { new SqlCommandParameterModel() { ParameterName = "@OldProductName", DataType = DbType.String, Value = "ADO.NET Product" } , new SqlCommandParameterModel() { ParameterName = "@NewProductName", DataType = DbType.String, Value = "ADO.NET Product - Edited"} } }; aaa.ExecuteNonQuery(model); aaa.Dispose(); }
此方法从密钥文件中获取连接字符串,并创建 AdoDotNetData 类的新实例。然后构建 SqlCommandModel,其中包含执行更新存储过程所需的属性。然后使用传入的模型调用 ExecuteNonQuery,并执行执行更新的存储过程。然后释放 AdoDotNetData 类。
-
添加
UpdateProductEF方法:[Benchmark] public void UpdateProductEF() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); EntityFrameworkCoreData efData = new EntityFrameworkCoreData(connectionString); IQueryable<Product> products = efData.Products .Where(p => p.ProductName.Contains("EF Product")); foreach (Product product in products) product.ProductName = "EF Product Edited"; efData.Products.UpdateRange(products); int recordsAffected = efData.SaveChanges(); efData.Dispose(); }
此方法从密钥文件中获取连接字符串,并创建 EntityFrameworkCoreData 类的新实例。然后声明并分配一个产品查询集合。然后遍历每个产品的名称进行更新。然后在 Products 集合上调用 UpdateRange 方法,并将更新后的集合传入。然后保存修改,并释放 EntityFrameworkCoreData 类。
-
添加
UpdateProductEFSP方法:[Benchmark] public void UpdateProductEFSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); EntityFrameworkCoreData efData = new(connectionString); SqlCommandModel model = new() { CommandText = "EXEC UpdateProductName @OldProductName = {0}, @NewProductName = {1}", CommandType = CommandType.StoredProcedure, CommandParameters = new SqlCommandParameterModel[] { new SqlCommandParameterModel() { ParameterName = "@OldProductName", DataType = DbType.String, Value = "EF Product" } , new SqlCommandParameterModel() { ParameterName = "@NewProductName", DataType = DbType.String, Value = "EF Product - Edited" } } }; efData.ExecuteNonQuerySP(model); efData.Dispose(); }
此方法从secrets文件中获取连接字符串并创建EntityFrameworkCoreData类的实例。然后构建包含生成更新存储过程调用所需属性的SqlCommandModel。该方法随后调用ExecuteNonQuerySP过程,执行存储过程,传入模型,然后释放EntityFrameworkCoreData方法。
-
添加
UpdateProductDDN方法:[Benchmark] public void UpdateProductDDN() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); DapperDotNet ddnData = new(connectionString); int recordsAffected = ddnData.ExecuteNonQuery("UPDATE Products SET ProductName = 'Dapper.NET Product - Edited' WHERE ProductName = 'Dapper.NET Product'"); ddnData.Dispose(); }
此方法从密钥文件中获取连接字符串并创建DapperDotNet类的新实例。然后调用ExecuteNonQuery方法,传入一个原始的 SQL 更新语句。返回受影响的记录数,并释放DapperDotNet类。
-
添加
UpdateProductDDNSP方法:[Benchmark] public void UpdateProductDDNSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings>("ConnectionString"); DapperDotNet ddnData = new(connectionString); SqlCommandModel model = new() { CommandText = "UpdateProductName", CommandType = CommandType.StoredProcedure, CommandParameters = new SqlCommand ParameterModel[]{ new SqlCommandParameterModel() { ParameterName = "@OldProductName", DataType = DbType.String, Value = "Dapper.NET Product - Edited" } , new SqlCommandParameterModel() { ParameterName = "@NewProductName", DataType = DbType.String, Value = "Dapper.NET Product" } } }; ddnData.ExecuteNonQuery(model); ddnData.Dispose(); }
此方法从密钥文件中获取连接字符串并创建DapperDotNet类的新实例。然后构建一个SQLCommandModel以准备执行存储过程。它调用ExecuteNonQuery方法,传入模型。执行存储过程,然后释放DapperDotNet类。
这是我们对更新基准测试的总结。现在,让我们来看最终的基准测试方法集。在下一节中,我们将编写我们的删除基准测试。
基准测试删除数据方法
在本节中,我们编写了测量我们删除方法性能的基准测试。这些基准测试将在下一节中进行运行和分析:
-
添加
DeleteProductADN方法:[Benchmark] public void DeleteProductADN() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); AdoDotNetData adnData = new(connectionString); int recordsAffected = adnData.ExecuteNonQuery("DELETE FROM Products WHERE ProductName LIKE 'ADO.NET Product%'"); adnData.Dispose(); }
此方法从密钥文件中获取连接字符串。然后创建AdoDotNetData类的实例。然后,该方法调用ExecuteNonQuery方法,向其中传递一个原始的 SQL 删除语句。然后释放AdoDotNetData类。
-
添加
DeleteProductADNSP方法:[Benchmark] public void DeleteProductADNSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); AdoDotNetData aaa = new(connectionString); SqlCommandModel model = new() { CommandText = "DeleteProduct", CommandType = CommandType.StoredProcedure, CommandParameters = new SqlCommandParameterModel[] { new SqlCommandParameterModel() { ParameterName = "@ProductName", DataType = DbType.String, Value = "ADO.NET Product - Edited"} } }; aaa.ExecuteNonQuery(model); aaa.Dispose(); }
此方法从密钥文件中获取连接字符串,然后创建AdoDotNetData类的实例。使用执行删除存储过程所需的属性构建SqlCommandModel。然后,将模型传递给ExecuteNonQuery模型,执行存储过程,并释放AdoDotNetData类。
-
添加
DeleteProductEF方法:[Benchmark] public void DeleteProductEF() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); EntityFrameworkCoreData efData = new EntityFrameworkCoreData(connectionString); IQueryable<Product> products = efData.Products .Where(p => p.ProductName.Contains("EF Product")); efData.Products.RemoveRange(products); int recordsAffected = efData.SaveChanges(); efData.Dispose(); }
此方法从密钥文件中获取连接字符串,然后创建EntityFrameworkCoreData类的实例。然后返回一个可查询的产品集合,匹配删除标准。然后将此集合传递给Products集合的RemoveRange方法,并保存修改,从数据库中删除这些项目。然后释放EntityFrameworkCoreData类。
-
添加
DeleteProductEFSP方法:[Benchmark] public void DeleteProductEFSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); EntityFrameworkCoreData efData = new(connectionString); SqlCommandModel model = new() { CommandText = "EXEC DeleteProduct @ProductName = {0}", CommandType = CommandType.StoredProcedure, CommandParameters = new SqlCommandParameterModel[] { new SqlCommandParameterModel() { ParameterName = "@NewProductName", DataType = DbType.String, Value = "EF Product - Edited" } } }; efData.ExecuteNonQuerySP(model); efData.Dispose(); }
此方法从密钥文件中获取连接字符串并创建 EntityFrameworkCoreData 类的实例。然后构建一个包含删除存储过程属性的 SqlCommandModel。使用传入的模型调用 ExecuteNonQuerySP 方法,执行删除存储过程,并释放 EntityFrameworkCoreData 类。
-
添加
DeleteProductDDN方法:[Benchmark] public void DeleteProductDDN() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); DapperDotNet ddnData = new(connectionString); int recordsAffected = ddnData.ExecuteNonQuery("DELETE FROM Products WHERE ProductName LIKE 'Dapper.NET Product%'"); ddnData.Dispose(); }
此方法从密钥文件中获取连接字符串并创建 DapperDotNet 类的实例。然后调用 ExecuteNonQuery 方法,向该方法传递一个原始 SQL 删除语句。执行删除操作并返回受影响的记录数。然后释放 DapperDotNet 类。
-
添加
DeleteProductDDNSP方法:[Benchmark] public void DeleteProductDDNSP() { string connectionString = SecretsManager .GetSecrets<DatabaseSettings> ("ConnectionString"); DapperDotNet ddnData = new(connectionString); SqlCommandModel model = new() { CommandText = "DeleteProduct", CommandType = CommandType.StoredProcedure, CommandParameters = new SqlCommandParameterModel[] { new SqlCommandParameterModel() { ParameterName = "@ProductName", DataType = DbType.String, Value = "Dapper.NET Product - Edited" } } }; ddnData.ExecuteNonQuery(model); ddnData.Dispose(); }
此方法从密钥文件中获取连接字符串并创建 DapperDotNet 类的实例。然后构建包含存储过程属性的 SqlCommandModel。此模型随后传递给 ExecuteNonQuery 方法,执行存储过程,并释放 DapperDotNet 类。
那就是我们的基准测试方法的最后一种。在我们能够运行基准测试之前,还有一项工作要做。按照以下方式更新 Program 类:
using BenchmarkDotNet.Running;
class Program
{
static void Main(string[] args)
{
BenchmarkRunner.Run<BenchmarkTests>();
}
}
Main 方法执行 BenchmarkTests 类。你现在可以执行发布构建来运行基准测试。程序将需要一段时间来执行,所以你需要耐心等待。在下一节中,我们将分析我们各种基准测试的结果,以找出执行插入、选择、更新和删除操作的最有效方式。
基准测试结果及其分析
在我们分析结果之前,值得注意一些 2020 年的大数据统计信息。谷歌每秒有超过 40,000 个查询。这相当于每天 345,600,000 个查询。每天发送 650,000,000,000 条 WhatsApp 商务应用消息。在 24 小时内,有 1,440 分钟,即 86,400 秒,即 86,400,000 毫秒。
这是我们的基准摘要报告:

图 11.1 – 数据访问基准摘要
让我们先讨论插入语句。结果如下:
-
InsertProductDDNSP= 1.841 毫秒 -
InsertProductADNSP= 1.894 毫秒 -
InsertProductDDN= 2.058 毫秒 -
InsertProductADN= 2.092 毫秒 -
InsertProductEF= 2.196 毫秒 -
InsertProductEFSP= 396.509 毫秒
从总结中我们可以看出,执行速度最快的插入语句是 Dapper.NET 存储过程插入,大约需要 1.841 毫秒来执行,其次是 InsertProductADNSP,大约需要 1.894 毫秒来执行。迄今为止,表现最差的是 InsertProductEFSP 方法,需要 396.509 毫秒来执行。从这些方法中我们可以看出,尽管我们有六种不同的数据插入方式,但它们的执行速度各不相同。当性能成为一个严重问题时,您最好的选择是在插入数据时使用 Dapper.NET 存储过程执行或 ADO.NET 存储过程执行。
我们现在将查看标量操作,首先是一个方法性能的有序列表:
-
ReadScalarProductDDN= 1.403 毫秒 -
ReadScalarProductADN= 1.407 毫秒 -
ReadScalarProductADNSP= 1.433 毫秒 -
ReadScalarProductDDNSP= 1.514 毫秒 -
ReadScalarProductEFSP= 53.235 毫秒 -
ReadScalarProductEF= 396.509 毫秒
观察这些结果,Dapper.NET 原生 SQL 执行大约需要 1.403 毫秒,其次是 ADO.NET 原生 SQL 执行,需要 1.407 毫秒。两个 Entity Framework Core 方法执行速度要慢得多。因此,当性能很重要时,您最好使用 Dapper.NET 或 ADO.NET 原生 SQL 查询来获取标量值。
接下来是过滤列表查询。以下是结果列表:
-
ReadFilteredProductsADNSP= 1.078 毫秒 -
ReadFilteredProductsADN= 1.084 毫秒 -
ReadFilteredProductsEFSP= 1.187 毫秒 -
ReadFilteredProductsEF= 1.305 毫秒 -
ReadFilteredProductsDDNSP= 1.529 毫秒 -
ReadFilteredProductsDDN= 199.910 毫秒
从这些结果中我们可以看出,ADO.NET 原生 SQL 和存储过程访问表现最佳,分别为 1.078 毫秒和 1.084 毫秒。令人惊讶的是,这次在原生 SQL 和存储过程访问方面表现最差的是 Dapper.NET。因此,当执行返回多个记录的查询时,性能很重要,您最好使用 ADO.NET。
现在,我们将注意力转向执行更新操作。以下是我们的结果列表:
-
UpdateProductADNSP= 1.562 毫秒 -
UpdateProductEFSP= 1.964 毫秒 -
UpdateProductDDNSP= 1.891 毫秒 -
UpdateProductDDN= 2.297 毫秒 -
UpdateProductADN= 3.583 毫秒 -
UpdateProductEF= 5,304.279 毫秒
从这些结果中,明显的胜者是 ADO.NET 存储过程访问,其时间为 1.562 毫秒。表现最差的是 Entity Framework Core 更新方法。当性能很重要时,使用 ADO.NET 存储过程来更新数据库记录。
最后,我们将查看我们的删除基准测试结果。以下是结果列表:
-
DeleteProductADNSP= 1.760 毫秒 -
DeleteProductDDNSP= 1.863 毫秒 -
DeleteProductEFSP= 2.012 毫秒 -
DeleteProductDDN= 2.522 毫秒 -
DeleteProductADN= 6.263 毫秒 -
DeleteProductEF= 386.716 毫秒
可以看到,表现最差的是 Entity Framework Core 方法,执行时间约为 386.716 毫秒。另一方面,表现最佳的是 ADO.NET 存储过程方法,它只需 1.760 毫秒,其次是 Dapper.NET 存储过程,耗时 1.863 毫秒。因此,当性能很重要时,您最佳的删除策略是使用 ADO.NET 存储过程。
我们能从这些结果中总结出什么?
在执行插入、读取、更新和删除操作时,Dapper.NET 和 ADO.NET 表现最佳。性能在原始 SQL 和存储过程执行之间有所不同。当性能至关重要时,似乎最好的策略不是只选择一个框架,并仅使用该框架进行所有数据操作,而是采用混合方法。
采用数据访问的混合方法,您将使用数据访问框架的组合。从每个框架中,您将决定最佳性能者并使用它进行数据操作。在我们的基准测试中,我们会使用两个框架。选择的框架是 ADO.NET 和 Dapper.NET。这样,我们就能为每种类型的数据操作找到最佳性能。
但是,鉴于这些时间只有毫秒级的差异,为什么这样的性能很重要?
嗯,记得在本节开头我们提到了 2020 年的大数据统计吗?以下表格显示了这些方法在大数据搜索查询和应用程序消息存储中的应用性能:

表 10.1 – 如果使用 SQL Server 存储和读取数据的大数据操作持续时间
这些基准测试是在一台配备 Intel Core i5-6300U CPU 2.40 GHz(Skylake)处理器的 HP 笔记本电脑上运行的。这是一台具有四个逻辑核心和两个物理核心的 CPU。我有 8 GB 的 RAM 和 256 GB 的 SSD。
如果在我的笔记本电脑上使用 SQL Server,并且我有足够的空间(我没有)来存储 WhatsApp 商务应用的消息数据,那么根据我使用的方法插入数据,它将在我笔记本电脑上需要 1,385.01157 到 298,299.595 天的处理时间。如果我的笔记本电脑用于从 SQL Server 获取 Google 搜索结果,那么检索这些结果将需要 43.12 到 7,996.4 天的处理时间。
这些基准测试在 2020 年大数据统计的基础上,将实际的大数据量应用于实际的大数据,显示了计算机基础设施和所需的投资类型的重要性,以便使这些搜索、消息发送和接收即时。在处理如此大的数据集时,达到峰值性能非常重要。
通过代码调整大数据集只能走这么远。这就是为什么服务器计算机比您正常的日常工作站和家庭计算机拥有更多的处理器、磁盘,以及更多的内存。
从本章中要吸取的关键点是,无论何时你在决定前进的方式以最大化性能时,都要进行实验和基准测试。同时,花时间仔细选择你的物理基础设施。
在使用云主机时,还需要记住的是,运行虚拟机时的每数据执行成本和每小时成本。然后,还有数据吞吐量和数据存储节省及检索的成本。考虑到像谷歌和 WhatsApp 这样的应用程序的数字以亿计,如果你能取得那样的成功,你能想象涉及的运行成本吗?这就是为什么在今天的竞争市场中,性能也是如此重要的原因。代码在云中执行得越快,价格就越便宜。代码运行时间越长,成本就越高。
例如,如果你有一个 Azure 函数,它在西美国区域消费层上执行你的数据操作,使用 128MB 的内存大小,执行时间为 1.078 毫秒,每月执行 65,000,000,000 次,那么你一个月的账单将是 13,133.54 美元。但如果你的执行时间是 396.509 毫秒,那么你一个月的账单将是 64,539.57 美元。所以,执行相同的代码操作可以在云支出操作上每月产生 64,539.57 - 12,133.54 = 52,406.03 美元的差异。我敢肯定你不会愿意在这样的大额支出上花费这么多钱,而且这还不包括 SQL Server 实例的成本!
这就结束了这个相当长的章节,因此我们现在将总结我们所学的知识。
摘要
在本章中,我们学习了如何在 SQL Server 中执行插入、选择、更新和删除操作。我们学习了如何使用纯 ADO.NET、Entity Framework Core 和 Dapper.NET 以不同的方式执行这些操作。不同的数据操作是通过原始 SQL 和存储过程来执行的。
为了理解不同数据访问框架的每种数据访问方法的性能,在本章中,我们使用BenchmarkDotNet对它们的运行时性能进行了基准测试。我们发现,在大多数情况下,Dapper.NET 和 ADO.NET 的性能优于 Entity Framework Core,即使在这两个框架中,性能也有很大的差异。
我们得出结论,与其仅仅采用单一的数据访问技术,在某些性能至关重要的场合,采用混合数据访问方法可能更有益。采用混合方法,你将使用针对特定数据访问任务的最佳框架和该框架内的最佳方法。这样,你可以最大化整体性能。这在降低基础设施费用方面也可能至关重要,尤其是在你使用的是第三方云提供商,并且你的月度账单达到数千美元的情况下。
但除了计算机代码性能提升之外,我们还研究了大数据量,并计算了当涉及的数据量达到数十亿时,执行查询和数据插入操作所需的天数。因此,除了代码性能之外,我们还认识到选择正确的基础设施也是必要的,这在使用云服务时也会带来一定的成本。
注意
无论你做什么,只要性能是关键的商业需求,强烈建议你进行实验并提供自己的基准测试。根据你的结果,你可以选择你认为最适合你需求的数据访问方法。
在下一章中,我们将探讨提高 SQL Server 和 Cosmos DB 的性能。但在我们这样做之前,先尝试回答以下问题,看看你对本章包含的信息掌握得如何。此外,进一步阅读部分有一些非常有用的文章,可以扩展本章所涵盖的内容。本章纯粹关注使用三个不同的框架在代码中识别最佳数据访问方法。但在进一步阅读部分,你会发现一些专门针对提高数据库性能的主题,这些主题非常值得一读。
问题
-
插入数据时哪种数据访问方法最快?
-
选择标量值时哪种数据访问方法最快?
-
选择多个记录时哪种数据访问方法最快?
-
更新数据时哪种数据访问方法最快?
-
删除数据时哪种数据访问方法最快?
-
你是否应该使用一个框架来完成所有数据访问操作,以及为什么?
进一步阅读
- Dapper 与 Entity Framework 与 ADO.NET 性能基准测试:
www.exceptionnotfound.net/dapper-vs-entity-framework-vs-ado-net-performance-benchmarking/
)
- Dapper 教程:
dapper-tutorial.net/dapper
)
- ADO.NET 初学者和专业人士教程:
dotnettutorials.net/course/ado-net-tutorial-for-beginners-and-professionals/
)
- SQL Server 数据库性能调优:
www.brentozar.com/sql/sql-server-performance-tuning/
)
- 书籍 – Benjamin Nevarez 著的 高性能 SQL Server:关键任务应用的一致响应:
amzn.to/3gnUbe7
)
- Azure Cosmos DB 和 .NET 的性能技巧:
docs.microsoft.com/azure/cosmos-db/performance-tips-dotnet-sdk-v3-sql
)
- 使用 EF Core 构建高性能数据库的技术:
www.thereformedprogrammer.net/a-technique-for-building-high-performance-databases-with-ef-core/
)
-
如何在 .NET 中提高 SQL Server 查询性能:
www.red-gate.com/products/dotnet-development/ants-performance-profiler/resources/how-to-improve-sql-server-query-performance-in-net -
在 .NET Core 中使用 Dapper 和 SQLKata 构建高性能应用程序:
medium.com/geekculture/using-dapper-and-sqlkata-in-net-core-for-high-performance-application-716d5fd43210
)
- 对于小型 .NET 应用程序,最好的数据库是什么?:
www.slant.co/topics/274/~best-databases-for-a-small-net-application
)
需要记住的要点
在书中阅读关于性能的内容非常好。但如果你非常重视性能,你应该始终进行自己的实验和基准测试。不同的硬件架构和不同的编程风格会产生非常不同的结果,这一点值得牢记。网络使用、安全软件、数据量,以及文件输入和输出,都可能影响你应用程序的性能。
第十二章:第十二章:响应式用户界面
在本章中,你将学习如何编写响应式用户界面。你将编写响应式的 Windows Forms(WinForms)、Windows Presentation Foundation(WPF)、ASP.NET、.NET MAUI 和 WinUI 应用程序。通过使用后台工作线程,你将了解如何在后台运行长时间运行的任务,从而实时更新和与 用户界面(UI)交互。
在本章中,我们将探讨以下主题:
-
使用 WinForms 构建响应式 UI:在本节中,你将编写一个简单的 WinForms 应用程序,该应用程序在执行多项任务的同时保持对用户交互的响应性。
-
使用 WPF 构建响应式 UI:在本节中,你将编写一个简单的 WPF 应用程序,该应用程序在执行多项任务的同时保持对用户交互的响应性。
-
使用 ASP.NET 构建响应式 UI:在本节中,你将编写一个简单的 ASP.NET 应用程序,该应用程序在执行多项任务的同时保持对用户交互的响应性。
-
使用 .NET MAUI 构建响应式 UI:在本节中,你将编写一个简单的 Xamarin.Forms 应用程序,该应用程序在执行多项任务的同时保持对用户交互的响应性。然后,你将通过更新库引用将项目从 Xamarin.Forms 迁移到 .NET MAUI。
-
使用 WinUI 构建响应式 UI:在本节中,你将编写一个简单的 WinUI 应用程序,该应用程序在执行多项任务的同时保持对用户交互的响应性。
通过学习本章,你将获得以下技能:
-
使用后台工作线程保持 UI 响应
-
使用等待屏幕在用户需要等待时提供更新
-
使用 AJAX、WebSockets、SignalR 和 gRPC/gRPC-Web 发送和接收数据以及传输资产
-
编写响应式桌面、Web 和移动 UI
注意
为了澄清,当在本章中讨论响应式 UI 时,我们不是在谈论 UI 布局适应设备大小或屏幕可用空间。相反,我们专注于使忙碌的 UI 对用户输入做出响应,而不是在任务执行期间阻止用户工作。
技术要求
-
Visual Studio 2022 或更高版本。
-
本章源代码可在
github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH12获取。
使用 WinForms 构建响应式 UI
在本节中,我们将构建一个非常简单的 WinForms 应用程序,该应用程序对 每英寸点数(DPI)敏感,并允许用户在长时间运行的操作期间继续工作。应用程序具有带有进度条和更新标签的启动画面,为用户提供视觉反馈,表明应用程序正在忙于加载。一旦加载进度完成,启动画面关闭,主窗口显示。
在主窗口中,有一个标签,每次点击增加计数按钮时都会更新,一个可以通过提供的按钮进行导航的分页表格,以及一个用于长时间运行任务的进度指示器,它还有一个取消按钮。
在长时间运行的任务执行期间,您可以移动窗口,通过点击增加计数按钮来增加标签,并且您可以浏览数据。如果您选择的话,您还可以取消长时间运行的任务。
当长时间运行的任务完成、取消或遇到错误时,任务进度面板将被隐藏。
启用 DPI 感知和长文件路径感知
在本节中,我们将配置一个 WinForms 应用程序,使其在高 DPI 屏幕和普通 DPI 大屏幕上看起来很好。我们还将其配置为能够识别长文件路径。请按照以下步骤操作:
-
启动一个新的.NET 6 WinForms 应用程序,并将其命名为
CH12_ResponsiveWinForms。 -
添加一个新的应用程序清单文件。
-
打开
app.manifest文件并更新compatibility部分如下:<compatibility xmlns=”urn:schemas-microsoft- com:compatibility.v1”> <application> <supportedOS Id=”{e2011457-1546-43c5-a5fe-008deee3d3f0}” /> <supportedOS Id=”{35138b9a-5d96-4fbd-8e2d-a2440225f93a}” /> <supportedOS Id=”{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}” /> <supportedOS Id=”{1f676c76-80e1-4239-95bb-83d0f6d0da78}” /> <supportedOS Id=”{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}” /> </application> </compatibility>
此 XML 代码使 Windows Vista 及更高版本的 WinForms 应用程序具有 DPI 感知能力。
-
取消以下
application部分的注释:<application xmlns=”urn:schemas-microsoft-com:asm.v3”> <windowsSettings> <dpiAware xmlns=””> True </dpiAware> <longPathAware xmlns=””> True </longPathAware> </windowsSettings> </application>
此代码通知编译器应用程序具有对长路径和 DPI 设置的感知能力。有了这些设置,应用程序现在将根据不同的屏幕 DPI 设置进行缩放,并且能够处理长达 256 个字符的长路径。
在下一节中,我们将添加一个带有加载进度反馈的启动画面。
添加一个随着加载进度更新的启动画面
应用程序可以非常快速地加载,或者加载相当缓慢。当它们正在加载时,用户并不知道应用程序正在做什么。您可以选择显示启动画面作为您应用程序品牌的一部分。如果您的应用程序加载速度快,那么您可能需要添加一个短暂的延迟,例如 3 秒,以便用户能够看到启动画面。否则,用户可能看到的只是屏幕快速闪烁。
如果应用程序有一些需要花费时间处理的繁重加载操作,用户可能会认为有问题,程序已崩溃。因此,提供提供视觉反馈的启动画面是一种良好的做法。这样,用户就知道应用程序正在忙于处理,并没有崩溃。当用户看到这样的反馈时,他们会更有耐心,并等待应用程序加载完成。
在本节中,我们添加了一个带有视觉反馈的启动画面。主窗口通过延迟模拟几个加载操作。然后,关闭启动画面并显示主窗口。现在,我们将开始添加必要的代码:
-
添加一个名为
SplashScreenForm的新表单,并将其FormBorderStyle属性更改为None,StartPosition属性更改为CentreScreen。将BackColor属性更改为ActiveCaptionText。 -
在表单中添加一个
LoadingProgressBar并将其停靠到表单底部。 -
向
LoadingProgressLabel添加一个标签并将其停靠到表单底部,使其出现在进度条上方。将 Text 属性设置为 Loading. Please wait…,并将 Font | Size 设置为 12。将 ForeColor 属性更改为 HighlightWhite。将 Margin | All 和 Padding | All 设置为 8。 -
向
TitleLabel添加另一个标签,其 Text 属性设置为 Responsive WinForms Example,ForeColor 设置为 HighlightText,Font | Size 设置为 32,并将 Location 设置为 29, 126。 -
重命名
MainForm并打开表单。双击 WindowsForm。这将打开代码窗口。 -
向
MainForm类添加以下using语句:using System; using System.Collections.Generic; using System.ComponentModel; using System.Threading; using System.Windows.Forms;
这些 using 语句为我们启动画面的代码提供了所需的所有功能。
-
向
MainForm类添加以下成员变量:private int _clickCounter; private int _operationNumber; private int _offset = 0; private int _pageSize = 10; private int _currentPage = 1;
这些成员变量将在我们的 MainForm 类的各个方法中被引用,以提供分页、内存数据存储以及存储正在处理操作的点击计数和操作编号。
-
按照以下方式更新
MainForm_Load方法:private void MainForm_Load(object sender, EventArgs e) { SplashScreenForm splashScreen = new SplashScreenForm(); splashScreen.Show(this); for (int x = 1; x <= 100; x++) { Thread.Sleep(500); splashScreen.UpdateProgress(x, $”Progress Update: Performing load operation {x} of 100...”); Application.DoEvents(); } splashScreen.Close(); }
此代码创建我们的启动画面,然后迭代 100 次,模拟许多加载操作。每次迭代都会使 UI 线程休眠半秒,更新启动画面进度,并通过调用 Application.DoEvents() 释放线程,以便其他线程可以通过调用 Application.DoEvents() 来执行它们的工作。
-
打开 SplashScreenForm 并查看其代码。添加以下方法:
public void UpdateProgress(int value, string message) { LoadingProgressBar.Value = value; LoadingProgressLabel.Text = message; Invalidate(); }
此代码从 MainForm 类获取输入,并更新启动画面的标签和进度条,向用户提供应用程序正在加载和进度的反馈。
我们现在已经完成了进度条。如果您运行代码,您将看到以下启动画面:

图 12.1 – WinForms 启动画面
现在我们的启动画面已经工作,让我们添加显示按钮点击增量计数的标签和按钮。
添加增量计数按钮和标签
为了演示在执行长时间操作时 UI 不会被阻塞,我们将有一个标签,每次用户点击按钮时都会更新其文本。在我们的代码中,我们需要执行以下任务:
-
向
MainForm添加一个名为ClickCounterLabel的标签并将其停靠到顶部。将其文本设置为空字符串,文本属性设置为 Segoe UI 和 36pt,并将 TextAlign 设置为 MiddleCenter。 -
向表单添加一个名为
IncrementCountButton的按钮并将其停靠到表单顶部。将其文本设置为 &Increment Text。 -
双击 按钮,生成其点击事件。更新点击事件的代码如下:
private void IncrementCountButton_Click(object sender, EventArgs e) { _clickCounter++; ClickCounterLabel.Text = $”You have clicked the button {_clickCounter} times.”; }
每次用户点击按钮,_clickCounter 变量就会增加一。然后更新 ClickCounterLabel 文本,通知用户他们点击按钮的次数。
接下来,我们将添加一个带有分页导航的表格。我们将在下一节中完成这项工作。
添加具有分页数据的表格
在本节中,我们将添加一个带有分页导航的表格。这将演示,即使在后台运行长时间操作时,用户仍然可以通过 WinForms 应用程序中的数据与页面进行交互。让我们开始:
-
添加
DataTable,并设置其 Dock 属性为 Fill。 -
添加
DataPagingPanel,将其 Dock 属性设置为 Bottom。 -
在
FirstButton上添加一个按钮,文本设置为 |<<。双击 按钮以生成点击事件。然后,返回到设计窗口。 -
在
PreviousButton上添加一个按钮,文本设置为 <<。双击 按钮以生成点击事件。然后,返回到设计窗口。 -
在 FlowLayoutPanel 中添加一个名为
PageTextBox的文本框。 -
在 FlowLayoutPanel 中添加一个名为
NextButton的按钮,文本设置为 >>。双击 按钮以生成其点击事件。然后,返回到设计窗口。 -
在 FlowLayoutPanel 中添加一个名为
LastButton的按钮,文本设置为 >>|。双击 按钮以生成其点击事件。这次,保持代码视图,因为我们已经完成了本节 UI 需要完成的工作。 -
添加
BuildCollection方法:private void BuildCollection() { _products = new(); for (int x = 1; x <= 100; x++) { _products.Add(new Product { Id = x, Name = $”Product {x}” }); } }
此方法构建一个包含 100 个产品的集合。
-
在
SplashScreenForm实例化行之前,将BuildCollection方法的调用添加到MainForm_Load方法中。 -
在关闭启动屏幕的行之后,添加以下两行代码:
DataTable.DataSource = PagedProducts(); PageTextBox.Text = $”Page {_currentPage} of {PageCount()}”;
此代码设置 PagedProducts 方法的数据源。
-
添加
PagedProducts方法:private List<Product> PagedProducts() { return _products.GetRange(_offset, _pageSize); }
此方法从 _products 集合返回一个范围。_offset 变量存储构成返回集合起始点的索引值,_pageSize 变量存储每页要返回的记录数。
-
添加
PageCount方法:private int PageCount() { return _products.Count / _pageSize; }
此方法获取 _products 集合中包含的产品数量,将该数量除以 _pageSize 变量,然后返回结果。结果是我们可以导航的数据页数。
-
按如下方式更新
FirstButton_Click方法:private void FirstButton_Click(object sender, EventArgs e) { if (_currentPage > 1) { _offset = 0; _currentPage = 1; PageTextBox.Text = $”Page {_currentPage} of {PageCount()}”; DataTable.DataSource = PagedProducts(); } }
此代码将数据集的第一页移动到当前页,并相应地更新 UI。
-
使用以下代码更新
PreviousButton_Click方法:private void PreviousButton_Click(object sender, EventArgs e) { if (_currentPage > 1) { _offset -= _pageSize; _currentPage--; PageTextBox.Text = $”Page {_currentPage} of {PageCount()}”; DataTable.DataSource = PagedProducts(); } }
此代码将数据集的前一页移动到当前页,并相应地更新 UI。
-
添加
NextButton_Click方法代码:private void NextButton_Click(object sender, EventArgs e) { if (_currentPage < PageCount()) { _offset += _pageSize; _currentPage++; PageTextBox.Text = $”Page {_currentPage} of {PageCount()}”; DataTable.DataSource = PagedProducts(); } }
此代码将数据集的下一页移动到当前页,并相应地更新 UI。
-
添加
LastButton_Click方法代码:private void LastButton_Click(object sender, EventArgs e) { if (_currentPage < PageCount()) { _offset = _products.Count - _pageSize; _currentPage = PageCount(); PageTextBox.Text = $”Page {_currentPage} of {PageCount()}”; DataTable.DataSource = PagedProducts(); } }
此方法将数据集的最后页移动到当前页,并相应地更新 UI。
-
最后,添加
Product类:internal class Product { public int Id { get; set; } public string Name { get; set; } public string Description { get; set; } = “It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.”; public float Price { get; set; } = 9.99F; public int Units { get; set; } = 100; }
此类是 Product 类,我们的 MainForm 在其 BuildCollection 方法中使用它来构建其产品列表。
我们现在已经构建了我们的分页数据表,并且我们的增量按钮和标签已经就位。我们表单的最后一件事是添加我们的长时间运行的任务,以表明用户交互仍然可能,而不会因为长时间运行的任务而被阻塞。这将是下一节的主题。
在后台运行长时间运行的任务
在本节中,我们将升级我们的 UI 以显示在后台运行的长运行任务的进度。用户可以在任何时候取消长时间运行的任务。当任务完成时,无论其状态如何,长时间运行的任务更新进度控件将从用户隐藏。让我们开始添加代码:
-
添加一个
LongRunningOperationCancelButton并将其文本设置为&取消长时间运行操作。 -
添加一个
StatusBar。 -
添加一个
TaskProgressBar。 -
添加一个
StatusLabel并确保其文本属性为空。 -
添加一个
CollectionBuilderBackgroundWorker。 -
添加一个
LongRunningProcessBackgroundWorker。 -
在
MainForm类的构造函数中,添加以下三行:LongRunningProcessBackgroundWorker.DoWork += LongRunningProcessBackgroundWorker_DoWork; LongRunningProcessBackgroundWorker.ProgressChanged += LongRunningProcessBackgroundWorker_ProgressChanged; LongRunningProcessBackgroundWorker .RunWorkerCompleted += LongRunning ProcessBackgroundWorker_RunWorkerCompleted;
此代码为我们的 BackgroundWorker 添加了处理程序,该处理程序将负责执行长时间运行的任务。
-
在
MainForm_Load方法的最后行(在闭合括号之前)添加以下方法调用:LongRunningProcess();。 -
添加以下
LongRunningProcess方法:private void LongRunningProcess() { if (LongRunningProcessBackgroundWorker.IsBusy != true) { LongRunningProcessBackgroundWorker .RunWorkerAsync(); } }
如果 LongRunningProcessBackgroundWorker 没有忙碌,则调用 RunWorkerAsync 方法的 LongRunningProcessBackground Worker_DoWork 将被执行。
-
将
LongRunningProcessBackgroundWorker_DoWork添加到MainForm类:private void LongRunningProcessBackgroundWorker_DoWork (object sender, DoWorkEventArgs e) { BackgroundWorker worker = sender as BackgroundWorker; for (int i = 1; i <= 100; i++) { if (worker.CancellationPending == true) { e.Cancel = true; break; } else { _operationNumber = i; System.Threading.Thread.Sleep(100); worker.ReportProgress((i / 100) * 100); } } }
我们将发送者强制转换为 BackgroundWorker 并将其分配给我们的本地工作变量。然后,我们迭代 100 次。每次迭代时,我们将 _operationNumber 变量设置为循环计数变量的值,休眠 100 毫秒,然后调用工作者的 ReportProgress 方法,传入已完成的工作百分比。
-
将
LongRunningProcessBackgroundWorker_ProgressChanged方法添加到MainForm类:private void LongRunningProcessBackgroundWorker _ProgressChanged(object sender, ProgressChanged EventArgs e) { StatusLabel.Text = ($”Progress: {_operationNumber}%”); TaskProgressBar.Value = _operationNumber; if (_operationNumber == 100) { Thread.Sleep(100); LongRunningOperationCancelButton .Visible = false; StatusBar.Visible = false; } }
此代码使用长运行任务的进度更新 UI。如果所有操作都已完成,则隐藏任务取消按钮和状态栏。
-
将
LongRunningProcessBackgroundWorker_RunWorkerCompleted方法添加到MainForm类:private void LongRunningProcessBackgroundWorker _RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) { if (e.Cancelled == true) StatusLabel.Text = “Canceled!”; else if (e.Error != null) StatusLabel.Text = “Error: “ + e.Error.Message; else StatusLabel.Text = “Done!”; }
当长时间运行的任务完成时,此方法将 StatusLabel.Text 执行到方法的输出,输出结果为 Cancelled、Error 或 Done。
-
在完成并运行我们的 WinForms 应用程序之前,我们需要编写的最后一部分代码是向
MainClass的LongRunningOperationButton_Click方法添加代码,如下所示:private void LongRunningOperationCancelButton _Click(object sender, EventArgs e) { if (LongRunningProcessBackgroundWorker .WorkerSupportsCancellation == true) { LongRunningProcessBackgroundWorker .CancelAsync(); LongRunningOperationCancelButton.Visible = false; StatusBar.Visible = false; } }
此代码检查任务是否支持取消。如果支持,则取消任务,并将取消按钮和状态栏从用户隐藏。
- 运行代码。您应该看到图 12.1中显示的启动画面。然后,您应该看到类似于图 12.2所示的主窗口。移动窗口并点击递增计数按钮。此外,点击翻页按钮在数据集的不同数据页之间移动,并取消任务。您应该看到窗口完全响应您的输入,如下所示:

图 12.2 – Windows Forms 主应用程序窗口
如您所见,我们编写了一个功能丰富的 WinForms 应用程序。我们有一个启动画面,它向用户提供视觉反馈,这样他们就不会认为应用程序以任何方式崩溃,并且我们有一个在长时间运行的任务期间对用户输入保持响应的 UI。
现在我们已经有一个工作的 WinForms 应用程序,让我们将注意力转向 WPF。在下一节中,我们将把我们在 WinForms 应用程序中学到的知识应用到 WPF 应用程序中。
使用 WPF 构建响应式 UI
在本节中,我们将构建与 WinForms 应用程序相同的界面,但这次将使用 WPF。我们现在将开始编写我们的代码:
-
创建一个名为
CH12_ResponsiveWPF的新 WPF 应用程序,并确保选择.NET 6.0作为目标框架。 -
将
Product类添加到项目中。它与我们在 WinForms 应用程序中使用的代码相同。 -
添加一个名为
SplashWindow的新窗口。 -
按如下方式修改SplashWindow XAML:
<Window x:Class=”CH12_ResponsiveWPF.SplashWindow” xmlns=”” xmlns:x=”” xmlns:d=”” xmlns:mc=”” xmlns:local=”clr-namespace:CH12_ResponsiveWPF” mc:Ignorable=”d” Background=”White” Foreground=”White” WindowStyle=”None” WindowStartupLocation=”CenterScreen” Title=”SplashWindow” Height=”450” Width=”800”> <StackPanel HorizontalAlignment=”Center” VerticalAlignment=”Center”> <Label TextBlock.FontSize=”32” Content=”Responsive WPF Example” /> <Label x:Name=”LoadingProgressLabel” TextBlock.FontSize=”12” Content=”Loading...” /> <ProgressBar x:Name=”LoadingProgressBar” Minimum=”0” Maximum=”100” /> </StackPanel> </Window>
我们刚刚更新的 XAML 声明了一个包含两个标签和进度条的堆叠面板。第一个标签显示标题,第二个标签显示与进度条一起的加载进度。
-
将以下方法添加到
SplashWindow类中:public void UpdateProgress(int value, string message) { LoadingProgressBar.Value = value; LoadingProgressLabel.Content = message; InvalidateVisual(); }
这段代码将由MainWindow类调用,并负责更新SplashWindow上的进度指示器。
-
打开
MainWindow.xaml文件,并用以下内容替换现有的 XAML:<StackPanel HorizontalAlignment=”Stretch” VerticalAlignment=”Stretch” Background=”Red”> <Label x:Name=”CounterLabel” FontSize=”32” Foreground=”Yellow” Margin=”8” Padding=”8” /> <Button x:Name=”IncrementCounterButton” Content=”Increment Counter” Click=”IncrementCounterButton_Click” HorizontalAlignment=”Center” Padding=”8” Margin=”0, 0, 0 , 8” /> <DataGrid x:Name=”DataTable” /> <StackPanel Orientation=”Horizontal” HorizontalAlignment=”Center” Margin=”0, 4, 0, 4”> <Button x:Name=”FirstButton” Content=”|<<” Click=”FirstButton_Click” Margin=”4” Padding=”8” /> <Button x:Name=”PreviousButton” Content=”<<” Click=”PreviousButton_Click” Margin=”4” Padding=”8” /> <Label x:Name=”PageLabel” Background=”White” Foreground=”Black” Width=”110” Height=”32” VerticalContentAlignment=”Center” /> <Button x:Name=”NextButton” Content=”>>” Click=”NextButton_Click” Margin=”4” Padding=”8” /> <Button x:Name=”LastButton” Content=”>>|” Click=”LastButton_Click” Margin=”4” Padding=”8” /> </StackPanel> <StackPanel x:Name=”StatusPanel” VerticalAlignment=”Bottom” Orientation=”Horizontal” Background=”Yellow”> <Label x:Name=”StatusLabel” Content=”Progress Update: ...” /> <ProgressBar x:Name=”TaskProgressBar” Minimum=”0” Maximum=”100” Width=”500” /> <Button x:Name=”CancelTaskButton” Content=”Cancel Task” Click=”CancelTaskButton_Click” /> </StackPanel> </StackPanel>
此 XAML 提供了一个状态面板,将显示任何后台任务的进度,一个递增标签和一个递增按钮,一个数据网格,以及用于翻页不同数据页面的导航面板。
-
将以下
using语句添加到MainWindow.xaml.cs文件中:using System; using System.Collections; using System.Collections.Generic; using System.ComponentModel; using System.Threading; using System.Windows; using System.Windows.Threading;
这些using语句对于我们的 WPF 窗口正常工作是必需的。
-
将以下成员变量添加到
MainWindow类中:private int _clickCounter; private int _operationNumber; private List<Product> _products; private int _offset = 0; private int _pageSize = 10; private int _currentPage = 1; BackgroundWorker _worker;
在这里,我们有与 WinForms 应用程序相同的变量,除了我们声明了一个后台工作线程。
-
使用以下代码更新
MainWindow构造函数:public MainWindow() { InitializeComponent(); BuildCollection(); SplashWindow splashWindow = new SplashWindow(); splashWindow.Show(); for (int x = 1; x <= 100; x++) { Thread.Sleep(100); splashWindow.UpdateProgress(x, $”Progress Update: Performing load operation {x} of 100...”); DoEvents(); } splashWindow.Close(); DataTable.ItemsSource = PagedData(); PageLabel.Content = $”Page {_currentPage} of {PageCount()}”; _worker = new BackgroundWorker(); _worker.WorkerReportsProgress = true; _worker.WorkerSupportsCancellation = true; _worker.DoWork += Worker_DoWork; _worker.ProgressChanged += Worker_ProgressChanged; _worker.RunWorkerCompleted += Worker_RunWorkerCompleted; _worker.RunWorkerAsync(); }
这段代码基本上与我们的 WinForms 加载方法相同。唯一的真正区别是我们所有的初始化代码都在构造函数中。
-
添加
Worker_DoWork方法:private void Worker_DoWork(object sender, DoWorkEventArgs e) { BackgroundWorker worker = sender as BackgroundWorker; for (int i = 1; i <= 100; i++) { if (worker.CancellationPending == true) { e.Cancel = true; break; } else { _operationNumber = i; System.Threading.Thread.Sleep(100); worker.ReportProgress((i / 100) * 100); } } }
这段代码模拟了 100 个操作的工作,每个操作之间有短暂的延迟。
-
添加
Worker_ProgressChanged方法代码:private void Worker_ProgressChanged(object sender, ProgressChangedEventArgs e) { StatusLabel.Content = ($”Progress: {_operationNumber}%”); TaskProgressBar.Value = _operationNumber; }
这段代码更新了长时间运行任务的进度指示器。
-
添加
Worker_RunWorkerCompleted方法:private void Worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) { if (e.Cancelled == true) StatusLabel.Content = “Cancelled!”; else if (e.Error != null) StatusLabel.Content = “Error: “ + e.Error. Message; else StatusLabel.Content = “Done!”; Thread.Sleep(1500); StatusPanel.Visibility = Visibility.Collapsed; }
此方法报告长时间运行任务的结果,然后从最终用户那里隐藏状态面板。
-
添加
PagedData方法:private IEnumerable PagedData() { return _products.GetRange(_offset, _pageSize); }
此方法返回一个数据页,其索引从 _offset 开始,返回的行数由 _pageSize 定义。
-
添加
DoEvents方法:public static void DoEvents() { Application.Current.Dispatcher .Invoke(DispatcherPriority.Render, new Action(delegate { // Your operation goes here. })); }
此代码的行为类似于 WinForms 的 Application.DoEvents() 代码。您可以将非 UI 阻塞代码放在这里,并更新 UI。
-
添加
BuildCollection方法:private void BuildCollection() { _products = new(); for (int x = 1; x <= 100; x++) { _products.Add(new Product { Id = x, Name = $”Product {x}” }); } }
BuildCollection 方法构建我们的 100 个产品数据集。
-
添加
PageCount方法:private int PageCount() { return _products.Count / _pageSize; }
PageCount 方法根据数据集大小和页面大小计算数据页数,然后返回结果。
-
添加
FirstButton_Click方法:private void FirstButton_Click(object sender, RoutedEventArgs e) { if (_currentPage > 1) { _offset = 0; _currentPage = 1; PageLabel.Content = $”Page {_currentPage} of {PageCount()}”; DataTable.ItemsSource = PagedData(); } }
当执行时,此方法将导航到我们数据集的第一条记录,并相应地升级 UI。
-
添加
PreviousButton_Click方法:private void PreviousButton_Click(object sender, RoutedEventArgs e) { if (_currentPage > 1) { _offset -= _pageSize; _currentPage--; PageLabel.Content = $”Page {_currentPage} of {PageCount()}”; DataTable.ItemsSource = PagedData(); } }
此方法将移动到数据集的前一页,并相应地更新 UI。
-
添加
NextButton_Click代码:private void NextButton_Click(object sender, RoutedEventArgs e) { if (_currentPage < PageCount()) { _offset += _pageSize; _currentPage++; PageLabel.Content = $”Page {_currentPage} of {PageCount()}”; DataTable.ItemsSource = PagedData(); } }
此方法移动到数据集的下一页,并相应地更新 UI。
-
添加
LastButton_Click方法:private void LastButton_Click(object sender, RoutedEventArgs e) { if (_currentPage < PageCount()) { _offset = _products.Count - _pageSize; _currentPage = PageCount(); PageLabel.Content = $”Page {_currentPage} of {PageCount()}”; DataTable.ItemsSource = PagedData(); } }
此方法移动到最后一个数据集页面,并相应地更新 UI。
-
添加
IncrementCounterButton_Click方法:private void IncrementCounterButton_Click(object sender, RoutedEventArgs e) { _clickCounter++; CounterLabel.Content = $”You have clicked the button {_clickCounter} times.”; }
每次您点击 IncrementCounterButton,此方法将增加 _clickCounter 变量,并在屏幕上报告您点击按钮的次数。
-
添加最终名为
CancelTaskButton_Click的 WPF 方法:private void CancelTaskButton_Click(object sender, RoutedEventArgs e) { if (_worker.WorkerSupportsCancellation == true) _worker.CancelAsync(); }
如果支持取消,此方法将取消长时间运行的任务。
- 运行 WPF 应用程序。您将看到显示加载进度的启动屏幕,如下所示:
![Figure 12.3 – WPF 应用程序的启动屏幕
![img/Figure_12.03_B16617.jpg]
图 12.3 – WPF 应用程序的启动屏幕
加载完成后,启动屏幕关闭,您将看到主窗口。在长时间运行的任务进行时,您可以移动窗口,点击增加计数器按钮,浏览分页数据,并取消长时间运行的任务。
如以下截图所示,我们已准备好一切,为最终用户提供进度视觉反馈,并在长时间运行的任务期间保持对用户输入的响应性 UI:
![Figure 12.4 – WPF 应用程序的主窗口
![img/Figure_12.04_B16617.jpg]
图 12.4 – WPF 应用程序的主窗口
在下一节中,我们将探讨如何保持 ASP.NET UI 对用户输入的响应性。
使用 ASP.NET 构建响应式 UI
在本节中,我们将探讨如何帮助 ASP.NET 应用程序快速响应。我们将首先查看内存和分布式缓存。然后,我们将探讨如何使用 AJAX 更新页面的一部分。接下来,我们将编写一个实时聊天应用程序,使用 SignalR。然后,我们将探讨在 ASP.NET 应用程序中使用 WebSockets。
注意
在本章中,我们将不会介绍 gRPC-Web,因为我们已经在 第九章 中通过示例代码介绍了该主题,即 增强网络应用程序的性能,其中我们探讨了 gRPC 用于非 Web 应用程序和 gRPC-Web 用于 Web 应用程序。在本章中,我们还使用 gRPC-Web 实现了一个简单的 Blazor Web 应用程序,因此你可以参考本章以了解 gRPC/gRPC-Web。
让我们通过关注缓存来开始查看一个响应式 ASP.NET 应用程序。我们将查看两种类型的缓存。这些是 内存缓存 和 分布式缓存。在下一节中,我们将实现内存缓存。
实现内存缓存
Web 应用程序通过网络(我们所有人都知道的网络)加载资源。从互联网访问、下载和渲染资源需要不同程度的时间。时间可能会因网络流量、网络质量以及计算机系统资源而变化。我们有没有办法加快这个过程?嗯,是的。我们可以实现缓存。但缓存究竟是什么呢?
缓存是将频繁访问的资源本地存储以提高访问和处理速度的一种方式。
在本节中,你将看到我们如何在 ASP.NET 中轻松实现内存缓存。要实现内存缓存,请按照以下步骤操作:
-
开始一个新的 ASP.NET Core Web 应用程序(模型-视图-控制器)项目,并将其命名为
CH12_ResponsiveASPNET。 -
添加
Microsoft.Extensions.Caching.MemoryNuGet 包。如果 Visual Studio 无法安装它,请在包管理器中运行以下命令:Install-Package Microsoft.Extensions.Caching.Memory - Version 6.0.0-preview.7.21377.19 -
在
HomeController类中,添加语句using Microsoft.Extensions.Caching.Memory。 -
添加以下成员变量:
private readonly ILogger<HomeController> _logger; private IMemoryCache _memoryCache;
此代码声明了将存储我们的日志记录器和内存缓存对象的变量。
-
如下更新
HomeController构造函数:public HomeController(ILogger<HomeController> logger, IMemoryCache memoryCache) { _logger = logger; _memoryCache = memoryCache; }
在此代码中,我们将注入我们将要使用的日志记录器和内存缓存对象,并传递变量以设置我们的成员变量。
-
添加
GetMemoryCacheTime方法:private DateTime GetMemoryCacheTime() { DateTime currentTime; bool alreadyExists = _memoryCache.TryGetValue (“CachedTime”, out currentTime); if (!alreadyExists) { currentTime = DateTime.UtcNow.ToLocalTime(); _memoryCache.Set( “CachedTime”, currentTime, MemoryCacheEntryExtensions .SetSlidingExpiration( new MemoryCacheEntryOptions() { SlidingExpiration = TimeSpan.FromMinutes(5) }, TimeSpan.FromMinutes(5) )); } return currentTime; }
在这里,我们检查我们的 CachedTime 变量是否存在于内存缓存中。如果它存在,则将 currentTime 变量设置并返回缓存的时长。否则,我们获取当前时间并将其存储在内存缓存中,并带有滑动过期值,然后返回缓存的时长。
-
使用以下代码更新
Index方法:[HttpGet] public string Index() { DateTime memoryCacheTime = GetMemoryCacheTime(); return $”Current Time: {DateTime.UtcNow.ToLocalTime()} \nMemory Cache Time: {memoryCacheTime}”; }
Index 控制器方法返回一个字符串。返回的字符串是缓存的时长。
- 运行项目并导航到
https://localhost:5001/Home。你应该会看到以下类似的输出:
当前时间:2021 年 12 月 7 日 20:18:25
内存缓存时间:2021 年 12 月 7 日 20:18:25
如你所见,时间不存在于缓存中,因此在返回之前被添加到缓存中。
注意
端口号的设置取决于端口的可用性。无论你选择哪个端口,如果它已被其他程序占用,则将无法工作。
- 现在,刷新页面,你应该会看到当前时间和内存缓存时间的不同值:
当前时间:12/07/2021 20:21:21
内存缓存时间:12/07/2021 20:18:25
你可以清楚地看到内存缓存时间早于当前时间。这表明我们已经将时间存储在内存缓存中并成功检索。
在 ASP.NET 中实现内存缓存非常简单,你可以通过存储和检索内存缓存中的数据来提高页面加载和渲染时间。现在我们已经了解了内存缓存,我们将转向分布式缓存。
实现分布式缓存
在本节中,我们将使用相同的 ASP.NET Web 项目和控制器来实现分布式缓存。我们所说的分布式缓存是什么意思?分布式缓存扩展了本地缓存的概念,包括跨多台计算机的缓存。这种缓存使得事务数据的扩展成为可能。你主要会使用分布式缓存来存储位于数据库中的应用程序数据,以及与 Web 会话相关的数据。在本节中,我们使用 Redis 进行缓存。Redis 是一个内存数据结构存储,用作分布式、内存中的键值数据库、缓存和消息代理,具有可选的持久性。要实现分布式缓存,执行以下操作:
-
将
Microsoft.Extensions.Caching.RedisNuGet 包添加到 Web 包中。你可以使用以下命令:Install-Package Microsoft.Extensions.Caching.Redis - Version 2.2.0 -
在
HomeController类中,添加using Microsoft.Extensions.Caching.Distributed语句。 -
添加以下成员变量:
private IDistributedCache _distributedCache;
这个变量将保存我们的分布式缓存对象,该对象通过构造函数注入。
-
现在,更新构造函数代码:
public HomeController(ILogger<HomeController> logger, IMemoryCache memoryCache, IDistributedCache distributedCache) { _logger = logger; _memoryCache = memoryCache; _distributedCache = distributedCache; }
我们正在注入分布式缓存对象并设置我们的成员变量。
-
要使用我们的分布式缓存,我们需要对 Base64 字符串进行编码和解码。添加以下两个方法:
private static string Base64Encode(string text) { byte[] bytes = Encoding.UTF8.GetBytes(text); return Convert.ToBase64String(bytes); } public static string Base64Decode(string text) { byte[] bytes = Convert.FromBase64String(text); return Encoding.UTF8.GetString(bytes); }
在这两个方法中,我们将字符串编码为 Base64 编码的字符串,同时也将字符串从 Base64 解码为 UTF8。
-
添加
GetDistriutedCacheString方法:private string GetDistributedCacheString() { string data = _distributedCache.GetString (“StringValue”); if (data == null) { data = Base64Encode($”Hello, World! {DateTime.UtcNow.ToLocalTime()}”); _distributedCache.Set(“StringValue”, Convert.FromBase64String(data), new DistributedCacheEntryOptions() { AbsoluteExpiration = DateTime.UtcNow.AddMinutes(10), }); data = Base64Decode(data); } return data; }
在此代码中,我们从缓存中获取字符串数据。如果存在,则返回它。如果不存在,则将字符串的 Base64 编码版本保存到缓存中,并设置绝对过期时间,然后以 UTF 编码的字符串形式返回字符串的 Base64 解码版本。
-
更新
HomeController.Index方法,如下所示:[HttpGet] public string Index() { DateTime memoryCacheTime = GetMemoryCacheTime(); string data = GetDistributedCacheString(); return $”Current Time: {DateTime.UtcNow.ToLocalTime()} \nMemory Cache Time: {memoryCacheTime} \nDistributed Cache String: {data}”; }
此代码获取内存缓存和分布式缓存存储的数据,并将其输出给用户,显示当前时间、内存缓存时间和分布式缓存中的数据。
-
运行程序并导航到
https://localhost:5001。你应该会看到以下输出:Current Time: 12/07/2021 21:05:59 Memory Cache Time: 12/07/2021 21:05:59 Distributed Cache String: Hello, World! 12/07/2021 21:05:59
我们可以看到,内存缓存时间和分布式缓存字符串都刚刚被添加到缓存中,因为它们与当前时间相同。现在,刷新你的浏览器。你应该会看到这两个缓存值都早于当前时间,如下所示:
Current Time: 12/07/2021 21:08:13
Memory Cache Time: 12/07/2021 21:05:59
Distributed Cache String: Hello, World! 12/07/2021
21:05:59
It is plain to see that both cached values already
existed in the cache, since they are older than the
current time.
在本节和上一节中,您已经看到如何轻松地将内存和分布式缓存添加到我们的应用程序中。这两种缓存形式都可以在提高您的 ASP.NET Web 应用程序性能方面非常有用。在下一节中,我们将探讨如何使用 AJAX 更新当前显示页面的一个小部分。
使用 AJAX 更新当前显示页面的部分
在本节中,我们将使用 AJAX 来更新当前正在显示的页面的一部分。这样我们就不必加载整个页面。让我们开始编写我们的 AJAX 示例:
-
右键单击
Controllers文件夹。从上下文菜单中选择 添加 | 控制器…。然后,选择 MVC 控制器 – 空的。 -
调用新的控制器
AjaxController并打开类。 -
通过添加以下方法来更新控制器:
[Route(“Ajax/Demo”)] public IActionResult AjaxDemo() { return new JsonResult(“Ajax Demo Result”); }
当调用此方法时,将返回一个 JSON 结果,在我们的例子中是一个简单的字符串。
-
右键单击
Index方法并选择index.cshtml。 -
使用以下 HTML 和 JavaScript 代码更新
Views/Ajax/index.cshtml文件:<!DOCTYPE html> <html> <head> <meta name=”viewport” content=”width=device- width” /> <title>Ajax Example</title> </head> <body> <fieldset> <legend>Ajax Demonstration</legend> <form> <input type=”button” value=”Ajax Demonstration” id=”ajaxDemonstration Button” /> <br /> <span id=”ajaxDemoResult”></span> </form> </fieldset> <script src=”https://code.jquery.com/jquery- 3.6.0.slim.min.js” integrity=”sha256-u7e5khyithlIdTpu22P HhENmPcRdFiHRjhAuHcs05RI=” crossorigin=”anonymous” > </script> <script> $(document).ready(function( ) { $(‘#ajaxDemonstrationButton’) .click(function() { $.ajax({ type: ‘GET’, url: ‘/Ajax/Demo’, success: function (result) { $(‘#ajaxDemoResult’) .html(result); } }); }); }); </script> </body> </html>
我们有一个 HTML 表单。该表单有一个按钮,当按下时,将执行 JavaScript,通过执行我们的AjaxDemo操作方法来检索 AJAX 数据。这将导致我们的 JSON 字符串在页面上显示。
- 运行项目并导航到
http://localhost:5001/Ajax。您应该看到以下内容:



如您所见,我们的页面在没有我们的 JSON 字符串的情况下加载。现在,点击 Ajax 演示 按钮。您现在看到以下内容:



点击按钮后,我们可以看到 AJAX 操作检索了我们的 JSON 字符串,并在不进行完整页面加载的情况下将其显示在页面上。
我们已经看到如何使用 AJAX 更新页面的一部分,在此之前,我们看到了如何实现内存和分布式缓存。在下一节中,我们将探讨如何实现 WebSockets。
实现 WebSockets
在本节中,我们将实现 WebSockets。您可能已经听说过 WebSockets,但它们是什么?WebSocket 是一个全双工通信协议,用于通过单个 TCP 连接进行通信。要了解更多关于 WebSocket 规范的信息,您可以查阅 2011 年的 IETF RFC 6455(www.rfc-editor.org/rfc/rfc6455.txt)。
我们为什么要使用 WebSockets?嗯,我们可以使用它们在浏览器和服务器之间打开一个单一的双向交互会话。这样,我们可以取消服务器轮询,向服务器发送消息,并通过事件接收响应。从而使我们的应用程序成为事件驱动的。
在我们的 WebSocket 演示中,我们将点击一个按钮。它将打开一个 WebSocket,发送消息,接收响应,然后关闭连接。我们浏览器和服务器之间的通信将输出到我们的网页上。因此,让我们开始编写我们的 WebSocket 示例:
-
添加一个名为
WebSocketsController的新控制器。 -
右键单击
Index方法并从上下文菜单中选择添加视图。 -
按照以下方式更新
Views/WebSockets/Index.cshtml文件:<script type = “text/javascript”> function WebSocketExample (){ var socket = new WebSocket(“wss:// javascript.info/article/websocket/ demo/hello”); var messages = document.getElementById (‘messages’) var innerHTML = messages.innerHTML; socket.onopen = function(e) { innerHTML += ‘<p>[open] Connection established</p>’; messages.innerHTML += innerHTML; innerHTML += ‘<p>Sending to server</p>’; messages.innerHTML += innerHTML; socket.send(‘WebSocket message!’); }; socket.onmessage = function(event) { innerHTML += `<p>[message] Data received from server: ${event.data}</p>`; }; socket.onclose = function(event) { if (event.wasClean) { innerHTML += `<p>[close] Connection closed cleanly, code=${event.code} reason=${event.reason}</p>`; messages.innerHTML = innerHTML; } else { // e.g. server process killed or network down // event.code is usually 1006 in this case innerHTML += ‘<p>[close] Connection died</p>’; messages.innerHTML = innerHTML; } }; socket.onerror = function(error) { innerHTML += `<p>[error] ${error.message}</p>`; messages.innerHTML = innerHTML; }; } </script> <p>Click the following button to see the function in action</p> <input type = “button” onclick = “WebSocketExample()” value = “Display”> <p id=”messages” onload=”WebSocketExample()”></p>
当通过按钮点击打开 WebSocket 时,messages段落会更新消息,然后向服务器发送一条消息。当服务器响应时,messages段落随后更新以通知用户服务器已响应。如果发生错误,则向用户显示一条消息。然后关闭 WebSocket 并在页面上显示一条消息。
- 运行代码并导航到
http://localhost:5001/WebSockets。点击按钮,你应该会得到以下结果:
![图 12.7 – 点击按钮并执行我们的 WebSocket 示例的最终结果]

图 12.7 – 点击按钮并执行我们的 WebSocket 示例的最终结果
WebSocket 的代码并不多。在这个示例中,我们发送了一条简单的消息并收到了响应。我们用于执行此操作的所有代码都存在于视图的CSHTML文件中。在下一节中,我们将探讨如何使用 SignalR 编写实时聊天程序。
使用 SignalR 实现实时聊天应用程序
在本节中,我们将学习如何使用 SignalR 在 ASP.NET web 应用程序中编写实时功能。我们将通过编写一个简单的聊天应用程序来演示 SignalR 的实际应用。现在,我们将开始编写应用程序:
- 右键单击项目,并从上下文菜单中选择添加 | 客户端库,然后填写如图 12.8 所示的详细信息。然后,点击安装按钮:
![图 12.8 – 添加客户端库以配置安装 SignalR]

图 12.8 – 添加客户端库以配置安装 SignalR
-
将
wwwroot/lib/microsoft/signalr库复制并粘贴到wwwroot/js文件夹中。 -
添加一个名为
SignalRController的新控制器。 -
在主项目根目录下添加一个名为
Hubs的文件夹。 -
在
Hubs文件夹中添加一个名为ChatHub的类。然后,更新ChatHub类,如下所示:public class ChatHub : Hub { public async Task SendMessage( string user, string message ) { await Clients.All .SendAsync( “ReceiveMessage”, user, message ); } }
我们已经有了 SignalR hub 类,并且SendMessage方法异步地向指定用户发送消息。
-
在
SignalRController类的Index方法上右键单击,并从上下文菜单中选择添加视图。 -
在
Views/SignalR/Index.cshtml文件中,将现有内容替换为以下代码:@page <div class=”container”> <div class=”row”> </div> <div class=”row”> <div class=”col-2”>User</div> <div class=”col-4”> <input type=”text” id=”userInput” /> </div> </div> <div class=”row”> <div class=”col-2”>Message</div> <div class=”col-4”> <input type=”text” id=”messageInput” /> </div> </div> <div class=”row”> </div> <div class=”row”> <div class=”col-6”> <input type=”button” id=”sendButton” value=”Send Message” /> </div> </div> </div> <div class=”row”> <div class=”col-12”> <hr /> </div> </div> <div class=”row”> <div class=”col-6”> <ul id=”messagesList”></ul> </div> </div> <script src=”~/js/signalr/dist/browser/signalr.js”> </script> <script src=”~/js/chat.js”></script>
我们已经构建了一个聊天 UI。脚本使用 SignalR。我们现在需要添加使我们的 UI 交互的 JavaScript。
-
在
wwwroot/js文件夹中,添加一个名为chat.js的文件,包含以下代码:“use strict”; var connection = new signalR.HubConnectionBuilder() .withUrl(“/chatHub”).build(); document.getElementById(“sendButton”).disabled = true; connection.on(“ReceiveMessage”, function (user, message) { var li = document.createElement(“li”); document.getElementById(“messagesList”) .appendChild(li); li.textContent = `${user} says ${message}`; }); connection.start().then(function () { document.getElementById(“sendButton”) .disabled = false; }).catch(function (err) { return console.error(err.toString()); }); document.getElementById(“sendButton”) .addEventListener(“click”, function (event) { var user = document .getElementById(“userInput”).value; var message = document .getElementById(“messageInput”).value; connection.invoke( “SendMessage”, user, message ).catch(function (err) { return console.error(err.toString()); }); event.preventDefault(); });
我们添加了使我们的 UI 交互式的 JavaScript。此代码管理用户之间的聊天消息发送。
-
在
Program类中,添加以下服务:services.AddRazorPages(); services.AddSignalR();
此代码将 SignalR 添加到我们的可用服务中,以便我们可以将 SignalR 请求传递给 SignalR。
注意事项
如果使用新的最小模板,代码是 builder.Services.AddRazorPages(); builder.Services.AddSignalR();。
-
更新
Program类以包含映射到我们的ChatHub的路由:app.MapHub<ChatHub>(“/chatHub”);
我们已经包含了到我们的 ChatHub 的路由,这样我们的聊天应用程序就知道如何处理传入的请求。
- 运行代码并导航到
https://localhost:5001/SignalR。你需要两个并排的浏览器实例。在每个浏览器中输入用户名和消息,然后点击发送消息按钮。每次输入文本时,它都会出现在接收者的聊天页面上,就像这里所示:
![Figure 12.9 – Our SignalR application in action]
![img/Figure_12.09_B16617.jpg]
![Figure 12.9 – Our SignalR application in action]
设置和运行我们的 SignalR 比较直接。正如你所见,SignalR 是实时通信的一个优秀选择,我相信你将能够在你编写的网络应用程序中进一步运用这些知识。这就结束了本章关于 ASP.NET 的内容。现在,让我们继续下一节,看看 .NET MAUI。
使用 .NET MAUI 构建响应式 UI
Microsoft .NET MAUI 是 Xamarin.Forms 的新版本。在 Xamarin.Forms 5.0 版本和 .NET MAUI(Xamarin.Forms 6.0 版本)之间有一些重大变化。MAUI 最大的变化是将 Android、iOS 和 macOS 项目合并为一个主项目。虽然针对 Windows 的特定代码仍然位于其自己的项目中,但 Microsoft 正在努力将 Windows 代码包含到主项目中。这将使我们能够使用 C# 和 XAML 编写跨平台应用程序时拥有一个单一的项目。让我们看看使用 .NET MAUI 构建跨平台应用程序的其他一些改进。
注意事项
如果你使用的是 MAUI 的早期版本,要运行 Windows 项目,你需要将 Windows 项目设置为启动项目并部署项目。一旦项目部署完成,你就可以从 Windows 启动菜单中运行应用程序。
布局
在 .NET MAUI 中做出的另一个重大更改是,Xamarin.Forms 项目原本使用的布局已被移动到 Microsoft.Maui.Controls.Compatibility 命名空间。默认情况下,MAUI 将使用新的布局。这些布局基于一个新编写的 LayoutManager,它针对性能、一致性和可维护性进行了优化。新的布局包括 Grid、FlexLayout 和 StackLayout(HorizontalStackLayout 和 VerticalStackLayout)。Microsoft 鼓励你选择最适合你需求的堆叠布局。同时,也鼓励你用新的布局替换旧布局。
新布局的默认间距值已标准化为 0。将这些值设置为 0 设定了您将设置自己的首选值以满足设计要求的预期。最好在全局样式设置这些值,如下所示:
<ResourceDictionary>
<Style TargetType=”StackLayout”>
<Setter Property=”Spacing” Value=”8”/>
</Style>
<Style TargetType=”Grid”>
<Setter Property=”ColumnSpacing” Value=”8”/>
<Setter Property=”RowSpacing” Value=”8”/>
</Style>
</ResourceDictionary>
让我们继续看看可访问性的改进。
可访问性
微软定期与那些致力于开发最高可访问性评级应用程序的开发者会面。这促使微软移除了TabIndex和IsTabStop属性,因为它们最终变得令人困惑,并且没有满足可访问性需求。为了提高可访问性,您可以通过实施深思熟虑的设计来提高屏幕阅读器识别 UI 阅读顺序的能力。如果您需要控制 UI 组件的顺序,微软建议您使用SemanticOrderView组件。
SetSemanticFocus 和 Announce
屏幕阅读器是可访问且友好的应用程序的重要组成部分。为了帮助这些应用程序在读取正确组件方面的性能,有一个新的SemanticExtensions类。作为此类的一部分,有一个名为SetSematicFocus的新方法。此方法允许将屏幕阅读器的焦点设置到特定元素。
注意
在撰写本文时,SetSemanticFocus和Announce仅适用于 iOS、Android 和 Mac Catalyst。
这里是一个设置语义焦点的 XAML 示例:
<VerticalStackLayout>
<Label
Text=”SemanticExtensions:”
TextColor=”Black”
FontAttributes=”Bold”
FontSize=”14”
Margin=”0,8”/>
<Button
Text=”Semantic focus is applied to the label that
follows upon the button being pressed.”
FontSize=”12”
Clicked=”LabelFocusButton_Clicked”/>
<Label
x:Name=”SomeLabel”
Text=”Hello, I am able to receive semantic focus!”
FontSize=”12”/>
</VerticalStackLayout>
在这个 XAML 中,我们有一个指令标签和一个用户可以按下的按钮。当按钮被按下时,点击事件会将语义焦点设置到semanticFocusLabel。以下是点击事件代码:
private void LabelFocusButton_Clicked(object sender,
EventArgs e)
{
SomeLabel.SetSemanticFocus();
}
以下代码使屏幕阅读器能够发出公告:
SemanticScreenReader.Announce(
“Make your applications accessible to MAUI users!”
);
另一个可访问性新增功能是自动字体缩放。
字体缩放
默认情况下,所有组件现在都具有自动字体缩放,并且默认启用。这意味着当您的用户在各个平台上更改文本缩放时,您的应用程序的文本将自动缩放到他们选择的设置。您可以使用以下标记关闭自动字体缩放:FontAutoScalingEnabled="False"。将属性更改为True或删除它将重新启用字体自动缩放。
BlazorWebView
使用 BlazorWebView,您可以在 Microsoft MAUI 应用程序中托管 Blazor 网站。这使得您的 Blazor 网站能够利用原生平台功能和各种用户控件。您可以将BlazorWebView添加到 XAML 页面,并将其指向 Blazor 应用程序的根:
<BlazorWebView HostPage=”wwwroot/index.html”
Services=”{StaticResource Services}”>
<BlazorWebView.RootComponent>
<RootComponent Selector=”#app”
ComponentType=”{x:Type local:Main}” />
</BlazorWebView.RootComponent>
</BlazorWebView>
如您从 XAML 中看到的,我们 Blazor 应用程序的根是wwwroot/index.html。在下一节中,我们将探讨 WinUI 3。
注意
截至 2022 年 6 月 20 日,MAUI 已普遍可用,但为了开发 MAUI 应用程序,您需要安装.NET 2022 预览版。
使用 MAUI 构建响应式 UI
在本节中,我们将使用 MAUI 构建一个简单的响应式 UI。在 MAUI 包含在 Visual Studio 2022 之前,您需要确保您使用 Visual Studio 2022 预览版:
-
启动一个新的.NET MAUI 应用程序,命名为
CH12_ResponsiveMAUI。 -
添加一个名为
Api的新文件夹。 -
在
Api文件夹中,添加一个名为PropertyChangedNotifier的类,并用以下代码替换其内容:namespace CH12_ResponsiveMAUI.Api { using System.ComponentModel; using System.Runtime.CompilerServices; public class PropertyChangeNotifier : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged ([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs (propertyName)); } } }
此代码是一个实现INotifyPropertyChanged接口的基类。
-
添加一个名为
Data的新文件夹。 -
在
Data文件夹中添加一个名为BaseEntity的新类,并具有以下属性:public int Id { get; set; } public DateTime CreatedDate { get; set; } public DateTime ModifiedDate { get; set; }
这些是我们实体的基本属性,将继承此类。
-
在
Data文件夹中添加一个名为IRepository的新接口,并用以下代码替换类:public interface IRepository<T> where T : BaseEntity { T GetById(int id); T FirstOrDefault(Func<T, bool> query); void Add(T entity); void Update(T entity); void Remove(T entity); List<T> GetAll(); List<T> Filter(Func<T, bool> query); int Count(); int FilteredCount(Func<T, bool> query); }
此接口将由所有我们的存储库实现。
-
在
Data文件夹中添加一个名为BaseRepository的类,并用以下代码更新类:public class BaseRepository<T> : IRepository<T> where T : BaseEntity { protected ICollection<T> Context; public BaseRepository(ICollection<T> context) { if (context == null) throw new ArgumentNullException (“context”); Context = context; } }
此类是一个通用的基本存储库,实现了IRepository接口。存储数据上下文为ICollection类型,我们将Context设置为作为参数传入的集合。
-
添加
Add方法:public void Add(T entity) { Context.Add(entity); }
此代码将一个实体添加到我们的集合中。
-
添加
Count方法:public int Count() { if (Context != null) return Context.Count; return 0; }
此代码返回我们集合中所有实体的数量。
-
添加
Filter方法:public List<T> Filter(Func<T, bool> query) { return Context.Where(query).ToList(); }
此代码接收一个查询并返回一个过滤后的项目列表。
-
添加
FilteredCount方法:public int FilteredCount(Func<T, bool> query) { return Context.Where(query).Count(); }
此代码返回我们过滤列表中的项目。
-
添加
FirstOrDefault方法:public T FirstOrDefault(Func<T, bool> query) { return Context.Where(query).FirstOrDefault(); }
此方法返回与我们的查询匹配的第一条记录。如果没有匹配项,则返回默认值。
-
添加
GetAll方法:public List<T> GetAll() { return Context.ToList(); }
此方法返回我们列表中的所有项。
-
添加
GetById方法:public T GetById(int id) { return Context.Where(t => t.Id == id) .FirstOrDefault(); }
此方法根据其 ID 号从列表中获取一个项。
-
添加
Remove方法:public void Remove(T entity) { Context.Remove(entity); }
此方法从集合中删除一个实体。
-
添加
Update方法:public void Update(T entity) { T item = Context.FirstOrDefault(t => t.Id == entity.Id); int index = Context.ToList().IndexOf(item); if (index != -1) Context.ToList()[index] = entity; }
此方法更新集合中的一个实体。
-
在
Data文件夹中添加一个名为PeopleRepository的新类,并按以下方式更新类定义:internal class PeopleRepository : BaseRepository <Person> { public PeopleRepository(ICollection<Person> context) : base(context) { } }
此类创建了一个新的Person类型存储库。
-
在
Data文件夹中添加一个名为Person的新类。然后,按以下方式更新类:public class Person : BaseEntity { public string FirstName { get; set; } public string LastName { get; set; } }
此类继承我们的BaseEntity类并添加了FirstName和LastName属性。
-
添加一个名为
ViewModels的新文件夹和一个名为ViewModelBase的新类。按以下所示更新类定义:public class ViewModelBase<T> : PropertyChangeNotifier { bool _isRefreshing; public ObservableCollection<T> Entities { get; private set; } = new ObservableCollection<T>(); public bool IsRefreshing { get { return _isRefreshing; } set { _isRefreshing = value; OnPropertyChanged(); } } }
此类是所有视图模型的基本视图模型类。它可以被转换为任何类型,并实现了PropertyChangeNotifer。
-
添加
PeopleViewModel:public class PeopleViewModel : ViewModelBase<Person> { public PeopleViewModel() { SeedPeopleRepository(); } private void SeedPeopleRepository() { Entities.Add(new Person { Id = 1, FirstName = “Person”, LastName = “One”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 2, FirstName = “Person”, LastName = “Two”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 3, FirstName = “Person”, LastName = “Three”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 4, FirstName = “Person”, LastName = “Four”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 5, FirstName = “Person”, LastName = “Five”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 6, FirstName = “Person”, LastName = “Six”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 7, FirstName = “Person”, LastName = “Seven”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 8, FirstName = “Person”, LastName = “Eight”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 9, FirstName = “Person”, LastName = “Nine”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 10, FirstName = “Person”, LastName = “Ten”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 11, FirstName = “Person”, LastName = “Eleven”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 12, FirstName = “Person”, LastName = “Twelve”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 13, FirstName = “Person”, LastName = “Thirteen”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 14, FirstName = “Person”, LastName = “Fourteen”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 15, FirstName = “Person”, LastName = “Fifteen”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 16, FirstName = “Person”, LastName = “Sixteen”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 17, FirstName = “Person”, LastName = “Seventeen”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 18, FirstName = “Person”, LastName = “Eighteen”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 19, FirstName = “Person”, LastName = “Ninetenn”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Entities.Add(new Person { Id = 20, FirstName = “Person”, LastName = “Twenty”, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); } }
此代码用人员填充我们的集合。
-
在项目根目录中添加一个名为
SplashPage的新页面:public partial class SplashPage : ContentPage, INotifyPropertyChanged { Timer _timer; double _progress; public event PropertyChangedEventHandler PropertyChanged; public SplashPage() { InitializeComponent(); _timer = new Timer(new TimerCallback((s) => ReportProgress()), null, TimeSpan.Zero, TimeSpan.FromSeconds(3)); } ~SplashPage() => _timer.Dispose(); }
我们的SplashPage是一个加载页面,将以进度条和标签的形式向用户显示进度。该类继承自Content页面并实现了INotifyPropertyChanged事件。我们有一个计时器,其回调是一个报告加载进度的方法。
-
添加
ReportProgress方法:private void ReportProgress() { _timer.Dispose(); Task.Run(() => { // Run code here for (int i = 0; i <= 100; i++) { Thread.Sleep(250); _progress = (double)i / 100; SafeInvokeInMainThread (UpdateProgress); } SafeInvokeInMainThread(LoadMainPage); }); }
此方法停止计时器并运行代码以更新应用程序加载进度状态。它使用一个安全调用方法来更新启动屏幕。
-
添加
LoadMainPage方法:private void LoadMainPage() { Application.Current.MainPage = new AppShell(new BaseEntity() { Id = 1, CreatedDate = DateTime.Now, ModifiedDate = DateTime.Now }); Shell.Current.GoToAsync(“//main”); }
此方法将应用程序的MainPage设置为AppShell,并传递一个类型为BaseEntity的参数。
-
添加
SaveInvokeInMaInThread方法:private void SafeInvokeInMainThread(Action action) { if (DeviceInfo.Platform == DevicePlatform.WinUI) { Application.Current.Dispatcher .Dispatch(action); } else { MainThread.BeginInvokeOnMainThread (action); } }
此代码在主线程上执行安全调用以更新 UI。该方法在调用设备对应的方法之前会检查应用程序正在运行的设备。
-
添加
UpdateProgress方法:private void UpdateProgress() { LoadingProgressBar.ProgressTo(_progress, 500, Easing.Linear); LoadingProgressLabel.Text = $”Progress Update: Performing load operation {(int) (_progress * 100)} of 100...”; }
此方法更新进度条和标签。
-
更新
SplashPageXAML,如下所示:<?xml version=”1.0” encoding=”utf-8” ?> <ContentPage xmlns=” http://schemas.microsoft.com/dotnet/2021/maui” xmlns:x=”http://schemas.microsoft.com/winfx/ 2009/xaml” x:Class=”CH12_ResponsiveMAUI.SplashPage” Title=”SplashPage”> <VerticalStackLayout VerticalOptions=”Center”> <StackLayout HorizontalOptions=”Center” VerticalOptions=”Center”> <Label FontSize=”32” Text=”Responsive MAUI Example” /> <Label x:Name=”LoadingProgressLabel” FontSize=”12” Text=”Loading...” /> <ProgressBar x:Name=”LoadingProgressBar” Progress=”0” /> </StackLayout> </VerticalStackLayout> </ContentPage>
此标记包含我们的 UI 定义,当它运行时将由代码更新。
-
通过替换以下 XAML 来更新
MainPage:<?xml version=”1.0” encoding=”utf-8” ?> <ContentPage xmlns= “http://schemas.microsoft.com/dotnet/2021/maui” xmlns:x=”http://schemas.microsoft.com/winfx/ 2009/xaml” x:Class=”CH12_ResponsiveMAUI.MainPage”> <ScrollView> <HorizontalStackLayout Spacing=”25” Padding=”30,0” VerticalOptions=”Center”> <StackLayout Margin=”20” HorizontalOptions=”Start”> <CollectionView x:Name= “collectionView” ItemsSource=”{Binding Entities}”> <CollectionView.ItemTemplate> <DataTemplate> <Grid Padding=”10”> <Grid.RowDefinitions> <RowDefinition Height=”Auto” /> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition Width=”Auto” /> <ColumnDefinition Width=”Auto” /> <ColumnDefinition Width=”Auto” /> <ColumnDefinition Width=”Auto” /> <ColumnDefinition Width=”Auto” /> </Grid.ColumnDefinitions> <Label Grid.Column=”1” Text=”{Binding Id}” FontAttributes=”Bold” /> <Label Grid.Column=”2” Text=”{Binding FirstName}” FontAttributes=”Bold” /> <Label Grid.Column=”3” Text=”{Binding LastName}” FontAttributes=”Bold” /> <Label Grid.Column=”4” Text=”{Binding CreatedDate}” FontAttributes=”Bold” /> <Label Grid.Column=”5” Text=”{Binding ModifiedDate}” FontAttributes=”Bold” /> </Grid> </DataTemplate> </CollectionView.ItemTemplate> </CollectionView> </StackLayout> <StackLayout HorizontalOptions=”End”> <Image Source=”dotnet_bot.png” SemanticProperties.Description=”Cute dot net bot waving hi to you!” HeightRequest=”200” HorizontalOptions=”Center” /> <Label Text=”Hello, World!” SemanticProperties.HeadingLevel= “Level1” FontSize=”32” HorizontalOptions=”Center” /> <Label Text=”Welcome to .NET Multi-platform App UI” SemanticProperties.HeadingLevel= “Level2” SemanticProperties.Description= “Welcome to dot net Multi platform App U I” FontSize=”18” HorizontalOptions=”Center” /> <Button x:Name=”CounterBtn” Text=”Click me” SemanticProperties.Hint=”Counts the number of times you click” Clicked=”OnCounterClicked” HorizontalOptions=”Center” /> </StackLayout> </HorizontalStackLayout> </ScrollView> </ContentPage>
此代码通过添加人员表来更新原始源代码。
-
添加
PeopleRepository类变量,并更新MainPage类的构造函数,如下所示:PeopleRepository _peopleRepository; public MainPage() { InitializeComponent(); BindingContext = new PeopleViewModel(); }
此代码通过将MainPage的BindingContext设置为PeopleViewModel来修改我们的MainPage。
- 运行代码,你应该会看到以下屏幕:

Figure 12.10 – 启动页面
下一个屏幕是你将看到的:

Figure 12.11 – 带有滚动视图中的表格和响应点击按钮的主表单
我们成功构建了一个响应式启动屏幕,它还会填充表格并响应用户点击按钮。这标志着我们对 MAUI 的探讨结束。现在我们将转向 WinUI 3。
使用 WinUI 3 构建响应式 UI
在本节中,我们将探讨如何在 WinUI 3 应用程序中执行长时间运行的操作时使用ProgressRing组件来提供用户反馈。当用户触发一个长时间运行的操作并阻止 UI 时,在操作完成之前提供用户反馈是一个好主意。让我们编写一个简单的应用程序,使用以下步骤来模拟长时间运行的操作:
-
启动一个新的 WinUI3 应用程序,并将其命名为
CH12_ResponsiveWinUI3。 -
打开
MainWindow.xaml并替换窗口标签之间的现有 XAML,如下所示:<StackPanel VerticalAlignment=”Center” HorizontalAlignment=”Center”> <ProgressRing x:Name=”ProgressRingIndicator1” IsActive=”{x:Bind IsWorking, Mode=OneWay}” Visibility=”{x:Bind IsWorking, Mode=OneWay}” /> <Button x:Name=”DoWorkButton” Content=”Do Work” Click=”DoWorkButton_Click” /> <TextBlock x:Name=”MessageTextBlock” /> </StackPanel>
我们已经使用OneWay绑定将ProgressRing类的IsActive和Visibility属性绑定到IsWorking属性。
-
在类的代码后面实现
INotifyPropertyChanged接口。 -
将以下成员添加到类中:
private DispatcherTimer _dispatcherTimer; public event PropertyChangedEventHandler PropertyChanged; private bool _isWorking;
_dispatcherTimer将被用来模拟长时间运行的操作。PropertyChanged事件将被用来通知ProgressRing,IsWorking属性已更改,_isWorking变量将被更新,让ProgressRing知道是显示还是隐藏自己。
-
如果
PropertyChanged事件不是null,则添加一个方法来引发该事件:private void NotifyPropertyChanged(string property) { if (PropertyChanged != null) { PropertyChanged(this, new PropertyChangedEventArgs(property)); } }
当我们设置 IsWorking 属性时,我们调用此方法以引发 PropertyChanged 事件。
-
在构造函数中添加以下三行代码:
_dispatcherTimer = new DispatcherTimer(); _dispatcherTimer.Interval = TimeSpan.FromSeconds(10); _dispatcherTimer.Tick += DispatcherTimer_Tick;
这三行代码实例化了我们的 DispatcherTimer,将其间隔设置为 10 秒,并添加了 Tick 事件处理程序。
-
我们现在将添加
DispatcherTimer_Tick事件处理程序:private void DispatcherTimer_Tick(object sender, object e) { _dispatcherTimer.Stop(); _dispatcherTimer.Tick -= DispatcherTimer_Tick; IsWorking = false; MessageTextBlock.Text = “Work completed.”; }
我们停止计时器并移除事件处理程序,以防止它再次触发并被保留在内存中。然后,我们将 IsWorking 属性设置为 false,这将导致 ProgressRing 被隐藏并变为不活动状态。然后,我们向 MessageTextBlock 添加一条消息。
-
现在,添加
IsWorking属性:public bool IsWorking { get { return _isWorking; } set { _isWorking = value; NotifyPropertyChanged(“IsWorking”); } } -
当设置我们的属性时,我们调用
NotifyPropertyChanged方法来引发PropertyChanged事件,让ProgressRing知道属性已更改。 -
现在,添加按钮点击的代码:
private void DoWorkButton_Click(object sender, RoutedEventArgs e) { DoWorkButton.Visibility = Visibility.Collapsed; IsWorking = true; _dispatcherTimer.Start(); }
我们折叠我们的按钮,因为它不再需要。将 IsWorking 属性设置为 true,并启动我们的 DispatcherTimer。
- 运行代码。你应该看到一个显示
ProgressRing的单个按钮,持续 10 秒。然后,ProgressRing应该消失,并被文本 工作完成 替换。
现在我们已经结束了对响应式 UI 的探讨,让我们总结一下我们学到了什么。
摘要
在本章中,你学习了如何使用各种 UI 框架来使 UI 响应。首先,我们看了 WinForms。使用 WinForms,我们启用了 DPI 和长文件路径感知。我们还确保即使在运行长时间的后台任务时,我们也能在表格中翻页并执行其他 UI 操作,我们还添加了一个更新加载进度的启动画面。
使用 WPF,我们成功地创建了一个具有长时间运行任务(可以取消并显示进度指示)的窗口。它还有一个分页的数据表和按钮,当点击时,会更新点击计数标签。
然后,我们看了 ASP.NET 中的内存缓存和分布式缓存。我们还使用了 AJAX 来更新当前显示页面的部分,并研究了 WebSockets 和 SignalR。我们使用 SignalR 实现了一个实时 ASP.NET 聊天应用程序。
然后,我们继续研究 MAUI。特别是,我们研究了布局、可访问性和 BlazorWebView。最后,我们研究了 WinUI 3 以及如何在长时间运行的过程中提供用户反馈。
在下一章中,我们将探讨分布式系统。但首先,尝试回答下一节中的问题,然后进行一些进一步阅读,以增强你对响应式 UI 的了解。
问题
-
你如何使 WinForms 应用程序在高清屏幕或普通 DPI 的大屏幕上正确缩放?
-
你如何在 Windows 上处理长文件路径?
-
当你的应用程序启动时间较长时,你如何保持用户的参与度?
-
当你有一个长时间运行的过程正在运行时,你如何保持应用程序对用户输入的响应性?
-
你可以使用哪些缓存方法来加速对资源的访问?
-
你如何只加载网页的一部分?
-
列出两个用于执行网络数据传输和实时网络通信的框架?
-
列出 MAUI 中可用的三种无障碍方法。
-
如何将现有的 Blazor Web 应用包含在 MAUI 项目中?
-
当您的应用程序已经加载,并且用户启动一个长时间运行的操作时,您可以使用哪些控件来提供用户反馈,以便用户不会认为您的 WinUI 3 应用程序已崩溃?
进一步阅读
-
哪个更好?WebSockets 还是 SignalR:
dotnetplaybook.com/which-is-best-websockets-or-signalr/ -
为什么 SignalR/messagepack 比 gRPC/protobuf 快两倍?:
github.com/grpc/grpc-dotnet/issues/812 -
教程:开始使用 ASP.NET Core SignalR:
docs.microsoft.com/aspnet/core/tutorials/signalr?view=aspnetcore-5.0&tabs=visual-studio -
WebSocket:
javascript.info/websocket -
将您的应用从 Xamarin.Forms 迁移: https://docs.microsoft.com/dotnet/maui/get-started/migrate
-
Xamarin.Forms Made Easy:
winstongubantes.blogspot.com/2018/09/backgrounding-with-xamarinforms-easy-way.html -
Xamarin – 与线程一起工作:
lukealderton.com/blog/posts/2016/october/xamarin-forms-working-with-threads/ -
在 Windows 上创建 Android 模拟器:
docs.microsoft.com/xamarin/android/get-started/installation/android-emulator/device-manager?tabs=windows&pivots=windows -
安装 Microsoft OpenJDK:
docs.microsoft.com/xamarin/android/get-started/installation/openjdk -
为 VS 2022 的单项目 MSIX 打包工具: https://marketplace.visualstudio.com/items?itemName=ProjectReunion.MicrosoftSingleProjectMSIXPackagingToolsDev17
-
使用 Blazor 组件虚拟化提高渲染性能:
www.daveabrock.com/2020/10/20/blazor-component-virtualization/#:~:text=Improve%20rendering%20performance%20with%20Blazor%20component%20virtualization%20Use,the%20entire%20HTML%20tree%20loads%20from%20the%20server. -
如何在 .NET MAUI 中重用 Xamarin.Forms 自定义渲染器:
www.syncfusion.com/blogs/post/how-to-reuse-xamarin-forms-custom-renderers-in-net-maui.aspx -
宣布 .NET MAUI 预览版 7:
devblogs.microsoft.com/dotnet/announcing-net-maui-preview-7/ -
.NET 多平台应用程序用户界面:
dotnet.microsoft.com/en-us/apps/maui
第十三章:第十三章:分布式系统
在本章中,你将学习关于分布式应用程序以及如何提高它们性能的知识。你将了解如何使用命令查询责任分离(CQRS)软件设计模式、事件溯源和微服务来构建高性能的应用程序。你将学习如何使用云服务提供商,如 Microsoft Azure,利用 Cosmos DB、Azure Functions 和开源的 Pulumi 基础设施工具构建可扩展的分布式解决方案。
在本章中,我们将涵盖以下主题:
-
实现 CQRS 设计模式:在本节中,我们将通过一个示例项目来演示如何实现 CQRS 设计模式,该示例项目展示了命令和查询的分离。
-
实现事件溯源:许多资源总是将事件溯源与 CQRS 结合展示。但在这个部分,我们将编写一个示例项目,展示没有 CQRS 的纯事件溯源。通过这样做,你将了解如何单独实现 CQRS 和事件溯源,并能够将它们结合起来协同工作。
-
使用 Microsoft Azure 构建分布式系统:在本节中,我们将提供 Azure Functions(特别是持久 Azure Functions)的高级概述,以提供在分布式环境中表现良好的强大、安全且可扩展的无服务器代码。我们还将探讨容器和无服务器之间的区别,以及何时使用其中一种而不是另一种。
-
使用 Pulumi 管理你的云基础设施:管理 Azure 资源可能会变得难以控制,尤其是在你部署的微服务数量增加时。因此,在本节中,我们将探讨 Pulumi 如何允许你使用纯 C# 来管理你的云基础设施和资源,这些代码可以包含在你的构建、测试和部署管道中。
通过完成本章,你将获得以下技能:
-
你将能够将命令和查询分离到不同的服务中。
-
你将能够将状态更改持久化为一系列状态更改事件。
-
你将能够理解容器和无服务器之间的区别,并知道何时使用其中一种而不是另一种。
-
你将理解不同的 Durable Azure Function 类型以及设计模式,以便你可以使用它们来构建无服务器函数。
-
你将能够使用 Pulumi 管理你的云。
技术要求
为了跟随本章并执行必要的编程任务,你需要以下组件:
-
Visual Studio 2022 或更高版本
-
本书源代码:
github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH13 -
可选:一个 Microsoft Azure 账户
-
可选:一个 Pulumi 订阅
实现 CQRS 设计模式
在本节中,我们将探讨 命令查询责任分离(CQRS)设计模式。简单来说,命令是一个执行动作的方法,而查询是一个返回数据的方法。命令不执行查询,查询也不执行命令。命令可以为查询有单独的模型。现在,让我们编写一个简单的控制台应用程序,演示实现此模式是多么简单,该模式在微服务开发中被广泛使用:
-
启动一个名为
CH13_CQRSPattern的新控制台应用程序。 -
添加一个名为
CQRSBasedClass的新类。 -
添加
SleepCommand方法:public void SleepCommand(int milliseconds) { Thread.Sleep(milliseconds); }
我们的 SleepCommand 方法是一个命令的例子。它接受一个长度为几毫秒的参数。然后执行一个命令,使当前线程休眠由调用者指定的毫秒数。这个特定的命令不返回任何值。
-
添加
DateTimeQuery方法:public DateTime DateTimeQuery() { return DateTime.Now; }
我们的 DateTimeQuery 方法是一个查询的例子。它是一个无参数查询,尽管查询可以有参数。查询不执行任何命令。它只是将当前日期和时间返回给调用者。
-
在
Program类中添加ExecuteCommand方法:private static void ExecuteCommand() { new CQRSBasedClass().SleepCommand(1000); }
ExecuteCommand 方法在我们的 CQRSBasedClass 中执行 SleepCommand,这将导致当前线程休眠 1 秒。
-
添加
ExecuteQuery方法:private static DateTime ExecuteQuery() { return new CQRSBasedClass().DateTimeQuery(); }
ExecuteQuery 方法在我们的 CQRSBasedClass 中执行 DateTimeQuery,查询当前日期和时间并返回值。
-
更新
Program.cs文件,如下所示:Console.WriteLine("Hello, World! This is the most simple example of CQRS in action."); ExecuteCommand(); Console.WriteLine($"The current date and time is: {ExecuteQuery()}.");
我们通过向控制台写入一条消息来开始我们的程序。然后,我们调用 ExecuteCommand。最后,我们向控制台写入一条消息,其中包含由 ExecuteQuery 调用返回的当前日期和时间。
如您所见,在它的最基本形式中,CQRS 模式实际上非常简单。命令执行一个动作,而什么都不做,而查询执行一个查询,而什么都不做。我们可以将命令移入它们自己的命令类,这样类的唯一目的就是执行命令。我们也可以通过将它们放入它们自己的查询类中来做同样的事情,这样查询类所做的只是返回查询。
如果你研究这本书的源代码,你会看到我们已经这样做了。我们有一个名为 CommandClass 的类,其中有一个名为 Sleep 的命令。我们还有一个名为 QueryClass 的类,其中有一个名为 Now 的查询。CQRS 是微服务开发中使用的启用模式。它通常与消息代理、消息总线、消息发送和接收、领域建模、领域事件、事件溯源、最终一致性、单独的读写模型以及 领域驱动设计(DDD)一起使用。这就是人们容易迷失的地方。但尽管 CQRS 模式与所有这些一起使用,该模式本身非常简单,它使这些其他模式和技术的结合变得非常融洽。
在数据库操作方面,你可以将add、edit、delete和update操作视为命令,而将select操作视为查询。
现在我们对 CQRS 模式有了简单的理解,在下一节中,我们将转向理解和实现事件溯源。
实现事件溯源
当你考虑文档存储中的文档和数据库中的记录时,这些通常是企业的真相来源。它们的状态是真相来源。
事件溯源记录的事件成为你的真相来源,而不是表格中的数据状态,或文档存储中的文档状态。
因此,我们不是使用状态作为真相来源,而是可以使用记录的事件作为真相来源。
在编程的旧时代,这被称为审计跟踪。我记得几年前我在一个数据库上工作。它有一个审计表。在那个表中,记录了在数据库上执行的所有操作以及由谁执行。我们可以知道数据操作发生的时间、那些数据操作是什么,以及谁或哪个过程执行了那些数据操作。然后,如果数据库出现任何问题,我们可以分析那个表并知道哪个操作导致了结果问题。为了存储这些信息,我们会使用在每次add、update、delete和read操作上触发的数据库触发器。这些触发器是在数据操作上触发的,记录了发生了哪些数据修改、谁进行了这些修改、为什么进行修改,以及修改发生的时间和日期。
在本节中,我们将探讨事件溯源,它记录的事件成为你的真相来源。事件允许你理解你如何在特定时间点达到特定的状态。
理解事件溯源的好处的一个简单方法就是看看你的银行对账单。当你收到银行对账单时,你从上个月结转的余额开始。然后,你看到一份在账单涵盖期间发生的交易列表,包括进入你账户的金钱和离开你账户的金钱。每一笔交易都是一个事件。这些事件可以是资金转入、资金转出、直接借记支付、利息支付、定期订单支付、银行费用支付、商品支付、工资/薪水的支付等等。
当你考虑这个场景时,你的银行对账单显示了你的钱是如何进入和离开账户的。但从数据库的角度来看,仅仅通过查看数据,这并不那么容易。当你查看数据时,你通常必须编写一个查询,将关系数据库中的多个表连接起来,以揭示你的账户状态是如何变化的实际情况。但你并不一定知道导致这些变化发生的原因。
然而,在相同的场景中,当你存储事件时,你是在存储事实。这些事实基于过去发生过的真实事件,这就是为什么它们可以信赖。
对于交易日志,它们会告知你发生了哪些状态变化。然而,它们并不一定告诉你为什么这些状态变化被做出。另一方面,当你存储事件时,它们会告知你发生了哪些状态变化,以及这些状态变化的原因。
事件以追加形式存储为聚合。聚合是一种一致性保护。你可以看到状态变化及其导致这些变化的环境。这意味着你可以通过向前或向后重放事件来将状态回滚到特定时间点的最后已知一致状态。你可以使用事件日志提供审计跟踪。诸如为什么和何时等信息对于各种业务功能,如高级管理层、市场营销、财务和资源规划等非常有用,因为事件日志充满了非常有价值的企业信息。
回到我们的示例场景,一个事件代表在我们银行领域发生的事实。我们银行系统中的每个事件都是真理的来源,我们的银行账户的当前状态就是从这些事实中得出的。这些事实是不可变业务事实。
我们的银行事件将遵循提供状态信息、提供上下文信息的元数据、发生的时间和日期以及其他必要和适当的信息的正常方法。
让我们看看一个例子,说明我们如何聚合事件,以便它们达到我们银行账户的特定状态:
-
事件:
-
投资公司于 2021 年 6 月 12 日凌晨 12:43 向客户发放了 39 英镑的股息。
-
投资公司于 2021 年 6 月 12 日凌晨 12:45 将 39 英镑的股息支付到客户的银行账户。
-
-
事件
-
薪资 2300 英镑于 2021 年 7 月 25 日凌晨 12:00 通过 BACS 支付到客户的银行账户。
-
客户于 2021 年 7 月 26 日上午 9:11 将 230 英镑的定期订单从客户的银行账户转入储蓄账户,以建立紧急储备金。
-
客户的银行账户于 2021 年 7 月 25 日晚上 7:00 通过相关的安卓银行应用程序支付了 432 英镑的直接借记费用给当地政府缴纳租金。
-
客户于 2021 年 7 月 26 日晚上 8:29 通过网上银行支付了 103 英镑的直接借记费用给当地政府缴纳地方税。
-
客户于 2021 年 7 月 27 日晚上 9:35 使用非接触式支付向商家支付了 23.79 英镑的杂货费用。
-
如您从我们的银行场景中看到的,当我们使用事件作为基于事实的真理点时,我们可以看到资金的来源、去向、方式、金额以及发生的精确日期和时间。
这些事件确保数据处于一致状态,有审计跟踪,并提供有价值的信息,允许基于可信事实做出业务决策。
继续我们的银行场景,每个银行账户都会有一个流和唯一标识符。针对该银行账户发生的所有事件将通过其流进行记录。因此,我们为每个聚合体得到一个流。在我们的银行场景中,我们的聚合体是针对特定银行账户发生的事件组。
事件溯源示例项目
在本节中,我们将编写一个简单的事件溯源应用程序,并提供使用示例。要实现项目,请按照以下步骤操作:
-
启动一个新的 .NET 6.0 控制台应用程序,并将其命名为
CH13_EventSourcing。 -
添加一个名为
IEvent的新公共接口,方法体为空。这是一个方便的接口,用于标记任何对象为事件。 -
添加一个名为
IRegisterable的新公共接口,并添加以下方法:void RegisterWithEventAggregator(IEventAggregator eventAggregator);
此方法允许可注册的对象将自己注册到事件聚合器中。
-
添加一个名为
IEventAggregator的新公共接口,并添加以下方法:void Register(IRegisterable registerable); void Register<T>(EventHandler<T> eventhandler) where T : IEvent; void RaiseEvent(IEvent evt);
Register 方法用于将 IRegisterable 类型的对象注册到事件聚合器。Register<T> 方法注册一个指定对象类型的 T 类型的事件处理器。最后,RaiseEvent 执行传入的参数事件。
-
添加一个名为
EventHandler的新类,并用以下代码替换其内容:namespace CH13_EventSourcing; public delegate void EventHandler<T>(T evt) where T : IEvent;
此委托定义了我们的事件处理器,该处理器为 T 类型,用于 IEvent 类型的事件。
-
添加一个名为
SingleThreadedEventAggregator的新类,该类实现了IEventAggregator接口。 -
将以下字典字段添加以保存我们的事件处理器:
IDictionary<Type, IList<EventHandler<IEvent>>> _eventHandlers;
此字典定义了指定类型对象的 IEvent 类型事件处理器列表。
-
添加以下构造函数:
public SingleThreadedEventAggregator() { _eventHandlers = new Dictionary<Type, IList<EventHandler<IEvent>>>(); }
在这里,我们实例化我们的事件处理器字典。
-
更新
Register方法,如下所示:public void Register(IRegisterable registerable) { registerable.RegisterWithEventAggregator(this); }
此方法将传入的可注册类型的事件聚合器注册。
-
更新
Register<T>方法,如下所示:public void Register<T>(EventHandler<T> eventHandler) where T : IEvent { if (!_eventHandlers.ContainsKey(typeof(T))) { _eventHandlers[typeof(T)] = new List<EventHandler<IEvent>>(); } var eventHandlerList = _eventHandlers[typeof(T)]; eventHandlerList.Add(evt => eventHandler ((T)evt)); }
此方法检查我们的字典是否包含指定类型的键;如果没有,则添加一个。然后,它创建一个新的指定类型的事件处理器列表,并将事件处理器添加进去。
-
更新
RaiseEvent方法:public void RaiseEvent(IEvent evt) { IList<EventHandler<IEvent>> eventHandlerList; if (_eventHandlers.TryGetValue(evt.GetType(), out eventHandlerList)) { foreach (EventHandler<IEvent> eventHandler in eventHandlerList) { eventHandler.Invoke(evt); } } }
此方法获取传入事件的事件处理器列表,并遍历它们,调用它们。
-
添加一个名为
MultiThreadedEventAggregator的新类,该类实现了IEventAggregator接口。 -
将以下字典添加到类中:
IDictionary<Type, IList<EventHandler<IEvent>>> _eventHandlers;
此字典将保存事件处理器及其事件的列表。
-
添加以下构造函数:
public MultiThreadedEventAggregator() { _eventHandlers = new ConcurrentDictionary<Type, IList<EventHandler<IEvent>>>(); }
我们的构造函数初始化我们的事件处理器列表。注意,我们正在使用并发字典来处理多线程场景。
-
添加以下方法:
public void Register(IRegisterable registerable) { registerable.RegisterWithEventAggregator(this); }
此方法将可注册对象的的事件处理程序注册到多线程事件聚合器中。
-
添加以下
Register方法:public void Register<T>(EventHandler<T> eventHandler) where T : IEvent { if (!_eventHandlers.ContainsKey(typeof(T))) { _eventHandlers[typeof(T)] = new List<EventHandler<IEvent>>(); } var eventHandlerList = _eventHandlers[typeof(T)]; eventHandlerList.Add(evt => eventHandler((T)evt)); }
此方法检查我们的字典是否包含指定类型的键;如果没有,则添加一个。然后,它创建一个新的指定类型的事件处理程序列表并将事件处理程序添加进去。
-
添加
RaiseEvent方法:public void RaiseEvent(IEvent evt) { IList<EventHandler<IEvent>> eventHandlerList; if (_eventHandlers.TryGetValue(evt.GetType(), out eventHandlerList)) { Parallel.ForEach(eventHandlerList, eventHandler => { eventHandler.Invoke(evt); }); } }
此方法遍历存储在事件处理程序列表中的所有事件处理程序,并为传入作为参数的指定事件调用它们。
这是完成的基础项目。现在,让我们看看如何使用我们的事件源代码的示例。
-
添加一个名为
BankApp的文件夹。 -
将以下
DividendPayment类添加到BankApp文件夹中:internal class DividendPayment : IEvent { public string From { get; set; } public string To { get; set; } public DateTime PaymentDate { get; set; } public Decimal Amount { get; set; } }
此类定义了我们的股息付款事件。此事件提供了有关股息付款的信息,包括谁发送了付款、付款对象是谁、付款日期以及付款金额。
-
将
InvalidDateException类添加到BankApp文件夹中:internal sealed class InvalidDateException : Exception { public InvalidDateException() : base() { } public InvalidDateException(string? message) : base(message) { } public InvalidDateException(string? message, Exception? innerException) : base(message, innerException) { } }
此类实现了 System.Exception 类,并将用于通知他人由于日期不正确而发生了异常。
-
将
StandingOrderPayment类添加到BankApp文件夹中:internal class StandingOrderPayment : IEvent { public string From { get; set; } public string To { get; set; } public DateOnly StartDate { get; set; } public decimal Amount { get; set; } }
此类定义了我们的定期订单付款事件,它通知我们谁支付了定期订单以及支付对象是谁,定期订单的开始日期以及应支付的金额。
-
将
EventHandlers类添加到BankApp文件夹中,并按以下方式更新:internal class EventHandlers : IRegisterable { }
我们的类实现了 IRegisterable 接口,并将用于将我们的事件注册到用于这些事件的聚合器中。
-
添加以下属性和构造函数:
public string Name { get; } public EventHandlers(string name) { Name = name; }
此属性在构造函数中设置,以便于人类参考 EventHandlers 类。
-
添加以下注册代码:
public void RegisterWithEventAggregator (IEventAggregator eventAggregator) { eventAggregator.Register<DividendPayment> (OnDividendPayment); eventAggregator.Register<StandingOrderPayment> (OnStandingOrderPayment); }
此方法将股息付款和定期订单的事件及其事件处理程序注册到事件聚合器中。
-
为股息付款添加以下处理方法:
private void OnDividendPayment(DividendPayment evt) { Console.WriteLine($"Dividend paid by {evt.From} to {evt.To} on {evt.PaymentDate} of £{evt.Amount}."); }
每次支付股息时,都会调用此事件处理程序,并将股息付款事件的属性记录到控制台窗口中。
-
为定期付款添加以下处理方法:
private void OnStandingOrderPayment (StandingOrderPayment evt) { try { Console.WriteLine($"Standing order paid by {evt.From} to {evt.To} on {GetStanding OrderDate(evt.StartDate)} of £{evt.Amount}."); } catch (InvalidDateException idex) { Console.WriteLine(idex.Message); } }
每次支付定期订单付款时,都会调用此事件处理程序。定期订单付款事件的属性将在控制台上输出。在此过程中,会检查付款日期是否有效;如果不是,则引发 InvalidDateException。
-
添加
GetStandingOrderDate方法:private static DateTime GetStandingOrderDate(DateOnly startDate) { if (DateTime.UtcNow.Ticks < startDate.ToDateTime (TimeOnly.FromTimeSpan(TimeSpan.Zero)).Ticks) throw new InvalidDateException("Invalid Date: Payment date cannot be before standing order start date!"); if (DateTime.Now.Day < startDate.Day) throw new InvalidDateException("InvalidDate: Payment cannot be made before the standing order month pay day."); return DateTime.Now; }
此方法接受定期订单的开始日期,并将该日期与当前日期进行比较。如果日期早于定期订单的开始日期或不在该月的付款日期或之后,则抛出异常。否则,返回当前日期和时间。
-
将
Program.cs类中的文本替换为以下内容:using CH13_EventSourcing; using CH13_EventSourcing.BankApp; using EventHandlers = CH13_EventSourcing.BankApp .EventHandlers; SingleThreadedEventAggregator eventAggregator = new(); EventHandlers eventHandlers = new("Payment Event Handlers"); DividendPayment dividendPayment = new DividendPayment { From = "Company Name", To = "Customer Name", PaymentDate = DateTime.Now, Amount = 23.45M }; StandingOrderPayment standingOrderPayment = new StandingOrderPayment { From = "Customer Name", To = "Company One", StartDate = DateOnly.Parse ("25/02/2022") }; eventAggregator.Register(eventHandlers); eventAggregator.RaiseEvent(dividendPayment); eventAggregator.RaiseEvent(standingOrderPayment);
这是我们的应用程序入口点。我们创建了一个单线程的事件聚合器。然后,我们创建了一个 EventHandlers 类的实例,并将显示这些事件处理器用于处理支付事件的文本传递给它。接下来,我们创建了两个事件——一个是用于股息支付,另一个是用于定期订单支付。然后,将 EventHandlers 类的实例传递给事件聚合器,以便注册事件处理器。最后,引发了股息支付和定期订单的事件。
- 运行程序。你应该会看到以下类似的输出:


图 13.1 – 我们事件源应用程序的输出
到此为止,你已经编码并运行了一个事件源应用程序。在此之前,你用 CQRS 应用程序做了同样的事情。通过编写这两个应用程序,你看到了纯 CQRS 和纯事件源的实际应用。有了这些知识,你现在可以编写使用这些模式单独或结合它们以协同工作的应用程序。在下一节中,我们将从编写分布式系统的角度提供一个关于微软 Azure 的高级概述。
使用微软 Azure 进行分布式系统
在本节中,我们将学习如何使用 Azure 通过无服务器功能实现持久化微服务,即 Azure Functions。
什么是 Azure?正如你现在已经意识到的,微软 Azure 是微软为托管你的数据库、API 和数据资源提供的云服务。它还有许多其他形式的云服务。微软 Azure 包括付费服务、免费一年的服务和始终免费的服务。建议你审查他们不同的云服务,并将它们与其他提供商进行比较,以满足你的需求。特别注意哪些服务是免费的,以及它们的用量限制,以及哪些服务你需要付费。
让我们列举一些将应用程序和数据库托管在云端而不是本地的一些好理由。首先,你不需要为硬件或电力成本付费。然后,当现有的基础设施达到最大容量时,就需要进行扩展和扩展。随着软件及其用户需求复杂性的增长,硬件可能会很快过时。因此,有许多理由使用云,你需要仔细考虑这些理由,并且随着这些理由的出现,会有利弊。因此,在决定使用云时,确保你研究、记录并评估一切,以便你有一个正确的起点。这将使系统管理、维护和长期业务增长变得更加容易。如果你从一开始就做对了,那么你将在未来的某个时候避免潜在的头痛!
微服务通常是一个简单的 Web 服务,它接收请求并发送响应。存在许多种类的微服务,例如电影和音乐流媒体服务以及文档上传和检索服务。在微服务的 DDD 中,微服务通常会有一个数据源。在 Azure 上,这可能是存储在 blob 存储中的文件,存储在 Azure SQL Server 关系型数据库中的数据,甚至是存储在 Azure Cosmos DB NoSQL 数据库中的数据。
现代微服务实现越来越不依赖于使用 Docker 和 Kubernetes 等工具的容器化,而是更多地依赖于如 Azure 函数这样的纯无服务器选项。Azure 函数的美丽之处在于它仅在调用期间处于活跃状态。一旦函数完成了它需要完成的工作,它就简单地进入休眠状态。与容器化解决方案相比,Azure 函数使用的计算资源和电力更少。唯一的缺点是你必须管理许多 Azure 函数。因此,就像容器化一样,你需要一种方法来以易于维护、扩展和实用的方式编排所有 Azure 函数。
Azure 函数
Azure 函数是一个工作单元。当你实现 Azure 函数时,你不需要担心基础设施的配置和管理,因为 Azure 函数是微软的无服务器计算服务之一。
无服务器计算由无服务器提供商管理。这意味着无服务器计算提供商负责大量投资于配置和管理托管你的无服务器计算服务(如 Azure 函数)的基础设施。这意味着你可以节省硬件和电力成本,并可以将全部精力集中在开发、测试、部署和维护你的无服务器项目上。
微软对无服务器计算的投资为你的 Azure 函数提供了网络、服务发现、路由和事件,以促进你的函数与其他软件系统架构方面的性能通信。
Azure 函数通常由一个或多个你可以绑定和触发的输入组成,以及你可以绑定输出的部分,你的自定义代码位于输入和输出之间,如下面的图所示:

图 13.2 – 高级 Microsoft Azure 函数概念图
Azure 函数是开发分布式系统时使用的优秀工具。但是,当你的项目中 Azure 函数的数量开始增加时,使用 Azure 函数的复杂性开始显现。管理大量 Azure 函数需要一种编排形式。编排使得基础设施团队管理大量 Azure 函数变得更加简单。用于 Azure 函数的编排是可持久 Azure 函数。
可持久 Azure 函数
你可以使用持久化函数执行具有状态编排的 Azure Functions。Azure Functions 提供了一个名为 Durable Functions 的扩展。持久化函数应用程序由多个 Azure Functions 组成。持久化函数编排中的每个函数都可以执行不同的角色和/或功能。持久化函数的不同类型包括活动、编排器、实体和客户端。让我们简要地看一下每种持久化函数类型。
持久化函数类型 – 活动
基本的工作单元是在持久化函数编排中定义的活动函数。这意味着当编排函数执行多个任务,如数据验证、读取数据和更新数据时,每个这些任务将由持久化活动函数执行。一旦持久化活动函数完成,它可能将数据返回到编排该活动的函数。
活动函数由活动触发器定义。DurableActivityContext作为参数传递。事件触发器可以绑定到可序列化为 JSON 的对象,这些对象可以用于将输入数据传递到函数中。由于活动函数只能传递单个值,你可以通过使用数组、复杂类型和元组来克服这种限制。
注意
活动函数只能从编排器函数触发,并且由 Durable Task Framework 保证至少运行一次。因为我们不知道活动可能被调用多少次,所以 Microsoft 建议尽可能使持久化活动函数具有幂等性。
持久化函数类型 – 编排器
当你需要控制要执行的操作以及它们的执行顺序时,请使用编排器函数类型。
持久化函数类型 – 实体
持久化实体可以通过客户端和编排器函数调用,并由实体触发器触发。持久化实体函数用于读取和更新对象的状态。
持久化函数类型 – 客户端
持久化客户端函数是通过持久化客户端输出绑定定义的。客户端函数用于启动编排器和实体函数,因为在 Azure 门户中,这些函数不能通过按钮点击来触发。
持久化函数模式
你可以使用几种模式来管理你的持久化函数。这些包括以下内容:
-
聚合器(有状态实体)
-
异步 HTTP API
-
扇出/扇入
-
函数链
-
人工交互
-
监控
聚合器(有状态实体)模式
在这个模式中,使用单个可寻址实体来聚合在一定时期内发生的事件数据。传递给聚合器的数据可以来自多个来源。数据可能随着时间的推移而分散,并且可以分批交付。你可以在数据到达时处理数据,并使聚合数据可供外部客户端查询。
在聚合器模式中,聚合器函数应在单个进程或虚拟机中运行。主要原因是因为当与无状态的普通函数一起使用时,并发控制的复杂性较高。
异步 HTTP API
影响 API 调用完成时间因素包括数量和延迟,以及超出你控制的其他因素。可持续函数具有内置机制来处理长时间运行函数的执行,并且可持续函数的运行时还负责管理状态。
扇出/扇入
可持续函数允许你并行执行函数并在任务的结果上执行。
函数链
当使用服务总线队列与普通函数一起使用时,错误处理会更加复杂,并且很难可视化函数与队列之间的关系。
然而,当你使用可持续函数时,你有一个位置可以设置函数的顺序,存储队列由可持续函数自动管理,如果任何活动发生错误,它们会被传播回编排函数。
人工交互
可持续函数可用于提升在约定时间内未收到人工交互的流程。
监控(演员)
当你需要执行重复性任务时,例如释放系统资源,可持续函数为你提供了一种灵活的方式来管理重复间隔,使用单个编排来管理多个监控过程,并管理任务的生命周期。
容器和无服务器
容器和无服务器技术都在微服务生态系统中有一个合理的位置。主要思路是了解它们的优缺点,以帮助你选择最适合你需求的最佳选项。
容器
如果你希望将遗留代码迁移到更现代的平台和代码库,容器是一个不错的选择。你不必立即重写你的遗留代码库,例如 Web 服务和批处理过程。你可以将它们放入容器中,并将它们部署到云端。然后,当时间、金钱和资源可用时,你可以计划并实施重写你的遗留项目。
当你依赖第三方依赖项时,成本和 PaaS 可用性可能成为问题。例如,Docker Hub 等网站提供了许多可用的容器,用于各种第三方依赖项,你可以拉取并部署它们。
使用 Docker Compose 文件可以简化多个微服务的本地开发。你可以在 Docker Compose 文件中添加所需的服务数量,并在需要时启动它们。
使用 Kubernetes 集群,一个入口控制器用于仅暴露你想要暴露的服务。这允许你提供具有有限足迹的安全代码,让黑客难以生存。
容器的缺点之一是它们可能会鼓励使用更重量级且需要更多计算能力的旧开发技术。这可能导致计算成本增加。容器还需要一个核心数量的始终运行的数据节点,这会增加你的成本。
无服务器
外部服务可以通过 Azure Functions 等无服务器技术进行集成。无服务器计算的简化编程模型促进了快速应用开发。
当编写无服务器代码时,鼓励您采用事件驱动的函数方法。此类代码易于扩展,并且可以根据业务的发展轻松重写或丢弃。
无服务器代码支持缩放至零,因为函数仅在需要时运行,不需要时则不运行。这有助于降低运行成本,因为与始终运行的数据节点等服务相比,资源消耗非常小。
无服务器代码的快速扩展是此类技术的另一个优点,因为你只为函数的运行时间付费。
无服务器函数可能存在安全风险,因此您必须采取措施确保您的函数安全且受保护。
现在您已经了解了容器和无服务器函数的优缺点,并已审查了 Microsoft Azure 中可用的各种持久性函数以及一些持久性函数模式,让我们看看如何使用 C# 和 Pulumi 在 C# 中管理我们的云基础设施。
使用 Pulumi 管理您的云基础设施
在本节中,您将学习如何使用 Pulumi 管理您的云基础设施。在云基础设施中,保持一致性很重要。实现这一目标的一种方法是通过消除容易出错的人类因素,尽可能实现自动化。云中可以轻松自动化的一个重要方面是基础设施提供任务。这正是 Pulumi 发挥作用的地方。
使用 Pulumi,您可以编写基础设施即代码(IaC)解决方案。代码和配置文件用于管理和提供软件将运行的底层基础设施。
Pulumi 项目可以用各种编程语言编写,如 Python、VB.NET、F# 和 C#。我们感兴趣的是使用 C# 进行我们的 Pulumi 项目。您可以使用 Pulumi 执行以下操作:
-
指定您的基础设施。
-
自动化云资源的创建、更新和删除过程。
-
使用 Visual Studio 和 Visual Studio Code 等集成开发环境(IDE)和代码编辑器。
-
在编译过程中捕捉错误。
-
强制执行安全性、合规性和最佳实践。
-
使用现有的 NuGet 库以及编写自己的库。
-
使用 Kubernetes、Docker 容器、Azure Functions 和 Cosmos DB 来构建易于扩展的应用程序。
注意
要跟随,您需要安装 Chocolatey,因为它将被用作安装 Pulumi 的包管理器。您还需要一个 Microsoft Azure 账户来部署您的 IaC。在 Windows 上,当使用命令行时,请确保您正在使用 PowerShell,并且以管理员身份运行它。
现在,让我们看看一个配置 Blob 存储、向 Blob 存储添加文件以及销毁我们配置的资源的一个非常简单的示例。以下步骤将配置、使用和删除 Azure Blob 存储:
-
使用以下命令安装 Pulumi:
> choco install pulumi -
确保您已安装 .NET 6 SDK 或更高版本。
-
通过输入以下命令配置 Pulumi 对您的 Microsoft Azure 账户的访问权限:
az login注意
您的凭据永远不会发送到 pulumi.com,并且它们仅用于 Pulumi 在管理和配置资源时的身份验证目的。
-
到目前为止,您已经准备好开始使用 Pulumi。如果
az术语不被识别,请尝试以下命令:Invoke-WebRequest -Uri https://aka.ms/ installazurecliwindows -OutFile .\AzureCLI.msi; Start- Process msiexec.exe -Wait -ArgumentList '/I AzureCLI.msi /quiet'; rm .\AzureCLI.msi -
使用以下命令创建一个新的项目:
> Mkdir CH13_Pulumi > cd CH13_Pulumi > pulumi new azure-csharp
您将被要求输入您的令牌,或者您可以简单地按 Enter 键以登录到 Pulumi,让 Pulumi 为您获取令牌。如果您还没有,您可以在这一阶段轻松创建一个。一旦登录,您将在 PowerShell 中被问及一系列问题。您可以简单地接受所有默认值。
-
在 Visual Studio 中打开项目。让我们回顾一下项目文件:
-
Pulumi.yaml用于定义项目。 -
Pulumi.dev.yaml用于存储您的堆栈配置值。 -
Program.cs是您项目的入口点。 -
MyStack.cs用于定义您的堆栈资源。
-
此类创建一个 Azure 资源组和一个存储账户。然后导出存储账户的主密钥。您可以在 Pulumi.dev.yaml 文件中找到资源组的定位,其属性名为 azure-native:location。
-
现在,使用以下命令部署您的堆栈:
Pulumi up
当提示时,选择 是 以将您的堆栈部署到 Azure。
-
在此阶段,您应该能够登录到您的 Azure 账户并查看新创建的资源,并且它是一个存储账户。
-
将名为
index.html的 HTML 文件添加到您的项目中,并编辑该文件,添加一些 HTML 内容并保存。以下是一些示例内容:<html><head><title>Sample HTML</title></head><body><h1>Hello, World!</h1> <hr /><p>This is a sample paragraph.</p></body></html> -
在创建 Azure 存储账户资源的代码块之后立即将以下代码添加到
MyStack.cs类中:// Enable static website support var staticWebsite = new StorageAccountStaticWebsite( "staticWebsite", new StorageAccountStaticWebsiteArgs { AccountName = storageAccount.Name, ResourceGroupName = resourceGroup.Name, IndexDocument = "index.html", });
有了这些,我们就创建了一个新的静态网站资源,该资源利用了我们刚刚创建的存储账户。
-
接下来,在 步骤 10 中显示的代码之后添加以下代码:
// Upload the file var index_html = new Blob("index.html", new BlobArgs { ResourceGroupName = resourceGroup.Name, AccountName = storageAccount.Name, ContainerName = staticWebsite.ContainerName, Source = new FileAsset("index.html"), ContentType = "text/html", });
在这里,我们使用了我们的云资源和本地 FileAsset 将我们的 index.html 文件上传到 Blob 存储。
-
在构造函数的末尾添加以下代码:
// Web endpoint to the website this.StaticEndpoint = storageAccount .PrimaryEndpoints.Apply( primaryEndpoints => primaryEndpoints.Web );
此代码配置了 Web 端点到我们的静态网站。
-
在构造函数上方添加以下属性:
[Output] public Output<string> StaticEndpoint { get; set; }
此属性提供了我们的静态网站端点。
-
现在,是时候通过输入以下命令来部署我们的更改了。
pulumi up
这将上传index.html文件到 blob 存储,并使我们的静态网站对公众可用。您应该看到一个可以用来查看您创建和上传的网页的 URL。该文件应在您的 blob 存储中可见,您可以通过 Azure 门户或 Azure 存储资源管理器查看。
-
一旦您确认前面的代码对您有效,就是时候销毁资源了。请输入以下命令:
pulumi destroy
如果您想销毁整个堆栈,请输入以下命令:
pulumi stack rm dev
因此,堆栈已经完全从 Pulumi 中移除。
在本节中,您学习了如何使用 Pulumi 管理您的 Azure 堆栈。通过使用 Visual Studio 和 PowerShell 命令行,您创建了一个 Azure 资源帐户,并将 blob 存储分配给它。然后,您创建了一个静态网站资源,并使用云资源和本地FileAsset上传了静态网站,该网站由一个名为index.html的单个文件组成。您能够在 blob 存储中查看文件,并在浏览器中查看网页。
在下一节中,我们将探讨分布式系统的一些性能考虑因素。
分布式计算的性能考虑因素
我们现在知道如何开发分布式系统。但它们的性能如何?在分布式系统的性能方面,我们应该注意哪些方面?
首先考虑的是客户端和服务器之间的网络连接。TCP 冲突可能导致信息包丢失。这可能会破坏多个设备之间的通信,并导致连接超时。TCP 冲突最常见的原因是两台或多台计算机共享相同的 IP 地址。
同一网络上的任何计算机都不应具有与同一网络上的另一台计算机相同的地址。这会导致不可预测的网络行为,对网络应用程序的性能和稳定性有害。如果您遇到这种情况,只需将其中一台计算机的 IP 地址更改为不同的 IP 地址即可。
另一个可能导致网络通信缓慢的问题是域名解析(DNS)。如果 DNS 设置不正确,那么访问网络资源,如网页或网络服务,可能比预期花费更长的时间,并可能导致连接或请求超时。值得注意的是,在分布式网络中通常不止一个 DNS。您有外部网络的 DNS 服务器和您的路由器,后者为您的本地网络提供 DNS。这些中的任何一个都可能导致 DNS 解析缓慢。您可以采取以下一些步骤来解决 DNS 问题:
-
检查您的网络连接。
-
确保您的 DNS 地址正确,并且顺序正确。
-
Ping 您试图访问的计算机名称、IP 地址或基本 URL(例如 google.co.uk),以查看它是否响应或超时。
-
使用
nslookup命令识别正在使用的域名服务器。 -
检查 DNS 后缀。
-
确保 DNS 设置已配置为从 DHCP 服务器获取 DNS IP 地址。
-
使用
ipconfig释放和更新 DHCP 地址和 DNS 信息。 -
检查 DNS 服务器,看是否有服务需要重启或服务器需要重新启动。
-
有时,路由器上的信息会过时,所以快速解决方案是重新启动路由器。
-
有时,ISP 会遇到影响你的问题。在这种情况下,你需要与他们沟通,了解问题并了解何时恢复正常。
可以使用分布式防火墙来保护企业网络。防火墙配置错误可能导致资源访问被拒绝或不可见。如果机器无法访问分布式资源,那么分布式防火墙是一个好的起点。如果分布式防火墙配置正确,那么检查客户端和服务器防火墙是否启用或禁用,以及它们是否配置正确。
例如,我处理过很多 SQL Server 问题。有些是 DNS 和 DHCP 问题,但最常见的问题是 SQL Server 配置和防火墙配置。SQL Server 使用动态端口。但有时,这些端口可能会冲突,固定端口也是如此。我还发现,为了在许多网络上运行 SQL Server,必须启用命名管道和 TCP 协议。一旦在 SQL Server 配置管理器中更改了这些协议,就需要重新启动受影响的 SQL Server 实例,然后是 SQL Server 浏览器服务。如果你有防火墙,那么需要将实例的 SQL Server 可执行文件添加到防火墙作为应用程序异常。如果你需要使用特定端口,那么你需要添加端口异常。SQL Server 的标准端口异常是 TCP 的 1433 和 UDP 的 1434。
有时,即使完成了上述 SQL Server 故障排除,网络应用程序仍然看不到 SQL Server 实例。当这种情况发生时,一种解决方案是重新创建以下格式的数据库连接字符串:IP_ADDRESS,PORT_NUMBER\INSTANCE_NAME。
另一个可能影响分布式环境中 SQL Server 连接性的问题是安装和使用的 SQL Server 驱动程序。如果你使用特定版本的 SQL Server 原生客户端,那么你需要确保该特定版本的原生客户端已安装在所有计算机上,以便它们能够连接到 SQL Server。解决这个问题的方法是意识到 SQL Server 驱动程序默认安装在所有 Windows 计算机上,无论是服务器还是客户端。如果你使用此驱动程序,那么你不必担心将 SQL Server 原生客户端推广到分布式系统中的各种计算机。
另一个性能方面是数据库查询。为了获取一组结果,相同的查询可以以多种不同的方式编写,以获得所需的结果。这在结果集较大的情况下尤其如此,因为它们有更多的连接。动态 SQL 也可能运行缓慢。因此,加快查询速度可以显著提高数据库驱动的分布式应用程序。您可以使用 SQL Server 配置文件和审查 SQL Server 执行计划来识别瓶颈,并重写 SQL 以提高其性能。您还可以添加缺失的索引,纠正错误的索引,并使用预编译的存储过程来提高性能。
SQL Server 可能因多种原因而损坏和失败,因此必须定期更新安全补丁。在这里,您可以使用 Always-On 和故障转移集群来保持连接活跃,并在服务器宕机或需要离线进行维护时在 SQL 服务器之间切换。
资源连接的数量也可能使分布式系统过载到客户端无法连接的程度。为了克服这一点,您可以采用负载均衡,以便当资源服务器达到某个峰值时,客户端会被发送到替代服务器以获取那些资源。
在共享网络资源时,另一个常见的疏忽是网络权限。有时,可能没有共享应该共享的文件夹。一个真正棘手的问题可能是通过组策略强制执行的权限层次结构,它甚至可以覆盖网络域管理员执行其工作的能力。
完全记录您的组策略和权限结构对于当前和未来的员工来说非常重要。一个清晰的文档可以直观地显示权限组和层次结构,并提供资源及其权限集的列表,如果有人或应用程序在访问资源时遇到问题,此类文档可以减轻解决此类问题的痛苦。
值得注意的是,有时系统管理员和受信任的安装程序会控制某些资源,阻止您在本地和网络中访问资源。这可能导致您必须覆盖该网络或本地位置的文件资源所有权。
计算机安全软件也可能显著减慢网络流量,甚至停止程序运行。通常的罪魁祸首是防火墙,正如我们之前提到的,以及防病毒软件。如果您的软件没有使用权威批准的代码签名证书进行签名,那么 DLL 和可执行文件可能会被隔离并识别为恶意软件。这就是所谓的误报。您可以选择对软件进行签名,将软件添加为应用程序或文件夹例外项,或将软件传递给安全公司以评估您的软件,并更新他们的软件以防止未来发生此类情况。
当所有网络流量甚至本地文件都进行实时扫描时,杀毒软件也可能减慢应用程序的速度。一个例子是在评估期间通过网络拉取音频文件的教育软件。当音频文件被备份并一起播放时,这表明了这种情况。为了克服这个问题,你可以通过将应用程序、其文件夹及其资源添加为文件夹和或应用程序/文件异常来更新杀毒软件。
资源的大小也会影响网络性能。资源越大,请求和接收资源所需的时间就越长。在这里,你可以使用各种压缩技术来减小图像、视频和音频文件等资源的大小。你还可以在需要访问之前压缩资源并传输它们,例如在应用程序启动时。一旦请求并接收了资源,你就可以将它们存储在本地缓存中。
当工作负载增加到当前系统无法处理的地步时,你有两个选择:垂直扩展或水平扩展。垂直扩展涉及增加物理计算能力以应对增加的工作负载。水平扩展是指添加更多服务器以应对增加的工作负载。在撰写本文时,许多公司的前进方向是使用服务器虚拟机(VMs)和容器,并在 Azure、AWS、Google Cloud 等云平台上的 Docker 和 Kubernetes 等容器管理软件中运行容器。
通过将代码移动到微服务(如 Azure Functions)中,可以减小大型库和可执行文件的大小。Azure Functions 是一个事件驱动的按需计算体验,它通过在 Azure 或第三方服务以及本地系统中发生的事件触发代码的能力,扩展了现有的 Azure 应用程序平台。这些在线服务可以自动扩展和缩减,并且仅在需要时运行。这还带来了额外的优势,例如节省成本,如电力和设备成本。
你还可以使用浏览器开发者工具和 Postman 等工具来监控应用程序和网络性能。
现在,让我们总结一下我们所学到的内容。
摘要
在本章中,我们首先探讨了 CQRS 设计模式的实现。然后,我们研究了事件源的实施。你可以单独使用这两种模式,尽管它们也可以结合起来提供非常强大和功能性的微服务。
然后,我们以高级别概述了使用 Microsoft Azure 编写分布式系统的方法。我们涵盖了容器和无服务器函数的优点和缺点,以帮助您了解何时使用每种技术。
在 Microsoft Azure 方面,我们主要关注 Azure Functions。具体来说,我们研究了持久 Azure Functions。我们确定了各种持久函数及其持久函数模式。
现在,花些时间回答本章的问题,看看你记住了多少。请查阅进一步阅读部分,以巩固你在本章中学到的知识。
在下一章中,我们将探讨 C#中的多线程编程。
问题
回答以下问题以测试你对本章知识的掌握:
-
CQRS 代表什么?
-
为什么我们在开发微服务时使用 CQRS 模式?
-
什么是事件溯源?
-
为什么我们使用事件溯源?
-
容器是什么?
-
为什么我们会使用容器?
-
什么是无服务器函数?
-
为什么我们应该使用无服务器函数?
-
什么是持久化函数?
-
持久化函数有哪些不同类型?
-
有哪些类型的持久化函数模式?
-
Pulumi 是什么?
-
为什么我们会使用 Pulumi?
进一步阅读
要了解更多关于本章所涵盖的主题,请查看以下资源:
-
在 Azure 上使用 Pulumi 入门:
www.pulumi.com/docs/get-started/azure/ -
使用 Pulumi 和.NET Core 构建现代云应用程序:
devblogs.microsoft.com/dotnet/building-modern-cloud-applications-using-pulumi-and-net-core/ -
使用持久化 Azure 函数进行编排:
blog.kiprosh.com/orchestration-using-durable-azure-function/ -
持久化函数编排:
docs.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-orchestrations?tabs=csharp -
持久化函数模式的最佳实践:
www.serverless360.com/blog/azure-durable-functions-patterns-best-practices -
《C#中的 Clean Code》第九章和第十章 by Jason Alls:
www.amazon.co.uk/Clean-Code-application-performance-practices-ebook/dp/B08614MS6S -
10 种解决 DNS 解析问题的方法:
techgenix.com/10-Ways-Troubleshoot-DNS-Resolution-Issues/
第三部分:线程和并发
第三部分涵盖了线程、并行处理和异步处理。我们讨论了同步、异步和并行处理代码的各种方法。通过这样做,我们学习如何减少处理一系列任务所需的时间,以及如何利用 CPU 和核心的数量。
本部分包含以下章节:
-
第十四章,多线程编程
-
第十五章,并行编程
-
第十六章,异步编程
第十四章:第十四章:多线程编程
在本章中,你将学习关于 多线程编程 的内容。你将了解线程是什么,以及后台和前台线程。然后,你将学习在运行线程之前如何将数据传递给线程。你还将学习如何暂停、中断、销毁、调度和取消线程。
在本章中,我们将涵盖以下主题:
-
理解线程和线程化:本节涵盖了线程的生命周期。
-
创建带参数和不带参数的线程:本节提供了带参数和不带参数创建线程的示例。
-
暂停和中断线程:本节涵盖了如何暂停和中断线程。
-
销毁和取消线程:本节涵盖了销毁和取消线程。
-
调度线程:本节涵盖了如何调度线程。
-
线程同步和锁:本节涵盖了如何同步线程、保护资源以及防止死锁和竞态条件。
到本章结束时,你将掌握以下技能:
-
你将理解线程和线程化。
-
你将能够创建带参数和不带参数的线程。
-
你将能够暂停和中断线程。
-
你将能够销毁和取消线程。
-
你将能够调度线程。
技术要求
为了确保你从本章中受益,你应该满足以下要求:
-
Visual Studio 2022
-
从以下链接获取本书的源代码:
github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH14。
理解线程和线程化
在本节中,我们将了解线程的生命周期。C# 中的线程具有以下生命周期:

图 14.1 – 线程生命周期
当线程启动时,它们进入 Suspend 方法,调用 Resume 方法可以恢复线程。
当调用 Monitor.Wait(object obj) 方法时,线程进入 wait 状态。当调用 Monitor.Pulse(object obj) 方法时,等待的线程将继续,你可以通过调用 Thread.Sleep(int millisecondsTimeout) 方法使线程休眠。
当你调用 Thread.Join() 方法时,它会导致线程进入 wait 状态。一旦依赖的线程完成运行,等待的线程将继续。如果任何依赖的线程被取消,线程将被中止并进入 stop 状态。一旦线程完成或取消,就无法重新启动它。
注意
如果项目针对 .NET 5 或更高版本,并且调用任何 Thread.Abort API,将引发 SYSLIB0006 编译时警告。Microsoft 建议您使用 CancellationToken 来中止 running 单元工作。Thread.Abort API 现已过时。
在下一节中,我们将探讨如何创建带参数和不带参数的背景和前台线程。
创建线程和使用参数
在本节中,我们探讨线程的创建。首先,我们将看到如何在前台和后台创建无参数线程。让我们如下定义前台和后台线程:
-
如果
Main方法已经完成而前台线程仍在运行,进程将保持活动状态,直到前台线程终止。 -
后台线程:后台线程的创建方式与前台线程相同。主要区别在于你必须明确设置线程以在后台运行。
以下代码展示了如何创建和运行一个前台线程:
var foregroundThread = new Thread(methodName);
foregroundThread.Start();
要创建并运行一个后台线程,你可以运行以下代码:
var backgroundThread = new Thread(methodName);
backgroundThread.IsBackground = true;
backgroundThread.Start();
你刚才看到的生成前台和后台线程的两种代码版本都没有使用参数来创建线程。以下代码展示了如何使用参数创建线程:
static void ThreadCreationWithParameters()
{
int result = 0;
Thread thread = new Thread(() => { result = Add(1, 2); );
thread.Start();
thread.Join();
Console.WriteLine($"The addition of 1 plus 2 is
{result}." + $"");
}
static int Add(int a, int b)
{
return a + b;
}
如前述代码所示,线程用于计算两个数字的和并返回结果。线程调用Add方法并将要相加的两个整数传递给它。方法调用和结果都放置在传递给线程构造函数的匿名函数中。
创建多个线程可能会对性能造成影响。可以通过使用线程池来提高多线程创建的性能。线程池通过限制应该创建和管理的线程数量来提高多线程应用程序的性能。
当使用线程池创建新线程时,它会被保留在那里,直到需要它。当需要时,线程将运行并完成其任务。一旦任务完成,线程将返回到线程池以供以后重用。
你可以按照以下方式在线程池中创建线程:
ThreadPool
.QueueUserWorkItem(
new WaitCallback(ThreadPoolWorkerMethod)
);
使用线程池时要注意的是,首次使用时,它们没有历史记录,但随时间推移,它们会调整自己以提高线程池性能。对于使用大量线程并对 CPU 造成重负载的应用程序,它们可能会遇到高昂的启动成本。线程必须被创建并可供线程池使用。这可能导致线程池必须等待直到那些线程可用。在启动时可以进行的一种性能调整是设置最小线程数。以下代码展示了如何设置最小线程数:
const int WorkerThreads = 12;
const int CompletionPortThreads = 12;
ThreadPool.SetMinThreads(WorkerThreads,
CompletionPortThreads);
WorkerThreads值是ThreadPool按需创建的最小工作线程数。CompletionPortThreads值是ThreadPool按需创建的异步 I/O 线程数。
除了设置最小线程数之外,你还可以设置最大线程数,如下所示:
const int WorkerThreads = 12;
const int CompletionPortThreads = 12;
ThreadPool.SetMaxThreads(WorkerThreads, CompletionPortThreads);
为了让这些设置有助于应用程序性能,您需要正确设置它们。否则,您可能会创建过多的线程并过度调度任务。这将通过增加上下文切换来降低性能,这将增加 CPU 的负载。ThreadPool足够智能,一旦收集到历史数据,就会切换到一种算法,以减少 CPU 需要完成的工作量。
在设置这些值之前,使用性能监控来监控应用程序的线程使用和上下文切换是一个好主意。您可以使用上下文可视化器进行性能计数器跟踪,这将在下一章中讨论。您还可以使用ThreadPool.GetMaxThreads和ThreadPool.GetMinThreads方法来帮助您分析设置最小和最大工作线程以及完成端口线程的最佳值。
您还可以设置线程的优先级。然而,您必须非常小心地设置线程优先级,因为它可能对其他线程和其他应用程序产生负面影响。将线程设置为更高的优先级可能会导致低优先级线程饥饿,从而使其很少运行。
只有在需要快速响应事件时,例如异常,您才应考虑将线程优先级更改为高值。当遇到竞态条件时,您可以合法地降低线程的优先级。由于优先级较低而一段时间没有运行的线程最终会运行。这是因为线程的动态优先级会随着 Windows 在没有运行的情况下时间的增加而提高。
如果您确实更改了线程的优先级,那么在返回到池中时,其优先级将被重置。然而,一个线程可能用于多个任务。在这种情况下,线程将不会返回到池中,直到这些任务完成。如果优先级设置不正确,这可能会降低应用程序性能和系统性能。
我们现在已经了解了如何创建和运行线程。让我们将注意力转向暂停和中断线程。
暂停和中断线程
在本节中,我们将探讨暂停和中断线程。您需要暂停或中断线程的一个例子是,如果正在运行的代码是调试器。如果一个线程正在执行并且遇到断点,它需要被暂停。
暂停/延迟线程最常见的方法是调用Thread.Sleep(millisecondsDuration),但这可能会冻结主线程,您的用户可能会认为您的程序已停止工作,从而导致他们终止它。
延迟线程的更好方法是让Task.Delay(TimeSpan)在后台运行。这将允许线程在后台工作,并防止延迟的线程停止主线程执行其工作。
以下代码显示了如何延迟线程:
static void Main(string[] args)
{
Console.WriteLine($"Current Time: {DateTime.Now}");
var delay = Task.Delay(TimeSpan.FromSeconds(5));
var duration = 0;
while (!delay.IsCompleted)
{
duration++;
Thread.Sleep(TimeSpan.FromSeconds(5));
Console.WriteLine($"Slept for {seconds} seconds");
}
Console.WriteLine($"Delay End:{DateTime.Now} after
{duration} seconds");
}
}
我们创建了一个具有五秒延迟的任务。循环会一直运行,直到延迟完成。
调用Interrupt方法来中断处于wait、sleep或join阻塞状态的线程。当方法被调用时,会引发ThreadInterruptedException异常。当在非阻塞状态的线程上调用Interrupt方法时,不会引发此异常。
销毁和取消线程
终止线程不是一个好主意,因为您并不总是知道线程的状态。如果线程是静态构造函数的一部分,情况可能会变得更糟。使用Thread.Abort来终止线程是导致应用程序崩溃的主要原因之一。Thread.Abort API 现在已过时。因此,建议您使用协作取消模式,定期使用CancellationToken检查取消操作。
在正常情况下,当一个线程被终止时,它将被销毁。线程的取消也会销毁线程。让我们编写一些示例代码,演示如何使用CancellationToken在超时时取消同步操作,如下所示:
-
启动一个新的.NET 6 控制台应用程序,并将其命名为 CH14_Multithreading。
-
在
CH14_Multithreading项目的Program.cs文件中,添加以下方法:static bool TryCallWithTimeout<TResult>( Func<CancellationToken, TResult> function, TimeSpan timeout, out TResult result ) { var cancellationTokentSource = new CancellationTokenSource(timeout); try { result = function(cancellationTokentSource.Token); return true; } catch (TaskCanceledException) { } finally { cancellationTokentSource.Dispose(); } result = default; return false; }
此方法接收一个在指定超时期间执行的方法,并返回一个结果。SleepyMethod被执行,但如果它超过了超时值,则引发TaskCanceledException异常,然后CancellationTokenSource被释放。
-
按如下方式添加
SleepyMethod代码:static int SleepyMethod(CancellationToken ct) { for (var i = 0; i < 10; i++) { Thread.Sleep(TimeSpan.FromMilliseconds(500)); if (ct.IsCancellationRequested) { throw new TaskCanceledException(); } } return 1234567890; }
SleepMethod接受CancellationToken作为参数。然后它循环十次。在每次迭代中,它睡眠半秒钟。然后,它检查是否已请求取消。如果已请求取消,则引发TaskCanceledException异常。否则,返回方法的值。
-
按如下方式添加
SynchronousThreadCancelation方法:static void SyncrhonousThreadCancelation() { TimeSpan timeoutTimeSpan = TimeSpan .FromMilliseconds(750); bool callResult = TryCallWithTimeout( SleepyMethod, timeoutTimeSpan, out int result ); Console.WriteLine($"SleepyMethod() { (callResult ? "Executed" : "Cancelled" ) }"); }
此方法创建了一个持续时间为三分之四秒的超时值。然后调用TryCallWithTimeout方法,该方法返回一个布尔值。传递给TryCallWithTimeout方法的参数如下:
-
SleepyMethod:要执行的方法的名称 -
timoutTimeSpan:方法运行前要运行的时间长度,直到超时 -
result:包含CancellationToken的结果
一旦调用完成,被调用方法的名称及其调用结果将被发送到控制台。在此代码中,我们没有将结果写入控制台窗口,但您可以修改代码来实现这一点。
-
在班级顶部,更新代码如下:
SyncrhonousThreadCancelation();
前面的代码调用我们的方法,是取消同步操作的一个示例。
- 运行前面的代码,结果应该类似于以下内容:
![图 14.2 – 程序的输出控制台,显示线程被取消]

图 14.2 – 程序的输出控制台,显示线程被取消
这就结束了取消和销毁线程的主题。现在让我们看看线程的调度。
线程调度
Thread.Start方法安排一个Thread开始执行。你可以通过不同的参数重载这个方法。在本节中,我们将查看两个示例。第一个示例将调用不带任何参数的Thread.Start()方法,第二个示例将调用Thread.Start(object)。
我们现在将按照以下方式编写代码:
-
添加一个名为
Job的类,如下所示:internal class Job { public void Execute() { Console.WriteLine( "Execute() method execute."); } public void PrintMessage(object message) { Console.WriteLine($"Message: {message}"); } }
这个类提供了两个方法,这些方法将在我们的Thread调度示例中使用。Execute方法与无参数的Thread.Start方法一起使用,而PrintMessage函数与接受参数的Thread.Start方法一起使用。
-
在
Program.cs类中,添加SheduleThreadWithoutParameters方法如下:static void ScheduleThreadWithoutParameters() { Job job = new(); Thread thread = new Thread(new ThreadStart(job.Execute)); thread.Start(); }
在前面的代码中,我们创建了一个新的Job类实例。然后,我们创建了一个新的Thread,通过将其构造函数中的new ThreadStart实例传递给构造函数。在ThreadStart构造函数中,我们传递object.method,这是我们希望执行的,然后我们启动线程。
-
添加
ScheduleThreadWithParameters方法如下:static void ScheduleThreadWithParameters() { Job job = new(); var thread1 = new Thread( new ParameterizedThreadStart( job.PrintMessage ) ); var thread2 = new Thread( new ParameterizedThreadStart( job.PrintMessage ) ); thread1.Start("Hello, world!"); thread2.Start("Goodbye, world!"); }
在前面的代码中,我们通过调用每个线程的ParameterizedThreadStart类来创建一个新的Job实例和两个线程,以便在每个线程上执行一个参数化方法。然后我们启动每个线程。
- 在类的顶部添加对每个方法的调用,然后运行前面的代码。你的控制台应该看起来像以下这样:

14.3 – 我们的参数化线程输出
线程同步和锁定
在应用程序中使用多个线程时,你必须考虑线程同步和锁定。如果不这样做,你可能会遇到竞态条件和死锁。有几种同步线程的方法。你可以使用互锁方法和同步对象,如Monitor、Semaphore和ManualResetEvent。
注意
在《C#中的整洁代码》一书的第八章《线程和并发》中,我们详细讨论了线程,包括使用线程、线程安全、使用信号量进行并行线程、线程同步和防止死锁,以及竞态条件。
为了同步你的代码,你可以使用一个锁对象,如下所示:
internal class LockMutexExample
{
public object _lockObject = new();
public void UsingLockObject()
{
lock(_lockObject)
{
// Perform your unsafe code here.
}
}
}
当进入被锁定的代码时,其他所有线程都被禁止访问被锁定的代码。这种方法的唯一缺点是可能会导致死锁。可以通过使用互斥锁来克服这个问题,如下所示:
internal class LockMutextExample
{
private static readonly Mutex _mutex = new();
public void UsingMutext()
{
try
{
_mutex.WaitOne();
// ... Do work here ...
}
finally
{
_mutex.ReleaseMutex();
}
}
}
上述代码声明了一个 Mutex 类级变量。需要保护的代码随后被包裹在 try/catch 块中。当前线程通过 WaitOne() 方法被阻塞,直到等待句柄收到信号。当 Mutex 被信号时,WaitOne() 方法返回 True。然后,Mutex 由调用线程拥有,可以访问受保护的资源。一旦受保护的资源完成,通过调用 ReleaseMutex() 释放 Mutex。始终在最终块中调用 ReleaseMutex() 方法,以防止在遇到异常时资源保持锁定。
当多个线程访问同一资源并基于它们的时序产生不同结果时,会发生竞态条件。可以通过使用如下代码来避免竞态条件:
Task
.Run(() => Method1())
.ContinueWith(task => Method2())
.Wait();
Task 在运行 Method1() 后继续执行 Method2()。然后我们使用 Wait() 等待 Task 完成 Method1() 和 Method2() 的执行,再继续。
这就结束了我们对多线程编程的探讨。正如你所见,线程调度并没有太多内容。让我们总结一下本章我们学到了什么。
摘要
在本章中,我们了解了线程和线程生命周期。我们构建了一些示例代码,展示了如何带参数和不带参数创建线程。我们还探讨了在前台和后台运行线程。
接下来,我们探讨了暂停和中断线程。然后,我们转向了销毁和取消线程。你不再在代码中使用 Thread.Abort。Thread.Abort 导致应用程序在运行时崩溃。相反,你使用取消令牌。取消线程也会销毁它们。
我们探讨了带参数和不带参数的线程调度。在下一章中,我们将探讨并行编程。
最后,我们探讨了使用锁对象和互斥锁进行线程同步和锁定,并学习了如何避免死锁和竞态条件。
现在是时候回答一些问题,以检验你对本章知识的掌握程度。一旦你完成了这些问题,进一步阅读部分提供了一些外部资源,以进一步扩展你对线程和多线程编程的知识。
问题
-
线程可以处于哪些状态?
-
你在
Thread.AbortAPI 的哪个部分使用来终止线程? -
线程可以在哪两个位置执行?
-
正确终止线程的方法是什么?
-
使用哪种方法来调度线程?
进一步阅读
)
)
)
)
-
如何在 C#中暂停代码执行:
csharpsage.com/c-delay/ -
暂停和中断线程:
docs.microsoft.com/en-us/dotnet/standard/threading/pausing-and-resuming-threads
第十五章:第十五章:并行编程
在本章中,你将学习如何利用现代计算机中可用的多个 CPU 核心来提高性能。你将学习如何通过在进程之间并发分配工作来处理代码,以及如何使用 任务并行库(TPL)和 并行 LINQ(PLINQ)来并行运行代码。在本书中,你将学习如何使用并行数据结构,并使用 Visual Studio 调试器来诊断任务和并行堆栈。你还将了解并发可视化器。
在本章中,我们将涵盖以下主题:
-
使用任务并行库(TPL):在本节中,我们将比较并行和非并行代码及其对 CPU 核心利用率的影响,使用 perfmon。
-
使用并行 LINQ (PLINQ):在本节中,我们将查看 PLINQ 以及如何使用它以不同的并行度执行 LINQ 语句。
-
编程并行数据结构:在本节中,我们将回顾一些你可以用于编程并行数据结构的线程安全集合。
-
使用 BenchmarkDotNet 进行基准测试:在本节中,我们将查看我们的并行代码的基准测试,并发现,在某些情况下,它可能比非并行代码更快,而在其他时候,它可能更慢。
-
Func和Action委托。
到本章结束时,你将能够做到以下事项:
-
使用 TPL 和 PLINQ 进行并行编程任务。
-
编程并行数据结构。
-
诊断任务和并行数据结构的问题。
-
在 TPL 和 PLINQ 查询中使用 lambda 表达式。
技术要求
对于本章,你需要以下内容:
-
Visual Studio 2022
-
本书源代码:
github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH15 -
Visual Studio 2022 的并发可视化器:
marketplace.visualstudio.com/items?itemName=Diagnostics.DiagnosticsConcurrencyVisualizer2022#overview
使用任务并行库(TPL)
在本章中,我们将使用 TPL 通过利用机器上可用的处理器能力来提高我们程序的性能。
我们在第十四章“多线程编程”中学习了如何编写线程并执行它们。当多个线程在单个处理器上运行时,它们提供了并行运行的错觉,但实际上它们是并发运行的。
当线程并发运行时,处理器使用调度算法和/或中断来确定线程之间的切换和优先级。然而,并行编程在不同的处理器上运行不同的线程,这样线程可以相互并行执行,减少了切换和线程中断的需求。
如其名称所示,TPL 用于并行运行任务。任务通过在计算机处理器的每个单独的核心上运行每个任务来并行运行。例如,如果你的计算机有四个核心,你有四个任务。每个任务将在单独的核心上运行,并且每个任务将与另外三个任务并行运行。这有助于提高代码的整体性能,因为你可以有与处理器核心一样多的任务并行执行。
此外,如果你有一个需要处理大量记录并存储在变量中的大数据集,你可以将任务分割成不同的线程,这些线程在不同的处理器上运行。然后,这些线程同步合并并存储在变量中。
注意
无法并行化的代码会减慢并行任务的速度,同样,必须由任务调度器分割和调度的代码也会减慢速度。始终是一个好主意,对你的代码进行性能分析,看看你使用的方法是否会加快或减慢事情的速度。
要看到并行编程的价值,一个好的方法是将单处理器上运行的线程与在不同处理器上分割的相同代码进行比较。让我们为这个比较编写一些代码:
-
启动一个新的控制台应用程序,并将其命名为
CH15_ParallelProgramming。然后,勾选表示不使用顶级语句的复选框。 -
添加以下
using语句:using System.Threading.Tasks;
此using语句为我们提供了对 TPL 的访问。
-
更新
Program类中的Main方法,如下所示:static void Main(string[] _) { RunSingleProcessorExample(); }
此方法调用RunSingleProcessorExample方法。
-
添加
RunSingleProcessorExample方法:static void RunSingleProcessorExample() { Thread thread = new(SingleProcessorExample); thread.Start(); }
此方法创建一个新的线程,并给它分配SingleProcessorExample方法,它将调用该方法。然后使用Start方法调用该方法。
-
现在,添加
SingleProcessorMethod:static void SingleProcessorExample() { string output = “Index: “; for (int index = 0; index < 1000000; index++) { Console.WriteLine($”{output}{index}”); } Console.ReadKey(); }
此方法将for循环索引的值写入控制台窗口一百万次,然后暂停,直到接收到用户按键。
-
在任务栏的搜索区域中输入
性能监控器并打开它。然后,删除现有的计数器,然后添加一个计数器来查看计算机上所有处理器的处理器时间。如果需要,你可以更改线的粗细。 -
清除性能监控器屏幕,然后运行控制台应用程序。你应该看到以下类似的内容:


图 15.2 – 性能监视器显示我们的修改后的程序正在使用所有处理器
如你所见,通过非常少的代码,你可以从利用单个处理器转变为利用所有处理器,使用 TPL。在之前的章节中,你学习了如何使用 BenchmarkDotNET 来基准测试同一代码的不同变体的性能。在决定是否将你的单处理器代码转换为多处理器代码时,进行基准测试是个好主意。使用并行代码会有开销,因此你需要确保并行代码会提高你的程序性能。
现在,让我们学习如何使用 PLINQ。
使用并行 LINQ (PLINQ)
在本节中,你将学习如何使用 PLINQ 将你的顺序 LINQ 查询转换为并行 LINQ。看看以下代码:
var productNames = GetProductNames();
var names = from name in productNames
where name.Length > 8
select name;
上述代码调用了 GetProductNames 方法,并将结果存储在 productNames 变量中。然后对 productNames 列表执行 LINQ 语句,以提取所有长度大于八个字符的产品名称列表。此 LINQ 语句的结果随后存储在 names 变量中。
以下代码与前面的代码相同,但我们已对其进行修改,使其在多个处理器上并行运行:
var productNames = GetProductNames();
var names = from name in productNames.AsParallel()
where name.Length > 8
select name;
在这里,我们可以看到,要使 LINQ 语句执行为并行 LINQ,唯一的更改是添加 AsParallel() 方法调用。其余代码保持不变。
如果你希望从 PLINQ 语句中返回数据,那么在 AsParallel() 调用后加上 AsOrdered() 调用:
var productNames = GetProductNames();
var names = from name in productNames
.AsParallel().AsOrdered()
where name.Length > 8
select name;
上述代码将返回一个按字母顺序排列的产品名称列表,其长度大于 8。
PLINQ 利用执行计算机上的所有处理器。然而,你可以使用 WithDegreeOfParallelism 调用来限制 PLINQ 使用的处理器数量,传递你想要限制 PLINQ 执行的处理器数量:
var productNames = GetProductNames();
var names = from name in productNames
.AsParallel()
.WithDegreeOfParallelism(2)
where name.Length > 8
select name;
上述代码仅限于在两个处理器上运行。
使用 PLINQ 时,以下是一些性能考虑因素:
-
不要在单核计算机上使用 PLINQ。这会导致比使用标准 LINQ 更慢的性能。
-
AsOrdered()会减慢 PLINQ 的速度。只有在你需要的时候才使用它。基准测试替代排序技术,看看哪个最快,然后实现最快的方法。 -
在开发和测试你的 PLINQ 代码时,使用生产规模的数据库集。这将更快地揭示性能问题!
-
避免在小型集合上使用 PLINQ,因为这可能会提供较低的性能。这是因为 PLINQ 已经针对大型数据集进行了优化。
在下一节中,我们将考虑一些适合并行编程的数据结构。
并行数据结构编程
当我们进行并行编程时,我们应该始终考虑我们正在使用线程。因此,我们应该使用线程安全的 数据结构。
对于实现 IProducerConsumerCollection<T> 接口的数据类型,你应该使用通用的 BlockingCollection<T> 类,它提供了边界和阻塞功能。使用 ConcurrentDictionary<TKey, TValue> 类来创建线程安全的字典。对于线程安全的 FIFO 队列,使用 ConcurrentQueue<T> 类。使用 ConcurrentStack<T> 类来创建 LIFO 栈。对于线程安全的元素集合实现,使用 ConcurrentBag<T> 类。最后,对于要在 BlockingCollection 中使用的类型,实现 IProducerConsumerCollection<T> 类。
你可以在 Microsoft Docs 网站上了解更多有关线程安全集合的信息:docs.microsoft.com/en-us/dotnet/standard/collections/thread-safe/。
接下来,我们将查看基准测试循环、LINQ 和 PLINQ。
使用 BenchmarkDotNet 进行基准测试
在本节中,我们将基准测试一些方法以确定哪种方法能给我们带来最佳性能。请注意,在并行运行代码时会有一些初始开销。因此,有时并行代码可能不是提高代码性能的最佳选择。让我们开始吧:
-
在
Main方法中注释掉代码并添加以下行:BenchmarkRunner.Run<Benchmarks>(); -
添加一个名为
Benchmarks的类。 -
添加以下
NuGet包:-
BenchmarkDotNet -
LinqOptimizer.Csharp
-
-
将每个
NuGet包的using语句添加到Benchmarks类中。 -
添加以下代码来设置我们的基准测试:
private short[] data; [GlobalSetup] public void GlobalSetup() { integers = new Int16[Int16.MaxValue]; for (short x = 1; x <= integers.Length - 1; x++) { integers[x] = x; } }
这里,我们声明了一个短数据类型的数组。然后初始化并填充该数组。这个数组将被以下六个方法中的两个使用。
-
添加
StandardForLoopExample方法:[Benchmark] public void StandardForEachLoopExample() { foreach (int x in integers) Console.WriteLine($”Item {x}: {x}”); }
上一段代码使用标准的 foreach 循环遍历数据数组中的值,然后将数组在给定索引处的值写入控制台窗口。
-
添加
ParallelForLoopExample方法:[Benchmark] public void ParallelForEachLoopExample() { Parallel.ForEach(integers, x => { Console.WriteLine($”Item {x}: {x}”); }); }
上一段代码与上一段代码执行相同,但使用 PLINQ 执行代码。
-
添加
UrlDownloader1方法:public List<string> DownloadWebsites1() { List<string> websitesContent = new(); HttpClient httpClient = new(); string[]? websites = new[] { “https://docs.microsoft.com”, “https://ownCloud.com”, “https://www.oanda.com/uk-en/”, “https://azure.microsoft.com/en-gb/” }; foreach (string? website in websites) { Console.WriteLine($”Downloading of {website} content has started.”); string websiteContent = httpClient.GetStringAsync(website) .GetAwaiter().GetResult(); websitesContent.Add(websiteContent); Console.WriteLine($”Downloading of {website} content has finished.”); } httpClient.Dispose(); return websitesContent; }
上一段代码创建了一个 URL 数组,并使用 foreach 循环下载它们的内容。
- 添加
UrlDownloader2方法:
[Benchmark]
public List<string> DownloadWebsites2()
{
List<string> websitesContent = new();
string[]? websites = new[]
{
"https://docs.microsoft.com",
"https://ownCloud.com",
"https://www.oanda.com/uk-en/",
"https://azure.microsoft.com/en-gb/"
};
Task[]? downloadJobs = websites
.Select(jobs => Task.Factory.StartNew(
state =>
{
using HttpClient? httpClient = new
HttpClient();
string? website = state == null ?
String.Empty : (string)state;
Console.WriteLine($"Downloading of
{website} content has started.");
string result =
httpClient.GetStringAsync(website)
.GetAwaiter().GetResult();
websitesContent.Add(result);
Console.WriteLine($"Downloading of
{website} content has finished.");
}, jobs)
)
.ToArray();
Task.WaitAll(downloadJobs);
return websitesContent;
}
上一段代码创建了一个 URL 数组,并将它们作为一系列任务下载。代码会在返回内容之前等待所有任务完成。
- 添加
Urldownloader3方法:
[Benchmark]
public List<string> DownloadWebsites3()
{
List<string> websitesContent = new();
HttpClient httpClient = new();
List<string> websites = new()
{
"https://docs.microsoft.com",
"https://ownCloud.com",
"https://www.oanda.com/uk-en/",
"https://azure.microsoft.com/en-gb/"
};
websites.ForEach(website =>
{
Console.WriteLine($"Downloading of
{website} content has started.");
string result =
httpClient.GetStringAsync(website)
.GetAwaiter().GetResult();
websitesContent.Add(result);
Console.WriteLine($"Downloading of
{website} content has finished.");
});
httpClient.Dispose();
return websitesContent;
}
上一段代码使用 Parallel.ForeEach 循环下载存储在数组中的 URL 的内容。
- 确保你的项目设置为发布模式,然后运行你的程序。程序将需要一些时间来执行。然而,一旦执行完成,你应该看到以下类似的内容:

图 15.3 – BenchmarkDotNet 结果
观察到ForEachLoop示例,我们可以看到标准的foreach循环比我们的Parallel.ForEach循环执行得更快。因此,在这个例子中,使用并行代码比使用非并行代码稍微慢一些。但如果数据集很大且数据类型更复杂,那么结果可能会显示并行代码执行得更快。
当查看我们的UrlDownloader方法时,UrlDownloader4使用Parallel.ForEach循环,这比使用foreach循环和带有 lambda 方法的foreach循环的方法要快得多。然而,创建任务数组并等待它们全部完成的方法比Parallel.ForEach循环稍微快一些。
从这些测试结果中,我们可以看到我们有不同的方式执行相同的行为,每种方法的处理速度都不同。在某些情况下,我们看到了并行代码比非并行代码慢,而在其他情况下,我们看到了并行代码比非并行代码快。
当性能成为问题时,你可以使用 BenchmarkDotNet 来测试对同一任务的多种不同方法的效率。然后,你可以为你要解决的问题选择最有效的选项。
在下一节中,我们将学习如何使用 TPL 和 LINQ 的 lambda 表达式。
使用 TPL 和 LINQ 的 lambda 表达式
TPL 中有几个方法接受System.Func<TResult>或System.Action委托作为输入参数。这些可以用来将自定义逻辑传递到任务、查询或并行循环中。在创建委托时可以使用内联块。
使用Func委托封装返回值的函数,使用Action委托封装不返回值的函数。让我们回顾以下示例:
static void FuncAction()
{
int[] numbers = { 15, 10, 12, 17, 11, 13, 16,
14, 18 };
int additionResult = 0;
try
{
Parallel.ForEach(
numbers,
() => 0,
(number, currentState, addition) =>
{
addition += number;
Console.WriteLine($"Thread:
{Thread.CurrentThread.
ManagedThreadId}, Number:
{number}, Addition: {addition}");
return addition;
},
(addition) => Interlocked.Add(ref
additionResult, addition)
);
Console.WriteLine($"Addition Result:
{additionResult}");
}
catch (AggregateException e)
{
Console.WriteLine($"Aggregate Exception:
FuncAction.\n{e.Message}");
}
}
上述代码展示了如何使用Parallel.ForEach方法和线程局部状态。我们期望代码以并行方式执行并计算存储在int数组中的所有值。Parallel.For循环的每个线程维护一个局部累加变量。当每个线程初始化时,该累加变量被设置为0。随着每次迭代的进行,累加变量会加上数值。一旦线程完成其任务,该线程的局部总和会安全地添加到全局总和。循环完成后,全局总和将被打印出来。
上述代码还展示了如何使用 lambda 表达式来表示Func和Action委托:
]Parallel.ForEach<TSource,TLocal>(IEnumerable<TSource>,
Func<TLocal>, Func<TSource,ParallelLoopState,Tlocal
,TLocal>, Action<TLocal>).
在下一节中,我们将探讨一些并行调试工具。
并行调试和性能分析工具
在本节中,我们将探讨三个并行应用程序调试和性能分析工具。为此,我们使用了CH15_ParallelProgrammingDebuggingAndProfilingSample项目。在接下来的三个部分中,我们将使用此项目。
并行堆栈窗口
运行程序,直到调试器将其暂停。然后,从Visual Studio菜单中选择调试 | 窗口 | 并行任务。这将显示并行任务窗口。你应该会看到以下内容:

图 15.4 – 并行堆栈线程视图
如你所见,我们的主线程是通过我们的Program.Main方法启动的。我们可以看到调试器暂停在Program.MethodC。有四个线程 – 分别对应方法 A、B 和 C,以及外部代码中的一个。还有五个线程正在运行 – 这些是外部代码线程。
当你悬停在方法上时,你会看到以下弹出窗口:

图 15.5 – 显示线程和堆栈帧视图的并行堆栈线程视图
通过悬停在每个方法组上,你可以看到一个线程和它们的堆栈帧的表格。这些堆栈帧提供了方法名称和行号。当前线程的活动堆栈帧由黄色箭头标识。如果你在悬停在堆栈帧上时右键单击,你可以选择显示哪些详细信息,包括参数值,如下所示:

图 15.6 – 线程和堆栈帧视图
在这里,我们可以看到我们线程方法的每个参数的值。接下来,我们将查看任务窗口。
任务窗口
要查看任务窗口,从并行任务选项卡中选择下拉菜单中的任务。你应该会看到以下内容:

图 15.7 – 任务视图
前面的截图显示了异步逻辑堆栈。如果你悬停在方法上,你会看到一个弹出窗口,就像你在线程视图中做的那样:


图 15.9 – 任务窗格
此视图显示了各种任务及其状态,以及其他信息。你可以右键单击列来自定义你想要看到的列。单击行应将你带到源位置以查看代码。
在下一节中,我们将探讨并发可视化器。
并发可视化器
并发可视化器是一个命令行实用工具,允许您从命令行收集跟踪数据。这些数据可以在 Visual Studio 2022 的并发可视化器中查看,该可视化器可用于未安装 Visual Studio 的计算机。并发可视化器不支持 Web 项目;它依赖于 Windows 事件跟踪。
默认情况下,CVCollectionCmd.exe安装在C:\Program Files\Microsoft Visual Studio\2022\Preview\Common7\IDE\Extensions\rf2nfg00.o0t和/或C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\Extensions\rf2nfg00.o0t。
要开始收集跟踪,您可以使用以下命令:
C:\Program Files\Microsoft Visual
Studio\2022\Preview\Common7\IDE\Extensions\rf2nfg00.o0t\CVC
ollectionCmd.exe" /launch D:\dev\CH15_ParallelProgrammingDe
buggingAndProfilingSample\CH15_ParallelProgrammingDebugging
AndProfilingSample\bin\Debug\net6.0\CH15_ParallelProgrammin
gDebuggingAndProfilingSample.exe /outdir D:\Debugging
\TraceData
这将启动我们的应用程序并将跟踪数据记录到由/outdir命令行参数指定的位置。工具将生成几个文件,它们将具有.etl和.cvtrace文件扩展名。
从Visual Studio菜单中选择分析 | 并发可视化器 | 打开跟踪以查看生成的跟踪文件。您应该看到以下类似的内容:

图 15.10 – 上下文可视化器利用率选项卡
此屏幕显示了您所追踪的程序正在使用的逻辑核心数量。正如您所看到的,我的计算机有 16 个逻辑核心。在这 16 个核心中,只有 12 个正在被使用。点击线程选项卡将显示以下视图:

图 15.11 – 上下文可视化器线程选项卡
此屏幕为我们提供了关于所使用的线程、其功能和执行时间的良好、详细的分解。点击核心选项卡将显示以下视图:

图 15.12 – 上下文可视化器核心选项卡
此视图显示了逻辑核心及其由主线程和工作线程的使用情况。您将看到线程 ID、其名称、跨核心上下文切换次数、总上下文切换次数以及上下文切换的百分比。
注意
微软提供了对并发可视化器的更详细说明。我刚刚为您提供了该工具的简要概述及其使用方法。如果您想了解更多关于如何使用此工具的信息,可以查看微软的文档,链接为docs.microsoft.com/en-us/visualstudio/profiling/concurrency-visualizer?view=vs-2022。
到此,我们已经到达了本章的结尾。现在,让我们总结一下我们所学到的内容。
摘要
在本章中,我们探讨了如何使用 TPL 和 PLINQ 来并行执行代码。到目前为止,我们了解到 TPL 和 PLINQ 之间的主要区别在于 TPL 不能有效地利用计算机上的所有核心,而 PLINQ 可以。
我们还看到了如何查看计算机的 CPU 利用率。使用 PLINQ 使我们能够有效地利用 CPU 的所有核心来提高代码性能。然而,在基准测试并行代码时,我们发现它有时比非并行代码更快,而有时则更快。因此,对你的代码进行基准测试以查看哪种方法最适合你是有益的。
我们还回顾了一段代码,展示了如何使用 lambda 表达式来表示Func和Action委托。
最后,我们通过一个使用 Parallel Tasks 窗口、任务面板和并发可视化器的代码示例来查看并行应用程序的调试。
在下一章中,我们将探讨异步编程。但在我们这样做之前,试着回答这些问题,看看你记住了多少。然后,查看进一步阅读部分以增强你的知识。
问题
回答以下问题以测试你对本章知识的掌握:
-
TPL 代表什么?
-
PLINQ 代表什么?
-
你可以使用什么 Windows 程序来查看 CPU 核心使用情况?
-
并行代码是否总是比非并行代码更快?
-
你如何测量并行方法的代码性能?
进一步阅读
要了解更多关于本章所涵盖的主题,请查看以下资源:
-
PLINQ 和 TPL 中的 Lambda 表达式:
docs.microsoft.com/en-us/dotnet/standard/parallel-programming/lambda-expressions-in-plinq-and-tpl -
任务并行库 (TPL):
docs.microsoft.com/en-us/dotnet/standard/parallel-programming/task-parallel-library-tpl -
PLINQ 简介:
docs.microsoft.com/en-us/dotnet/standard/parallel-programming/introduction-to-plinq -
并行诊断工具:
docs.microsoft.com/en-us/dotnet/standard/parallel-programming/parallel-diagnostic-tools -
调试异步代码:任务的并行堆栈:
devblogs.microsoft.com/visualstudio/debugging-async-code-parallel-stacks-for-tasks/ -
在 Visual Studio 中调试并行应用程序(C#、Visual Basic、C++)的教程:
docs.microsoft.com/en-us/visualstudio/debugger/walkthrough-debugging-a-parallel-application?view=vs-2022&tabs=csharp#main
第十六章:第十六章:异步编程
在本章中,你将学习关于async、await和WhenAll的内容。你还将了解不同的返回类型并提取所需的结果。此外,你将学习如何正确地取消异步操作以及执行异步文件读写。
在本章中,我们将涵盖以下主题:
-
理解 TAP 模型:在本节中,我们提供了一个关于 TAP 模型的概述。
-
Task.Run)并异步执行。 -
GetAwaiter.GetResult()、.Result和.Wait对于Task和ValueTask都适用。 -
取消异步操作:在本节中,我们将编写演示异步任务取消的代码。
-
异步写入文件:在本节中,我们将异步地向文件中写入文本。
-
异步读取文件:在本节中,我们将异步地从文件中读取文本。
完成本章后,你将在以下领域具备技能:
-
理解 TAP 模型
-
异步处理网络资源
-
异步写入文件
-
异步读取文件
技术要求
你需要 Visual Studio 来处理本章中展示的代码。
本章的所有代码都放置在 GitHub 上,地址为github.com/PacktPublishing/High-Performance-Programming-in-CSharp-and-.NET/tree/master/CH16。
理解 TAP 模型
在我们开始之前,值得注意的是,处理异步编程有三种不同的模型。具体如下:
-
异步编程模型(APM)
-
基于事件的异步模式(EAP)模型
-
任务并行库(TPL)
APM 使用BeginMethod启动异步过程,使用EndMethod完成异步过程。EAP 使用MethodAsync启动异步过程,使用CancelAsync处理异步操作的取消,以及使用完成的事件处理器处理完成的异步操作。这两种执行异步操作的方式在 C# 4.5 中被 TPL 所取代。
TPL 使用async和await模式。异步方法名称以async结尾。异步方法通常返回一个可等待的Task或Task<Result>。从.NET 4.5 开始,建议使用 TPL 而不是使用 APM 和 EAP。
TAP 的基础类型是System.Thread.Tasks命名空间,以及通过异步操作提供的Task和Task<Tresult>类。Microsoft 建议在开始新项目时使用 TAP。
命名、参数和返回类型
使用 TAP 模型的异步方法在方法签名前缀为async Task(对于无返回值的方法),或async Task<Tresult>、async ValueTask或async ValueTask<Tresult>(对于返回值的方法)。不返回值的异步方法名称应以动词开头,如Begin或Process。
TAP 方法参数应与同步对应方法的参数匹配,并且顺序相同。您应避免完全使用免于遵守此规则的out和ref参数。如果您需要返回数据,请使用Task<Tresult>返回的Tresult。使用数据结构来适应多种返回类型。还值得考虑将取消令牌添加到 TAP 方法参数中,即使同步方法对应者没有这样的令牌也是如此。
当意图明确时,与多个任务一起工作的组合方法不必遵循此命名模式。WhenAll和WhenAny是组合方法的例子。
启动异步操作
您可能希望在异步方法开始时执行一些同步任务,例如验证和准备异步操作以执行。如果是这样,建议将这些任务保持到最小,并且它们所需的时间应尽可能短。原因是这些方法可能从用户界面(UI)线程调用,您不希望导致您的应用程序挂起或暂时冻结。
将同步操作保持到最小,并在异步操作中花费最短时间的原因之一是,当您运行并发异步方法时,长时间运行的同步操作会降低并发的好处。
有时,准备和启动异步操作所需的时间可能比同步完成相同操作所需的时间更长。在这些情况下,您可以同步运行该方法并返回一个任务。
异常
使用错误,如传递null参数,是异步方法中应该抛出的唯一错误。您可以通过修改调用代码来防止异步方法抛出使用错误,确保错误参数不会传递到异步方法中。所有其他类型的异常和错误应分配给返回的任务。通常,一个任务返回一个异常。但当单个任务表示多个操作时,单个任务可能会返回多个异常。
可选取消
异步方法实现者和消费者的取消是可选的。可以取消的任务公开了一个接受名为cancellationToken的CancellationToken的重载方法。
异步操作的取消请求被监控。当收到取消请求时,可能会被接受。如果取消导致未完成的工作,将返回一个Canceled状态的任务,没有可用的结果和没有异常。
Canceled状态是一个已完成的任务状态,RanToCompletion和Faulted也是如此。当一个任务的状态是Canceled、RanToCompletion或Faulted时,IsCompleted属性返回true。
当任务被取消时,除非指定了NotOnCancelled延续选项,否则延续将继续被安排和执行。如果指定了此选项,则在任务被取消时,延续将不会被安排或执行。
通过语言功能等待取消任务的异步代码将继续运行,但将接收到OperationCanceledException或其派生异常。而通过Wait和WaitAll等方法同步阻塞等待任务的代码将继续运行,并抛出异常。
当取消令牌在接受该令牌的 TAP 方法被调用之前请求取消时,TAP 方法应返回一个Canceled任务。在异步操作执行期间,可以忽略取消请求。在返回任务时,通常返回具有以下三种状态之一的任务:
-
Canceled:操作因取消请求而结束。 -
RanToCompletion:请求取消操作已完成并生成了结果。 -
Faulted:请求取消导致生成了异常。
如果你正在编写异步方法,并希望首先启用操作可取消,则不需要生成一个没有CancellationToken的重载方法。如果你正在编写无法取消的异步方法,则不需要提供接受CancellationToken的重载方法。这些指南有助于调用者了解目标方法是否可取消。当调用接受CancellationToken的方法的消费者没有取消方法调用的意愿时,可以将None传递给CancellationToken参数,因为这功能上等同于默认的CancellationToken。
可选进度报告
当异步操作作为 UI 流程的一部分运行时,提供进度更新可能是有益的。这有助于最终用户知道程序仍在运行。
IProgress<T>接口用于处理进度,并将其作为名为progress的参数传递给异步方法。将此接口传递给异步方法可以帮助防止在操作开始后错误注册事件处理器时可能发生的竞争条件,这可能导致更新丢失。将接口传递的另一个原因是,消费代码可以支持各种进度实现。只有当 TAP 实现支持进度通知时,才提供IProgress<T>接口。
与进度更新很好地匹配的示例是 FindFilesAsync 方法,该方法返回满足特定搜索模式的文件列表。在这种情况下,您可以提供已完成的工作百分比以及当前的中间结果集。这些信息将由特定于您的 API 的某些数据类型提供。这些数据类型通常以 ProgressInfo 后缀结尾。
提供进度参数的 TAP 方法应允许不进行进度报告,通过允许进度参数为 null。进度应同步报告给实现 IProgress<T> 接口的 Progress<T> 对象。这使异步方法能够快速提供进度。消费者可以确定他们想要如何以及在哪里处理进度更新提供的信息。
ProgressChanged 事件由 Progress<T> 类的实例公开。每次异步操作报告进度更新时都会触发此事件。当 Progress<T> 对象被实例化时,ProgressChanged 事件在捕获的 SynchronizationContext 对象上触发。如果没有可用同步上下文,则使用针对线程池的目标上下文作为默认上下文。
您可以像注册任何其他事件的处理程序一样注册此事件的处理程序,并且您还可以向 Progress<T> 构造函数提供一个处理程序,以方便起见。单个处理程序的行为与 ProgressChanged 事件的处理器相同。在执行事件处理器期间,通过异步地发出进度更新来避免对异步操作的延迟。
现在我们对基于任务的异步模式有了高级别的理解,在下一节中,我们将探讨 async、await 和 Task。
async、await 和 Task
在本节中,我们将探讨同步运行方法、使用 Task.Run 和异步运行方法之间的性能差异。异步方法通过 async 关键字来标识。
await 关键字通知运行时在指定行等待,直到当前任务完成。它只能与前面带有 async 关键字的方法一起使用。
System.Threading.Tasks 命名空间。任务封装了线程,以便最大化利用计算机硬件的多核。
让我们编写一个简单的项目来基准测试调用方法的三种不同方式。我们将使用 Task.Run 同步调用该方法,并使用 async/await 异步调用。我们将使用 BenchmarkDotNet 来查看每种方法调用类型的性能。我们的目标是展示使用异步调用相对于同步和 Task.Run 调用的性能优势。
我们执行以下步骤来编写我们的小程序:
-
启动一个新的 .NET 6.0 控制台应用程序,并将其命名为
CH16_AsynchronousProgramming。 -
添加
BenchmarkDotNetNuGet 包。 -
添加一个名为
Benchmarks的新类,并在该类中添加以下方法:public static void LengthyTask() { int y = 0; for (int x = 0; x < 10; x++) y++; }
此方法是我们的工作方法。它所做的只是将y变量增加 1,重复十次。
-
将
SynchronousMethod添加到类中:[Benchmark] public void SychronousMethod() { LengthyTask(); }
此方法同步调用LengthyTask方法,并作为一个基准测试。
-
将
TaskMethod添加到类中:[Benchmark] public void TaskMethod() { Task.Run(new Action(LengthyTask)); }
此方法将LengthyTask方法作为一个新的Action运行,该Action被排队在ThreadPool上运行。该方法返回一个Task或Task<Tresult>句柄。
-
将
AsynchronousTaskMethod添加到类中:[Benchmark] public void AsynchronousTaskMethod() { var data = async () => await Task.Run(new Action(LengthyTask)); }
此方法以异步方式使用Task.Run运行LengthyTask方法,并在继续之前等待方法完成。
-
我们现在已经完成了基准测试类。因此,在
Program.cs文件中,将代码替换为以下内容:using BenchmarkDotNet.Running; using CH16_AsynchronousProgramming; Console.WriteLine("CH16 - Asynchronous Programming"); var summary = BenchmarkRunner.Run<Benchmarks>(); Console.ReadLine();
此代码将运行我们的基准测试并为我们生成报告。
-
确保项目设置为
Release构建。 -
构建项目。
-
打开命令窗口,并在
bin\Release\net6.0文件夹中执行名为CH16_AsynchronousProgramming.exe的编译后的可执行文件。 -
基准测试应该开始运行,一旦完成,您应该看到一个类似于图 16.1所示的报告:

图 16.1 – BenchmarkDotNet 报告,针对我们的 CH16_AsynchronusProgramming 项目
如您在图 16.1中可以看到,同步运行LengthyTask方法耗时7.3220 ns完成。使用Task.Run运行耗时最长,为112.4494 ns。而运行代码最快的方式是异步,只需0.9982ns即可完成。
我们可以从这些时间中清楚地看到,运行我们的代码异步确实有明显的性能优势,因为我们的代码完成所需的总时间更少。
在下一节中,我们将比较await与GetAwaiter.GetResult()、.Result和.Wait的性能。我们将涵盖Task和ValueTask。
对 Task 和 ValueTask 的 GetAwaiter.GetResult()、.Result 和.Wait 进行基准测试
在本节中,我们将编写一些代码来基准测试GetAwaiter.GetResult()、.Result和.Wait方法,以查看哪种方法最适合获取Task和ValueTask的返回值。
在github.com/dotnet/BenchmarkDotNet/issues/236,BenchmarkDotNet的维护者adamsitnik回复了@i3arnon:
“@i3arnon 谢谢提示!我已经测量了.Result与.Wait与GetAwaiter.GetResult()的比较,对于Tasks来说,GetAwaiter.GetResult()似乎也是最快的方式。另一方面,对于ValueTask来说,它要慢得多,所以我继续使用.Result来处理 VT。”
因此,从我们将要编写的代码中,我们应该看到.Result在处理ValueTask时应该提供给我们最佳的性能。而GetAwaiter.GetResult()在处理Task时应该提供给我们最佳的性能。
我们现在将开始编写我们的代码。请在上一节中开始的 CH16_AsynchronousProgramming 项目中完成以下任务:
-
打开
CH16_AsynchronousProgramming项目。 -
打开
Benchmarks类。 -
添加以下返回
int的方法:public static int LengthyTaskReturnsInt() { int y = 0; for (int x = 0; x < 10; x++) y++; return y; }
在此代码中,我们增加 y 变量并返回结果。
-
添加
GetAwaiterGetResult方法:[Benchmark] public void GetAwaiterGetResult() { int value = Task.Run(() => LengthyTaskReturnsInt()).GetAwaiter() .GetResult(); }
此方法基准测试了使用 GetAwaiter().GetResult() 从方法返回 int 所花费的时间。
-
添加
Result方法:[Benchmark] public async Task Result() { int value = await Task.Run(() => LengthyTaskReturnsInt()).ConfigureAwait(false); }
此方法基准测试了等待方法返回 int 所花费的时间。
-
添加
Wait方法:[Benchmark] public void Wait() { Task.Run(() => LengthyTask()).Wait(); }
此方法运行一个长时间的任务,并在它完成之前等待。
-
添加
GetAwaiter方法:[Benchmark] public void GetAwaiter() { Task.Run(() => LengthyTask()).GetAwaiter(); }
此方法获取一个用于等待任务完成的等待者。
- 构建项目并通过命令行运行可执行文件。你应该会看到一个类似于 图 16.2 所示的总结报告:
![图 16.2 – 本节方法的 BenchmarkDotNet 总结报告]
![图 16.2 – 本节方法的 BenchmarkDotNet 总结报告]
图 16.2 – 本节方法的 BenchmarkDotNet 总结报告
从这些结果中我们可以看出,当从 Task 返回值时,GetAwaiterGetResult 方法比 Result 方法运行得快得多。并且当执行长时间运行的 Task 时,GetAwaiter 方法比 Wait 方法运行得更快。
在下一节中,我们将探讨如何通过使用 WhenAll 来加快我们异步等待多个任务时的代码。
使用 async、await 和 WhenAll
在本节中,我们将编写一些示例代码,演示 async、await 和 WhenAll 的使用及其对执行时间的影响。
如果你在一个方法中执行多个任务,并且对每个任务都使用 await,你的代码将以异步方式工作,执行时间将会很长。你可以通过使用 WhenAll 在继续之前等待所有完成的任务来避免这种时间开销,从而提高性能。在我们将要编写的代码中,你将看到 WhenAll 如何减少在函数内部执行两个异步方法所需的时间,与逐个等待每个任务相比。
让我们逐步完成以下任务:
-
在
Benchmarks类中,添加以下异步方法,该方法等待300毫秒然后返回一个int:private async Task<int> TaskOne() { await Task.Delay(300); return 100; }
TaskOne 方法是我们将要由基准测试运行的方法之一。
-
添加我们的第二个异步方法:
private async Task<string> TaskTwo() { await Task.Delay(300); return "TaskTwo"; }
TaskTwo 方法等待 300 毫秒然后返回一个 string。
-
首先,我们将基准测试同步运行异步任务:
[Benchmark] public async Task SynchronousAwait() { int intValue = await TaskOne(); string stringValue = await TaskTwo(); }
在这里,我们有两个任务,我们需要等待它们都完成后再继续。
-
现在,我们将添加一个方法,它将利用
WhenAll:[Benchmark] public async Task AsynchynchronousWhenAll() { var taskOne = TaskOne(); var taskTwo = TaskTwo(); await Task.WhenAll(taskOne, taskTwo); }
在此方法中,我们创建了我们的两个任务,然后将它们作为参数传递给 WhenAll 方法。我们不会继续,直到所有任务都完成。
- 通过命令行构建和运行您的可执行文件。您应该会看到类似 图 16.3 的内容:

图 16.3 – 多个异步调用的同步和异步执行结果
从我们的基准测试结果中可以看出,使用 WhenAll 执行多个异步任务比依次等待它们要快得多。在下一节中,我们将探讨如何取消异步任务。
取消异步操作
在本节中,我们将探讨如何取消长时间运行的自异步操作。有时一个任务会花费比预期更长的时间。一个很好的例子是在网站宕机时从网站获取数据。由于像 Error 404、Error 401 或 Error 500 这样的原因,异步操作可能需要很长时间才能由服务器重置。因此,在设置的时间后取消异步操作以防止浪费最终用户的时间是很有用的。
我们将要编写的代码将从网站 URL 返回文本。我们将分配一个非常短的超时时间。这个超时将取消负责返回网站文本的任务。按照以下步骤操作:
-
打开
CH16_AsynchronousProgramming项目,并添加一个名为TaskCancellation的新类。 -
添加
using System.Text;语句。 -
添加以下两个成员变量:
private const string _website = "https://docs.microsoft.com"; private static readonly CancellationTokenSource _cancellationTokenSource = new();
_website 变量持有我们将返回其页面文本的网站的 URL。CancellationTokenSource 将用于向 CancellationToken 发送取消信号。
-
添加以下方法:
private static readonly HttpClient HttpClient = new() { MaxResponseContentBufferSize = 1000000 };
在这里,我们声明一个方法,该方法返回用于我们的 HTTP 请求的 HttpClient。MaxResponseContentBufferSize 设置在读取响应内容时缓冲的字节数。
-
现在添加
ReturnWebsiteTextAsync方法:private static async Task<string> ReturnWebsiteTextAsync() { HttpResponseMessage response = await HttpClient .GetAsync( _website, _cancellationTokenSource.Token) .ConfigureAwait(false); byte[] contentAsByteArray = await response .Content .ReadAsByteArrayAsync( _cancellationTokenSource.Token) .ConfigureAwait(false); return Encoding.ASCII.GetString( contentAsByteArray ); }
在此方法中,我们声明 HttpResponseMessage,它等待一个异步任务,该任务返回网页的内容。然后读取响应并将其转换为字节数组。然后,这个字节数组被转换成 ASCII 字符串并返回。
-
现在添加
Start方法:public static async Task Start() { Console.WriteLine("Task started."); try { _cancellationTokenSource.CancelAfter(3000); await ReturnWebsiteTextAsync() .ConfigureAwait(false); } catch (OperationCanceledException) { Console.WriteLine( "\nThe task has timed out and been cancelled. \n"); } finally { _cancellationTokenSource.Dispose(); } Console.WriteLine("Task completed."); }
在 Start 方法中,我们写入一个控制台消息,表明任务已开始。然后我们将 cancellationTokenSource 的取消时间设置为 30 秒,即 3000 毫秒。然后我们 await 调用 ReturnWebsiteTextAsync。如果在设置的超时时间后进程超时,将引发 OperationCanceledException,它将在控制台输出一条消息。最后,cancellationTokenSource 被释放,并输出一条控制台消息,表明任务已完成。
-
在
Program.cs文件中注释掉基准运行代码,并添加以下行:TaskCancellation.Start().GetAwaiter(); -
运行项目,并尝试使用不同的超时时间多次运行,以测试代码成功完成并返回文本,以及测试操作超时并引发异常。
通过几次运行此代码并设置超时为3000和30000,将分别呈现操作超时异常并显示网页文本。正如你自己运行代码时可以看到的,编写在设定时间段后取消的任务的异步任务是很容易的。
在下一节中,我们将编写代码来展示如何异步地写入文件。
异步写入文件
在本节中,我们将异步地将文本写入文件。异步文件写入可能有用的场景包括将大量文本和数据写入不会立即读取的文件。
使用以下步骤来编写我们的代码:
-
在您的
C:\驱动器上,如果您还没有创建一个名为Temp的文件夹,请添加一个。 -
打开
CH16_AsynchronousProgramming项目。 -
添加一个名为
FileReadWriteAsync的类。 -
添加以下方法:
public static async Task WriteTextAsync() { string filePath = @"C:\Temp\Greetings.txt"; string text = "Hello, World!"; byte[] encodedText = Encoding.Unicode.GetBytes(text); using (FileStream fileStream = new FileStream( filePath, FileMode.Append, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true ) ) { await fileStream.WriteAsync( encodedText, 0, encodedText.Length); }; }
在WriteTextAsync方法中,我们声明一个文本文件的文件路径和一个包含要写入文件的文本的变量。要写入的文本被转换为字节数组。然后以追加模式打开一个可写的异步文件流。然后我们将文本写入文件流并关闭它。
在下一节中,我们将继续在本节课中添加我们的异步读取方法,展示如何异步地读取文件。
异步读取文件
在本节中,我们将异步地从文件中读取文本。我们将基于上一节中写入文本到文件的代码进行构建。
以下步骤将添加我们的异步读取方法并更新Program.cs文件以运行我们的异步代码:
-
在
FileReadWriteAsync类中,添加以下方法:public static async Task<string> ReadTextAsync() { string filePath = @"C:\Temp\Greetings.txt"; using (FileStream fileStream = new FileStream( filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true ) ) { StringBuilder sb = new StringBuilder(); byte[] buffer = new byte[0x1000]; int numRead; while (( numRead = await fileStream .ReadAsync(buffer, 0, buffer.Length)) != 0 ) { string text = Encoding.Unicode .GetString(buffer, 0, numRead); sb.Append(text); } return sb.ToString(); } }
在这里,我们定义了需要读取的文件的路径。然后我们以读取模式打开一个文件流,具有读取访问权限。接下来,我们定义StringBuilder和字节数组,它们将作为我们的缓冲区来存储读取的数据。然后我们读取流,直到读取完成。在每次读取迭代中,我们从文件中读取文本,将其编码为 Unicode,然后将其追加到StringBuilder。然后,一旦循环完成并退出,我们从方法中返回字符串。
-
打开
Program.cs类。 -
注释掉以下行:
//var summary = BenchmarkRunner.Run<Benchmarks>(); // TaskCancellation.Start().GetAwaiter();
当我们运行代码时,我们不需要这些行。
-
添加以下代码行:
FileReadWriteAsync.WriteTextAsync().GetAwaiter(); string data = FileReadWriteAsync.ReadTextAsync() .GetAwaiter().GetResult(); Console.WriteLine(data);
在此代码中,我们调用将文本异步写入文件的方法,异步地将文本读取到变量中,然后将变量的内容打印到控制台。
- 运行代码,你应该会看到类似于图 16.3的内容:
![Figure 16.4 – 我们异步写入和读取代码的结果]
![img/B16617_Figure_16.4.jpg]
图 16.4 – 我们异步写入和读取代码的结果
如从截图所示,我们已经成功地将文本异步写入文件,从该文件异步读取,并将内容打印到控制台窗口。
在下一节中,我们将总结本章所学的内容。
摘要
在这一章中,我们从基于任务的异步模式的高级概述开始。我们涵盖了命名、参数、返回类型、初始化异步操作、异常,以及可选地提供报告进度更新和取消操作的方法。我们了解到,我们可以有允许取消的异步操作,以及不允许取消的异步操作。此外,我们还了解到,当请求取消时,取消请求要么继续进行,要么被忽略。已完成的任务可以具有已取消、已运行完成或已出错的完成状态。
然后,我们对三种不同的同步调用方法、使用Task.Run和异步调用进行了基准测试。使用Task.Run花费的时间最长,其次是同步运行方法,而异步运行方法是运行该方法的最快方式。
然后,我们对GetAwaiter.GetResult()、Result和Wait方法在Task和TaskValue上的性能进行了基准测试。我们发现,当从Task返回值时,GetAwaiterGetResult方法比Result方法运行得快得多。而在执行长时间运行的Task时,GetAwaiter方法比Wait方法运行得更快。
接下来,我们探讨了取消异步操作。我们编写了一个示例,从网站获取文本并将文本输出到控制台。如果操作在设定的时间内未能完成,则取消操作。
在最后两个部分中,我们编写了一些代码来演示文本和数据异步的读写。
为了完成这一章,有一些问题供你回答,以检验你对所读内容的掌握程度,以及一些关于异步编程的进一步阅读材料。
感谢您购买这本书。希望您喜欢阅读它,并且学到了许多改进您自己代码的方法。祝您编码愉快!
问题
-
TAP 代表什么?
-
哪种参数类型标识异步操作可以被取消?
-
将哪种参数类型传递给异步任务以提供进度更新?
-
解释
async、await和Task。 -
如何取消异步操作?
-
如何报告异步操作进度?
进一步阅读
-
异步编程;APM 与 EAP 的比较:
stackoverflow.com/questions/11276314/asynchronous-programming-apm-vs-eap -
C#中异步编程的介绍:
auth0.com/blog/introduction-to-async-programming-in-csharp/ -
C# 中异步方法的性能特性:
devblogs.microsoft.com/premier-developer/the-performance-characteristics-of-async-methods/ -
异常处理(任务并行库):
docs.microsoft.com/en-us/dotnet/standard/parallel-programming/exception-handling-task-parallel-library
第十七章:评估
本节用于回答所有章节的问题。
第一章,介绍 C# 10.0 和.NET 6
-
垃圾收集器和 JIT 编译器的性能改进,基于文本处理的性能改进,正则表达式处理速度加快,以及线程和异步操作的性能得到提升。集合、LINQ、网络和 Blazor 的性能也得到了改进;此外,.NET 6 还引入了基于性能的 API 和分析器。
-
你现在可以编写顶层程序并使用仅
init属性和记录。有新的模式匹配功能和针对特定类型的表达式。你可以使用协变返回并进行原生编译。 -
dotnet和ngen。 -
运行 Microsoft Store 应用性能评估。根据评估结果遵循 Microsoft 的建议来提高你的应用性能,并解决应用中发现的每个突出显示的问题。
-
进行基线测量,通过执行具有最大整体影响的重构开始优化,启用 HTTP 压缩,减少 TCP/IP 连接开销,并使用 SSL 上的 HTTP/2。
-
读者自行决定的阅读任务。
-
读者自行决定的编码任务。
-
读者自行决定的基准测试任务。
第二章,实现 C#互操作性
-
平台调用。
-
解释
P/Invoke是什么。 -
它提醒程序员他们有责任确保其代码的安全性,因为.NET 框架不对其进行管理。
-
有三代对象:零代、一代和二代。通常,对象被添加到零代,并执行垃圾回收。但如果它们在零代中存活下来,它们将被提升到一代。在一代中存活下来的对象将被提升到二代。如果零代、一代和二代完全填满且添加了新对象,那么你将遇到
OutOfMemoryException,你的应用程序将崩溃。 -
fixed关键字用于确保指针引用的对象不会被垃圾收集器提升。否则,指针将指向错误的对象,导致软件中的错误。 -
BSTR。
-
IronPython,尽管也存在其他包。
-
实现可处置的设计模式。
-
在对象被处置时将大字段设置为
null。这使得它们无法访问,并且它们比非确定性回收时释放得更快。你将在conditional块之外执行此操作。参见docs.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose。
第三章,预定义数据类型和内存分配
-
bool、byte、char、DateTime、decimal、double、enum、float、int、long、sbyte、short、struct、value tuple、uint和ulong。 -
object、string、delegate和dynamic。 -
创建一个
static类型的实例。 -
不。栈和堆都使用相同的物理内存。
-
当项目在使用时,它们简单地被推入栈中,当它们不再需要时,立即从栈中弹出。添加到堆中的对象需要被管理和维护对象引用计数。放置在栈上的项目同时使用栈和堆,因为堆上的项目在栈上有指针变量。因此,与栈相比,使用堆有更多的开销。
-
字符串被放置在堆上。变量被放置在栈上,带有字符串的内存地址。当另一个变量被分配相同的字符串时,它将获得字符串的地址。因此,栈上的多个项目将指向相同的字符串。然而,如果你向字符串中添加任何内容,那么将在堆上创建一个新的字符串,并带有新的内存地址。分配新字符串的变量将具有指向堆上新字符串的内存地址,因此原始字符串永远不会更新。
-
小于 80,000 字节。
-
80,000 字节或更高。
第四章,内存管理
-
三:0 代、1 代和 2 代。
-
小于 80,000 字节的对象被放置在 SOH 上。
-
80,000 字节或更大的对象被放置在 LOH 上。
-
强引用是一种不会被垃圾回收的引用。
-
弱引用是一种会被垃圾回收的引用。
-
实现
IDisposable模式。 -
当不再使用时取消订阅事件监听器。不再使用时处置事件发布者或将它们设置为 null。
-
Marshal.ReleaseComObject(object)。 -
确保任何分配的内存都被释放。使用
IDisposable模式确保在对象被处置时清理内存。
第五章,应用程序分析和跟踪
-
应用程序、程序集、命名空间、类型、方法和字段。
-
可维护性指数、循环复杂度、继承深度、类耦合、源代码行数和可执行代码行数。
-
记录位置和时间、进程名称、处理器架构、异常信息、操作系统和 CLR 版本以及加载模块的名称、版本和物理路径。
-
名称、路径、优化后的用户代码、符号状态、O(顺序)、版本、进程和 AppDomain。
-
Microsoft Visual Studio 2022 和 JetBrains dotTrace、dotMemory 和 dotnet-counters。
-
我们能够列出可监控的 .NET 进程和可用于收集数据的计数器。我们获取了 .NET 进程标识符并对其进行了监控,并收集、保存和查看我们从运行中的 .NET 进程中收集的数据。
第六章,.NET 集合
-
System.Collections、System.Collections.Generic、System.Collections.Concurrent和System.Collections.Specialized。 -
使用大 O 符号来确定算法效率。
-
算法效率决定了时间如何随输入量而变化。
-
基准测试表明,使用
IList<T>比使用List<T>更快,因此优先使用IList<T>而不是List<T>。 -
你可以使用任一方法。你的选择取决于你的性能需求和你要实现的目标。使用集合和数组之间存在权衡。理解这些权衡将帮助你选择应该应用到你的代码中的选项。
-
索引器使得类中的对象可以像访问数组中的项一样被访问。
-
IEnumerator<T>在遍历内存集合时比IEnumerable<T>更快。 -
根据基准测试,在内存和速度性能方面,查询数据库并获取枚举器是查询数据库和遍历结果最快的方式。
-
使用
yield关键字。
第七章,LINQ 性能
-
使用索引而不是
Last()调用直接访问集合中的最后一个元素。在 LINQ 查询中避免使用let关键字。将列表转换为数组以执行分组,然后返回枚举器。 -
编译器生成的代码行数更多,运行时间更长,并且当不使用
let关键字时,运行时分配的内存更多。 -
从具有最少项的对象开始过滤项,然后是具有递增项数的对象。此外,避免使用
let关键字。 -
带参数的闭包比不带参数的闭包性能更好。
第八章,文件和流 I/O
-
绝对路径、相对路径、UNC 路径和 DOS 设备。
-
在注册表编辑器中,将
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled设置为1。 -
最有效计算目录大小的方法是获取目录的
DirectoryInfo,然后调用GetFileSystemInfos()。然后遍历结果,将每个FileInfo对象的长度添加到获取目录的大小。 -
移动文件最高效的方法是从内存缓存中获取
FileInfo对象,然后使用FileInfo.MoveTo(string destination)方法移动文件。 -
在退出应用程序之前遇到不可恢复的异常。
-
IOException。 -
本地、本地缓存、漫游、临时和 C:\ProgramData。
-
当被提示时,用户只能为自己安装软件。这将导致每个登录的人使用软件时都有自己的数据副本,数据位于他们的登录账户下的 Microsoft VirtualStore 中。
-
当多个用户登录到同一台计算机,并且只安装了一个用户的应用程序而不是所有用户时,应用程序数据将存储在 Microsoft 虚拟存储中,而不是存储在
C:\ProgramData的集中位置。 -
C:\Users\%USERNAME%\AppData\Local\VirtualStore。
第九章,增强网络应用程序的性能
-
应用层、表示层、会话层、传输层、网络层、数据链路层和物理层。
-
HTTP、HTTPS、SSH、SSL、DHCP、DNS、FTP、TFTP、Telnet、SMTP、IMAP4、POP3、TCP、IP、UDP、以太网和 PPP。
-
TCP 使数据传输和接收得到保证。UDP 只允许传输无法保证接收的数据。
-
使用浏览器内置的开发者工具。
-
gRPC 是一个跨平台、跨语言和跨设备的框架,用于在应用程序之间进行远程过程调用。gRPC-Web 是浏览器基于 RCP 调用的代理,因为浏览器应用程序无法直接使用 gRPC。
-
减少页面执行的操作数量和页面调用的服务数量。减小图像大小。使用文件压缩减小通过网络传输的文件大小。缓存网络资源。在服务器上过滤数据,将其分成页面,并只返回请求的数据页面。
第十章,设置我们的数据库项目
N/A。
第十一章,基准测试关系型数据访问框架
-
使用 Dapper.NET 执行存储过程。
-
使用 Dapper.NET 执行原始 SQL 语句。
-
使用 ADO.NET 执行存储过程。
-
使用 ADO.NET 执行存储过程。
-
使用 ADO.NET 执行存储过程。
-
不一定。混合方法可能更好,因为您可以通过使用您选择的框架中最高效的方法来最大化您所涉及的数据操作的性能。
第十二章,响应式用户界面
-
配置应用程序以支持高 DPI。
-
配置应用程序以支持长文件路径。
-
在应用程序的开始处添加启动画面。
-
将长时间运行的任务作为后台任务运行。
-
内存缓存和分布式缓存。
-
使用 AJAX。
-
WebSockets和 SignalR。 -
SetSemanticFocus、Announce和字体缩放。 -
将
BlazorWebView组件添加到页面中,并将其指向 Blazor 应用程序的根目录。 -
ProgressRing和ProgressBar。
第十三章,分布式系统
-
命令查询责任分离。
-
我们可能希望为命令使用一个模型,为查询使用另一个模型。
-
事件驱动编程。
-
我们使用事件来触发无服务器函数的执行,例如 Azure Durable Function。
-
一种用于打包应用程序及其依赖项的软件,可以在云或本地部署和执行。
-
用于部署第三方依赖项和遗留代码。
-
以函数形式存在的微服务,仅在需要时运行,并且通常在事件触发器响应下运行。
-
无服务器函数可以快速扩展,并且您只需为函数运行的时间付费。与需要大部分时间运行容器的相比,这可以节省金钱。
-
Azure Functions 的扩展,使您能够在无服务器环境中编写有状态函数。我们还可以使用它们来定义工作流程。
-
活动、编排器、实体和客户端。
-
聚合器(有状态实体)、扇出/扇入、函数链、人工交互和监控(演员)。
-
用于管理微服务的基础设施即代码平台。
-
您可以使用 C#管理微服务和它们资源,从创建到运行、停止和删除。
第十四章,多线程编程
-
运行、挂起、等待、睡眠、加入和停止。 -
您不需要 – 这个 API 现在已过时。
-
前台和后台。
-
使用
CancellationToken在CancellationTokenSource操作超时时引发TaskCanceledException。 -
Thread.Start()或Thread.Start(object)。
第十五章,并行编程
-
任务并行库。
-
并行 LINQ 库。
-
性能监控器即
perfmon。 -
不。
-
使用
BenchmarkDotNet测试各种方法的性能。
第十六章,异步编程
-
基于任务的异步模式。
-
CancellationToken。 -
IProgress<T>。 -
声明了一个异步方法,
async关键字位于方法名之前。await关键字位于异步操作之前,并阻止任何进一步代码的继续执行,直到异步操作完成。Task是异步方法返回的内容。对于void方法,返回类型是Task,而对于返回值的函数,返回类型是Task<T>。 -
创建一个新的
CancelationTokenSource,然后设置取消方法,例如CancelAfter(3000)。 -
将
IProgress<T>类型作为参数传递给异步方法,并为ProgressChanged事件添加事件处理器。或者,您也可以将单个处理器传递给Progress<T>构造函数。

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及行业领先的工具,帮助您规划个人发展并推进您的职业生涯。有关更多信息,请访问我们的网站。
第十八章:为什么订阅?
-
通过来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,增加编码时间
-
通过为您量身定制的技能计划提高您的学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于快速访问关键信息
-
复制粘贴、打印和书签内容
您知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?您可以在 packt.com 升级到电子书版本,并且作为印刷书客户,您有权获得电子书副本的折扣。有关更多信息,请联系我们 customercare@packtpub.com。
在 www.packt.com,您还可以阅读一系列免费的技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
您可能还会喜欢的其他书籍
如果您喜欢这本书,您可能对 Packt 的其他书籍也感兴趣:
C# 10 和 .NET 6 – 第六版 – 现代跨平台开发
马克·J·普莱斯
ISBN: 9781801077361
-
使用 Blazor、Razor Pages、模型-视图-控制器(MVC)模式和其他 ASP.NET Core 功能构建丰富的网络体验
-
使用面向对象编程构建自己的类型
-
编写、测试和调试函数
-
使用 LINQ 查询和操作数据
-
使用 Entity Framework Core 在您的应用程序中集成和更新数据库,
-
Microsoft SQL Server 和 SQLite
-
使用最新的技术,包括 gRPC 和 GraphQL 构建和消费强大的服务
-
使用 XAML 构建跨平台应用程序
C# 10 和 .NET 6 软件架构 – 第三版
加布里埃尔·巴蒂斯塔,弗朗西斯科·阿布鲁齐塞
ISBN: 9781803235257
-
使用经过验证的技术克服现实世界的架构挑战
-
应用分层架构等架构方法
-
利用容器等工具有效地管理微服务
-
利用 Azure 功能,快速掌握全球解决方案
-
使用 C# 10 编程和维护 Azure Functions
-
了解何时最好使用测试驱动开发(TDD)
-
在现代架构中使用 ASP.NET Core 实现微服务
-
用人工智能丰富您的应用程序
-
获得最佳 DevOps 原则,以启用 CI/CD 环境
[![图]
描述自动生成,置信度中等](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hiprf-prog-csp-dn/img/9781803232973_Cover.png)](https://packt.link/9781803232973)
C# 10 和 .NET 6 企业级应用开发 – 第二版
拉温德拉·阿凯拉,阿伦·库马尔·塔米里萨,苏尼尔·库马尔·库纳尼,布普什·古普塔·穆蒂亚卢
ISBN: 9781803232973
-
通过充分利用 .NET 6 的最新功能来设计企业应用程序
-
发现应用程序的不同层,例如数据层、API 层和 Web 层
-
通过实现使用 .NET 和 C# 10 的企业级网络应用程序并在 Azure 上部署来探索端到端架构
-
专注于网络应用程序开发的核心理念,并在 .NET 6 中实现它们
-
将新的 .NET 6 健康和性能检查 API 集成到您的应用程序中
-
探索 MAUI 并构建针对多个平台的应用程序 - Android、iOS 和 Windows
Packt 正在寻找像您这样的作者
如果您有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像您一样,帮助他们将见解分享给全球技术社区。您可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交您自己的想法。
分享您的想法
您已经完成了 C# 和 .NET 中的高性能编程,我们非常乐意听到您的想法!如果您在亚马逊购买了这本书,请点击此处直接转到该书的亚马逊评论页面并分享您的反馈或在该购买网站上留下评论。
您的评论对我们和整个技术社区都很重要,并将帮助我们确保我们提供高质量的内容。
您可能还会喜欢的其他书籍
您可能还会喜欢的其他书籍



浙公网安备 33010602011771号