.NET Memory Performance Analysis
知道什么时候该担心,以及在需要担心的时候该怎么做
译者注
**作者信息:Maoni Stephens ** - 微软架构师,负责.NET Runtime GC设计与实现 博客链接 Github
译者:Bing Translator、INCerry 博客链接:https://incerry.cnblogs.com 联系邮箱:incerry@foxmail.com
本文已获得Maoni大佬授权,另外感谢@晓青、@贾佬、@黑洞、@晓晨、@一线码农 在百忙之中抽出时间校对和提出修改建议。
本文Github仓库:https://github.com/InCerryGit/mem-doc/blob/master/doc/.NETMemoryPerformanceAnalysis.zh-CN.md
原文链接:https://github.com/Maoni0/mem-doc/blob/master/doc/.NETMemoryPerformanceAnalysis.md
本文90%通过机器翻译,另外10%译者按照自己的理解进行翻译,和原文相比有所删减,与原文并不是一一对应,但是意思基本一致。另外文章较长,还没有足够的时间完全校对好,后续还会对一些语句不通顺、模糊和错漏的地方进行补充,请关注文档版本号。
文档版本号 | 修订记录 | 修订人 | 修订日期 |
---|---|---|---|
0.0.1 | 翻译文档创建 | - | 2021-12-05 |
0.0.2 | 人工校对,修复超链接错误 | - | 2021-12-16 |
译者水平有限,如果错漏欢迎批评指正
本文档的目的
本文旨在帮助.NET开发者,如何思考内存性能分析,并在需要时找到正确的方法来进行这种分析。在本文档中.NET的包括.NET Framework和.NET Core。为了在垃圾收集器和框架的其他部分获得最新的内存改进,我强烈建议你使用.NET Core,如果你还没有的话,因为那是应该尽快去升级的地方。
本文档的状态
这是一份正在完善的文档。现在,这份文档主要净重在Windows上。添加相应的Linux材料肯定会使它更有用。我正计划在未来这样做,但也非常欢迎其他朋友(尤其是对Linux部分)对该文件的贡献。
如何阅读本文档
这是一份很长的文档,但你不需要读完它;你也不需要按顺序阅读各部分。根据你在做性能分析方面的经验,有些章节可以完全跳过。
🔹 如果你对性能分析工作完全陌生,我建议从头开始。
🔹 对于那些已经能自如地进行一般的性能分析工作,但希望增加他们在管理内存相关主题方面的知识的人,他们可以跳过开头,直接进入基础知识部分。
🔹 如果你不是很有经验,并且在做一次性能分析,你可以跳到知道什么时候该担心部分开始阅读,如果需要,再参考基础知识部分的具体内容。
🔹 如果你是一名性能工程师,其工作包括将托管内存分析作为一项常规任务,但又是.NET的新手,我强烈建议你真正阅读并内化GC基础部分,因为它能帮助你更快地关注正确的事情。然而,如果你手头有一个紧急问题,你可以去看看我在本文档中将要使用的工具,熟悉它,然后看看你是否能在GC暂停问题或堆大小问题部分找到相关症状。
🔹 如果你已经有做托管内存性能分析工作的经验,并且有具体的问题,你可以在GC停顿时间长或GC堆太大部分找到它。
注意!
当我在写这篇文档时,我打算根据分析的需要来介绍一些概念,如并发的GC或钉住。所以在你阅读的过程中,你会逐渐接触到它们。如果你已经知道它们是什么,并且正在寻找关于特定概念的解释,这里有它们的链接-
分配预算
并发GC/后台GC
终结器
分代GC
LOH (LOH-Large-Object-Heap 大对象堆)
钉住
服务器GC
内容
如何看待性能分析工作?
挑选正确的方法来做性能分析
◼️知道你的目标是什么
◼️理解GC只是框架的一个部分
◼️不要猜测,去测量
◼️足够的测量,让你知道应该把精力集中在哪个领域
◼️测量那些可能影响你的性能指标的因素
◼️优化框架代码与用户代码
内存基础
◼️虚拟内存基础
◼️GC基础
▪️了解GC堆内存的使用与进程/机器内存的使用情况
GC堆只是你进程中的一种内存使用情况
GC是按进程进行的,但它知道机器上的物理内存负载
▪️了解GC是如何被触发的
触发GC的主要原因是分配
触发GC的其他因素
▪️了解分配内存的成本
▪️如何正确看待GC堆的大小
看一下GC堆的大小与GC发生的时间关系
分配预算
分代GC的影响
碎片化(自由对象)是堆大小的一部分
GC自己的记账
什么时候GC会抛出一个OOM异常?
▪️了解GC暂停-即何时触发GC以及GC持续多长时间)
单个GC的持续时间
多长时间触发一次GC?
要记住的一条规则
是什么使一个对象得以存活
1-分代方面
2-用户根
3-托管内存泄漏
“主线GC场景” vs “非主线”
[完全不做 GC 的部分 GC 暂停—线程挂起](#完全不做 GC 的部分 GC 暂停—线程挂起)
知道什么时候该担心
◼️顶层应用指标
◼️顶层的GC指标
◼️何时应担心GC
挑选正确的工具和解释数据
◼️性能工具一览
◼️我们使用的工具以及它是如何完成工作的
◼️如何开始进行内存性能分析
◼️如何收集顶层的GC指标
◼️显示顶级的GC指标
◼️PerfView中的其他相关视图
◼️GC暂停时间百分比高
▪️太多的停顿,即太多的GC
测量分配
查看带有堆栈信息的采样分配
▪️个别的长时间停顿
首先,您是否存在托管内存泄漏?
长时间的停顿是由于短暂的GCs、完全阻塞的GCs还是BGCs?
计算出gen2 GC的工作量
计算出短暂GC的工作量
弄清楚长的GC是否是由于GC工作造成的
◼️大尺寸的GC堆
调试OOM
峰值尺寸太大,但GC后尺寸不大?
GC后尺寸很大?
gen2 GC是否主要为后台GC?
你看到的堆的大小从GC的角度来看是合理的,但仍然希望有一个更小的堆?
GC是否为自己的记账工作使用了太多的内存?
性能问题的明确迹
◼️暂停时间太长
◼️随机的长时间GC停顿
◼️大多数GC是完全阻塞的GC
有助于我们帮助你调试性能问题的信息
◼️运行时的文件版本
◼️你已经进行了哪些诊断
◼️性能数据
如何看待性能分析工作?
那些在做性能分析方面有经验的人知道,这可能就像侦探工作一样--没有 "如果你按照这10个步骤去做,你就会改善性能或从根本上解决性能问题"的方法。这是为什么呢?因为你的代码不是唯一在运行的东西--你使用操作系统、运行时、库(至少是BCL,但通常是许多其他的),甚至你自己的代码。而运行你的代码的线程需要与同一进程中的其他线程和/或其他进程共享机器/VM/容器。
然而,这并不意味着你需要对我刚才提到的一切有一个彻底的了解。否则我们都不会有任何成就--你根本没有时间。但你不需要这样做。你只需要了解足够的基础知识,掌握足够的性能分析技能,这样你就可以专注于自己代码的性能分析。在本文中,我们将讨论这两点。我还会解释事情为什么会这样,这样才有意义,而不是让你背诵那些很容易被翻出来的东西。
这篇文档谈到了你自己可以做什么,以及什么时候是把分析工作交给GC团队的好时机,因为这将是需要在运行时进行的改进。很明显,我们在GC中仍然在做改进的工作(否则我就不会还在这个团队中)。正如我们将看到的,GC的行为是由你的应用行为驱动的,所以你肯定可以改变你的应用行为来影响GC。在你作为性能工程师需要做多少工作和GC自动处理多少工作之间存在着一个平衡。.NET GC的理念是,我们尽量自动处理;当我们需要你的输入时(通过配置),我们会以一种从应用角度看有意义的方式来要求你,而不是要求你了解GC的亲密细节。当然,GC团队一直在努力让.NET GC处理越来越多的性能场景,这样用户就不需要担心了。但如果你遇到了GC目前不能很好处理的情况,我将指出你可以做什么来解决它。
我对性能分析的目标是使客户需要做的大部分分析自动化。我们在这方面已经走了很长的路,但我们还没有达到所有分析都自动化的程度。在这份文件中,我将告诉你目前做分析的方法,在文件的最后,我将给你一个展望,说明我们正在为实现这个目标做什么样的改进。
挑选正确的方法来做性能分析
我们都有有限的资源,如何将这些资源花在能够产生最大回报的事情上是关键。这意味着你应该找到哪个部分是最值得优化的,以及如何以最有效的方式优化它们。当你断定你需要优化某些东西,或者你要如何优化某些东西时,应该有一个合理的理由来说明你为什么这样做。
知道你的目标是什么
当人们第一次来找我时,我总是问他们这样一个问题 - 你的性能目标是什么?不同的产品有非常不同的性能要求。在你想出一个数字之前(例如,将某些东西提高X%),你需要知道你要优化的是什么。在顶层,这些是需要优化的方面 -
◼️ 优化内存占用,例如,需要在同一台机器上尽可能多地运行实例。
◼️ 优化吞吐量,例如,需要在一定的时间内处理尽可能多的请求。
◼️针对尾部延迟进行优化,例如,需要满足一定的延迟SLA。
当然,你可以有多个这样的要求,例如,你可能需要满足一个SLA,但仍然需要在一个时间段内至少处理一定数量的请求。在这种情况下,你需要弄清楚什么是优先考虑的,这将决定你应该把大部分精力放在什么地方。
理解GC只是框架的一个部分
GC行为的变化可能是由于GC本身的变化或框架其他部分的变化,当你得到一个新版本时,框架中通常会有很多变化。当你在升级后看到内存行为的变化时,可能是由于GC的变化或框架中的其他东西开始分配更多的内存,并以不同的方式保留内存。此外,如果你升级你的操作系统版本或在不同的虚拟化环境中运行你的产品,你也可以得到不同的行为,只是因为它们可能导致你的应用程序行为不同。
不要猜测,去测量
测量是你在开始一个产品时绝对应该计划做的事情,而不是在事情发生后才想到的,特别是当你知道你的产品需要在相当紧张的情况下运行时。如果你正在阅读这份文件,那么你很有可能正在从事一些对性能有要求的工作。
对于我所接触的大多数工程师来说,测量并不是一个陌生的概念。然而,如何测量和测量什么是我见过的许多人需要帮助的事情。
◼️ 这意味着你需要有一些方法来真实地测量你的性能。在复杂的服务器应用程序上的一个常见问题是,你很难在你的测试环境中模拟你在生产中实际看到的情况。
◼️ 测量并不仅仅意味着 "我可以测量我的应用程序每秒可以处理多少个请求,因为这是我所关心的",它意味着你也应该有一些东西,当你的测量结果告诉你某些东西没有达到理想的水平时,你可以做有意义的性能分析。能够发现问题是一件事。如果你没有任何东西可以帮助你找出这些问题的原因,那就没有什么帮助了。当然,这需要你知道如何收集数据,我们将在下面谈及。
◼️ 能够衡量来自修复/解决方法的效果。
足够的测量,让你知道应该把精力集中在哪个领域
我一次又一次地听到人们会测量一件事,并选择只优化这件事,因为他们从朋友或同事那里听说了这件事。这就是了解基本原理真正有帮助的地方,这样你就不会一直关注你听说过的一件事,而这件事可能是也可能不是正确的。
测量那些可能影响你的性能指标的因素
在您知道哪些因素可能对您关心的事情(即您的性能指标)影响最大之后,你应该测量它们的效果,这样你就可以观察它们在你开发产品的过程中贡献是大还是小。一个完美的例子是,服务器应用程序如何改善其P95请求延迟(即第95百分位的请求延迟)。这是一个几乎每个网络服务器都会看的性能指标。当然,任何数量的因素都可以影响这个延迟,但你知道那些可能影响最大的因素。
网络IO只是另一个可能导致你的请求延迟的因素的例子。这里的方框宽度仅仅是为了说明问题。
你每天(或你记录P95的任何时间单位)的P95延迟可能会波动,但你知道大致的数字。比方说,你的平均请求延迟是<3ms,而你的P95大约是70ms。你必须有一些方法来测量每个请求总共需要多长时间(否则你就不会知道你的延迟百分数)。你可以记录你看到GC暂停或网络IO的时间(两者都可以通过事件来测量)。对于那些在P95延迟附近的请求,你可以计算出 "P95的GC影响",即
这些请求观察到的GC暂停总时间/请求总延时
如果这是10%,你应该有其他因素没有计算在内。
通常人们会猜测GC停顿是影响他们P95延迟的原因。当然这是有可能的,但这绝不是唯一可能的因素,也不是对你的P95影响最大的因素。这就是为什么了解影响很重要,它告诉你应该把大部分精力花在什么地方。
而影响你的P95的因素可能与影响你的P99或P99.99的因素非常不同;同样的原则也适用于其他百分位数。
优化框架代码与用户代码
虽然这个文档是为每一个关心内存分析的人准备的,但根据你所工作的层次,应该有不同的考虑。
作为一个从事终端产品的人,你有很大的自由空间去优化,因为你可以预测你的产品在什么样的环境下运行,例如,一般来说,你知道你倾向于哪种资源的饱和,CPU,内存或其他东西。你可以控制你的产品在什么样的机器/虚拟机上运行,你使用什么样的库,你如何使用它们。你可以做一些估计,比如 "我们的机器上有128GB的内存,计划在我们最大的进程中拿出20GB的内存缓存"。
从事平台技术或库工作的人无法预测他们的代码将在什么样的环境中运行。这意味着:1)如果希望用户能够在性能关键路径上使用代码,则需要节约内存使用;2)你可能要提供不同的API,在性能和可用性之间做出权衡,并对你的用户进行教育。
内存基础
正如我在上面提到的,让一个人对整个技术栈有透彻的了解是完全不现实的。本节列出了任何需要从事内存性能分析工作的人都必须知道的基本知识。
虚拟内存基础
我们通过VMM(虚拟内存管理器)使用内存,它为每个进程提供了自己的虚拟地址空间,尽管同一台机器上的所有进程都共享物理内存(如果你有页面文件的话)。如果你在一个虚拟机中运行,虚拟机就有一种在真实机器上运行的错觉。对于应用程序来说,实际上,你很少会直接使用虚拟内存工作。如果你写的是本地代码,通常你会通过一些本地分配器来使用虚拟地址空间,比如CRT堆,或者C++的new/delete助手-这些分配器会代表你分配和释放虚拟内存;如果你写的是托管代码,GC是代表你分配/释放虚拟内存的人。
每个VA(虚拟地址)范围(指虚拟地址的连续范围)可以处于不同的状态--自由(free)、保留(reserved)和已提交(committed)。"自由"很容易理解,就是空闲的内存。"保留"和"已提交"之间的区别有时让人困惑。"保留"是说 "我想让这个区域的内存供我自己使用"。当你"保留"了一个虚拟地址的范围后,这个范围就不能用来满足其他的"保留"请求。在这一点上,你还不能在这个地址范围内存储你的任何数据--要做到这一点,你必须"提交"它,这意味着系统将不得不用一些物理存储来支持它,以便你可以在其中存储东西。当你通过性能工具查看内存时,要确保你看的是正确的东西。如果你的预留空间用完了,或者"提交"空间用完了,你就会出现内存不足的情况(在本文档中,我主要关注Windows VMM--在Linux中,当你实际接触到内存时,你会出现OOM(Out Of Memory))。
虚拟内存可以是私有或共享的。私有意味着它只被当前进程使用,而共享意味着它可以被其他进程共享。所有与GC相关的内存使用都是私有的。
虚拟地址空间可能被分割--换句话说,地址空间中可能有 "缺口"(空闲块)。当你请求保留一大块虚拟内存时,虚拟机管理器需要在虚拟地址范围内找到一个足够大的空闲块来满足该请求--如果你只有几个空闲块,其总和足够大,那就无法工作。这意味着即使你有2GB,你也不一定能看到所有的2GB被使用。当大多数应用程序作为32位进程运行时,这是一个严重的问题。今天,我们在64位有一个充足的虚拟地址范围,所以物理内存是主要的关注点。当你提交内存时,VMM确保你有足够的物理存储空间,如果你真的想使用该内存。当你实际写入数据时,VMM将在物理内存中找到一个页面(4KB)来存储这些数据。这个页面现在是你进程工作集的一部分。当你启动你的进程时,这是一个非常正常的操作。
当机器上的进程使用的内存总量超过机器所拥有的内存时,一些页面将需要被写入页面文件(如果有的话,大多数情况下是这样的)。这是一个非常缓慢的操作,所以通常的做法是尽量避免进入分页。我在简化这个问题--实际的细节与这个讨论没有关系。当进程处于稳定状态时,通常你希望看到你积极使用的页面被保留在你的工作集中,这样我们就不需要支付任何成本来把它们带回来。在下一节中,我们将讨论GC是如何避免分页的。
我故意把这一节写得很短,因为GC才是需要代表你与虚拟内存互动的人,但了解一点基本情况有助于解释性能工具的结果。
GC基础
垃圾收集器提供了内存安全的巨大好处,使开发人员不必手动释放内存,并节省了可能是几个月或几年的调试堆损坏的时间。如果你不得不调试堆损坏,你就会知道这有多难。但是它也给内存性能分析带来了挑战,因为GC不会在每个对象死亡后运行(这将是令人难以置信的低效),而且GC越复杂,如果你需要做内存分析,你就必须考虑得越多(你可能会,也可能不会,我们将在下一节讨论这个问题)。本节是为了建立一些基本概念,帮助你对.NET GC有足够的了解,以便在面对内存调查时知道什么是正确的方法。
了解GC堆内存的使用与进程/机器内存的使用情况
GC堆只是你进程中的一种内存使用情况
在每个进程中,每个使用内存的组件都是相互共存的。在任何一个.NET进程中,总有一些非GC堆的内存使用,例如,在你的进程中总是有一些模块被加载,需要消耗内存。但可以说,对于大多数的.NET应用程序来说,这意味着GC堆占用大部分的内存。
如果一个进程的私有提交字节总数(如上所述,GC堆总是在私有内存中)与你的GC堆的提交字节数相当接近,你就知道大部分是由于GC堆本身造成的,所以这就是你应该关注的地方。如果你观察到一个明显的差异,这时你应该开始担心查看进程中的其他内存使用情况。
GC是按进程进行的,但它知道机器上的物理内存负载
GC是一个按进程的组件(自从CLR诞生以来一直如此)。大多数GC的启发式方法都是基于每个进程的测量,但GC也知道机器上的全局物理内存负载。我们这样做是因为我们想避免陷入分页的情况。GC将一定的内存负载百分比识别为 "高内存负载情况"。当内存负载百分比超过这个百分比时,GC就会进入一个更积极的模式,也就是说,如果它认为有成效的话,它会选择做更多的完全阻塞的GC,因为它想减少堆的大小。
目前,在较小的机器上(即内存小于80GiB),默认情况下GC将90%视为高内存负荷。在有更多内存的机器上,这是在90%到97%之间。这个阈值可以通过COMPLEX_GCHighMemPercent环境变量(或者从.NET 5开始在runtimeconfig.json中配置System.GC.HighMemoryPercent)来调整。你想调整这个的主要原因是为了控制堆的大小。例如,在一台有64GB内存的机器上,对于主要的主导进程,当有10%的内存可用时,GC开始反应是合理的。但是对于较小的进程(例如,如果一个进程只消耗1GB的内存),GC可以在<10%的可用内存下舒适地运行,所以你可能想对这些进程设置得更高。另一方面,如果你想让较大的进程拥有较小的堆大小(即使机器上有大量可用的物理内存),把这个值调低将是一个有效的方法,让GC更快地做出反应,压缩堆的大小。
对于在容器中运行的进程,GC会根据容器的限制来考虑物理内存。
本节描述了如何找出每个GC观察到的内存负载。
了解GC是如何被触发的
到目前为止,我们用GC来指代组件。下面我将用GC来指代组件,或者指代一个或多个在堆上进行内存回收的集合行为,即GC或GCs。
触发GC的主要原因是分配
由于GC是用来管理内存分配的,自然触发GC的最主要因素是由于分配。随着进程的运行和分配的发生,GC将不断被触发。我们有一个 "分配预算 "的概念,它是决定何时触发GC的主导因素。我们将在下面非常详细地讨论分配预算
触发GC的其他因素
GC也可以由于机器运行到高物理内存压力而被触发,或者如果用户通过调用
GC.Collect
而自己诱发GC。
了解分配内存的成本
由于大多数GC是由于分配而触发的,所以值得了解分配的成本。首先,当分配没有触发GC时,它是否有成本?答案是绝对的。有一些代码需要运行来提供分配--只要你必须运行代码来做一些事情,就会有成本。这只是一个多少的问题。
分配中开销最大的部分(没有触发GC)是内存清除。GC有一个契约,即它所有分配的内存会用零填充。我们这样做是为了安全、保障和可靠性的原因。
我们经常听到人们谈论测量GC成本,但却不怎么谈论测量分配成本。一个明显的原因是由于GC干扰了你的线程。还有一种情况是,监测GC发生的时间是非常轻量的 - 我们提供了轻量级的工具,可以告诉你这个。但是分配一直在发生,而且很难在每次分配发生时都进行监控 - 会占用很多性能资源,很可能使你的进程不再以有意义的状态运行。我们可以通过以下适当的方式来测量分配成本,在工具部分,我们将看到如何用各种工具技术来做这些事情--
监控内存分配的3种方法
1)我们还可以测量GC的发生频率,这告诉我们发生了多少分配。毕竟,大多数GC是由于分配而被触发的。
2)对非常频繁发生的事情进行分析的方法之一是抽样。
3)当你有了CPU使用信息,你可以在GC方法中查看内存清除的成本。实际上,通过GC方法名称来查找东西显然是非常内部且专业的,并受制于实现的变化。但由于本文档的目标是大众,包括有经验的性能工程师,我将提到几个具体的方法(其名称往往不会有太大的变化),作为进行性能测量的一种方式。
如何正确看待GC堆的大小
这听起来是一个简单的问题。通过测量,对吗?是的,但是当你测量GC堆的时候,就很重要了。
看一下GC堆的大小与GC发生的时间关系
这到底是什么意思?假设我们不考虑GC发生的时间,只是每秒钟测量一次堆的大小。请看下面这个(编造的)例子
表格 1
秒 动作 这一秒过后的堆大小 1 分配 1 GB 1 GB 2 分配 2 GB 3 GB 3 分配 0 GB 3 GB 4 GC发生(500M存活),然后分配1GB 1.5 GB 5 分配 3 GB 4.5 GB 我们可以说,是的,有一个GC发生在第4秒,因为堆的大小比第3秒小。但我们再看看另一种可能性-
表格2
秒 动作 这一秒过后的堆大小 1 分配 1 GB 1 GB 2 分配 2 GB 3 GB 3 GC发生(1GB存活),然后分配2GB 3 GB 4 分配 1 GB 4 GB 如果我们只有堆的大小数据,我们就不能说GC是否已经发生。
这就是为什么测量GC发生时的堆大小是很重要的。自然,GC本身提供的部分性能测量数据正是如此 - 每次GC前后的堆大小,也就是说,每次GC的进入和退出(以及其他大量的数据,我们将在本文的后面看到)。不幸的是,许多内存工具,或者我经常看到人们采取的诊断方法,都没有考虑到这一点。他们做内存诊断的方式是 "让我给你看看在你碰巧问起的时候堆是什么样子的"。这通常是没有帮助的,有时甚至是完全误导的。这并不是说像这样的工具完全没有帮助 - 当问题很简单的时候,它们可能会有帮助。如果你有一个已经持续了一段时间的戏剧性的内存泄漏,并且你使用了一个工具来显示你在那个时候的堆(要么通过采取进程转储和使用SoS,要么通过另一个工具来转储堆),那可能真的很明显泄漏是什么。这是性能分析中的一个常见模式--问题越严重,就越容易找出问题。但是,当你遇到的性能问题不是这种低垂的果实时,这些工具就显得不足了。
分配预算
看完上一段,思考分配预算的一个简单方法是上一次GC退出时的堆大小和这次GC进入时的堆大小之间的差异。因此,分配预算是指在触发下一次GC之前,GC允许多少分配。在表1和表2中,分配预算是一样的 - 3GB。
然而,由于.NET GC支持钉住对象(防止GC移动被钉住的对象)以及钉住的复杂情况,分配预算往往不是2个堆大小之间的区别。然而,预算是 "在触发下一次GC之前的分配量 "的想法仍然成立。我们将在本文档的后面讨论更多关于钉住的问题( 后面的内容.)。
当试图提高内存性能时,我看到人们经常做的一件事(或只做一件事)是减少分配。如果你真的可以在性能关键路径开始之前预先分配所有的东西,我说你更有更多的权利!但是,这有时是非常不实际的。例如,如果你使用的是库,你并不能完全控制它们的分配(当然,你可以尝试找到一种无分配的方式来调用API,但并不保证有这样的方式,而且它们的实现可能会改变)。
那么,减少分配是一件好事吗?是的,只要它确实会对你的应用程序的性能产生影响,并且不会使你的代码中的逻辑变得非常笨拙或复杂,从而使它成为一个值得的优化。减少分配实际上会降低性能吗?这完全取决于你是如何减少分配的。你是在消除分配还是用其他东西来代替它们?因为用其他东西代替分配可能不会减少GC所要做的工作。
分代GC的影响
.NET的GC是分代的,有3代,IOW,GC堆中的对象被分为3代;gen0是最年轻的一代,gen2是老一代。gen1作为一个缓冲区,通常是为了在触发GC时仍在请求中的数据(所以我们希望在我们做gen1时,这些数据不会被你的代码所引用)。
根据设计,分代GC不会在每次触发GC时收集整个堆。他们尝试做年轻一代的GC,比老一代的GC更频繁。老一代的GC通常成本更高,因为它们收集的堆更多。
你很可能曾经听说过 "GC暂停 "这个术语。GC暂停是指GC以STW(Stop-The-World)的方式执行其工作时。对于并发GC来说,它与用户线程同时进行大部分的GC工作,GC暂停的时间并不长,但是GC仍然需要花费CPU周期来完成它的工作。年轻的gen GCs,即gen0和gen1 GC,被称为短暂的GC,而老的gen GC,即gen2 GC,也被称为full GC,因为它们收集整个堆。当genX GC发生时,它收集了genX和它所有的年轻世代。因此,gen1 GC同时收集了堆中的gen0和gen1部分。
这也使得看堆变得更加复杂,因为如果你刚从一个老一代的GC中出来,特别是一个正在整理的GC,你的堆的大小显然比你在该GC被触发之前要小得多;但如果你看一下年轻一代的GC,它们可能正在被整理,但堆的大小差异没有那么大,这就是设计。
上面提到的分配预算概念实际上是每一代的,所以gen0、gen1和gen2都有自己的分配预算。用户的分配将发生在gen0,并消耗gen0的分配预算。当分配消耗了gen0的所有预算时,GC将被触发,gen0的幸存者将消耗gen1的分配预算。同样地,gen1的幸存者将消耗gen2的预算。
图1 - 经过不同代GC的对象
一个对象 "死了 "和它被清理掉之间的区别可能会让人困惑。我收到的一个常见问题是:"我不再保留我的对象了,而且我看到GC正在发生,为什么我的对象还在那里?"。请注意,一个对象不再被用户代码持有的事实(在本文中,用户代码包括框架/库代码,即不是GC代码)需要被GC扫描到。要记住的一个重要规则是:"如果一个对象在genX中,这意味着它只可能在genX GC发生时被回收",因为这时GC会真正去检查genX中的对象是否还活着。如果一个对象在gen2中,不管发生了多少次短暂的GC(即0代和1代GC),这个对象仍然会在那里,因为GC根本没有收集gen2。另一种思考方式是,一个对象所处的代数越高,GC需要收集的工作就越多。
大对象堆
现在是谈论大对象的好时机,也就是LOH(大对象堆)。到目前为止,我们已经提到了gen0、gen1和gen2,以及用户代码总是在gen0中分配对象。实际上,如果对象太大,这并不正确 - 它们会被分配到堆的另一个部分,即LOH。而gen0、gen1和gen2组成了SOH(小对象堆)。
在某种程度上,你可以认为LOH是一种阻止用户不小心分配大对象的方式,因为大对象比小对象更容易引入性能挑战。例如,当运行时默认发放一个对象时,它保证内存被清空。内存清空是一个昂贵的操作,如果我们需要清空更多的内存,它的成本会更高。也更难找到空间来容纳一个更大的对象。
LOH在内部是作为gen3被跟踪的,但在逻辑上它是gen2的一部分,这意味着LOH只在gen2的GC中被收集。这意味着,如果你代码经常会使用LOH,你就会经常触发gen2的GC,如果你的gen2也很大,这意味着GC将不得不做大量的工作来执行gen2的GC。
和其他gen一样,LOH也有它的分配预算,当它用完时,与gen0不同,gen2 GC将被触发,因为LOH只在gen2 GC期间被清理。
默认情况下,一个对象进入LOH的阈值是>=85000字节。这可以通过使用GCLOHThreshold配置来调整更高。LOH也默认不压缩,除非它在有内存限制的容器中运行(容器行为在.NET Core 3.0中引入)。
碎片化(自由对象)是堆大小的一部分
另一个常见问题是 "我看到gen2有很多自由空间,为什么GC没有使用这些空间?"。
答案是,GC正在使用这个空间。我们必须再次回到何时测量堆的大小,但现在我们需要增加另一个维度 - 整理GC vs 清扫GC。
.NET GC可以执行整理或清扫GC。整理是开销更大的操作,因为GC会移动对象(会发生内存复制),这意味着它必须更新堆上这些对象的所有引用,但整理可以大大减少堆的大小。清扫GC不进行压缩,而是将相邻的死对象凝聚成一个空闲对象,并将这些空闲对象穿到该代的空闲列表中。空闲列表占据的大小,我们称之为碎片,也是gen的一部分,因此在我们报告gen和堆的大小时也包括在内。虽然在这种情况下,堆的大小并没有什么变化,但重要的是要明白这个空闲列表是用来容纳年轻一代的幸存者的,所以我们要使用空闲空间。
这里我们将介绍GC的另一个概念 - 并发的GC与阻塞的GC。
并发GC/后台GC
我们知道,如果我们以停止托管线程的方式进行GC,可能需要很长的时间,也就是我们所说的完全阻塞式GC。我们不想让用户线程暂停那么久,所以大多数时候,一个完整的GC是并发进行的,这意味着GC线程与用户线程同时运行,在GC的大部分时间里(一个并发的GC仍然需要暂停用户线程,但只是短暂的暂停)。目前.NET中的并发GC风格被称为后台GC,或简称BGC。BGC只进行清扫。也就是说,BGC的工作是建立一个第二代自由列表来容纳第一代的幸存者。短暂的GC总是作为阻塞的GC来做,因为它们足够短。
现在我们再来思考一下 "何时测量 "的问题。当我们做一个BGC时,在该GC结束时,一个新的自由列表被建立起来。随着第一代GC的运行,他们将使用这个自由列表的一部分来容纳他们的幸存者,所以列表的大小将变得越来越小。因此,当你说 "我看到gen2有很多空闲空间 "时,如果那是在BGC刚刚发生的时候,或者刚刚发生不久的时候,那是正常的。如果到了我们做下一次BGC的时候,gen2中总是有很多空闲空间,这意味着我们做了那么多工作来建立一个空闲列表,但它并没有被使用多少,这就是一个真正的性能问题。我已经在一些场景中看到了这种情况,我们正在建立一个解决方案,使我们能够进行最佳的BGC触发。
Pinning 再次增加了碎片的复杂性,我们将在钉住章节中谈及。
GC堆的物理表示
我们一直在讨论如何正确地测量GC堆的大小,但是GC堆在内存中到底是什么样子的,也就是说,GC堆是如何物理组织的?
GC像其他Win32程序一样通过
VirtualAlloc
和VirtualFree
API来获取和释放虚拟内存(在Linux上通过mmap
/munmap
完成)。GC对虚拟内存进行的操作有以下几点当GC堆被初始化时,它为SOH保留了一个初始段,为LOH保留了另一个初始段,并且只在每个段的开头提交几个页面来存储一些初始信息。
当分配发生在这个段上时,内存会根据需要被提交。对于SOH来说,由于只有一个段,gen0、gen1和gen2此时都在这个段上。要记住的一个不变因素是,两个短暂的gen,即gen0和gen1,总是生活在同一个段上,这个段被称为短暂段,这意味着合并的短暂gen永远不会比一个段大。如果SOH的增长超过了一个段的容量,在GC期间将获得一个新的段。gen0和gen1所在的段是新的短暂段,另一个段现在变成了gen2段。这是在GC期间完成的。LOH是不同的,因为用户的分配会进入LOH,新的段是在分配时间内获得的。因此,GC堆可能看起来像这样(在段的末尾可能有未使用的空间,用白色空间表示):
图. 2 - GC堆的段
随着GC的发生和内存回收,当段上没有发现活对象时,段就会被释放;段空间的末端(即段上最后一个活对象的末端,直到段的末端)被取消提交,除了短暂的段。
对短暂段的特殊处理
对于短暂段,我们保留GC后提交的最后一个实时对象之后的空间,因为我们知道gen0分配将立即使用这个空间。因为我们要分配的内存量是gen0的预算,所以提交的空间量就是gen0的预算。这回答了另一个常见问题 - "为什么GC提交的内存比堆的大小多?"。这是因为提交的字节包括gen0预算部分,而如果你碰巧在GC发生后不久看一下堆,它还没有消耗大部分的空间。特别是当你有服务器GC时,它可能有相当大的gen0预算;这意味着这个差异可能很大,例如,如果有32个堆,每个堆有50MB的gen0预算,你在GC后马上看堆的大小,你看到的大小会比提交的字节少(32 * 50 = 1.6 GB)。
请注意,在.NET 5中,取消提交的行为发生了变化,我们可以留下更多的内存,因为我们想把gen1也纳入GC的考虑。另外,服务器GC的取消提交现在是在GC暂停之外完成的,所以GC结束时报告的部分内容可能会被取消提交。这是一个实现细节--使用gen0的预算通常仍然是一个非常好的近似值,可以确定投入的部分是多少。
按照上面的例子,在gen2 GC之后,堆可能看起来是这样的(注意这只是一个例子说明)。
图3 - gen2 GC后的GC堆段
在gen0的GC之后,由于它只能收集gen0的空间,我们可能会看到这个:
图4 - gen0 GC后的GC堆段
大多数时候,你不必关心GC堆被组织成段的事实,除了在32位上,因为虚拟地址空间很小(总共2-4GB),而且可能是碎片化的,甚至当你要求分配一个小对象时,你可能得到一个OOM,因为我们需要保留一个新的段。在64位平台上,也就是我们大多数客户现在使用的平台上,有大量的虚拟地址空间,所以预留空间不是一个问题。而且在64位平台上,段的大小要大得多。
GC自己的记账
很明显,GC也需要做自己的记账工作,这就需要消耗内存 - 这大约是GC堆大小的1%。最大的消耗是由于启用了并行GC,这是默认的。准确地说,并发的GC记账与堆的储备大小成正比,但其余的记账实际上与堆的范围成正比。由于这是1%,你需要关心它的可能性极低。
什么时候GC会抛出一个OOM异常?
几乎所有人都听说过或遇到过OOM异常。GC究竟什么时候会抛出一个OOM异常呢?在抛出OOM之前,GC确实非常努力。因为GC大多做短暂的GC,这意味着堆的大小往往不是最小的,这是设计上的。然而,GC通常会尝试一个完全阻塞的GC,并在抛出OOM之前验证它是否仍然不能满足分配请求。但也有一个例外,那就是GC有一个调整启发式,说它不会继续尝试完全阻塞的GC,如果它们不能有效地缩小堆的大小。它将尝试一些gen1 GCs和完全阻塞的GCs混合在一起。所以你可能会看到一个OOM抛出,但抛出它的GC并不是一个完全阻塞的GC。
了解GC暂停,即何时触发GC以及GC持续多长时间
当人们研究 GC 暂停问题时,我总是问他们是否关心总暂停和/或单个暂停。总暂停是由 "GC中的%暂停时间 "来表示的,每次GC被触发,暂停都会被加到总暂停中。通常情况下,你关心这个是出于吞吐量的原因,因为你不希望GC过多地暂停你的代码,以至于把吞吐量降低到可接受的程度。单个暂停表示单个GC持续的时间。除了作为总暂停的一部分,你关心单个暂停的一个原因通常是为了请求的尾部延迟--你想减少长的GC以消除或减少它们对尾部延迟的影响。
单个GC的持续时间
.NET的GC是一个引用追踪式GC,这意味着GC需要通过各种根(例如,堆栈定位,GC处理表)去追踪,以找出哪些对象应该是活的。因此,GC的工作量与有多少对象在内存中存活成正比。一个GC持续的时间与GC的工作量大致成正比。我们将在本文档的后面更多地讨论根的问题。
对于阻塞式GC来说,由于它们在整个GC期间暂停用户线程,所以GC持续的时间与GC暂停的时间相同。对于BGC,它们可以持续相当长的时间,但暂停时间要小得多,因为GC主要是以并发的方式工作。
注意,我说过GC的持续时间与GC的工作量大致成正比。为什么是大致?GC需要像其他东西一样分享机器上的核心。对于阻塞式GC,当我们说 "GC暂停用户线程 "时,我们实际上是指 "执行托管代码的线程"。执行本地代码的线程可以自由运行(尽管需要等待GC结束,如果它们需要在GC仍在进行的时候返回到托管代码)。最后,不要忘了,在线程运行时,其他进程由于GC的原因暂停了你的进程。
这就是我们引入的另一个概念,即GC的不同主要类型--工作站GC vs 服务器GC(简称WKS GC vs SVR GC)。
服务器GC
顾名思义,它们分别用于工作站(即客户端)和服务器的工作负载。工作站工作负载意味着你与许多其他进程共享机器,而服务器工作负载通常意味着它是机器上的主导进程,并倾向于有许多用户线程在这个进程中工作。这两种GC的主要区别在于,WKS GC只有一个堆,SVR GC有多少个堆取决于机器上有多少逻辑核心,也就有和逻辑核心相同数量的GC线程进行GC工作。到目前为止,我们介绍的所有概念都适用于每个堆,例如,分配预算现在是每代每堆,所以每个堆都有自己的gen0预算。当任何一个堆的gen0分配预算用完后,就会触发GC。上图中的GC堆段将在每个堆上重复出现(尽管它们可能包含不同数量的内存)。
由于2种工作负载的性质不同,SVR GC有2个明显不同的属性,而WKS GC则没有。
SVR GC线程的优先级被设置为 "THREAD_PRIORITY_HIGHEST",这意味着如果其他线程的优先级较低,它就会抢占这些线程,而大多数线程都是如此。相比之下,WKS GC在触发GC的用户线程上运行GC工作,所以它的优先级是该线程运行的任何优先级,通常是正常的优先级。
SVR GC线程与逻辑核心硬性绑定。
参见MSDN文档中关于SVR GC的图解。既然我们现在谈到了服务器和并发/后台GC,你可能会问服务器GC也有并发的吗?答案是肯定的。我再次向你推荐MSDN doc,因为它对Background WKS GC与Background SVR GC有一个明确的说明。
我们这样做的原因是,当SVR GC发生时,我们希望它能够尽可能快地完成它的工作。虽然这在大多数情况下确实达到了这个目标,但是它可能会带来一个你应该注意的复杂情况 - 如果在SVR GC发生的同时,有其他线程也以
THREAD_PRIORITY_HIGHEST
或更高的速度运行,它们会导致SVR GC花费更长的时间,因为每个GC线程只在其各自的核心上运行(我们将在后面的章节)看到如何诊断长GC的问题。而这种情况通常非常罕见,但是有一个注意事项,那就是当你在同一台机器上有多个使用SVR GC的进程时。在运行时的早期,这种情况很少见,但是随着这种情况越来越少,我们创建了一些配置,允许你为使用SVR GC的进程指定更少的GC堆/线程。这些配置的解释是这里。我见过一些人故意把一个大的服务器进程分成多个小的进程,这样每个进程都会有一个较小的堆,通过使用堆数较少的服务器GC。他们用这种方式取得了更好的效果(更小的堆意味着更短的暂停时间,如果它确实需要做完全阻塞的GC的话)。这是一个有效的方法,但当然只能在有意义的情况下使用它 - 对于某些应用来说,将一个进程分成多个进程是非常尴尬的。
多长时间触发一次GC?
如前所述,当gen0的分配预算用完时,就会触发GC。当一个GC被触发时,发生的第一步是我们决定这个GC将是哪一代。在工具那一章节,我们将看到哪些原因会导致GC从gen0升级到可能的gen1或gen2,但其中的一个主要因素是gen1和gen2的分配预算。如果我们检测到gen2的分配预算已经用完,我们就会把这个GC升级到完全的GC。
因此,"多长时间触发一次GC "的答案是由gen0/LOH预算耗尽的频率决定的,而gen1或gen2的GC被触发的频率主要由gen1和gen2的预算耗尽的频率决定。你自然会问 "那么预算是如何计算的?"。预算主要是根据我们看到的那一代的存活率来计算的。存活率越高,预算就越大。如果GC收集了一代对象并发现大多数对象都存活了,那么这么快再收集它就没有意义了,因为GC的目标是回收内存。如果GC做了所有这些工作,而能回收的内存却很少,那么它的效率就会非常低。
这如何转化为触发GC的频率是,如果一个代被频繁地使用(即,它的存活率很低),它将被更频繁地收集。这就解释了为什么我们最频繁地收集gen0,因为gen0是用于非常临时的对象,其存活率非常低。根据代际假说,对象要么活得很久,要么很临时,gen2持有长寿的对象,所以它们被收集的次数最少。
如前所述,在高内存负载情况下,我们会更积极地触发gen2阻塞式GC。当内存负载很高的时候,我们倾向于做完全阻塞的GC,这样我们就可以进行整理。虽然BGC对暂停时间有好处,但它对缩小堆没有好处,而当GC认为它的内存不足时,缩小堆就更重要了。
当内存负载不高时,我们做完全阻塞的GC的另一个原因是当gen2碎片非常高时,GC认为大幅减少堆的大小是有成效的。如果这对你来说是不必要的(即你有足够的可用内存),而且你宁愿避免长时间的停顿,你可以将延迟模式设置为SustainedLowLatency,告诉GC只在必须的时候做全阻塞的GC。
要记住的一条规则
那是很多材料,但如果我们把它总结为一条规则,这就是我在谈论GC被触发的频率和单个GC持续的时间时总是告诉人们的事情。
存活的对象数量通常决定了GC需要做多少工作;不存活的对象数量通常决定了GC被触发的频率
下面是一些极端的例子,当我们应用这一规则时-
情况1 - gen0根本没有任何存活对象。这意味着gen0的GC被频繁地触发。但是单次gen0的暂停时间非常短,因为基本上没有工作要做。
情况2 - 大部分gen2对象都存活。这意味着gen2的GC被触发的频率很低。对于单个gen2的暂停,如果GC作为阻塞GC进行,那暂停时间会非常长;如果作为BGC进行,会持续很长时间(但暂停时间仍然很短)。
你不能处于分配率和生存率都很高的情况下 - 你会很快耗尽内存。
是什么使一个对象得以存活
从GC的角度来看,它被各种运行时组件告知哪些对象应该存活。它并不关心这些对象是什么类型;它只关心有多少内存可以存活,以及这些对象是否有引用,因为它需要通过这些引用来追踪那些也应该存活的子对象。我们一直在对GC本身进行改进,以改善GC暂停,但作为一个写托管代码的人,知道是什么让对象存活下来是一个重要的方法,你可以通过它来改善你这边的个别GC暂停。
1. 分代方面
我们已经谈到了分代GC的效果,所以第一条规则是当一个代没有被回收,这意味着该代的所有对象都是活的。
因此,如果我们正在收集gen2,代数方面是不相关的,因为所有的代数都会被收集。我收到的一个常见问题是:"我已经多次调用GC.Collect()了,对象还在那里,为什么GC不把它处理掉呢?"。这是因为当你诱导一个完全阻塞的GC时,GC并不参与决定哪些对象应该是活的 - 它只会由我们将在下面讨论的用户根(堆栈/GC句柄/等等)告知是否存活,我们将在下面谈论。因此,这意味着无论什么东西还活着,都是因为它需要活着,而GC根本无法回收它。
不幸的是,很少有性能工具会强调生成效应,尽管这是.NET GC的一个基石。许多性能工具会给你一个堆转储--有些会告诉你哪些堆栈变量或哪些GC句柄持有对象。你可以摆脱很大比例的GC句柄,但你的GC暂停时间几乎没有改善。为什么呢?如果你的大部分GC暂停是由于gen0的GC被gen2中的一些对象持有而造成的,那么如果你设法摆脱一些gen2的对象,而这些对象并不持有这些gen0的对象,那也是没有用的。是的,这将减少gen2的工作,但是如果gen2的GC发生的频率很低,那就不会有太大的区别,如果你的目标是减少gen2的GC的数量,你就不会有什么进展。
2. 用户根
你最有可能听到的常见类型的根是指向对象的堆栈变量、GC句柄和终结器队列。我把这些称为用户根,因为它们来自用户代码。由于这些是用户代码可以直接影响的东西,所以我将详细地讨论它们。
堆栈变量
堆栈变量,特别是对于C#程序来说,实际上并没有被谈及很多。原因是JIT也能很好地意识到堆栈变量何时不再被使用。当一个方法完成后,堆栈根保证会消失。但即使在这之前,JIT也能知道什么时候不再需要一个堆栈变量,所以不会向GC报告,即使GC发生在一个方法的中间。请注意,在DEBUG构建中不是这种情况。
GC句柄
GC句柄是一种方式,用户代码可以持有一个对象,或者检查一个对象而不持有它。前者被称为强柄,后者被称为弱柄。强句柄需要被释放,以使它不再保留一个对象,也就是说,你需要在句柄上调用Free。有一些人给我看了!gcroot(SoS调试器的一个扩展命令,可以显示一个对象的根部)的输出,说有一个强句柄指向一个对象,问我为什么GC还没有回收这个对象。根据设计,这个句柄告诉GC这个对象需要是活的,所以GC不能回收它。目前,以下用户暴露的句柄类型是强句柄。Strong和Pinned;而弱柄是Weak和WeakTrackResurrection。但是如果你看过SoS的 !gchandles输出,Pinned句柄也可以包括AsyncPinned。
钉住
我在上面提到过几次钉住。大多数人都知道钉住是什么 - 它向GC表示一个对象不能被移动。但从GC的角度来看,钉住的意义是什么呢?由于GC不能移动这些被钉住的对象,它使被钉住的对象之前的死角变成了一个自由对象,这个自由对象可以用来容纳年轻一代的生存者。但这里有一个问题 - 正如我们从上面的代际讨论中看到的,如果我们简单地将这些被钉住的对象提升到老一代,就意味着这些自由空间也是老一代的一部分,要用它们来容纳年轻一代的幸存者,唯一的办法就是我们真的对年轻一代做一次GC(否则我们甚至没有 "年轻一代的幸存者")。然而,如果我们能在gen0中保留这些自由空间,它们就可以被用户分配使用。这就是为什么GC有一个叫做降代的功能,我们将把这些被钉住的对象降代到gen0,这意味着它们之间的空闲空间将是gen0的一部分,当用户代码分配时,我们可以立即使用它们。
图5 - 降代(我从一张旧的幻灯片上取下来的,所以这看起来与之前的片段图片有些不同。)
由于gen0分配可以发生在这些自由空间中,这意味着它们将消耗gen0预算而不增加gen0的大小(除非自由空间不能满足所有的gen0预算,在这种情况下它将需要增长gen0)。
然而,GC 不会无条件地降代,因为我们不想在 gen0 中留下许多固定对象,这意味着我们必须在每次 GC 中再次查看它们,可能会有很多次 GC(因为它们是 gen0 的一部分,每当我们执行 gen0 GC 我们需要查看它们)。这意味着如果您遇到严重的固定情况,它仍然会导致 gen2 中的碎片问题。同样,GC 确实有机制来应对这些情况。但是如果你想对 GC 施加更少的压力,你可以从用户的 POV 中遵循这个规则—
早点钉住对象,分批钉住对象
我们的想法是,如果你把对象钉在已经整理的那部分堆里,意味着这些对象已经不需要移动了,所以碎片化就不是问题。如果你以后确实需要钉住,通常的做法是分配一批缓冲区,然后把它们钉在一起,而不是每次都分配一个并钉住它。在.NET 5中,我们引入了一个名为POH(Pinned Object Heap(固定堆))的新特性,允许你告诉GC在分配时将钉住的对象放在一个特定的堆上。因此,如果你有这样的控制权,在POH上分配它们将有助于缓解碎片化问题,因为它们不再散落在普通堆上。
终结器
终结队列是另一个根来源。如果你已经写了一段时间的.NET应用程序,你有可能听说过终结器是你需要避免的东西。然而,有时终结器并不是你的代码,而是来自你所使用的库。由于这是一个非常面向用户的特性,我们来详细了解一下。下面是终结器的基本性能含义 -
分配
· 如果你分配了一个可终结的对象(意味着它的类型有一个终结器),就在GC返回到VM端的分配助手之前,它将把这个对象的地址记录在终结队列中。
· 有一个终结者意味着你不能再使用快速分配器进行分配,因为每个可终结的对象的分配都要到GC去注册。
然而,这种成本通常是不明显的,因为你不太可能分配大部分可终结的对象。更重要的成本通常来自于GC实际发生的时间,以及在GC期间如何处理可终结的对象。
回收
当GC发生时,它将发现那些仍然活着的对象,并对它们升代。然后它将检查终结队列中的对象,看它们是否被升代 - 如果一个对象没有被升代,就意味着它已经死了,尽管它不能被回收(见下一段的原因)。如果你在被收集的几代中有成吨的可终结的对象,仅这一成本就可能是明显的。比方说,你有一大堆被提升到gen2的可终结对象(只是因为它们一直在存活),而你正在做大量的gen2 GC,在每个gen2 GC中,我们需要花时间来扫描所有的可终结对象。如果你很不频繁地做gen2 GC,你就不需要支付这个成本。
这里就是你听到 "终结器不好 "的原因了 - 为了运行GC已经发现的这个对象的终结器,这个对象需要是存活的。由于我们的GC是一代一代的,这意味着它将被提升到更高的一代,正如我们上面所谈到的,这反过来意味着它将需要一个更高的一代GC,也就是说,一个更昂贵的GC来收集这个对象。因此,如果一个可终结的对象在第一代GC中被发现死亡,它将需要等到下一次做第二代GC时才会被收集,而这可能是相当长的一段时间。也就是说,这个对象的内存的回收可能会被推迟很多。
然而,如果你用GC.SuppressFinalize来抑制终结器,你告诉GC的是你不需要运行这个对象的终结器。所以GC就没有理由去提升(升代)它。当GC发现它死亡时,它将被回收。
运行终结器
这是由终结器线程处理的。在GC发现死的、可终结的对象(然后被升代)后,它将其移至终结队列的一部分,告诉终结者线程何时向GC请求运行终结者,并向终结者线程发出信号,表示有工作要做。在GC完成后,终结器线程将运行这些终结器。被移到终结队列这一部分的对象被说成是 "准备好终结了"。你可能已经看到各种工具提到了这个术语,例如,sos的 !finalizequeue命令告诉你finalize队列的哪一部分储存了准备好的对象,像这样:
Ready for finalization 0 objects (000002E092FD9920->000002E092FD9920)
您经常会看到这是 0,因为终结器线程以高优先级运行,因此终结器将快速运行(除非它们被某些东西阻塞)。
下图说明了2个对象以及可最终确定的对象F是如何演变的。正如你所看到的,在它被提升到gen1之后,如果有一个gen0的GC,F仍然是活的,因为gen1没有被收集;只有当我们做一个gen1的GC时,F才能真正成为死的,我们看一下F所处的代。
图 6 - O 是不可终结的,F 是可终结的
3. 托管内存泄漏
现在我们了解了不同类别的根,我们可以谈谈管理性内存泄漏的定义了
托管内存泄漏意味着你至少有一个用户根,随着进程的运行,直接或间接地引用了越来越多的对象。这是一个泄漏,因为根据定义,GC不能回收这些对象的内存,所以即使GC尽了最大努力(即做一个完全阻塞的GC),堆的大小最终还是会增长。
所以最简单的方法,如果可行的话,识别你是否有托管内存泄漏,就是在你知道你应该有相同的内存使用量的时候,简单地诱导全阻塞GC(例如,在每个请求结束时),并验证堆的大小没有增长。显然,这只是一种帮助调查内存泄漏的方法--当你在生产中运行你的应用程序时,你通常不希望诱发全阻塞的GCs。
“主线GC场景” vs “非主线”
如果你有一个程序只是使用堆栈并创建一些对象来使用,GC已经优化了很多年了。基本上是 "扫描堆栈以获得根部,并从那里处理对象"。这就是许多GC论文所假设的主线GC方案,也是唯一的方案。当然,作为一个已经存在了几十年的商业产品,并且必须满足各种客户的要求,我们还有一堆其他的东西,比如GC句柄和终结器。需要了解的是,虽然多年来我们也对这些东西进行了优化,但我们的操作是基于 "这些东西不多 "的假设,这显然不是对每个人都是如此。因此,如果你确实有很多这样的东西,那么如果你在诊断内存问题时,就值得关注了。换句话说,如果你没有任何内存问题,你不需要关心;但如果你有(例如,在GC时间百分比高),它们是值得怀疑的好东西。
**完全不做 GC 的部分 GC 暂停—线程挂起 **
我们没有提到的GC暂停的最后一个部分是根本不做GC工作的部分--我指的是运行时中的线程暂停机制。GC调用暂停机制,让进程中的线程在GC工作开始前停止。我们调用这个机制是为了让线程到达它们的安全点。因为GC可能会移动对象,所以线程不能在随机的点上停止;它们需要在运行时知道如何向GC报告对GC堆对象的引用的点上停止,这样GC才能在必要时更新它们。这是一个常见的误解,认为GC在做暂停工作--GC只是调用暂停机制来让你的线程停止。然而暂停被报告为GC暂停的一部分,因为GC是使用它的主要组件。
我们谈到了并发与阻塞的GC,所以我们知道阻塞的GC会让你的线程在GC期间保持暂停状态,而BGC(并发的味道)会让它们在短时间内暂停,并在用户线程运行时做大部分的GC工作。不太常见的是,让线程进入暂停状态可能需要一段时间。大多数情况下这是非常快的,但是缓慢的暂停是一类与管理内存相关的性能问题,我们将专门讨论如何诊断这些问题。
注意,在GC的暂停部分,只有运行托管代码的线程被暂停。运行本地代码的线程可以自由运行。然而,如果它们需要在这样的暂停部分返回到托管代码,它们将需要等待,直到暂停部分结束。
知道什么时候该担心
与任何性能调查一样,首要的是弄清楚你是否应该担心这个问题。
顶层应用指标
如上所述,关键是要有性能目标 - 这些应该由一个或多个顶级应用指标来表示。它们是应用指标,因为它们直接告诉你应用的性能方面的数据,例如,你处理的并发请求数,平均、最大和/或P95请求延迟。
使用顶级应用指标来表明你在开发产品时是否有性能退步或改进,这是相对容易理解的,所以我们不会在这里花太多时间。但有一点值得指出的是,有时要让这些指标稳定到有一个月到一个月的趋势,甚至一天到一天的趋势并不容易,原因很简单,因为工作负载并不是每天都保持不变,特别是对尾部延迟的测量。我们如何解决这个问题呢?
· 这正是衡量能影响它们的因素的重要原因之一。当然,你很可能在前期不知道所有的因素。当你知道得越多,你就可以把它们加入到你要测量的东西的范围内。
· 有一些顶级的组件指标,帮助你决定工作负载中有多少变化。对于内存,一个简单的指标是做了多少分配。如果在今天的高峰时段,你的分配量是昨天的两倍,你就知道这表明今天的工作负荷也许给GC带来了更大的压力(分配量绝对不是影响GC暂停的唯一因素,见上面的GC暂停一节)。然而,有一个原因使得这成为一个受欢迎的追踪对象,因为它与用户代码直接相关--你可以在一行代码中看到分配何时发生,而将GC与一行代码关联起来则比较困难。
顶层的GC指标
既然你在阅读本文档,显然你关心的组件之一就是GC。那么,你应该跟踪哪些顶层的GC指标,以及如何决定何时应该担心?
我们提供了许多不同的GC指标,你可以测量 - 显然你不需要关心所有的指标。事实上,要确定你是否/何时应该开始担心GC,你只需要一到两个顶级的GC指标。表3列出了哪些顶级GC指标是基于你的性能目标相关的。如何收集这些指标将在[后面的章节]中描述(#如何收集顶层的GC指标)。
表格3
Application perf goal 应用性能目标 | Relevant GC metrics 相关的GC指标 |
---|---|
Throughput 吞吐量 | % Pause time in GC (maybe also % CPU time in GC) 在GC中暂停时间的百分比(也许还有GC中CPU时间的百分比) |
Tail latency 尾部延时 | Individual GC pauses 个别的GC停顿 |
Memory footprint 内存占用率 | GC heap size histogram GC堆大小直方图 |
何时应担心GC
如果你理解了GC基本原理,那么GC行为是由应用行为驱动的,这一点应该是非常明显的。顶层的应用指标应该告诉你什么时候出现了性能问题。而GC指标可以帮助你对这些性能问题进行调查。例如,如果你知道你的工作负载在一天中长时间处于休眠状态,那么你看一天中 "GC中暂停时间百分比 "指标的平均值是没有意义的,因为 "GC中暂停时间百分比 "的平均值会非常小。看这些GC指标的一个更合理的方法是:"我们在X点左右发生了故障,让我们看一下那段时间的GC指标,看看GC是否可能是故障的原因"。
当相关的GC指标显示GC的影响很小的时候,把你的精力放在其他地方会更有成效。如果它们表明GC确实有很大的影响,这时你应该开始担心如何进行内存管理分析,这就是本文档的大部分内容。
让我们详细看看每个目标,以了解为什么你应该看他们相应的GC指标 -
吞吐量
为了提高你的吞吐量,你希望GC尽可能少地干扰你的线程。GC会在以下两个方面进行干扰
· GC可以暂停你的线程 - 阻塞的GC会在整个GC期间暂停它们,BGC会暂停一小段时间。这种暂停由 "GC中的%暂停时间(% Pause time in GC)"来表示。
· GC线程会消耗CPU来完成工作,虽然BGC不会让你的线程暂停太多,但它确实会与你的线程竞争CPU。所以还有一个指标叫做 "GC花费的CPU时间%(% CPU time in GC)"。
这两个数字可能有很大差别。"GC中的暂停时间百分比 "的计算方法是
线程被GC暂停时的耗时/进程的总耗时
因此,如果从进程开始到现在已经10s了,线程由于GC而暂停了1s,那么GC中的暂停时间百分比就是10%。
即使BGC不在其中,GC中的CPU时间百分比也可能多于或少于GC中的暂停时间百分比,因为这取决于CPU在进程中被其他事物使用的情况。当GC正在进行时,我们希望看到它尽可能快地完成;所以我们希望看到它在执行期间有尽可能高的CPU使用率。这曾经是一个非常令人困惑的概念,但现在似乎发生得更少了。我曾经收到过一些担心的人的报告,说 "当我看到一个服务器GC时,它使用了100%的CPU! 我需要减少这个!"。我向他们解释说,这实际上正是我们希望看到的--当GC暂停了你的线程时,我们希望能使用所有的CPU,这样我们就能更快地完成GC工作。假设GC的暂停时间为10%,在GC暂停期间,CPU使用率为100%(例如,如果你有8个核心,GC会完全使用所有8个核心),在GC之外,你的线程的CPU使用率为50%,并且没有BGC发生(意味着GC只在你的线程暂停时做工作),那么GC的CPU时间将为
(100% * 10%) / (100% * 10% + 50% * 90%) = 18%
我建议首先监测GC中的%暂停时间,因为它的监测开销很低,而且是一个很好的衡量标准,可以确定你是否应该把GC作为一个最高级别的指标来关注。监测GC中的CPU时间百分比的成本较高(需要实际收集CPU样本),而且通常没有那么关键,除非你的应用程序正在做大量的BGC,而且CPU真的饱和了。
通常情况下,一个行为良好的应用程序在GC中的暂停时间小于5%,而它正在积极处理工作负载。如果你的应用程序的暂停时间是3%,那么你把精力放在GC上就没有什么成效了--即使你能去掉一半的暂停时间(这很困难),你也不会使总的性能提高多少。
尾部延时
之前我们讨论了如何考虑测量导致你的尾部延迟的因素。如果尾部延迟是你的目标,除了其他因素外,GC或最长的GC可能发生在那些最长的请求中。因此,测量这些单独的GC暂停是很重要的,看看它们是否/在多大程度上导致了你的延迟。有一些轻量级的方法可以知道一个单独的GC暂停何时开始和结束,我们会看到在本文档后面。
内存占用率
如果你还没有正确阅读GC堆只是你进程中的一种内存使用情况,以及如何测量GC heap size,我强烈建议你现在就去做。实际上,一个被管理的进程在GC堆之外还有明显的甚至是大量的内存使用,这并不罕见,所以了解是否是这样的情况很重要。如果GC堆在整个进程的内存使用中只占很小的比例,那么你专注于减少GC堆的大小就没有意义了。
挑选正确的工具和解释数据
性能工具一览
我怎么强调挑选正确工具的重要性都不为过。我经常看到一些人花了很多时间(有时是几个月)试图弄清一个问题,因为他们没有发现正确的工具和/或如何使用它。这并不是说即使有了正确的工具,你也不需要花费一些精力--有时只需要一分钟,但其他时候可能需要许多分钟或几个小时。
挑选合适的工具的另一个挑战是,除非你在做一个基本的分析,否则根本没有很多工具可以选择。也就是说,有更多的工具能够解决简单的问题,所以如果你正在解决其中的一个问题,你选择哪一个并不那么重要。例如,如果你有一个严重的托管内存泄漏,你可以在你的开发环境中重现,你将能够很容易地找到一个能够比较堆快照的工具,这样你就可以看到哪些对象存活了,而不应该。你很有可能通过这种方式来解决你的问题。你不需要关心像何时测量堆的大小这样的事情,就像我们在"如何正确测量GC堆的大小 "部分广泛谈论的那样。
我们使用的工具以及它是如何完成工作的
运行时团队制作的、我经常使用的工具是PerfView - 你们中的很多人可能都听说过它。但我还没有看到很多人充分使用它。PerfView的核心是使用TraceEvent,这是一个解码ETW(Event Tracing for Windows)事件的库,它来自运行时提供者、内核提供者和其他一些提供者。如果你以前没有接触过ETW事件,你可以把它们看作是各种组件随着时间的推移所发出的数据。它们具有相同的格式,所以来自不同组件的事件可以被那些知道如何解释ETW事件的工具放在一起看。这对香水调查来说是一个非常强大的东西。在ETW术语中,事件按提供者(例如,运行时提供者)、关键字(例如,GC)和粗略程度(例如,通常意味着轻量级的Informational和通常更重的Verbose)进行分类。使用ETW的成本与你所收集的事件量成正比。在GC信息级别,开销非常小,如果你需要,你可以一直收集这些事件。
由于.NET Core是跨平台的,而ETW在Linux上并不存在,我们有其他的事件机制,旨在使这个过程透明,如LTTng或EventPipe。因此,如果你使用TraceEvent库,你可以在Linux上获得这些事件的信息,就像你在Windows上使用ETW事件那样。然而,有不同的工具可以在Linux上收集这些事件。
PerfView中的另一个功能,我不太经常使用,但作为GC的用户,你可能更经常使用,那就是堆快照,即显示堆上有哪些对象,它们之间是如何连接的。我不经常使用它的原因是,GC并不关心对象的类型。
你可能也用过我们的调试器扩展SoS。我很少用SoS来分析性能,因为它更像是一个调试工具,而不是剖析工具。它也不怎么看GCs,主要是看堆,即堆统计和转储单个对象。
在本节的其余部分,我将向你展示如何用PerfView的正确方式进行内存分析。我将多次引用内存基础来解释为什么要用这种方式进行分析,这样做是有意义的,而不是让你记住我们在这里做什么。
如何开始进行内存性能分析
当你开始进行内存性能分析时,这些步骤是否听起来很熟悉?
捕获一个CPU采样文件,看看你是否可以减少任何高开销的方法的CPU
在一个工具中打开一个堆快照,看看你能摆脱什么?
捕获内存分配,看看你能摆脱什么?
根据你要解决的问题,这些可能是有缺陷的。比方说,你有一个尾部延迟的问题,你正在做1)。你可能正在寻找是否可以减少你的代码中的CPU使用率,或者是一些库代码。但是,如果你的尾部延迟受到长GC的影响,减少这些就不太可能影响你的长GC情况。
处理问题的有效方法是推理出有助于实现性能目标的因素,然后从那里开始。我们谈到了对不同性能目标有贡献的顶级GC指标。我们将讨论如何收集它们,并看看我们如何分析每一个指标。
本文档的大多数读者已经知道如何收集与内存有关的一般指标,所以我将简要介绍一下。由于我们知道GC堆只是进程中内存使用的一部分,但GC知道物理内存负载,我们想测量进程的内存使用和机器的物理内存负载。在Windows上,你可以通过收集以下性能计数器来实现这一目标。
Memory\Available MBytes(内存\可用内存 单位MB)
Process\Private Bytes(进程\私有内存占用 单位MB)
对于一般的CPU使用率,你可以监控
Process\% Processor time(进程\占用处理器时间百分比)
计数器。调查CPU时间的一个常见做法是,每隔一段时间就进行一次CPU抽样调查(例如,有些人可能每小时做一次,每次一分钟),并查看汇总的CPU堆栈。
如何收集顶层的GC指标
GC发出轻量级的信息级事件,你可以收集(如果你愿意,可以一直开着),涵盖所有顶级的GC指标。使用PerfView的命令行来收集这些事件 -
perfview /GCCollectOnly /AcceptEULA /nogui collect
完成后,在perfview cmd窗口中按下s
来停止它。
这应该运行足够长的时间来捕获足够多的GC活动,例如,如果你知道问题发生的时间,这应该涵盖问题发生前的时间(不仅仅是在问题发生的时间)。如果你不确定问题何时开始发生,你可以让它开很长时间。
如果你知道要运行多长时间的集合,可以使用下面的方法(实际上这个方法用得更多)。
perfview /GCCollectOnly /AcceptEULA /nogui /MaxCollectSec:1800 collect
并将1800(半小时)替换为你需要的任何秒数。当然,你也可以将这个参数应用于其他命令行。这将产生一个名为PerfViewGCCollectOnly.etl.zip的文件。用PerfView的话来说,我们称之为GCCollectOnly跟踪。
在Linux上,有一种类似的方法,就是这个dotnet trace命令行:
dotnet trace collect -p <pid> -o <outputpath with .nettrace extension> --profile gc-collect --duration <in hh:mm:ss format>
这算是一种等价的方法,因为它收集了同样的GC事件,但只针对已经启动的一个进程,而perfview命令行收集的是整个机器的ETW,即该机器上每个进程的GC事件,在收集开始后启动的进程也会收集到。
还有其他方法来收集顶级的GC指标,例如,在.NET Framework上,我们有GC perf计数器;在.NET Core上,也增加了一些GC计数器。计数器和事件之间最重要的区别是,计数器是抽样的,而事件则捕获所有的GC,但抽样也足够好。在.NET 5中,我们还添加了一个GC采样API--
public static GCMemoryInfo GetGCMemoryInfo(GCKind kind);
它返回的GCMemoryInfo是基于 "GCKind "的,在GCMemoryInfo.cs中做了解释。
/// <summary>指定垃圾收集的种类</summary>
/// <remarks>
/// GC可以是3种类型中的一种--短暂的、完全阻塞的或后台的。
/// 它们的发生频率是非常不同的。短暂的GC比其他两种GC发生的频率高得多。
/// 其他两种类型。后台GC通常不经常发生,而
/// 完全阻塞的GC通常发生的频率很低。为了对那些非常
/// 不经常发生的GC,集合被分成不同的种类,因此调用者可以要求所有三种GC
/// 同时保持
/// 合理的采样率,例如,如果你每秒钟采样一次,如果没有这个
/// 区分,你可能永远不会观察到一个后台GC。有了这个区别,你可以
/// 总是能得到你指定的最后一个GC的信息。
/// </remarks>
public enum GCKind
{/// <summary>任何种类的回收</summary>Any = 0,/// <summary>gen0或gen1会后.</summary>Ephemeral = 1,/// <summary>阻塞的gen2回收.</summary>FullBlocking = 2,/// <summary>后台GC回收</summary>/// <remarks>这始终是一个gen2回收</remarks>Background = 3
};
GCMemoryInfo提供了与这个GC相关的各种信息,比如它的暂停时间、提交的字节数、提升的字节数、它是压缩的还是并发的,以及每一代被收集的信息。请参阅GCMemoryInfo.cs了解完整的列表。你可以调用这个API,在过程中以你希望的频率对GCs进行采样。
显示顶级的GC指标
在PerfView的 "GCStats "视图中,这些数据与你刚刚收集的轨迹一起被方便地显示。
在PerfView中打开PerfViewGCCollectOnly.etl.zip文件,即通过运行PerfView并浏览到该目录,双击该文件;或者运行 "PerfView PerfViewGCCollectOnly.etl.zip "命令行。你会看到该文件名下有多个节点。我们感兴趣的是 "Memory Group"节点下的 "GCStats "视图。双击它来打开它。在顶部,我们有这样的内容
我运行了Visual Studio,它是一个托管应用程序--这就是顶部的devenv进程。
对于每一个进程,你都会得到以下细节--我对那些命令行的进程添加了注释。
Summary
– 这包括像命令行、CLR启动标志、GC的%暂停时间等。
GC stats rollup by generation
– 对于gen0/1/2,它有一些东西,如该gen的多少个GCs被完成,它们的平均/平均停顿,等等。
GC stats for GCs whose pause time was > 200ms
暂停时间大于200ms的GC的统计数字
LOH Allocation Pause (due to background GC) > 200 Msec for this process
- Gen2 GC stats该进程的LOH分配暂停(由于后台GC)>200Msec
对于大型对象的分配,有一个注意事项,即在BGC进行时,我们不允许过多的LOH分配。如果你在BGC期间分配了太多的对象,你会看到一个表格出现在这里,告诉你哪些线程在BGC期间被阻塞了(以及多久),因为有太多的LOH分配。这通常是一个信号,告诉你减少LOH分配将有助于不使你的线程被阻塞。
All GC stats
所有GC统计资料
Condemned reasons for GCs
GC被触发的原因
Finalized Object Counts
终结器对象数量
Summary
explanation "摘要 "解释
· Commandline
self-explanatory. 命令行
· Runtime version
运行时版本是相当无用的,因为我们只是显示一些通用版本。然而,你可以使用事件视图中的FileVersion事件来获得你所使用的运行时的确切版本。
· CLR Startup Flags
GC启动标志, 在GC调查中,主要是寻找CONCURRENT_GC和SERVER_GC。如果你没有这两个标志,通常意味着你使用的是工作站GC,并且禁用了并发的GC。不幸的是,这不是绝对的,因为在我们捕获这个事件之后,它可能会被改变。你可以用其他东西来验证这一点。注意:请注意,目前.NET Core/.NET 5没有这些标志,所以你在这里不会看到任何东西。
· Total CPU Time and Total GC CPU Time
总的CPU时间和总的GC CPU时间这些总是0,除非你真的在收集CPU样本。
· Total Allocs
你在这次追踪中为这个进程所做的总分配。
· MSec/MB Alloc
是0,除非你收集CPU样本(它将是GC CPU总时间/分配的总字节数)。
· Total GC pause
被GC停顿的总时间。注意,这包括暂停时间,即在GC开始之前暂停被管理的线程所需的时间。
· % Time paused for Garbage Collection
暂停垃圾收集的时间这是 "GC中暂停时间的%"指标。
· % CPU Time spent Garbage Collecting
花在垃圾收集上的CPU时间%这是 "GC中的CPU时间%"指标。它是NaN%,除非你收集CPU样本。
· Max GC Heap Size
在本次跟踪过程中,该进程的最大托管堆尺寸。
· 其余的都是链接,我们将在本文件中介绍其中一些。
所有 All GC stats
表 中显示了在跟踪收集过程中发生的每一个GC(如果有太多的话,它会有一个链接到没有显示的GC)。在这个表中有很多列。由于这是一个非常广泛的表格,我将只显示这个表格中与每个主题有关的列。
其他顶级的GC指标,个别暂停和堆大小,作为这个表格的一部分被显示出来,就像这样(Peak MB指的是该GC进入时的GC堆大小,After是退出时的大小)。
GC | Pause | Peak | After |
---|---|---|---|
Index | MSec | MB | MB |
804 | 5.743 | 1,796.84 | 1,750.63 |
805 | 6.984 | 1,798.19 | 1,742.18 |
806 | 5.557 | 1,794.52 | 1,736.69 |
807 | 6.748 | 1,798.73 | 1,707.85 |
808 | 5.437 | 1,798.42 | 1,762.68 |
809 | 7.109 | 1,804.95 | 1,736.88 |
现在,这是一个html表格,你不能进行排序,所以如果你确实想进行排序(例如,找出最长的单个GC停顿),你可以点击每个过程开始时的 "在Excel中查看 "链接 --
· Individual GC Events
o View in Excel
这将在Excel中打开上面的表格,所以你可以对你喜欢的任何一列进行排序。在GC团队中,由于我们需要对数据进行更多的切分,我们有自己的性能基础设施 ,直接使用TraceEvent。
PerfView中的其他相关视图
除了GCStats视图之外,介绍PerfView中的其他几个视图也很有用,因为我们会用到它们。
CPU Stacks是你所期望的--如果你在追踪中收集了CPU的样本事件,这个视图就会亮起来。有一点值得一提的是,我总是先清除3个高亮的文本框--在你这样做之后,你需要点击更新来刷新。
我很少发现这3个文本框有用。偶尔我会想按模块分组,你可以阅读PerfView的帮助文档,看看如何做到这一点。
Events就是我们上面提到的 - 原始事件视图。由于是原始的,它可能听起来很不友好,但它有一些功能,使它相当方便。你可以通过 "过滤器 "文本框过滤事件。如果你需要用多个字符串进行过滤,你可以使用"|"。如果我想获得所有名称中带有file的事件和GC/Start事件,我使用file|GC/Start(没有空格)。
双击一个事件的名称会显示该事件的所有发生情况,你可以搜索具体细节。例如,如果我想找出coreclr.dll的确切版本,我可以在查找文本框中输入coreclr。
然后你可以看到你正在使用的coreclr.dll的确切版本信息。
我还经常使用 "开始/结束 "来限制事件的时间范围,使用 "进程过滤器 "将其限制在某个进程中,使用 "显示列 "来限制要显示的事件字段(这使我能够对事件的某个字段进行排序)。
内存组下的GC Heap Alloc Ignore Free是我们要用来查看分配的东西。
Any Stacks显示所有的事件和它们的堆栈(如果堆栈被收集)。如果我想看一个特定的事件,但还没有一个既定的视图,或者既定的视图没有提供我所要的东西,我觉得这很方便。
对比堆栈视图
像CPU堆栈视图一样的视图(即堆快照视图或GC堆分配视图)提供了一个Diff-ing功能。如果你在同一个PerfView实例中打开2个跟踪,当你为每个跟踪打开一个堆栈视图时,并且Diff菜单提供了一个 "with Baseline "选项(在Help/Users Guide中搜索 "Diffing Two Traces")。
关于对比2个运行的问题--当你对比2个运行以查看什么是退步时,最好是让工作负载尽可能的相似。比方说,你做了一个改变以减少分配,与其在相同的时间内运行2个运行,不如用相同数量的请求来运行它们,因为你知道你的新构建正在处理相同数量的工作。否则,一个运行可能运行得更快,因此处理更多的请求,这本来就意味着它已经需要做不同数量的分配。这只是意味着它更难做比较。
GC暂停时间百分比高
如果你不知道如何收集GC暂停时间数据,请按照"如何收集顶级GC指标"中的说明进行。
如果总的暂停时间很高,可能是由于GC太多(即GC触发太频繁),GC暂停时间太长或两者都有。
太多的停顿,即太多的GC
根据我们的一条规则的一部分,触发GC的频率是由不存活的东西决定的。因此,如果你正在做大量的临时对象分配(意味着它们不能存活),这意味着你将触发大量的GC。在这种情况下,看一下这些分配是有意义的。如果你能消除一些,那就太好了,但有时这并不实际。我们提到了3种方法来剖析分配。让我们看看如何进行每个分析。
测量分配
获得分配的字节数
我们已经知道,你可以在摘要中得到分配字节的总数。在GCStats视图中,你还可以得到每个GC的分配字节数gen0。
GC Index(GC编号) | Gen (代) | Gen0 Alloc MB (Gen分配数量(MB)) |
---|---|---|
7 | 0N | 95.373 |
8 | 1N | 71.103 |
9 | 0N | 103.02 |
10 | 2B | 0 |
11 | 0N | 111.28 |
12 | 1N | 94.537 |
在Gen一栏中,'N'表示Nonconcurrent GC,'B'表示Background。所以完全阻塞的GC显示为2N。因为只有gen2的GC可以是后台,所以你只能看到2B,而不是0B或1B。你也可能看到'F',这意味着前景GC--当BGC正在进行时发生的短暂GC。
注意,对于2B来说是0,因为如果gen0或gen1的分配预算被超过,我们会在BGC的开始做一个gen0或gen1的GC,所以gen0分配的字节数会显示在gen0或gen1的GC上。
我们知道,当gen0的分配预算被超过时,就会触发GC。这个数据在GCStats中默认是不显示的(只是因为表格中已经有很多列了)。但你可以通过点击GCStats中表格前的Raw Data XML file(用于调试)链接来获得。一个xml文件将被生成并包含更详细的数据。对于每个GC,你会看到这个(我把它修剪了一下,所以不会太宽)-
<GlobalHeapHistory FinalYoungestDesired="9,830,400" NumHeaps="12"/>
FinalYoungestDesired是为这个GC计算的最终gen0预算。由于我们对所有堆的预算进行了均衡,这意味着每个堆都有这个相同的预算。由于有12个堆,任何一个堆用完它的gen0预算都会导致下一次GC被触发。所以在这种情况下,这意味着最多只有12*9,830,400=117MB的分配,直到下一次GC被触发。我们可以看到下一个GC是一个BGC,它的Gen0 Alloc MB是0,因为我们把这个BGC开始时做的短暂GC归结为GC#11,它在GC#9结束后在Gen0中分配了111.28 MB。
查看带有堆栈信息的采样分配
当然,你会想知道这些分配的情况。GC提供了一个叫做AllocationTick的事件,大约每100KB的分配就会被触发。对于小对象堆来说,100KB意味着很多对象(这意味着对于SOH来说是采样),但对于LOH来说,这实际上是准确的,因为每个对象至少有85000字节大。这个事件有一个叫做AllocationKind的字段--small意味着它是由分配为SOH而触发的,而这个分配恰好使该SOH上的累积分配量超过了100KB(那么这个量会被重置)。所以你实际上不知道最后的分配量是多大。但是根据这个事件的频率,看看哪些类型被分配的最多,以及分配它们的调用栈,仍然是一个非常好的近似值。
很明显,与只收集GCCollectOnly跟踪相比,收集这个会增加明显的开销,但这仍然是可以容忍的。
PerfView.exe /nogui /accepteula /KernelEvents=Process+Thread+ImageLoad /ClrEvents:GC+Stack /BufferSize:3000 /CircularMB:3000 collect
这将收集AllocationTick事件及其分配被采样对象的调用栈。然后你可以在内存组下的 "GC Heap Alloc Ignore Free (Coarse Sampling) "视图中打开它。
点击一个类型,你就可以看到分配该类型实例的堆栈。
注意,当你在同一个PerfView实例中打开两个追踪时,你可以比较两个GC对的分配视图
而且你可以双击每一种类型来查看分配给它们的调用栈。
查看AllocationTick事件的另一种方法是使用Any Stacks视图,因为它按大小分组。例如,这是我从一个客户的跟踪中看到的情况(类型名称已匿名或缩短)。
Name | Inc |
---|---|
Event Microsoft-Windows-DotNETRuntime/GC/AllocationTick | 627,509 |
+ EventData TypeName Entry[CustomerType,CustomerCollection][] | 221,581 |
|+ EventData Size 106496 | 4,172 |
||+ EventData Kind Small | 4,172 |
|| + coreclr | 4,172 |
|| + corelib!System.Collections.Generic.Dictionary`2[CustomerType,System.__Canon].Resize(int32,bool) | 4,013 |
|| + corelib!System.Collections.Generic.Dictionary`2[CustomerType,System.__Canon].Initialize(int32) | 159 |
|+ EventData Size 114688 | 3,852 |
||+ EventData Kind Small | 3,852 |
|| + coreclr | 3,852 |
|| + corelib!System.Collections.Generic.Dictionary`2[CustomerType,System.__Canon].Resize(int32,bool) | 3,742 |
|| + corelib!System.Collections.Generic.Dictionary`2[CustomerType,System.__Canon].Initialize(int32) | 110 |
这说明大部分分配来自于字典的重新调整,你也可以从 GC Heap Alloc 视图中看到,但样本计数信息给了你更多的线索(Resize 有 4013 次,而 Initialize 有 159 次)。所以,如果你能更好地预测字典会有多大,你可以把初始容量设置得更大,以大大减少这些分配。
**使用 CPU 样本查看内存清理成本 **
如果你没有这些AllocationTick事件的跟踪,但有CPU样本(这很常见),你也可以看一下内存清除的成本-
如果你看一下memset_repmovs的调用者,突出显示的2个调用者来自GC在把新对象递出之前的内存清除:
(这是在.NET 5下,如果你有旧版本,你会看到WKS::gc_heap::bgc_loh_alloc_clr而不是WKS::gc_heap::bgc_uoh_alloc_clr)。
在我的例子中,因为分配几乎是测试的全部内容,所以分配成本非常高--占CPU总使用量的25.6%
理解为什么GC决定收集这一代
在GCStats中,每个GC都有一列叫做 "Trigger Reason"。这告诉你这个GC是如何被触发的。可能的触发原因在PerfView repo的ClrTraceEventParser.cs中定义为GCReason
。
public enum GCReason
{AllocSmall = 0x0,Induced = 0x1,LowMemory = 0x2,Empty = 0x3,AllocLarge = 0x4,OutOfSpaceSOH = 0x5,OutOfSpaceLOH = 0x6,InducedNotForced = 0x7,Internal = 0x8,InducedLowMemory = 0x9,InducedCompacting = 0xa,LowMemoryHost = 0xb,PMFullGC = 0xc,LowMemoryHostBlocking = 0xd
}
在这些原因中,最常见的是AllocSmall - 这是说gen0的预算被超过了。如果你看到的最常见的是AllocLarge,那么它很可能表明了一个问题--它是说你的GC被触发了,因为你分配了大的对象,超过了LOH预算。正如我们所知,这意味着它将触发gen2 GC
而我们知道,触发频繁的完全GC通常是性能问题的秘诀。其他由于分配引起的触发原因是OutOfSpaceSOH和OutOfSpaceLOH - 你看到这些比AllocSmall和AllocLarge要少得多--这些是为你接近物理空间极限时准备的(例如,如果我们内存分配正在接近短暂段的终点)。
那些几乎总是引起危险信号的事件是 "Induced",因为它们意味着一些代码实际上是自己触发了GC。我们有一个GCTriggered事件,专门用于发现什么代码用其调用栈触发了GC。你可以用堆栈和最小的内核事件收集一个非常轻量级的GC信息级别的跟踪:
PerfView.exe /nogui /accepteula /KernelEvents=Process+Thread+ImageLoad /ClrEvents:GC+Stack /ClrEventLevel=Informational /BufferSize:3000 /CircularMB:3000 collect
然后在任意堆栈视图中查看GCTriggered事件的堆栈:
因此,"触发原因 "是指GC是如何开始或产生的。如果一个GC开始的最常见原因是由于在SOH上分配,那么这个GC将作为一个gen0的GC开始(因为gen0的预算被超过了)。现在在GC开始之后,我们再决定我们实际上会收集哪一代。它可能保持为0代GC,或者升级为1代甚至2代GC-这是我们在GC中最先决定的事情之一。导致我们升级到更高世代的GC的因素就是我们所说的 "派遣的原因"(所以对于一个GC来说,只有一个触发的原因,但可以有多个派遣的原因)。
以下是出现在表格本身之前的 "GC的派遣理由 "部分的解释文本
本表更详细地说明了GC决定收集那一代的确切原因。将鼠标悬停在各列标题上,以获得更多信息。
我不会在这里重复这些信息。最有趣的是那些升级到gen2的GC - 通常这些是由gen2的高内存负载或高碎片引起的。
个别的长时间停顿
如果你不知道如何收集GC暂停时间数据,请按照"如何收集顶级GC指标"中的说明进行。
如果你不熟悉是什么导致了单个GC暂停,请先阅读GC暂停部分,它解释了哪些因素导致了GC暂停时间。
我们知道,所有短暂的GCs都是阻塞的,而gen2 GC可以是阻塞的,也可以是背景的(BGC)。短暂的GC和BGC应该会产生短暂的停顿。但是事情可能出错,我们将展示如何分析这些情况。
如果堆的大小很大,我们知道一个阻塞的gen2 GC会产生一个很长的停顿。但是当我们需要做gen2 GC的时候,我们一般倾向于BGC。所以长的GC暂停是由于阻塞的gen2 GC造成的,我们会想弄清楚为什么我们要做这些阻塞的gen2 GC。
如此长时间的个别停顿可能是由以下因素或它们的组合造成的—
· 在暂停期间有很多GC工作要做。
· GC正在尝试执行工作,但无法执行,因为CPU被占用
让我们看看如何分析每个场景。
首先,您是否存在托管内存泄漏?
如果你不知道什么是管理型内存泄露,请先回顾一下那一节。根据定义,这不是GC能帮你解决的问题。如果你有一个托管的内存泄漏,保证GC会有越来越多的工作要做。
在生产中触发完全阻塞的GC可能是非常不可取的,所以在开发阶段做尽职调查很重要。例如,如果你的产品处理请求,你可以在每个请求或每N个请求结束时触发一个完全阻塞的GC。如果你认为内存使用量应该是相同的,你应该能够用工具来验证。在这种情况下可以使用很多工具,因为这是一个简单的场景。所以PerfView当然也有这个功能。你可以通过Memory/Take Heap Snapshot来获取堆快照。它确实有一些不完全直截了当的选项-
"Max Dump K Objs "是一个 "聪明 "的尝试,所以它不会转储每一个对象。我总是把它增加到至少是默认值(250)的10倍。冻结选项是为生产诊断而设的,当你不想招致完全阻塞的GC暂停时。但是,如果你在开发过程中这样做,并试图了解你的应用程序的基本行为,你没有理由不检查它,这样你就能得到一个准确的图片,而不是用非Freeze选项试图 "尽最大努力来跟踪对象图"。
然后在PerfView中打开生成的.gcDump文件,它有一个类似堆栈的视图,显示根信息(例如,GC句柄持有这个对象)和转储中的类型实例的聚合信息。由于这是一个类似于堆栈的视图,它提供了差分功能,所以你可以在PerfView中取两个gcDump文件并进行差分。
当你在生产中这样做时,你可以先尝试不使用冻结。
长时间的停顿是由于短暂的GCs、完全阻塞的GCs还是BGCs?
GCStats视图在每个进程的顶部都有一个方便的滚动表,显示了各代的最大/平均/总停顿时间(我确实应该把全阻塞的GC和BGC分开,但现在你可以参考Gen2的表格)。一个例子。
计算出gen2 GC的工作量
对于gen2 GCs,我们希望看到大部分或所有的GC都以BGC的形式完成。但是如果你看到一个完全阻塞的GC(在GCStats中表示为2N),如果你的堆很大的话,它的暂停时间有可能很长(你会看到gen2 GCs有非常高的升代内存数量 MB,与短暂的GC相比)。通常人们在这个时候要做的是进行堆快照,看看堆上有什么东西,并尝试减少这些东西。然而,首先要弄清楚的是,为什么你首先要做完全阻塞的GCs。你可以查看Condemned Reasons表来了解这个问题。最常见的原因是高内存负载和gen2碎片化。要想知道每个GC观察到的内存负载,点击GCStats中 "GC Rollup By Generation "表格上方的 "Raw Data XML file (for debugging) "链接,它将生成一个xml文件,其中包括内存负载等额外信息。一个例子是(我剪掉了大部分的信息)-
<GCEvent GCNumber= "45" GCGeneration="2" ><GlobalHeapHistory FinalYoungestDesired="69,835,328" NumHeaps="32"/><PerHeapHistories Count="32" MemoryLoad="47"></PerHeapHistory></GCEvent>
这说明当GC#45发生时,它观察到的内存负载为47%。
由于bug导致的长时间停顿
通常BGC的停顿都很小。唯一的一次是由于运行时的一个罕见的bug(例如,我们修复了一个bug,即模块迭代器占用了一个锁,当过程中有很多很多模块时,这种锁的争夺意味着每个GC线程需要很长时间来迭代这些模块),或者你正在做一些只在BGC的STW标记部分做的工作。由于这可能是由于非GC工作造成的,我们将在 "弄清长时间的GC是否是由于GC工作 "中讨论如何诊断这个问题。
计算出短暂GC的工作量
GC的工作量大致与幸存者成正比,这由 "Promoted Bytes "指标表示,该指标是GCStats表格中的一列 -
这是有道理的--gen1 GCs比gen0 GCs升代的对象更多,因此它们需要更长的时间。而且它们不会进行太多升代,因为它们只收集(通常是一小部分)堆。
如果你看到短暂的GCs突然增加了很多,那么估计暂停时间会长很多。我所看到的一个原因是,它进入了一个不经常被调用的代码路径,对象存活下来,而这些对象是不应该存活的。不幸的是,我们用于找出导致短暂对象存活的原因的工具不是很好--我们已经在.NET 5中添加了运行时支持,你可以使用PerfView中一个特殊的视图,称为 "Generational Aware "视图,以查看哪些老年代对象导致年轻代对象存活--我将很快写出更多细节。你将看到的是这样的情况:
我不知道有什么其他工具可以方便地告诉你这些信息(如果你知道有什么工具可以告诉你老一代的对象在年轻一代的对象上保持着什么,使它们在GC期间存活,请好心地告诉我!)。
请注意,如果你在gen2/LOH中有一个对象持有年轻gen对象的引用,如果你不再需要它们引用那些对象,你需要手动将这些引用字段设置为null。否则,它们将继续持有那些对象的引用,并导致它们被升代。对于C#程序来说,这是导致短暂对象存活的一个主要原因(对于F#程序来说,就不是这样了)。你可以从GCStats视图生成的Raw XML中看到这一点(点击 "Raw Data XML file (for debugging) "链接,就在 "GC Rollup By Generation "表的上方),我把大部分属性从xml中修剪掉了 -
<GCEvent GCNumber="9" GCGeneration="0"><PerHeapHistories Count="12" MemoryLoad="20"><PerHeapHistory MarkStack="0.145(10430)" MarkFQ="0.001(0)" MarkHandles="0.005(296)" MarkOldGen="2.373(755538)"><PerHeapHistory MarkStack="0.175(14492)" MarkFQ="0.001(0)" MarkHandles="0.003(72)" MarkOldGen="2.335(518580)">
每个GC线程由于各种根而升代的字节数是PerHeapHistory数据的一部分-MarkStack/FQ/Handles分别是标记堆栈变量、终结队列和GC句柄,MarkOldGen表示由于来自老一代的引用而升代的字节数量。因此,举例来说,如果你正在做一个gen1的GC,这就是gen2对象对gen0/gen1对象的持有数量,以使其存活。我们在.NET 5中对服务器GC所做的一个改进是,当我们标记OldGen根时,平衡GC线程的工作,因为这通常会导致最大的升代数量。因此,如果你在你的应用程序中看到这个数字非常不平衡,升级到.NET 5会有帮助。
弄清楚长的GC是否是由于GC工作造成的
如果一个GC很长,但却不符合上述任何一种情况,也就是说,没有很多工作需要GC去做,但还是会造成长时间的停顿,这意味着我们需要弄清楚为什么GC在它想做工作的时候却没有做到。而通常当这种情况发生时,它似乎是随机发生的。
偶尔长停的一个例子 -
我们在PerfView中做了一个非常方便的功能,叫做停止触发器,意思是 "当观察到某些条件满足时,尽快停止跟踪,这样我们就能捕捉到最相关的最后部分"。它已经有一些专门用于GC目的的内置停止触发器。
GC事件发生的顺序
为了了解它们是如何工作的,我们首先需要简要地看一下GC的事件序列。这里有6个相关的事件-
Microsoft-Windows-DotNETRuntime/GC/SuspendEEStart //开始暂停托管线程运行
Microsoft-Windows-DotNETRuntime/GC/SuspendEEStop //暂停托管线程完成
Microsoft-Windows-DotNETRuntime/GC/Start // GC开始回收
Microsoft-Windows-DotNETRuntime/GC/Stop // GC回收结束
Microsoft-Windows-DotNETRuntime/GC/RestartEEStart //恢复之前暂停的托管线程
Microsoft-Windows-DotNETRuntime/GC/RestartEEStop //恢复托管线程运行完成
(你可以在事件视图中看到这些内容)
在一个典型的阻塞式GC中(这意味着所有短暂的GC和完全阻塞的GC),事件发生顺序非常简单:
GC/SuspendEEStart
GC/SuspendEEEnd <– 暂停托管线程完成
GC/Start
GC/End <– actual GC is done
GC/RestartEEStart
GC/RestartEEEnd <– 恢复托管线程运行完成
GC/SuspendEEStart和GC/SuspendEEEnd是用于暂停;GC/RestartStart和GC/RestartEEEnd是用于恢复。恢复只需要很少的时间,所以我们不需要讨论它。暂停是可能需要很长时间的。
BGC要复杂得多,一个完整的BGC事件序列看起来是这样的
1) GC/SuspendEEStart
2) GC/SuspendEEStop
3) GC/Start <– BGC/ starts<- there might be an ephemeral GC happen here, if so you'd see(这里可能有一个短暂的GC发生,如果是这样,你会看到)
GC/Start
GC/Stop4) GC/RestartEEStart
5) GC/RestartEEStop <– done with the initial suspension (完成了最初的暂停)<- there might be 0 or more foreground ephemeral GC/s here, an example would be (这里可能有0个或更多的前台瞬时的GC/s,一个例子是)
GC/SuspendEEStart
GC/SuspendEEStop
GC/Start
GC/Stop
GC/RestartEEStart
GC/RestartEEStop6) GC/SuspendEEStart
7) GC/SuspendEEStop
8) GC/RestartEEStart
9) GC/RestartEEStop <– done with BGC/'s 2nd suspension (完成了BGC/的第二次停牌)<- there might be 0 or more foreground ephemeral GC/s here (这里可能有0个或更多的前台短暂GC/s)10) GC/Stop <– BGC/ Stops
所以BGC在它的中间有两对暂停/重启。目前在GCStats视图中,我们实际上是将这两个暂停合并在一起(我正计划将它们分开),但如果你确实看到一个长的BGC暂停,你总是可以使用事件视图来找出哪个暂停是长的。在下面的例子中,我从事件视图中复制并粘贴了一个客户跟踪的事件序列,它遇到了我提到的bug导致长时间暂停。
Event Name | Time MSec | Reason | Count | Depth | Type | explanation |
---|---|---|---|---|---|---|
GC/Start | 160,551.74 | AllocSmall | 188 | 2 | BackgroundGC | |
GC/Start | 160,551.89 | AllocSmall | 189 | 0 | NonConcurrentGC | We are doing a gen0 at the beginning of this BGC(这个BGC开始时正在做一个gen0) |
GC/Stop | 160,577.48 | 189 | 0 | |||
GC/RestartEEStart | 160,799.87 | There's a long period of time here between last event and this one due to the bug(由于错误,在上次活动和这次活动之间,这里有一段很长的时间) | ||||
GC/RestartEEStop | 160,799.91 | |||||
GC/SuspendEEStart | 161,803.36 | SuspendForGC | 188 | A Foreground gen1 happens(前台gen1发生) | ||
GC/SuspendEEStop | 161,803.42 | |||||
GC/Start | 161,803.61 | AllocSmall | 190 | 1 | ForegroundGC | |
GC/Stop | 161,847.14 | 190 | 1 | |||
GC/RestartEEStart | 161,847.15 | |||||
GC/RestartEEStop | 161,847.23 | The Foreground gen1 ends(前台gen1结束) | ||||
GC/SuspendEEStart | 161,988.57 | SuspendForGCPrep | 188 | BGC's 2nd suspension starts with SuspendForGCPrep as its reason(BGC的第二次暂停开始,理由是SuspendForGCPrep) | ||
GC/SuspendEEStop | 161,988.71 | |||||
GC/RestartEEStart | 162,239.84 | |||||
GC/RestartEEStop | 162,239.94 | BGC's 2nd suspension ends, another long pause due to the same bug(BGC的第二次停顿结束,由于同样的错误,又一次长时间停顿) | ||||
GC/Stop | 162,413.70 | 188 | 2 |
我所做的是在CPU堆栈视图中查找那些长时间停顿的时间范围(160,577.482-160,799.868和161,988.57-162,239.94),发现了这个错误。
PerfView GC停止触发器
有3个GC特定的停止触发器 -
Trigger name | What it measures |
---|---|
StopOnGCOverMsec | trigger if the time between GC/Start and GC/Stop is over this value, and it's not a BGC(如果GC/Start和GC/Stop之间的时间超过这个值,并且不是BGC,则触发。) |
StopOnGCSuspendOverMSec | trigger if the time between GC/SuspendEEStart and GC/SuspendEEStop is over this value(如果GC/SuspendEEStart和GC/SuspendEEStop之间的时间超过这个值,则触发。) |
StopOnBGCFinalPauseOverMSec | trigger if the time between GC/SuspendEEStart (with Reason SuspendForGCPrep) and GC/RestartEEStop is over this value(如果GC/SuspendEEStart(与原因是 SuspendForGCPrep)和GC/RestartEEStop之间的时间超过这个值,则触发。) |
我通常与/StopOnGCOverMSec和/StopOnBGCFinalPauseOverMSec一起使用的命令行是 --
PerfView.exe /nogui /accepteula /StopOnGCOverMSec:15 /Process:A /DelayAfterTriggerSec:0 /CollectMultiple:3 /KernelEvents=default /ClrEvents:GC+Stack /BufferSize:3000 /CircularMB:3000 /Merge:TRUE /ZIP:True collect
如果你的进程被称为A.exe,你会想指定/Process:A。我在这篇博客中对每个参数都有详细解释。
调试一个随机的长GC
这里有一个例子,演示了如何调试一个突然比其他升代了类似数量的GC要长很多的GC。我用上面的命令行收集了一个跟踪,我可以在GCStats中看到有一个GC比15个长 - 它是GC#4022,是20.963ms,而且它没有比正常情况下更多的升代(你可以看到在它上面的gen0,升代的数量非常相似,但花的时间却少很多)。
所以我在CPU堆栈视图中输入GC#4022的开始和结束时间戳(30633.741到30654.704),我看到对于执行实际GC工作的coreclr!SVR::gc_heap::gc_thread_function,有两部分没有CPU占用,而应该有很多--____ 部分意味着没有CPU占用。
此,我们可以在CPU堆栈视图中突出显示第一个平面部分,右击它并选择 "设置时间范围"。这将向我们显示这个进程在这段时间内的CPU样本,当然我们将看到没有。
我们看到mpengine模块,它来自MsMpEng.exe进程(双击mpengine单元会告诉你它属于哪个进程)。要确认这个进程干扰了我们的进程,就是在Events中输入开始和结束的时间戳,然后看一下原始的CPU样本事件(如果你不知道如何使用这个视图,请看PerfView中的其他相关视图部分) -
你可以看到MsMpEng.exe进程的样本的优先级非常高--15。服务器GC线程运行的优先级是11左右。
为了调试长时间的暂停,我通常采取ThreadTime跟踪,其中包括ContextSwitch和ReadyThread事件--它们是大量的,但应该准确地告诉我们GC线程在调用SuspendEE时正在等待什么-
PerfView.exe /nogui /accepteula /StopOnGCSuspendOverMSec:200 /Process:A /DelayAfterTriggerSec:0 /CollectMultiple:3 /KernelEvents=ThreadTime /ClrEvents:GC+Stack /BufferSize:3000 /CircularMB:3000 /Merge:TRUE /ZIP:True collect
然而,ThreadTime追踪可能太多,可能会导致你的应用程序运行得不够 "正常",无法表现出你所调试的行为。在这种情况下,我会从默认的内核事件开始追踪,这通常会揭示问题或给你足够的线索。你可以简单地把ThreadTime替换成Default -
PerfView.exe /nogui /accepteula /StopOnGCSuspendOverMSec:200 /Process:A /DelayAfterTriggerSec:0 /CollectMultiple:3 /KernelEvents=Default /ClrEvents:GC+Stack /BufferSize:3000 /CircularMB:3000 /Merge:TRUE /ZIP:True collect
我在这篇博客中有一个详细的调试长悬挂问题的例子。
大尺寸的GC堆
如果你不知道如何收集GC堆大小数据,请按照"如何收集顶级GC指标"中的说明进行操作。
调试OOM
在我们谈论大的GC堆大小作为一个一般的问题类别之前,我想特别提到调试OOM,因为这是一个大多数读者都熟悉的例外。而且有些人可能已经使用了SoS !"AnalyzeOOM "命令,它可以显示2个方面--1)是否确实存在一个托管堆的OOM。因为GC堆只是你进程中的一种内存使用,OOM不一定是GC堆造成的;2)如果是托管堆OOM,什么操作造成的,例如,GC试图保留一个新段,但做不到(你在64位上永远不会真正看到这个)或在试图做分配时无法提交。
在不使用SoS的情况下,你也可以通过简单地查看GC堆使用多少内存与进程使用多少内存来验证GC堆是否是OOM的罪魁祸首。我们将在下面讨论堆大小的分析。如果你确认GC堆占用了大部分的内存,而且我们知道OOM只是在GC非常努力地减少堆的大小,但是不能之后才被抛出,这意味着有大量的内存由于用户根而幸存下来,这意味着GC无法回收它。而你可以按照管理性内存泄露调查来弄清楚哪些内存幸存下来。
现在让我们来谈谈这样的情况:你没有得到OOM,但需要看一下堆的大小,看看是否可以或如何优化它。在"如何正确看待GC堆的大小 "一节中,我们谈到了堆的大小以及如何广泛地测量。所以我们知道,堆的大小在很大程度上取决于你在GC发生时的测量和分配预算。GCStats视图显示了GC进入和退出时的大小,即峰值和后值。
剖析一下这些尺寸是有帮助的。After MB这一栏是以下的总和
Gen0 MB + Gen1 MB + Gen2 MB + LOH MB
还注意到Gen0 Frag %说的是99.99%。我们知道这是由于pinning。因此,部分gen0分配将适合于这种碎片化。所以对于GC #2来说,在GC #1结束时以26.497 MB开始,然后分配了101.04 MB,在GC #2开始时以108.659 MB的大小结束。
峰值尺寸太大,但GC后尺寸不大?
如果是这种情况,这通常意味着在触发下一次GC之前有太多的gen0分配。在.NET Core 3.0中,我们启用了一个限制这种情况的配置,叫做GCGen0MaxBudget--我通常不建议人们设置这个配置,因为你可能会把它设置得太小,从而导致GC过于频繁。这是为了限制gen0的最大分配预算。当你使用Server GC时,GC在设置gen0预算时相当积极,因为它认为进程使用机器上的大量资源是可以的。这通常是可以的,因为如果一个进程使用了服务器GC,往往意味着它可以负担得起使用大量的资源。但是,如果你确实有这样的情况,你对更频繁地触发GC以交换到更小的堆大小没有意见,你可以用这种配置来做。我的希望是,在未来,我们将使这成为一些高级配置的一部分,允许你向我们传达你想做这种交换,这样GC就可以为你自动调整,而不是你自己使用一个非常低级的配置。
在过去使用GC性能计数器的人认识到 "#Total Committed Bytes "计数器,他们问如何在.NET Core中获得这个计数器。首先,如果你用这种方式测量已提交的字节,你可能会看到它更接近峰值大小,而不是之后的大小,这是因为在短暂段上对已提交的特殊处理。因为 "After size "没有报告我们将要使用但还没有使用的gen0预算部分。所以你可以直接使用GCStats中报告的峰值大小作为你的近似的总投入。但如果你使用的是.NET 5,你可以通过调用我们前面提到的GetGCMemoryInfoAPI得到这个数字--它是GCMemoryInfo结构上返回的属性之一。
有一个不太方便的方法,就是每堆历史事件中的ExtraGen0Commit字段。你可以在你已经得到的堆大小信息(即在GCHeapStats事件中)的基础上添加这个字段(如果你使用的是服务器GC,它将是所有堆的ExtraGen0Commit之和)。但是我们没有在PerfView的用户界面中公开这一点,所以你需要自己去使用TraceEvent库来获得这些信息。
GC后尺寸很大?
如果是这样,大部分的尺寸是在gen2/LOH中吗?你是否主要在做后台GC(不压缩)?如果你已经在做完全阻塞的GC,而After的大小还是太大,这仅仅意味着你有太多的数据存活下来。你可以按照管理性内存泄露调查来弄清楚存活的数据。
另一种可能的情况是有很大比例的堆在gen0中,但大部分是碎片。这种情况会发生,特别是当你把一些对象钉住了很久,而且它们在堆上足够分散时。所以即使GC已经降代它们到了gen0,只要这些引脚没有消失,堆的那一部分仍然不能被回收。你可以收集GCHandle事件来计算它们何时被钉住。PerfView的命令行是
perfview /nogui /KernelEvents=Process+Thread+ImageLoad /ClrEvents:GC+Stack+GCHandle /clrEventLevel=Informational collect
gen2 GC是否主要为后台GC?
如果GC主要是后台GC,那么需要看看碎片的使用是否高效,也就是说,当后台GC开始时,gen2 Frag %是否非常大?如果不是非常大,这意味着它的工作是最优化的。否则这表明后台GC的调度问题--请让我知道。要看后台GC开始时的碎片情况,你可以使用GCStats视图中的Raw XML链接来查看它。我已经把数据修剪成只有相关的部分-
<GCEvent GCNumber= "1174" GCGeneration="2" Type= "BackgroundGC" Reason= "AllocSmall"><PerHeapHistory><GenData Name="Gen2" SizeBefore="187,338,680" SizeAfter="187,338,680" ObjSpaceBefore="177,064,416" FreeListSpaceBefore="10,200,120" FreeObjSpaceBefore="74,144"/><GenData Name="GenLargeObj" SizeBefore="134,424,656" SizeAfter="131,069,928" ObjSpaceBefore="132,977,592" FreeListSpaceBefore="1,435,640" FreeObjSpaceBefore="11,424"/>
SizeBefore = ObjSpaceBefore + FreeListSpaceBefore + FreeObjSpaceBefore
SizeBefore` 这一代的总规模
ObjSpaceBefore
这一代的有效对象所占的大小
FreeListSpaceBefore
这一代的自由列表所占的大小
FreeObjSpaceBefore
在这一代中,太小的自由对象所占用的大小,不能进入自由列表。
(FreeListSpaceBefore + FreeObjSpaceBefore) 就是我们所说的碎片化
在这种情况下,我们看到((FreeListSpaceBefore + FreeObjSpaceBefore)/ SizeBefore)是5%,这是相当小的,这意味着我们已经用掉了大部分BGC建立好的自由空间。当然,我们希望看到这个比例越小越好,但如果自由空间太小,就意味着GC可能无法使用它们。一般来说,如果这个比例是15%或更小,我不会担心,除非我们看到自由空间足够大但没有被使用。
你也可以从我们前面提到的GetGCMemoryInfoAPI中获得这些数据。
你看到的堆的大小从GC的角度来看是合理的,但仍然希望有一个更小的堆?
在你经历了上述情况后,你可能会发现,从GC的角度来看,这一切都可以解释。但如果你仍然希望堆的大小更小呢?
你可以把你的进程放在一个内存受限的环境中,也就是说,一个有内存限制的容器,这意味着GC会自动识别为它可以使用的内存。然而,如果你使用的是Server GC,你至少要升级到.NET Core 3.0,它对容器的支持更加强大。在该版本中,我们还添加了2个新的配置,允许你指定GC堆的内存限制 - GCHeapHardLimit和GCHeapHardLimitPercent。它们在本博客文章中得到了解释。
当你的进程运行在有其他进程的机器上时,GC开始反应的默认内存负载可能不是每个进程都想要的。你可以考虑使用GCHighMemPercent配置,并将该阈值设置得更低--这将使GC更积极地进行完全阻塞的GC,所以即使有内存可用,它也不会使你的堆增长得那么多。
GC是否为自己的记账工作使用了太多的内存?
偶尔我也收到一些人的报告,他们确实观察到有一大块内存被用于GC记账。你可以通过GC的gc_heap::grow_brick_card_tables
的VirtualAlloc调用看到。这是因为,由于在地址空间中保留了一些意想不到的区域,堆的范围被拉得太长了。如果你确实遇到了这个问题,并且无法防止意外的保留,你可以考虑用GCHeapHardLimit/GCHeapHardLimitPercent指定一个内存限制,那么整个限制将被提前保留,这样你就不会遇到这个问题了。
性能问题的明确迹象
如果你看到以下任何情况,毫无疑问你有性能问题。与任何性能问题一样,正确确定优先次序总是很重要的。例如,你可能有很长的GC暂停,但如果它们不影响你所关心的性能指标,你把时间花在其他地方会更有成效。
我使用PerfView中的GCStats视图来显示这些症状。如果你不熟悉这个视图,请看本节。你不一定要使用PerfView,只要能够显示下面的数据,使用任何工具都可以。
暂停时间太长
暂停通常在每次发生时都会少于1ms。如果你看到的是10秒或100秒的东西,你不需要怀疑你是否有宁问题--这是一个明确的信号,说明你有。
如果你看到你的大部分GC暂停都被暂停占用了,尤其是持续的暂停,而且你的总GC暂停太多,你肯定应该调试它。我在这篇博客中有一个详细的调试长暂停问题的例子。
这可以通过GCStats视图中的 "Suspend Msec "和 "Pause Msec "列来表示。我模拟了一个例子 --
GC Index(索引) | Suspend Msec(暂停时间) | Pause Msec(停顿时间) |
---|---|---|
10 | 150 | 180 |
11 | 190 | 200 |
两个GCs的大部分停顿时间都是在暂停中度过的。
随机的长时间GC停顿
"随机长的GC停顿 "意味着你突然看到一个GC并没有比平时升代更多,但却需要更长的时间。下面是一个模拟的例子
GC Index(索引) | Suspend Msec(暂停时间) | Pause Msec(停顿时间) | Promoted MB(升代MB) |
---|---|---|---|
10 | 0.01 | 5 | 2.0 |
11 | 0.01 | 200 | 2.1 |
12 | 0.01 | 6 | 2.2 |
所有的GCs都升代了~2MB,但是GC#10和#12花了几毫秒,而GC#11花了200。这就说明在GC#11期间出了问题。有时你可能会看到突然花了很长时间的GC也招致了很长时间的暂停,因为导致长时间暂停的原因也影响了GC的工作。
我已经给出了一个例子上面如何调试这个问题。
大多数GC是完全阻塞的GC
如果你看到大多数GC是完全阻塞的,如果你有一个大的堆,这通常需要相当长的时间,这就是一个性能问题。我们不应该一直做完全阻塞的GCs,就是这样。即使你处于高内存负载的情况下,做完全阻塞GC的目的是为了减少堆的大小,这样内存负载就不再高了。而GC有办法在有挑战的情况下进行对抗,比如针对高内存负载的临时模式+沉重的固定,以避免做更多的完全阻塞GC,而不是必要。我见过的最常见的原因实际上是诱导的完全阻塞的GCs,这对调试来说是很容易的,因为GCStats会告诉你触发原因是诱导的。下面是一个模拟的例子
GC Index | Trigger Reason | Gen | Pause Msec |
---|---|---|---|
10 | Induced | 2NI | 1000 |
11 | Induced | 2NI | 1100 |
12 | Induced | 2NI | 1000 |
本节讲述了如何找出诱发GC的原因。
有助于我们帮助你调试性能问题的信息
在某些时候,在你遵循本文件中的建议并做了详尽的调查后,你仍然发现你的性能问题没有得到解决。我们很愿意帮助你! 为了节省你和我们的时间,我们建议你准备以下信息 -
运行时的文件版本
每个版本都会有新的GC变化,所以我们很自然地想知道你使用的是哪个版本的运行时,以便我们知道该版本的运行时有哪些GC变化。所以提供这些信息是非常必要的。版本如何映射到 "公共名称",如.NET 4.7,是不容易追踪的,所以提供dll的 "FileVersion "属性会对我们有很大帮助,它可以告诉我们版本号与分支名称(对于.NET Framework)或实际提交(对于.NET Core)。你可以通过像这样的powerhell命令来获得这些信息:
PS C:\Windows\Microsoft.NET\Framework64\v4.0.30319> (Get-Item C:\Windows\Microsoft.NET\Framework64\v4.0.30319\clr.dll).VersionInfo.FileVersion
4.8.4250.0 built by: NET48REL1LAST_CPS C:\> (Get-Item C:\temp\coreclr.dll).VersionInfo.FileVersion
42,42,42,42424 @Commit: a545d13cef55534995115eb5f761fd0cecf66fc1
获得这些信息的另一个方法是通过调试器通过lmvm命令(部分省略)-
0:000> lmvm coreclr
Browse full module list
start end module name
00007ff8`f1ec0000 00007ff8`f4397000 CoreCLR (deferred) Image path: C:\runtime-reg\artifacts\tests\coreclr\windows.x64.Debug\Tests\Core_Root\CoreCLR.dllImage name: CoreCLR.dllInformation from resource tables:FileVersion: 42,42,42,42424 @Commit: a545d13cef55534995115eb5f761fd0cecf66fc1
如果你捕捉到ETW跟踪,你也可以找到KernelTraceControl/ImageID/FileVersion
事件。它看起来像这样(部分省略)。
ThreadID="-1" ProcessorNumber="2" ImageSize="10,412,032" TimeDateStamp="1,565,068,727" BuildTime="8/5/2019 10:18:47 PM" OrigFileName="clr.dll" FileVersion="4.7.3468.0 built by: NET472REL1LAST_C"
你已经进行了哪些诊断
如果你已经按照本文件中的技术,自己做了一些诊断,这是强烈建议的,请与我们分享你做了什么,得出了什么结论。这不仅可以节省我们的工作,还可以告诉我们我们提供的信息对你的诊断有什么帮助或没有帮助,这样我们就可以对我们提供给客户的信息进行调整,使他们的生活更轻松。
性能数据
就像任何性能问题一样,在没有任何性能跟踪数据的情况下,我们真的只能给出一些一般性的指导和建议。要真正找出问题所在,我们需要性能跟踪数据。
正如本文档中多次提到的,性能跟踪是我们调试性能问题的主要方法,除非你已经进行了诊断,表明不需要顶级GC跟踪,否则我们总是要求你收集这样的跟踪来开始。我通常也会要求你提供带有CPU样本的追踪,特别是当我们要诊断长时间的GC暂停时。我们可能会要求你根据我们从最初的追踪中得到的线索,收集更多的追踪信息。
一般来说,转储不太适合调查性能问题。但是,我们了解有时可能无法获得跟踪,而您所拥有的只是转储(dump)。如果情况确实如此,请尽可能与我们分享(即,在没有隐私问题的情况下,因为dump可能会泄漏源码和内存数据)。