-NET-性能高级教程-一-

51 阅读1小时+

.NET 性能高级教程(一)

原文:Pro .NET Performance

协议:CC BY-NC-SA 4.0

零、简介

这本书已经成为,因为我们觉得没有权威的文本,涵盖所有这三个领域有关。网络应用性能:

  • 确定性能指标,然后测量应用性能,以验证它是否满足或超过这些指标。
  • 在内存管理、网络、I/O、并发性和其他方面提高应用性能。
  • 了解 CLR 和。NET 内部细节,以便设计高性能的应用,并在出现性能问题时进行修复。

我们相信。如果不彻底了解这三个方面,NET 开发人员就无法实现系统化的高性能软件解决方案。例如,。NET 内存管理(由 CLR 垃圾收集器提供)是一个极其复杂的领域,并且会导致严重的性能问题,包括内存泄漏和长时间的 GC 暂停时间。在不了解 CLR 垃圾回收器如何工作的情况下,中的高性能内存管理。网络只能靠运气。类似地,从。NET Framework 必须提供的,或者决定实现自己的,需要全面熟悉 CPU 缓存、运行时复杂性和同步问题。

这本书的 11 个章节被设计成连续阅读,但你可以在主题之间来回跳跃,并在必要时填空。这些章节分为以下逻辑部分:

  • 第一章和第二章涉及性能指标和性能测量。它们介绍了您可以用来测量应用性能的工具。
  • 第三章和第四章深入探讨了 CLR 的内部机制。他们主要关注类型内部和 CLR 垃圾收集的实现——这是在内存管理方面提高应用性能的两个关键主题。
  • 第五章、第六章、第七章、第八章和第十一章讨论的特定领域。NET Framework 和 CLR 提供了性能优化的机会——正确使用集合、并行化顺序代码、优化 I/O 和网络操作、高效使用互操作性解决方案,以及提高 Web 应用的性能。
  • 第九章简要介绍了复杂性理论和算法。写这篇文章是为了让你了解什么是算法优化。
  • 第十章是不适合本书其他地方的各种主题的垃圾场,包括启动时间优化、异常和。净反射。

其中一些主题有一些先决条件,可以帮助您更好地理解它们。在本书的整个过程中,我们假设您对 C# 编程语言和。NET 框架,并熟悉基本概念,包括:

  • Windows:线程、同步、虚拟内存
  • 公共语言运行时(CLR):实时(JIT)编译器、微软中间语言(MSIL)、垃圾收集器
  • 计算机组织:主存、缓存、磁盘、显卡、网络接口整本书有相当多的示例程序、摘录和基准。为了不再制作这本书,我们通常只包括一个简短的部分——但是你可以在书的网站上的配套源代码中找到整个程序。

在某些章节中,我们使用 x86 汇编语言中的代码来说明 CLR 机制如何运行,或者更彻底地解释特定的性能优化。虽然这些部分对本书的要点并不重要,但我们建议专业读者花些时间学习 x86 汇编语言的基础知识。Randall Hyde 的免费书籍《汇编语言编程的艺术》(http://www.artofasm.com/Windows/index.html)是一个极好的资源。

总之,这本书充满了性能测量工具,提高应用性能的小技巧和诀窍,许多 CLR 机制的理论基础,实际的代码示例,以及来自作者经验的几个案例研究。近十年来,我们一直在为客户优化应用,并从头开始设计高性能系统。在这些年里,我们培训了数百名开发人员,让他们思考软件开发生命周期的每个阶段的性能,并积极寻找提高应用性能的机会。看完这本书,你就会加入高绩效的行列。NET 应用开发人员和性能调查人员优化现有的应用。

萨沙·戈德斯通

迪马·祖巴列夫

去菲洛

一、性能指标

在我们开始进入。NET 性能,我们必须了解性能测试和优化中涉及的指标和目标。在第二章中,我们探索了十几种剖析器和监控工具;然而,要使用这些工具,您需要知道您对哪些性能指标感兴趣。

不同类型的应用有多种不同的性能目标,由业务和运营需求驱动。有时,应用的体系结构决定了重要的性能指标:例如,知道您的 Web 服务器必须服务于数百万并发用户,就决定了具有缓存和负载平衡的多服务器分布式系统。在其他时候,性能测量结果可能需要改变应用的架构:我们已经看到无数的系统在压力测试运行后被重新设计——或者更糟,系统在生产环境中失败。

根据我们的经验,了解系统的性能目标及其环境的限制通常会引导您完成改进其性能的大半过程。以下是我们在过去几年中能够诊断和修复的一些示例:

  • 我们发现托管数据中心的强大 Web 服务器存在严重的性能问题,这是由测试工程师使用的共享低延迟 4Mbps 链路引起的。由于不了解关键的性能指标,工程师们浪费了几十天的时间来调整 Web 服务器的性能,而实际上它运行得非常好。
  • 我们能够通过调整 CLR 垃圾收集器(一个明显不相关的组件)的行为来提高富 UI 应用中的滚动性能。精确的时间分配和对 GC 风格的调整消除了困扰用户的明显的 UI 延迟。
  • 通过将硬盘移动到 SATA 端口,我们能够将编译时间提高 10 倍,以解决微软 SCSI 磁盘驱动程序中的一个错误。
  • 我们通过调优 WCF 的序列化机制,将 WCF 服务交换的消息大小减少了 90 %,大大提高了其可伸缩性和 CPU 利用率。
  • 对于一个在过时硬件上有 300 个程序集的大型应用,我们通过压缩应用的代码,并仔细理清它的一些依赖关系,使它们在加载时不再需要,从而将启动时间从 35 秒减少到 12 秒。

这些例子旨在说明,从低功耗触摸设备、具有强大显卡的高端消费类工作站,到多服务器数据中心,每种系统都因无数微妙因素的相互作用而展现出独特的性能特征。在这一章中,我们简要地探讨了典型现代软件中的各种性能度量和目标。在下一章中,我们将说明如何准确地测量这些指标;本书的其余部分展示了如何系统地改进它们。

绩效目标

性能目标更多地取决于应用的领域和架构。当您收集完需求后,您应该确定一般的性能目标。根据您的软件开发过程,随着需求的变化和新的业务和操作需求的出现,您可能需要调整这些目标。我们回顾了几个原型应用的性能目标和指导原则的例子,但是,和任何与性能相关的东西一样,这些指导原则需要适应您的软件领域。

首先,这里有一些陈述的例子,说明不是的良好绩效目标:

  • 当许多用户同时访问购物车屏幕时,应用将保持响应。
  • 只要用户数量合理,应用就不会使用不合理的内存量。
  • 即使有多个满载的应用服务器,单个数据库服务器也能快速提供查询服务。

这些陈述的主要问题是它们过于笼统和主观。如果这些是你的绩效目标,那么你一定会发现它们在参照系上有不同的解释和分歧。业务分析师可能认为 100,000 个并发用户是一个“合理”的数字,而技术团队成员可能知道可用的硬件无法在单台机器上支持这么多的用户。相反,开发人员可能会认为 500 ms 的响应时间是“响应性的”,但是用户界面专家可能会认为这是滞后的和不完善的。

然后,一个性能目标用可量化的性能指标来表达,这些指标可以通过一些性能测试的方法来测量。绩效目标还应该包含一些关于其环境 的信息——一般的或者特定于该绩效目标的信息。一些明确的绩效目标的例子包括:

  • 只要并发访问购物车屏幕的用户不超过 5,000 人,该应用将在不到 300 毫秒的时间内(不包括网络往返时间)为“重要”类别中的每个页面提供服务。
  • 对于每个空闲用户会话,应用将使用不超过 4 KB 的内存。
  • 数据库服务器的 CPU 和磁盘利用率不应超过 70%,并且只要访问它的应用服务器不超过 10 个,它应该在 75 毫秒内返回对“普通”类别查询的响应。

image 注意这些例子假设“重要”页面类别和“常见”查询类别是由业务分析师或应用架构师定义的众所周知的术语。保证应用中每个角落的性能目标通常是不合理的,不值得在开发、硬件和运营成本上投资。

我们现在考虑一些典型应用的性能目标示例(见表 1-1 )。此列表绝非详尽无遗,也不打算用作您自己的绩效目标的清单或模板——它是一个通用框架,在涉及不同的应用类型时,可以确定不同的绩效目标。

表 1-1 。典型应用的性能目标示例

系统类型绩效目标环境约束
外部 Web 服务器从请求开始到生成完整响应的时间不应超过 300 毫秒不超过 300 个并发活动请求
外部 Web 服务器虚拟内存使用量(包括缓存)不应超过 1.3GB不超过 300 个并发活动请求;不超过 5,000 个连接的用户会话
应用服务器CPU 利用率不应超过 75%不超过 1,000 个并发活动 API 请求
应用服务器硬页面错误率不应超过每秒 2 个硬页面错误不超过 1,000 个并发活动 API 请求
智能客户端应用从双击桌面快捷方式到主屏幕显示员工列表的时间不应超过 1500 毫秒-
智能客户端应用应用空闲时的 CPU 利用率不应超过 1%-
网页过滤和分类收到的电子邮件的时间不应超过 750 毫秒,包括播放动画单个屏幕上显示的接收邮件不超过 200 封
网页“与代表聊天”窗口的缓存 JavaScript 对象的内存利用率不应超过 2.5MB-
监控服务从故障事件到生成和发送警报的时间不应超过 25 毫秒-
监控服务未主动生成警报时,磁盘 I/O 操作率应为 0-

image 注意运行应用的硬件特性是环境约束的重要组成部分。例如,表 ** 1-1 ** 中对智能客户端应用的启动时间限制可能要求固态硬盘或转速至少为 7200RPM 的旋转硬盘、至少 2GB 的系统内存以及支持 SSE3 指令的 1.2GHz 或更快的处理器。这些环境约束不值得为每个性能目标重复,但是在性能测试期间值得记住。

当性能目标被很好地定义时,性能测试、负载测试和随后的优化过程就很简单了。验证假设,例如“对于 1,000 个并发执行的 API 请求,应用服务器上每秒出现的硬页面错误少于 2 个”,通常需要访问负载测试工具和合适的硬件环境。下一章将讨论如何度量应用,以确定一旦建立了这样的环境,它是否达到或超过了它的性能目标。

构建定义良好的性能目标通常需要事先熟悉性能指标,我们将在下面讨论。

绩效指标

与绩效目标不同,绩效指标与特定的场景或环境无关。性能指标是反映应用行为的可测量的数字量。您可以在任何硬件和任何环境中测量性能指标,而不管活动用户、请求或会话的数量。在开发生命周期中,您选择度量标准来度量并从中导出特定的性能目标。

一些应用具有特定于其领域的性能指标。我们不打算在这里确定这些指标。相反,我们在表 1-2 中列出了对许多应用来说非常重要的性能指标,以及讨论这些指标优化的章节。(CPU 利用率和执行时间指标非常重要,本书的每一章都会在中讨论。)

表 1-2 。绩效指标列表(部分)

绩效指标计量单位本书中的特定章节
CPU 利用率百分比所有章节
物理/虚拟内存使用情况字节、千字节、兆字节、千兆字节第四章–垃圾收集第五章–收集和泛型
缓存未命中计数,速率/秒第五章–集合和泛型第六章–并发和并行
页面错误计数,速率/秒-
数据库访问计数/计时计数,速率/秒,毫秒-
分配字节数、对象数、速率/秒第三章–类型内部原理第四章–垃圾收集
执行时间毫秒所有章节
网络运营计数,速率/秒第七章–网络、I/O 和序列化第十一章–网络应用
磁盘操作计数,速率/秒第七章—网络、I/O 和序列化
响应时间毫秒第十一章–网络应用
垃圾收集计数,速率/秒,持续时间(毫秒),占总时间的百分比第四章–垃圾收集
引发的异常计数,速率/秒第十章–绩效模式
启动时间毫秒第十章–绩效模式
引起争论的计数,速率/秒第六章—并发和并行

有些指标与某些应用类型的相关性比其他的更强。例如,数据库访问时间不是您可以在客户端系统上测量的指标。性能指标和应用类型的一些常见组合包括:

  • 对于客户端应用,您可能会关注启动时间、内存使用和 CPU 利用率。
  • 对于托管系统算法的服务器应用,您通常关注 CPU 利用率、缓存未命中、争用、分配和垃圾收集。
  • 对于 Web 应用,通常测量内存使用、数据库访问、网络和磁盘操作以及响应时间。

关于性能指标的最后一个观察是,在没有显著改变指标意义的情况下,通常可以改变它们被测量的级别。例如,分配和执行时间可以在系统级、单个进程级,甚至是单个方法和行进行测量。与整体 CPU 利用率或流程级别的执行时间相比,特定方法中的执行时间可能是更具可操作性的性能指标。不幸的是,增加测量的粒度通常会导致性能开销,我们将在下一章通过讨论各种分析工具来说明这一点。

软件开发生命周期中的性能

您认为性能在软件开发生命周期中处于什么位置?这个天真的问题带来了一个包袱,那就是必须在现有的流程中改进 ?? 的性能。虽然这是可能的,但更健康的方法是将开发生命周期的每一步都视为更好地理解应用性能的机会:首先,性能目标和重要的度量标准;接下来,应用是否达到或超过其目标;最后,维护、用户负载和需求变更是否会引入任何回归。

  1. 在需求收集阶段,开始考虑您想要设定的性能目标。
  2. 在架构阶段,细化对应用重要的性能指标,并定义具体的性能目标。
  3. 在开发阶段,经常对原型代码或部分完成的特性执行探索性的性能测试,以验证您完全符合系统的性能目标。
  4. 在测试阶段,执行重要的负载测试和性能测试,以完全验证系统的性能目标。
  5. 在后续的开发和维护过程中,对每个版本执行额外的负载测试和性能测试(最好是每天或每周一次),以快速识别系统中引入的任何性能退化。

Taking the time to develop a suite of automatic load tests and performance tests, set up an isolated lab environment in which to run them, and analyze their results carefully to make sure no regressions are introduced is very time-consuming. Nevertheless, the performance benefits gained from systematically measuring and improving performance and making sure regressions do not creep slowly into the system is worth the initial investment in having a robust performance development process.

摘要

这一章是对性能指标和目标的介绍。确保你知道衡量什么和什么样的绩效标准对你来说是重要的,这甚至比实际的衡量绩效更重要,这是下一章的主题。在本书的其余部分,我们使用各种工具来衡量性能,并提供如何改进和优化应用的指导。

二、性能测量

这本书是关于提高性能的。NET 应用。你不能改进你不能首先测量的东西,这就是为什么我们的第一个实质性章节处理性能测量工具和技术。对于注重性能的开发人员来说,猜测应用的瓶颈在哪里,并过早地得出要优化什么的结论是最糟糕的事情,而且往往以危险告终。正如我们在第一章中看到的,有许多有趣的性能指标可能是您的应用感知性能的核心因素;在本章中,我们将看到如何获得它们。

绩效评估方法

衡量应用性能的正确方法不止一种,在很大程度上取决于上下文、应用的复杂性、所需信息的类型以及所获得结果的准确性。

测试小程序或库方法的一种方法是白盒测试 :检查源代码,在白板上分析其复杂性,修改程序的源代码,并在其中插入度量代码。我们将在本章末尾讨论这种方法,通常称为微基准;在需要精确的结果和对每条 CPU 指令的绝对理解的情况下,它可能非常有价值,而且通常是不可替代的,但在涉及大型应用时,它相当耗时且不灵活。此外,如果您事先不知道要度量和推理程序的哪个小部分,那么在不借助自动化工具的情况下,隔离瓶颈可能会非常困难。

对于更大的程序,更常见的方法是黑盒测试 ,其中性能指标由人识别,然后由工具自动测量。当使用这种方法时,开发人员不必预先确定性能瓶颈,也不必假设问题出在程序的某个(且很小的)部分。在本章中,我们将考虑许多工具,这些工具可以自动分析应用的性能,并以易于理解的形式提供定量结果。这些工具包括性能计数器 、Windows 事件跟踪(【ETW】)和商业剖析器

当您阅读本章时,请记住性能测量工具会对应用性能产生负面影响。很少有工具能够提供准确的信息,同时在应用执行时不产生任何开销。当我们从一个工具转移到下一个工具时,请始终记住,工具的准确性经常与它们对您的应用造成的开销相冲突。

内置 Windows 工具

在我们转向需要安装和侵入性测量应用性能的商业工具之前,最重要的是要确保 Windows 提供的开箱即用的一切都得到最大限度的利用。近二十年来,性能计数器一直是 Windows 的一部分,而 Windows 的事件跟踪则稍新一些,在 Windows Vista 时期(2006 年)变得真正有用。两者都是免费的,存在于每个版本的 Windows 上,并且可以用最小的开销用于性能调查。

性能计数器

Windows 性能计数器是一种内置的 Windows 机制,用于性能和运行状况调查。包括 Windows 内核、驱动程序、数据库和 CLR 在内的各种组件提供了性能计数器,用户和管理员可以使用这些计数器来了解系统的运行情况。额外的好处是,默认情况下,大多数系统组件的性能计数器都是打开的,因此收集这些信息不会带来任何额外的开销。

从本地或远程系统读取性能计数器信息非常容易。内置的性能监视器工具(perfmon.exe)可以显示系统上可用的每个性能计数器,并将性能计数器数据记录到一个文件中以供后续调查,并在性能计数器读数超过定义的阈值时提供自动警报。如果您有管理员权限并且可以通过本地网络连接到远程系统,性能监视器也可以监视远程系统。

性能信息按以下层次组织:

  • 性能计数器类别(或性能对象)代表与某个系统组件相关的一组单独的计数器。类别的一些例子包括。NET CLR 内存、处理器信息、TCPv4 和 PhysicalDisk。
  • 性能计数器是性能计数器类别中的单个数字数据属性。通常用斜线分隔性能计数器类别和性能计数器名称,例如 Process\Private Bytes。性能计数器有几种受支持的类型,包括原始数字信息(进程\线程计数)、事件速率(打印队列\每秒打印的字节数)、百分比(物理磁盘%空闲时间)和平均值(service model operation 3 . 0 . 0 . 0 \ Calls Duration)。
  • 性能计数器类别实例用于区分几组计数器和一个特定组件,该组件有几个实例。例如,因为系统上可能有多个处理器,所以每个处理器都有一个处理器信息类别的实例(以及一个 aggregated _Total 实例)。性能计数器类别可以是多实例的,也可以是单实例的(例如内存类别)。

如果您查看由运行的典型 Windows 系统提供的性能计数器的完整列表。NET 应用,您将会看到许多性能问题无需借助任何其他工具就可以识别出来。至少,在调查性能问题或检查生产系统的数据日志以了解其行为是否正常时,性能计数器通常可以提供一个大致的方向。

下面是一些场景,在这些场景中,系统管理员或性能调查员可以在使用更强大的工具之前大致了解性能问题的症结所在:

  • 如果应用出现内存泄漏,可以使用性能计数器来确定是托管内存分配还是本机内存分配导致了内存泄漏。Process\Private Bytes 计数器可以与。所有堆计数器中的. NET CLR 内存# 字节。前者占进程分配的所有私有内存(包括 GC 堆),而后者只占托管内存。(参见图 2-1 。)
  • 如果一个 ASP.NET 应用开始表现出异常行为,ASP.NET 应用类别可以提供关于正在发生的事情的更多信息。例如,Requests/Sec、Requests Timed Out、Request Wait Time 和 Requests Executing 计数器可以识别极端负载条件,Errors Total/Sec 计数器可以提示应用是否面临异常数量,而各种与缓存和输出缓存相关的计数器可以指示缓存是否得到有效应用。
  • 如果严重依赖数据库和分布式事务的 WCF 服务无法处理其当前负载,则 ServiceModelService 类别可以查明问题——未完成的调用、每秒调用数和每秒失败的调用数计数器可以确定负载过重,每秒流动的事务数计数器报告服务正在处理的事务数,同时 SQL Server 类别(如 MSSQL$instance name:Transactions 和 MSSQL$instance name:Locks)可以指出事务执行、过度锁定甚至死锁方面的问题。

9781430244585_Fig02-01.jpg

图 2-1性能监视器主窗口,显示特定进程的三个计数器。图中最上面一行是进程\私有字节计数器,中间一行是。所有堆中的. NET CLR 内存# 字节,最下面的是。NET CLR 内存\每秒分配的字节数。从图中可以明显看出,应用在 GC 堆中出现了内存泄漏

用性能计数器监视内存使用情况

在这个简短的实验中,您将使用性能监视器和上面讨论的性能计数器来监视一个示例应用的内存使用情况,并确定它是否存在内存泄漏。

  1. 打开性能监视器—您可以通过搜索“性能监视器”在“开始”菜单中找到它,或者直接运行 perfmon.exe。
  2. 从本章的源代码文件夹中运行 MemoryLeak.exe 应用。
  3. 单击左侧树中的“性能监视器”节点,然后单击绿色的 + 按钮。
  4. 从。NET CLR 内存类别,选择所有堆中的# Bytes 和分配的字节/秒性能计数器,从实例列表中选择 MemoryLeak 实例,然后单击“添加> >”按钮。
  5. 从 Process 类别中选择 Private Bytes 性能计数器,从 instance 列表中选择 MemoryLeak 实例,然后单击“Add > >”按钮。
  6. 单击“确定”按钮确认您的选择并查看性能图。
  7. 您可能需要右键单击屏幕底部的计数器,并选择“缩放选定的计数器”来查看图表上的实际线条。

You should now see the lines corresponding to the Private Bytes and # Bytes in all Heaps performance counters climb in unison (somewhat similar to Figure 2-1). This points to a memory leak in the managed heap. We will return to this particular memory leak in Chapter 4 and pinpoint its root cause.

image 提示在一个典型的 Windows 系统上,几乎有数千个性能计数器;没有一个绩效调查员会记得所有的问题。这就是“添加计数器”对话框底部的小“显示描述”复选框派上用场的地方——它可以告诉您系统\处理器队列长度代表系统处理器上等待执行的就绪线程的数量。NET CLR LocksAndThreads \ Contention Rate/sec 是线程尝试获取托管锁失败并必须等待该锁可用的次数(每秒)。

性能计数器日志和警报

配置性能计数器日志相当容易,您甚至可以向系统管理员提供一个 XML 模板来自动应用性能计数器日志,而不必指定单独的性能计数器。您可以在任何机器上打开生成的日志,并回放它们,就像它们代表实时数据一样。(您甚至可以使用一些内置的计数器集,而不是手动配置要记录的数据。)

您还可以使用性能监视器来配置性能计数器警报,当超过某个阈值时,该警报将执行任务。您可以使用性能计数器警报来创建一个基本的监视基础结构,当违反性能约束时,它可以向系统管理员发送电子邮件或消息。例如,您可以配置一个性能计数器警报,当它达到危险的内存使用量时,或者当整个系统用完磁盘空间时,它会自动重新启动您的进程。我们强烈建议您试用性能监视器,并熟悉它所提供的各种选项。

配置性能计数器日志

要配置性能计数器日志,请打开性能监视器并执行以下步骤。(我们假设您在 Windows 7 或 Windows Server 2008 R2 上使用性能监视器;在以前的操作系统版本中,性能监视器的用户界面略有不同,如果您正在使用这些版本,请查阅文档以获得详细说明。)

  1. 在左侧的树中,展开数据收集器集节点。
  2. 右键单击用户定义的节点,并从上下文菜单中选择新建image数据收集器集。
  3. 命名数据收集器集,选择“手动创建(高级)”单选按钮,然后单击“下一步”。
  4. 确保选中“创建数据日志”单选按钮,选中“性能计数器”复选框,然后单击“下一步”。
  5. 使用“添加”按钮添加性能计数器(将打开标准的“添加计数器”对话框)。完成后,配置一个采样间隔(默认为每 15 秒对计数器进行一次采样),然后单击 Next。
  6. 提供一个目录,性能监视器将使用它来存储您的计数器日志,然后单击“下一步”。
  7. 选择“打开此数据收集器集的属性”单选按钮,然后单击“完成”。
  8. 使用各种选项卡进一步配置数据收集器集—您可以定义自动运行的计划、停止条件(例如,在收集超过一定数量的数据后)以及数据收集停止时运行的任务(例如,将结果上载到集中位置)。完成后,点按“好”。
  9. 单击用户定义的节点,右键单击主窗格中的数据收集器集,然后从上下文菜单中选择启动。
  10. Your counter log is now running and collecting data to the directory you’ve selected. You can stop the data collector set at any time by right-clicking it and selecting Stop from the context menu.
当您完成数据收集并希望使用性能监视器检查数据时,执行以下步骤:
  1. 选择用户定义的节点。
  2. 右键单击数据收集器集,并从上下文菜单中选择最新报告。
  3. 在出现的窗口中,您可以在日志的计数器列表中添加或删除计数器,配置时间范围,并通过右键单击图表并从上下文菜单中选择属性来更改数据比例。

Finally, to analyze log data on another machine, you should copy the log directory to that machine, open the Performance Monitor node, and click the second toolbar button from the left (or Ctrl + L). In the resulting dialog you can select the “Log files” checkbox and add log files using the Add button.

自定义性能计数器

虽然性能监视器是一个非常有用的工具,但是您可以从任何。NET 应用。诊断。PerformanceCounter 类。更好的是,您可以创建自己的性能计数器,并将它们添加到大量可用于性能调查的数据中。

下面是一些您应该考虑导出性能计数器类别的场景:

  • 您正在开发一个基础设施库,作为大型系统的一部分。您的库可以通过性能计数器报告性能信息,对于开发人员和系统管理员来说,这通常比跟踪日志文件或在源代码级别进行调试更容易。
  • 您正在开发一个服务器系统,该系统接受定制请求,处理它们,并交付响应(定制 Web 服务器、Web 服务等)。).您应该报告请求处理率、遇到的错误和类似统计的性能信息。(有关一些想法,请参见 ASP.NET 性能计数器类别。)
  • 您正在开发一个高可靠性的 Windows 服务,该服务在无人值守的情况下运行,并与自定义硬件进行通信。您的服务可以报告硬件的健康状况、软件与硬件的交互率以及类似的统计数据。

以下代码是从应用中导出单实例性能计数器类别并定期更新这些计数器所需的全部内容。它假设 AttendanceSystem 类具有关于当前登录的雇员数量的信息,并且您希望将此信息公开为性能计数器。(你将需要这个系统。诊断命名空间来编译这段代码。)

public static void CreateCategory() {
  if (PerformanceCounterCategory.Exists("Attendance")) {
   PerformanceCounterCategory.Delete("Attendance");
  }
  CounterCreationDataCollection counters = new CounterCreationDataCollection();
  CounterCreationData employeesAtWork = new CounterCreationData(
   "# Employees at Work", "The number of employees currently checked in.",
   PerformanceCounterType.NumberOfItems32);
  PerformanceCounterCategory.Create(
   "Attendance", "Attendance information for Litware, Inc.",
   PerformanceCounterCategoryType.SingleInstance, counters);
}
public static void StartUpdatingCounters() {
  PerformanceCounter employeesAtWork = new PerformanceCounter(
   "Attendance", "# Employees at Work", readOnly: false);
  updateTimer = new Timer(_ = > {
   employeesAtWork.RawValue = AttendanceSystem.Current.EmployeeCount;
  }, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
}

正如我们所看到的,配置自定义性能计数器几乎不费吹灰之力,而且在执行性能调查时,它们可能是至关重要的。将系统性能计数器数据与自定义性能计数器相关联通常是性能调查人员查明性能或配置问题的确切原因所需的全部工作。

image 注意性能监视器可以用来收集与性能计数器无关的其他类型的信息。您可以使用它从系统中收集配置数据—注册表项的值、WMI 对象属性,甚至有趣的磁盘文件。您还可以使用它从 ETW 提供者(我们将在下面讨论)获取数据,以供后续分析。通过使用 XML 模板,系统管理员可以快速地将数据收集器集应用于系统,并通过很少的手动配置步骤生成有用的报告。

尽管性能计数器提供了大量有趣的性能信息,但它们不能用作高性能的日志记录和监控框架。没有系统组件更新性能计数器的频率超过每秒几次,Windows 性能监视器读取性能计数器的频率不会超过每秒一次。如果您的性能调查需要每秒跟踪数千个事件,那么性能计数器并不适合。我们现在将注意力转向 Windows (ETW)的事件跟踪,它是为高性能数据收集和更丰富的数据类型(不仅仅是数字)而设计的。

Windows 事件跟踪(ETW)

Windows 事件跟踪(ETW) 是一个内置于 Windows 的高性能事件记录框架。与性能计数器的情况一样,许多系统组件和应用框架,包括 Windows 内核和 CLR,都定义了提供者 ,它们报告事件——关于组件内部工作的信息。与始终打开的性能计数器不同,ETW 提供程序可以在运行时打开和关闭,因此只有在性能调查需要它们时,才会产生传输和收集它们的性能开销。

最丰富的 ETW 信息来源之一是内核提供者,它报告关于进程和线程创建、DLL 加载、内存分配、网络 I/O 和堆栈跟踪统计的事件(也称为采样 )。表 2-1 显示了内核和 CLR ETW 提供者报告的一些有用信息。您可以使用 ETW 来调查整体系统行为,例如哪些进程正在消耗 CPU 时间,分析磁盘 I/O 和网络 I/O 瓶颈,获取托管进程的垃圾收集统计信息和内存使用情况,以及本节稍后讨论的许多其他场景。

ETW 事件标记有精确的时间,可以包含自定义信息,以及可选的堆栈跟踪事件发生的位置。这些堆栈跟踪可用于进一步识别性能和正确性问题的来源。例如,CLR 提供程序可以在每次垃圾回收的开始和结束时报告事件。结合精确的调用堆栈,这些事件可用于确定程序的哪些部分通常会导致垃圾收集。(有关垃圾收集及其触发器的更多信息,请参见第四章。)

表 2-1。Windows 中 ETW 事件的部分列表和 CLR

Tab02-01.jpg

访问这些非常详细的信息需要一个 ETW 收集工具和一个能够读取原始 ETW 事件并执行一些基本分析的应用。在撰写本文时,有两种工具能够完成这两种任务: Windows 性能工具包 (WPT,也称为 XPerf),它与 Windows SDK 一起提供,以及性能监视器 (不要与 Windows 性能监视器混淆!),这是微软 CLR 团队的一个开源项目。

Windows 性能工具包(WPT)

windows Performance Toolkit(WPT)是一套实用程序,用于控制 ETW 会话,将 ETW 事件捕获到日志文件中,并对它们进行处理以供以后显示。它可以生成 ETW 事件的图形和覆盖图,包括调用堆栈信息和聚合的汇总表,以及用于自动处理的 CSV 文件。要下载 WPT,请从msdn.microsoft.com/en-us/performance/cc752957.aspx下载 Windows SDK Web 安装程序,并从安装选项屏幕中仅选择常用实用程序image Windows Performance Toolkit。Windows SDK 安装程序完成后,导航到 SDK 安装目录 的 Redist \ Windows Performance Toolkit 子目录,并运行适用于您系统架构的安装程序文件(32 位系统为 Xperf_x86.msi,64 位系统为 Xperf_x64.msi)。

image 注意在 64 位 Windows 上,堆栈审核需要更改注册表设置,以禁用内核代码页的分页(对于 Windows 内核本身和任何驱动程序)。这可能会将系统的工作集(RAM 利用率)增加几兆字节。若要更改此设置,请导航到注册表项 HKLM \系统\当前控制集\控制\会话管理器\内存管理,将 DisablePagingExecutive 值设置为 DWORD 0x1,然后重新启动系统。

您将用于捕获和分析 ETW 追踪的工具是 XPerf.exe 和 XPerfView.exe。这两个工具都需要管理权限才能运行。XPerf.exe 工具有几个命令行选项,用于控制跟踪期间启用哪些提供程序、使用的缓冲区大小、事件刷新到的文件名以及许多其他选项。XPerfView.exe 工具分析并提供跟踪文件内容的图形报告。

所有的跟踪都可以用调用栈来扩充,调用栈通常允许对性能问题进行精确的放大。但是,您不必从特定的提供者捕获事件来获得系统正在做什么的堆栈跟踪;SysProfile 内核标志组支持以 1 毫秒为间隔从所有处理器收集堆栈跟踪。这是理解一个繁忙的系统在方法级做什么的基本方法。(在本章后面讨论采样评测器 时,我们会更详细地回到这个模式。)

用 XPERF 捕获和分析内核踪迹

在本节中,您将使用 XPerf.exe 捕获一个内核跟踪,并在 XPerfView.exe 图形工具中分析结果。本实验旨在 Windows Vista 系统或更高版本上进行。(它还要求您设置两个系统环境变量。为此,右键单击计算机,单击属性,单击“高级系统设置”,最后单击对话框底部的“环境变量”按钮。)

  1. 设置系统环境变量 _NT_SYMBOL_PATH 指向微软公共符号服务器和本地符号缓存,例如:SRV * C:\ Temp \ Symbols *msdl.microsoft.com/download/symbols
  2. 将系统环境变量 _NT_SYMCACHE_PATH 设置为磁盘上的本地目录—这应该是与上一步中的本地符号缓存不同的目录。
  3. 打开管理员命令提示符窗口,导航到安装 WPT 的安装目录(例如 C:\ Program Files \ Windows Kits \ 8.0 \ Windows Performance Toolkit)。
  4. 从基本内核提供者组开始跟踪,该组包含 PROC_THREAD、LOADER、DISK_IO、HARD_FAULTS、PROFILE、MEMINFO 和 MEMINFO_WS 内核标志(参见表 2-1 )。为此,运行以下命令:xperf -on Base
  5. 启动一些系统活动:运行应用,在窗口之间切换,打开文件——至少几秒钟。(这些是将进入跟踪的事件。)
  6. 通过运行以下命令,停止跟踪并将跟踪刷新到日志文件中:xperf -d KernelTrace.etl
  7. 通过运行以下命令启动图形性能分析器:xperfview KernelTrace.etl
  8. 结果窗口包含几个图形,每个图形对应一个在跟踪过程中生成事件的 ETW 关键字。您可以选择在左侧显示的图表。通常,最上面的图形按处理器显示处理器利用率,随后的图形显示磁盘 I/O 操作计数、内存使用情况和其他统计信息。
  9. 选择处理器利用率图的一个部分,右键单击它,然后从上下文菜单中选择 Load Symbols。再次右键单击所选部分,并选择简单汇总表。这应该会打开一个可扩展的视图,您可以在跟踪期间有一些处理器活动的所有进程中的方法之间导航。(第一次从 Microsoft 符号服务器加载符号可能会很耗时。)

There’s much more to WPT than you’ve seen in this experiment; you should explore other parts of the UI and consider capturing and analyzing trace data from other kernel groups or even your own application’s ETW providers. (We’ll discuss custom ETW providers later in this chapter.)

在许多有用的场景中,WPT 可以洞察系统的整体行为和单个进程的性能。以下是这些场景的一些截图和示例:

  • WPT 可以捕获系统上的所有磁盘 I/O 操作,并将它们显示在物理磁盘的地图上。这有助于深入了解昂贵的 I/O 操作,尤其是在旋转硬盘上涉及大量寻道的情况下。(参见图 2-2 。)
  • WPT 可以在跟踪期间为系统上的所有处理器活动提供调用堆栈。它在进程、模块和函数级别聚集调用堆栈,并允许对系统(或特定应用)在何处花费 CPU 时间有一目了然的了解。请注意,托管帧不受支持—我们将在稍后使用 PerfMonitor 工具解决这一缺陷。(参见图 2-3 。)
  • WPT 可以显示不同活动类型的覆盖图,以提供 I/O 操作、内存利用率、处理器活动和其他捕获指标之间的相关性。(参见图 2-4 。)
  • WPT 可以在跟踪中显示调用堆栈聚合(当跟踪最初用-stackwalk 命令行开关配置时)—这提供了创建了某些事件的调用堆栈的完整信息。(参见图 2-5 。)

9781430244585_Fig02-02.jpg

图 2-2 磁盘 I/O 操作在物理磁盘的地图上布局。I/O 操作之间的寻道和单个 I/O 细节通过工具提示提供

9781430244585_Fig02-03.jpg

图 2-3 单个进程的详细堆栈帧(times napper . exe)。权重栏显示了(大致)在该帧中花费了多少 CPU 时间

9781430244585_Fig02-04.jpg

图 2-4 CPU 活动(线条-每条线条表示不同的处理器)和磁盘 I/O 操作(列)的叠加图。I/O 活动和 CPU 活动之间没有明显的相关性

9781430244585_Fig02-05.jpg

图 2-5 在报表中调用堆栈聚合。请注意,托管框架仅部分显示。!?无法解析帧。mscorlib.dll 框架(例如系统。DateTime.get_Now())被成功解析,因为它们是使用 NGen 预编译的,而不是在运行时由 JIT 编译器编译的

image 注意最新版本的 Windows SDK(8.0 版)附带了一对新工具,名为 Windows Performance Recorder(wpr.exe)和 Windows Performance Analyzer(wpa . exe),旨在逐步取代我们之前使用的 XPerf 和 XPerfView 工具。比如 wpr -start CPU 大致相当于 xperf -on Diag,wpr -stop reportfile 大致相当于 xperf -d reportfile。WPA 分析 UI 略有不同,但提供了类似于 XPerfView 的功能。有关新工具的更多信息,请查阅位于 msdn.microsoft.com/en-us/libra… 的 MSDN 文档。

XPerfView 非常能够以吸引人的图形和表格显示内核提供者数据,但是它对定制提供者的支持却不那么强大。例如,我们可以从 CLR ETW 提供程序捕获事件,但 XPerfView 不会为各种事件生成漂亮的图形,我们必须根据提供程序文档中的关键字和事件列表来理解跟踪中的原始数据(MSDN 文档中提供了 CLR ETW 提供程序的关键字和事件的完整列表,http://msdn . Microsoft . com/en-us/library/ff 357720 . aspx)。

如果我们使用 CLR ETW 提供程序(e 13 c0d 23-ccbc-4e 12-931 b-d 9 cc 2 eee 27 e 4)运行 XPerf,使用 GC 事件的关键字(0x00000001)和详细日志级别(0x5),它将忠实地捕获提供程序生成的每个事件。通过将它转储到一个 CSV 文件或用 XPerfView 打开它,我们将能够——慢慢地——识别应用中与 GC 相关的事件。图 2-6 显示了生成的 XPerfView 报告的一个示例——GC/Start 和 GC /Stop 行之间经过的时间是在被监控的应用中完成一次垃圾收集所花费的时间。

9781430244585_Fig02-06.jpg

图 2-6 CLR GC 相关事件的原始报告 。选中的行显示 GCAllocationTick_V1 事件,每次大约分配 100KB 内存时都会引发该事件

幸运的是,微软的基础类库(BCL)团队已经发现了这一缺陷,并提供了一个开源库和工具来分析 CLR ETW 跟踪,称为 PerfMonitor。我们接下来讨论这个工具。

性能监视器

PerfMonitor.exe 开源命令行工具已经由微软的 BCL 团队通过 CodePlex 网站发布。在撰写本文时,最新的版本是 PerfMonitor 1.5,可以从 bcl.codeplex.com/releases/vi… 下载。与 WPT 相比,PerfMonitor 的主要优势 是它对 CLR 事件有深入的了解,并提供不仅仅是原始的表格数据。PerfMonitor 分析进程中的 GC 和 JIT 活动,可以对托管堆栈跟踪进行采样,并确定应用的哪些部分正在使用 CPU 时间。

对于高级用户,PerfMonitor 还附带了一个名为 TraceEvent 的库,该库支持对 CLR ETW 跟踪的编程访问,以便进行自动检查。您可以使用定制系统监视软件中的 TraceEvent 库来自动检查来自生产系统的跟踪,并决定如何对其进行分类。

虽然 PerfMonitor 可用于收集内核事件,甚至是来自自定义 ETW 提供程序的事件(使用/KernelEvents 和/Provider 命令行开关),但它通常用于分析使用内置 CLR 提供程序的托管应用的行为。它的 runAnalyze 命令 行选项执行您选择的应用,监控它的执行,并在它终止时生成一个详细的 HTML 报告并在您的默认浏览器中打开它。(您应该遵循 PerfMonitor 用户指南(至少是快速入门部分),以生成类似于本部分截图的报告。要显示用户指南,请运行 PerfMonitor usersguide。)

当指示 PerfMonitor 运行应用并生成报告时,它会生成以下命令行输出。在阅读本节时,您可以通过在本章的源代码文件夹 中的 JackCompiler.exe 示例应用上运行该工具来亲自试验该工具。

c:\性能监视器>性能监视器运行分析 JackCompiler.exe

开始内核跟踪。输出文件:PerfMonitorOutput.kernel.etl

开始用户模型跟踪。输出文件:PerfMonitorOutput.etl

从 2012 年 4 月 7 日下午 12:33:40 开始

当前目录 C:\PerfMonitor

执行:JackCompiler.exe {

}停止于 2012 年 4 月 7 日 12 时 33 分 42 秒= 1.724 秒

正在停止对会话“NT 内核记录器”和“PerfMonitorSession”的跟踪。

分析 C:\ PerfMonitor \ perfmonitoroutput . etlx 中的数据

C:\ PerfMonitor \ PerfMonitorOutput 中的 GC 时间 HTML 报告。GCTime.html

C:\ PerfMonitor \ perfmonitoroutput . JIT Time . HTML 中的 JIT 时间 HTML 报告

筛选以处理 JackCompiler (1372)。开始于 1372.000 毫秒。

过滤至时间区域[0.000,1391.346]毫秒

C:\ PerfMonitor \ perfmonitoroutput . cputime . HTML 中的 CPU 时间 HTML 报告

筛选以处理 JackCompiler (1372)。开始于 1372.000 毫秒。

C:\ perf monitor \ perfmonitoroutput . analyze . HTML 中的性能分析 HTML 报告

PerfMonitor 处理时间:7.172 秒。

PerfMonitor 生成的各种 HTML 文件 包含了经过提炼的报告,但是您始终可以通过 XPerfView 或任何其他能够读取二进制 ETW 跟踪的工具来使用原始的 ETL 文件。上面示例的概要分析包含以下信息(当然,当您在自己的机器上运行这个实验时,这可能会有所不同):

  • CPU 统计—消耗的 CPU 时间为 917 毫秒,平均 CPU 利用率为 56.6%。剩下的时间都用来等待什么了。
  • GC 统计—总 GC 时间为 20 毫秒,最大 GC 堆大小为 4.5MB,最大分配率为 1496.1MB/s,平均 GC 暂停时间为 0.1 毫秒
  • JIT 编译统计—JIT 编译器在运行时编译了 159 个方法,总共有 30493 字节的机器代码。

深入到 CPU、GC 和 JIT 报告 可以提供大量有用的信息。CPU 详细报告提供了使用大量 CPU 时间的方法的信息(自下而上分析)、CPU 时间使用位置的调用树(自上而下分析)以及跟踪中每个方法的单独调用者-被调用者视图。为了防止报告变得非常大,不超过预定义相关性阈值(自下而上分析为 1%,自上而下分析为 5%)的方法被排除在外。图 2-7 是一个自下而上报告的例子 CPU 工作量最大的三种方法是系统。String.Concat,JackCompiler。Tokenizer.Advance 和 system . linq . enumerable . contains .图 2-8 是一个(部分)自上而下报告的例子 JackCompiler 消耗了 84.2%的 CPU 时间。Parser.Parse,它调用 ParseClass、ParseSubDecls、ParseSubDecl、ParseSubBody 等等。

9781430244585_Fig02-07.jpg

图 2-7 来自 PerfMonitor 的自下而上报告 。“Exc %”列是该方法单独使用的 CPU 时间的估计值;“Inc %”列是该方法及其调用的所有其他方法(调用树中的子树)使用的 CPU 时间的估计值

9781430244585_Fig02-08.jpg

图 2-8 来自性能监视器的自上而下报告

详细的 GC 分析报告 包含一个表格,其中包含每一代的垃圾收集统计信息(计数、次数),以及单个 GC 事件信息,包括暂停时间、回收的内存和许多其他信息。当我们在第四章中讨论垃圾收集器的内部工作方式和性能含义时,这些信息中的一些会非常有用。图 2-9 显示了几行单独的 GC 事件。

9781430244585_Fig02-09.jpg

图 2-9 单个 GC 事件,包括回收的内存量、应用暂停时间、发生的收集类型以及其他详细信息

最后,详细的 JIT 分析报告 显示了 JIT 编译器为每个应用的方法所需的时间以及它们被编译的精确时间。这些信息有助于确定应用的启动性能是否可以提高——如果 JIT 编译器花费了过多的启动时间,预编译应用的二进制文件(使用 NGen)可能是一个值得的优化。我们将在第十章的中讨论 NGEN 和其他减少应用启动时间的策略。

image 提示从多个高性能 ETW 提供商那里收集信息会生成非常大的日志文件。例如,在默认收集模式下,PerfMonitor 通常每秒生成 5MB 以上的原始数据。让这样的痕迹持续几天可能会耗尽磁盘空间,即使是在大容量硬盘上。幸运的是,XPerf 和 PerfMonitor 都支持循环日志模式,在这种模式下,只保留最后的 N 兆字节的日志。在 PerfMonitor 中,/Circular 命令行开关采用最大日志文件大小(以兆字节为单位),并在超过阈值时自动丢弃最旧的日志。

虽然 PerfMonitor 是一个非常强大的工具,但它的原始 HTML 报告和丰富的命令行选项使它有点难以使用。我们将看到的下一个工具提供了与 PerfMonitor 非常相似的功能,并且可以在相同的场景中使用,但是它有一个更加用户友好的界面来收集和解释 ETW 信息,并且将使一些性能调查大大缩短。

性能视图工具

PerfView 是一个免费的微软工具,它将 PerfMonitor 中已经提供的 ETW 收集和分析功能与堆分析功能统一起来,我们将在后面结合 CLR Profiler 和 ANTS Memory Profiler 等工具讨论堆分析功能。你可以从微软下载中心下载 PerfView,地址是 www.microsoft.com/download/en… PerfView,因为它需要访问 ETW 基础架构。](www.microsoft.com/download/en…)

9781430244585_Fig02-10.jpg

图 2-10??。 PerfView 的主界面。在文件视图(左侧)中,可以看到一个堆转储和一个 ETW 跟踪。主视图上的链接指向工具支持的各种命令

要分析来自特定进程的 ETW 信息,使用 PerfView 中的 Collect image Run 菜单项(图 2-10 显示了主 UI)。出于我们稍后将执行的堆分析的目的,您可以在本章源代码文件夹中的 MemoryLeak.exe 示例应用上使用 PerfView。它将为您运行该流程,并生成一份报告,其中包含 PerfMonitor 提供的所有信息以及更多信息,包括:

  • 从各种提供程序收集的 ETW 事件的原始列表(例如,CLR 争用信息、本机磁盘 I/O、TCP 数据包和硬页面错误)
  • 应用 CPU 时间花费的分组堆栈位置,包括可配置的过滤器和阈值
  • 映像(程序集)加载、磁盘 I/O 操作和 GC 分配的堆栈位置(针对每 100KB 分配的对象)
  • GC 统计和事件,包括每次垃圾收集的持续时间和回收的空间量

此外,PerfView 可用于从当前运行的进程中捕获堆快照,或者从转储文件中导入堆快照。导入后,PerfView 可用于在快照中查找内存利用率最高的类型,并识别负责保持这些类型活动的引用链。图 2-11 显示了调度类的 PerfView 引用分析器,它负责(包括)31MB 的堆快照内容。PerfView 成功地识别出持有调度对象引用的 Employee 类实例,同时 Employee 实例被 f-reachable 队列保留(在第四章中讨论)。

9781430244585_Fig02-11.jpg

图 2-11??。调度类实例的引用链,在捕获的堆快照中负责应用 99.5%的内存使用

当我们在本章后面讨论内存分析器时,我们会看到与商业工具相比,PerfView 的可视化功能仍然有些欠缺。尽管如此,PerfView 是一个非常有用的免费工具,它可以大大缩短许多性能调查的时间。您可以使用从其主屏幕链接的内置教程来了解更多信息,还有 BCL 团队录制的视频展示了该工具的一些主要功能。

自定义 ETW 提供商

与性能计数器类似,您可能希望利用 ETW 为您自己的应用需求提供的强大的工具和信息收集框架。之前。NET 4.5 中,从托管应用公开 ETW 信息是相当复杂的。您必须处理大量关于为应用的 ETW 提供者定义清单、在运行时实例化它以及记录事件的细节。截至。NET 4.5,编写一个定制的 ETW 提供程序再简单不过了。你需要做的就是从系统中推导出来。并调用 WriteEvent 基类方法来输出 ETW 事件。向系统注册 ETW 提供者和格式化事件数据的所有细节都是自动为您处理的。

下面的类是托管应用中 ETW 提供程序的一个示例(完整的程序可以在本章的源代码文件夹中找到,以后可以使用 PerfMonitor 运行它):

public class CustomEventSource : EventSource {
  public class Keywords {
   public const EventKeywords Loop = (EventKeywords)1;
   public const EventKeywords Method = (EventKeywords)2;
  }
  [Event(1, Level = EventLevel.Verbose, Keywords = Keywords.Loop,
   Message = "Loop {0} iteration {1}")]
  public void LoopIteration(string loopTitle, int iteration) {
   WriteEvent(1, loopTitle, iteration);
  }
  [Event(2, Level = EventLevel.Informational, Keywords = Keywords.Loop,
   Message = "Loop {0} done")]
  public void LoopDone(string loopTitle) {
   WriteEvent(2, loopTitle);
  }
  [Event(3, Level = EventLevel.Informational, Keywords = Keywords.Method,
   Message = "Method {0} done")]
  public void MethodDone([CallerMemberName] string methodName = null) {
   WriteEvent(3, methodName);
  }
}
class Program {
  static void Main(string[] args) {
   CustomEventSource log = new CustomEventSource();
   for (int i = 0; i < 10; ++i) {
   Thread.Sleep(50);
   log.LoopIteration("MainLoop", i);
   }
   log.LoopDone("MainLoop");
   Thread.Sleep(100);
   log.MethodDone();
  }
}

PerfMonitor 工具可用于从该应用自动获取其包含的 ETW 提供程序,在监控该 ETW 提供程序的同时运行该应用,并生成该应用提交的所有 ETW 事件的报告。例如:

c:\性能监视器>性能监视器监视器转储 Ch02.exe

开始内核跟踪。输出文件:PerfMonitorOutput.kernel.etl

开始用户模型跟踪。输出文件:PerfMonitorOutput.etl

找到了提供程序 CustomEventSource Guid ff 6a 40d 2-5116-5555-675 b-4468 e 821162 e

正在启用提供程序 ff 6a 40d 2-5116-5555-675 b-4468 e 821162 e 级别:详细关键字:0xffffffffffffffff

从 2012 年 4 月 7 日下午 1:44:00 开始

当前目录 C:\PerfMonitor

执行:Ch02.exe {

}停止于 2012 年 4 月 7 日下午 1 点 44 分 01 秒= 0.693 秒

正在停止对会话“NT 内核记录器”和“PerfMonitorSession”的跟踪。

将 C:\ PerfMonitor \ perfmonitoroutput . etlx 转换为 XML 文件。

C:\ PerfMonitor \ PerfMonitor output . dump . XML 中的输出

PerfMonitor 处理时间:1.886 秒。

image 注意还有一个性能监控和系统健康检测框架我们没有考虑: Windows 管理检测 (WMI)。WMI 是集成在 Windows 中的命令和控制(C & C)基础设施,不在本章讨论范围之内。它可用于获取有关系统状态的信息(如安装的操作系统、BIOS 固件或可用磁盘空间),注册感兴趣的事件(如进程创建和终止),以及调用更改系统状态的控制方法(如创建网络共享或卸载驱动程序)。有关 WMI 的更多信息,请参考位于msdn . Microsoft . com/en-us/library/windows/desktop/aa 394582 . aspx的 MSDN 文档。如果您对开发托管 WMI 提供程序感兴趣,可以阅读 Sasha Goldshtein 的文章“WMI 提供程序扩展。NET 3.5”(www . code project . com/Articles/25783/WMI-Provider-Extensions-in-NET-3-5,2008)提供了一个良好的开端。

时间分析器

虽然性能计数器和 ETW 事件提供了对 Windows 应用性能的大量洞察,但通常从更具侵入性的工具(分析器)中可以获得很多,这些工具在方法和行级别检查应用的执行时间(在 ETW 堆栈跟踪收集支持的基础上进行改进)。在本节中,我们将介绍一些商业工具,并了解它们带来的好处,请记住,更强大、更精确的工具需要更大的测量开销。

在我们进入分析器世界的旅程中,我们会遇到许多商业工具;其中大多数都有几个现成的对等物。我们不认可任何特定的工具供应商;本章中展示的产品只是我们最常用的分析器,放在我们的工具箱中用于性能研究。和软件工具一样,你的里程数可能会有所不同。

我们考虑的第一个分析器是 Visual Studio 的一部分,自 Visual Studio 2005(Team Suite edition)以来就由微软提供。在本章中,我们将使用 Visual Studio 2012 探查器,它在 Visual Studio 的高级版和旗舰版中都有提供。

Visual Studio 采样探查器

Visual Studio 采样分析器 的操作类似于我们在 ETW 部分看到的概要内核标志。它定期中断应用,并记录当前运行应用线程的每个处理器上的调用堆栈信息。与内核 ETW 提供程序不同,这个采样分析器可以根据几个标准中断进程,其中一些标准在表 2-2 中列出。

表 2-2 。Visual Studio 采样探查器事件(部分列表)

Tab02-02.jpg

使用 Visual Studio profiler 捕获样本非常便宜,如果样本事件间隔足够宽(默认为 10,000,000 个时钟周期),应用的执行开销可以少于 5%。此外,采样非常灵活,可以连接到一个正在运行的进程,收集一段时间的样本事件,然后从该进程断开以分析数据。由于这些特点,采样是开始 CPU 瓶颈性能调查的推荐方法,这种方法需要大量的 CPU 时间。

当采样会话完成时,探查器会提供摘要表,其中每个方法都与两个数字相关联:独占样本数*,这是该方法当前在 CPU 上执行时获取的样本;包含样本数*,这是该方法当前执行时或调用堆栈上任何其他位置获取的样本。具有许多独占样本的方法负责应用的 CPU 利用率;具有许多包含样本的方法不直接使用 CPU,而是调用其他使用 CPU 的方法。(例如,在单线程应用中,Main 方法拥有 100%的包含样本是有意义的。)**

**从 VISUAL STUDIO 运行采样分析器

运行采样分析器最简单的方法是从 Visual Studio 本身,尽管(我们将在后面看到)它也支持从简化的命令行环境中进行生产分析。我们建议您使用自己的一个应用来做这个实验。

  1. 在 Visual Studio 中,单击 Analyze image启动性能向导菜单项。
  2. 在向导的第一页上,确保选择了“CPU sampling”单选按钮,然后单击“Next”按钮。(在本章的后面,我们将讨论其他的分析模式;然后你可以重复这个实验。)
  3. 如果要分析的项目加载到当前解决方案中,请单击“一个或多个可用项目”单选按钮,并从列表中选择项目。否则,单击“可执行文件(。EXE 文件)”单选按钮。单击下一步按钮。
  4. 如果您选择了“可执行文件(。在上一个屏幕上,将 profiler 指向您的可执行文件,并在必要时提供任何命令行参数,然后单击 Next 按钮。(如果您手头没有自己的应用,请随意使用本章源代码文件夹中的 JackCompiler.exe 示例应用。)
  5. 选中“向导完成后启动性能分析”复选框,然后单击“完成”按钮。
  6. 如果您不是以管理员身份运行 Visual Studio,将提示您升级探查器的凭据。
  7. 当您的应用完成执行时,将会打开一个分析报告。使用顶部的“当前视图”组合框在不同视图之间导航,显示应用代码中收集的样本。

For more experimentation, after the profiling session completes make sure to check out the Performance Explorer tool window (Analyze image Windows image Performance Explorer). You can configure sampling parameters (e.g. choosing a different sample event or interval), change the target binary, and compare multiple profiler reports from different runs.

图 2-12 显示了一个概要分析器结果的窗口,带有最昂贵的调用路径和收集了最多独占样本的函数。图 2-13 显示的是详细报告,其中有几个方法负责大部分 CPU 利用率(有大量独占样本)。双击列表中的一个方法会弹出一个详细的窗口,该窗口显示了应用的源代码,这些代码行用颜色进行了编码,在这些代码行中收集了大多数样本(参见图 2-14 )。

9781430244585_Fig02-12.jpg

图 2-12??。 Profiler 报告, 概要视图——负责大部分样本的调用路径和样本独占最多的函数

9781430244585_Fig02-13.jpg

图 2-13??。功能视图 ,显示样本最多的功能。系统。String.Concat 函数负责的样本是其他函数的两倍

9781430244585_Fig02-14.jpg

图 2-14??。函数详细视图,显示 JackCompiler 调用的函数。CompilationOutputTextWriter . WriteLine 函数。在该函数的代码中,根据累积的包含样本的百分比来突出显示各行

image 注意看起来采样是一种测量 CPU 利用率的精确技术。你可能会听到这样的说法,“如果这个方法有 65%的独占样本,那么它会运行 65%的时间”。由于抽样的统计性质,这种推理是不可靠的,在实际应用中应该避免。有几个因素会导致采样结果的不准确:在应用执行期间,CPU 时钟速率每秒钟可以改变数百次,使得样本数量和实际 CPU 时间之间的相关性被扭曲;如果在采集许多样本时,某个方法碰巧没有运行,则该方法可能被“遗漏”(代表性不足);如果一个方法在采集许多样本时碰巧正在运行,但每次都很快完成,则该方法可能被过度表示。总而言之,您不应该认为采样分析器的结果是 CPU 时间消耗的精确表示,而是应用主要 CPU 瓶颈的一般概述。

Visual Studio 探查器除了为每种方法提供独占/包含示例表之外,还提供了更多信息。我们建议您自己浏览一下评测器的窗口——调用树视图显示了应用方法中调用的层次结构(与 PerfMonitor 的自顶向下分析相比,图 2-8 ),“行”视图显示行级别的采样信息,“模块”视图按汇编对方法进行分组,这可以快速得出查找性能瓶颈的大致方向。

因为所有采样间隔都需要触发它们的应用线程在 CPU 上主动执行,所以无法从等待 I/O 或同步机制时被阻塞的应用线程中获取样本。对于 CPU 受限的应用,采样是理想的;对于 I/O 绑定的应用,我们将不得不考虑依赖于更具侵入性的分析机制的其他方法。

Visual Studio 检测分析器

Visual Studio profiler 提供了另一种操作模式,称为检测分析 ,它是为测量整体执行时间而定制的,而不仅仅是 CPU 时间。这使得它适合于分析 I/O 绑定的应用或大量参与同步操作的应用。在检测分析模式中,分析器修改目标二进制文件,并在其中嵌入测量代码,该代码向分析器报告每个被检测方法 的准确计时和调用计数信息。

例如,考虑以下方法:

public static int InstrumentedMethod(int param) {
  List< int > evens = new List < int > ();
  for (int i = 0; i < param; ++i) {
   if (i % 2 == 0) {
   evens.Add(i);
   }
  }
  return evens.Count;
}

在检测过程中,Visual Studio 探查器会修改此方法。请记住,插装发生在二进制级别——您的源代码是经过而不是修改的,但是您总是可以使用 IL 反汇编程序来检查插装的二进制代码,比如。网状反射器。(为了简洁起见,我们稍微修改了结果代码。)

public static int mmid = (int)
  Microsoft.VisualStudio.Instrumentation.g_fldMMID_2D71B909-C28E-4fd9-A0E7-ED05264B707A;
public static int InstrumentedMethod(int param) {
  _CAP_Enter_Function_Managed(mmid, 0x600000b, 0);
  _CAP_StartProfiling_Managed(mmid, 0x600000b, 0xa000018);
  _CAP_StopProfiling_Managed(mmid, 0x600000b, 0);
  List < int > evens = new List < int > ();
  for (int i = 0; i < param; i++) {
   if (i % 2 == 0) {
   _CAP_StartProfiling_Managed(mmid, 0x600000b, 0xa000019);
   evens.Add(i);
   _CAP_StopProfiling_Managed(mmid, 0x600000b, 0);
   }
  }
  _CAP_StartProfiling_Managed(mmid, 0x600000b, 0xa00001a);
  _CAP_StopProfiling_Managed(mmid, 0x600000b, 0);
  int count = evens.Count;
  _CAP_Exit_Function_Managed(mmid, 0x600000b, 0);
  return count;
}

以 _CAP 开头的方法调用是对 VSPerf110.dll 模块的互操作调用,该模块被检测的程序集引用。他们负责测量时间和记录方法调用计数。因为检测会捕获从检测代码发出的每个方法调用,并捕获方法进入和退出位置,所以在检测运行结束时可用的信息可能非常准确。

当我们在图 2-12 、图 2-13 和图 2-14 中看到的同一个应用在检测模式下运行时(您可以跟随—这是 JackCompiler.exe 应用),分析器生成一个带有摘要视图的报告,其中包含类似的信息—应用中开销最大的调用路径,以及具有最多单独工作的函数。然而,这一次信息不是基于样本计数(仅测量 CPU 上的执行);它基于仪器代码记录的精确定时信息。图 2-15 显示了函数视图,在该视图中,以毫秒为单位测量的包含时间和不包含时间是可用的,以及函数被调用的次数。

9781430244585_Fig02-15.jpg

图 2-15??。功能视图:系统。随着我们的注意力转移到 JackCompiler 上,String.Concat 似乎不再是性能瓶颈。Tokenizer.NextChar 和 JackCompiler。代币..克特。第一个方法被调用了将近一百万次

image 提示用于生成图 2-12 和图 2-15 的示例应用并不完全受限于 CPU 事实上,它的大部分时间都花在了阻止 I/O 操作完成上。这解释了指向系统的采样结果之间的差异。String.Concat 作为 CPU hog,以及指向 JackCompiler 的插装结果。Tokenizer.NextChar 成为整体的性能瓶颈。

尽管插装看起来是更准确的方法,但在实践中,如果应用的大部分代码都是 CPU 受限的,那么您应该尽量坚持采样。检测限制了灵活性,因为您必须在启动应用之前检测它的代码,并且不能将探查器附加到已经运行的进程。此外,插装有一个不可忽略的开销——它显著增加了代码的大小,并增加了运行时开销,因为每当程序进入或退出一个方法时都会收集探测。(一些检测分析器提供了行检测模式,其中每一行都被检测探针包围;这些更慢!)

和往常一样,最大的风险是过于信任检测分析器的结果。有理由假设对特定方法的调用次数不会因为应用使用检测而改变,但是由于探查器的开销,所收集的时间信息可能仍然有很大偏差,尽管探查器尝试从最终结果中抵消检测成本。小心使用时,采样和插装可以提供关于应用在哪里花费时间的深刻见解,特别是当您比较多个报告并注意您的优化是否产生成果时。

时间分析器的高级用途

时间分析器还有一些我们在前面章节中没有研究过的技巧。本章太短,无法详细讨论它们,但是它们值得指出来,以确保您不会因为 Visual Studio 向导的舒适性而错过它们。

取样提示

正如我们在 Visual Studio 采样分析器一节中看到的,采样分析器可以从几种类型的事件中收集样本,包括缓存未命中和页面错误。在第五章和第六章中,我们将看到几个应用的示例,这些应用可以从改善其内存访问特性中大大受益,主要围绕着最大限度地减少缓存缺失。在分析这些应用显示的缓存未命中和页面错误的数量以及它们在代码中的精确位置时,分析器将被证明是有价值的。(使用指令评测时,您仍然可以收集 CPU 计数器,如缓存未命中、失效的指令以及预测错误的分支。为此,请从性能资源管理器窗格中打开性能会话属性,并导航到 CPU 计数器选项卡。收集的信息将在报告的 Functions 视图中作为附加列提供。)

采样分析模式通常比检测模式更灵活。例如,您可以使用“性能资源管理器”窗格将探查器(在采样模式下)附加到已经运行的进程。

分析时收集附加数据

在所有的评测模式 中,当评测器处于活动状态时,您可以使用 Performance Explorer 窗格暂停和恢复数据收集,并生成标记,这些标记将在最终的评测器报告中可见,以便更容易地识别应用执行的各个部分。这些标记将在报告的标记视图中可见。

image 提示Visual Studio profiler 甚至有一个 API,应用可以使用它来暂停和恢复对代码的分析。这可以用来避免从应用中不感兴趣的部分收集数据,并减小分析器数据文件的大小。有关探查器 API 的更多信息,请参考位于msdn . Microsoft . com/en-us/library/bb 514149 的 MSDN 文档。aspx

在正常的分析运行期间,探查器还可以收集 Windows 性能计数器和 ETW 事件(本章前面已经讨论过)。要启用这些功能,请从性能资源管理器中打开性能会话属性,并导航到 Windows 事件和 Windows 计数器选项卡。ETW 跟踪数据只能通过使用 VSPerfReport /summary:ETW 命令行开关从命令行查看,而性能计数器数据将出现在 Visual Studio 的报告标记视图中。

最后,如果 Visual Studio 需要很长时间来分析包含大量附加数据的报告,您可以确保这是一次性的性能损失:分析完成后,在性能资源管理器中右键单击该报告,然后选择“保存分析的报告”。序列化报告文件具有。vsps 文件扩展名,并在 Visual Studio 中即时打开。

分析器指南

在 Visual Studio 中打开一个报告时,您可能会注意到一个名为 Profiler Guidance 的部分,其中包含许多有用的提示,可以检测到本书其他地方讨论的常见性能问题,包括:

  • “考虑使用 StringBuilder 进行字符串连接”——这是一个有用的规则,可能有助于降低应用创建的垃圾量,从而减少垃圾收集时间,在第四章的中讨论过。
  • “你的许多对象都在第二代垃圾收集中被收集”——对象的中年危机现象,也在第四章中讨论。
  • “覆盖值类型上的等于和等于运算符”——一个常用值类型的重要优化,在第三章的中讨论。
  • “你可能过度使用了反射。这是一个昂贵的手术”——在第十章中讨论。

高级概要定制

如果您必须安装 Visual Studio 之类的大型工具,从生产环境中收集性能信息可能会很困难。幸运的是,Visual Studio 探查器可以在没有整个 Visual Studio 套件的生产环境中安装和运行。您可以在 Visual Studio 安装介质上的独立探查器目录中找到探查器安装文件(32 位和 64 位系统有不同的版本)。安装了 profiler 之后,请按照msdn . Microsoft . com/en-us/library/ms 182401(v = vs . 110)中的说明进行操作。aspx 在分析器下启动您的应用,或者使用 VSPerfCmd.exe 工具附加到现有的进程。完成后,探查器将生成一个. vsp 文件,您可以使用 Visual Studio 在另一台计算机上打开该文件,或者使用 VSPerfReport.exe 工具生成 XML 或 CSV 报告,您可以在生产计算机上查看这些报告,而无需求助于 Visual Studio。

对于检测分析,使用 VSInstr.exe 工具,可以从命令行使用许多定制选项。具体来说,您可以使用 START、SUSPEND、INCLUDE 和 EXCLUDE 选项来启动和暂停特定函数中的分析,并根据函数名称中的模式在检测中包含/排除函数。更多关于 VSInstr.exe 的信息可以在 msdn.microsoft.com/en-us/libra… 的 MSDN 上找到。

一些时间分析器提供了远程分析模式,允许主分析器 UI 在一台机器上运行,而分析会话在另一台机器上进行,而无需手动复制性能报告。例如,JetBrains dotTrace 分析器通过一个小的远程代理支持这种操作模式,该代理运行在远程机器上并与主分析器 UI 通信。这是在生产机器上安装整个 profiler 套件的一个很好的替代方法。

image 注第六章中的我们将利用 GPU 进行超并行计算,导致可观的(超过 100×!)加速。当性能问题出现在 GPU 上运行的代码中时,标准的时间分析器就没有用了。有一些工具可以分析和诊断 GPU 代码中的性能问题,包括 Visual Studio 2012。这个主题超出了本章的范围,但是如果您使用 GPU 进行图形或简单计算,您应该研究适用于您的 GPU 编程框架的工具(如 C++ AMP、CUDA 或 OpenCL)。

在本节中,我们已经非常详细地了解了如何使用 Visual Studio profiler 来分析应用的执行时间(总体时间或仅 CPU 时间)。内存管理是托管应用性能的另一个重要方面。在接下来的两节中,我们将讨论分配分析器 和内存分析器,它们可以查明应用中与内存相关的性能瓶颈。

分配分析器

分配分析器检测应用执行的内存分配,并可以报告哪些方法分配了最多的内存,每个方法分配了哪些类型,以及类似的与内存相关的统计信息 。内存密集型应用通常会在垃圾收集器中花费大量时间,回收以前分配的内存。正如我们将在《??》第四章中看到的,CLR 使得分配内存变得非常容易和便宜,但是恢复它可能会非常昂贵。因此,一组分配大量内存的小方法可能不会花费大量的 CPU 时间来运行,并且在时间分析器的报告中几乎看不到,但会在应用执行的不确定点造成垃圾收集,从而导致速度下降。我们已经看到生产应用在内存分配上粗心大意,并能够通过调整其分配和内存管理来提高其性能——有时提高 10 倍。

我们将使用两个工具来分析内存分配——无处不在的 Visual Studio profiler,它提供了分配分析模式,以及 CLR Profiler,它是一个免费的独立工具。不幸的是,这两种工具通常会给内存密集型应用带来严重的性能影响,因为每次内存分配都必须通过分析器进行记录。尽管如此,结果可能非常有价值,即使是 100 倍的减速也值得等待。

Visual Studio 分配探查器

Visual Studio profiler 可以在采样和检测模式下收集分配信息和对象生存期数据(垃圾收集器回收了哪些对象)。将此功能用于采样时,探查器从整个进程中收集分配数据;使用检测,探查器只从检测的模块中收集数据。

您可以通过在本章的源代码文件夹中的 JackCompiler.exe 示例应用上运行 Visual Studio profiler 来跟进。确保选择”。NET 内存分配”。在分析过程的最后,概要视图显示了分配最多内存的函数和分配最多内存的类型(参见图 2-16 )。报告中的函数视图包含每个方法分配的对象数和字节数(通常提供包含和不包含的度量),函数细节视图可以提供调用者和被调用者信息,以及在空白处带有分配信息的彩色突出显示的源代码(参见图 2-17 )。更有趣的信息在分配视图中,它显示了哪些调用树负责分配特定的类型(参见图 2-18 )。

9781430244585_Fig02-16.jpg

图 2-16??。分配分析结果汇总视图

9781430244585_Fig02-17.jpg

图 2-17??。功能详情视图 为 JackCompiler。Tokenizer.Advance 函数,显示调用者、被调用者和函数的源代码,在空白处有分配计数

9781430244585_Fig02-18.jpg

图 2-18??。分配视图 ,显示负责分配系统的调用树。字符串对象

在第四章中,我们将学会理解快速丢弃临时对象的重要性,并讨论一种称为中年危机的关键性能相关现象,这种现象发生在临时对象经历了太多垃圾收集之后。为了识别应用中的这种现象,探查器报告中的对象生存期视图可以指示对象在哪一代被回收,这有助于了解它们是否在太多的垃圾收集后仍然存在。在图 2-19 中你可以看到应用分配的所有字符串(超过 1GB 的对象!)已经在第 0 代中被回收,这意味着它们甚至连一次垃圾收集都没有存活下来。

9781430244585_Fig02-19.jpg

图 2-19??。对象生存期视图 帮助识别在多次垃圾收集中幸存的临时对象。在这个视图中,所有对象都在第 0 代中被回收,这是可用的最便宜的垃圾收集方式。(详见第四章。)

尽管 Visual Studio profiler 生成的分配报告非常强大,但它们在可视化方面有些欠缺。例如,如果一个特定的类型被分配在许多地方(字符串和字节数组总是这样),那么通过分配调用堆栈跟踪它就非常耗时。CLR 探查器提供了几个可视化功能,这使它成为 Visual Studio 的一个有价值的替代方案。

CLR Profiler

CLR Profiler 是一个独立的分析工具,不需要安装,占用的磁盘空间不到 1MB。你可以从 www.microsoft.com/download/en… 下载。另外,它附带了完整的源代码,如果您正在考虑使用 CLR Profiling API 开发一个定制工具,这将是一个有趣的读物。它可以附加到正在运行的进程(从 CLR 4.0 开始)或启动可执行文件,并记录所有内存分配和垃圾收集事件。

虽然运行 CLR Profiler 非常简单(运行 Profiler,单击“启动应用”,选择您的应用,然后等待报告出现),但报告信息的丰富程度可能会让人不知所措。我们将讨论报告中的一些观点;CLR 探查器的完整指南是 CLRProfiler.doc 文档,它是下载包的一部分。和往常一样,您可以在 JackCompiler.exe 示例应用上运行 CLR Profiler。

图 2-20 显示了剖析应用终止后生成的主视图 。它包含有关内存分配和垃圾收集的基本统计信息。从这里有几个共同的方向。我们可以重点调查内存分配源,以了解应用在何处创建其大多数对象(这类似于 Visual Studio profiler 的 Allocations 视图)。我们可以关注垃圾收集,以了解哪些对象正在被回收。最后,我们可以直观地检查堆的内容,以了解它的一般结构。

9781430244585_Fig02-20.jpg

图 2-20??。 CLR Profiler 的主报告视图,显示分配和垃圾收集统计信息

在图 2-20 中“已分配字节”和“最终堆字节”旁边的直方图按钮和会导致一个对象类型的图表,这些对象类型根据它们的大小被分组到各个容器中。这些直方图可以用来识别大对象和小对象,以及程序分配最频繁的类型的要点。图 2-21 显示了我们的示例应用在运行过程中分配的所有对象的直方图。

9781430244585_Fig02-21.jpg

图 2-21??。被分析的应用分配的所有对象。每个箱代表特定大小的对象。左边的图例包含从每种类型分配的字节和实例总数

图 2-20 中的分配图按钮 打开一个视图,在一个分组图中显示应用中所有对象的分配调用栈,这样可以很容易地从分配大部分内存的方法导航到各个类型,并查看哪些方法分配了它们的实例。图 2-22 显示了分配图的一小部分,从解析器开始。ParseStatement 方法,它分配了 372MB 的内存,并依次显示了它调用的各种方法。(此外,CLR Profiler 视图的其余部分有一个“显示分配给谁”上下文菜单项,它为应用对象的子集打开分配图。)

9781430244585_Fig02-22.jpg

图 2-22??。评测应用的分配图。这里只显示了方法;实际分配的类型在图的最右边

图 2-20 中的年龄直方图按钮显示了一个图表,该图表根据年龄将最终堆中的对象分组到存储箱中。这使得能够快速识别长寿命的对象和临时对象,这对于检测中年危机情况是重要的。(我们将在第四章中深入讨论这些。)

图 2-20 中的按地址对象按钮将最终管理的堆内存区域分层可视化;最低层是最老的层(见图 2-23 )。就像考古探险一样,您可以挖掘这些层,看看哪些对象构成了您的应用的内存。这个视图对于诊断堆中的内部碎片也很有用(例如,由于固定)——我们将在第四章的中更详细地讨论这些。

9781430244585_Fig02-23.jpg

图 2-23??。应用堆的可视化视图 。左边轴上的标签是地址;“gen 0”和“gen 1”标记是堆的子部分,在第四章中讨论

最后,图 2-20 中垃圾收集统计部分的时间线按钮 导致单个垃圾收集及其对应用堆的影响的可视化(参见图 2-24 )。该视图可用于确定哪些类型的对象正在被回收,以及随着垃圾收集的发生,堆是如何变化的。它还有助于识别内存泄漏,即垃圾收集没有回收足够的内存,因为应用保留了越来越多的对象。

9781430244585_Fig02-24.jpg

图 2-24??。应用垃圾收集的时间线。底部轴上的记号代表单独的 GC 运行,所描绘的区域是托管堆。随着垃圾收集的发生,内存使用量显著下降,然后急剧上升,直到下一次收集发生。总的来说,内存使用(在 GC 之后)是恒定的,所以这个应用没有出现内存泄漏

分配图和直方图是非常有用的工具,但有时识别对象之间的引用和不调用方法堆栈同样重要。例如,当应用出现托管内存泄漏时,对其堆进行爬网、检测最大的对象类别并确定阻止 GC 收集这些对象的对象引用是非常有用的。当被分析的应用正在运行时,单击“Show Heap now”按钮会生成一个堆转储,稍后可以对其进行检查以对对象之间的引用进行分类。

图 2-25 显示了三个堆转储如何同时显示在分析器的报告中,显示了 f-reachable 队列保留的 byte[]对象数量的增加(在第四章中讨论),通过雇员和调度对象引用。图 2-26 显示了从上下文菜单中选择“显示新对象”的结果,以便只查看在第二次和第三次堆转储之间分配的对象。

9781430244585_Fig02-25.jpg

图 2-25??。三个堆转储一个在另一个上面,显示 11MB 的 byte[]实例被保留

9781430244585_Fig02-26.jpg

图 2-26??。在最后一个和倒数第二个堆转储之间分配的新对象,显示内存泄漏的来源显然是来自 f-reachable 队列的这个引用链

您可以在 CLR Profiler 中使用堆转储来诊断应用中的内存泄漏,但是缺少可视化工具。我们接下来讨论的商业工具提供了更丰富的功能,包括常见内存泄漏源的自动检测器、智能过滤器和更复杂的分组。因为这些工具中的大多数不记录每个分配对象的信息,也不捕获分配调用栈,所以它们给被分析的应用带来了较低的开销——这是一个很大的优势。

内存分析器

在这一节中,我们将讨论两个商业内存分析器,它们专门用于可视化托管堆和检测内存泄漏源。因为这些工具相当复杂,我们将只研究它们的一小部分特性,而将其余的留给读者在各自的用户指南中探索。

蚂蚁内存分析器

RedGate 的 ANTS 内存分析器专门从事堆快照分析。下面我们详细介绍使用 ANTS 内存分析器诊断内存泄漏的过程。如果您想在阅读本节时遵循这些步骤,请从www . red-gate . com/products/dot net-development/ANTS-Memory-Profiler/下载 14 天免费试用版的 ANTS Memory Profiler,并使用它来分析您自己的应用。在下面的说明和截图中,我们使用了 ANTS Memory Profiler 7.3,这是撰写本文时可用的最新版本。

您可以使用本章源代码文件夹中的 FileExplorer.exe 应用来遵循这个演示——要使它泄漏内存,请导航左边的目录树到非空目录 。

  1. 从探查器中运行应用。(与 CLR Profiler 类似,从 CLR 4.0 开始,ANTS 支持附加到正在运行的进程。)
  2. 应用完成初始化后,使用“获取内存快照”按钮捕获初始快照。该快照是后续性能调查的基准。
  3. 随着内存泄漏的累积,获取额外的堆快照。
  4. 应用终止后,比较快照(基线快照与最后一个快照,或它们之间的中间快照)以了解哪些类型的对象正在内存中增长。
  5. 使用实例分类程序关注特定类型,以了解哪些类型的引用保留了可疑类型的对象。(在这个阶段,您正在检查类型之间的引用——引用类型 B 实例的类型 A 实例将按类型分组,就好像 A 正在引用 B 。)
  6. 使用实例列表浏览可疑类型的单个实例。确定几个有代表性的实例,并使用实例保留图来确定它们保留在内存中的原因。(在这个阶段,您正在检查各个对象之间的引用,并且可以看到为什么特定的对象没有被 GC 回收。)
  7. 返回到应用的源代码并修改它,使泄漏的对象不再被有问题的链引用。

在分析过程的最后,您应该很好地理解了为什么应用中最重的对象没有被 GC 回收。内存泄漏的原因有很多,真正的艺术是从百万对象堆中快速辨别出有趣的代表性对象和类型,从而导致主要的泄漏源。

图 2-27 显示了两张快照之间的对比视图。内存泄漏(以字节为单位)主要由字符串对象组成。关注实例分类器中的字符串类型(在图 2-28 中)可以得出这样的结论:有一个事件将 FileInformation 实例保留在内存中,它们又保存了对 byte[]对象的引用。使用实例保留图(参见图 2-29 )向下钻取以检查特定实例指向文件信息。FileInformationNeedsRefresh 需要一个新的静态事件作为内存泄漏的来源。

9781430244585_Fig02-27.jpg

图 2-27 。两个堆快照之间的比较。两者之差总计+6.23MB,目前内存中持有的最大类型是 System。线

9781430244585_Fig02-28.jpg

图 2-28??。字符串 由字符串数组保留,字符串数组由 FileInformation 实例保留,file information 实例又由事件(通过系统)保留。EventHandler 委托)

9781430244585_Fig02-29.jpg

图 2-29??。我们选择检查的单个字符串 是字符串数组中的元素 29,由 FileInformation 对象的k _ _ backing field 字段保存。跟随引用指向文件信息。FileInformationNeedsRefresh 刷新静态事件

科学技术。NET 内存分析器

科学技术。内存分析器是另一个专注于内存泄漏诊断的商业工具。虽然一般的分析流程与 ANTS 内存分析器非常相似,但是这个分析器可以打开转储文件 ,这意味着您不必在应用旁边运行它,并且可以使用当 CLR 耗尽内存时生成的崩溃转储。在问题已经在生产环境中发生之后,这对于诊断内存泄漏事后检查 可能是至关重要的。你可以从 memprofiler.com/download.as… 下载 10 天评估版。在下面的说明和截图中,我们使用了。NET 内存分析器 4.0,这是撰写本文时可用的最新版本。

image 注意 CLR Profiler 不能直接打开转储文件,但是有一个 SOS.DLL 命令叫做!可以生成 CLR 探查器格式的. log 文件的 TraverseHeap。我们将在第三章和第四章中讨论更多 SOS.DLL 命令的例子。同时,Sasha Goldshtein 在blog . sashag . net/archive/2008/04/08/next-generation-production-debugging-demo-2-and-demo-3 . aspx上发表的博客文章提供了一个如何一起使用 SOS.DLL 和 CLR Profiler 的示例。

在中打开内存转储。NET 内存分析器,选择文件image导入内存转储菜单项,并将分析器定向到转储文件。如果您有几个转储文件,您可以将它们全部导入到分析会话中,并将它们作为堆快照进行比较。导入过程可能相当长,尤其是在涉及大堆的情况下;对于更快的分析会话,SciTech 提供了一个单独的工具,NmpCore.exe,它可以用来捕获生产环境中的堆会话,而不是依赖于转储文件。

图 2-30 显示了比较两个内存转储的结果。NET 内存分析器。它立即发现了由事件处理程序直接保存在内存中的可疑对象,并将分析指向 FileInformation 对象。

9781430244585_Fig02-30.jpg

图 2-30 两张内存快照 的初步分析。第一列列出了活动实例的数量,而第三列列出了它们所占用的字节数。由于工具提示,主内存猪——字符串 对象——不可见

关注 FileInformation 对象说明了 FileInformation 只有一个根路径。FileInformation 需要一个刷新事件处理程序来处理所选的 file information 实例 (参见图 2-31 )并且单个实例的可视化证实了我们之前在 ANTS 内存分析器中看到的相同的引用链。

9781430244585_Fig02-31.jpg

图 2-31 。文件信息实例。“保留的字节”列列出了每个实例保留的内存量(它在对象图中的子树)。右侧显示了实例的最短根路径

我们不会在这里重复使用的其余说明。NET Memory Profiler 的功能——你可以在 SciTech 的网站上找到优秀的教程,memprofiler.com/OnlineDocs/。该工具总结了我们对内存泄漏检测工具和技术的调查,这是从 CLR Profiler 的堆转储开始的。

其他分析器

在本章中,我们选择主要关注 CPU、时间和内存分析器,因为这些是大多数性能调查关注的指标。有几个其他的性能指标有专门的测量工具;在这一节中,我们将简要地提到其中的一些。

数据库和数据访问分析器

许多托管应用都是围绕数据库构建的,它们花费大量时间等待数据库返回查询结果或完成批量更新。数据库访问可以在两个位置进行分析:从应用端,这是数据访问分析器的领域;从数据库端,最好留给数据库分析器

数据库分析器通常需要特定于供应商的专业知识,通常由数据库管理员在他们的性能调查和日常工作中使用。这里我们不考虑数据库分析器;您可以在msdn.microsoft.com/en-us/library/ms181091.aspx了解更多关于 SQL Server Profiler 的信息,这是一款非常强大的数据库分析工具,适用于 Microsoft SQL Server。

另一方面,数据访问分析器完全属于应用开发人员的领域。这些工具检测应用的数据访问层(DAL) 并通常报告以下内容:

  • 由应用的 DAL 执行的数据库查询,以及启动每个操作的精确堆栈跟踪。
  • 启动数据库操作的应用方法列表,以及每个方法已经运行的查询列表。
  • 针对低效数据库访问的警报,例如使用未绑定的结果集执行查询、检索所有表列而只使用其中的一些列、发出具有太多连接的查询,或者对具有 N 个关联实体的实体进行一次查询,然后对每个关联实体进行另一次查询(也称为“选择 N + 1”问题)。

有几种商业工具可以分析应用数据访问模式。其中一些只适用于特定的数据库产品(如 Microsoft SQL Server),而另一些只适用于特定的数据访问框架(如 Entity Framework 或 NHibernate)。以下是几个例子:

  • RedGate ANTS Performance Profiler 可以分析对 Microsoft SQL Server 数据库的应用查询。
  • Visual Studio 的“层交互”分析功能可以分析来自 ADO 的任何同步数据访问操作。遗憾的是,它不报告数据库操作的调用栈。
  • 休眠 Rhinos 系列分析器(LINQ 到 SQL 分析器、实体框架分析器和 NHibernate 分析器)可以分析特定数据访问框架执行的所有操作。

我们不会在这里更详细地讨论这些分析器,但是如果您关心数据访问层的性能,您应该考虑在您的性能调查中与时间或内存分析器一起运行它们。

并发分析器

并行编程越来越受欢迎,这使得使用多线程在多个处理器上运行的高并发软件需要专门的分析器。在第六章中,我们将考察几个场景,在这些场景中,并行化可以轻松实现成熟的性能提升——而这些性能提升最好通过精确的测量工具来实现。

Visual Studio 探查器在其并发和并发可视化工具模式下使用 ETW 来监视并发应用的性能,并报告几个有用的视图,这些视图有助于检测特定于高并发软件的可伸缩性和性能瓶颈。它有两种工作模式,如下所示。

并发模式(或资源争用剖析)检测应用线程正在等待的资源,例如托管锁。报告的一部分集中在资源本身,以及被阻塞等待它们的线程——这有助于发现和消除可伸缩性瓶颈(见图 2-32 )。报告的另一部分显示特定线程的争用信息,即线程必须等待的各种同步机制,这有助于减少特定线程执行中的障碍。要在这种操作模式下启动 profiler,请使用 Performance Explorer 窗格或 Analyze image启动性能向导菜单项,并选择并发模式。

9781430244585_Fig02-32.jpg

图 2-32??。对特定资源的争用——有几个线程同时等待获取资源。当一个线程被选中时,它的阻塞调用堆栈列在底部

Concurrency Visualizer 模式(或多线程执行可视化)显示一个图表,其中包含所有应用线程的执行细节,并根据其当前状态进行颜色编码。每一个线程状态转换——阻塞 I/O、等待同步机制、运行——都被记录下来,以及它的调用堆栈和解除阻塞调用堆栈(如适用)(参见图 2-33 )。这些报告非常有助于理解应用线程的作用,并检测不良的性能模式,如超额预订、预订不足、饥饿和过度同步。图中还内置了对任务并行库机制的支持,比如并行循环和 CLR 同步机制。要在这种操作模式下启动分析器,请使用分析image并发可视化子菜单。

9781430244585_Fig02-33.jpg

图 2-33 几个应用线程(列在左边)及其执行的可视化。从可视化效果和底部的直方图可以明显看出,工作并没有在不同线程之间均匀分布

image 注意 MSDN 的特色是基于并发可视化图形的多线程应用的反模式集合,包括锁护卫、不均匀的工作负载分布、超额订阅等等——你可以在msdn . Microsoft . com/en-us/library/ee 329530(v = vs . 110)找到这些在线反模式。aspx 。当您运行自己的测量时,您将能够通过直观地比较报告来识别类似的问题。

并发分析和可视化是非常有用的工具,我们将在后续章节中再次遇到它们。它们是 ETW 巨大影响力的另一个有力证据——这种无处不在的高性能监控框架被用于托管和本机分析工具。

I/O 配置文件 ??

本章中我们研究的最后一个性能指标类别是 I/O 操作。ETW 事件可以用来获得物理磁盘访问、页面错误、网络数据包和其他类型的 I/O 的计数和详细信息,但我们还没有看到任何针对 I/O 操作的专门处理。

Sysinternals 进程监视器是一个收集文件系统、注册表和网络活动的免费工具(见图 2-34 )。您可以从 TechNet 网站 technet.microsoft.com/en-us/sysin…下载整个 Sysinternals 工具套件,或者只下载最新版本的 Process Monitor。通过应用其丰富的过滤功能,系统管理员和性能调查人员可以使用 Process Monitor 来诊断与 I/O 相关的错误(如文件丢失或权限不足)以及性能问题(如远程文件系统访问或过度分页)。

9781430244585_Fig02-34.jpg

图 2-34??。进程监视器在主视图中显示几种类型的事件,在对话框窗口中显示特定事件的调用堆栈。第 19 帧和更低的帧是管理帧

Process Monitor 为其捕获的每个事件提供了完整的用户模式和内核模式堆栈跟踪,这使得了解应用源代码中过多或错误的 I/O 操作的来源非常理想。不幸的是,在撰写本文时,Process Monitor 无法解码托管调用堆栈,但它至少可以指出执行 I/O 操作的应用的大致方向。

在本章的学习过程中,我们使用了自动工具来从各个方面衡量应用的性能——执行时间、CPU 时间、内存分配,甚至 I/O 操作。各种各样的测量技术势不可挡,这也是为什么开发人员经常喜欢对他们的应用的性能进行手动基准测试的原因之一。在结束本章之前,我们讨论一下微基准测试和它的一些潜在缺陷。

微基准测试

一些性能问题只能通过手动测量来解决。您可能正在决定是否值得使用 StringBuilder、测量第三方库的性能、通过展开内部循环来优化复杂的算法,或者通过反复试验来帮助 JIT 将常用的数据放入寄存器——并且您可能不愿意使用分析器来为您进行性能测量,因为分析器太慢、太贵或者太麻烦。尽管经常有危险,微基准测试仍然非常流行。如果你做了,我们想确保你做对了。

糟糕的微基准测试示例

我们从一个设计不良的微基准测试的例子开始,并对其进行改进,直到它提供的结果有意义并与问题领域的实际知识很好地相关。目的是确定哪个更快——使用 is 关键字然后转换为所需的类型,或者使用 as 关键字并依赖结果。

//Test class
class Employee {
  public void Work() {}
}
//Fragment 1 – casting safely and then checking for null
static void Fragment1(object obj) {
  Employee emp = obj as Employee;
  if (emp ! = null) {
   emp.Work();
  }
}
//Fragment 2 – first checking the type and then casting
static void Fragment2(object obj) {
  if (obj is Employee) {
   Employee emp = obj as Employee;
   emp.Work();
  }
}

一个基本的基准框架可能遵循以下路线:

static void Main() {
  object obj = new Employee();
  Stopwatch sw = Stopwatch.StartNew();
  for (int i = 0; i < 500; i++) {
   Fragment1(obj);
  }
  Console.WriteLine(sw.ElapsedTicks);
  sw = Stopwatch.StartNew();
  for (int i = 0; i < 500; i++) {
   Fragment2(obj);
  }
  Console.WriteLine(sw.ElapsedTicks);
}

这是而不是令人信服的微基准,尽管结果是相当可重复的。通常,第一个循环的输出是 4 个节拍,第二个循环是 200-400 个节拍。这可能导致第一个片段快 50-100 倍的结论。然而,这种测量和由此得出的结论存在重大误差:

  • 循环只运行一次,500 次迭代不足以得出任何有意义的结论——运行整个基准测试只需要很少的时间,因此它会受到许多环境因素的影响。
  • 没有努力阻止优化,所以 JIT 编译器可能已经完全内联并丢弃了这两个度量循环。
  • Fragment1 和 Fragment2 方法不仅度量 is 和 as 关键字的开销,还度量方法调用的开销(对于片段 N 方法本身!).调用该方法可能比其余的工作要昂贵得多。

针对这些问题,下面的微基准测试更接近地描述了两种操作的实际成本:

class Employee {
  //Prevent the JIT compiler from inlining this method (optimizing it away)
  [MethodImpl(MethodImplOptions.NoInlining)]
  public void Work() {}
}
static void Measure(object obj) {
  const int OUTER_ITERATIONS = 10;
  const int INNER_ITERATIONS = 100000000;
  //The outer loop is repeated many times to make sure we get reliable results
  for (int i = 0; i < OUTER_ITERATIONS; ++i) {
   Stopwatch sw = Stopwatch.StartNew();
   //The inner measurement loop is repeated many times to make sure we are measuring an
   //operation of significant duration
   for (int j = 0; j < INNER_ITERATIONS; ++j) {
   Employee emp = obj as Employee;
   if (emp ! = null)
   emp.Work();
   }
   Console.WriteLine("As - {0}ms", sw.ElapsedMilliseconds);
  }
  for (int i = 0; i < OUTER_ITERATIONS; ++i) {
   Stopwatch sw = Stopwatch.StartNew();
   for (int j = 0; j < INNER_ITERATIONS; ++j) {
   if (obj is Employee) {
   Employee emp = obj as Employee;
   emp.Work();
   }
   }
   Console.WriteLine("Is Then As - {0}ms", sw.ElapsedMilliseconds);
  }
}

在作者的一台测试机器上的结果(在丢弃第一次迭代之后)是,第一次循环大约 410ms,第二次循环大约 440ms,这是一个可靠的、可重复的性能差异,这可能会使您确信,实际上,只使用 as 关键字进行强制转换和检查更有效。

然而,谜题还没有结束。如果我们将虚拟修改器添加到工作方法中,性能差异会完全消失,即使我们增加迭代次数也不会。这不能用我们的微基准框架的优缺点来解释——这是问题域的结果。在这两种情况下,如果不进入汇编语言级别并检查 JIT 编译器生成的循环,就无法理解这种行为。一、虚修饰语前:

; Disassembled loop body – the first loop
mov edx,ebx
mov ecx,163780h (MT: Employee)
call clr!JIT_IsInstanceOfClass (705ecfaa)
test eax,eax
je WRONG_TYPE
mov ecx,eax
call dword ptr ds:[163774h] (Employee.Work(), mdToken: 06000001)
WRONG_TYPE:
; Disassembled loop body – the second loop
mov edx,ebx
mov ecx,163780h (MT: Employee)
call clr!JIT_IsInstanceOfClass (705ecfaa)
test eax,eax
je WRONG_TYPE
mov ecx,ebx

cmp 双字指针[ecx],ecx

call dword ptr ds:[163774h] (Employee.Work(), mdToken: 06000001)
WRONG_TYPE:

在第三章中,我们将深入讨论 JIT 编译器发出的指令序列来调用一个非虚方法和一个虚方法。当调用非虚方法时,JIT 编译器必须发出一条指令,确保我们没有在空引用上进行方法调用。第二个循环中的 CMP 指令服务于该任务。在第一个循环中,JIT 编译器足够聪明地优化了这种检查,因为在调用之前,有一个对转换结果的空引用检查(if (emp!= null)。。。).在第二个循环中,JIT 编译器的优化试探法不足以优化 check away(尽管它同样安全),这个额外的指令造成了额外的 7-8%的性能开销。

但是,在添加虚拟修饰符后,JIT 编译器在两个循环体中生成完全相同的代码:

; Disassembled loop body – both cases
mov edx,ebx
mov ecx,1A3794h (MT: Employee)
call clr!JIT_IsInstanceOfClass (6b24cfaa)
test eax,eax
je WRONG_TYPE
mov ecx,eax
mov eax,dword ptr [ecx]
mov eax,dword ptr [eax + 28h]
call dword ptr [eax + 10h]
WRONG_TYPE:

原因是当调用一个虚拟方法时,不需要显式地执行空引用检查——这是方法分派序列中固有的(正如我们将在第三章中看到的)。当循环体相同时,计时结果也相同。

微基准测试指南

对于成功的微基准测试,您必须确保您决定测量的内容遵循以下准则 :

  • 您的测试代码环境代表了开发它的真实环境。例如,如果某个方法被设计为对数据库表进行操作,则不应该对内存中的数据集合运行该方法。
  • 您的测试代码的输入代表了它被开发的真实输入。例如,如果一个排序方法被设计成对有几百万个元素的集合进行操作,那么就不应该衡量它在三元素列表上的表现。
  • 与您正在测量的实际测试代码相比,用于设置环境的支持代码应该可以忽略不计。如果这是不可能的,那么设置应该发生一次,测试代码应该重复多次。
  • 测试代码应该运行足够长的时间,以便在面对硬件和软件波动时相当可靠。例如,如果您正在测试值类型的装箱操作的开销,单个装箱操作可能会太快而无法产生显著的结果,并且将需要多次重复相同的测试才能变得实际。
  • 测试代码不应该被语言编译器或 JIT 编译器优化掉。当试图测量简单的操作时,这经常发生在发布模式中。(我们稍后会回到这一点。)

当您已经确定您的测试代码足够健壮,并且测量了您想要测量的精确效果时,您应该投入一些时间来设置基准测试环境:

  • 当基准运行时,不应该允许其他进程在目标系统上运行。应该尽量减少网络、文件 I/O 和其他类型的外部活动(例如,通过禁用网卡和关闭不必要的服务)。
  • 分配许多对象的基准应该警惕垃圾收集的影响。建议在重要的基准测试迭代前后强制进行垃圾收集,以最小化它们之间的相互影响。
  • 测试系统上的硬件应该与生产环境中使用的硬件相似。例如,涉及密集随机磁盘寻道的基准测试在固态硬盘上的运行速度要比带有旋转磁头的机械硬盘快得多。(这同样适用于显卡、SIMD 指令等特定处理器特性、内存架构和其他硬件特性。)

最后,你应该关注测量本身。设计基准测试代码时要记住以下几点:

  • 丢弃第一个测量结果——它经常受到 JIT 编译器和其他应用启动成本的影响。此外,在第一次测量期间,数据和指令不太可能在处理器的高速缓存中。(有一些衡量缓存效果的基准,不应该听从这个建议。)
  • 多次重复测量,不仅仅使用平均值,标准偏差(代表结果的方差)和连续测量之间的波动也很有趣。
  • 从基准测试代码中减去测量循环的开销——这需要测量空循环的开销,这不是小事,因为 JIT 编译器可能会优化掉空循环。(用汇编语言手动编写循环是对抗这种风险的一种方法。)
  • 从计时结果中减去时间测量开销,并使用最便宜且最精确的可用时间测量方法,这通常是 System.Diagnostics.Stopwatch。
  • 了解测量机制的分辨率、精度和准确度,例如环境。TickCount 的精度通常只有 10-15 毫秒,尽管它的分辨率似乎是 1 毫秒。

image 注意分辨率是细度的度量机制。如果它报告的结果是 100 纳秒的整数倍,那么它的分辨率就是 100 纳秒。然而,它的精度可能要低得多——对于 500 纳秒的物理时间间隔,它可能一次报告 2×100 纳秒,另一次报告 7×100 纳秒。在这种情况下,我们或许可以将精度上限定在 300ns。最后,准确性是衡量机制的正确程度。如果它可靠地、重复地以 100 纳秒的精度将 5000 纳秒的物理时间间隔报告为 5400 纳秒,我们可以说它的准确性是事实的+8%。

本节开头的不幸例子不应该阻止您编写自己的微基准。但是,您应该注意这里给出的建议,并设计有意义的基准,其结果可以信任。最差的性能优化是基于不正确的测量;不幸的是,手工基准测试经常会陷入这个陷阱。

摘要

性能测量不是一项简单的任务,原因之一是度量和工具的多样性,以及工具对测量准确性和应用行为的影响。我们在这一章中已经看到了大量的工具,如果让你准确地说出哪种情况下应该使用哪种工具,你可能会感到有点头晕。表 2-3 总结了本章展示的所有工具的重要特征。

表 2-3 。本章中使用的绩效衡量工具

工具性能指标开销特殊优点/缺点
Visual Studio 采样探查器CPU 使用率、缓存未命中、页面错误、系统调用低的-
Visual Studio 检测分析器执行时间中等无法附加到正在运行的进程
Visual Studio 分配探查器内存分配中等-
Visual Studio 并发可视化工具线程可视化、资源争用低的可视化线程进度信息、争用细节、解除阻塞堆栈
CLR 配置文件内存分配、垃圾收集统计、对象引用高的可视化堆图、分配图、GC 时间线可视化
性能监控器流程或系统级别的数字性能指标没有人只有数字信息,而不是方法级别的
BCL 性能监视器运行时间、GC 信息、JIT 信息极低简单、几乎没有开销的运行时分析
PerfView运行时间、堆信息、GC 信息、JIT 信息极低为 PerfMonitor 添加了空闲堆分析功能
Windows 性能工具包来自系统级和应用级提供商的 ETW 事件极低-
过程监视器文件、注册表和网络 I/O 操作低的-
实体框架分析器通过实体框架类进行数据访问中等-
蚂蚁内存分析器内存使用和堆信息中等强大的过滤器和强大的可视化功能
。网络内存分析器内存使用和堆信息中等可以打开内存转储文件

有了这些工具和对托管应用预期的性能指标的一般理解,我们现在准备深入 CLR 的内部,看看可以采取哪些实际步骤来提高托管应用的性能。**

三、类型内部原理

本章关注的是的内部。NET 类型、值类型和引用类型在内存中的布局、JIT 调用虚方法必须做什么、正确实现值类型的复杂性以及其他细节。为什么我们要自寻烦恼,花几十页来讨论这些内部工作方式呢?这些内部细节如何影响我们应用的性能?事实证明,值类型和引用类型在布局、分配、相等、赋值、存储和许多其他参数方面是不同的,这使得正确的类型选择对应用性能至关重要。

一个例子

考虑一个名为 Point2D 的简单类型,它表示一个小的二维空间中的一个点。两个坐标中的每一个都可以用一个短整型来表示,对于整个对象来说总共有四个字节。现在假设你想在内存中存储一千万个点的数组。它们需要多大的空间?这个问题的答案很大程度上取决于 Point2D 是引用类型还是值类型。如果是引用类型,一千万个点的数组实际上会存储一千万个引用。在 32 位系统上,这一千万个引用消耗了将近 40 MB 的内存。物体本身消耗的能量至少是相同的。事实上,我们很快就会看到,每个 Point2D 实例将占用至少 12 个字节的内存,从而使一个包含一千万个点的数组的总内存使用量达到 160MB!另一方面,如果 Point2D 是值类型,一千万个点的数组将存储一千万个点——不会浪费一个额外的字节,总共 40MB,比引用类型方法少四倍(见图 3-1 )。这种内存密度的差异是在某些设置中偏好值类型的关键原因。

9781430244585_Fig03-01.jpg

图 3-1 。Point2D 实例的数组在 Point2D 的情况下是引用类型而不是值类型

image 注意存储对点的引用而不是实际的点实例还有一个缺点。如果您想顺序遍历这个巨大的点数组,编译器和硬件访问 Point2D 实例的连续数组要比通过引用访问堆对象容易得多,因为堆对象不能保证在内存中是连续的。正如我们将在第五章中看到的,CPU 缓存的考虑会影响应用的执行时间一个数量级。

不可避免地得出这样的结论:理解 CLR 如何在内存中布局对象以及引用类型与值类型有何不同的细节,对于我们的应用的性能至关重要。我们首先回顾值类型和引用类型在语言层面上的基本区别,然后深入内部实现细节。

引用类型和值类型之间的语义差异

中的引用类型。NET 包括类、委托、接口和数组。字符串(系统。String),这是。NET 也是一种引用类型。中的值类型。NET 包含结构和枚举。基本类型,比如 int,float,decimal,都是值类型,但是。NET 开发人员可以使用 struct 关键字自由定义其他值类型。

在语言层面上,引用类型享有引用语义,其中对象的身份在其内容之前被考虑,而值类型享有值语义,其中对象没有身份,不通过引用访问,并根据其内容处理。这会影响的几个方面。NET 语言,如表 3-1 所示。

表 3-1。值类型和引用类型的语义差异

标准参考类型值类型
将对象传递给方法仅传递引用;更改会传播到所有其他引用对象的内容被复制到参数中(除非使用 ref 或 out 关键字);更改不会影响方法之外的任何代码
将一个变量赋给另一个变量仅复制引用;两个变量现在包含对同一对象的引用内容被复制;这两个变量包含不相关数据的相同副本
使用运算符==比较两个对象比较参考文献;如果两个引用引用同一个对象,则它们是相等的内容比较;如果两个对象的内容在逐字段级别上相同,则它们是相等的

这些语义差异是我们在任何。网语。然而,就引用类型和值类型及其用途的不同而言,它们只是冰山一角。首先,让我们考虑存储对象的内存位置,以及它们是如何分配和释放的。

存储、分配和解除分配

引用类型专门从托管堆 中分配,托管堆是由。NET 垃圾收集器,这将在第四章中详细讨论。从托管堆中分配一个对象需要增加一个指针,就性能而言,这是一个相当便宜的操作。在多处理器系统上,如果多个处理器访问同一个堆,就需要一些同步,但是与非托管环境(如 malloc)中的分配器相比,这种分配仍然非常便宜。

垃圾收集器 以一种不确定的方式回收内存,并且对其内部操作不做任何承诺。正如我们将在《??》第四章中看到的,一个完整的垃圾收集过程是极其昂贵的,但是一个表现良好的应用的平均垃圾收集成本应该比一个类似的非托管应用的平均垃圾收集成本要小得多。

image 注意准确的说,这里的一个可以从栈中分配的引用类型的化身。使用不安全上下文和 stackalloc 关键字,或者通过使用 fixed 关键字(在第八章中讨论)将固定大小的数组嵌入到自定义结构中,可以从堆栈中分配某些原始类型的数组(例如整数数组)。然而,由 stackalloc 和 fixed 关键字创建的对象并不是“真正的”数组,它们的内存布局不同于从堆中分配的标准数组。

独立值类型 通常从执行线程的堆栈中分配。然而,值类型可以嵌入到引用类型中,在这种情况下,它们被分配到堆中,并且可以被装箱,将它们的存储转移到堆中(我们将在本章后面重新讨论装箱)。从堆栈中分配值类型实例是一种非常廉价的操作,它涉及修改堆栈指针寄存器(尤其是在 Intel x86 上),并且具有一次分配几个对象的额外优势。事实上,对于一个方法的序言代码来说,只使用一条 CPU 指令为其最外层块中的所有局部变量分配堆栈存储是很常见的。

回收堆栈内存也非常有效,并且需要对堆栈指针寄存器进行反向修改。由于将方法编译成机器码的方式,通常足够编译器不需要跟踪方法的局部变量的大小,并且可以在一组标准的三个指令中破坏整个堆栈帧,称为函数后记

下面是编译成 32 位机器码的托管方法的典型序言和尾声(这不是由 JIT 编译器生成的实际生产代码,它采用了在第十章中讨论的许多优化)。该方法有四个局部变量,它们的存储在序言中一次分配,在结语中一次回收:

int Calculation(int a, int b)
{
  int x = a + b;
  int y = a - b;
  int z = b - a;
  int w = 2 * b + 2 * a;
  return x + y + z + w;
}
; parameters are passed on the stack in [esp+4] and [esp+8]
push ebp
mov ebp, esp
add esp, 16 ; allocates storage for four local variablesmov eax, dword ptr [ebp+8]
add eax, dword ptr [ebp+12]
mov dword ptr [ebp-4], eax
; ...similar manipulations for y, z, w
mov eax, dword ptr [ebp-4]
add eax, dword ptr [ebp-8]
add eax, dword ptr [ebp-12]
add eax, dword ptr [ebp-16] ; eax contains the return value
mov esp, ebp ; restores the stack frame, thus reclaiming the local storage spacepop ebp
ret 8 ; reclaims the storage for the two parameters

image 注意在 C# 和其他托管语言中,new 关键字并不意味着堆分配。您也可以使用 new 关键字在堆栈上分配值类型。例如,下面的代码行从堆栈中分配一个 DateTime 实例,用除夕夜(系统。DateTime 是值类型):DateTime new year = new DateTime(2011,12,31);

栈和堆有什么区别?

与普遍的看法相反,在. NET 进程中,堆栈和堆之间并没有太大的区别。堆栈和堆只不过是虚拟内存中的地址范围,与为托管堆保留的地址范围相比,为特定线程的堆栈保留的地址范围没有内在优势。访问堆上的内存位置并不比访问堆栈上的内存位置更快或更慢。总的来说,在某些情况下,有几个考虑因素可能支持对堆栈位置的内存访问比对堆位置的内存访问更快的说法。其中包括:

  • 在堆栈上,时间分配局部性(在时间上靠得很近的分配)意味着空间局部性(在空间上靠得很近的存储)。反过来,当时间分配局部性意味着时间访问局部性(一起分配的对象被一起访问)时,顺序堆栈存储往往相对于 CPU 高速缓存和操作系统分页系统表现得更好。
  • 由于引用类型的开销,堆栈上的内存密度往往高于堆上的内存密度(这将在本章后面讨论)。更高的内存密度通常会带来更好的性能,例如,因为更多的对象适合 CPU 缓存。
  • 线程堆栈往往很小 Windows 上默认的最大堆栈大小是 1MB,大多数线程实际上只使用很少的堆栈页面。在现代系统中,所有应用线程的堆栈都可以放入 CPU 缓存,使得典型的堆栈对象访问速度极快。(另一方面,整个堆很少适合 CPU 缓存。)

也就是说,你不应该把所有的分配都转移到堆栈中!Windows 上的线程堆栈是有限的,通过应用不明智的递归和大量堆栈分配很容易耗尽堆栈。

在研究了值类型和引用类型之间的表面差异之后,是时候转向底层的实现细节了,这也解释了我们已经多次暗示过的内存密度的巨大差异。在我们开始之前,有一个小警告:下面描述的细节是 CLR 的内部实现细节,可能会在不通知的情况下随时更改。我们已尽最大努力确保这些信息是最新的。NET 4.5 发布,但不能保证以后仍然正确。

参考型内部原理

我们从引用类型开始,引用类型的内存布局相当复杂,对它们的运行时性能有重大影响。出于讨论的目的,让我们考虑一个雇员引用类型的教科书示例,它有几个字段(实例和静态)以及几个方法:

public class Employee
{
  private int _id;
  private string _name;
  private static CompanyPolicy _policy;
  public virtual void Work() {
   Console.WriteLine(“Zzzz...”);
  }
  public void TakeVacation(int days) {
   Console.WriteLine(“Zzzz...”);
  }
  public static void SetCompanyPolicy(CompanyPolicy policy) {
   _policy = policy;
  }
}

现在考虑托管堆上 Employee 引用类型的一个实例。图 3-2 描述了一个 32 位实例的布局。净进程:

9781430244585_Fig03-02.jpg

图 3-2 。托管堆上 Employee 实例的布局,包括引用类型开销

对象中 _id 和 _name 字段的顺序是不确定的(尽管它是可以控制的,正如我们将在“值类型内部”一节中看到的,使用 StructLayout 属性)。然而,对象的内存存储以一个名为对象头字(或同步块索引)的四字节字段开始,随后是另一个名为方法表指针(或类型对象指针)的四字节字段。这些字段不能从任何。NET 语言——它们服务于 JIT 和 CLR 本身。对象引用(在内部只是一个内存地址)指向方法表指针的开始,因此对象头字位于对象地址的负偏移量处。

image 注意在 32 位系统上,堆中的对象与最近的四字节倍数对齐。这意味着,由于对齐的原因,只有一个字节成员的对象仍然会在堆中占用 12 个字节(事实上,即使没有实例字段的类在实例化时也会占用 12 个字节)。引入 64 位系统有几个不同之处。首先,方法表指针字段(它就是一个指针)占用了 8 个字节的内存,对象头字也占用了 8 个字节。其次,堆中的对象与最接近的 8 字节倍数对齐。这意味着一个 64 位堆中只有一个字节成员的对象将占用 24 个字节的内存。这只是为了更有力地证明引用类型的内存密度开销,尤其是在批量创建小对象的情况下。

方法表

方法表指针指向称为方法表(MT)的内部 CLR 数据结构,方法表又指向另一个称为 EEClass 的内部结构(EE 代表执行引擎)。MT 和 EEClass 一起包含调度虚拟方法调用、接口方法调用、访问静态变量、确定运行时对象的类型、有效地访问基本类型方法以及服务于许多附加目的所需的信息。方法表包含频繁访问的信息,这是关键机制(如虚拟方法调度)的运行时操作所需要的,而 EEClass 包含不太频繁访问的信息,但仍由一些运行时机制(如反射)使用。我们可以通过使用!DumpMT 和!DumpClass SOS 命令和 Rotor (SSCLI)源代码,请记住,我们正在讨论的内部实现细节在不同的 CLR 版本之间可能会有很大的不同。

image SOS(罢工之子)是一个调试器扩展 DLL,方便使用 Windows 调试器调试托管应用。它最常用于 WinDbg,但也可以使用即时窗口加载到 Visual Studio 中。它的命令提供了对 CLR 内部的洞察,这也是我们在本章中经常使用它的原因。有关 SOS 的更多信息,请参考内嵌帮助(的!加载扩展后的 help 命令)和 MSDN 文档。Mario Hewardt 的书《高级》对 SOS 特性和调试托管应用进行了精彩的论述。NET 调试”(Addison-Wesley,2009)。

静态字段的位置由 EEClass 决定。基元字段(如整数)存储在加载器堆上动态分配的位置,而自定义值类型和引用类型存储为对堆位置的间接引用(通过 AppDomain 范围的对象数组)。要访问静态字段,没有必要查阅方法表或 ee class——JIT 编译器可以将静态字段的地址硬编码到生成的机器码中。静态字段的引用数组是固定的,因此它的地址在垃圾收集期间不能改变(在第四章的中有更详细的讨论),原始静态字段驻留在方法表中,垃圾收集器也不接触它。这确保了硬编码地址可用于访问这些字段:

public static void SetCompanyPolicy(CompanyPolicy policy)
{
   _policy = policy;
}
mov ecx, dword ptr [ebp+8] ;copy parameter to ECX
mov dword ptr [0x3543320], ecx ;copy ECX to the static field location in the global pinned array

方法表包含的最明显的东西是一个代码地址数组,该类型的每个方法都有一个地址,包括从其基类型继承的任何虚方法。例如,图 3-3 显示了上面 Employee 类的一个可能的方法表布局,假设它只来自 System。对象:

9781430244585_Fig03-03.jpg

图 3-3 。雇员类的方法表(局部视图)

我们可以使用!DumpMT SOS 命令,给定一个方法表指针(可以通过检查它的第一个字段或使用!Name2EE 命令)。-md 开关将输出方法描述符表,其中包含该类型的每个方法的代码地址和方法描述符。(JIT 列可以有三个值之一:PreJIT,表示该方法是使用 NGEN 编译的;JIT,意味着该方法是在运行时 JIT 编译的;或者没有,这意味着该方法尚未编译。)

0:000> r esi
esi=02774ec8
0:000> !do esi
Name: CompanyPolicy
MethodTable: 002a3828
EEClass: 002a1350
Size: 12(0xc) bytes
File: D:\Development\...\App.exe
Fields:
None
0:000> dd esi L1
02774ec8 002a3828
0:000> !dumpmt -md 002a3828
EEClass: 002a1350
Module: 002a2e7c
Name: CompanyPolicy
mdToken: 02000002
File: D:\Development\...\App.exe
BaseSize: 0xc
ComponentSize: 0x0
Slots in VTable: 5
Number of IFaces in IFaceMap: 0
--------------------------------------
MethodDesc Table
Entry    MethodDe   JIT Name
5b625450 5b3c3524 PreJIT System.Object.ToString()
5b6106b0 5b3c352c PreJIT System.Object.Equals(System.Object)
5b610270 5b3c354c PreJIT System.Object.GetHashCode()
5b610230 5b3c3560 PreJIT System.Object.Finalize()
002ac058 002a3820 NONE CompanyPolicy..ctor()

image 注意与 C++虚函数指针表不同,CLR 方法表包含了所有方法的代码地址,包括非虚函数。方法表创建者布置方法的顺序是不确定的。目前,它们按以下顺序排列:继承的虚方法(包括任何可能的重写——稍后讨论)、新引入的虚方法、非虚实例方法和静态方法。

存储在方法表中的代码地址是动态生成的 JIT 编译器在方法第一次被调用时编译它们,除非使用了 NGEN(在第十章中讨论)。然而,由于一个相当常见的编译器技巧,方法表的用户不需要知道这个步骤。当方法表第一次被创建时,它被填充了指向特殊的 pre-JIT 存根的指针,这些存根包含一个调用指令,该指令将调用者调度到一个 JIT 例程,该例程动态地编译相关的方法。编译完成后,存根会被 JMP 指令覆盖,该指令将控制权转移给新编译的方法。存储 pre-JIT 存根和一些关于方法的附加信息的整个数据结构称为方法描述符(MD ),可以通过!DumpMD SOS 命令。

在方法被 JIT 编译之前,它的方法描述符包含以下信息:

0:000> !dumpmd 003737a8
Method Name:   Employee.Sleep()
Class:   003712fc
MethodTable:   003737c8
mdToken:   06000003
Module:   00372e7c
IsJitted:   no
CodeAddr:       ffffffff
Transparency:   Critical

下面是一个负责更新方法描述符的 pre-JIT 存根的示例:

0:000> !u 002ac035
Unmanaged code
002ac035 b002 mov al,2
002ac037 eb08 jmp 002ac041
002ac039 b005 mov al,5
002ac03b eb04 jmp 002ac041
002ac03d b008 mov al,8
002ac03f eb00 jmp 002ac041
002ac041 0fb6c0 movzx eax,al
002ac044 c1e002 shl eax,2
002ac047 05a0372a00 add eax,2A37A0h
002ac04c e98270ca66 jmp clr!ThePreStub (66f530d3)

该方法经过 JIT 编译后,其方法描述符更改为:

0:007> !dumpmd 003737a8
Method Name: Employee.Sleep()
Class: 003712fc
MethodTable: 003737c8
mdToken: 06000003
Module: 00372e7c
IsJitted: yes
CodeAddr: 00490140
Transparency: Critical

一个真实的方法表包含了更多我们之前公开的信息。理解一些额外的字段对于下面讨论的方法分派的细节是至关重要的;这就是为什么我们必须花更长的时间来研究 Employee 实例的方法表结构。我们还假设 Employee 类实现了三个接口:IComparable、IDisposable 和 ICloneable。

在图 3-4 中,对我们之前对方法表布局的理解有几个补充。首先,方法表头包含几个有趣的标志,允许动态发现其布局,例如虚方法的数量和类型实现的接口的数量。其次,方法表包含一个指向其基类的方法表的指针、一个指向其模块的指针和一个指向其 EEClass 的指针(包含一个对方法表的反向引用)。第三,实际方法前面是该类型实现的接口方法表列表。这就是为什么在方法表中有一个指向方法列表的指针,它离方法表开始处有一个 40 字节的恒定偏移量。

9781430244585_Fig03-04.jpg

图 3-4 。Employee 方法表的详细视图,包括用于虚拟方法调用的接口列表和方法列表的内部指针

image 注意到达该类型方法的代码地址表所需的额外解引用步骤允许该表与方法表对象分开存储在不同的内存位置。例如,如果您检查系统的方法表。对象,您可能会发现它的方法代码地址存储在单独的位置。此外,有许多虚方法的类将有几个一级表指针,允许在派生类中部分重用方法表。

调用引用类型实例上的方法

显然,方法表可以用来调用任意对象实例上的方法。假设堆栈位置 EBP-64 包含一个 Employee 对象的地址,其方法表布局如上图所示。然后我们可以使用下面的指令序列调用工作虚拟方法:

mov ecx, dword ptr [ebp-64]
mov eax, dword ptr [ecx] ; the method table pointer
mov eax, dword ptr [eax+40] ; the pointer to the actual methods inside the method table
call dword ptr [eax+16] ; Work is the fifth slot (fourth if zero-based)

第一条指令将引用从堆栈复制到 ECX 寄存器,第二条指令解引用 ECX 寄存器以获得对象的方法表指针,第三条指令获取指向方法表(位于 40 字节的常量偏移量处)内的方法列表的内部指针,第四条指令解引用偏移量为 16 的内部方法表以获得工作方法的代码地址并调用它。为了理解为什么有必要使用方法表进行虚拟方法调度,我们需要考虑运行时绑定是如何工作的——也就是说,多态是如何通过虚拟方法实现的。

假设一个额外的类 Manager 将从 Employee 派生并覆盖它的 Work 虚方法,同时实现另一个接口:

public class Manager : Employee, ISerializable
{
  private List<Employee> _reports;
  public override void Work() ...
  //...implementation of ISerializable omitted for brevity
}

编译器可能需要向管理器发送一个调用。Work 方法,如下面的代码清单所示:

Employee employee = new Manager(...);
employee.Work();

在这种特殊情况下,编译器可能能够使用静态流分析推断出管理器。应该调用 Work 方法(这在当前的 C# 和 CLR 实现中不会发生)。然而,在一般情况下,当提供静态类型的雇员引用时,编译器需要将绑定推迟到运行时。事实上,绑定到正确方法的唯一方式是在运行时确定 employee 变量引用的对象的实际类型,并根据该类型信息调度虚拟方法。这正是方法表使 JIT 编译器能够做到的。

如图 3-5 所示,管理器类的方法表布局用不同的代码地址覆盖了工作槽,而方法分派序列保持不变。请注意,被覆盖的槽距方法表开头的偏移量是不同的,因为 Manager 类实现了一个额外的接口;然而,“指向方法的指针”字段仍然在相同的偏移量处,并且适应这种差异:

9781430244585_Fig03-05.jpg

图 3-5 。管理器方法表的方法表布局。这个方法表包含一个额外的接口 MT 槽,这使得“指向方法的指针”偏移量更大

mov ecx, dword ptr [ebp-64]
mov eax, dword ptr [ecx]
mov eax, dword ptr [ecx+40] ;this accommodates for the Work method having a different
call dword ptr [eax+16] ;absolute offset from the beginning of the MT

image 注意对象布局是 CLR 4.0 中的新特性,在该布局中,被覆盖的方法从方法表开始的偏移量不能保证在派生类中是相同的。在 CLR 4.0 之前,由类型实现的接口列表存储在方法表的末尾,在代码地址之后;这意味着物体的偏移量。Equals 地址(和其余的代码地址)在所有派生类中都是常量。反过来,这意味着虚拟方法分派序列只由三条指令组成,而不是四条(上面序列中的第三条指令是不必要的)。旧的文章和书籍可能仍然引用以前的调用序列和对象布局,作为内部 CLR 细节如何在没有任何通知的情况下在版本之间变化的额外演示。

分派非虚拟方法

我们也可以使用类似的分派序列来调用非虚拟方法。然而,对于非虚方法,不需要使用方法表进行方法分派:当 JIT 编译方法分派时,被调用方法的代码地址(或者至少是它的预 JIT 存根)是已知的。例如,如前所述,如果堆栈位置 EBP-64 包含雇员对象的地址,那么下面的指令序列将调用带参数 5 的 TakeVacation 方法:

mov edx, 5 ;parameter passing through register – custom calling convention
mov ecx, dword ptr [ebp-64] ;still required because ECX contains ‘thisby convention
call dword ptr [0x004a1260]

仍然需要将对象的地址加载到 ECX 寄存器中——所有实例方法都希望在 ECX 中接收隐含的这个参数。然而,不再需要解引用方法表指针并从方法表中获取地址。JIT 编译器在执行调用后仍然需要能够更新调用站点;这是通过对最初指向预 JIT 存根的内存位置(在本例中为 0x004a1260)执行间接调用来实现的,一旦编译了该方法,JIT 编译器就会对其进行更新。

不幸的是,上面的方法分派序列遇到了一个严重的问题。它允许对空对象引用的方法调用被成功调度,并且可能保持不被检测到,直到实例方法试图访问实例字段或虚拟方法,这将导致访问冲突。事实上,这是 C++实例方法调用的行为——下面的代码在大多数 C++环境中不会受到伤害,但肯定会让 C# 开发人员在椅子上不安地移动:

class Employee {
public: void Work() { } //empty non-virtual method
};
Employee* pEmployee = NULL;
pEmployee->Work(); //runs to completion

如果您检查 JIT 编译器调用非虚拟实例方法所使用的实际序列,它将包含一条附加指令:

mov edx, 5 ;parameter passing through register – custom calling convention
mov ecx, dword ptr [ebp-64] ;still required because ECX contains ‘thisby convention
cmp ecx, dword ptr [ecx]
call dword ptr [0x004a1260]

回想一下,CMP 指令从第一个操作数中减去第二个操作数,并根据操作结果设置 CPU 标志。上面的代码不使用存储在 CPU 标志中的比较结果,那么 CMP 指令如何帮助防止使用空对象引用调用方法呢?嗯,CMP 指令试图访问包含对象引用的 ECX 寄存器中的内存地址。如果对象引用为空,则此内存访问将因访问冲突而失败,因为在 Windows 进程中访问地址 0 总是非法的。CLR 将此访问冲突转换为在调用点引发的 NullReferenceException 这比在方法被调用后在方法内部发出空检查要好得多。此外,CMP 指令仅占用内存中的两个字节,并且具有能够检查除 null 之外的无效地址的优点。

image 注意调用虚方法时不需要类似的 CMP 指令;空检查是隐式的,因为标准的虚拟方法调用流访问方法表指针,这确保了对象指针是有效的。即使对于虚拟方法调用,您也不一定总能看到发出的 CMP 指令;在最近的 CLR 版本中,JIT 编译器足够聪明,可以避免多余的检查。例如,如果程序流刚刚从一个对象上的虚拟方法调用返回——它隐式地包含空检查——那么 JIT 编译器可能不会发出 CMP 指令。

我们如此关注调用虚方法与调用非虚方法的精确实现细节的原因,并不是额外的内存访问或额外的指令(可能需要,也可能不需要)。虚拟方法排除的主要优化是方法内联,这对现代高性能应用至关重要。内联 是一个相当简单的编译器技巧,它以代码大小换取速度,由此对小型或简单方法的方法调用被方法体取代。例如,在下面的代码中,将对 Add 方法的调用替换为在该方法内部执行的单个操作是非常合理的:

int Add(int a, int b)
{
  return a + b;
}
int c = Add(10, 12);
//assume that c is used later in the code

非优化调用序列将有将近 10 条指令:三条用于设置参数和分派方法,两条用于设置方法框架,一条用于将数字相加,两条用于拆除方法框架,一条用于从方法返回。优化后的调用序列将只有指令——你能猜到是哪一条吗?一个选项是 ADD 指令,但事实上,另一种称为常数折叠的优化可以用于在编译时计算加法运算的结果,并将常量值 22 赋给变量 c。

内联和非内联方法 调用之间的性能差异可能很大,特别是当方法像上面的方法一样简单时。例如,属性是内联的绝佳选择,编译器生成的自动属性更是如此,因为它们除了直接访问字段之外不包含任何逻辑。但是,虚方法会阻止内联,因为只有当编译器在编译时(对于 JIT 编译器,在 JIT 时)知道将要调用哪个方法时,才会发生内联。当要调用的方法在运行时由嵌入到对象中的类型信息确定时,没有办法为虚拟方法分派生成正确的内联代码。如果默认情况下所有的方法都是虚拟的,那么属性也应该是虚拟的,并且间接方法调度的累积成本(如果没有内联的话)将会是巨大的。

鉴于内联的重要性,您可能想知道 sealed 关键字对方法分派的影响。例如,如果 Manager 类将 Work 方法声明为 sealed,则对具有 Manager 静态类型的对象引用的工作调用可以作为非虚拟实例方法调用进行:

public class Manager : Employee
{
  public override sealed void Work() ...
}
Manager manager = ...; //could be an instance of Manager, could be a derived type
manager.Work(); //direct dispatch should be possible!

尽管如此,在撰写本文时,sealed 关键字对我们测试的所有 CLR 版本的方法调度都没有影响,尽管知道类或方法是密封的可以有效地消除对虚方法调度的需要。

调度静态和接口方法

为了完整起见,我们需要考虑另外两种类型的方法:静态方法和接口方法。分派静态方法相当容易:不需要加载对象引用,简单地调用方法(或其预 JIT 存根)就足够了。因为调用不通过方法表进行,所以 JIT 编译器使用与非虚拟实例方法相同的技巧:方法调度是通过一个特殊的内存位置间接进行的,该内存位置在方法被 JIT 编译后被更新。

然而,接口方法是完全不同的事情。分派接口方法似乎与分派虚拟实例方法没有什么不同。事实上,接口实现了一种让人想起经典虚方法的多态形式。不幸的是,不能保证跨几个类的特定接口的接口实现最终都在方法表中的相同位置。考虑下面的代码,其中有两个类实现了 IComparable 接口:

class Manager : Employee, IComparable {
  public override void Work() ...
  public void TakeVacation(int days) ...
  public static void SetCompanyPolicy(...) ...
  public int CompareTo(object other) ...
}
class BigNumber : IComparable {
  public long Part1, Part2;
  public int CompareTo(object other) ...
}

显然,这些类的方法表布局会非常不同,CompareTo 方法结束的槽号也会不同。复杂的对象层次结构和多个接口实现使得需要一个额外的分派步骤来识别接口方法在方法表中的位置变得很明显。

在以前的 CLR 版本中,该信息存储在一个全局(AppDomain 级)表中,该表由接口 ID 索引,在首次加载接口时生成。方法表有一个特殊的条目(在偏移量 12 处),指向全局接口表中的适当位置,全局接口表条目又指向方法表,指向其中存储接口方法指针的子表。这允许多步方法调度,如下所示:

mov ecx, dword ptr [ebp-64] ; object reference
mov eax, dword ptr [ecx] ; method table pointer
mov eax, dword ptr [eax+12] ; interface map pointer
mov eax, dword ptr [eax+48] ; compile time offset for this interface in the map
call dword ptr [eax] ; first method at EAX, second method at EAX+4, etc.

这看起来很复杂,也很昂贵!需要四次内存访问来获取接口实现的代码地址并将其分发,对于某些接口来说,这可能成本太高。这就是为什么您永远不会看到生产 JIT 编译器使用的上述序列,即使没有启用优化。JIT 使用了几个技巧来有效地内联接口方法,至少对于常见的情况是这样。

热路径分析 —当 JIT 检测到经常使用相同的接口实现时,它用优化的代码替换特定的调用点,甚至可能内联常用的接口实现:

mov ecx, dword ptr [ebp-64]
cmp dword ptr [ecx], 00385670 ; expected method table pointer
jne 00a188c0 ; cold path, shown below in pseudo-code
jmp 00a19548 ; hot path, could be inlined body here
cold path:
if (--wrongGuessesRemaining < 0) { ;starts at 100
  back patch the call site to the code discussed below
} else {
  standard interface dispatch as discussed above
}

频率分析 —当 JIT 检测到它选择的热路径对于特定的呼叫站点不再准确时(跨越一系列的几个调度),它用新的热路径替换以前的热路径猜测,并在每次猜测错误时继续在它们之间交替:

start: if (obj->MTP == expectedMTP) {
  direct jump to expected implementation
} else {
  expectedMTP = obj->MTP;
  goto start;
}

有关接口方法调度的更多细节,可以考虑阅读萨沙·戈尔德施泰因的文章“JIT 优化”(www.codeproject.com/Articles/25801/JIT-Optimizations)和万斯·莫里森的博客文章(blogs . msdn . com/b/vancem/archive/2006/03/13/550529 . aspx)。接口方法调度是一个移动的目标,也是优化的成熟场所;未来的 CLR 版本可能会引入这里没有讨论的进一步优化。

同步块和锁定关键字

嵌入在每个引用类型实例中的第二个头字段是对象头字(或同步块索引)。与方法表指针不同,该字段有多种用途,包括同步、GC 簿记、终结和哈希代码存储。该字段的几个位决定了在任何时刻存储在其中的确切信息。

使用对象头字最复杂的目的是使用 CLR 监控机制进行同步,通过 lock 关键字向 C# 公开。要点如下:几个线程可能试图进入由 lock 语句保护的代码区域,但是一次只有一个线程可以进入该区域,实现互斥:

class Counter
{
  private int _i;
  private object _syncObject = new object();
  public int Increment()
  {
   lock (_syncObject)
   {
     return ++_i; //only one thread at a time can execute this statement
   }
  }
}

然而,lock 关键字仅仅是使用监视器包装以下结构的语法糖。进入并监控。退出方法:

class Counter
{
  private int _i;
  private object _syncObject = new object();
  public int Increment()
  {
   bool acquired = false;
   try
   {
     Monitor.Enter(_syncObject, ref acquired);
     return ++_i;
   }
   finally
   {
     if (acquired) Monitor.Exit(_syncObject);
   }
  }
}

为了确保这种互斥,同步机制可以与每个对象相关联。因为一开始就为每个对象创建一个同步机制是很昂贵的,所以当对象第一次用于同步时,这种关联是延迟发生的。当需要时,CLR 从名为同步块表的全局数组中分配一个名为同步块的结构。sync 块包含一个对其所属对象的向后引用(尽管这是一个不阻止对象被收集的弱引用),以及一个名为 monitor 的同步机制,它是使用 Win32 事件在内部实现的。分配的同步块的数字索引存储在对象的头字中。随后使用该对象进行同步的尝试会识别现有的同步块索引,并使用相关的监视器对象进行同步。

9781430244585_Fig03-06.jpg

图 3-6。与对象实例相关联的同步块。同步块索引字段仅将索引存储到同步块表中,允许 CLR 在不修改同步块索引的情况下在内存中调整表的大小和移动表

在同步块长时间未使用后,垃圾收集器会回收它,并将其所属的对象与其分离,从而将同步块索引设置为无效索引。在这种回收之后,同步块可以与另一个对象相关联,这节省了同步机制所需的昂贵的操作系统资源。

那个!SyncBlk SOS 命令可用于检查当前竞争的同步块,即由一个线程拥有并由另一个线程(可能不止一个等待者)等待的同步块。从 CLR 2.0 开始,有一种优化可以延迟创建一个同步块,仅当存在争用时。当没有同步块时,CLR 可以使用一个瘦锁来管理同步状态。下面我们探索一些这方面的例子。

首先,让我们来看看一个对象的对象头字,这个对象还没有被用于同步,但是它的哈希代码已经被访问过了(本章后面我们将讨论引用类型中的哈希代码存储)。在下面的例子中,EAX 指向一个雇员对象,它的散列码是 46104728:

0:000> dd eax-4 L2
023d438c 0ebf8098 002a3860
0:000> ? 0n46104728
Evaluate expression: 46104728 = 02bf8098
0:000> .formats 0e**bf8098**
Evaluate expression:
  Hex: 0e**bf8098**
  Binary: 00001110 10111111 10000000 10011000
0:000> .formats 02**bf8098**
Evaluate expression:
  Hex: 02**bf8098**
  Binary: 00000010 10111111 10000000 10011000

这里没有同步块索引;只有哈希码和两个设置为 1 的位,其中一个可能表示对象头字现在存储哈希码。接下来,我们发布一个监视器。从一个线程输入对对象的调用以锁定它,并检查对象头字:

0:004> dd 02444390-4 L2
0244438c 08000001 00173868
0:000> .formats 08000001
Evaluate expression:
  Hex: 08000001
  Binary: 00001000 00000000 00000000 00000001
0:004> !syncblk
Index	SyncBlock	MonitorHeld	Recursion	Owning	Thread	Info	SyncBlock	Owner
   1	0097db4c 3 1	0092c698 1790 0	02444390	Employee

对象被分配了同步块#1,这从!SyncBlk 命令输出(有关命令输出中的列的更多信息,请参考 SOS 文档)。当另一个线程试图用同一个对象输入 lock 语句时,它会进入一个标准的 Win32 等待(尽管如果它是一个 GUI 线程,则会有消息泵送)。下面是等待监视器的线程堆栈的底部:

0:004> kb
ChildEBP RetAddr Args to Child
04c0f404 75120bdd 00000001 04c0f454 00000001 ntdll!NtWaitForMultipleObjects+0x15
04c0f4a0 76c61a2c 04c0f454 04c0f4c8 00000000 KERNELBASE!WaitForMultipleObjectsEx+0x100
04c0f4e8 670f5579 00000001 7efde000 00000000 KERNEL32!WaitForMultipleObjectsExImplementation+0xe0
04c0f538 670f52b3 00000000 ffffffff 00000001 clr!WaitForMultipleObjectsEx_SO_TOLERANT+0x3c
04c0f5cc 670f53a5 00000001 0097db60 00000000 clr!Thread::DoAppropriateWaitWorker+0x22f
04c0f638 670f544b 00000001 0097db60 00000000 clr!Thread::DoAppropriateWait+0x65
04c0f684 66f5c28a ffffffff 00000001 00000000 clr!CLREventBase::WaitEx+0x128
04c0f698 670fd055 ffffffff 00000001 00000000 clr!CLREventBase::Wait+0x1a
04c0f724 670fd154 00939428 ffffffff f2e05698 clr!AwareLock::EnterEpilogHelper+0xac
04c0f764 670fd24f 00939428 00939428 00050172 clr!AwareLock::EnterEpilog+0x48
04c0f77c 670fce93 f2e05674 04c0f8b4 0097db4c clr!AwareLock::Enter+0x4a
04c0f7ec 670fd580 ffffffff f2e05968 04c0f8b4 clr!AwareLock::Contention+0x221
04c0f894 002e0259 02444390 00000000 00000000 clr!JITutil_MonReliableContention+0x8a

使用的同步对象是 25c,它是一个事件的句柄:

0:004> dd 04c0f454 L1
04c0f454 0000025c
0:004> !handle 25c f
Handle 25c
 Type Event
 Attributes 0
 GrantedAccess	0x1f0003:
   Delete,ReadControl,WriteDac,WriteOwner,Synch
   QueryState,ModifyState
 HandleCount 2
 PointerCount 4
 Name <none>
 Object Specific Information
   Event Type Auto Reset
   Event is Waiting

最后,如果我们检查分配给该对象的原始同步块内存,哈希代码和同步机制句柄清晰可见:

0:004> dd 0097db4c
0097db4c 00000003 00000001 0092c698 00000001
0097db5c 80000001 0000025c 0000000d 00000000
0097db6c 00000000 00000000 00000000 02bf8098
0097db7c 00000000 00000003 00000000 00000001

值得一提的最后一个微妙之处是,在前面的例子中,我们通过在锁定对象之前调用 GetHashCode 来强制创建同步块。从 CLR 2.0 开始,有一个特殊的优化旨在节省时间和内存,如果对象以前没有与同步块关联,则不会创建同步块。相反,CLR 使用一种叫做瘦锁 的机制。当对象第一次被锁定并且还没有争用时(即,没有其他线程试图锁定该对象),CLR 在对象头字中存储该对象的当前拥有线程的托管线程 ID。例如,下面是应用主线程在发生锁争用之前锁定的对象的对象头字:

0:004> dd 02384390-4
0238438c 00000001 00423870 00000000 00000000

这里,托管线程 ID 为 1 的线程是应用的主线程,这从!线程命令:

0:004> !Threads
ThreadCount: 2
UnstartedThread: 0
BackgroundThread: 1
PendingThread: 0
DeadThread: 0
Hosted Runtime: no
   Lock
   ID OSID ThreadOBJ State GC Mode GC Alloc Context Domain Count Apt Exception
  0 1 12f0 0033ce80 2a020 Preemptive 02385114:00000000 00334850 2 MTA
  2 2 23bc 00348eb8 2b220 Preemptive 00000000:00000000 00334850 0 MTA (Finalizer)

瘦锁也被 SOS 举报了!DumpObj 命令,它指示一个对象的所有者线程,该对象的头包含一个瘦锁。同样的。DumpHeap -thinlock 命令可以输出托管堆中当前存在的所有瘦锁:

0:004> !dumpheap -thinlock
 Address MT Size
02384390 00423870 12 ThinLock owner 1 (0033ce80) Recursive 0
02384758 5b70f98c 16 ThinLock owner 1 (0033ce80) Recursive 0
Found 2 objects.
0:004> !DumpObj 02384390
Name: Employee
MethodTable: 00423870
EEClass: 004213d4
Size: 12(0xc) bytes
File: D:\Development\...\App.exe
Fields:
   MT Field Offset Type VT Attr Value Name
00423970 4000001 4 CompanyPolicy 0 static 00000000 _policy
ThinLock owner 1 (0033ce80), Recursive 0

当另一个线程试图锁定对象时,它将旋转一小段时间,等待瘦锁被释放(即,所有者线程信息从对象头字中消失)。如果在某个时间阈值之后,锁没有被释放,则它被转换为同步块,同步块索引被存储在对象头字中,并且从那时起,线程照常在 Win32 同步机制上阻塞。

值类型内部

既然我们已经了解了引用类型在内存中的布局以及对象头字段的用途,那么是时候讨论值类型了。值类型有一个简单得多的内存布局,但是它引入了限制和装箱,这是一个昂贵的过程,用来补偿在需要引用的地方使用值类型的不兼容性。正如我们所看到的,使用值类型的主要原因是它们出色的内存密度和没有开销;当您开发自己的值类型时,每一点性能都很重要。

出于讨论的目的,让我们使用本章开始时讨论过的简单值类型 Point2D,它表示二维空间中的一个点:

public struct Point2D
{
  public int X;
  public int Y;
}

用 X=5,Y=7 初始化的 Point2D 实例的内存布局简单如下,没有额外的“开销”字段混乱:

9781430244585_Fig03-07.jpg

图 3-7。Point2D 值类型实例的内存布局

在一些罕见的情况下,可能需要自定义值类型布局——一个例子是为了实现互操作性,当你的值类型实例被原封不动地传递给非托管代码时。通过使用两个属性 StructLayout 和 FieldOffset,可以实现这种自定义。StructLayout 属性可用于指定根据类型的定义(这是默认设置)或根据 FieldOffset 属性提供的指令来顺序布局对象的字段。这允许创建 C 风格的联合,其中字段可能重叠。一个简单的例子是下面的值类型,它可以将浮点数“转换”为其表示形式所使用的四个字节:

[StructLayout(LayoutKind.Explicit)]
public struct FloatingPointExplorer
{
  [FieldOffset(0)] public float F;
  [FieldOffset(0)] public byte B1;
  [FieldOffset(1)] public byte B2;
  [FieldOffset(2)] public byte B3;
  [FieldOffset(3)] public byte B4;
}

当您将浮点值赋给对象的 F 字段时,它会同时修改 B1-B4 的值,反之亦然。实际上,F 场和 B1-B4 场在内存中重叠,如图 3-8 所示:

9781430244585_Fig03-08.jpg

图 3-8 。FloatingPointExplorer 实例的内存布局。水平对齐的块在内存中重叠

因为值类型实例没有对象头字和方法表指针,所以它们不能像引用类型那样提供丰富的语义。我们现在将看看它们的简单布局带来的限制,以及当开发人员试图在用于引用类型的设置中使用值类型时会发生什么。

值类型限制

首先,考虑对象头字。如果一个程序试图使用值类型实例进行同步,这通常是程序中的一个错误(我们将很快看到),但是运行时应该使它非法并抛出一个异常吗?在下面的代码示例中,当同一个 Counter 类实例的 Increment 方法由两个不同的线程执行时,会发生什么情况?

class Counter
{
  private int _i;
  public int Increment()
  {
   lock (_i)
   {
   return ++_i;
   }
  }
}

当我们试图验证发生了什么时,我们遇到了一个意想不到的障碍:C# 编译器不允许使用带有 lock 关键字的值类型。但是,我们现在已经熟悉了 lock 关键字的内部工作原理,可以尝试编写一个解决方法:

class Counter
{
  private int _i;
  public int Increment()
  {
   bool acquired=false;
   try
   {
     Monitor.Enter(_i, ref acquired);
     return ++_i;
   }
   finally
   {
     if (acquired) Monitor.Exit(_i);
   }
 }
}

通过这样做,我们在程序中引入了一个 bug 结果是多个线程将能够同时进入锁并修改 _i,此外还有监视器。Exit 调用将抛出一个异常(要了解同步访问整数变量的正确方法,请参考第六章)。问题是监视器。输入方法接受系统。对象参数,它是一个引用,我们通过值传递给它一个值类型。即使可以在需要引用的地方不加修改地传递该值,也要将该值传递给监视器。输入方法没有与传递给监视器的值相同的标识。退出方式;同样,传递给监视器的值。一个线程上的 Enter 方法没有与传递给监视器的值相同的标识。在另一个线程上输入方法。如果我们传递值(按值传递!)在需要引用的地方,无法获得正确的锁定语义。

值类型语义不适合对象引用的另一个例子出现在从方法返回值类型时。考虑以下代码:

object GetInt()
{
  int i = 42;
  return i;
}
object obj = GetInt();

GetInt 方法返回一个值类型——通常由值返回。但是,调用方期望从方法返回一个对象引用。该方法可以返回一个指向堆栈位置的直接指针,在该方法执行期间,I 存储在该堆栈位置。不幸的是,这将是对无效内存位置的引用,因为该方法的堆栈帧在返回之前已被清除。这表明值类型默认情况下具有的按值复制语义不太适合需要对象引用(到托管堆中)的情况。

值类型上的虚拟方法

我们还没有考虑方法表指针,当试图将值类型作为一等公民对待时,我们已经有了不可克服的问题。现在我们转向虚方法和接口实现。CLR 禁止值类型之间的继承关系,这使得不可能在值类型上定义新的虚方法。这是幸运的,因为如果有可能在值类型上定义虚方法,调用这些方法将需要一个方法表指针,该指针不是值类型实例的一部分。这不是一个实质性的限制,因为引用类型的按值复制语义使它们不适合需要对象引用的多态。

然而,值类型配备了从 System.Object 继承的虚方法,有几种:Equals、GetHashCode、ToString 和 Finalize。这里我们将只讨论前两个,但是大部分讨论也适用于其他虚拟方法。让我们从检查他们的签名开始:

public class Object
{
  public virtual bool Equals(object obj) ...
  public virtual int GetHashCode() ...
}

这些虚方法由每个。NET 类型,包括值类型。这意味着给定一个值类型的实例,我们应该能够成功地分派虚拟方法,即使它没有方法表指针!第三个例子说明了值类型内存布局如何影响我们对值类型实例进行简单操作的能力,这需要一种机制,能够将值类型实例“转化”为更能代表“真实”对象的东西。

拳击

每当语言编译器检测到需要将值类型实例视为引用类型的情况时,它都会发出 box IL 指令。反过来,JIT 编译器解释这个指令,并发出对分配堆存储的方法的调用,将值类型实例的内容复制到堆中,并用对象头-对象头字和方法表指针包装值类型内容。每当需要对象引用时,就使用这个“盒子”。请注意,该框是从原始值类型实例中分离出来的,对其中一个实例所做的更改不会影响另一个实例。

9781430244585_Fig03-09.jpg

图 3-9。堆上的原始值和装箱副本。装箱的副本有标准的引用类型“开销”(对象头字和方法表指针),可能需要进一步的堆对齐

.method private hidebysig static object GetInt() cil managed
{
   .maxstack 8
   L_0000: ldc.i4.s 0x2a
   L_0002: box int32
   L_0007: ret
}

装箱是一项开销很大的操作——它涉及到内存分配、内存复制,并且随后当垃圾收集器努力回收临时盒时会给它造成压力。随着 CLR 2.0 中泛型的引入,几乎没有必要将反射和其他晦涩的场景打包。尽管如此,装箱在许多应用中仍然是一个严重的性能问题;正如我们将看到的,如果不进一步了解值类型上的方法分派是如何操作的,那么“正确处理值类型”以防止各种类型的装箱是很重要的。

抛开性能问题不谈,装箱为我们之前遇到的一些问题提供了一种补救方法。例如,GetInt 方法返回对堆上包含值 42 的 box 的引用。只要有对它的引用,这个盒子就会存在,并且不受方法堆栈上局部变量的生存期的影响。同样的,当班长。Enter 方法需要一个对象引用,它在运行时接收对堆上一个框的引用,并使用该框进行同步。不幸的是,在代码的不同点从相同值类型实例创建的盒子被认为是不相同的,所以盒子被传递给 Monitor。Exit 不是传递给 Monitor 的同一个框。回车,框传递给监视器。一个线程上的 Enter 不是传递给 Monitor 的同一个框。进入另一个线程。这意味着,任何基于监视器的同步的值类型的使用本质上都是错误的,不管装箱提供的部分解决方案如何。

问题的关键仍然是从 System.Object 继承的虚方法。直接反对;相反,它们派生自一个名为 System.ValueType 的中间类型。

image 混淆地,系统地。ValueType 是引用类型–CLR 根据以下标准区分值类型和引用类型:值类型是从 System.ValueType 派生的类型。ValueType 是一种引用类型。

系统。ValueType 重写从 System 继承的 Equals 和 GetHashCode 虚方法。对象,这样做有一个很好的理由:值类型与引用类型具有不同的默认相等语义,这些默认值必须在某个地方实现。例如,系统中被重写的 Equals 方法。ValueType 确保值类型基于它们的内容进行比较,而系统中的原始 Equals 方法。对象只比较对象引用(标识)。

不管系统如何。ValueType 实现这些重写的虚方法,请考虑以下场景。您在列表中嵌入了一千万个 Point2D 对象,然后使用 Contains 方法在列表中查找单个 Point2D 对象。反过来,Contains 没有更好的选择,只能对一千万个对象进行线性搜索,并将它们分别与您提供的对象进行比较。

List<Point2D> polygon = new List<Point2D>();
//insert ten million points into the list
Point2D point = new Point2D { X = 5, Y = 7 };
bool contains = polygon.Contains(point);

遍历一个包含一千万个点的列表并逐个与另一个点进行比较需要一段时间,但这是一个相对较快的操作。访问的字节数大约是 80,000,000(每个 Point2D 对象 8 个字节),比较操作非常快。遗憾的是,比较两个 Point2D 对象需要调用 Equals 虚拟方法:

Point2D a = ..., b = ...;
a.Equals(b);

这里有两个关键问题。首先,等于——即使被系统覆盖。value type–接受系统。对象引用作为其参数。正如我们已经看到的,将 Point2D 对象作为对象引用需要装箱,所以 b 必须装箱。此外,调度 Equals 方法调用需要装箱 a 来获取方法表指针!

image 注意JIT 编译器有一个短路行为,可能允许对 Equals 的直接方法调用,因为值类型是密封的,虚拟分派目标在编译时由 Point2D 是否覆盖 Equals 来确定(这是由受约束的 IL 前缀启用的)。尽管如此,因为制度。ValueType 是一个引用类型,Equals 方法也可以自由地将其 this 隐式参数视为引用类型,而我们使用值类型实例(Point2D a)来调用 Equals——这需要装箱。

总而言之,对于 Point2D 实例上的每个 Equals 调用,我们有两个装箱操作。对于上面代码执行的 10,000,000 个 Equals 调用,我们有 20,000,000 个装箱操作,每个操作分配(在 32 位系统上)16 个字节,总共有 320,000,000 个字节的分配和 160,000,000 个字节的内存被复制到堆中。这些分配的成本远远超过了实际比较二维空间中的点所需的时间。

避免使用 Equals 方法对值类型进行装箱

我们能做些什么来完全摆脱这些拳击操作?一种想法是覆盖 Equals 方法,并提供适合我们值类型的实现:

public struct Point2D
{
  public int X;
  public int Y;
  public override bool Equals(object obj)
  {
   if (!(obj is Point2D)) return false;
   Point2D other = (Point2D)obj;
   return X == other.X && Y == other.Y;
  }
}

使用前面讨论的 JIT 编译器的短路行为,a.Equals(b)仍然需要对 b 进行装箱,因为该方法接受一个对象引用,但不再需要对 a 进行装箱。

public struct Point2D
{
  public int X;
  public int Y;
  public override bool Equals(object obj) ... //as before
  public bool Equals(Point2D other)
  {
   return X == other.X && Y == other.Y;
   }
}

每当编译器遇到 a.Equals(b)时,它肯定会选择第二个重载而不是第一个重载,因为它的参数类型更接近所提供的参数类型。当我们这样做的时候,还有更多的重载方法——我们经常使用==和!来比较对象。=运算符:

public struct Point2D
{
  public int X;
  public int Y;
  public override bool Equals(object obj) ... // as before
  public bool Equals(Point2D other) ... //as before
  public static bool operator==(Point2D a, Point2D b)
  {
   return a.Equals(b);
  }
  public static bool operator!= (Point2D a, Point2D b)
  {
   return !(a == b);
  }
}

这就差不多够了。有一种边缘情况与 CLR 实现泛型的方式有关,当 List 在两个 Point2D 实例上调用 Equals 时,仍然会导致装箱,其中 Point2D 作为其泛型类型参数(T)的实现。我们将在第五章中讨论具体细节;现在可以说,Point2D 需要实现 IEquatable < Point2D >,这允许 List < T >和 EqualityComparer < T >中的聪明行为通过接口将方法调用分派给重载的 Equals 方法(代价是对 EqualityComparer < T >的虚拟方法调用)。等于抽象方法)。结果是在 10,000,000 个 Point2D 实例的列表中搜索特定实例时,执行时间提高了 10 倍,并且完全消除了所有内存分配(由装箱引入)!

public struct Point2D : IEquatable<Point2D>
{
  public int X;
  public int Y;
  public bool Equals(Point2D other) ... //as before
}

这是思考值类型接口实现主题的好时机。正如我们已经看到的,典型的接口方法分派需要对象的方法表指针,这将请求涉及值类型的装箱。事实上,从值类型实例到接口类型变量的转换需要装箱,因为接口引用可以被视为所有意图和目的的对象引用:

Point2D point = ...;
IEquatable<Point2D> equatable = point; //boxing occurs here

然而,当通过静态类型的值类型变量进行接口调用时,不会发生装箱(这与上面讨论的由受约束的 IL 前缀启用的短路相同):

Point2D point = ..., anotherPoint = ...;
point.Equals(anotherPoint); //no boxing occurs here, Point2D.Equals(Point2D) is invoked

如果值类型是可变的,通过接口使用值类型会引发一个潜在的问题,就像我们在本章中讨论的 Point2D。与往常一样,修改盒装副本不会影响原件,这可能会导致意外行为:

Point2D point = new Point2D { X = 5, Y = 7 };
Point2D anotherPoint = new Point2D { X = 6, Y = 7 };
IEquatable<Point2D> equatable = point; //boxing occurs here
equatable.Equals(anotherPoint); //returns false
point.X = 6;
point.Equals(anotherPoint); //returns true
equatable.Equals(anotherPoint); //returns false, the box was not modified!

这是让值类型不可变,并且只允许通过制作更多副本来修改的常见建议的一个理由。(考虑系统。DateTime API 是一个设计良好的不可变值类型的例子。)

ValueType 棺材上的最后一颗钉子。Equals 是它的实际实现。根据内容比较两个任意值类型实例并不简单。反汇编方法提供了下面的图片(为简洁起见略加编辑):

public override bool Equals(object obj)
{
  if (obj == null) return false;
  RuntimeType type = (RuntimeType) base.GetType();
  RuntimeType type2 = (RuntimeType) obj.GetType();
  if (type2 != type) return false;
  object a = this;
  if (CanCompareBits(this))
  {
   return FastEqualsCheck(a, obj);
  }
  FieldInfo[] fields = type.GetFields(BindingFlags.NonPublic | ← BindingFlags.Public | BindingFlags.Instance);
  for (int i = 0; i < fields.Length; i++)
  {
   object obj3 = ((RtFieldInfo) fields[i]).InternalGetValue(a, false);
   object obj4 = ((RtFieldInfo) fields[i]).InternalGetValue(obj, false);
   if (obj3 == null && obj4 != null)
   return false;
   else if (!obj3.Equals(obj4))
   return false;
  }
  return true;
}

简而言之,如果 CanCompareBits 返回 true,FastEqualsCheck 负责检查相等性;否则,该方法进入一个基于反射的循环,其中使用 FieldInfo 类提取字段,并通过调用 Equals 进行递归比较。不用说,基于反射的循环是性能之争完全让步的地方;反射是一种极其昂贵的机制,其他一切都相形见绌。CanCompareBits 和 FastEqualsCheck 的定义被推迟到 CLR——它们是“内部调用”,没有在 IL 中实现——所以我们不能轻易反汇编它们。然而,从实验中我们发现,如果以下任一条件成立,CanCompareBits 将返回 true:

  1. 值类型只包含基元类型,不重写等于
  2. 值类型只包含(1)成立且不重写等于的值类型
  3. 值类型只包含(2)成立且不重写等于的值类型

FastEqualsCheck 方法同样是一个谜,但是它有效地执行了 memcmp 操作——比较两个值类型实例的内存(逐字节)。不幸的是,这两种方法仍然是内部实现细节,依赖它们作为比较值类型实例的高性能方法是一个非常糟糕的想法。

GetHashCode 方法

最后一个重要的方法是 GetHashCode。在展示合适的实现之前,让我们先复习一下它的用途。哈希代码最常与哈希表结合使用,哈希表是一种(在特定条件下)允许对任意数据进行常数时间( O (1))插入、查找和删除操作的数据结构。中常见的哈希表类。NET 框架包括字典< TKey,TValue >,Hashtable,HashSet < T >。典型的哈希表实现由动态长度的桶数组组成,每个桶包含一个项目链表。为了将一个条目放入哈希表,它首先计算一个数值(通过使用 GetHashCode 方法),然后对其应用一个哈希函数,指定条目映射到哪个桶。该项被插入到其存储桶的链表中。

9781430244585_Fig03-10.jpg

图 3-10。一种哈希表,由存储项目的链表(桶)数组组成。一些桶可能是空的;其他存储桶可能包含相当多的项目

哈希表的性能保证在很大程度上依赖于哈希表实现使用的哈希函数,但也需要 GetHashCode 方法的几个属性:

  1. 如果两个对象相等,则它们的哈希代码相等。
  2. 如果两个对象不相等,则它们的哈希代码不太可能相等。
  3. GetHashCode 应该很快(虽然通常在对象大小上是线性的)。
  4. 对象的哈希代码不应改变。

image 注意属性(2)不能陈述“如果两个对象不相等,则它们的散列码不相等”,因为鸽笼原理:可能存在对象比整数多得多的类型,因此不可避免地会有许多对象具有相同的散列码。例如,考虑 longs 有 2 个 64 个不同的长,但是只有 2 个 32 个不同的整数,所以至少会有一个整数值是 2 个 32 个不同长的散列码!

形式上,属性(2)可以表述如下以要求散列码的均匀分布:设置一个对象 A ,设 S(A) 为所有对象 B 的集合,使得:

  • B 不等于A;
  • B 的哈希码等于 A 的哈希码。

性质(2)要求对于每个对象 A ,S(A) 的大小大致相同。(这假设看到每个对象的概率是相同的——这对于实际类型来说不一定是正确的。)

属性(1)和(2)强调对象相等和哈希代码相等之间的关系。如果我们费力地重写和重载虚拟 Equals 方法,那么除了确保 GetHashCode 实现也与它一致之外,别无选择。似乎 GetHashCode 的典型实现会在某种程度上依赖于对象的字段。例如,对于 int,GetHashCode 的一个很好的实现就是简单地返回整数值。对于 Point2D 对象,我们可能会考虑两个坐标的某种线性组合,或者将第一个坐标的一些位与第二个坐标的一些其他位组合起来。一般来说,设计一个好的哈希代码是一件非常困难的事情,这超出了本书的范围。

最后,考虑属性(4)。背后的推理是这样的:假设你有 point (5,5)并把它嵌入到哈希表中,进一步假设它的哈希码是 10。如果您将该点修改为(6,6)——并且它的散列码也被修改为 12——那么您将无法在散列表中找到您插入到其中的点。但是这与值类型无关,因为您不能修改您插入哈希表的对象——哈希表存储了它的一个副本,您的代码无法访问它。

参考类型是什么?对于引用类型,基于内容的相等成为一个问题。假设我们有以下 Employee 的实现。GetHashCode:

public class Employee
{
  public string Name { get; set; }
  public override int GetHashCode()
  {
   return Name.GetHashCode();
  }
}

这似乎是个好主意;哈希代码基于对象的内容,我们使用的是字符串。GetHashCode 这样我们就不用为字符串实现一个好的哈希代码函数了。但是,考虑一下当我们使用一个 Employee 对象并在它被插入哈希表后更改其名称时会发生什么:

HashSet<Employee> employees = new HashSet<Employee>();
Employee kate = new Employee { Name = “Kate Jones” };
employees.Add(kate);
kate.Name = “Kate Jones-Smith”;
employees.Contains(kate); //returns false!

该对象的哈希代码已更改,因为其内容已更改,我们无法再在哈希表中找到该对象。这可能有点出乎意料,但问题是现在我们根本不能从哈希表中删除 Kate,即使我们可以访问原始对象!

CLR 为依赖对象的标识作为相等条件的引用类型提供了默认的 GetHashCode 实现。如果两个对象引用相等当且仅当它们引用同一个对象时,将哈希代码存储在对象本身的某个地方是有意义的,这样它就永远不会被修改并且容易被访问。事实上,当创建引用类型实例时,CLR 可以将其哈希代码嵌入到对象 heard word 中(作为一种优化,只有在第一次访问哈希代码时才会这样做;毕竟很多对象从来不用做哈希表键)。要计算散列码,不需要依赖随机数的生成,也不需要考虑对象的内容;一个简单的柜台就可以了。

image 哈希码怎么能和同步块索引一起共存在对象头字里?正如您所记得的,大多数对象从不使用它们的对象头字来存储同步块索引,因为它们不用于同步。在极少数情况下,对象通过存储其索引的对象头字链接到同步块,散列码被复制到同步块并存储在那里,直到同步块与对象分离。为了确定散列码或同步块索引当前是否存储在对象头字中,该字段的一个位被用作标记。

使用默认 Equals 和 GetHashCode 实现的引用类型不需要关心上面强调的四个属性中的任何一个——它们是免费获得的。但是,如果你的引用类型应该选择覆盖默认的相等行为(这是什么系统。String 是这样的),那么如果您将引用类型用作哈希表中的键,您应该考虑使其不可变。

使用值类型的最佳实践

下面是一些最佳实践,当您考虑为某个任务使用值类型时,这些实践应该会引导您朝着正确的方向前进:

  • 如果您的对象很小,并且您打算创建大量的对象,请使用值类型。
  • 如果需要高密度的内存集合,请使用值类型。
  • 重载等于,重载等于,实现 IEquatable ,重载运算符==,重载运算符!=在值类型上。
  • 在值类型上重写 GetHashCode。
  • 考虑让你的值类型不可变。

摘要

在这一章中,我们已经揭示了引用类型和值类型的实现细节,以及这些细节如何影响应用的性能。值类型表现出极高的内存密度,这使它们成为大型集合的理想选择,但不具备对象所需的特性,如多态、同步支持和引用语义。CLR 引入了两类类型,以便在需要时提供面向对象的高性能替代方案,但仍然需要开发人员付出巨大努力来正确实现值类型。