https://michaelscodingspot.com/cpu-bound-memory-bound/
优化.NET 应用程序 CPU 和内存的11 个实践
凡事都有其限度,对吧?汽车只能开这么快,进程只能使用这么多内存,程序员只能喝这么多咖啡。我们的生产力受到资源的限制,我们有能力更好或更差地利用它们。尽可能接近其极限使用我们的每一种资源是我们的目标,我们希望使用我们的 CPU 和内存的每一点,否则我们会为昂贵的机器多付钱。
然而,若是我们使用了过多的资源,我们就有可能导致性能问题、服务不可用问题和程序宕机底崩溃问题。软件开发看似简单,但一旦遇到性能问题,就会变得非常棘手,这就是我们今天要讨论的内容。
定义最佳基准
让我们尝试描述我们的最佳应用程序行为。假设我们有许多服务器机器需要处理高吞吐量的请求。为简单起见,让我们暂时忘记高峰时间或周末。我们的服务器负载在一天中的所有时间都或多或少相同。我们为这些服务器机器支付了很多钱,我们希望从它们那里获得尽可能多的价值,这意味着处理尽可能多的请求。按照我们对简单性的承诺,我们还假设服务器仅使用内存和 CPU 来处理所述请求,并且没有其他瓶颈,例如慢速网络或锁争用。
在所描述的场景中,我们的最佳行为是在任何给定时间使用尽可能多的 CPU 和内存,对吗?这样,我们可以用更少的机器来处理相同数量的请求。但是您可能不想利用这些资源中的 99.9%,因为负载的轻微增加可能会导致性能问题、服务器崩溃、数据丢失和其他令人头疼的问题。所以我们应该选择一个有足够缓冲问题的数值。平均 85% 或 90% 的 CPU 和内存利用率听起来是正确的。
我们应该首先优化什么?
我们的应用程序不是为平等利用 CPU 和内存而构建的。或者到它托管的机器的确切限制。因此,您首先应该查看的是您的服务器是CPU-bound还是Memory-bound。当服务器受 CPU 限制时,这意味着服务器可以处理的吞吐量受到其 CPU 的限制。换句话说,如果您尝试处理更多请求,CPU 将在其他资源(如内存)达到其限制之前达到 100%。同样的逻辑也适用于Memory-bound服务器。服务器的吞吐量将受到它可以分配的内存的限制,当尝试处理更多负载时,在其他资源(如 CPU)达到其限制之前,该内存将达到 100%。
还有其他资源可以限制服务器,例如I/O,在这种情况下,吞吐量会受到磁盘或网络的读取或写入限制。但是我们将在这篇文章中忽略这一点,乐观地假设我们的 I/O 是快速且无限的。
一旦你知道是什么限制了你的服务器的性能,你就会知道首先要尝试和优化什么。如果您的服务器受 CPU 限制,那么优化内存使用没有意义,因为它不会提高处理的吞吐量。事实上,它可能会损害吞吐量,因为您可能会因为更多的 CPU 利用率而提高内存使用率。对于内存受限的服务器也是如此,在这种情况下,您应该在查看 CPU 之前优化内存使用。
测量 .NET 服务器中的 CPU 和内存消耗
CPU 和内存的实际测量最简单的是使用Performance Counters[1]完成。CPU 使用率的指标是Process | % 处理器时间。内存有几个指标,但我建议查看Process | 私有字节。您可能还对.NET CLR 内存感兴趣 | # 代表托管内存的所有堆中的字节(CLR 占用的部分,而不是所有内存,即托管 + 本机内存)。
要查看性能计数器,您可以在 Windows 计算机上使用Process Explorer[2]或 PerfMon,或者在 .NET Core 服务器上使用dotnet-counters 。[3]如果您的应用程序部署在云中,您可以使用像Application Insights[4](Azure Monitor[5]的一部分)这样的 APM 工具来显示这些信息。或者,您可以在代码中获取性能计数器值并每 10 秒左右记录一次,使用Azure 数据资源管理器[6]之类的工具在图表中显示数据。
提示:检查机器级指标和进程级指标。您可能会发现其他进程正在限制您的性能。
一旦确定了哪些资源限制了您的 .NET 服务器,就该优化该资源消耗了。如果您受 CPU 限制,让我们减少 CPU 使用率。如果您受内存限制,让我们减少内存使用量。
至少如果您在云中运行,一种简单的方法是更改机器规格。如果您受内存限制,请增加内存。如果您受 CPU 限制,请增加内核数量或获得更快的 CPU。这将提高成本,但在此之前,您可以检查一些容易实现的目标,以优化 CPU 或内存消耗。在更改机器规格之前尝试进行这些优化,因为优化后一切都会改变。您可能会优化 CPU 使用率并变得受内存限制。然后优化内存使用并再次成为 CPU 密集型。因此,如果您想避免不得不不断更改机器资源以适应最新的优化,最好把它留到最后。
所以让我们谈谈一些内存优化。
优化内存使用
有很多方法可以优化 .NET 中的内存使用。深入讨论它们需要一整本书,而且已经有好几本了。但我会尽量给你一些方向和想法。
1. 了解什么占用了你的内存
尝试优化内存时,您应该做的第一件事是了解全局。什么占用了大部分内存?有哪些数据类型?它们分配在哪里?它们会在记忆中停留多久?
有几种工具可以获取此信息:
•捕获转储文件[7]并使用内存分析器[8]或WinDbg[9]打开它。•使用新的GC 转储[10](.NET Core 3.1+) 并使用 Visual Studio 进行调查。•捕获堆快照并使用内存分析器[11]、PerfView[12]或Visual Studio 诊断工具[13]对其进行探索。
此分析将显示哪些对象占用了您的大部分内存。如果你发现它被采取了MyProgram.CustomerData
那就更好了。但通常,最大的对象类型是string
、byte[]
或byte[][]
。由于应用程序中的几乎所有内容都可以使用这些类型,因此您需要找到引用它们的人。为此,查看所占用的包容性内存(又名保留内存)很重要。这个指标不仅包括对象本身占用的内存,还包括它引用的对象占用的内存。例如,您可能会发现它MyProgram.Inventory.Item
本身并不占用太多内存,但它引用了一个byte[]
它保存内存中的图像并占用高达 70% 的内存。上面描述的所有工具都可以显示包含最多字节的对象和到 GC 根的引用路径(也就是到根的最短路径[14])。
2. 了解谁把内存放在了哪里
找出谁引用了最大的内存块很棒,但这可能还不够。有时您需要知道这些内存是如何分配的。您可能从引用路径中知道,一些占用大部分内存的对象位于缓存中,但谁将它们放在那里?来自单个时间点的内存快照无法提供该答案。为此,您需要分配堆栈跟踪。分析器使您能够记录您的应用程序并在每次分配时保存调用堆栈。例如,您可能会发现创建有问题MyProgram.Inventory.Item
对象的流程将它们分配到调用堆栈App.OnShowHistoryClicked | App.SeeItemHistory | App.GetItemFromDatabase
中。
要获得分配堆栈,您可以:
•使用商业内存分析器来显示分配[15]。
•使用 PerfView 的 GC Heap [] Stacks 之一
分配让您全面了解占用大部分内存的内容以及它是如何产生的。一旦你知道了这一点,你就可以开始切割最大的块并优化它们以减少内存使用。
3.检查内存泄漏
在 .NET 中导致内存泄漏非常容易。有了足够多的泄漏,内存消耗会随着时间的推移而增加,你会遇到各种各样的问题。内存瓶颈就是其中之一,但由于 GC 压力,您最终也会遇到 CPU 问题。
当您不再需要对象但由于某种原因它们仍然被引用并且垃圾收集器永远不会释放它们时,就会发生内存泄漏。发生这种情况的原因[16]有很多。
要了解您是否有严重的内存泄漏,请查看一段时间内的内存消耗图表(进程 | 私有字节计数器)。如果内存一直在增加,而没有偏离某个水平,则可能存在内存泄漏。
使用内存分析器调试泄漏[17]相当简单。
4. 切换到 GC 工作站模式
.NET 中有几种垃圾收集器模式。主要的两种模式是Workstation GC和Server GC。Workstation GC 针对更短的 GC 暂停和更快的交互性进行了优化,非常适合桌面应用程序。服务器 GC 具有更长的 GC 暂停时间,并且针对更高的吞吐量进行了优化。在 Server GC 模式下,应用程序可以在垃圾回收之间处理更多数据。
服务器 GC 为每个 CPU 核心创建不同的托管堆。这意味着不同的 X 代内存空间需要更长的时间才能填满,因此内存消耗会更高。您基本上是在用内存换取吞吐量。从 GC 服务器模式(.NET 服务器的默认模式)更改为 GC 工作站模式将减少内存使用量。这在请求负载不重的小型应用程序中可能是合理的。也许在与主应用程序一起运行的 IIS 主机中的辅助进程中。
Sergey Tepliakov[18]对此有一篇很棒的文章。
5.检查你的缓存
在第 1 步之后,您应该能够看到哪些对象占用了您的内存,但我想特别强调缓存。每当涉及到高内存消耗时,根据我的经验,它总是最终成为内存泄漏或缓存。
缓存似乎是许多问题的神奇解决方案。当您可以将结果保存在内存中并重新使用它时,为什么要执行两次?但是缓存是有代价的。一个简单的实现会将对象永远保存在内存中。您应该按时间限制或以其他方式使缓存无效。缓存还会将临时对象留在内存中相对较长的时间,这会导致更多的 Gen 1 和 Gen 2 收集,进而导致GC 压力[19]。
以下是一些优化内存缓存的想法:
•使用.NET 中的现有缓存实现[20]可以轻松创建失效策略。•考虑为某些事情选择不缓存。您可能会用 CPU 或 IO 换取内存,但是当您受到内存限制时,您应该这样做。•考虑使用内存不足缓存。这可能是将数据保存在文件或本地数据库中。或者使用像Redis[21]这样的分布式缓存解决方案。
6.定期调用GC.Collect()
这条建议是违反直觉的,因为最好的做法是永远不要调用GC.Collect()
. 垃圾收集器很聪明,它应该自己知道何时触发收集。但问题是垃圾收集器只考虑自己的进程。如果它没有足够的内存,它会小心触发收集并腾出空间。但如果它确实有足够的内存,GC 会非常乐意忍受过多的内存消耗。因此,GC 的自私本性可能是生活在同一台机器上的其他进程的问题,可能托管在同一个 IIS 上。这种多余的内存可能会导致其他进程更快地达到它们的极限,或者导致它们各自的垃圾收集器更加努力地工作,因为它们可能错误地认为它们即将耗尽内存。
您可能会认为,如果其他进程的 GC 会达到认为我们内存不足并因此更加努力地工作的程度,那么我们自己的进程也会这样认为并触发垃圾收集来解决问题。但我们不能做出这样的假设。一方面,这些进程可能运行不同的 GC 实现版本(因为不同的 CLR 版本)。此外,您有不同的应用程序行为可以使 GC 以不同的方式工作。例如,一个进程可能会以更高的速率分配内存,因此 GC 将更快地开始“强调”可用内存。底线是软件很困难,当你在一台机器上有多个进程时,就像 IIS 一样,你需要考虑到这一点,并可能采取一些不寻常的步骤。
优化 CPU 使用率
硬币的另一面是 CPU 使用率。一旦您发现 CPU 是应用程序吞吐量的瓶颈,就需要做很多事情。
1. 分析您的应用程序
优化 CPU 的第一步是了解它。究竟是什么原因造成的?哪些方法负责?哪些请求是最大的 CPU 消耗者,哪些是流量?这一切都可以通过分析应用程序来解决。
分析允许您记录执行范围并显示所有被调用的方法以及它们在记录期间使用了多少 CPU。分析器通常允许将这些结果视为普通列表、调用树甚至火焰图。
这是 PerfView 中的简单列表视图:
这是相同场景的火焰图:
您可以通过以下方式分析您的应用:
•如果场景在本地重现,请使用性能分析器,如PerfView[22]、dotTrace[23]、ANTS perf profiler[24],或在您的开发计算机上使用 Visual Studio 。[25]•在生产环境中,最简单的分析方法是使用应用程序性能监控 (APM) 工具,例如Azure Application Insights profiler[26]或RayGun[27]。•您可以通过将代理复制到生产机器并记录快照来分析没有 APM 的生产环境。使用 PerfView,您应该复制整个程序。它结构紧凑,无需安装。使用 dotTrace,您可以复制允许在生产中记录快照的轻量级代理。[28]•在 .NET Core 3.0+ 应用程序中,您可以安装 .NET Core 3.0 SDK 并使用 dotnet-trace 命令行工具记录快照[29],然后使用 PerfView 将其复制到开发机器并进行分析。
2.检查垃圾收集器的使用情况
我想说优化 .NET CPU 使用最重要的一点是正确的内存管理。在这方面要问的重要问题是:“垃圾收集浪费了多少 CPU?”。GC 的工作方式是在收集期间,您的执行线程被冻结。这意味着垃圾收集直接影响性能。因此,如果您受 CPU 限制,我建议您检查的第一件事是性能计数器[30]。NET CLR 内存 | % GC 时间。
我不能给你一个指示问题的神奇数字,但根据经验,当这个值超过 20% 时,你可能会遇到问题。如果超过 40%,那么你肯定有问题。如此高的百分比表明 GC 压力,并且有办法处理它[31]。
3.使用数组和对象池来重用内存
阵列的分配和不可避免的解除分配可能非常昂贵。高频率执行这些分配会造成 GC 压力并消耗大量 CPU 时间。解决这个问题的一个好方法是使用内置的ArrayPool
ObjectPool ([32]仅限 .NET Core)。这个想法很简单。为数组或对象分配一个共享缓冲区,然后在不分配和取消分配新内存的情况下重复使用。这是一个简单的使用示例ArrayPool
:
public void Foo()
{var pool = ArrayPool<int>.Shared;int[] array = pool.Rent(ArraySize);// do stufpool.Return(array);
}
4. 切换到 GC 服务器模式
我们已经讨论过转移到GC 工作站模式[33]以节省内存。但如果您受 CPU 限制,请考虑切换到服务器模式以节省 CPU。权衡是服务器模式以更多内存为代价允许更高的吞吐量。因此,如果您保持相同的吞吐量,您最终将节省 CPU 时间,否则垃圾收集会花费这些时间。
默认情况下,.NET 服务器很可能具有 GC 服务器模式,因此可能不需要此更改。但是可能有人之前将其更改为工作站模式,在这种情况下,您应该小心将其更改回来,因为他们可能有充分的理由。
更改时,请务必监控内存消耗和 GC 中的 % Time。您可能想查看第 2 代回收率,但如果这个数字很高,它将反映在更高的 GC 时间百分比中。
5.检查其他进程
当试图将您的服务器发挥到最佳极限时,您可能想要彻底了解它,这意味着不要放弃存在于您的进程之外的问题。很有可能其他进程不时消耗一堆CPU,并导致一段时间的性能下降。这些可能是您在 IIS 上部署的其他应用程序、定期 Web 作业、由操作系统触发的东西、防病毒程序或其他一千种东西。
对此进行分析的一种方法是使用 PerfView 记录整个系统中的 ETW 事件。PerfView 从
所有
进程中捕获 CPU 堆栈。您可以以很小的性能开销运行它很长时间。您可以在达到某个 CPU 峰值时自动停止收集[34]并进行挖掘。您可能会对结果感到惊讶。
总结
在我看来,从自上而下的层面处理大规模的性能问题是令人着迷的。您可能有一个团队花费数月时间优化一段代码,相比之下,资源分配的简单更改将产生更大的影响。而且,如果您的业务足够大,那么这个微小的变化就会转化为一大笔钱。你记得在你的合同中要求一个佣金条款吗?无论如何,我希望这篇文章对你有用,如果你发现了,你可能会对我的书Practical Debugging for .NET 开发人员[35]感兴趣,我在其中深入讨论了性能和内存问题的故障排除。
References
[1]
Performance Counters: https://michaelscodingspot.com/performance-counters/[2]
Process Explorer: https://docs.microsoft.com/en-us/sysinternals/downloads/process-explorer[3]
dotnet-counters 。: https://docs.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-counters[4]
Application Insights: https://docs.microsoft.com/en-us/azure/cloud-services/diagnostics-performance-counters#application-insights[5]
Azure Monitor: https://azure.microsoft.com/en-us/services/monitor/#overview[6]
Azure 数据资源管理器: https://azure.microsoft.com/en-us/services/data-explorer/#getting-started[7]
捕获转储文件: https://michaelscodingspot.com/how-to-create-use-and-debug-net-application-crash-dumps-in-2019/[8]
内存分析器: https://memprofiler.com/online-docs/manual/importmemorydumpfiles.html[9]
WinDbg: https://michaelscodingspot.com/how-to-create-use-and-debug-net-application-crash-dumps-in-2019/#Investigate-Dumps-with-WinDbg[10]
GC 转储: https://devblogs.microsoft.com/dotnet/collecting-and-analyzing-memory-dumps/[11]
内存分析器: https://michaelscodingspot.com/memory-profilers-principles#snapshots[12]
PerfView: https://bennettadelson.wordpress.com/2013/04/11/using-perfview-to-diagnose-a-net-memory-leak-2/[13]
Visual Studio 诊断工具: https://docs.microsoft.com/en-us/visualstudio/profiling/memory-usage?view=vs-2022[14]
到根的最短路径: https://www.jetbrains.com/help/dotmemory/Shortest_Paths_to_Roots.html[15]
内存分析器来显示分配: https://www.jetbrains.com/help/dotmemory/Analyze_Memory_Allocation.html#types[16]
发生这种情况的原因: https://michaelscodingspot.com/ways-to-cause-memory-leaks-in-dotnet/[17]
使用内存分析器调试泄漏: https://michaelscodingspot.com/find-fix-and-avoid-memory-leaks-in-c-net-8-best-practices/#profiler[18]
Sergey Tepliakov: https://devblogs.microsoft.com/premier-developer/understanding-different-gc-modes-with-concurrency-visualizer/[19]
GC 压力: https://michaelscodingspot.com/avoid-gc-pressure/[20]
.NET 中的现有缓存实现: https://michaelscodingspot.com/cache-implementations-in-csharp-net/[21]
Redis: https://redis.io/[22]
PerfView: https://github.com/microsoft/perfview[23]
dotTrace: https://www.jetbrains.com/profiler/[24]
ANTS perf profiler: https://www.red-gate.com/products/dotnet-development/ants-performance-profiler/[25]
使用 Visual Studio 。: https://docs.microsoft.com/en-us/visualstudio/profiling/beginners-guide-to-performance-profiling?view=vs-2022[26]
Azure Application Insights profiler: https://docs.microsoft.com/en-us/azure/azure-monitor/app/profiler-overview[27]
RayGun: https://raygun.com/for/dotnet-performance-monitoring[28]
的轻量级代理。: https://blog.jetbrains.com/dotnet/2012/09/10/dottrace-remote-profiling/[29]
使用 dotnet-trace 命令行工具记录快照: https://michaelscodingspot.com/dotnet-trace/[30]
性能计数器: https://michaelscodingspot.com/performance-counters/[31]
办法处理它: https://michaelscodingspot.com/avoid-gc-pressure/[32]
(: https://docs.microsoft.com/en-us/aspnet/core/performance/objectpool?view=aspnetcore-6.0[33]
GC 工作站模式: https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/workstation-server-gc[34]
在达到某个 CPU 峰值时自动停止收集: https://www.drware.com/perfview-command-for-capturing-automated-high-cpu-dumps/[35]
Practical Debugging for .NET 开发人员: https://practicaldebugging.net/