编写高性能的.NET代码

978-7-115-46191-9
作者: 【美】Ben Watson(沃森)
译者: 戴旭
编辑: 陈冀康

图书目录:

详情

本书旨在教会读者认识和使用CLR技术,并且编写出高性能的.NET代码。同时,书中还循序渐进地给出了详细的指导,通过丰富的示例和内容介绍,帮助读者更好地掌握.NET编码技巧。最终成功构建出一个成熟的高性能的.NET应用。

图书摘要

版权信息

书名:编写高性能的.NET代码

ISBN:978-7-115-46191-9

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [美]Ben Watson

    译    戴 旭

    责任编辑 陈冀康

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

    邮编 100164  电子邮件 315@ptpress.com.cn

    网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

    反盗版热线:(010)81055315


Simplified Chinese translation copyright ©2017 by Posts and Telecommunications Press ALL RIGHTS RESERVED

Writing High-Performance .NET Code by Ben Watson

Copyright © 2015 by Ben Watson

本书中文简体版由作者授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式或任何手段复制和传播。

版权所有,侵权必究。


本书详细介绍了如何编写高性能的.NET程序,以使得托管代码的性能最大化的同时,还能保证.NET的特性优势。

本书循序渐进地深入.NET的各个部分,特别是底层的公共语言运行时(Common Language Runtime,CLR),了解CLR是如何完成内存管理、代码编译、并发处理等工作的。本书还详细介绍了.NET的架构,探讨了编程方式如何影响程序的整体性能,在全书中,还分享发生在微软的一些趣闻轶事。本书的内容偏重于服务器程序,但几乎所有内容也同样适用于桌面端和移动端应用程序。

本书条理清楚,言简意赅,适合有一定.NET基础的读者和想要提高代码性能的C#程序员学习参考。


Ben Watson从2008年开始就已经是微软的软件工程师了。他在必应(Bing)平台的研发团队工作时,建立了一套世界一流、基于.NET的高性能服务应用,足以应付几千台电脑发起的大容量、低延迟请求,用户数量高达几百万。他在业余时间喜欢参加地理寻宝游戏、阅读各种书籍、欣赏古典音乐,享受与妻子Leticia、女儿Emma的欢聚时刻。他还是《C# 4.0 How-To》一书的作者,该书已由Sams出版。


戴旭,1973年生,浙江萧山人,西安建筑科技大学计算机应用学生,杭州电子科技大学软件工程硕士,高级项目管理师。QQ:82429536


Mike Magruder早在20世纪90年代就已是一名软件工程师了。他曾在.Net Runtime的研发团队中参与第1版到第4版的研发工作。在为必应平台工作时,他负责架构了一套世界一流、基于.NET的高性能服务应用。业余时间他热爱单板滑雪,喜欢自制滑雪板,当然还乐于和妻子Helen消磨闲暇时光。


.NET是一套令人惊叹的软件开发系统,它可以让我们在很短的时间内建立起功能强大、保持联线的多个应用程序,而在此之前我们得花费特别多的时间才能完成。它能完成的工作非常多,这真的很棒。它向应用程序提供内存,支持类型安全性,提供高可靠的Framework库,并拥有自动内存管理等众多特性。

用.NET编写的程序被称为托管应用程序,因为它们依赖于“运行时”(Runtime)和Framework。Framework维持着很多关键任务的运行,确保应用程序有基本可靠的运行环境可用。与直接调用操作系统API的非托管应用或原生应用不同,托管应用程序对其所属进程没有自由控制权。

有些开发人员认为,这个位于应用程序和计算机处理器之间的托管层,必定会显著增加开销,所以他们会有些顾虑。本书会让你放下心来,用证据说明这点开销是值得的,想象中的性能下降往往是夸大其辞。通常,归咎于.NET的性能问题,实际上都是由于编程模式不佳,或者是缺少.NET Framework环境的程序优化技能。那些在C++、Java或VB编程时多年积累下来的程序优化技能,并不一定都能适用于托管代码,有些方法其实是适得其反。有时候,.NET的快速开发特性会使得人们能够更迅速地编写出臃肿、缓慢、缺乏优化的代码。当然,导致代码质量低下的原因还有很多:编程水平有限、赶进度、不良设计、缺少人手、偷懒等。本书将让不熟悉.NET Framework不再成为理由,并且还试图解决一些其他问题。按照本书介绍的原则,你可以学到如何开发精炼、快速、高效的应用程序,避免前面提到的那些失误。不论什么类型的代码,也不论采用什么平台,有一点总是对的:要想得到高性能的代码,只有靠不断努力。

本书既不是语言参考手册,也不是教程,甚至都没有详细讨论CLR。这些内容都在其他资料中有介绍(参见附录C参考文献,那里列出了很多有用的书籍、人士、博客,以供参考)。要想从本书获得最大的收益,需要对.NET阅历颇深。

本书有很多示例代码,特别是有一些底层实现是用中间语言(IL)或汇编语言编写的。强烈建议你不要略过这些内容。在阅读本书的过程中,你应该尝试重现我的这些结果,这样才能真正理解这些代码的运行过程。

本书将教你如何让托管代码的性能最大化,同时不牺牲或尽量少牺牲.NET的特性优势。你将学到良好的编码技术,知道应该避免哪些做法。最重要的也许就是,学习利用免费的工具来方便地评估程序的性能。本书的教学方式条理清楚,只讲必要内容,言简意赅,没有废话。大部分章节一开始是知识点和背景的总体介绍,然后是具体的实现技巧,最后讲解多种场景下的性能评估和调试过程。

你将循序渐进地深入学习.NET的各个部分,特别是学习底层的公共语言运行时(Common Language Runtime,CLR),了解它是如何完成内存管理、代码编译、并发处理等工作的。你将会了解.NET的架构,它既要让程序正常运行,又要安全、可控。你还将知道编程方式将会极大地影响程序的整体性能。作为额外奉送,我将分享过去6年来发生在微软的一些趣闻轶事,那时我们正在搭建一些庞大、复杂、高性能的.NET系统。你也许会注意到,本书的内容偏重于服务器程序,但其实几乎所有内容也同样适用于桌面端和移动端应用程序。我会适时给出每个优化技巧所适用的平台。

你将充分了解.NET架构和高性能编码原则,这样当你陷入本书内容未涉及的境况时,你也照样可以运用这些知识解决未知的问题。

.NET环境下的编程,与其他环境并没有太大区别。你仍然需要具备算法知识,大部分的标准编程思路也都类似,但我们要讨论的是性能优化问题。如果你以前采用的是非托管编程模式,那就有很多差异需要你去注意。你可能再也不用显式地调用delete了(万岁!),但如果想获得极佳的性能,你最好要了解垃圾回收器对应用程序的影响。

如果你的目标是高可用性,那你多少都需要关心一下JIT编译过程。如果你用到了泛型系统,那就可能要考虑接口的分发(Dispatch)问题。.NET Framework类库自带的API有没有问题?会不会对性能造成负面影响?多种线程同步机制之间是否有优劣之分?

除了单纯的代码之外,我还会适时讨论一些性能评估技术和流程,帮助你和你的团队建立追求性能的习惯。好的性能无法一蹴而就,必须持续改进和关注才能永不退化。如果能花些代价搭建一个良好的性能测试平台,日后将会获得丰厚的回报,因为你可以让大部分性能维护工作都自动进行。

归根结底,你能对程序做出多少性能优化,不仅直接取决于你对自己的代码有多了解,还包括你对底层框架、操作系统、硬件环境的理解程度。这一点对于任何编程平台都是一样的。

本书所有的示例代码都是用C#、底层IL和极少量x86汇编语言编写的,但这些原则完全适用于任何.NET语言。本书均基于.NET 4及以上版本。如果你的环境与此不符,强烈建议升级到最新版本,以便充分利用最新技术、特性、错误修正,以及性能的提升。

我不会讨论.NET的一些子框架,比如WPF、WCF、ASP.NET、Windows Form、MVC、ADO.NET等。当然这些子框架都有自己的议题和性能优化技巧,本书只涉及基础知识和技术,也就是在所有.NET开发场景下都必须掌握的内容。只要掌握了这些基本功,你就可以将其运用到所有开发项目中,等积累了一定经验后可再加入特定领域的知识。

选择托管代码而不是非托管代码的原因有很多。

所有这些特性都说明了一点,你可以快速写出更多的代码,而且错误也更少。错误诊断也变得更为容易。正因为这些优点,托管代码应该成为你的第一选择。

.NET还鼓励你使用标准框架库。在本机代码环境下,很容易分别陷入多个开发环境中,因为要用到多个框架库(例如STL、Boost、COM),或者各种类型的智能指针(Smart Pointer)。在.NET环境下,使用多个框架库的种种理由都不复存在了。

虽然代码“一次编写、到处运行”的终极承诺,好像一直是个白日梦,但它正在一步步成为现实。.NET现在已经支持可移植类库(Portable Class Libraries),你可以只用一个类库为多个平台开发应用程序,如Windows、Windows Phone和Windows Store。关于跨平台开发的更多信息,请参阅http://www.writinghighperf.net/go/1/。随着.NET版本的不断升级,通用于多个平台的系统API将会越来越多。

托管代码拥有众多优点,如果你还想用本机代码实现项目,那请认真给出充分的理由。你真的可以获得预期的性能提升吗?即时编译生成的代码真的会导致性能受限吗?你能写出快速原型系统来证明吗?你能撇下所有.NET特性来完成任务吗?在一个复杂的本机代码应用中,你可能会发现得自己去实现某些.NET的特性。你一定不想陷入重复劳动的境地。

有一种情况可以考虑使用本机代码,而不是托管代码,这就是需要使用完整的处理器指令集,特别是某些用到了SIMD(Single Instruction Multiple Data)指令的高级数据处理程序。不过这种情况也在改变。请阅读第3章,了解JIT编译器未来版本的能力。

使用本机代码的另一个原因就是,仍需使用大量的已有本机代码库。在这种情况下,你可以考虑在新老代码之间建立接口。如果能把新老代码的关系定义成清晰的API,那就可以让新代码都成为可托管的,与本机代码的交互可以通过简单的接口层来实现。然后你可以逐步把本机代码迁移成托管代码。

世上令人遗憾的观念有很多。很不幸,其中有一条就是,托管代码不够快。但这不是真的。

比较接近真相的说法是:如果你不够严谨,.NET平台能让你轻松写出性能低下的代码。

在用C#、VB.NET或其他托管语言编写代码时,编译器会把高级语言翻译成中间语言(IL)和与自定义类关联的元数据。在运行时,这些中间代码会被即时编译(JIT)。也就是说,在第一次执行方法时,CLR会把IL代码提交给编译器,以转换成汇编代码(x86、x64、ARM等)。

大部分的代码优化工作就在这一阶段进行。第一次运行时,的确会发生固定的性能损耗,但之后就一直会调用编译后的版本。后面我们将看到,必要时可以针对首次运行损耗采取多种措施来提高性能。

托管应用的稳态性能(Steady-state),取决于以下两点。

1.JIT编译器的质量。

2.NET服务的运行开销。

除少数情况外,即时编译所生成代码的质量一般都是很高的,而且质量还一直在进步,特别是近段时间以来。

.NET提供的服务并非不需要开销,但比你预想的要低。想把这类开销降到零是没有必要的(这也不可能)。只要降到足够低,使得影响程序性能的其他因素变得更为明显就可以了。

事实上,有些时候你会发现托管代码会带来明显的性能收益。

在绝大多数场合,“托管代码比本机代码慢吗?”的答案一定是“否”。当然,肯定存在一些场合,托管代码无法逾越运行环境的一些安全约束而影响性能。这种情况远比想象中的要少,而且绝大部分应用程序都无法得到明显改善。在大部分情况下,性能的差异言过其实。请阅读第3章(JIT编译)了解这些特定的场合。

有一种现象非常普遍,就是来回穿插地调用托管代码和本机代码,其实这是一种蹩脚的编程方式。这样就无法妥善管理内存,不能应用良好的编程模式,无法应用CPU缓存策略,还不利于提高性能。

在反对使用托管代码的理由中,有一条十分常见,就是似乎让你失去了太多对程序运行的控制权。这是一种对垃圾回收过程的恐惧,因为看似是随机发生的,时间不定。然而,实际并非如此。垃圾回收行为是确定的,通过对内存分配模式、对象作用域、垃圾回收配置参数的调控,你可以明确指定其运行时机。虽然控制的方式与本机代码不同,但控制能力依然存在。

初次接触托管代码的人,常把垃圾回收器或JIT编译器视为不得不“面对”“接受”或想“躲避”的东西。这种看法是错误的。在任何系统中,想要大幅提高性能都需要专门的调优方法,不管使用什么框架都一样。无论缘于何种原因,都不要错误地把垃圾回收器和JIT视作不得不去挑战的“问题”。

随着你慢慢习惯CLR对程序执行过程的管理,你会意识到,只需善用CLR就能让性能大幅提升。所有框架的设计初衷都希望能被善加利用,.NET也不例外。但是很不幸,这些设想往往不能被清晰地表达出来,API不会也不能阻止你做出错误的选择。

在本书中,我花了大量篇幅来解释CLR的工作机制,这样你选用了托管代码后就能更加充分地与之合作。对于垃圾回收行为尤其如此,它的性能优化有着非常明确的规则。如果忽视这些规则,就会后患无穷。按系统规则进行优化,你就更有可能获得成功。而如果强行要求系统按照你的意愿行事,或者更糟糕地完全抛开它,那就难说了。

在某种程度上,CLR的有些优点也是双刃剑。易用的profiling记录、足量的文档、丰富的元数据和ETW事件查看器,这些都有助于快速定位问题。但眼前的东西越多,你就越会轻易地做出责任认定。本机代码程序可能存在的问题大都比较类似,比如堆内存分配问题、线程的低效使用等。但因为这些问题不太容易被察觉,所以往往不会被认为是本机开发平台的问题。在托管代码和本机代码共存的情况下,程序常常会出错。为了更好地适应底层平台,就需要修正代码。请不要因为错误易于发现,就误认为整个平台都有问题。

综上所述,并不是说CLR一点问题都没有,但出了问题首先应当考虑的一定是程序本身,而不应该是Framework、操作系统或者硬件。

软件的性能优化需要考虑很多因素,关注点不同,考虑的内容也不一样。对于.NET应用程序而言,可以分4个层面来考虑性能优化问题,如图1所示。

图1 层级图—性能优化的优先级

顶层是你自己编写的软件,即数据处理算法。所有性能优化工作首先得从这里开始,因为这里是最具优化潜力的地方。代码的变动会引起下面几层因素的剧烈变化,因此要首先保证这一层完美无误,然后再往下走。这条经验法则与以下软件调试原则有关:经验丰富的程序员总是假定是自己的代码有误,而不是去归咎于编译器、平台、操作系统或硬件。这无疑也适用于性能优化工作。

自编代码的下一层是.NET Framework,也就是由微软和第三方公司提供的类库,里面实现了一些标准功能,如字符串、集合(Collection)、并行机制,甚至还有WCF、WPF等相对完整的子Framework。.NET Framework提供的功能你或多或少总会用到一些,但其他大部分独立类库则不一定。.NET Framework本身绝大部分都是用托管代码实现的,与你自己编写的程序一样(你可以在http://www. writinghighperf.net/go/2中在线阅读框架的源代码,或是在Visual Studio中读到)。

Framework类的下面,是.NET真正的运行部分CLR。它的组件既有托管代码编写的,又有非托管代码编写的,提供了垃圾回收、类型加载、JIT编译和其他所有.NET特性的支持。

再往下就是操纵硬件的代码了。一旦CLR对代码完成了JIT编译,实际上就在运行处理器级别的汇编代码。如果用本机调试程序通过断点进入到托管进程,你会发现正在运行的就是汇编代码。所有的托管代码都会成为普通的机器汇编指令,而且是在稳如磐石的Framework上下文中运行。

再次重申,在做性能优化计划或研究时,应该自上而下地进行。先确保程序结构和算法的合理性,再往下面几层推进。宏观的优化(macro-optimization)总是比微观优化(micro-optimization)更加有效。

本书重点关注中间两层:.NET Framework和CLR。这两层一起维持着程序的运行,往往也是程序员最难看清的地方。当然,我们介绍的很多工具是适用于所有层级的。在本书的最后,我将介绍一些实践过程,按照那些步骤你可以从系统的各个层面提高性能。

请记住,虽然本书中涉及的信息都是公开的,但因为介绍了一些CLR的内部实现细节,这些实现细节可能随时会发生变化。

本书经常会提到一些示例项目。这些项目都很小,只是为了演示某个特定的优化原则。它们都是一些简单的示例,无法充分代表你所面临的性能问题的全部环境。因此请把它们作为优化技术或者研究的一个起点,而不要当成是正式的代码范例。

在本书的网站http://www.writinghighperf.net或www.epubit.com.cn可以下载到全部代码。开发环境是Visual Studio Ultimate 2012,但用其他版本打开和编译也毫无问题。

最后,我想简单介绍一下封面。在写这本书之前,我就已经想好了这个齿轮图案。我一直认为,要想充分发挥性能,就应该像钟表的齿轮装置那样运作,而并不完全是追求速度。虽然速度是很重要的一个方面。你编写的程序不仅要高效地完成自己的任务,还得和.NET及其内部各个部件、操作系统、硬件紧密合作。通常,正确的做法就是保持应用程序流畅运行,尽量减少中断,确保不会执行任何干扰整个系统运作的工作。这一原则无疑适用于垃圾回收和异步线程模式,但同样也适用于JIT编译、日志记录等场合。在阅读时请记住这个关于齿轮的比喻,这将有助于理解本书的各个主题。

[1] Exception译为“异常”已是共识,因此作为特定称谓时,会加上引号。


感谢我的朋友、技术编辑Mike Magruder,感谢他对本书提出的宝贵意见。特别要感谢他在微软当了我3年的导师,这改变了我的职业生涯,让我的技术精进百倍。

我十分感谢Maoni Stephens的深度辅导,她针对垃圾回收部分给了我很多意见和指导。我还要感谢Abhinaba Basu提供的Windows Phone CLR信息,以及Brian Rasmussen的一些反馈。

如果没有与姐夫James Adams的一次偶然聊天,我就不会开始本书的写作,就是那次闲聊才让我真正考虑去写这么一本书。感谢我的爸爸Michael Watson和妻子Leticia,他们花了大量时间反复阅读文稿,俨然就是我的校对。

特别感谢Leticia,还有我的女儿Emma。为了支持我完成本书,Emma放弃了很多我的陪伴。要是没有她们的支持和鼓励,我是不可能完成这项工作的。


在收集性能数据之前,你需要知道评估的内容是什么。听上去这显而易见,但实际上涉及面远比想象的要广泛得多。就拿内存来说,很显然需要评估内存的占用情况,以便减少内存消耗。但要查看哪类内存呢?专用工作集内存(Private Working Set)、提交大小(Commit Size)、页面缓冲池(Paged Pool)、峰值工作集(Peak Working set)、.NET堆内存大小,还是大对象堆内存(Large Object Heap,LOH)?为了保证负载的均衡,是否要查看各个处理器的堆内存?是否还需要关心其他类型的内存?为了跟踪一段时间内的内存占用情况,是否需要知道每小时的平均值和峰值?内存的占用是否和系统负载相关?现在你明白了吧,光是针对内存,就能轻易地列出一大堆指标。而且目前我们还没有涉及私有堆内存(Private Heap),也没有对程序本身进行评估,还不知道都是哪些对象正在消耗内存呢。

请尽可能明确地描述评估内容。

故事

 

我曾经负责过一个大型的服务程序,当时我把进程专有内存的大小作为关键的性能指标,根据它来决定是否要在启动内存需求很高的大型任务之前重启进程。这导致了大量的“专有内存”被交换出去,对降低系统的内存负载毫无意义,而我们真正的目标就是要降低内存的负载。我们后来修改了评估系统,转而评估工作集内存,这才产生了效果,把内存占用量减少了几个GB(我说过这是一个大型应用)。

一旦确定了需要评估的内容,接下来就是选择每个指标的目标值。在开发阶段初期,这些目标值可能比较易变,甚至不可能知道。其实在初始阶段不需要满足这些目标值,但这能迫使你建立一套评价体系,依据这些值来自动评估你的工作。这些目标值应该是可量化的。我们对程序的较高要求也许就是要“快”,当然这没错。但这不算是一个很好的指标,因为“快”比较主观,没有什么明确的途径来判断是否达标。你必须能把目标定义成某个数字,而且是可测量的数字。

差的目标:用户界面应该响应迅速。

好的目标:任何操作都不会阻塞UI线程超过20 ms。

但只是能被量化还不够,还需要十分精确,正如前面的内存优化案例中所述。

差的目标:内存占用应该小于1 GB。

好的目标:当负载为每秒100个请求时,工作集内存的占用不能超过1 GB。

第二个版本的目标值给定了非常明确的前提条件,你可以明确知道是否满足需求。实际上,这已给出了一个良好的测试用例。

目标值中的另一个决定因素是应用程序的类型。带有用户界面的程序必须不惜一切代价保证UI线程的响应能力,无论执行任何任务时都应如此。而服务器端程序每秒要处理几十、几百,甚至几千个请求。它必须非常高效地完成I/O操作和数据同步,以保证吞吐量和CPU利用率的最大化。因此服务器端程序的设计完全不同于其他程序。如果某个应用程序的基础架构先天不足,对效率问题考虑欠佳,那么再回头去修正就很难了。

在设计系统和规划性能评估方案时,有一条经验也许很有用,那就是设想一下理论上的最佳性能。如果你能去掉其他所有开销,比如垃圾回收、JIT、线程中断,以及其他任何你能想到的开销,然后还能剩下什么资源用来干活呢?对于负载、内存占用、CPU占用、内部同步等资源,你能想到的理论极限是多少?这通常依赖于程序所处的硬件和操作系统。比如,有1台16个处理器、64GB内存的服务器,带有2条10GB的网络,你需要估计一下最大并行处理能力、内存中最多能存放多少数据,以及每秒的网络吞吐量是多少。这能帮助你作出规划,假如1台服务器不够用,那到底需要多少台同档次的机器。所有这些信息都是性能评估目标的绝佳来源。

你大概听说过一个说法:“过早的优化是万恶之源”,这是由Donald Knuth首先提出的。这句话仅适用于代码层面的微观优化。在设计阶段时,你需要理解整体架构和约束条件,不然你就会遗漏一些关键点,这将严重制约程序的运行。你必须在设计阶段就把性能目标预先考虑进去。

在软件设计阶段,就得考虑安全性等很多方面的问题。性能问题也一样,不能事后再议,必须从一开始就提出明确的目标。要想从头开始把一个已有的应用程序重新设计一遍,这是不可能的,这比一开始就考虑周全要付出多得多的代价。

在项目初始阶段的性能分析,与开发完成即将进入测试阶段的分析是不一样的。在初始阶段必须得保证设计的灵活性,确保技术路线在理论上能完成任务,确保在架构上没有大的问题以免除后患。一旦项目进入测试、部署和维护阶段,就得把更多的精力投入微观优化、具体代码方式的分析、减少内存占用等工作。

最后,你还需要了解阿姆达尔定律(Ahmdals’s Law,参见http://www.writinghighperf.net/go/3[PDF]),特别是其应用于顺序执行程序的情况,以便能找到哪部分程序是需要优化的。那些不能明显改善整体性能的微观优化,多半是在浪费时间。为了获得最佳效果,应该优先优化那些效率最低的部分。优化永远不可能面面俱到,得有一个明智的起点。因此,准备好优化目标,再有一套优秀的评估系统,这些都是十分重要的。不然你连从哪儿开始都不知道。

在选择评估值时,需要考虑用什么统计值才合适。多数人会优先选用平均值。当然大部分情况下这确实是个重要指标,但还应该考虑一下百分位值。如果对程序的可用性有要求,肯定会用到百分比形式的性能指标。比如,“数据库请求的平均延迟必须少于10 ms,95%以上的数据库请求延迟必须少于100 ms。”

你可能对这个概念不大熟悉,其实它相当简单。假定测了100次,并对结果排序,第95条结果就是本次结果数据的95%百分位值。95%百分位值的意思是:“采样数据中有95%的值小于等于这个值。”

换句话说,“5%的请求高于此值。”

对已排序数据集的第P个百分位值的计算公式为:(P/100) × N,这里P为百分位值,N为数据个数。

假定测得以下由0代垃圾回收导致的暂停服务时间(参见第2章),单位为毫秒(ms,已排序):

1、2、2、4、5、5、8、10、10、11、11、11、15、23、24、25、50、87。

这里有18个样本数据,平均值为17 ms,但95%百分位值远大于50ms。如果只看平均值,你也许不会注意到垃圾回收引起的延时问题,但有了百分位值,判断就更加全面。你会发现垃圾回收过程有时候的性能会很差。

这些数据还表示,中间值(50%百分位值)与平均值的差距相当大。那些占比高的数值,对平均值的影响往往较大。

对于可用性要求很高的服务,百分位值通常要重要得多。可用性要求越高,需要跟踪评估的百分位值就越高。一般99%就能满足需求了,但如果真的需要处理海量请求,99.99%、99.999%,甚至更高的数值也是必要的。一般需要多高的百分位值,取决于业务需求而不是技术。

百分位值能让你了解到完整运行环境下性能指标的下降情况,因此它是很有价值的。即使平均起来用户和请求的响应情况都还不错,但90%的百分位指标表示性能也许还有提升的空间。这表示10%的操作受到了性能制约。通过对多个百分位值的跟踪,你就会知道这种性能下降发生得有多快。这种用户和请求的响应百分位值到底有多重要,最终取决于业务需求,这里确实存在“回报递减”(Diminishing Returns)法则。要获得最后1%的提升可能非常困难,付出的代价也会极其高昂。

我说过,在上面的数据中95%百分位点是50ms。当然从技术上说,此例中的这个数值毫无用处,因为样本数据太少,不具备统计学意义,也许这次只是偶发现象。可以用一条经验法则来确定所需的样本数:比目标百分位值高1个数量级。对于0~99%至少需要100个样本,对于99.9%至少需要1000个样本,对于99.99%则至少需要10 000个样本,依此类推。该法则在大部分情况下是有效的,但如果你想从数学角度了解到底需要多少样本数才够用,可以从http://www.writinghighperf.net/go/4开始学习。

如果非要说出本书最重要的一条法则来,那就是:

评估、评估、再评估!

如果没有精确评估过,你是不会知道性能问题出在哪里。

你确实可以只靠查看代码或者直觉获得很多经验,也会获得一些哪里存在性能问题的强烈暗示。你甚至会找对地方,但请彻底打消省略性能评估的念头,除非是些微不足道的问题。原因有两个。

第一,假定你是对的,你确实精确地找到了性能问题所在。你也许想知道程序性能到底提高了多少,对吧?有了扎实的数据作支撑,你的优化成就将会稳固很多。

第二,我不清楚出错的频率有多高。举个例子,有一次在分析本机进程内存和托管内存时,我们一直认为性能问题是出在一个需要加载大量数据的地方。我们没有让开发人员设法减少内存的占用,而是试着禁止加载组件。我们用调试器把进程的所有堆内存信息都做了转储。让我们惊讶的是,大部分内存消耗都是由程序集的加载引起的,而不是因为数据太多。由此我们省了不少力气。

如果缺少有效的评估工具,性能的优化就毫无意义。

性能的评估是一个持续性的过程,在使用开发工具、测试程序、监视工具时都应该穿插进行。如果需要对程序的功能进行持续监测,那很可能就需要同时对性能进行监测。

本章接下来会讨论各种工具,用于配置、监视和调试性能问题。虽然我着重介绍的是免费软件,但还有很多商业软件可用,有时它们能简化调优工作。如果你有购买这些工具软件的预算,那就尽管去买吧。当然,使用我介绍的这些小工具(或是其他类似软件)还是很有意义的。至少有一点,它们在客户的机器或生产环境中很容易运行。更重要的是,它们都是“更贴近底层”(Closer to the Metal)的小工具,能让你深入理解问题的本质,帮助你分析数据,这些都是与使用什么工具无关的。

我会介绍每个工具软件的基本用法和常用背景知识。本书的各章中针对每个特定场景,都会给出详细的操作步骤。你对界面及基本操作的熟练程度,决定了你的理解程度。

提示

 

在深入学习具体的工具软件之前,有一个用好它们的诀窍,那就是循序渐进。如果在一个大型的、复杂的项目中尝试使用某个陌生的工具软件,不知所措、失败,甚至得到错误的结果,都是很有可能发生的。如果要学习用一个新工具软件来评估性能,请创建一个测试程序,它实现的功能得是众所周知的,然后用新工具来验证它的性能。这样,在更复杂的情形下,你就能更从容地使用这个工具了,出现技术和判断错误的可能性也会更小。

虽然Visual Studio不是唯一的IDE环境,但绝大部分.NET程序员都在用它。如果你也在用,从现在开始就能进行性能分析了。不同版本的Visual Studio自带了不同的性能分析工具。本书假定你至少是安装了专业版(Professional)。如果你手头的版本不对,那就略过以下内容,去看看本书介绍的其他工具吧。假如安装了专业版以上的Visual Studio,你就可以在“分析”菜单下的“性能向导”菜单中找到性能分析工具,如图1-1所示。在Visual Studio 2013中,你必须先在“分析”菜单中开启“性能和诊断”视图,然后才能使用性能向导。

图1-1 Visual Studio性能向导中的分析选项

Visual Studio可以对CPU占用、内存分配和资源争用情况进行分析。无论是在开发阶段,还是在整体测试阶段,都可以对产品进行精确分析。

然而,对生产环境下的大型应用进行性能指标的精确采集,这是非常少见的。如果需要在生产机上采集性能数据,也就是在客户或数据中心的主机上,那就需要一种能脱离Visual Studio运行的工具。这时可以使用Visual Studio Standalone Profiler,在专业版以上的Visual Studio中都有附带,并需要从安装介质中单独安装。在专业版Visual Studio 2012和2013的ISO镜像中,它位于Standalone Profiler文件夹内。关于安装包的位置及安装方法的详细说明,请参阅http://www.writinghighperf.net/go/5

Visual Studio Standalone Profiler采集数据的命令行如下。

1.进入安装文件夹(或者把该文件夹加入path变量中)。

2.运行命令:“VsPerfCmd.exe /Start:Sample /Output:outputfile.vsp”。

3.运行需要分析性能的程序。

4.运行命令:“VsPerfCmd.exe /Shutdown”。

这样就会生成一个名为outputfile.vsp的文件,可以在Visual Studio中打开它。VsPerfCmd.exe还有很多其他参数,囊括了Visual Studio支持的所有分析类型。除了最常用的Sample参数之外,还可以选用:

有一点比较重要,就是选用Trace还是Sample模式,这取决于评估的内容。首选是Sample模式,这时执行过程每隔几毫秒就会中断一下,所有线程的堆栈使用情况都会被记录下来。这是全面了解CPU使用情况的最佳方式,但不适用于分析I/O调用。因为I/O操作消耗不了多少CPU,却可能影响整体性能。

Trace模式需要修改每个函数的调用,以便记录所有时间戳。这种模式更具侵入性,会进一步降低程序的运行速度。但这样就能记录每一次方法调用的真实时间,精确性就可能更高,特别是针对I/O操作而言。

Coverage模式不是用来分析性能的,而是用于查看哪些代码行被执行到了。在进行产品测试时,利用这个模式就能很好地分析出测试覆盖程度。有一些商业软件可以帮你检查测试覆盖率,但你可以利用这个模式自己来完成。

在Concurrency模式下,如果使用锁或者其他同步对象时发生了资源访问冲突,将会把所有事件记录在案。假如因为资源争用导致了线程阻塞,利用这个模式就会知晓。关于异步编程及统计锁争用次数的详细信息,请阅读第4章。

Visual Studio自带的工具当然是最易于使用的,但如果你手头没有合适的版本,那这些工具可不便宜。如果你用不上Visual Studio,本书后续还会介绍许多免费的工具可供替代。几乎所有的性能评估工具都使用了相同的底层机制(至少在Windows 8/Server 2012以上版本中是这样),那就是ETW事件。

ETW即Event Tracing for Windows,是由操作系统提供的事件日志,速度很快,效率也很高。所有应用程序都会生成事件,探查器(Profiler)可以捕获这些事件进行各种分析。第8章介绍了如何在应用程序中充分利用ETW事件,包括捕获预置事件及定义自己的事件。

我常常喜欢使用其他的性能评估工具,特别是在生产环境下。因为Visual Studio自带工具太专业化了,每次只能收集并显示一种数据。而PerfView之类的工具则可以一次收集所有ETW事件,只要运行一次就能按类别分析全部事件。虽然这点不太重要,但很有意义。有时候我把Visual Studio的性能分析工具视为“开发阶段”(Development-time)工具,而其他工具则适用于生产环境。你的习惯也许并非如此,请按自己的方式使用这些工具,只要能取得最佳效果即可。

性能计数器是监测应用程序和系统性能的最简单方式。Windows拥有几百个性能计数器,被归为几十个类别(Category),其中很多都是专用于.NET的。查看性能计数器的最简单方式就是通过系统自带的工具“性能监视器”(PerfMon.exe),如图1-2所示。

图1-2 PerfMon的主界面,显示了一段时间内的处理器计数器。竖线指示着当前监视值,
默认满100 s从头开始绘制图形

每个性能计数器都带有所属Category和名称,很多计数器还可拥有多个实例。例如% Processor Time计数器属于Process类,它的多个实例分别对应了当前存在的各个处理器。有些性能计数器还带有“元实例”(meta-instance),比如_Total或 <Global>,代表所有实例的合计值。

本书很多章节将会首先介绍与该章内容有关的性能计数器,但还有一些不是专用于.NET的通用计数器,也是需要你了解的。在Windows中几乎所有部件都存在对应的性能计数器,可供任何应用程序使用,如图1-3所示。

图1-3 多种Category、数以百计的计数器,显示了所有可监视的实例(此例为进程)

但在继续后续内容之前,你应该首先熟悉以下基本的操作系统术语。

上述的一些术语将在本书中会涉及,特别是在第2章讨论垃圾回收机制时。关于这些知识点的详细信息,请阅读专业的操作系统书籍,比如《Windows Internals》(参见附录C中的参考文献)。

通过每个处理器实例对应的计数器,Process类计数器可以提供很多重要信息,包括:

根据应用程序的不同需求,还有一些其他种类的计数器也很有用。你可以用PerfMon找到以下这些计数器。

令人惊讶的是,互联网上很难找到性能计数器的详细介绍,但好在文档还算齐全。在PerfMon 的“添加计数器”对话框中,只要把底部的“显示描述”勾选框勾上,就可以显示当前选中计数器的详细描述信息了。

PerfMon还能由计划任务定时收集指定的性能计数器信息,并保存在日志中供日后查看,甚至可以在计数器值超过阈值时执行指定的动作。数据收集器集(Data Collector Sets)可以完成这些任务,不仅限于性能计数器的数据,还能收集系统配置信息和ETW事件。

请在PerfMon 的主界面中设置数据收集器集。

1.展开“数据收集器集”树状菜单。

2.在“用户定义”菜单上单击鼠标右键。

3.选择“新建”菜单。

4.选择“数据收集器集”菜单。

5.给定名称,选中“手动创建(高级)”,单击“下一步”,如图1-4所示。

6.勾选“创建数据日志”下方的“性能计数器”,单击“下一步”,如图1-5所示。

图1-4 用于设置常规计数器收集器的“数据收集器集”配置对话框

图1-5 “性能计数器”对话框

7.单击“添加”选择需要加入的计数器,如图1-6所示。

8.单击“下一步”设置日志文件的存储路径,再单击“下一步”设置安全信息。

全部完成后,就可以打开收集器集的属性页,设置收集器程序的运行计划。

收集器也可以手动运行,只要在该数据收集器集节点上单击右键并选择“开始”即可。然后就会生成一份报告,在菜单树的“报告”菜单下双击该数据收集器,就可查看报告了,如图1-7所示。

图1-6 指定需要记录的数据类型

图1-7 报告文件示例。使用工具栏上的按钮可以改变已收集数据的图表样式

如果要创建告警程序,步骤是一样的,只是要在创建数据收集器向导中选择“性能计数器警报”。

如果仅使用上述功能,好像针对性能计数器你只能做到这些了。但如果你需要以编程方式进行控制,或者是要创建自己的计数器,详情请阅读第7章(性能计数器)。你将使用性能计数器对应用程序进行整体性能的基线(Baseline)分析。

ETW是Windows系统内置的一个基础模块,记录了所有的诊断日志,而并非专为性能评估服务的。本节会对ETW做个概述,第8章还将介绍如何创建并监视自定义事件。

事件是由事件提供者(Provider)产生的。例如在CLR中就有Runtime Provider,本书涉及的绝大部分事件就是由它产生的。几乎所有的Windows部件都包含了Provider,比如CPU、磁盘、网络、防火墙、内存等,数量庞大。ETW系统的效率非常高,可以用最小的开销处理大量事件。

每个事件都带有一些标准字段,比如事件级别(信息、警告、错误、详细和关键)和关键字。每个Provider都可以定义自己的关键字。CLR的Runtime Provider定义的关键字包括GC、JIT、Security、Interop、Contention等。

你可以通过关键字把需要监视的事件过滤出来。

每个事件还带有一个由Provider定义的自定义数据字段,描述了某些状态信息。比如Runtime的垃圾回收事件会给出当前属于第几代垃圾回收、是否后台回收等信息。

ETW的强大之处就在于,Windows的绝大部分部件都会产生大量的事件,几乎包含了影响应用程序运行的每个层面的因素。仅凭ETW事件你就可以完成大部分性能分析工作。

很多工具都能分析ETW事件并给出各种分析视图。实际上,从Windows 8开始,所有对CPU的跟踪分析都已通过ETW事件来完成了。图1-8为一次对GC Start事件为时60 s的跟踪分析报告。

图1-8 一次对GC Start事件为时60 s的跟踪。请注意各种与事件关联的数据项,
比如Reason和Depth

要想查看当前系统中全部已注册的ETW Provider,请打开命令行窗口并输入,如图1-9所示。

图1-9 在命令行窗口中输入命令

还可以通过关键字获取指定Provider的详细信息,如图1-10所示。

图1-10 获取Provider的详细信息

关于ETW的详细信息,请查阅http://www.writinghighperf.net/go/6。不幸的是,关于系统内核事件(Windows Kernel Trace Provider)的详细解释,并没有很好的在线资源。其中有一些适用于所有Windows进程的常见内核ETW事件被归入了Windows Kernel Trace类中。

通过自行收集并分析ETW事件,你可以查看到Kernel Trace Provider及其他Provider生成的所有事件。

本书会提醒你关注那些ETW跟踪过程中应该重点关注的事件,特别是来自CLR Runtime Provider的事件。请查阅http://www.writinghighperf.net/go/7获取所有的CLR事件关键字。查阅http://www.writinghighperf.net/go/8可以找到全部CLR事件。

虽然收集分析ETW事件的工具有很多,但PerfView是我的最爱,它是由微软的.NET性能架构师Vance Morrison编写的。你可以从http://www.writinghighperf.net/go/9下载到这个工具。以上关于ETW事件的屏幕截图就来自于它。PerfView能将函数调用栈进行分组和折叠显示,这是非常强大的功能,也正是它的实用之处。借此你就能层层深入每个事件,在多个抽象层面进行分析。

虽然其他ETW分析工具也很有用,但我通常还是愿意使用PerfView,原因如下。

1.不需要安装,在任何机器上运行都很方便。

2.高度可定制化。

3.易于脚本化运行。

4.可以选择某个事件进行非常精细的数据收集,比如可以只对几类事件进行长达数小时的连续跟踪。

5.通常对机器和被监视进程的性能影响非常轻微。

6.对调用栈进行分组和折叠显示的能力绝妙无比。

7.可以用扩展插件实现自定义功能,同样能用到内置的调用栈分组和折叠显示的能力。

以下是一些我经常被问到的PerfView使用问题。

使用PerfView进行事件收集和分析的基本步骤如下。

1.在“Collect”菜单中选择“Collect”菜单项。

2.在弹出的对话框中设置所需参数。

a.展开“Advanced Options”选择需要捕获的事件类型,尽量缩小范围。

b.如果当前版本不是.NET 3.5,请选中“No V3.X NGEN Symbols”勾选框。

c.可以指定“Max Collect Sec”参数,以便在该指定时间后自动停止收集工作。

3.单击“Start Collection”按钮。

4.如果未设置“Max Collect Sec”参数,请在数据收集完成后单击“Stop Collection”按钮。

5.等待事件分析完成。

6.在结果树中选择各种视图来查看结果。

在收集事件的过程中,PerfView会捕获所有进程的ETW事件。你可以在收集过程结束后过滤出每个进程的事件。

事件的收集是需要开销的。某些事件的开销还会比其他种类的事件更大一些,你需要了解哪些事件会产生大量无用的日志文件,哪些事件会对应用程序的性能产生负面影响。比如CPU分析就会产生大量的事件,因此应该尽量缩短持续时间(不超过1~2分钟),否则你就会收到几个GB的文件,根本就无从分析。

PerfView的界面和视图

PerfView的大部分视图都是源自同一种视图,因此理解它的运行机制还是很有意义的。

PerfView基本上就是一个调用栈的归集和查看器。当你记录ETW事件时,每个事件的调用栈都被记录了下来。PerfView分析这些信息并用表格(Grid)的形式显示出来,常见事件包括CPU占用率、内存分配情况、资源锁的争用情况、“异常”抛出情况等。对一种事件的分析规则同样也适用于其他类型,因为对调用栈的分析过程是一样的。

你还需要理解一下分组及折叠显示的概念。分组功能是把多个事件源归入一项中显示。假定需要一次分析多个.NET Framework DLL,而每个DLL提供的具体功能通常不需要关心。有了分组功能,就可以定义一个分组模板(Grouping Pattern),比如“System.!=>LIB”,就会把所有System..dll归并在一个名为LIB的组中。这是PerfView默认的分组模板之一。

折叠功能可以把一些无关的下层代码的复杂度隐藏起来,只把这些被隐藏部分的开销计入并显示在调用者的节点中。举个简单的例子,假定发生了一些内存分配,通常是由某些内部的CLR方法通过new操作符提交的。你真正想知道的是哪类内存分配是数量最多的。利用折叠功能可以把下层的开销都归并到调用代码中,因为调用层的代码才是你真正可控的。比如大部分情况下你都不会关心String.Format内部操作的开销,你真正关心的是第一次调用String.Format时的代码。PerfView可以把这些内部操作算到调用者头上,为代码的性能提供更为直观的显示。

折叠功能的模板(Folding Pattern)可以共用分组模板。因此可以指定折叠模板为“LIB”,这样就把System.中的所有方法都归并到System.之外的调用者中。

下面再介绍一下调用栈查看器(Stack Viewe)的用户界面,如图1-11所示。

图1-11 PerfView中的标准视图,包含了很多用于过滤、排序、查找的可选项

顶部的控件能让调用栈视图以多种形式显示。以下列出了所有控件的用途,单击控件就可以看到详细的帮助信息。

视图的类型包括以下几种。

表格视图中包含了很多列,鼠标指针停留在列名上会显示详细介绍。[1]以下列出了最重要的几个列。

在后续章节中,我还将通过各种有关性能的研究来讲解具体问题的解决过程。关于PerfView的完整介绍都值得写一本书了,至少也该写一份非常详细的帮助文件,好在PerfView已经自带了。我强烈建议你进行一些简单的分析,同时认真阅读这份帮助手册。

PerfView貌似大多是在对内存和CPU进行分析,但请别忘记它真的只是一个通用的调用栈收集程序,这些调用栈可能来自任何ETW事件。PerfView可以用来分析锁竞争的来源、磁盘I/O等所有应用程序事件,同样提供了强大的分组和折叠显示能力。

如果你需要图形化地展示内存堆的占用情况以及对象间的关系,可以用CLR Profiler代替PerfView来完成内存分析。CLR Profiler能显示丰富的详细信息,比如:

由于CLR Profiler存在某些限制,我平时很少用它。但有时候它还是有点用处的,虽然年代久远了些。CLR Profiler的可视化效果是独一无二的,目前还没有什么免费工具可与之匹敌。你可以从http://www.writinghighperf.net/go/10下载到CLR Profiler,包含了32位版和64位版,还附带了文档和源码。

开始跟踪分析的基本步骤如下。

1.根据需要跟踪的目标程序选择正确的版本(32位或64位),64位版的Profiler无法分析32位程序,反之亦然。

2.选中“Profiling active”复选框,如图1-12所示。

图1-12 CLR Profiler的主界面

3.根据需要勾选“Allocations”和“Calls”。

4.根据需要在“File | Set Parameters…”菜单中设置命令行参数、工作目录(Working Directory)和日志文件目录(Log File Directory)。

5.单击“Start Application”按钮。

6.找到需要分析的应用程序并单击“Open”按钮。

这样就能在分析模式下(Profiling Active)运行应用程序。完成分析之后,请退出程序或在CLR Profiler中单击“Kill Application”按钮。然后被分析的应用程序会停止运行,并开始处理捕获到的日志数据。数据处理过程需要一定的时间,视上述分析运行过程的时间长短而定(我曾经碰到过超过一个1小时的情况)。

在分析运行过程中,你可以随时单击CLR Profiler 中的“Show Heap now”按钮对内存堆进行转储,并会以可视化图将对象间关系显示出来。分析运行的过程并不会中断,可以在多个时间点进行多次内存堆转储。

最后会显示分析结果,如图1-13所示。

图1-13 CLR Profiler的结果界面,显示了跟踪分析过程中收集到的数据

你可以从这个界面打开内存堆数据的各种图形。可以先从“Allocation Graph”和“Time Line”开始了解基本功能。等你熟悉了托管代码的分析过程,直方图视图也会成为极为有用的资源。

注意

 

CLR Profiler是很强大,但我还是觉得它存在很大问题。首先,CLR Profiler有点脆弱。如果没能正确配置就开始分析过程,它就会抛出“异常”或意外中止。比如为了能获取结果数据,我每次都得勾上Allocations或Calls复选框。Attach to Process按钮则完全可以忽略不用,因为这个功能好像无法可靠地运行。对于那些内存堆很大或者程序集(Assembly)很多的大型程序,CLR Profiler好像也不能正常分析。如果你在性能评估过程中遇到了麻烦,PerfView也许会是个更好的选择。因为它比较美观(Polish),而且非常详细的命令行参数也提供了强大的可定制性,你几乎可以在命令行控制所有行为。也许你能得到不一样的收获。此外,CLR Profiler还附带了源码,所以你自己就可以修复错误!

Windbg是微软免费发布的一种通用Windows调试程序。如果你已经习惯了用Visual Studio作为主要调试工具,那么这个原始的、基于文本的调试程序可能会让你望而生畏。别这么想,你只要经过几条命令的学习就会很快适应,然后你就不太会再用Visual Studio进行调试工作了,除非你还处于开发阶段。

Windbg比Visual Studio强大得多,它那些查看进程的手段是其他工具无法提供的。Windbg还很轻巧,更易于在生产服务器或客户的电脑上进行部署,在这种生产环境下你最好还是能把Windbg熟练运用起来。

Windbg能让你迅速获得以下问题的答案。

Windbg通常不是我的首选工具(首选往往是PerfView),但常常是我的第二或第三选择,它能让我查看一些其他工具无法轻易展现的东西。因此在本书中,我会频繁使用Windbg来教你如何检查程序的运行情况,即便其他工具可以更快、更好地完成任务。(别担心,其他工具我也会一一介绍的。)

不要被Windbg的纯文本界面吓到了。只要用几条命令查看一下进程,你很快就会适应起来,并会对这么快就能进行程序分析而心怀感激。本书会借助一些具体的场景,一步一步增长你的知识。

http://www.writinghighperf.net/go/11可以获取Windbg,按照步骤安装Windows SDK即可。(只要你愿意,也可以选择只安装Windbg。)

为了调试托管代码,你需要用到NET’s SOS调试扩展包,.NET Framework的全部版本都有附带。在http://www.writinghighperf.net/go/12有一篇非常易懂的SOS使用说明。

在开始使用Windbg之前,我们先用一个示例程序来进行一次简单的教学。这个示例足够简单——直接导致一次很容易被调试的内存泄漏。在随书代码的MemoryLeak项目中可以找到这个示例。

using System; 
using System.Collections.Generic; 
using System.Threading; 

namespace MemoryLeak 
{ 
  class Program 
  { 
    static List<string> times = new List<string>(); 

    static void Main(string[] args) 
    { 
      Console.WriteLine("Press any key to exit"); 
      while (!Console.KeyAvailable) 
      { 
        times.Add(DateTime.Now.ToString()); 
        Console.Write('.'); 
        Thread.Sleep(1000); 
      } 
    } 
  } 
}

启动该程序并让它运行几分钟。

运行Windbg,如果是通过Windows SDK安装的,那就应该在开始菜单中。请注意选择正确的版本,x86(适用于32位进程)或x64(适用于64位进程)。单击“File | Attach to Process”菜单(或者按F6键),打开“Attach to Process”对话框,如图1-14所示。

找到MemoryLeak进程,(选择按“By Executable”排序,很好找的)单击“OK”。

Windbg会把目标进程挂起(如果你正在调试生产机上的进程,请牢记这一点!)并显示所有已加载的模块。然后,Windbg就等待你输入命令。第一件事情通常就是加载CLR调试扩展。请输入以下命令。

.loadby sos clr

图1-14 WinDbg的Attach to Process界面

如果命令执行成功,不会有任何输出。

如果看到错误信息“Unable to find module ‘clr’”,最有可能是CLR还没有加载完成。如果你是从Windbg中启动目标程序的,然后马上就断点进入了,就可能会发生这种错误。这时可先在加载CLR模块时设置一个断点。

sxe ld clr 
g

第一条命令在加载CLR模块时设置一个断点。g命令告诉调试程序继续运行。当再次中断时,CLR模块就应该已经加载完成了,然后就可以如前所述用loadby sos clr命令加载SOS了。

然后可以执行任何命令。下面就执行一条。

g

这条命令表示继续运行。在程序运行期间是无法执行命令的。

<Ctrl-Break>

这条命令会暂停目标程序的运行。如果你在开始运行目标程序后需要重新获得控制权,可以执行这条命令。

.dump /ma d:\memorydump.dmp

这条命令会生成一个包含所有进程数据的转储,保存到指定文件中。这样你就能以后再来分析进程的状态,因为这只是一个快照,你当然没办法对运行过程进行调试了。

!DumpHeap –stat

DumpHeap会显示当前内存堆中所有托管对象的汇总信息,包括内存占用大小(只计算当前对象,不含引用对象)、数量等信息。如果要查看内存堆中每个System.String对象的信息,输入!DumpHeap –type System.String即可。在介绍垃圾回收时,你还会看到DumpHeap命令的更多用法。

~*kb

这是一条Windbg的常规命令,不属于SOS,用于显示当前进程中全部线程的调用栈。

如果要切换到另一个线程,请使用命令。

~32s

这条命令会把当前线程切换为#32线程。请注意,Windbg的线程编号和线程ID不同。为了便于引用,Windbg自行为进程内的所有线程统一编号,和Windows或.NET的线程ID都没有关系。

!DumpStackObjects

这条命令也可以缩写为:!dso,执行后会把当前线程所有栈帧(Stack Frame)中每个对象的类型,加上内存地址转储出来。

请注意,SOS调试扩展中所有针对托管代码的命令,都带有“!”前缀。

要想让Windbg调试程序充分发挥作用,还需要做一件事情,就是要设置符号(Symbol)文件路径,以便下载微软DLL的公共调试符号(Public Symbol)数据,这样你就能看明白系统底层的执行过程。请把环境变量_NT_SYMBOL_PATH设为以下字符串。

symsrv*symsrv.dll*c:\symbols*http://msdl.microsoft.com/download/symbols

请把“c:\symbols”替换为你自己的本地符号文件缓存路径(并且确保已创建了该目录)。Windbg和Visual Studio都会使用这个环境变量自动下载并缓存系统DLL的公共调试符号数据。在一开始的下载过程中,符号解析会比较慢,但只要缓存成功,解析速度就会明显加快。还可以使用.symfix命令自动把符号文件路径设置为微软的调试符号服务器和本地缓存目录。

.symfix c:\symbols

很多产品都可以把编译过的程序集反编译成IL、C#、VB.NET或任何其他.NET语言的源码,有免费的也有收费的。比较流行的产品包括Reflector、ILSpy、dotPeek,其实还有一些。

如果需要查看别人的代码,这些反编译工具非常有用,有些代码对于性能分析至关重要。这些工具我最常用于查看.NET Framework自身的工作状况,因为我希望知道不同的API对性能会有什么潜在的影响。

将你自己编写的代码转换为可读的IL,这也很有意义,如图1-15所示。因为你能看到很多操作,比如装箱操作就是在高级语言中无法看到的。

图1-15 ILSpy将Enum.HasFlag反编译为 C#源码。ILSpy是学习第三方代码运行机制的有力工具

第6章将会介绍.NET Framework的源码,并鼓励你练就一副挑剔的眼光,在使用每一个API时都能认真审视一番。因此ILSpy和Reflector之类的工具是必不可少的,你将会每天使用这些工具,你会对系统提供的代码越来越熟悉。你还会经常发出惊叹,在看似简单的方法中需要完成这么多工作。

MeasureIt 是一个微型性能基准(Benchmark)测试工具,易用性不错,由Vance Morrison编写的(就是PerfView的作者)。MeasureIt会分门别类显示各种.NET API的相对开销,包括方法调用、数组、委托(Delegates)、迭代(Iteration)、反射(Reflection)P/Invoke等。MeasureIt会以空的静态函数调用为基准,测试所有调用的相对开销。

MeasureIt的主要用途就是,在API级别把软件设计对性能的影响显示出来。比如在lock类中,你会发现使用ReaderWriteLock会比常规的lock语句慢4倍左右。

http://www.writinghighperf.net/go/13可以下载到MeasureIt。

在MeasureIt的代码中加入自己的测试是非常容易的,它已经自带了源代码,运行MeasureIt /edit就可以解包出来。通过研究这些代码,会让你对编写精确的测试代码产生很大启发。在它的代码注释中,详细讲解了如何进行高质量的代码分析。你应该重点关注这些讲解,特别是你如果要自行进行一些简单的性能测试的话。

比如MeasureIt使用了如下方法阻止编译器进行函数的内联调用(Inlining)。

[MethodImpl(MethodImplOptions.NoInlining)] 
public void AnyEmptyFunction() 
{ 
}

MeasureIt还用到了其他一些技巧,比如利用处理器缓存和充分的迭代来产生足具统计学意义的结果。

那种通过控制台输出的传统调试方案仍可延用,而且不应该被忽略。此外,我还建议你换用ETW事件来进行调试,可以完成很多更为复杂的分析工作,第8章将会详细介绍。

对代码进行精确计时往往也是很有用的。永远不要使用DateTime.Now来进行性能跟踪,因为DateTime.Now太慢了。请选用System.Diagnostics.Stopwatch类来记录事件的间隔,无论事件的大小,System.Diagnostics.Stopwatch都非常准确,精度也很高,开销却很低。

var stopwatch = Stopwatch.StartNew(); 
...do work... 
stopwatch.Stop(); 
TimeSpan elapsed = stopwatch.Elapsed; 
long elapsedTicks = stopwatch.ElapsedTicks;

关于.NET中的时间和计时方法,详情请阅读第6章。

如果要保证自己编写的测试程序准确无误、结果可重现,请研究一下MeasureIt的源代码和文档,里面重点介绍了相关的最佳实践。

自行编写性能测试程序往往比想象中更为困难,错误的性能测试还不如根本就不做,因为你会把时间都浪费在错误的地方。

无论开发人员、系统管理员,还是业余编程爱好者,都应该拥有这套强大的工具软件集。

SysInternals起初是由Mark Russinovich和Bryce Cogswell开发的,现在已归微软所有,其中包含了计算机管理、进程查看、网络分析等很多工具。

以下是我最喜欢的一些工具。

类似的工具还有很多,在http://www.writinghighperf.net/go/14可以下载到单个工具或整个工具包。

最后一个性能工具很普通,就是数据库,用于记录一段时间内的性能数据。需要记录什么指标是与你的项目有关,数据库格式不一定要用成熟的SQL Server关系型数据库(当然用了会有一定的好处)。只要能保存一段时间内的报表数据、可读性较强,哪怕是只包含了标题和数据的CSV文件也可以。重点是要把数据记录、保存下来,并能用工具生成测试报告。

如果有人问你,你的程序性能有没有获得提升?以下哪个答案更合适些呢?

1.是的。

2.在过去6个月里,我们减少了50%的CPU占用率,降低了25%的内存消耗,减少了15%的请求延时。我们的垃圾回收频率下降到1次/10 s(以前是1次/s!),我们的启动时间现在完全可由配置文件来控制(35 s)。

我曾经说过,有扎实的数据作为支撑,性能优化的成绩将会出色很多。

其他工具还有很多,包括静态代码分析工具、ETW事件收集和分析工具、反编译工具、性能跟踪分析工具等。

你可以只把本章列出的工具作为开端,但你要明白只用这些工具也能完成意义重大的任务。把性能问题可视化,有时候可能会有用,但你并不总是会用得上。

随着对性能计数器和ETW事件之类的技术更加熟悉,你还会发现,编写自己的测试工具完成自定义报告或是进行智能分析,是件轻而易举的事情。本书中的很多工具都是为了在一定程度上实现性能评估的自动化。

无论怎么实现,性能评估过程本身一定会有开销。CPU跟踪分析会轻微降低目标程序的运行速度,性能计数器需要占用一定的内存及磁盘空间。ETW事件虽然速度很快,但也不是没有开销的。

你必须在代码中监视并优化这些评估本身的开销,就像对待其他运行开销一样。然后在某些场景下,你得确定评估本身的开销与性能的提升相比是否划算。

如果你做不到全面的评估,那就只能选择其中几种分析手段。只要足以发现最频繁出现的问题,基本也就可以了。

你的软件可能还会有一些“特别版本”,这有点危险。你肯定不希望这些特别版的程序演变成无法代表实际产品的东西。

软件要考虑的方面有很多,到底是保留所有的数据,还是要更好的性能,你可能不得不进行一定的平衡。

提升性能的首要法则就是评估、评估、再评估!

知道该为你的应用程序使用什么性能指标是非常重要的,每个指标都应该是精确、可量化的。平均值是很不错,但百分位值也同样要重视,特别是针对高可用性的服务而言。在前期设计阶段就要把性能目标考虑在内,理解系统架构对性能的影响程度。先从那些影响最大的部分开始着手优化。首先关注算法及整体性的宏观优化,然后再转移到微观优化中去。

应该充分了解性能计数器和ETW事件。要善用工具软件进行性能分析和调试。请学习如何使用Windbg和PerfView这类最强大的工具,以便快速解决性能问题。

[1] 原文有误,单击列名将会导致排序。原文为:“Click on the column names to bring up more information.”。

[2] 原文有误。原文为:“The number of samples in just this node”,与Exc Ct的含义重复了。


垃圾回收将会是你一直关注的性能因素。大部分容易察觉的性能问题,“显然”都是由垃圾回收引起的。这些问题修正起来速度最快,也是需要你持续关注并时刻检查的。我用了“显然”这个词,是因为我们将会发现,很多问题实际上都是由于对垃圾回收器的行为和预期结果理解有误。在.NET环境中,你需要更多地关注内存的性能,至少要像对CPU性能一样。较好的内存性能是.NET程序流畅运行的重要基础,本书将花费最大的篇幅来着重讨论内存性能问题。

很多人一想到垃圾回收可能导致的系统开销,都会觉得非常不安。其实只要你理解了垃圾回收机制,对程序的优化就会变得简单了。在第1章你已经了解到,很多情况下垃圾回收器实际上会整体提高内存堆的性能,因为它能高效地完成内存分配和碎片整理工作。垃圾回收肯定能为你的应用程序带来好处。

Windows的本机代码模式下,内存堆维护着一张空闲内存块的列表,用于内存的分配。尽管用到了低碎片化的内存堆(Low Fragmentation Heaps),很多长时间运行的本机代码应用还是得费尽心机地对付内存碎片问题。内存分配操作的速度会越来越慢,因为系统分配程序遍历空闲内存表的时间会越来越长。内存的占用率会持续增长,进程肯定也需要重启以开始新的生命周期。为了减少内存碎片,有些本机代码程序用大量代码实现了自己的内存分配机制,把默认的malloc函数给替换掉了。

在.NET环境中,内存分配的工作量很小,因为内存总是整段分配的,通常情况下不会比内存的扩大、减小或比较增加多少开销。在通常情况下,不存在需要遍历的空闲内存列表,也几乎不可能出现内存碎片。其实GC内存堆的效率还会更高,因为连续分配的多个对象往往在内存堆中也是连续存放的,提高了就近访问的可能性(Locality)。

在默认的内存分配流程中,会有一小段代码先检查目标对象的大小,看看内存分配缓冲区中所剩的内存还够不够用。只要缓冲区还够用,内存分配过程就十分迅速,不存在资源争用问题。如果内存分配缓冲区已被耗尽,就会交由GC分配程序来检索足以容纳目标对象的空闲内存。然后一个新的分配缓冲区会被保留下来,用于以后的内存分配。

上述内存分配过程的汇编代码只是一小段指令,分析这些代码是很有价值的。

简单演示内存分配过程的C#代码如下。

class MyObject {
   int x;
   int y;
   int z;
}

static void Main(string[] args)
{
   var x = new MyObject();
}

首先,让我们分解一下。以下是调用内存分配函数的代码。

;把类的方法表指针拷贝到ecx中
;作为new()的参数
;可以用!dumpmt查看值
mov ecx,3F3838h

;调用new
call 003e2100

;把返回值(对象的地址)拷贝到寄存器中
mov edi,eax

下面是实际的分配函数。

;注意:为了格式统一,大部分代码的地址都未给出
;
;把eax值设为0x14,也就是需要分配给对象的内存大小
;数值来自于方法表
mov eax,dword ptr [ecx+4] ds:002b:003f383c=00000014

;把内存分配缓冲区数据写入edx
mov edx,dword ptr fs:[0E30h]

;edx+40存放着下一个可用的内存地址
;把其中的值加上对象所需大小,写入eax
add eax,dword ptr [edx+40h]

;把所需内存地址与分配缓冲区的结束地址进行比较
cmp eax,dword ptr [edx+44h]

;如果超出了内存分配缓冲区
;跳转到速度较慢的分配流程
ja 003e211b

;更新空闲内存指针(在旧值上增加0x14字节)
mov dword ptr [edx+40h],eax

;将指针减去对象大小
;指向新对象的起始位置
sub eax,dword ptr [ecx+4]

;将方法表指针写入对象的前4字节
;现在eax指向的是新对象
mov dword ptr [eax],ecx

;返回调用者
ret

;慢速分配流程(调用CLR方法)
003e211b  jmp clr!JIT_New (71763534)

总之,以上过程只用了1个直接方法调用和9条指令。完美无暇,无懈可击。

如果你的垃圾回收配置成服务器模式,内存分配过程就没有快速和慢速之分,因为每个处理器都有各自的内存堆。.NET的内存分配流程比较简单,而解除分配的过程则复杂得多,但这个复杂的过程不需要你直接处理。你只需要学习如何优化即可,也就是本章将教会你的内容。

本书之所以要从垃圾回收开始,是因为后续的很多内容都会与本章有关联。理解垃圾回收器对程序的影响,是获得理想性能的重要基础,垃圾回收器几乎会影响到其他所有的性能因素。

垃圾回收器的决策过程正在变得越来越优雅,特别是随着高性能系统越来越普遍地采用.NET环境。下面介绍的内容可能有一些会在未来的.NET版本中发生变化,但最近一段时间内好像还不太会发生整体性的改变。

在托管进程中存在两种内存堆(本机堆和托管堆)。本机内存堆(Native Heap)是由VirtualAlloc这个Windows API分配的,是由操作系统和CLR使用的,用于非托管代码所需的内存,比如Windows API、操作系统数据结构、很多CLR数据等。CLR在托管堆(Managed Heap)上为所有.NET托管对象分配内存,也被成为GC堆,因为其中的对象均要受到垃圾回收机制的控制。

托管堆又分为两种——小对象堆和大对象堆(LOH),两者各自拥有自己的内存段(Segment)。每个内存段的大小视配置和硬件环境而定,对于大型程序可以是几百MB或更大。小对象堆和LOH都可拥有多个内存段。

小对象堆的内存段进一步划分为3代,分别是0、1、2代。第0代和第1代总是位于同一个内存段中,而第2代可能跨越多个内存段,LOH也可以跨越多个内存段。包含第0代和第1代堆的内存段被称为暂时段(Ephemeral Segment)。

一开始内存堆就如下所示,两个内存段分别被标为A和B,内存地址从左到右由小变大。

小对象堆由A段内存构成,LOH拥有B段内存。第2代和第1代堆只占有开头的一点内存,因为它们还都是空的。

下面有必要介绍一下在小对象堆中分配内存的对象的生存期。如果对象小于85 000字节,CLR都会把它分配在小对象堆中的第0代,通常紧挨着当前已用内存空间往后分配。因此,正如本章开头所示,.NET的内存分配过程非常迅速。如果快速分配失败,对象就可能会被放入第0代内存堆中的任意地方,只要能容纳得下就行。如果没有合适的空闲空间,那么分配器就会扩大第0代内存堆,以便能存入新对象。如果扩大内存堆时超越了内存段的边界,则会触发垃圾回收过程。

对象总是诞生于第0代内存堆。只要对象保持存活,每当发生垃圾回收时,GC都会把它提升一代。第0代和第1代内存堆的垃圾回收有时候被称为瞬时回收(Ephemeral Collection)。

在发生垃圾回收时,可能会进行碎片整理(Compaction),也就是GC把对象物理迁移到新的位置中去,以便让内存段中的空闲空间能够连续起来以备使用。如果未发生碎片整理,那就只需要重新调整各块内存的边界即可。在经历了几次未做碎片整理的垃圾回收之后,内存堆的分布可能会如下所示。

对象的位置没有移动过,但各代内存堆的边界已经发生了变化。

每一代内存堆都有可能发生碎片整理。因为GC必须修正所有对象的引用,使它们指向新的位置,所以碎片整理的开销相对较大,还有可能需要暂停所有托管线程。正因如此,垃圾回收器只在划算(Productive)时才会进行碎片整理,判断的依据是一些内部指标。

如果对象到达了第2代内存堆,它就会一直留在那里直至终结。这并不意味着第2代内存堆只会一直变大。如果第2代内存堆中的对象都终结了,整个内存段也没有存活的对象了,垃圾回收器会把整个内存段交还给操作系统,或者作为其他几代内存堆的附加段。在进行完全垃圾回收(Full Garbage Collection)时,就可能发生这种第2代内存堆的回收。

那么“存活”是什么意思呢?如果GC能够通过任一已知的GC根对象(Root),沿着层层引用访问到某个对象,那它就是存活的。GC根对象可以是程序中的静态变量,或者某个线程的堆栈被正在运行的方法占用(用于局部变量),或者是GC句柄(比如固定对象的句柄,Pinned Handle),或是终结器队列(Finalizer Queue)。请注意,有些对象可能没有受GC根对象的引用,但如果是位于第2代内存堆中,那么第0代回收是不会清理这些对象的,必须等到完全垃圾回收才会被清理到。

如果第0代堆即将占满一个内存段,而且垃圾回收也无法通过碎片整理获取足够的空闲内存,那么GC会分配一个新的内存段。新的内存段会用于容纳第1代和第0代堆,老的内存段将会变为第2代堆。老的第0代堆中的所有对象都会被放入新的第1代堆中,老的第1代堆同理将提升为第2代堆(提升很方便,不必复制数据)。现在的内存段将如下所示。

如果第2代堆继续变大,就可能会跨越多个内存段。LOH堆同样也可能跨越多个内存段。无论存在多少个内存段,第0代和第1代总是位于同一个段中。以后我们想找出内存堆中有哪些对象存活时,这些知识将会派上用场。

LOH则遵从另一套回收规则。大于85 000字节的对象将自动在LOH中分配内存,且没有什么“代”的模式。超过这个尺寸的对象通常也就是数组和字符串了。出于性能考虑,在垃圾回收期间LOH不会自动进行碎片整理,但从.NET 4.5.1开始,必要时你也可以人为发起碎片整理。与第2代内存堆类似,如果LOH的内存不再有用了,就可能会被用于其他内存堆。不过我们以后将会看到,理想状态下你根本就不会愿意让LOH的内存被回收掉。

在LOH中,垃圾回收器用一张空闲内存列表来确定对象的存放位置。本章中我们会讨论一些减少LOH碎片的技巧。

注意

 

如果是在调试器中查看位于LOH的对象,你会发现有可能整个LOH都小于85 000字节,而且可能还有对象的大小是小于已分配值的。这些对象通常都是CLR分配出去的,可以不予理睬。

垃圾回收是针对某一代及其以下几代内存堆进行的。如果回收了第1代,则也会同时回收第0代。如果回收了第2代,则所有内存堆都会回收,包括LOH。如果发生了第0代或第1代垃圾回收,那么程序在回收期间就会暂停运行。对于第2代垃圾回收而言,有部分回收是在后台线程中进行的,这要根据配置参数而定。

垃圾回收包含4个阶段。

1.挂起(Suspension)——在垃圾回收发生之前,所有托管线程都被强行中止。

2.标记(Mark)——从GC根对象开始,垃圾回收器沿着所有对象引用进行遍历并把所见对象记录下来。

3.碎片整理(Compact)——将对象重新紧挨着存放并更新所有引用,以便减少内存碎片。在小对象堆中,碎片整理会按需进行,无法控制。在LOH中,碎片整理不会自动进行,但你可以在必要时通知垃圾回收器来上一次。

4.恢复(Resume)——托管线程恢复运行。

在标记阶段并不需要遍历内存堆中的所有对象,只要访问那些需要回收的部分即可。比如第0代回收只涉及到第0代内存堆中的对象,第1代回收将会标记第0代和第1代内存堆中的对象。而第2代回收和完全回收,则需遍历内存堆中所有存活的对象,这一过程的开销有可能非常大。这里有个小问题需要注意,高代内存堆中的对象有可能是低代内存堆对象的根对象。这样就会导致垃圾回收器遍历到一部分高代内存堆的对象,但这样的回收开销还是小于高代内存堆的完全垃圾回收。

由上述讨论可以形成以下几点重要结论。

第一,垃圾回收过程的耗时几乎完全取决于所涉及“代”内存堆中的对象数量,而不是你分配到的对象数量。这就是说,即使你分配了1棵包含100万个对象的树,只要在下一次垃圾回收之前把根对象的引用解除掉,这100万个对象就不会增加垃圾回收的耗时。

第二,垃圾回收的频率取决于所涉及“代”内存堆中已被占用的内存大小。只要已分配内存超过了某个内部阈值,就会发生该“代”垃圾回收。这个阈值是持续变化的,GC会根据进程的执行情况进行调整。如果某“代”回收足够划算(提升了很多对象所处的“代”),那垃圾回收就会发生得频繁一些,反之亦然。另一个触发垃圾回收的因素是所有可用内存,与你的应用程序无关。如果可用内存少于某个阈值,为了减少整个内存堆的大小,垃圾回收可能会更为频繁地发生。

由上所述,貌似垃圾回收是难以控制的,但事实不是这样。通过控制内存分配模式来控制垃圾回收的统计指标,就是一种最容易实现的优化方法。这需要理解垃圾回收的工作机制、可用的配置参数、你的内存分配率,还需要对对象的生存期有很好的控制能力。

.NET Framework对外提供的配置垃圾回收器的方法并不多,你最好不要去自寻烦恼(“…less rope to hang yourself with.”)。垃圾回收器的配置及调优,很大程度上由硬件配置、可用资源和程序的行为决定。屈指可数的几个参数也是用于控制很高层的行为,且主要取决于程序的类型。

最重要的垃圾回收参数选择是采用工作站(Workstation)模式还是服务器(Server)模式。

垃圾回收默认采用工作站模式。在工作站模式下,所有的GC都运行于触发垃圾回收的线程中,优先级(Priority)也相同。工作站模式非常适用于简单应用,特别是那些运行在人机交互型工作站(Interactive Workstation)上的应用,机器上会运行着多个托管进程。对于单处理器的计算机而言,工作站模式是唯一选择,配置成其他参数也是无效的。

在服务器模式下,GC会为每个逻辑处理器或处理器核心创建各自专用的线程。这些线程的优先级是最高的(THREAD_PRIORITY_HIGHEST),但在需要进行垃圾回收之前会一直保持挂起状态。垃圾回收完成后,这些线程会再次进入休眠(Sleep)状态。

此外,CLR还会为每个处理器创建各自独立的内存堆。每个处理器堆都包含1个小对象堆和1个LOH。从应用程序角度来看,就只有一个逻辑内存堆,你的代码不清楚对象属于哪一个堆,对象引用会在所有堆之间交叉进行(这些引用共用相同的虚拟地址空间)。

多个内存堆的存在会带来一些好处。

1.垃圾回收可以并行进行,每个垃圾回收线程负责回收一个内存堆。这可以让垃圾回收的速度明显快于工作站模式。

2.在某些情况下,内存分配的速度也会更快一些,特别是对LOH而言,因为会在所有内存堆中同时进行分配。

服务器模式还有一点与工作站模式不同,就是拥有更大的内存段,也就意味着垃圾回收的间隔时间可以更长一些。

请在app.config文件的<runtime>节点下把垃圾回收配置为服务器模式。

<configuration>
   <runtime>
    <gcServer enabled="true"/> 
   </runtime> 
</configuration>

到底是用工作站还是服务器模式进行垃圾回收呢?如果应用程序运行于专为你准备的多处理器主机上,那就无疑要选择服务器模式。这样在大部分情况下,都能让垃圾回收占用的时间降至最低。

不过,如果需要与多个托管进程共用一台主机,那么选择就不那么明确了。服务器模式的垃圾回收会创建多个高优先级的线程。如果多个应用程序都这么设置,那线程调度就会相互带来负面影响。这时可能还是选用工作站模式垃圾回收更好。

如果你确实想让同一台主机上的多个应用程序使用服务器模式的垃圾回收,还有一种做法,就是让存在竞争关系的应用程序都集中在指定的几个处理器上运行,这样CLR只会为这些处理器创建自己的内存堆。

无论你怎么选择,本书给出的大部分技巧对两种垃圾回收模式都适用。

后台垃圾回收(Background GC)只会影响第2代内存堆的垃圾回收行为。第0代和第1代的垃圾回收仍会采用前台垃圾回收,也就是会阻塞所有应用程序的线程。

后台垃圾回收由一个专用的第2代堆垃圾回收线程完成。对于服务器模式的垃圾回收而言,每个逻辑处理器都拥有一个额外的后台GC线程。没错,这就是说,如果采用服务器模式垃圾回收和后台垃圾回收,那每个处理器就会有两个GC专用线程,但这没什么值得特别关注的。拥有多个线程并不会为进程带来多大负担,特别是大多数线程在大部分时间都是无事可干的。

后台垃圾回收与应用程序的线程是并行发生的,但也有可能同时发生了阻塞式垃圾回收。这时,后台GC线程会和其他应用程序线程一起暂停运行,等待阻塞式垃圾回收的完成。

如果你正在使用工作站模式垃圾回收,那后台垃圾回收就会一直开启。从.NET 4.5开始,服务器模式垃圾回收中默认开启了后台垃圾回收,但你还是能够将其关闭的。

以下配置将会关闭后台垃圾回收。

<configuration>
   <runtime>
    <gcConcurrent enabled="false"/>
   </runtime>
</configuration>

在实际应用中,应该很少会有关闭后台垃圾回收的理由。如果你想阻止后台垃圾回收的线程占用应用程序的CPU时间,而且不介意完全垃圾回收和阻塞垃圾回收时可能增加的时间和频次,那就可以把它关闭。

如果你需要在一段时间内确保较高的性能,可以通知GC不要执行开销很大的第2代垃圾回收。请根据其他参数把GCSettings.LatencyMode属性赋为以下值之一。

因为不会再进行碎片整理了,所以这两种参数都会显著增加托管堆的大小。如果你的进程需要大量内存,就应该避免使用这种低延迟模式。

在即将进入低延迟模式前,最好是能强制执行一次完全垃圾回收,这通过调用GC.Collect(2, GCCollectionMode.Forced)即可完成。当代码离开低延迟模式后,马上再做一次完全垃圾回收。

请勿将低延迟模式作为默认模式来使用。低延迟模式确实是用于那些必须长时间不被中断的应用程序,但不是100%的时间都得如此。一个很好的例子就是股票交易,在开市期间,当然不希望发生完全垃圾回收。而在休市时间里,就可以关闭低延迟模式并执行完全垃圾回收,等到下一次开市时再切换回来。

仅当以下条件都满足时,才能开启低延迟模式。

因为存在潜在的不确定性,低延迟模式是很少用到的,你应该三思而后行。如果你觉得这种模式有用,请仔细进行性能评估以确保效果。开启低延迟模式可能会导致其他的性能问题,因为这会产生副作用。为了应对完全垃圾回收的缺失,瞬时回收(第0代和第1代垃圾回收)的频次会增加。你很可能是“按下了葫芦起了瓢”。

最后请注意,低延迟模式并不一定能保证生效。如果要在完全回收或抛出out of MemoryException之间做出选择,垃圾回收器可能会选择完全回收,这样你的设置就无效了。

这几乎无需多言,如果你减少了内存分配数量,也就减轻了垃圾回收器的运行压力,同时还可以减少内存碎片整理量和CPU占用率。要想减少内存分配量,得动些脑筋才行,还有可能与其他设计目标发生冲突。

请严格审查每一个对象,扪心自问一下。

故事

 

在一个需要处理用户请求的服务器程序中,我们发现有一类很常见的请求会导致内存分配量超过一整个内存段的大小。因为CLR对内存段的最大尺寸有限制,而第0代内存堆必须全部位于一个内存段中,因此每次请求都必定会发生一次垃圾回收。这种处境可不大妙,因为除了减少内存分配量几乎就别无选择了。

针对垃圾回收器,存在一条基本的高性能编码规则。其实垃圾回收器明显就是按照这条规则进行设计的:

只对第0代内存堆中的对象进行垃圾回收。

换句话说,对象的生存期应该尽可能短暂,这样垃圾回收器根本就不会去触及它们。或者做不到转瞬即逝,那就让对象尽快提升到第2代内存堆并永远留在那里,再也不会被回收。这意味着需要一直保持一个对长久存活对象的引用,通常这也意味着要把可重用的对象进行池化(Pooling),特别是LOH中的所有对象。

内存堆的代数越高,垃圾回收的代价就越大。应该确保大多数回收都是发生在第0代和第1代中,第2代回收应尽可能少。即便第2代堆开启了后台回收,CPU的开销也是你不愿承受的,毕竟处理器资源本该是为你的程序服务的。

注意

 

你也许听说过一种传言,发生10次第0代回收才应有1次第1代回收,10次第1代回收才能发生1次第2代回收。这不是真的,你只要明白,第0代回收很迅速应该尽量多一些,而第2代回收开销很大,所以尽可能要减少。

应该避免大部分第1代回收的发生,因为从第0代提升到第1代的对象,往往会被适时提升到第2代。第1代内存堆可以说是第2代堆的一种缓冲区。

理想状态下,所有对象都应该在下一次第0代回收到来之前离开作用域(Scope)。你可以测算出两次0代回收之间的间隔时间,并与数据在应用程序中的存活时间进行比较。本章的末尾将会介绍如何使用工具来获取这些信息。

如果你还没有习惯遵守本条规则,那就需要让自己的观念来一次根本转变。本规则会影响到应用程序的方方面面,因此请尽快适应并牢记于心。

对象的作用域越小,在垃圾回收时就越没有机会被提升到下一代。一般来说,对象在使用前不应该被分配内存。除非创建对象的开销太大,需要提早创建才不至于影响到其他操作的执行。

另外在使用对象时,应该确保对象尽快地离开作用域。对于局部变量而言,可能是最后一次局部使用之后,甚至可以在方法结束之前。你可以用成对的“{}”在语法上缩小作用域,但很可能没有什么实际效果,因为编译器通常会识别出对象何时会失效。如果你的代码要对某个对象进行多次操作,请尽量缩短第一次和最后一次使用的间隔,这样GC就能尽早地回收这个对象了。

如果某个对象的引用是一个长时间存活对象的成员,有时你得把这个引用显式地设置为null。这也许会稍微增加一点代码的复杂度,因为你得随时准备多检查一下null值,并且还有可能导致功能有效性和完整性之间的矛盾,特别是在调试的时候。

有一种做法是将需要设置为null的对象转换为其他格式(比如日志信息),这样就可以更有效地记录下状态,以备后续调试时使用。

另一种平衡功能性和完整性的做法,就是专为调试作出临时修改,让程序(或满足特定需求的部分功能)运行时不对引用设置null,尽可能保持存活。

正如本章开头所述,GC将会沿着对象引用遍历。在服务器模式GC中,一次会有多个线程同时遍历。你肯定希望能尽可能地利用这种并发机制,但如果有某个线程陷入一条很长的嵌套对象链中,那么整个垃圾回收过程就得等这个线程完成工作后才会结束。如果CLR的版本比较新,这种影响会轻微一些,因为目前GC线程采用了work-stealing算法来更好地平衡负载。如果你怀疑代码中有很深的对象树存在,那么检查一下还是有好处的。

这条与前一节的对象树深度有关联,但还有一些其他因素需要考虑。

如果对象引用了很多其他对象,垃圾收集器对其遍历时就要耗费更多的时间。如果垃圾回收引起的暂停时间较长,往往意味着有大型、复杂的对象间引用关系存在。

如果难以确定对象所有的被引用关系,那还有一个风险就是很难预测对象的生存期。减少对象引用的复杂度,不仅对提高代码质量有利,而且可以让代码调试和修正性能问题变得更加容易。

另外还要注意,不同代的内存堆之间的对象引用可能会导致垃圾回收器的低效运行,特别是从老对象中引用新对象的情况。比如第2代内存堆中有个对象包含了对第0代内存堆对象的引用,这样每次第0代垃圾回收时,总有一部分第2代内存堆中的对象不得不被遍历到,以便确认它们是否还持有对第0代对象的引用。这种遍历的代价虽然没有像完全垃圾回收那么高,但不必要的开销还是能免则免。

对象固定(Pinning)是为了能够安全地将托管内存的引用传递给本机代码。最常见的用处就是传递数组和字符串。如果不与本机代码进行交互,就完全不应该有对象固定的需求。

对象固定会把内存地址固定下来,垃圾回收器就无法移动这类对象。虽然固定操作本身开销并不大,但会给垃圾回收工作造成一定困扰,增加出现内存碎片的可能。垃圾回收器是会记住那些被固定的对象,以便能利用固定对象之间的空闲内存,但如果固定对象过多,还是会导致内存碎片的产生和内存堆的扩大。

对象固定既可能是显式的,也可能是隐式的。使用GCHandleType.Pinned类型的GCHandle或者fixed关键字,可以完成显式对象固定,代码块必须标记为unsafe。用关键字fixed和GCHandle之间的区别类似于using和显式调用Dispose的差别。fixed/using用起来更方便,但无法在异步环境下使用,因为异步状态下不能传递handle,也不能在回调方法中销毁handle。

隐式的对象固定更为普遍,但也更难被发现,消除则更困难。最明显的来源就是通过P/Invoke传给非托管代码的所有对象。这种P/Invoke并不仅仅是由你编写的代码发起的,你调用的托管API可以而且经常会调用本机代码,也都需要对象固定。

CLR的内部数据结构中也会有些被固定的对象,但这些对象通常不必理会。

理想状态下,应该尽可能消除对象固定。如果真的做不到,请参照缩短托管对象生存期的规则,尽可能地缩短固定对象的生存期。如果对象只是暂时被固定,那影响下一次垃圾回收的机会就比较少。你还应该避免同时固定很多对象。位于第2代堆或LOH中的固定对象一般不会有问题,因为移动这些对象的可能性比较小。这样就产生了一种优化策略,可以在LOH中分配大块缓冲区然后按需切分,或者在小对象堆中分配小块缓冲区并保证对象在被固定前提升到第2代。实施这个策略会把一部分内存管理的任务放到你的肩上,但可以完全避免第0代垃圾回收时碰到固定内存区域的问题。

若非必要,永远不要实现终结方法(Finalizer)。终结方法是一段由垃圾回收器引发调用的代码,用于清理非托管资源。终结方法由一个独立的线程调用,排成队列依次完成,而且只有在一次垃圾回收之后,对象被垃圾回收器声明为已销毁,才会进行调用。这就意味着,如果类实现了终结方法,对象就一定会滞留在内存中,即便是在垃圾回收时应该被销毁的情况下。终结方法不仅会降低垃圾回收的整体效率,而且清理对象的过程肯定会占用CPU资源。

如果实现了终结方法,那就必须同时实现IDisposable接口以启用显式清理,还要在Dispose方法中调用GC.SuppressFinalize(this)来把对象从移除终结队列中移除。只要能在下一次垃圾回收之前调用Dispose,那就能适时把对象清理干净,也就不需要运行终结方法了。以下代码演示了正确的实现方式。

class Foo : IDisposable 
{ 
  ~Foo() 
  { 
    Dispose(false); 
  } 

  public void Dispose() 
  { 
    Dispose(true); 
    GC.SuppressFinalize(this); 
  } 

  protected virtual void Dispose(bool disposing) 
  { 
    if (disposing) 
    { 
      this.managedResource.Dispose(); 
    }
    // 清理非托管资源
    UnsafeClose(this.handle); 
    // 如果基类是IDisposable
    // 请务必调用
    //base.Dispose(disposing); 
  }
}

关于Dispose模式和终结过程的详细信息,请查阅http://www.writinghighperf. net/go/15。

注意

 

有些人以为终结方法肯定会被执行到。一般情况下确实如此,但并不绝对。如果程序被强行终止,就不会再运行任何代码,进程也会立即被销毁。而且即便是在进程正常关闭时,所有终结方法的总运行时间也是有限制的。如果你的终结方法被排在了队列的末尾,就有可能被忽略掉。此外,因为终结方法是逐个执行的,如果某个终结方法陷入死循环,那么排在后面的终结方法就都无法运行了。虽然终结方法不是运行在GC线程中,但仍需由GC引发调用。如果没有发生垃圾回收,那么终结方法就不会运行。因此,请勿依靠终结方法来清理当前进程之外的状态数据。

大对象的界限被设为85 000字节,判断的依据是基于当天的统计学分析。任何大于这个值的对象都被认为是大对象,并在独立的内存堆中进行分配。

应该尽可能避免在LOH中分配内存。不仅是因为LOH的垃圾回收开销更大,更多原因是因为内存碎片会导致内存用量不断增长。

为了避免这些问题,需要严格控制程序在LOH中的分配。LOH中的对象应该在整个程序的生存期都持续可用,并以池化的方式随时待命。

LOH不会自动进行碎片整理,但自.NET 4.5.1开始可以通过代码发起整理。不过你只能把它当作最后的手段,因为碎片整理会导致长时间的系统暂停。在介绍如何发起碎片整理之前,我会首先介绍如何避免陷入这种被动的局面。

任何时候都应该避免复制数据。比如你已经把文件数据读入了MemoryStream(如果需要较大的缓冲区,最好是用池化的流),一旦内存分配完毕,就应把此MemoryStream视为只读流,所有需要访问MemoryStream的组件都能从同一份数据备份中读取数据。

如果需要表示整个缓冲区的一段,请使用ArraySegment<T>类,可用来代表底层byte[]类型缓冲区的一部分区域。此ArraySegment可以传给API,而与原来的流无关,甚至可以被绑定到一个新的MemoryStream对象上。这些过程都不会发生数据复制。

var memoryStream = new MemoryStream(); 
var segment = new ArraySegment<byte>(memoryStream.GetBuffer(), 100, 1024); 
... 
var blockStream = new MemoryStream(segment.Array,  
                       segment.Offset,  
                       segment.Count);

内存复制造成的最大影响肯定不是CPU,而是垃圾回收。如果你发现自己有复制缓冲区的需求,那就尽量把数据复制到另一个池化的或已存在的缓冲区中,以避免发生新的内存分配。

还记得前面介绍过的基本规则吧,对象要么转瞬即逝,要么一直存活;要么在第0代垃圾回收时消失,要么就在第2代内存堆中一直留存下去。有些对象基本上是静态的,伴随程序自然诞生,并在程序生存期间保持存活。还有一些对象看不出有一直存活的必要,但它们在程序上下文中体现出来的生存期,决定了它们会历经第0代(也可能是第1代)垃圾回收并仍然存活下去。应该考虑对这类对象进行池化,虽然池化实际上是一种人工的内存管理策略,但在这种场合却真的收效甚佳。另一种强烈推荐池化的对象,就是在LOH中分配的对象,典型例子就是集合类对象。

池化的方法没有一定之规,也没有标准的API可用,确实只能自己开发,可以针对整个应用,也可以只为特定的池化对象服务。

可以这么来看待可池化的对象,就是把通常是被托管的资源(内存)转由你自己掌控。.NET已提供了一种针对受限托管资源的处理模式——IDisposable模式,正确的实现方式请参阅本章之前的内容。比较合理的设计是派生一个新类型并实现IDisposable接口,在Dispose方法中将池化对象归还共享池(Pool)。这样就会给用户一个强烈暗示,这种资源需要进行特殊处理。

实现高质量的池化策略并非易事,有可能完全取决于程序的使用需求,以及被池化对象的类型。以下代码示例了一个简单的池化类,有助于你了解需要考虑的因素,代码来自PooledObjects例程。

interface IPoolableObject : IDisposable 
{ 
  int Size { get; } 
  void Reset(); 
  void SetPoolManager(PoolManager poolManager); 
} 

class PoolManager 
{ 
  private class Pool 
  { 
    public int PooledSize { get; set; } 
    public int Count { get { return this.Stack.Count; } } 
    public Stack<IPoolableObject> Stack { get; private set; } 
    public Pool() 
    { 
      this.Stack = new Stack<IPoolableObject>(); 
    } 

  } 
  const int MaxSizePerType = 10 * (1 << 10); // 10 MB 

  Dictionary<Type, Pool> pools =  
    new Dictionary<Type, Pool>(); 

  public int TotalCount 
  { 
    get 
    { 
      int sum = 0; 
      foreach (var pool in this.pools.Values) 
      { 
        sum += pool.Count;
       } 
      return sum; 
    } 
  } 

  public T GetObject<T>()  
    where T : class, IPoolableObject, new() 
  { 
    Pool pool; 
    T valueToReturn = null; 
    if (pools.TryGetValue(typeof(T), out pool)) 
    { 
      if (pool.Stack.Count > 0) 
      { 
        valueToReturn = pool.Stack.Pop() as T; 
      } 
    } 
    if (valueToReturn == null) 
    { 
      valueToReturn = new T(); 
    } 
    valueToReturn.SetPoolManager(this); 
    return valueToReturn; 
  } 

  public void ReturnObject<T>(T value)  
    where T : class, IPoolableObject, new() 
  { 
    Pool pool; 
    if (!pools.TryGetValue(typeof(T), out pool)) 
    { 
      pool = new Pool(); 
      pools[typeof(T)] = pool; 
    } 

    if (value.Size + pool.PooledSize < MaxSizePerType) 
    { 
      pool.PooledSize += value.Size; 
      value.Reset();         
      pool.Stack.Push(value); 
    } 
  } 
}
class MyObject : IPoolableObject 
{ 
  private PoolManager poolManager; 
  public byte[] Data { get; set; } 
  public int UsableLength { get; set; } 

  public int Size 
  { 
    get { return Data != null ? Data.Length : 0; } 
  } 

  void IPoolableObject.Reset() 
  { 
    UsableLength = 0; 
  } 

  void IPoolableObject.SetPoolManager( 
    PoolManager poolManager) 
  { 
    this.poolManager = poolManager; 
  } 

  public void Dispose() 
  { 
    this.poolManager.ReturnObject(this); 
  } 
}

被池化对象必须要实现自定义接口,看起来有点工作量,但是除了增加麻烦之外还说明了一个重要事实——为了实现池化和对象重用,你必须完全了解并掌控这些对象。在每次把池化对象归还共享池时,你的代码必须把对象重置为已知的、安全的状态。这意味着你不能天真地把第三方对象直接进行池化。通过由自己的对象实现自定义接口,你释放出一个非常强烈的信息:这是一个特殊的对象。在池化.NET Framework已提供的对象时,你应该特别小心。

池化对象的回收也是一件特别棘手的事情,因为你不是真的要销毁内存(这也是池化的全部意义所在),但你必须能通过可用空间表示出“空集合”的概念。幸好大部分集合类都同时实现了Length和Capacity属性,分别表示集合大小和池的大小。既然池化.NET已提供的集合类存在风险,你最好还是利用标准的集合接口实现自己的集合类,比如IList<T>、ICollection<T>等。关于如何创建自己的集合类,在第7章会有一些基本的指导。

另外还有一条策略就是,为你的可池化类实现终结方法,以作为保险机制。如果终结方法得以运行,就意味着Dispose没被调用过,也就是存在错误。这时可以把信息写入日志,可以让程序异常终止,或者是把错误信息显示出来。

请记住共享池中的对象永远不会被销毁,这与内存泄漏很难区分开。共享池的尺寸应该限定边界(字节数或是对象数),只要超过了规定大小,就应该把对象扔给GC进行清理。理想状态下,共享池的尺寸应该要能满足正常工作,不应抛弃任何对象,只有当内存占用达到顶峰时才需要GC的介入。根据共享池的大小和池中的对象数量,销毁可能会引发长时间的完全垃圾回收。请确保你的共享池大小能够完美匹配使用需求。

故事

 

通常我不会把池化作为默认解决方案。如果是把池化作为一种通用的解决方案,那它太过笨拙,也很容易出错。当然你可能会发现,针对某些类进行池化,确实能让应用程序受益。在一个因大量LOH分配而饱受困扰的应用程序中,我们发现如果把某类对象池化,就能消除99%的LOH问题。这类对象就是MemoryStream,我们用它来序列化并通过网络传递数据。因为需要避免触发碎片整理,实际的池化实现代码要复杂得多,不只是把MemoryStream放入队列那么简单,但从概念上讲确实如此。每次MemoryStream对象被销毁后,都会被释放到池中去等待下一次重用。

如果做不到完全避免LOH分配,那你就应该尽力避免碎片整理。

LOH的体积稍不留神就会持续增大,通过空闲内存列表可以减轻这种现象。为了能让空闲内存列表发挥最大作用,应该提高每次内存分配时空闲内存块都能满足要求的可能性。

有一种方法可以提高这种可能性,就是保证LOH的每次分配都是统一尺寸的,或者至少也是几种标准尺寸的组合。比如LOH的一种常规用途就是缓冲区池,不能让各个缓冲区大小不一,而应该所有缓冲区都具有相同尺寸,或者是由几块固定大小(比如1MB)的内存组成。如果某一块缓冲区确实需要被垃圾回收了,那么下一次分配的缓冲区就有很大概率会落在这块空闲内存上,而不会被放到堆的末尾去。

故事

 

还是接着上一个关于MemoryStreams池化的故事。第一种PooledMemoryStream实现方式是将流视为一个整体,允许缓冲区无限增长。增长算法就由底层MemoryStream提供,每当容量不足时缓冲区大小就会翻倍。这种方式解决了很多LOH问题,但也产生了严重的碎片问题。第二种方案抛弃了第一种思路,主张对多个独立的byte[]缓冲区进行池化,每个缓冲区的大小是128KB。多个小缓冲区连起来组成一个大的虚拟缓冲区,再从大缓冲区中抽象出流对象。我们把大缓冲区分成多种尺寸,从1MB到8MB不等。新的实现方式显著减少了碎片问题,当然也是有代价的。当需要用到一整块连续的缓冲区时,偶尔我们不得不把多个128KB缓冲区中的数据复制到一个1MB的缓冲区中去。但因为所有缓冲区都已经池化了,这点代价还是划算的。

绝大部分情况下,除了GC正常的调度计划中安排的之外,你不应该再强制执行完全垃圾回收。那样会干扰垃圾回收器的自动调优活动,还可能导致整体性能下降。不过在某些特定的情况下,高性能系统中的某些因素可能会让你重新考虑这条建议。

通常,为了避免以后发生不合时宜的完全垃圾回收过程,在某个更合适的时间段强制执行一次完全回收也许会有所收益。请注意我们现在只讨论完全垃圾回收,它开销较大,理想状态下应该很少发生。为了避免第0代堆的尺寸过大,第0代和第1代垃圾回收可以而且应该经常执行。

值得进行强制完全回收的场合可能有以下这些:

1.你采用了低延迟GC模式。这种模式下内存堆的大小可能会一直增长,需要适时进行一次完全垃圾回收。关于低延迟GC模式,请阅读本章前面的相关内容。

2.偶尔你会创建大量对象,并会存活很长时间(理想状态是一直保持存活)。这时最好是把这些对象尽快提升到第2代内存堆中。如果这些对象覆盖了即将成为垃圾的其他对象,通过一次强制垃圾回收就能立即销毁这些垃圾对象。

3.你正处于要对LOH进行碎片整理的状态。请参阅LOH碎片整理的章节。

第1种和第2种情况都是为了避免在特定时间段发生完全垃圾回收,所以才在其他时间强制完成。第3种情况是在LOH里内存碎片很严重时,减小整个内存堆的尺寸。如果这3种情况都不符合,你就不应该考虑强制执行完全垃圾回收。

调用GC.Collect方法,参数为需要回收的代数,即可执行完全垃圾回收。此外还可以附带一个参数,值为GCCollectionMode枚举,指明完全回收的时间由GC决定。参数值有3种可能。

GC.Collect(2); 
// 等效于:
GC.Collect(2, GCCollectionMode.Forced);

故事

 

以下情形发生在某个用于响应用户请求的服务程序中。每过几个小时我们就需要重新载入并替换现有数据,数量超过1GB。因为载入过程开销很大,我们不但要把收到的请求数减少,还要在数据载入后强制执行2次完全垃圾回收。强制回收会把旧数据清除干净,并保证在第0代内存堆中的对象或被回收或被提升为第2代。这样等查询请求恢复到满负荷状态后,就不会让开销巨大的完全垃圾回收影响响应速度了。

即便是做了池化处理,还是有可能存在无法控制的内存分配,LOH也会逐渐变得碎片化。自.NET 4.5.1开始,你可以指挥GC在下一次完全垃圾回收时进行一次碎片整理。

GCSettings.LargeObjectHeapCompactionMode =
  GCLargeObjectHeapCompactionMode.CompactOnce;

碎片整理的过程可能会比较缓慢,多达几十上百秒,这要视LOH的尺寸而定。你也许应该让程序进入一种闲置状态,再调用GC.Collect方法来强制进行一次立即执行的完全垃圾回收。

这种碎片整理的设置只会影响下一次完全垃圾回收。一旦下一次完全垃圾回收开始进行,GCSettings.LargeObjectHeapCompactionMode就会被自动重置为GCLargeObjectHeap CompactionMode.Default。

因为这种碎片整理的开销很大,我建议你要尽量减少LOH的分配量,并进行必要的对象池化,以便能显著减少碎片整理的需求。请把这种碎片整理作为最后的手段,仅当碎片和过大的内存堆已经成为系统问题时才予以考虑。

如果你的应用程序绝对不能受到第2代垃圾回收的破坏,那么可以让GC在即将执行完全垃圾回收时通知你。这样你就有机会暂停程序的运行,也许是停止向这台主机发送请求,或者是让你的应用程序进入更合适的状态。

这种通知机制貌似能一揽子解决所有的垃圾回收问题,但我还是提醒你要特别小心。只有在尽可能完成了其他优化之后,最后再考虑采用这一招。仅当以下条件都成立时,你才能从垃圾回收通知中受益:

1.完全垃圾回收的开销过大,以至于程序在正常运行期间无法承受。

2.你可以完全停止程序的运行(也许这时的工作可以由其他计算机或处理器承担)。

3.你可以迅速停止程序运行(停止运行的过程不会比真正执行垃圾回收的时间更久,你就不会浪费更多的时间)。

4.第2代垃圾回收很少发生,因此执行一次还是划算的。

只有在大对象和高于第0代的对象都已最大程度地减少时,第2代垃圾回收才会很少发生。所以要想真正受益于垃圾回收通知,前期还有相当多的工作要准备。

不幸的是,因为垃圾回收的触发时机并不确定,你只能用1~99的数字粗略指定获得通知的提前量。数字越小,表示离垃圾回收的时间就越近,就越有可能在你准备就绪之前就启动垃圾回收了。数字越大,离垃圾回收的时间可能就越久,你收到通知的频率就会越高,程序的运行效率也就不高。提前量的取值完全取决于内存的分配量和整体占用情况。请注意需要指定两个数值,一个是第2代内存堆的阈值,另一个是LOH的阈值。和其他特性一样,垃圾回收器对通知机制只是“尽力而为”。垃圾回收器从不保证你一定能及时躲开垃圾回收的发生。

请按以下步骤使用垃圾回收通知机制。

1.调用GC.RegisterForFullGCNotification方法,参数是两个阈值。

2.调用GC.WaitForFullGCApproach方法轮询(Poll)垃圾回收状态,可以一直等待下去或者指定一个超时值。

3.如果WaitForFullGCApproach方法返回Success,就将你的程序转入可接受完全垃圾回收的状态(比如切断发往本机的请求)。

4.调用GC.Collect方法手动强制执行一次完全垃圾回收。

5.调用GC.WaitForFullGCComplete(仍可指定一个超时值)等待完全垃圾回收的完成。

6.重新开启请求。

7.如果不想再收到完全垃圾回收的通知,请调用GC.CancelFullGCNotification方法。

因为要用到轮询机制,你需要在一个线程中周期性完成检查任务。很多应用程序已经实现了一些“内务”(Housekeeping)线程,用于执行各种计划任务。轮询操作也可以算是一种合适的计划任务,或者你也可以创建一个单独的线程专门完成轮询操作。

以下是来自GCNotification项目的一个完整示例,这里的测试程序会不停地分配内存。请运行随书所附源码来进行测试。

class Program 
{ 
  static void Main(string[] args) 
  { 
    const int ArrSize = 1024; 
    var arrays = new List<byte[]>(); 

    GC.RegisterForFullGCNotification(25, 25); 

    // 启动一个单独的线程等待接收垃圾回收通知 
    Task.Run(()=>WaitForGCThread(null));   

    Console.WriteLine("Press any key to exit"); 
    while (!Console.KeyAvailable) 
    { 
      try 
      { 
        arrays.Add(new byte[ArrSize]); 
      } 
      catch (OutOfMemoryException) 
      { 
        Console.WriteLine("OutOfMemoryException!"); 
       arrays.Clear(); 
      } 
    } 

    GC.CancelFullGCNotification(); 
  } 

  private static void WaitForGCThread(object arg) 
  { 
    const int MaxWaitMs = 10000; 
    while (true) 
    { 
      // 无限期地等待还是会让WaitForFullGCApproach过载
      GCNotificationStatus status =     
                        GC.WaitForFullGCApproach(MaxWaitMs); 
      bool didCollect = false; 
      switch (status) 
      { 
        case GCNotificationStatus.Succeeded: 
          Console.WriteLine("GC approaching!"); 
          Console.WriteLine( 
             "-- redirect processing to another machine -- "); 
          didCollect = true; 
          GC.Collect(); 
          break; 
        case GCNotificationStatus.Canceled: 
          Console.WriteLine("GC Notification was canceled"); 
          break; 
        case GCNotificationStatus.Timeout: 
          Console.WriteLine("GC notification timed out"); 
          break; 
      } 

      if (didCollect) 
      { 
        do 
        { 
          status = GC.WaitForFullGCComplete(MaxWaitMs); 
          switch (status) 
          { 
            case GCNotificationStatus.Succeeded: 
              Console.WriteLine("GC completed"); 
              Console.WriteLine( 
              "-- accept processing on this machine again --"); 
              break; 
            case GCNotificationStatus.Canceled:
              Console.WriteLine("GC Notification was canceled"); 
              break; 
            case GCNotificationStatus.Timeout: 
              Console.WriteLine("GC completion notification timed out"); 
              break; 
          } 
          // 这里的循环不一定有必要
          // 但如果你在进入下一次等待之前还需要检查其他状态,那么就有用了
        } while (status == GCNotificationStatus.Timeout);         
      } 
    } 
  } 
}

另一种可能要用到通知的理由就是要对LOH进行碎片整理,你可能想根据内存使用量来进行整理,这样也许更合理一点。

弱引用(Weak Reference)指向的对象允许被垃圾回收器清理。与之相反,强引用(Strong Reference)会完全阻止所指对象被垃圾回收。有些对象内存开销很大,我们原本是希望它们能长期存活,但在内存实在吃紧时也愿意释放出来。弱引用最大的用处就是缓存这种对象。

WeakReference weakRef = new WeakReference(myExpensiveObject); 
… 
// 创建强引用
// 现在就不会被GC考虑了
var myObject = weakRef.Target; 
if (myObject != null) 
{ 
  myObject.DoSomethingAwesome(); 
}

WeakReference带有一个IsAlive属性,但只能用于确认对象是否已经消亡,而不能判断是否存活。如果发现IsAlive属性为true,那么你就是在与垃圾回收器赛跑,可能就在你查看IsAlive属性之后对象就被回收了。如果你非要这么用,则只能先把弱引用复制给你自己的强引用再进行判断。

WeakReference的一种上佳用途就是构建对象缓存区(Cache),有些对象一开始由强引用创建,经过相当长的时间后就会失去作用,然后可被降级由弱引用来保存,最终就可能会被销毁。

在本节中,你将学习很多研究GC堆运行状况的技术。很多时候,多种不同的工具会给出相同的信息,每个场景我都会尽量多介绍几种适用的工具。

.NET为内存堆提供了一些Windows性能计数器,都归在.NET CLR Memory类别下。除了Allocated Bytes/sec之外,所有计数器数据都在垃圾回收完成之后更新。如果你发现数据没有变化,那么可能是因为垃圾回收发生的频率不高。

CLR会发布大量的GC事件。大多数情况下,你都可以依靠工具软件对这些事件进行统计分析。但如果你需要跟踪特定事件,并与你的应用程序的其他事件关联分析,那么理解事件数据的记录方式就很有意义了。你可以在PerfView的“Events”视图中查看事件的详细信息。下面列出一些最重要的事件。

其他事件还有很多,比如垃圾回收期间的终结方法和线程控制。更多信息可以查看http://www.writinghighperf.net/go/16。

GC为自己的运行过程记录了很多事件。你可以用PerfView以多种方式高效浏览这些事件。

为了查看GC的状态,请启动AllocateAndRelease示例。

启动PerfView并按以下步骤进行。

1.选择“Collect| Collect”(按下Alt+C)。

2.展开“Advanced Options”。你可以选择把GC之外的其他所有事件类型都关闭,但这次还是保留默认选项,因为GC事件包含在.NET事件当中。

3.勾选“No V3.X NGEN Symbols”(这会加快符号解析的速度)。

4.单击“Start Collection”。

5.等待几分钟,PerfView正在评估进程的运行情况。(如果数据收集过程过长,你可以考虑关闭CPU事件的收集。)

6.单击“Stop Collection”。

7.等待文件合并完成。

8.在结果树中双击“GCStats”节点,会弹出一个新的页面。

9.找到你的进程查看数据汇总表,给出了每代回收的平均暂停时间、已发生的垃圾回收次数、已分配的内存字节数等很多信息。图2-1是汇总表示例。

图2-1 AllocateAndRelease示例程序的GCStats表。它给出了垃圾回收次数、平均(Mean)/最大(Max)暂停次数和内存分配速度等状态信息

如果要查看内存是为哪些对象分配的、何时分配的,那么,PerfView就是一个很好的工具。

1.PerfView既可以收集.NET事件,也可以只收集GC事件。

2.收集完毕后,打开“GC Heap Alloc Ignore Free (Coarse Sampling) Stacks”[1]视图,在进程列表中选择相应进程(可以用AllocateAndRelease例程为例)。

3.在“By Name”页将给出按总分配大小排序的所有类型的内存分配。双击类型名称就会跳转到“Callers”页,显示引发内存分配的调用栈,如图2-2所示。

图2-2 GC Heap Alloc Ignore Free (Coarse Sampling) Stacks视图显示出最常用的进程内存分配情况。“LargeObject”项是一个伪节点,双击后会打开LOH中分配内存的真实对象

关于充分利用PerfView视图的更多信息,请参阅第1章。

通过上述信息,你应该能弄明白示例程序中所有内存分配行为的调用栈,以及相对频率(Relative Frequency)。比如在我的分析过程中,String的分配数大概是占了总内存分配次数的59.7%。

用CLR Profiler也可以查看这些信息,并以多种形式显示出来。

每当完成一次数据收集,就会打开“Summary”窗口。再单击“Allocation Graph”按钮,就会打开图形化界面,显示出对象分配的跟踪过程及相应的方法名称,如图2-3所示。

图2-3 CLR Profiler可视化展示了对象分配的调用栈,能把最需要关注的对象迅速地显示出来

Visual Studio性能分析工具也能获取这些内存分配信息并像CPU采样数据那样显示出来。

内存分配频率最高的对象基本上也就是触发垃圾回收最多的,减少这类对象的内存分配就能降低垃圾回收的发生频率。

为了确保系统的性能,知道LOH中当前已分配的对象是至关重要的。本章介绍的第一条原则就是,所有对象都应该在第0代垃圾回收时得以清理,不然就该永久存活下去。

大对象只能由开销巨大的第2代垃圾回收进行清理,因此根本就不遵守上述原则。

利用PerfView,按照之前获取GC事件的步骤操作,就可以查看LOH中存在的对象。在“GC Heap Alloc Ignore Free (Coarse Sampling) Stacks”视图的“By Name”页中,你会发现一个名为“LargeObject”的特殊节点,这是由PerfView生成的,双击它就会跳转到“Callers”页,显示出LargeObject的所有“调用者”。在前面的示例程序中,大对象都是Int32型的数组。逐个双击这些对象,就会显示发生内存分配的方法,如图2-4所示。

图2-4 PerfView可以把大对象、对象类型、分配内存时的调用栈一并显示出来

CLR Profiler也能显示LOH堆中的对象类型。在获得分析跟踪的结果后,单击“View Objects by Address”按钮,将会打开一个直观的图形,用不同颜色标注出内存堆中的各种对象,如图2-5所示。

图2-5 用CLR Profiler可视化地展示LOH中已分配内存的对象

如果要查看对象的调用栈,右击相应的类型,选择Show Who Allocated即可。在弹出的窗口中将会显示内存分配示意图,结果类似于PerfView,只是变成了彩色版,如图2-6所示。

图2-6 用CLR Profiler可视化地展示对象分配的调用栈

PerfView还可以将整个内存堆都转储出来(Dump),然后将对象之间的关系全部显示出来。PerfView将用堆栈视图显示结果,但与其他方式的堆栈视图有细微的差别。比如在“GC Heap Alloc Ignore Free (Coarse Sampling) Stacks”视图中,你看到的是已分配内存对象的调用栈,而“GC Heap Dump”视图则以堆栈形式展示了对象的引用关系,也就是对象的“拥用者”。

请在PerfView中按以下步骤查看内存堆中的全部对象。

1.在“Memory”菜单中选择“Heap Snapshot”。请注意这不会暂停进程的运行(除非勾选了“Freeze”选项),但会明显影响进程的运行性能。

2.在结果对话框中高亮选中需要监视的进程。

3.单击“Dump GC Heap”。

4.等待数据收集完成,关闭窗口。

5.从PerfView的文件树中打开文件(可能在关闭数据收集窗口后会自动打开)。

让我们来看一下LargeMemoryUsage示例程序。

class Program 
{ 
  const int ArraySize = 1000; 
  static object[] staticArray = new object[ArraySize]; 

  static void Main(string[] args) 
  { 
    object[] localArray = new object[ArraySize]; 

    Random rand = new Random(); 
    for (int i = 0; i < ArraySize; i++) 
    { 
      staticArray[i] = GetNewObject(rand.Next(0, 4)); 
      localArray[i] = GetNewObject(rand.Next(0, 4)); 
    } 

    Console.WriteLine( 
          "Use PerfView to examine heap now. Press any key to exit..."); 
    Console.ReadKey(); 

    // 在获得内存堆快照之前,阻止localArray被垃圾回收
    Console.WriteLine(staticArray.Length); 
    Console.WriteLine(localArray.Length); 
  } 

  private static Base GetNewObject(int type) 
  { 
    Base obj = null; 
    switch (type) 
    { 
      case 0: obj = new A(); break; 
      case 1: obj = new B(); break; 
      case 2: obj = new C(); break; 
      case 3: obj = new D(); break; 
    } 
    return obj; 
  } 
} 

class Base 
{ 
  private byte[] memory; 
  protected Base(int size) { this.memory = new byte[size]; } 
} 

class A : Base { public A() : base(1000) { } } 
class B : Base { public B() : base(10000) { } } 
class C : Base { public C() : base(100000) { } } 
class D : Base { public D() : base(1000000) { } }

你将看到如图2-7所示的结果。

图2-7 PerfView对大对象的跟踪分析结果

你立即会发现,D类对象占用了462MB 内存,相当于整个程序所占内存的88%,其中包含了924个对象。你还可以发现局部变量占用的内存高达258MB,staticArray对象居然占用了263MB内存。

在D类上双击,跳转到“Referred-From”视图,显示效果如图2-8所示。

图2-8 PerfView以表格的形式显示对象关系栈,只要习惯了就很容易理解

以上视图清晰地显示出,D对象属于staticArray变量和1个局部变量(经过编译失去了变量名)。

Visual Studio 2013自带了一个新的“Managed Heap Analysis[2]”视图,与PerfView的思路很相似。你可以在打开一个托管内存转储文件后使用这个视图,如图2-9所示。

图2-9 Visual Studio 2013包含了托管堆分析视图,能对托管内存转储文件进行分析

CLR Profiler也可以用图形化的方式显示同样的内存堆信息。在你的应用程序运行的时候,单击“Show Heap now”按钮开始收集内存堆样本数据。生成的结果视图如图2-10所示。

图2-10 CLR Profiler展示的信息与PerfView类似,只是变得图形化了

如果要弄清楚某个对象为什么没有被回收,你需要找出谁在引用这个对象。之前关于内存堆转储的一节中,已经介绍了查看对象正在被谁引用的方法,那就是阻止垃圾回收的原因。

如果你关注的是某个特定的对象,那么你可以使用Windbg。只要有了对象的内存地址,你就可以用!gcroot命令。

0:003> !gcroot 02ed1fc0  
HandleTable: 
  012113ec (pinned handle) 
  -> 03ed33a8 System.Object[] 
  -> 02ed1fc0 System.Random 

Found 1 unique roots (run '!GCRoot -all' to see all roots).

如果要在Windbg中获取对象的内存地址,你可以用!dso命令把当前堆栈中的所有对象都转储出来,或者用!DumpHeap命令在内存堆中找到所需对象,如下所示。

0:004> !DumpHeap -type LargeMemoryUsage.C 
 Address     MT   Size 
021b17f0 007d3954     12    
021b664c 007d3954     12    
...    


Statistics: 
    MT  Count  TotalSize Class Name 
007d3954    475     5700 LargeMemoryUsage.C 
Total 475 objects

通常!gcroot就够用了,但有时候它会失效,特别是当对象的根引用来自于上代内存堆中时,这时就需要用到!findroots命令了。

为了能让!gcroot命令生效,必须首先在GC中设置断点,就在垃圾回收即将发生的时刻。可以用以下命令来完成。

!findroots –gen 0 
g

这条命令在下一次第0代垃圾回收前设置断点。断点是一次性的,要想在下一次垃圾回收之前中断,必须再运行一遍命令。

一旦代码中断运行,你就要运行以下命令查找所需对象。

!findroots 027624fc

如果对象所处的内存堆已经比当前垃圾回收的代数更高,那么你会看到如下输出信息。

Object 027624fc will survive this collection: 
  gen(0x27624fc) = 1 > 0 = condemned generation.

如果对象处于当前正在回收的内存堆中,但根引用是来自上代内存堆,你会看到以下类似信息。

older generations::Root:  027624fc (object)-> 
  023124d4(System.Collections.Generic.List`1 
  [[System.Object, mscorlib]])

如前所述,性能计数器能显示垃圾回收过程中遇到了多少固定对象(Pinned),但这无助于了解是哪些对象被固定了。

请打开Pinning示例项目,里面用显式的fixed语句固定了一些对象,并调用了一些Windows API。

请用Windbg来查看固定对象,命令如下(同时包含了例程的输出信息)。

0:010> !gchandles 
  Handle Type      Object   Size   Data Type 
… 
003511f8 Strong    01fa5dbc     52      System.Threading.Thread 
003511fc Strong    01fa1330    112      System.AppDomain 
003513ec Pinned    02fa33a8   8176      System.Object[] 
003513f0 Pinned    02fa2398   4096      System.Object[] 
003513f4 Pinned    02fa2178    528      System.Object[] 
003513f8 Pinned    01fa121c     12      System.Object 
003513fc Pinned    02fa1020   4420      System.Object[] 
003514fc AsyncPinned 01fa3d04    64    System.Threading.OverlappedData

通常你会看到有很多System.Object[] 对象被固定了。CLR内部将这些数组用于静态对象及其他固定对象。在上述例子中,你可以看到一个AsyncPinned句柄,这是一个与示例程序中的FileSystemWatcher关联的对象。

不幸的是,Windbg不会告诉你对象被固定的原因,但一般可以查到这些固定对象并反向追踪到它们实际代表的对象。

下面的Windbg调试过程演示了如何通过引用关系追踪到上层对象,也许能为找到原始的固定对象提供一些线索。请注意跟踪粗体部分的引用。

0:010> !do 01fa3d04 
Name:  System.Threading.OverlappedData 
MethodTable: 64535470 
EEClass:   646445e0 
Size:  64(0x40) bytes 
File:  
C:\windows\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c56193
4e089\mscorlib.dll 
Fields: 
  MT  Field   Offset   Type VT   Attr  Value Name 
64927254  4000700  4  System.IAsyncResult  0 instance 020a7a60 
m_asyncResult 
64924904  4000701  8 ...ompletionCallback  0 instance 020a7a70 m_iocb 
... 
0:010> !do 020a7a70 
Name:  System.Threading.IOCompletionCallback 
MethodTable: 64924904 
EEClass:   6463d320 
Size:  32(0x20) bytes 
File:  
C:\windows\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c56193
4e089\mscorlib.dll 
Fields: 
  MT  Field   Offset   Type VT   Attr  Value Name 
649326a4  400002d  4  System.Object  0 instance 01fa2bcc _target 
... 
0:010> !do 01fa2bcc 
Name:  System.IO.FileSystemWatcher 
MethodTable: 6a6b86c8 
EEClass:   6a49c340 
Size:  92(0x5c) bytes 
File:  
C:\windows\Microsoft.Net\assembly\GAC_MSIL\System\v4.0_4.0.0.0__b77a5c56193
4e089\System.dll 
Fields: 
  MT  Field   Offset   Type VT   Attr  Value Name 
649326a4  400019a  4  System.Object  0 instance 00000000 __identity 
6a699b44  40002d2  8 ...ponentModel.ISite  0 instance 00000000 site 
...

尽管Windbg提供的功能最为强大,但用得再熟练也都是很繁琐的。你可以换用PerfView,这样很多工作都可以得到简化。

在用PerfView分析时,你会看到一个名为“Pinning at GC Time Stacks”的视图,用于显示垃圾收集过程中被固定住的对象调用栈,如图2-11所示。

图2-11 PerfView将会展示垃圾回收过程中哪些类型的对象被固定住了,并且会追根溯源将其可能的引用关系显示出来

下一节将会讨论内存堆中的不连续空闲块(Hole),查找这些内存碎片时还会遇到固定对象问题。

如果在已占用的内存段中存在空闲的内存块,就会产生内存碎片。内存碎片的概念可能存在于多个层级,可能是在GC堆的内存段中,也可能存在于整个进程的虚拟内存这一级。

第0代内存堆中的内存碎片通常不会有什么问题,除非出现了非常严重的对象固定问题。也就是被固定的对象太多了,每个空闲内存块都不足以分配给新的对象,这会导致小对象堆的增长,并增加垃圾回收次数。通常在第2代内存堆或者LOH中,内存碎片问题会更加突出。特别是未启用后台垃圾回收的时候。你也许会发现内存碎片化程度看起来会很高,有时甚至会达到50%,但这不一定表示有问题存在。请考虑一下整个内存堆的大小,如果尚在可接受范围内并且没有持续增长,也许你就不必理会。

发现GC堆的碎片相对容易一些。让我们从Windbg开始,学习如何发现碎片问题吧。

用!DumpHeap –type Free命令可以列出所有空闲内存块。

0:010> !DumpHeap -type Free 
 Address     MT   Size 
02371000 008209f8     10 Free 
0237100c 008209f8     10 Free 
02371018 008209f8     10 Free 
023a1fe8 008209f8     10 Free 
023a3fdc 008209f8     22 Free 
023abdb4 008209f8    574 Free 
023adfc4 008209f8     46 Free 
023bbd38 008209f8    698 Free 
023bdfe0 008209f8     18 Free 
023d19c0 008209f8   1586 Free 
023d3fd8 008209f8     26 Free 
023e578c 008209f8   2150 Free 
...

用!eeheap –gc命令可以列出每个内存块所属的内存段。

0:010> !eeheap -gc 
Number of GC Heaps: 1 
generation 0 starts at 0x02371018 
generation 1 starts at 0x0237100c 
generation 2 starts at 0x02371000 
ephemeral segment allocation context: none 
     segment       begin     allocated  size 
02370000  02371000 02539ff4  0x1c8ff4(1871860) 
Large object heap starts at 0x03371000 
     segment       begin     allocated  size 
03370000  03371000  03375398  0x4398(17304) 
Total Size:        Size: 0x1cd38c (1889164) bytes. 
------------------------------ 
GC Heap Size:  Size: 0x1cd38c (1889164) bytes.

可以把整个内存段或空闲块附近的所有对象都转储出来。

0:010> !DumpHeap 0x02371000 02539ff4 
 Address     MT   Size 
02371000 008209f8     10 Free 
0237100c 008209f8     10 Free 
02371018 008209f8     10 Free 
02371024 713622fc     84    
02371078 71362450     84    
023710cc 71362494     84    
02371120 713624d8     84    
02371174 7136251c     84    
023711c8 7136251c     84 
0237121c 71362554 12 
...

所有步骤都需手工录入,繁琐但确实很实用,你应该了解这种方法。你可以编写一个脚本处理输出,根据上一条命令的输出来生成下一条Windbg命令。CLR Profiler可以用图形化汇总表的形式显示同样的信息,或许也够你用的了,如图2-12所示。

图2-12 CLR Profiler可以把内存堆的情况图形化展示出来,这样就能发现空闲内存块旁边存放的是什么类型的对象。本图中空闲内存块被System.Byte[] 和各种其他类型包围着

你还可以去查找一下虚拟内存中的碎片。因为假如找不到足够大的内存区域,虚拟内存碎片会导致非托管内存分配失败。这种非托管内存分配包括了GC堆内存段的分配,也就是说托管内存的分配也会一起失败。

虚拟内存碎片在32位进程中更有可能产生,因为应用程序默认的内存地址空间上限是2GB。当发生问题时,最明显的信号就是触发了OutOfMemoryException。最简单的修复方法就是把应用程序转换为64位,以获取128TB的地址空间。如果无法转换为64位应用,那你唯一的选择就是同时大幅提升非托管和托管内存的分配效率。你需要确保内存堆能够进行碎片整理,也许还需要实现有效的池化。你可以利用VMMap(SysInternal实用工具集的成员)来获取进程的内存分布图。VMMap会把内存堆划分为托管堆、本机堆和空闲区。选中Free部分将会把当前所有标记为空闲的内存段显示出来,如图2-13所示。如果最大块的空闲内存段都不够分配的,那么就会触发OutOfMemoryException。

图2-13 VMMap可以显示大量与内存有关的信息,包括所有空闲块的地址区间。
本图中最大的内存块超过了1.4GB,真富裕

VMMap还带有一个“碎片视图”(Fragmentation View),能够显示内存碎片在整个进程地址空间中的位置,如图2-14所示。

图2-14 VMMap的碎片视图显示出空闲内存块与其他内存段的位置关系

你还可以在Windbg中用以下命令获取这些信息。

!address –summary

产生的输出如下所示。

... 
-- Largest Region by Usage -- Base Address -- Region Size -- 
Free                 26770000    49320000 (1.144 Gb) 
...

可以用以下命令获取指定内存块的信息。

!address –f:Free

输出如下所示。

BaseAddr EndAddr+1 RgnSize  Type State   Protect     Usage 
-------------------------------------------------------------- 
   0   150000   150000     MEM_FREE  PAGE_NOACCESS Free

用Windbg可以查到指定对象属于第几代内存堆。只要获取了对象的地址(用!DumpStackObjects或!DumpHeap可以查到),就可以用!gcwhere命令进行查询。

0:003> !gcwhere 02ed1fc0  
Address   Gen Heap segment  begin  allocated size 
02ed1fc0  1   0  02ed0000 02ed1000 02fe5d4c  0x14(20)

你还可以在代码中通过GC.GetGeneration方法获取上述信息,参数为所需查询的对象。

最容易的方法就是使用CLR Profiler。在数据收集完毕后,在结果对话框中单击“Timeline”按钮。将会弹出一个内存分配的时光轴示意图,每种类型对象都用不同颜色标注出来。纵坐标是内存地址。垃圾回收事件都会被标出,你可以看到哪些对象消失了,哪些则一直存活着。

在图2-15的屏幕截图中,你可以看到对AllocateAndRelease程序的分析数据。

图2-15 CLR Profiler的时间轴视图很容易就能看出垃圾回收过程中存活下来的对象

每次垃圾回收都被标记出来了(第0代垃圾回收被标为红色)。图表的下部是每次垃圾回收时被清理掉的各类对象,看起来很像锯齿状。深绿色的System.Char[] 正在缓慢增长,表明是存活下来并提升为第1代。在图表的上部是正在增长的System.Int32[],这里表示LOH,因为还没有发生过第2代回收,所以就没有清理过。

CLR Profiler能够获得内存分配的全貌,这一点十分强大,可用来作为分析存活对象的第一步工具。但有时候你需要获取非常详细的信息,也许是针对某个特定的对象。由于体积过大和其他一些限制因素,CLR Profiler可能无法对进程进行监控。这时就需要用到Windbg来对垃圾回收后的某个对象进行精确查看。通过一小段脚本,你甚至可以自动化分析过程,以获得统计数字。

先把Windbg附着(Attach)于进程并载入SOS,然后执行以下命令。

!FindRoots –gen 0 
g

这样就在下一次第0代垃圾回收即将开始时设置了1个断点。在断点中断后,你就可以发送命令转储内存堆中的任何对象。你也可以只是简单地执行以下命令。

!DumpHeap

这会把内存堆中的所有对象都转储出来,也许数量太多了。你还可以添加-stat参数来限制输出,只显示所有对象的统计信息(数量、大小、类型)。当然,如果你想只对第0代内存堆进行分析,你可以给!DumpHeap命令指定地址区间。这样就带来了一个问题,如何找到第0代内存堆的地址区间呢?用另一条SOS命令就可以了。请回忆一下本章开头有关内存段的介绍内容。内存段一般如下所示。

请注意,第0代内存堆的位置是在内存段的最后,它的末端也就是该内存段的末端。详细信息请参考本章的开始部分。

你可以用eeheap –gc命令获取内存堆和内存段的列表。

0:003> !eeheap -gc 
Number of GC Heaps: 1 
generation 0 starts at 0x02ef0400 
generation 1 starts at 0x02ed100c 
generation 2 starts at 0x02ed1000 
ephemeral segment allocation context: none 
      segment  begin   allocated  size 
02ed0000  02ed1000  02fe5d4c 0x114d4c(1133900) 
Large object heap starts at 0x03ed1000 
      segment  begin      allocated  size 
03ed0000  03ed1000  041e2898  0x311898(3217560) 
Total Size:        Size: 0x4265e4 (4351460) bytes. 
------------------------------ 
GC Heap Size:  Size: 0x4265e4 (4351460) bytes.

上述命令会打印出每一代内存堆和每一个内存段。包含第0代和第1代内存堆的内存段被称为Ephemeral Segment(暂时段)。!eeheap命令给出了第0代内存堆的起始地址,只需要找到包含该起始地址的内存段,你就能得到第0代内存堆的终点地址。每个内存段都带有地址和长度。在上述例子中,Ephemeral Segment从02ed0000开始,到02fe5d4c结束。因此第0代内存堆的地址区间就是02ef0400~02fe5d4c。

有了地址区间,你可以给DumpHeap命令增加一些限制参数,以便只打印第0代内存堆的对象。

!DumpHeap 02ef0400 02fe5d4c

执行完这条命令,你就应该和垃圾回收刚刚完成之后的结果进行比较。这里存在一点小小的困难,你需要在CLR内部方法中设置断点。CLR会在即将继续运行托管代码的时候调用这个方法。如果垃圾回收采用了工作站模式,请调用

bp clr!WKS::GCHeap::RestartEE

服务器模式则为

bp clr!SVR::GCHeap::RestartEE

断点设置完毕后,继续运行程序(按F5键或执行g命令)。垃圾回收一完成,程序就会再次中断,你就能再次执行!eeheap –gc和!DumpHeap命令获取结果了。

现在你就拥有了两份输出结果,通过对比就可以发现变化情况,知道哪些对象在垃圾回收之后还保留在内存中。通过本节介绍的其他命令和技巧,你还可以知道这些存活的对象都是被谁引用着。

注意

 

如果垃圾回收采用了服务器模式,那么请别忘了会有多个内存堆存在。你需要对每个堆都重复上述命令,才能完成分析。!eeheap会打印出进程中所有内存堆的信息。

请在Windbg中执行以下命令,在GC类的Collect方法上设置托管断点。

!bpmd mscorlib.dll System.GC.Collect

继续运行程序。一旦命中断点,就可以执行以下命令查看调用栈跟踪信息,分析显式执行垃圾回收的对象。

!DumpStack

因为弱引用是一种GC句柄,在Windbg中你可以用!gchandles命令来找到它们。

0:003> !gchandles 
PDB symbol for clr.dll not loaded 
  Handle Type       Object  Size  Data Type 
006b12f4 WeakShort   022a3c8c  100   System.Diagnostics.Tracing... 
006b12fc WeakShort   022a3afc  52  System.Threading.Thread 
006b10f8 WeakLong  022a3ddc  32  Microsoft.Win32.UnsafeNati... 
006b11d0 Strong    022a3460  48  System.Object[] 
... 

Handles: 
  Strong Handles:     11 
  Pinned Handles:     5 
  Weak Long Handles: 1 
  Weak Short Handles:   2

对于上述例子而言,Weak Long和Weak Short的区别并不怎么要紧。Weak Long类型的句柄记录着已终结的对象是否又复活了。如果某个对象已被标为终结但还未被GC清理,却又被使用到了,这就是对象复活。对象复活可能与池化有关。但是池化是有可能不终结对象的,考虑到对象复活让事情变得复杂了,为了确保对象的方法能够正常执行,请避免对象复活的发生。

为了能让应用程序真正获得性能的优化,你需要深入了解垃圾回收的过程。请为应用程序选择正确的配置参数,比如在独占主机时选用服务器模式的垃圾回收机制。请尽量缩短对象的生存期,减少内存分配次数。把那些生存期必须长于平均垃圾回收频率的对象全部都进行池化,或者让它们在第2代内存堆中永久性存活下去。

尽可能避免对象固定和使用终结方法。所有LOH中的内存分配都应该池化并维持永久存活,以避免发生完全垃圾回收。让对象维持统一大小,偶尔也适时进行一次碎片整理,以减少LOH中的内存碎片。为了避免不合时宜的完全垃圾回收对应用程序的影响,可以考虑使用垃圾回收通知。

垃圾回收器的行为是确定可控的,通过仔细调整对象分配频率和生存期,你就可以控制垃圾回收器的行为。与.NET的垃圾回收器相伴,你并不会放弃控制权,只是需要更多的技巧和分析。

[1] 原文为“GC Heap Alloc Stacks”。PerfView自Version 1.6.28开始,已改为“GC Heap Alloc Ignore Free (Coarse Sampling) Stacks”。

[2] “Managed Heap Analysis”视图属于“.NET内存转储分析”(.NET Memory Dump Analysis)功能,需要Visual Studio 2013 Ultimate以上版本才支持。


相关图书

云原生测试实战
云原生测试实战
Kubernetes快速入门(第2版)
Kubernetes快速入门(第2版)
Kubernetes零基础实战
Kubernetes零基础实战
深入浅出Windows API程序设计:核心编程篇
深入浅出Windows API程序设计:核心编程篇
深入浅出Windows API程序设计:编程基础篇
深入浅出Windows API程序设计:编程基础篇
云原生技术中台:从分布式到云平台设计
云原生技术中台:从分布式到云平台设计

相关文章

相关课程