.NET内存性能分析

2020-09-10 07:45:01

知道什么时候该担心它,如果你需要担心该怎么做。

本文档旨在帮助使用.NET开发应用程序的人员如何考虑内存性能分析,并在需要时找到执行此类分析的正确方法。在此上下文中,.NET包括.NET Framework和.NET Core。为了在垃圾收集器和框架的其余部分获得最新的内存改进,我强烈建议您使用.NET Core(如果您还没有使用.NET Core),因为这是进行活动开发的地方。

这是一份活生生的文件。目前,本文档主要关注Windows。为Linux添加相应的材料肯定会让它更有用。我计划在将来这样做,但是非常欢迎其他人(特别是Linux部分)为文档做贡献。

这是一个很长的文档,但您不需要全部阅读;您也不需要按顺序阅读各部分。根据您在性能分析方面的经验,可以完全跳过某些部分。

🔹如果您对性能工作完全陌生,我建议您从头开始。

🔹对于那些已经在做一般性能工作但希望增加管理内存相关主题知识的人,他们可以跳过开始部分,直接转到基础部分。

🔹如果您没有非常丰富的经验并且没有进行一次性分析,您可以跳到“知道何时需要担心”部分,从那里开始阅读,如果需要,还可以参考“基础知识”部分了解特定主题。

🔹如果您是一名性能工程师,其工作包括将托管内存分析作为一项常规任务,但对.NET还不熟悉,我强烈建议您真正阅读并内化GC基础一节,因为它将帮助您更快地专注于正确的事情。但是,如果您手头有紧急问题,可以转到我将在本文档中使用的工具,熟悉一下,然后看看是否可以在暂停问题或堆大小问题部分找到相关症状。

🔹如果您已经有过执行托管内存性能工作的经验,并且有一个特定的问题,您可以在暂停问题或堆大小问题部分找到它。

在撰写本文档时,我打算根据分析解释的需要引入并发GC或钉住等概念。所以当你读它的时候,你会逐渐遇到它们。如果你已经知道它们是什么,并且正在寻找对特定概念的解释,这里是到它们的链接-。

那些在性能分析方面有经验的人知道这可能像是侦探工作-没有“如果您遵循这10个步骤,您将提高性能或根本导致性能问题”。这是为什么?因为您的代码并不是唯一在运行的东西-您甚至可以使用操作系统、运行时、库(至少是BCL,但通常还有很多其他库)来运行您自己的代码。并且运行您的代码的线程需要与同一进程和/或其他进程中的其他线程共享机器/VM/容器。

然而,这并不意味着你需要对我刚才提到的一切都有透彻的了解。否则我们谁也做不成任何事--你根本没有时间。但你不需要这么做。您只需要了解足够的基础知识并获得足够的性能分析技能,这样您就可以专注于您自己的代码的性能。在本文档中,我们将讨论这两个问题。我还将充分解释为什么事情是这样工作的,这样做是有意义的,而不是让您记住容易被页出的内容。

本文档讨论您可以做些什么,以及何时是将分析移交给GC团队的好时机,因为这将是一个需要在运行时进行的改进。显然,我们在GC中仍在努力改进它(否则我不会还在这个团队中)。

我对性能分析的目标是自动化客户需要执行的大多数类型的分析。我们在这方面已经走了很长一段路,但我们还没有到所有这些都是自动化的地步。在这份文档中,我将告诉您当前进行分析的方式,并在文档的末尾向您展望我们为实现这一目标正在进行哪些改进。

我们每个人的资源都是有限的,如何把这些资源花在能产生最大回报的东西上是关键。这意味着您应该找到最值得优化的部分,以及如何以最有效的方式优化它们。当你得出结论你需要优化某事,或者你打算如何优化某事时,你这样做应该有一个合乎逻辑的理由。

当人们第一次来找我时,我总是问他们这个问题--你的绩效目标是什么?不同的产品有非常不同的性能要求。在你计算出一个数字之前(例如,将某事提高X%),你需要知道你在针对什么进行优化。在顶层,这些是需要优化的方面-

优化内存占用的◼️,例如,需要在同一台计算机上运行尽可能多的实例。

◼️优化吞吐量,例如,需要在一定时间内处理尽可能多的请求。

针对尾部延迟进行◼️优化,例如,需要满足特定的延迟服务级别协议。

当然,您可以有多个这样的请求,例如,您可能需要满足SLA,但在一段时间内仍需要处理至少一定数量的请求。在这种情况下,你需要找出什么是优先的,这将决定你应该把最大的精力放在什么上。

GC行为的更改可能是由于GC更改或框架其余部分的更改造成的,当您获得新版本时,框架中通常会有很多更改。当您在升级后看到内存行为发生变化时,可能是由于GC更改或框架中的其他内容开始分配更多内存并以不同方式存活下来。此外,如果您升级操作系统版本或在不同的虚拟化环境中运行您的产品,您还可以获得不同的行为,因为它们可能会导致您的应用程序的行为不同。

能够测量是你在开始一个产品时绝对应该计划做的事情,而不是在坏事情发生时事后考虑,特别是当你知道你的产品将需要在相当有压力的情况下运行的时候,尤其是当你知道你的产品需要在非常有压力的情况下运行的时候。如果您正在阅读这份文档,那么您很有可能正在做一些性能很重要的工作。

对于与我共事过的大多数工程师来说,测量真的不是一个陌生的概念。然而,如何衡量以及衡量什么是我见过的很多人需要帮助的问题。

◼️这意味着你需要有一些方法来真实地衡量你的绩效。使用复杂的服务器应用程序的一个常见问题是,很难在您的测试环境中模拟您在生产中实际看到的东西。

◼️测量并不仅仅意味着我可以测量我的应用程序每秒可以处理多少请求,因为这才是我所关心的,它意味着你还应该准备一些东西,当你的测量结果告诉你某些东西的性能没有达到理想的水平时,你可以进行有意义的性能分析,这意味着你还应该准备好一些东西,让你能够在你的测量结果显示某些东西没有达到理想的水平时,进行有意义的性能分析。能够发现问题是一回事。如果你没有任何东西可以帮助你找出导致这些问题的原因,那是没有多大帮助的。当然,这需要您知道如何收集数据,我们将在下面讨论。

我一遍又一遍地听说,人们会衡量一件事,然后选择只优化那件事,因为他们在某个地方从朋友或同事那里听说了这件事。这就是了解基本原理真正有帮助的地方,所以你不必一直专注于你听到的一件事,这件事可能是正确的,也可能根本不是正确的。

在您知道哪些因素可能对您关心的事情(即您的性能指标)贡献最大之后,您应该衡量它们的影响,以便您可以在开发产品时观察它们的贡献是多还是少。一个很好的例子就是服务器应用程序如何改善它们的P95请求延迟(即第95个百分位的请求延迟)。这几乎是每个Web服务器都会关注的性能指标。当然,任何数量的因素都可能影响此延迟,但您知道哪些因素可能对延迟的影响最大。

这是5个请求的图示,R1受GC影响,R4受网络IO影响。

网络IO只是可能导致请求延迟的另一个因素的一个示例。这里的宽度仅仅是为了说明的目的。

您每天(或您记录P95的任何时间单位)的P95延迟可能会波动,但您知道大概的数字。假设您的平均请求延迟为<;3ms,P95约为70ms。您必须有某种方法来衡量每个请求总共需要多长时间(否则您不会知道您的延迟百分位数)。您可以在看到GC暂停或网络IO(两者都可以通过事件测量)时进行记录。对于占用您的P95延迟的请求,您可以计算P95&34;对GC的影响,这是。

人们通常会猜测GC暂停是影响其P95延迟的原因。当然,这是可能的,但它绝不是唯一可能的因素,也不是对你的P95影响最大的因素。这就是为什么了解它的影响是很重要的,它告诉你应该把你的大部分精力花在什么上。

影响P95的因素可能与影响P99或P99.99的因素非常不同,同样的原理也适用于其他百分位数。

虽然本文档面向所有关心内存分析的人,但根据您使用的层的不同,应该做出不同的考虑。

作为从事最终产品工作的人,您有很大的优化自由,因为您可以预测您的产品在哪种环境中运行,例如,通常您知道您倾向于饱和的资源是CPU、内存还是其他什么。您可以在一定程度上控制您的产品在哪种机器/VM上运行,以及您使用哪种库/如何使用它们。您可以做出这样的估计";我们机器上有128 GB内存,并计划在我们最大的进程";中将20 GB专门用于内存缓存。

从事平台技术或库工作的人无法预测他们的代码将在什么样的环境中运行。这意味着1)如果您希望您的用户能够在性能关键路径上使用您的代码,那么您需要节约内存使用;2)您可能希望提供在性能和可用性之间进行折衷的不同API,并对您的用户进行这方面的教育。

正如我上面提到的,要一个人对整个堆栈有透彻的了解是完全不现实的。本节列出了任何需要进行内存性能分析的人都必须了解的基础知识。

我们通过VMM(虚拟内存管理器)使用内存,它为每个进程提供自己的虚拟地址空间,即使同一台机器上的所有进程共享物理内存(如果您有页文件,还可以共享物理内存)。如果您在VM中运行,则VM会产生在机器上运行的错觉。对于应用程序来说,即使是直接使用虚拟内存,实际上也是相当少见的。如果您正在编写本机代码,则通常通过一些本地分配器(如CRT堆或jemalloc)使用虚拟地址空间-这些分配器将代表您分配和释放虚拟内存;如果您正在编写托管代码,则GC将代表您分配/释放虚拟内存。

每个VA(虚拟地址)范围(意味着连续的虚拟地址)可以处于不同的状态-空闲、保留和提交。自由很容易。保守和承诺之间的区别有时会让人感到困惑。保留的意思是“我想让这块内存区域供我自己使用”。预留一定范围的虚拟地址后,该范围不能用于满足其他预留请求。在这一点上,您还不能在该地址范围内存储任何数据-要做到这一点,您必须提交它,这意味着您必须使用一些物理存储来备份它,以便可以在其中存储内容。当您通过性能工具查看内存时,请确保您查看的是正确的内容。如果要保留的空间或要提交的空间即将用完,则可能会出现内存不足的情况(我将重点放在本文档的Windows VMM上-在Linux中,当您实际触摸内存时,可能会出现OOM(内存不足))。

虚拟内存可以是私有的,也可以是共享的。私有意味着它只被当前进程使用,而共享意味着它可以被其他进程共享。所有与GC相关的内存使用都是私有的。

虚拟地址空间可能会碎片化-换句话说,地址空间中可能会有“空洞”(空闲块)。当您请求保留虚拟内存块时,VM管理器需要在虚拟地址范围中找到一个足够大的空闲块来满足该请求-如果您只有几个空闲块的总和足够大,那么它将不起作用。这意味着即使您有2 GB空间,您也不一定会看到所有的2 GB空间都被使用了。当大多数应用程序作为32位进程运行时,这是一个严重的问题。今天,我们有足够的64位虚拟地址范围,因此物理内存是主要的关注点。当您提交内存时,VMM会确保您有足够的物理存储空间,以防您实际想要访问该内存。当您实际向其中写入数据时,VMM现在会将该页放入物理内存中以存储该数据。此页面现在是您的流程工作集的一部分。当你开始你的过程时,这是一个非常正常的操作。当进程处于稳定状态时,我们通常希望看到您活跃使用的页面保留在您的工作集中,这样我们就不需要支付任何费用来将它们带进来。

当机器上的进程总体使用的内存超过机器拥有的内存时,将需要将某些页面写入页面文件(如果存在页面文件,大多数情况下都是如此)。这是一个非常慢的操作,所以通常的做法是尽量避免进入分页状态。我正在简化这一点--实际的细节与这个讨论无关。在下一节中,我们将讨论GC如何避免分页。

我特意将这一节保持简短,因为GC是需要为您与虚拟内存交互而担心的人,但了解一些基本知识有助于解释性能工具的结果。

垃圾收集器提供了内存安全的巨大好处,使开发人员不必手动释放内存,并可能节省数月或数年的调试堆损坏。如果您必须调试堆损坏,您就知道这有多难。但这也给内存性能分析带来了挑战,因为GC不会在每个对象死后运行(这将是非常低效的),而且GC越复杂,如果您需要进行内存分析,您就必须考虑得越多(您可能会,也可能不会,我们将在下一节讨论这一点)。本节将建立一些基本概念,以帮助您充分揭开.NET GC的神秘面纱,从而知道面对内存调查时正确的方法是什么。

在每个进程中,使用内存的每个组件彼此共存。在任何.NET进程中,总会有一些非GC堆内存的使用,例如,您的进程中总是加载了需要消耗内存的模块。但公平地说,对于大多数.NET应用程序来说,这在很大程度上意味着GC堆。

如果进程的总私有提交字节数(如上所述,GC堆始终在私有内存中)与您的GC堆的提交字节数非常接近,您就知道其中大部分是由于GC堆本身造成的,所以这才是您应该关注的问题。如果您确实观察到一个重大的差异,那么您就应该开始担心在您的进程中查看其他内存使用情况。

GC是每个进程的组件(从CLR开始就一直是这样)。大多数GC性能启发式方法都是基于每个进程的度量,但是GC知道机器上的全局物理内存负载。我们这样做是因为我们想避免进入分页情况。GC将某个内存负载百分比识别为高内存负载情况。当内存负载百分比超过该百分比时,GC将进入更积极的模式,即,如果它认为完全阻塞GC是高效的,则它会选择执行更多的完全阻塞GC,因为它想要减小堆大小。

目前在较小的计算机(即<;80GiB内存)上,默认情况下GC将90%视为高内存负载。在内存更大的计算机上,这一比例在90%到97%之间。此阈值可以通过COMPLUS_GCHighMemPercent环境变量(或从.NET 5开始的runtimeconfig.json中的System.Foundation HighMemoryPercent配置)进行调整。您想要调整它的主要原因是为了控制堆大小。例如,在具有64 GB内存的计算机上,对于主要的主导进程,GC在有10%的可用内存时开始响应是合理的。但是对于较小的进程(例如,如果一个进程只消耗1 GB内存),GC可以在<;10%可用内存的情况下轻松运行,因此您可能希望为这些进程设置更高的内存。另一方面,如果您希望较大的进程具有较小的堆大小(即使在机器上有大量可用物理内存的情况下也是如此),降低这个值将是GC更快做出反应以压缩堆的有效方式。

对于在容器中运行的进程,GC会根据容器限制来考虑物理内存。

到目前为止,我们使用GC来引用组件。下面,我将使用GC来引用组件或一个或多个执行收集以回收堆上内存的行为,即GC或GC。

由于GC应该管理分配,自然触发GC的主要因素是分配。随着流程的运行和分配的发生,GC将被持续触发。我们有一个“分配预算”的概念,它是决定何时触发GC的主导因素。下面我们将非常详细地讨论分配预算。

由于机器遇到高物理内存压力,并且如果用户自己通过调用EgCollect诱导GC,也可能触发GCS。

由于大多数GC都是由于分配而触发的,因此有必要了解分配的成本。首先,当分配不触发GC时,分配是否有成本?答案绝对是肯定的。有一些代码需要运行才能提供分配-每当您必须运行代码来做某件事时,都是要付出代价的。这只是多少钱的问题。

分配最昂贵的部分(不触发GC)是内存清除。GC维护一个合同,即它分发的所有分配都是零填充的。我们这样做是出于安全、安全和可靠的原因。

经常听到人们谈论衡量GC成本,但不太多地谈论衡量分配成本。一个明显的原因是GC干扰了你的线程。此外,当GC发生时监视非常便宜-我们提供了轻量级工具来告诉您这一点。但是分配一直在发生,很难监视每次发生分配时的情况-您会招致如此多的开销,这可能会使您的进程不再以有意义的状态运行。我们可以通过以下适当的方式来衡量分配成本,在工具部分,我们将了解如何使用各种工具技术来衡量分配成本-。

我们还可以测量GC发生的频率,这告诉我们发生了多少分配。毕竟,大多数GC都是由于分配而触发的。

当您有CPU使用情况信息时,您可以在GC方法中查看成本,该方法会产生内存

.