性能决定了应用程序是被用户喜爱使用还是被卸载并永远遗忘 。
仅仅拥有一个能响应用户需求的应用程序是不够的。要想让应用被频繁使用(很可能是每天使用),它必须能够快速启动并高效执行各项任务 。
这种速度和响应能力直接影响用户满意度,因为人们对数字体验的期望越来越高。研究表明,即使是加载时间或任务完成速度的微小延迟,也会显著降低用户参与度和整体满意度 。
在本章中,我们将讨论可以提升性能的不同方面,以及实现这一目标的技术手段。具体来说,我们将涵盖以下内容:
- 提升应用程序性能需要考虑的不同方面
- 如何对应用程序进行检测以识别性能问题
- 如何提升应用程序性能
20年工作经验,承接微信小程序,App,网站,网站后端开发。有意向私聊我哈。微信:akluse
性能优化领域
应用程序性能不仅关乎代码质量。它是通过不同层面的精细调优来实现性能效率的过程。
因此,性能优化主要涉及以下方面:
- 应用设计与架构 :路径越长,到达目的地所需时间就越久。正如我常对客户说的,你的速度或许是我的两倍,但如果路径也是我的两倍长,我们仍会同时抵达。这里想表达的是:若架构效率低下,即便采用高性能框架和库也收效甚微。我常常见到过度解耦的架构,存在过多跳转和上下文切换,最终导致应用性能低下。关键在于构建一个在性能与解耦程度之间取得平衡的架构。从设计角度看,通过寻找更短的实现路径(从而打造更高效的应用),许多现有设计都有优化空间。当然,并非每段代码都需要如此处理,但应当重点关注热路径——即用户频繁使用的路径。对于每年仅被单个用户使用一次的功能,优化其性能可能毫无意义。 你必须在优化工作的成本(这需要时间,因此会产生成本)与预期收益之间找到平衡。
- 基础设施 :如果我们在基础设施上托管应用程序,就必须确保该基础设施高效且经过优化,以最大化应用程序的吞吐量,同时最小化其延迟。然而,在 CLI 应用程序的上下文中,应用程序运行在用户的计算机上,因此我们可能会认为这里无事可做,但这种想法是错误的!我们可以执行一些调优任务来积极影响性能。例如,我们可以减少资源利用率,使得在用户计算机上运行该应用程序时消耗最少的资源,从而高效执行,即使计算机同时运行其他应用程序或性能较低。
- 框架与库 :当然,使用高效且性能优异的框架和库有助于提升应用程序性能。例如,每个新版本的.NET 都承诺提供更好的性能。因此,升级.NET 版本可能是提升我们应用性能的简便方法。我们使用的库也是如此:有些库的性能优于其他库 。
- 编码实践 :最后关键环节是编码实践。我们已经提到过热点和热路径,但编码实践还包括使用最合适的数据结构 。
在开始优化应用程序性能之前,我们需要对其进行检测,识别出其中的热点和热路径 。
.NET 应用程序的检测
现有多种工具可帮助检测.NET 应用程序。这些工具的主要区别在于它们的作用范围 。
然而,仪器化的一大关键优势在于能够检测内存泄漏并识别缓慢的代码路径 。
仪器化既可在开发阶段实现,也能在应用程序生产环境运行时持续进行。
开发时性能分析 | Visual Studio 诊断工具、BenchmarkDotNet、dotTrace、dotMemory 以及 PerfView 非常适用于分析 CPU、内存泄漏与分配情况,以及应用程序性能 。 |
生产环境监控 | Azure Application Insights、AppDynamics 和 New Relic 可帮助实时监控和诊断生产环境中的性能问题 |
表12.1 - 部分常用检测工具
您可能注意到了 "性能分析" 和 "监控" 这两个术语。 它们之间的主要区别在于:
- 性能分析提供了应用程序性能的详细视图,通常聚焦于特定代码段或方法。这包括每个功能或方法的 CPU 使用率、内存分配、执行时间以及方法调用频率和持续时间。
- 监控通常在生产环境中进行,提供应用程序健康状况的概览,关注长期的整体性能趋势和运行数据,而非单个代码路径。这包括整个应用程序的 CPU 和内存使用率、错误率(异常、故障)、响应时间和吞吐量(例如请求耗时、每秒请求数),以及应用程序的资源使用情况(磁盘 I/O、网络使用率等)。
由于 CLI 应用程序运行在用户计算机上,对其进行监控可能较为困难。它需要用户授权才能收集必要数据,通常需要频繁采集。因此我们可能面临用户拒绝共享遥测数据的情况,导致监控无法实现。
了解帮助我们检测应用程序性能的工具固然重要,但同样关键的是要明白在何处使用这些工具,换句话说,如何识别那些可能适合进行性能优化的代码区域。在这方面,能够识别热点和热路径至关重要。
热点与热路径
这并非本章首次提及热点和热路径概念,但我尚未详细解释它们。现在让我们立即解决这个问题!
热点是指代码中活动密集的区域,通常指那些频繁执行且消耗大量运行时间的方法。因此,热点代表着可以优化以提升应用程序整体性能的潜在目标。
热路径是指代码中频繁执行的路径,因此对应用程序的运行时间有显著影响。热路径有助于定位资源使用低效的问题,例如内存使用与分配。
这里可能出现的问题是 “我们可以遵循什么流程来识别应用程序的热点及热路径?”
识别应用程序的热点与热路径
幸运的是,识别应用程序的热点和热路径不必盲目进行。相反,我们可以遵循一个由三个步骤组成的结构化流程:性能分析、问题分析和优化改进。如果实施了监控,它将作为该流程的输入,因为这个流程应定期执行以确保应用程序的最佳性能。
该流程在下表中描述:
步骤 | 执行内容 |
|
|
|
|
|
|
表12.2 – 识别热点和热路径
我们提到 BenchmarkDotNet 可以帮助我们分析应用程序性能。接下来就该学习如何使用它了。
使用 BenchmarkDotNet 对 Bookmarkr 进行性能分析
尽管 BenchmarkDotNet 被视为基准测试库(即用于比较不同实现方案与基准的差异以确定最优性能方案),但通过策略性使用,它也能识别我们代码中的热点路径和关键路径。
让我们看看如何利用这个库来分析 CLI 应用程序的性能。
首先需要引用 BenchmarkDotNet 库,可通过执行以下命令实现:
dotnet add package BenchmarkDotNet
下一步是配置基准测试的收集与报告。为此,我们需要在 Main 方法的最开始处添加以下代码块:
if(args.Length > 0 && args[0].ToLower() == "benchmark")
{
BenchmarkRunner.Run<Benchmarks>();
return 0;
}
这样当我们执行应用程序并以 benchmark 作为参数传递时 ,就能运行基准测试。
这段代码的作用是通过 BenchmarkRunner 类让 BenchmarkDotNet 运行 Benchmarks 类中发现的所有基准测试。
现在让我们创建这个 Benchmarks 类!
按照我们在前几章定义的文件夹结构规范,我们将在项目中创建一个 Benchmarks 文件夹,并在其中创建 Benchmarks.cs 文件。
我们可以将所有基准测试集中在一个类中,也可以为每个需要测试的命令或服务创建单独的基准测试类。本章我们将采用第一种方式,因为我们只需要对 export 命令进行基准测试。
让我们添加第一个基准测试方法,其代码如下所示:
public async Task ExportBookmarks()
{
var exportCmd = new ExportCommand(_service!, "export", "Exports
all bookmarks to a file");
var exportArgs = new string[] { "--file", "bookmarksbench.json" };
await exportCmd.InvokeAsync(exportArgs);
}
该方法创建了一个 ExportCommand 类的实例,并通过调用其 InvokeAsync 方法并传入命令所需的参数来执行该命令。
目前,该方法尚未被 BenchmarkRunner 类视为基准测试方法。原因是,要使一个方法被认定为基准测试,它需要用 [Benchmark] 特性进行装饰。让我们解决这个问题!
[Benchmark]
public async Task ExportBookmarks()
{
var exportCmd = new ExportCommand(_service!, "export", "Exports
all bookmarks to a file");
var exportArgs = new string[] { "--file", "bookmarksbench.json" };
await exportCmd.InvokeAsync(exportArgs);
}
太棒了!但我们还没准备好运行它...
发现缺少什么了吗?
答对了!ExportCommand 类需要接收一个 IBookmarkService 类型的实例作为参数,但我们至今尚未提供这样的对象实例。
由于我们已经在 Program 类中定义了这样的实例,您可能会认为我们可以通过构造函数将其传递给 Benchmarks 类,这确实是一个完全合理的假设。然而,BenchmarkRunner 类不允许我们这样做(至少在当前版本的 BenchmarkDotNet 中)。
我们将改为直接在 Benchmarks 类中实例化此对象。代码将如下所示:
#region Properties
private IBookmarkService? _service;
#endregion
#region GlobalSetup
[GlobalSetup]
public void BenchmarksGlobalSetup()
{
_service = new BookmarkService();
}
#endregion
请注意,服务的实例化并非在类构造函数中完成,而是在一个用 [GlobalSetup] 特性修饰的方法中进行。这个特殊特性会指示 BenchmarkDotNet 在执行每个基准测试方法前调用此方法一次。这样做是为了让每个基准测试方法都能获得一个全新的服务实例,从而避免受到先前基准测试的副作用影响。
全局设置与类构造函数
在计算基准方法执行时间时,[GlobalSetup] 方法的执行时间未被计入,这与构造函数的执行时间处理方式不同。虽然这看似微不足道,但当方法需要执行大量次数时,这种差异就不可忽视了。
我们现在准备执行基准测试 。
为此,我们首先需要构建应用程序,但这次需要以 Release 模式构建。否则,BenchmarkDotNet 会报错。原因在于 Debug 模式下运行程序并非最优选择,与生产环境应采用的 Release 模式相比存在显著的性能损耗。因此在对应用程序进行基准测试时,应在最优的性能模式下进行。
调试模式与发布模式对比
使用 Debug 模式构建代码会生成未优化的完整符号调试信息,便于设置断点和调试。而 Release 模式则生成优化后的代码,以获得更好性能和更小文件体积。Release 版本通常会省略调试符号、内联方法,并应用各种优化措施,这些可能增加调试难度但能提升执行速度。虽然 Debug 版本适合开发和故障排查,Release 版本则用于生产环境部署 。
要使用 Release 模式构建应用程序,可输入以下命令:
dotnet build -c Release
接着我们通过输入以下命令来运行基准测试:
dotnet C:\code\Chap12\bookmarkr\bin\Release\net8.0\bookmarkr.dll benchmark
C:\code\Chap12\bookmarkr\bin\Release\net8.0 是 Bookmarkr 应用程序生成的 DLL 文件所在路径。
结果为如下:
图12.1 - 导出命令基准测试
基准测试方法已运行 98 次,平均执行 export 命令耗时 6.356 毫秒,这个表现相当不错, 不是吗?
您可以在屏幕中央看到表格。该表格汇总了每个基准测试方法的指标。下面我们来解释各列的含义:
- 平均值 :这表示基准测试方法在所有执行次数(在我们的示例中为 98 次)中的平均耗时。
- 误差值 :简而言之,该值表示平均值测量的精确度。误差越小,平均值的测量就越精确。例如,我们的平均值为 6.356 毫秒,误差为 0.7840 毫秒,所有测量结果都落在 6.356 毫秒±0.7840 毫秒的范围内,即在 5.572 毫秒到 7.140 毫秒之间。
- 标准差 :该值表示所有测量结果的标准差。它量化了执行时间的变异或离散程度。换句话说, 标准差值越低,表明执行时间越紧密地聚集在平均值周围。
基准测试不仅适用于命令!
虽然我们在这里对一个命令进行基准测试,但重要的是要注意,基准测试不仅适用于命令,还适用于所有可能影响应用程序性能的代码组件,包括服务。因此,通过对命令及其使用的服务进行基准测试,我们可以确定执行时间和内存消耗中归因于服务和命令的百分比。
太棒了!然而,我们在这里还没有看到一个测量指标,那就是内存消耗的测量。让我们解决这个问题!
要收集关于内存消耗的数据,我们只需要在Benchmarks类上面添加[MemoryDiagnoser]标签,如下所示:
[MemoryDiagnoser]
public class Benchmarks
{
// …
}
现在,如果我们以完全相同的方式运行代码,将得到以下结果:
图12.2 - 内存消耗基准测试
注意现在多了一个名为 Allocated 的新列,它表示每次执行基准测试方法时分配的内存量(单位为千字节)。这个列值得关注有两个原因:
- 它让我们能判断基准测试方法是否使用了远超预期的内存量(或比预期多得多)。这可能表明代码中存在需要深入调查的内存泄漏问题。
- 在优化代码时,我们可以观察新实现是否会影响内存消耗。例如,我们可能提出一种以显著内存占用为代价来加快执行速度的实现方案。
执行时间与内存消耗的优化
您可能想知道我们应该专注于优化内存消耗还是执行时间。这个决策取决于我们最重视哪方面——是内存消耗还是执行时间。有趣的是,在某些情况下,我们甚至可以同时优化两者!为此,我们需要创造性地实现一个方案,通过利用所用框架和库的高级功能,结合先进且富有创意的算法来同时解决这两个问题。
虽然 BenchmarkDotNet 能帮助我们在开发阶段识别优化机会,但实施监控同样重要,这样我们就能在应用生产环境运行时持续检查其性能表现。
使用 Azure Application Insights 监控 BookmarkrSyncr
我们之前提到过,CLI 应用程序运行在用户本地计算机上,用户可能拒绝我们收集对监控至关重要的遥测数据。因此我们不会在 Bookmarkr 中实现监控功能,而是在 BookmarkrSyncr——这个由 Bookmarkr 调用的外部网络服务中实现。由于该网络服务由我们托管和管理,我们可以实施监控并确保遥测数据能够被收集,从而保证监控得以进行 。
由于该网络服务部署在 Microsoft Azure 云平台上,我们将依赖 Azure 原生的应用程序性能监控 (APM)解决方案——Azure Application Insights,该服务由 Microsoft Azure 云平台提供。
当我们把 BookmarkrSyncr 部署到 Microsoft Azure 时,创建了托管它的基础设施。具体而言,我们创建了一个 Azure 应用服务实例。在创建该服务的过程中,系统提供了创建 Azure Application Insights 服务实例的选项。这项监控解决方案由微软为我们提供和管理。
Azure Application Insights 是一项出色的服务,它使我们能够监控性能、可用性、失败的请求、异常、页面视图、跟踪、浏览器计时、使用情况(包括用户流 ,这有助于我们识别应用程序中的热点路径),甚至还能访问实时指标以便进行实时监控。Azure Application Insights 的另一大亮点是能够配置警报,当某项指标达到特定阈值时触发,例如,如果服务器响应时间(即从接收 HTTP 请求到向客户端发送响应之间的时长)超过了我们组织标准规定的最大允许值。当警报触发时,我们可以启动自动化处理或发送通知(比如向特定人群发送电子邮件)。
要了解使用 Azure Application Insights 进行监控的实际效果,请参阅(这篇 Microsoft Learn 上的文章,链接为 Application Insights Overview dashboard - Azure Monitor | Microsoft Learn.
好的。既然我们已经知道如何识别应用中需要性能调优的部分(通过性能分析和监控),接下来让我们讨论一些最常用的技术来提升应用程序的性能。
常见性能优化技术
值得一提的是,我们将要讨论的这些技术不仅适用于 CLI 应用程序,实际上可以应用于任何类型的应用程序。让我们根据前面提出的分类来分解这些技术。对于每个类别,我将为您列出常用的技术列表 。
应用程序设计与架构:
- 建立实现目标的最短路径,移除所有不必要的中间环节。
- 这可以通过使用高效算法来实现。
- 在解耦与低延迟之间找到最佳平衡点。
- 对非立即需要的资源采用懒加载方式。
- 实施高效的错误处理与日志记录机制。
- 从一开始就设计可扩展性
基础设施:
- 在打包和分发应用程序时,请使用 Release 模式进行编译。虽然 Debug 模式在开发阶段非常有用,但它可能会带来显著的性能开销。
- 此外,在打包和分发应用程序时,如果目标平台已提前确定或打包分发机制不具备跨平台特性,则应编译为平台特定版本。例如,将我们的应用程序打包为 Winget 格式意味着它只能在 Windows 平台上运行。同理,apt-get 包(应用程序将仅在 Linux 系统运行)和 Homebrew 包(应用程序将仅在 macOS 系统运行)也是如此。因此很容易判断应该采用哪种平台特定编译方式,这样.NET 会应用所有可能的优化措施——若目标平台未提前确定则无法实现这类优化(例如文件处理在 Windows、Linux 和 macOS 系统中就存在差异)。最终生成的应用程序版本将在目标平台上以最高效的方式运行。
- 您也可以选择使用 AOT( 预先编译 )将代码预编译为原生代码(而非依赖 JIT),以获得更快的启动速度或减少对运行时编译的依赖。如果您针对的是移动端(iOS/Android)或 WebAssembly 等环境,而 JIT 可能无法适用,这种方法将特别有用。请注意,平台定向和 AOT 可以结合使用,以实现更佳的性能优化 。
框架与类库:
- 除非绝对必要,避免使用依赖反射机制的类库。
- 选择符合特定需求的轻量级框架和类库。警惕那些引用时会连带引入数十个其他类库的依赖项。
- 保持依赖项更新以获取性能改进优势。
- 对于较小的专项任务 ,可考虑使用微框架。
编码实践:
- 尽可能依赖异步操作。这能避免阻塞主线程,提升应用程序的响应感。
- 根据目标选择最优化的数据类型或数据结构。这将确保对计算机资源的占用最小化。
- 尽可能减少内存分配来完成操作。例如,在撰写本文时,.NET 9 已发布,通过调用 AsSpan().Split(…) 实现了无需内存分配的拆分操作.
- 实现缓存机制以避免对外部依赖项(如 Web 服务或数据库) 的不必要调用
- 优化数据库查询并建立适当的索引
- 关于数据库,如果使用 ORM( 对象关系映射器 )如 Entity Framework Core,可以调用 AsNoTracking() 来显著提升查询性能并降低内存占用,特别是在处理大型数据集或只读操作时。该方法会告知 ORM 不对检索到的实体进行变更追踪,绕过变更跟踪机制,从而实现更快的查询速度和更低的内存开销。
- 使用连接池技术,即复用已建立的数据库连接而非为每个请求创建新连接。这是因为建立数据库连接可能消耗较大资源,因此连接池能降低连接延迟并提升服务器端的数据库吞吐量(每秒事务处理数)。
- 实施正确的内存管理并释放未使用的资源 。
我们已了解多种常用于优化各类应用程序性能的技术,这些应用采用不同技术栈构建,包括使用 .NET 开发的命令行应用程序。
现在让我们运用其中一些技术来提升 Bookmarkr 的性能 。
优化 Bookmarkr 的性能
我们无法优化已经完美的事物, 不是吗?
开个玩笑。我们当然可以! 改进的空间总是存在的。
让我们来看看一些可以快速提升我们钟爱的 CLI 应用程序性能的优化方法。
观察 ExportCommand 类的处理程序方法(即 OnExportCommand),可以看到它已经采用了异步操作。这是个很好的开端,实际上这正是我们之前提到的技术之一。
然而,这个处理方法还可以进一步优化。为了说明这一点,我们先复制 ExportCommand 类并将其命名为 ExportCommandOptimized。我们将原封不动地复制 ExportCommand 中的代码, 稍后再对其进行优化。
我们创建原始类的副本而非直接优化它的原因,是为了能够为优化版本添加基准测试方法,并将其与原始版本进行比较。
在 ExportCommandOptimized 类的处理方法中,我们修改以下两行代码:
string json = JsonSerializer.Serialize(bookmarks, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(outputfile.FullName, json, token);
将其替换为以下两行:
using var fileStream = new FileStream(outputfile.FullName, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true);
await JsonSerializer.SerializeAsync(fileStream, bookmarks, new JsonSerializerOptions { WriteIndented = true }, token);
让我们看看做了哪些改动:
- 使用 JsonSerializer.SerializeAsync 处理大型数据集更高效,它能直接将 JSON 流式写入文件,而无需将整个序列化字符串保留在内存中
- 使用 FileStream 进行异步操作能更好地控制文件 I/O 操作,尤其对于大文件可提升性能
好的。我们将这个新实现与原版进行比较
为此,我们向 Benchmarks 类添加以下基准测试方法:
[Benchmark]
public async Task ExportBookmarksOptimized()
{
var exportCmd = new ExportCommandOptimized(_service!, "export",
"Exports all bookmarks to a file");
var exportArgs = new string[] { "--file", "bookmarksbench.json" };
await exportCmd.InvokeAsync(exportArgs);
}
这个基准测试方法与之前的方法相同。嗯,几乎相同...唯一的区别在于我们实例化(并调用)的是 ExportCommandOptimized 类,而非 ExportCommand 类。
由于我们需要将新的优化实现与原始版本进行对比,我们将修改原始方法的 [Benchmark] 属性,使其呈现如下形式。
这指示 BenchmarkDotNet 将该方法作为性能对比的基准:
[Benchmark(Baseline = true)]
让我们重新构建应用程序(当然是在 Release 模式下),并执行基准测试 。
结果为以下内容:
图12.3 – 新实现方案与原方案的基准测试对比
请注意出现了两个新列:
- 比率 :这表示相对于基准测试方法的平均性能指标
- 比率标准差 :这表示相对于基准测试方法标准差的平均标准差
比率列中的 0.91 值表明优化实现(ExportCommandOptimized)平均比基准实现(ExportCommand)快 9%。我们之前提到过,在 ExportCommandOptimized 中实现的版本在处理大文件时性能尤其突出。因此可以预期,随着输出文件体积增大 ,其速度将比基准实现更快。
太棒了!我们现在已经知道如何提升我们心爱的 CLI 应用程序的性能,并且让我们的用户感到满意 。
摘要
在本章中,我们探讨了性能优化的多个方面,学习了识别性能热点和关键路径的技术,并了解了如何提升它们的性能,最终目标是为用户提供一个高效出色、让他们爱不释手的应用程序。
希望您已经理解,提升性能并非依靠单一领域或行动,而是通过一系列细微调整共同发挥作用 。
太棒了!现在我们拥有了一个能高效提供强大功能的应用程序。
不过在构建 CLI 应用程序(实际上任何类型的应用程序)时,还有一个关键领域我们尚未涉及。这个关键领域就是安全性 ,这也将是下一章的主题。
轮到你了!
配合提供的代码进行实践是通过实操学习的最佳方式。
更好的方法是通过挑战自己完成任务。因此,我向你发起挑战,要求你通过添加以下功能来改进 Bookmarkr 应用程序。
任务 #1 – 编写更多基准测试
在本章中,我们仅通过为 export 命令编写基准测试来示范基准测试方法的编写。但正如前文所述,基准测试不仅适用于命令,也适用于服务 。
因此,您的任务是为每个命令以及 Bookmarkr 应用所使用的各项服务编写额外的基准测试方法。
任务 #2 – 微调 Bookmarkr 以实现最佳性能
在本章中,我们并未实现所有性能优化的机会,可能还遗漏了一些(这是故意的吗?*眨眼暗示*)。因此,你的任务是找出 Bookmarkr 中其他潜在的性能优化点并加以实现 。