-NET-性能高级教程-全-
.NET 性能高级教程(全)
零、简介
这本书已经成为,因为我们觉得没有权威的文本,涵盖所有这三个领域有关。网络应用性能:
- 确定性能指标,然后测量应用性能,以验证它是否满足或超过这些指标。
- 在内存管理、网络、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 毫秒内返回对“普通”类别查询的响应。
注意这些例子假设“重要”页面类别和“常见”查询类别是由业务分析师或应用架构师定义的众所周知的术语。保证应用中每个角落的性能目标通常是不合理的,不值得在开发、硬件和运营成本上投资。
我们现在考虑一些典型应用的性能目标示例(见表 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 | - |
注意运行应用的硬件特性是环境约束的重要组成部分。例如,表 ** 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 利用率或流程级别的执行时间相比,特定方法中的执行时间可能是更具可操作性的性能指标。不幸的是,增加测量的粒度通常会导致性能开销,我们将在下一章通过讨论各种分析工具来说明这一点。
软件开发生命周期中的性能
您认为性能在软件开发生命周期中处于什么位置?这个天真的问题带来了一个包袱,那就是必须在现有的流程中改进 ?? 的性能。虽然这是可能的,但更健康的方法是将开发生命周期的每一步都视为更好地理解应用性能的机会:首先,性能目标和重要的度量标准;接下来,应用是否达到或超过其目标;最后,维护、用户负载和需求变更是否会引入任何回归。
- 在需求收集阶段,开始考虑您想要设定的性能目标。
- 在架构阶段,细化对应用重要的性能指标,并定义具体的性能目标。
- 在开发阶段,经常对原型代码或部分完成的特性执行探索性的性能测试,以验证您完全符合系统的性能目标。
- 在测试阶段,执行重要的负载测试和性能测试,以完全验证系统的性能目标。
- 在后续的开发和维护过程中,对每个版本执行额外的负载测试和性能测试(最好是每天或每周一次),以快速识别系统中引入的任何性能退化。
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)可以指出事务执行、过度锁定甚至死锁方面的问题。
图 2-1 。性能监视器主窗口,显示特定进程的三个计数器。图中最上面一行是进程\私有字节计数器,中间一行是。所有堆中的. NET CLR 内存# 字节,最下面的是。NET CLR 内存\每秒分配的字节数。从图中可以明显看出,应用在 GC 堆中出现了内存泄漏
用性能计数器监视内存使用情况
在这个简短的实验中,您将使用性能监视器和上面讨论的性能计数器来监视一个示例应用的内存使用情况,并确定它是否存在内存泄漏。
- 打开性能监视器—您可以通过搜索“性能监视器”在“开始”菜单中找到它,或者直接运行 perfmon.exe。
- 从本章的源代码文件夹中运行 MemoryLeak.exe 应用。
- 单击左侧树中的“性能监视器”节点,然后单击绿色的 + 按钮。
- 从。NET CLR 内存类别,选择所有堆中的# Bytes 和分配的字节/秒性能计数器,从实例列表中选择 MemoryLeak 实例,然后单击“添加> >”按钮。
- 从 Process 类别中选择 Private Bytes 性能计数器,从 instance 列表中选择 MemoryLeak 实例,然后单击“Add > >”按钮。
- 单击“确定”按钮确认您的选择并查看性能图。
- 您可能需要右键单击屏幕底部的计数器,并选择“缩放选定的计数器”来查看图表上的实际线条。
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.
提示在一个典型的 Windows 系统上,几乎有数千个性能计数器;没有一个绩效调查员会记得所有的问题。这就是“添加计数器”对话框底部的小“显示描述”复选框派上用场的地方——它可以告诉您系统\处理器队列长度代表系统处理器上等待执行的就绪线程的数量。NET CLR LocksAndThreads \ Contention Rate/sec 是线程尝试获取托管锁失败并必须等待该锁可用的次数(每秒)。
性能计数器日志和警报
配置性能计数器日志相当容易,您甚至可以向系统管理员提供一个 XML 模板来自动应用性能计数器日志,而不必指定单独的性能计数器。您可以在任何机器上打开生成的日志,并回放它们,就像它们代表实时数据一样。(您甚至可以使用一些内置的计数器集,而不是手动配置要记录的数据。)
您还可以使用性能监视器来配置性能计数器警报,当超过某个阈值时,该警报将执行任务。您可以使用性能计数器警报来创建一个基本的监视基础结构,当违反性能约束时,它可以向系统管理员发送电子邮件或消息。例如,您可以配置一个性能计数器警报,当它达到危险的内存使用量时,或者当整个系统用完磁盘空间时,它会自动重新启动您的进程。我们强烈建议您试用性能监视器,并熟悉它所提供的各种选项。
配置性能计数器日志
要配置性能计数器日志,请打开性能监视器并执行以下步骤。(我们假设您在 Windows 7 或 Windows Server 2008 R2 上使用性能监视器;在以前的操作系统版本中,性能监视器的用户界面略有不同,如果您正在使用这些版本,请查阅文档以获得详细说明。)
- 在左侧的树中,展开数据收集器集节点。
- 右键单击用户定义的节点,并从上下文菜单中选择新建数据收集器集。
- 命名数据收集器集,选择“手动创建(高级)”单选按钮,然后单击“下一步”。
- 确保选中“创建数据日志”单选按钮,选中“性能计数器”复选框,然后单击“下一步”。
- 使用“添加”按钮添加性能计数器(将打开标准的“添加计数器”对话框)。完成后,配置一个采样间隔(默认为每 15 秒对计数器进行一次采样),然后单击 Next。
- 提供一个目录,性能监视器将使用它来存储您的计数器日志,然后单击“下一步”。
- 选择“打开此数据收集器集的属性”单选按钮,然后单击“完成”。
- 使用各种选项卡进一步配置数据收集器集—您可以定义自动运行的计划、停止条件(例如,在收集超过一定数量的数据后)以及数据收集停止时运行的任务(例如,将结果上载到集中位置)。完成后,点按“好”。
- 单击用户定义的节点,右键单击主窗格中的数据收集器集,然后从上下文菜单中选择启动。
- 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.
当您完成数据收集并希望使用性能监视器检查数据时,执行以下步骤:
- 选择用户定义的节点。
- 右键单击数据收集器集,并从上下文菜单中选择最新报告。
- 在出现的窗口中,您可以在日志的计数器列表中添加或删除计数器,配置时间范围,并通过右键单击图表并从上下文菜单中选择属性来更改数据比例。
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));
}
正如我们所看到的,配置自定义性能计数器几乎不费吹灰之力,而且在执行性能调查时,它们可能是至关重要的。将系统性能计数器数据与自定义性能计数器相关联通常是性能调查人员查明性能或配置问题的确切原因所需的全部工作。
注意性能监视器可以用来收集与性能计数器无关的其他类型的信息。您可以使用它从系统中收集配置数据—注册表项的值、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
访问这些非常详细的信息需要一个 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 安装程序,并从安装选项屏幕中仅选择常用实用程序 Windows Performance Toolkit。Windows SDK 安装程序完成后,导航到 SDK 安装目录 的 Redist \ Windows Performance Toolkit 子目录,并运行适用于您系统架构的安装程序文件(32 位系统为 Xperf_x86.msi,64 位系统为 Xperf_x64.msi)。
注意在 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 系统或更高版本上进行。(它还要求您设置两个系统环境变量。为此,右键单击计算机,单击属性,单击“高级系统设置”,最后单击对话框底部的“环境变量”按钮。)
- 设置系统环境变量 _NT_SYMBOL_PATH 指向微软公共符号服务器和本地符号缓存,例如:SRV * C:\ Temp \ Symbols *
msdl.microsoft.com/download/symbols
- 将系统环境变量 _NT_SYMCACHE_PATH 设置为磁盘上的本地目录—这应该是与上一步中的本地符号缓存不同的目录。
- 打开管理员命令提示符窗口,导航到安装 WPT 的安装目录(例如 C:\ Program Files \ Windows Kits \ 8.0 \ Windows Performance Toolkit)。
- 从基本内核提供者组开始跟踪,该组包含 PROC_THREAD、LOADER、DISK_IO、HARD_FAULTS、PROFILE、MEMINFO 和 MEMINFO_WS 内核标志(参见表 2-1 )。为此,运行以下命令:xperf -on Base
- 启动一些系统活动:运行应用,在窗口之间切换,打开文件——至少几秒钟。(这些是将进入跟踪的事件。)
- 通过运行以下命令,停止跟踪并将跟踪刷新到日志文件中:xperf -d KernelTrace.etl
- 通过运行以下命令启动图形性能分析器:xperfview KernelTrace.etl
- 结果窗口包含几个图形,每个图形对应一个在跟踪过程中生成事件的 ETW 关键字。您可以选择在左侧显示的图表。通常,最上面的图形按处理器显示处理器利用率,随后的图形显示磁盘 I/O 操作计数、内存使用情况和其他统计信息。
- 选择处理器利用率图的一个部分,右键单击它,然后从上下文菜单中选择 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 。)
图 2-2 磁盘 I/O 操作在物理磁盘的地图上布局。I/O 操作之间的寻道和单个 I/O 细节通过工具提示提供
图 2-3 单个进程的详细堆栈帧(times napper . exe)。权重栏显示了(大致)在该帧中花费了多少 CPU 时间
图 2-4 CPU 活动(线条-每条线条表示不同的处理器)和磁盘 I/O 操作(列)的叠加图。I/O 活动和 CPU 活动之间没有明显的相关性
图 2-5 在报表中调用堆栈聚合。请注意,托管框架仅部分显示。!?无法解析帧。mscorlib.dll 框架(例如系统。DateTime.get_Now())被成功解析,因为它们是使用 NGen 预编译的,而不是在运行时由 JIT 编译器编译的
注意最新版本的 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 的功能。有关新工具的更多信息,请查阅位于 http://msdn.microsoft.com/en-us/library/hh162962.aspx 的 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 行之间经过的时间是在被监控的应用中完成一次垃圾收集所花费的时间。
图 2-6 CLR GC 相关事件的原始报告 。选中的行显示 GCAllocationTick_V1 事件,每次大约分配 100KB 内存时都会引发该事件
幸运的是,微软的基础类库(BCL)团队已经发现了这一缺陷,并提供了一个开源库和工具来分析 CLR ETW 跟踪,称为 PerfMonitor。我们接下来讨论这个工具。
性能监视器
PerfMonitor.exe 开源命令行工具已经由微软的 BCL 团队通过 CodePlex 网站发布。在撰写本文时,最新的版本是 PerfMonitor 1.5,可以从 http://bcl.codeplex.com/releases/view/49601 下载。与 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 等等。
图 2-7 来自 PerfMonitor 的自下而上报告 。“Exc %”列是该方法单独使用的 CPU 时间的估计值;“Inc %”列是该方法及其调用的所有其他方法(调用树中的子树)使用的 CPU 时间的估计值
图 2-8 来自性能监视器的自上而下报告
详细的 GC 分析报告 包含一个表格,其中包含每一代的垃圾收集统计信息(计数、次数),以及单个 GC 事件信息,包括暂停时间、回收的内存和许多其他信息。当我们在第四章中讨论垃圾收集器的内部工作方式和性能含义时,这些信息中的一些会非常有用。图 2-9 显示了几行单独的 GC 事件。
图 2-9 单个 GC 事件,包括回收的内存量、应用暂停时间、发生的收集类型以及其他详细信息
最后,详细的 JIT 分析报告 显示了 JIT 编译器为每个应用的方法所需的时间以及它们被编译的精确时间。这些信息有助于确定应用的启动性能是否可以提高——如果 JIT 编译器花费了过多的启动时间,预编译应用的二进制文件(使用 NGen)可能是一个值得的优化。我们将在第十章的中讨论 NGEN 和其他减少应用启动时间的策略。
提示从多个高性能 ETW 提供商那里收集信息会生成非常大的日志文件。例如,在默认收集模式下,PerfMonitor 通常每秒生成 5MB 以上的原始数据。让这样的痕迹持续几天可能会耗尽磁盘空间,即使是在大容量硬盘上。幸运的是,XPerf 和 PerfMonitor 都支持循环日志模式,在这种模式下,只保留最后的 N 兆字节的日志。在 PerfMonitor 中,/Circular 命令行开关采用最大日志文件大小(以兆字节为单位),并在超过阈值时自动丢弃最旧的日志。
虽然 PerfMonitor 是一个非常强大的工具,但它的原始 HTML 报告和丰富的命令行选项使它有点难以使用。我们将看到的下一个工具提供了与 PerfMonitor 非常相似的功能,并且可以在相同的场景中使用,但是它有一个更加用户友好的界面来收集和解释 ETW 信息,并且将使一些性能调查大大缩短。
性能视图工具
PerfView 是一个免费的微软工具,它将 PerfMonitor 中已经提供的 ETW 收集和分析功能与堆分析功能统一起来,我们将在后面结合 CLR Profiler 和 ANTS Memory Profiler 等工具讨论堆分析功能。你可以从微软下载中心下载 PerfView,地址是 http://www.microsoft.com/download/en/details.aspx?id=28567。请注意,您必须以管理员身份运行 PerfView,因为它需要访问 ETW 基础架构。
图 2-10??。 PerfView 的主界面。在文件视图(左侧)中,可以看到一个堆转储和一个 ETW 跟踪。主视图上的链接指向工具支持的各种命令
要分析来自特定进程的 ETW 信息,使用 PerfView 中的 Collect 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 队列保留(在第四章中讨论)。
图 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 秒。
注意还有一个性能监控和系统健康检测框架我们没有考虑: 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 采样探查器事件(部分列表)
使用 Visual Studio profiler 捕获样本非常便宜,如果样本事件间隔足够宽(默认为 10,000,000 个时钟周期),应用的执行开销可以少于 5%。此外,采样非常灵活,可以连接到一个正在运行的进程,收集一段时间的样本事件,然后从该进程断开以分析数据。由于这些特点,采样是开始 CPU 瓶颈性能调查的推荐方法,这种方法需要大量的 CPU 时间。
当采样会话完成时,探查器会提供摘要表,其中每个方法都与两个数字相关联:独占样本数,这是该方法当前在 CPU 上执行时获取的样本;包含样本数,这是该方法当前执行时或调用堆栈上任何其他位置获取的样本。具有许多独占样本的方法负责应用的 CPU 利用率;具有许多包含样本的方法不直接使用 CPU,而是调用其他使用 CPU 的方法。(例如,在单线程应用中,Main 方法拥有 100%的包含样本是有意义的。)**
**从 VISUAL STUDIO 运行采样分析器
运行采样分析器最简单的方法是从 Visual Studio 本身,尽管(我们将在后面看到)它也支持从简化的命令行环境中进行生产分析。我们建议您使用自己的一个应用来做这个实验。
- 在 Visual Studio 中,单击 Analyze 启动性能向导菜单项。
- 在向导的第一页上,确保选择了“CPU sampling”单选按钮,然后单击“Next”按钮。(在本章的后面,我们将讨论其他的分析模式;然后你可以重复这个实验。)
- 如果要分析的项目加载到当前解决方案中,请单击“一个或多个可用项目”单选按钮,并从列表中选择项目。否则,单击“可执行文件(。EXE 文件)”单选按钮。单击下一步按钮。
- 如果您选择了“可执行文件(。在上一个屏幕上,将 profiler 指向您的可执行文件,并在必要时提供任何命令行参数,然后单击 Next 按钮。(如果您手头没有自己的应用,请随意使用本章源代码文件夹中的 JackCompiler.exe 示例应用。)
- 选中“向导完成后启动性能分析”复选框,然后单击“完成”按钮。
- 如果您不是以管理员身份运行 Visual Studio,将提示您升级探查器的凭据。
- 当您的应用完成执行时,将会打开一个分析报告。使用顶部的“当前视图”组合框在不同视图之间导航,显示应用代码中收集的样本。
For more experimentation, after the profiling session completes make sure to check out the Performance Explorer tool window (Analyze Windows 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 )。
图 2-12??。 Profiler 报告, 概要视图——负责大部分样本的调用路径和样本独占最多的函数
图 2-13??。功能视图 ,显示样本最多的功能。系统。String.Concat 函数负责的样本是其他函数的两倍
图 2-14??。函数详细视图,显示 JackCompiler 调用的函数。CompilationOutputTextWriter . WriteLine 函数。在该函数的代码中,根据累积的包含样本的百分比来突出显示各行
注意看起来采样是一种测量 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 显示了函数视图,在该视图中,以毫秒为单位测量的包含时间和不包含时间是可用的,以及函数被调用的次数。
图 2-15??。功能视图:系统。随着我们的注意力转移到 JackCompiler 上,String.Concat 似乎不再是性能瓶颈。Tokenizer.NextChar 和 JackCompiler。代币..克特。第一个方法被调用了将近一百万次
提示用于生成图 2-12 和图 2-15 的示例应用并不完全受限于 CPU 事实上,它的大部分时间都花在了阻止 I/O 操作完成上。这解释了指向系统的采样结果之间的差异。String.Concat 作为 CPU hog,以及指向 JackCompiler 的插装结果。Tokenizer.NextChar 成为整体的性能瓶颈。
尽管插装看起来是更准确的方法,但在实践中,如果应用的大部分代码都是 CPU 受限的,那么您应该尽量坚持采样。检测限制了灵活性,因为您必须在启动应用之前检测它的代码,并且不能将探查器附加到已经运行的进程。此外,插装有一个不可忽略的开销——它显著增加了代码的大小,并增加了运行时开销,因为每当程序进入或退出一个方法时都会收集探测。(一些检测分析器提供了行检测模式,其中每一行都被检测探针包围;这些更慢!)
和往常一样,最大的风险是过于信任检测分析器的结果。有理由假设对特定方法的调用次数不会因为应用使用检测而改变,但是由于探查器的开销,所收集的时间信息可能仍然有很大偏差,尽管探查器尝试从最终结果中抵消检测成本。小心使用时,采样和插装可以提供关于应用在哪里花费时间的深刻见解,特别是当您比较多个报告并注意您的优化是否产生成果时。
时间分析器的高级用途
时间分析器还有一些我们在前面章节中没有研究过的技巧。本章太短,无法详细讨论它们,但是它们值得指出来,以确保您不会因为 Visual Studio 向导的舒适性而错过它们。
取样提示
正如我们在 Visual Studio 采样分析器一节中看到的,采样分析器可以从几种类型的事件中收集样本,包括缓存未命中和页面错误。在第五章和第六章中,我们将看到几个应用的示例,这些应用可以从改善其内存访问特性中大大受益,主要围绕着最大限度地减少缓存缺失。在分析这些应用显示的缓存未命中和页面错误的数量以及它们在代码中的精确位置时,分析器将被证明是有价值的。(使用指令评测时,您仍然可以收集 CPU 计数器,如缓存未命中、失效的指令以及预测错误的分支。为此,请从性能资源管理器窗格中打开性能会话属性,并导航到 CPU 计数器选项卡。收集的信息将在报告的 Functions 视图中作为附加列提供。)
采样分析模式通常比检测模式更灵活。例如,您可以使用“性能资源管理器”窗格将探查器(在采样模式下)附加到已经运行的进程。
分析时收集附加数据
在所有的评测模式 中,当评测器处于活动状态时,您可以使用 Performance Explorer 窗格暂停和恢复数据收集,并生成标记,这些标记将在最终的评测器报告中可见,以便更容易地识别应用执行的各个部分。这些标记将在报告的标记视图中可见。
提示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 的信息可以在 http://msdn.microsoft.com/en-us/library/ms182402.aspx 的 MSDN 上找到。
一些时间分析器提供了远程分析模式,允许主分析器 UI 在一台机器上运行,而分析会话在另一台机器上进行,而无需手动复制性能报告。例如,JetBrains dotTrace 分析器通过一个小的远程代理支持这种操作模式,该代理运行在远程机器上并与主分析器 UI 通信。这是在生产机器上安装整个 profiler 套件的一个很好的替代方法。
注第六章中的我们将利用 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 )。
图 2-16??。分配分析结果汇总视图
图 2-17??。功能详情视图 为 JackCompiler。Tokenizer.Advance 函数,显示调用者、被调用者和函数的源代码,在空白处有分配计数
图 2-18??。分配视图 ,显示负责分配系统的调用树。字符串对象
在第四章中,我们将学会理解快速丢弃临时对象的重要性,并讨论一种称为中年危机的关键性能相关现象,这种现象发生在临时对象经历了太多垃圾收集之后。为了识别应用中的这种现象,探查器报告中的对象生存期视图可以指示对象在哪一代被回收,这有助于了解它们是否在太多的垃圾收集后仍然存在。在图 2-19 中你可以看到应用分配的所有字符串(超过 1GB 的对象!)已经在第 0 代中被回收,这意味着它们甚至连一次垃圾收集都没有存活下来。
图 2-19??。对象生存期视图 帮助识别在多次垃圾收集中幸存的临时对象。在这个视图中,所有对象都在第 0 代中被回收,这是可用的最便宜的垃圾收集方式。(详见第四章。)
尽管 Visual Studio profiler 生成的分配报告非常强大,但它们在可视化方面有些欠缺。例如,如果一个特定的类型被分配在许多地方(字符串和字节数组总是这样),那么通过分配调用堆栈跟踪它就非常耗时。CLR 探查器提供了几个可视化功能,这使它成为 Visual Studio 的一个有价值的替代方案。
CLR Profiler
CLR Profiler 是一个独立的分析工具,不需要安装,占用的磁盘空间不到 1MB。你可以从 http://www.microsoft.com/download/en/details.aspx?id=16273 下载。另外,它附带了完整的源代码,如果您正在考虑使用 CLR Profiling API 开发一个定制工具,这将是一个有趣的读物。它可以附加到正在运行的进程(从 CLR 4.0 开始)或启动可执行文件,并记录所有内存分配和垃圾收集事件。
虽然运行 CLR Profiler 非常简单(运行 Profiler,单击“启动应用”,选择您的应用,然后等待报告出现),但报告信息的丰富程度可能会让人不知所措。我们将讨论报告中的一些观点;CLR 探查器的完整指南是 CLRProfiler.doc 文档,它是下载包的一部分。和往常一样,您可以在 JackCompiler.exe 示例应用上运行 CLR Profiler。
图 2-20 显示了剖析应用终止后生成的主视图 。它包含有关内存分配和垃圾收集的基本统计信息。从这里有几个共同的方向。我们可以重点调查内存分配源,以了解应用在何处创建其大多数对象(这类似于 Visual Studio profiler 的 Allocations 视图)。我们可以关注垃圾收集,以了解哪些对象正在被回收。最后,我们可以直观地检查堆的内容,以了解它的一般结构。
图 2-20??。 CLR Profiler 的主报告视图,显示分配和垃圾收集统计信息
在图 2-20 中“已分配字节”和“最终堆字节”旁边的直方图按钮和会导致一个对象类型的图表,这些对象类型根据它们的大小被分组到各个容器中。这些直方图可以用来识别大对象和小对象,以及程序分配最频繁的类型的要点。图 2-21 显示了我们的示例应用在运行过程中分配的所有对象的直方图。
图 2-21??。被分析的应用分配的所有对象。每个箱代表特定大小的对象。左边的图例包含从每种类型分配的字节和实例总数
图 2-20 中的分配图按钮 打开一个视图,在一个分组图中显示应用中所有对象的分配调用栈,这样可以很容易地从分配大部分内存的方法导航到各个类型,并查看哪些方法分配了它们的实例。图 2-22 显示了分配图的一小部分,从解析器开始。ParseStatement 方法,它分配了 372MB 的内存,并依次显示了它调用的各种方法。(此外,CLR Profiler 视图的其余部分有一个“显示分配给谁”上下文菜单项,它为应用对象的子集打开分配图。)
图 2-22??。评测应用的分配图。这里只显示了方法;实际分配的类型在图的最右边
图 2-20 中的年龄直方图按钮显示了一个图表,该图表根据年龄将最终堆中的对象分组到存储箱中。这使得能够快速识别长寿命的对象和临时对象,这对于检测中年危机情况是重要的。(我们将在第四章中深入讨论这些。)
图 2-20 中的按地址对象按钮将最终管理的堆内存区域分层可视化;最低层是最老的层(见图 2-23 )。就像考古探险一样,您可以挖掘这些层,看看哪些对象构成了您的应用的内存。这个视图对于诊断堆中的内部碎片也很有用(例如,由于固定)——我们将在第四章的中更详细地讨论这些。
图 2-23??。应用堆的可视化视图 。左边轴上的标签是地址;“gen 0”和“gen 1”标记是堆的子部分,在第四章中讨论
最后,图 2-20 中垃圾收集统计部分的时间线按钮 导致单个垃圾收集及其对应用堆的影响的可视化(参见图 2-24 )。该视图可用于确定哪些类型的对象正在被回收,以及随着垃圾收集的发生,堆是如何变化的。它还有助于识别内存泄漏,即垃圾收集没有回收足够的内存,因为应用保留了越来越多的对象。
图 2-24??。应用垃圾收集的时间线。底部轴上的记号代表单独的 GC 运行,所描绘的区域是托管堆。随着垃圾收集的发生,内存使用量显著下降,然后急剧上升,直到下一次收集发生。总的来说,内存使用(在 GC 之后)是恒定的,所以这个应用没有出现内存泄漏
分配图和直方图是非常有用的工具,但有时识别对象之间的引用和不调用方法堆栈同样重要。例如,当应用出现托管内存泄漏时,对其堆进行爬网、检测最大的对象类别并确定阻止 GC 收集这些对象的对象引用是非常有用的。当被分析的应用正在运行时,单击“Show Heap now”按钮会生成一个堆转储,稍后可以对其进行检查以对对象之间的引用进行分类。
图 2-25 显示了三个堆转储如何同时显示在分析器的报告中,显示了 f-reachable 队列保留的 byte[]对象数量的增加(在第四章中讨论),通过雇员和调度对象引用。图 2-26 显示了从上下文菜单中选择“显示新对象”的结果,以便只查看在第二次和第三次堆转储之间分配的对象。
图 2-25??。三个堆转储一个在另一个上面,显示 11MB 的 byte[]实例被保留
图 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 应用来遵循这个演示——要使它泄漏内存,请导航左边的目录树到非空目录 。
- 从探查器中运行应用。(与 CLR Profiler 类似,从 CLR 4.0 开始,ANTS 支持附加到正在运行的进程。)
- 应用完成初始化后,使用“获取内存快照”按钮捕获初始快照。该快照是后续性能调查的基准。
- 随着内存泄漏的累积,获取额外的堆快照。
- 应用终止后,比较快照(基线快照与最后一个快照,或它们之间的中间快照)以了解哪些类型的对象正在内存中增长。
- 使用实例分类程序关注特定类型,以了解哪些类型的引用保留了可疑类型的对象。(在这个阶段,您正在检查类型之间的引用——引用类型 B 实例的类型 A 实例将按类型分组,就好像 A 正在引用 B 。)
- 使用实例列表浏览可疑类型的单个实例。确定几个有代表性的实例,并使用实例保留图来确定它们保留在内存中的原因。(在这个阶段,您正在检查各个对象之间的引用,并且可以看到为什么特定的对象没有被 GC 回收。)
- 返回到应用的源代码并修改它,使泄漏的对象不再被有问题的链引用。
在分析过程的最后,您应该很好地理解了为什么应用中最重的对象没有被 GC 回收。内存泄漏的原因有很多,真正的艺术是从百万对象堆中快速辨别出有趣的代表性对象和类型,从而导致主要的泄漏源。
图 2-27 显示了两张快照之间的对比视图。内存泄漏(以字节为单位)主要由字符串对象组成。关注实例分类器中的字符串类型(在图 2-28 中)可以得出这样的结论:有一个事件将 FileInformation 实例保留在内存中,它们又保存了对 byte[]对象的引用。使用实例保留图(参见图 2-29 )向下钻取以检查特定实例指向文件信息。FileInformationNeedsRefresh 需要一个新的静态事件作为内存泄漏的来源。
图 2-27 。两个堆快照之间的比较。两者之差总计+6.23MB,目前内存中持有的最大类型是 System。线
图 2-28??。字符串 由字符串数组保留,字符串数组由 FileInformation 实例保留,file information 实例又由事件(通过系统)保留。EventHandler 委托)
图 2-29??。我们选择检查的单个字符串 是字符串数组中的元素 29,由 FileInformation 对象的
科学技术。NET 内存分析器
科学技术。内存分析器是另一个专注于内存泄漏诊断的商业工具。虽然一般的分析流程与 ANTS 内存分析器非常相似,但是这个分析器可以打开转储文件 ,这意味着您不必在应用旁边运行它,并且可以使用当 CLR 耗尽内存时生成的崩溃转储。在问题已经在生产环境中发生之后,这对于诊断内存泄漏事后检查 可能是至关重要的。你可以从 http://memprofiler.com/download.aspx 下载 10 天评估版。在下面的说明和截图中,我们使用了。NET 内存分析器 4.0,这是撰写本文时可用的最新版本。
注意 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 内存分析器,选择文件导入内存转储菜单项,并将分析器定向到转储文件。如果您有几个转储文件,您可以将它们全部导入到分析会话中,并将它们作为堆快照进行比较。导入过程可能相当长,尤其是在涉及大堆的情况下;对于更快的分析会话,SciTech 提供了一个单独的工具,NmpCore.exe,它可以用来捕获生产环境中的堆会话,而不是依赖于转储文件。
图 2-30 显示了比较两个内存转储的结果。NET 内存分析器。它立即发现了由事件处理程序直接保存在内存中的可疑对象,并将分析指向 FileInformation 对象。
图 2-30 两张内存快照 的初步分析。第一列列出了活动实例的数量,而第三列列出了它们所占用的字节数。由于工具提示,主内存猪——字符串 对象——不可见
关注 FileInformation 对象说明了 FileInformation 只有一个根路径。FileInformation 需要一个刷新事件处理程序来处理所选的 file information 实例 (参见图 2-31 )并且单个实例的可视化证实了我们之前在 ANTS 内存分析器中看到的相同的引用链。
图 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 启动性能向导菜单项,并选择并发模式。
图 2-32??。对特定资源的争用——有几个线程同时等待获取资源。当一个线程被选中时,它的阻塞调用堆栈列在底部
Concurrency Visualizer 模式(或多线程执行可视化)显示一个图表,其中包含所有应用线程的执行细节,并根据其当前状态进行颜色编码。每一个线程状态转换——阻塞 I/O、等待同步机制、运行——都被记录下来,以及它的调用堆栈和解除阻塞调用堆栈(如适用)(参见图 2-33 )。这些报告非常有助于理解应用线程的作用,并检测不良的性能模式,如超额预订、预订不足、饥饿和过度同步。图中还内置了对任务并行库机制的支持,比如并行循环和 CLR 同步机制。要在这种操作模式下启动分析器,请使用分析并发可视化子菜单。
图 2-33 几个应用线程(列在左边)及其执行的可视化。从可视化效果和底部的直方图可以明显看出,工作并没有在不同线程之间均匀分布
注意 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 网站 http://technet.microsoft.com/en-us/sysinternals/bb896645 的下载整个 Sysinternals 工具套件,或者只下载最新版本的 Process Monitor。通过应用其丰富的过滤功能,系统管理员和性能调查人员可以使用 Process Monitor 来诊断与 I/O 相关的错误(如文件丢失或权限不足)以及性能问题(如远程文件系统访问或过度分页)。
图 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 毫秒。
注意分辨率是细度的度量机制。如果它报告的结果是 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 )。这种内存密度的差异是在某些设置中偏好值类型的关键原因。
图 3-1 。Point2D 实例的数组在 Point2D 的情况下是引用类型而不是值类型
注意存储对点的引用而不是实际的点实例还有一个缺点。如果您想顺序遍历这个巨大的点数组,编译器和硬件访问 Point2D 实例的连续数组要比通过引用访问堆对象容易得多,因为堆对象不能保证在内存中是连续的。正如我们将在第五章中看到的,CPU 缓存的考虑会影响应用的执行时间一个数量级。
不可避免地得出这样的结论:理解 CLR 如何在内存中布局对象以及引用类型与值类型有何不同的细节,对于我们的应用的性能至关重要。我们首先回顾值类型和引用类型在语言层面上的基本区别,然后深入内部实现细节。
引用类型和值类型之间的语义差异
中的引用类型。NET 包括类、委托、接口和数组。字符串(系统。String),这是。NET 也是一种引用类型。中的值类型。NET 包含结构和枚举。基本类型,比如 int,float,decimal,都是值类型,但是。NET 开发人员可以使用 struct 关键字自由定义其他值类型。
在语言层面上,引用类型享有引用语义,其中对象的身份在其内容之前被考虑,而值类型享有值语义,其中对象没有身份,不通过引用访问,并根据其内容处理。这会影响的几个方面。NET 语言,如表 3-1 所示。
表 3-1。值类型和引用类型的语义差异
标准 | 参考类型 | 值类型 |
---|---|---|
将对象传递给方法 | 仅传递引用;更改会传播到所有其他引用 | 对象的内容被复制到参数中(除非使用 ref 或 out 关键字);更改不会影响方法之外的任何代码 |
将一个变量赋给另一个变量 | 仅复制引用;两个变量现在包含对同一对象的引用 | 内容被复制;这两个变量包含不相关数据的相同副本 |
使用运算符==比较两个对象 | 比较参考文献;如果两个引用引用同一个对象,则它们是相等的 | 内容比较;如果两个对象的内容在逐字段级别上相同,则它们是相等的 |
这些语义差异是我们在任何。网语。然而,就引用类型和值类型及其用途的不同而言,它们只是冰山一角。首先,让我们考虑存储对象的内存位置,以及它们是如何分配和释放的。
存储、分配和解除分配
引用类型专门从托管堆 中分配,托管堆是由。NET 垃圾收集器,这将在第四章中详细讨论。从托管堆中分配一个对象需要增加一个指针,就性能而言,这是一个相当便宜的操作。在多处理器系统上,如果多个处理器访问同一个堆,就需要一些同步,但是与非托管环境(如 malloc)中的分配器相比,这种分配仍然非常便宜。
垃圾收集器 以一种不确定的方式回收内存,并且对其内部操作不做任何承诺。正如我们将在《??》第四章中看到的,一个完整的垃圾收集过程是极其昂贵的,但是一个表现良好的应用的平均垃圾收集成本应该比一个类似的非托管应用的平均垃圾收集成本要小得多。
注意准确的说,这里的是一个可以从栈中分配的引用类型的化身。使用不安全上下文和 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
注意在 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 位实例的布局。净进程:
图 3-2 。托管堆上 Employee 实例的布局,包括引用类型开销
对象中 _id 和 _name 字段的顺序是不确定的(尽管它是可以控制的,正如我们将在“值类型内部”一节中看到的,使用 StructLayout 属性)。然而,对象的内存存储以一个名为对象头字(或同步块索引)的四字节字段开始,随后是另一个名为方法表指针(或类型对象指针)的四字节字段。这些字段不能从任何。NET 语言——它们服务于 JIT 和 CLR 本身。对象引用(在内部只是一个内存地址)指向方法表指针的开始,因此对象头字位于对象地址的负偏移量处。
注意在 32 位系统上,堆中的对象与最近的四字节倍数对齐。这意味着,由于对齐的原因,只有一个字节成员的对象仍然会在堆中占用 12 个字节(事实上,即使没有实例字段的类在实例化时也会占用 12 个字节)。引入 64 位系统有几个不同之处。首先,方法表指针字段(它就是一个指针)占用了 8 个字节的内存,对象头字也占用了 8 个字节。其次,堆中的对象与最接近的 8 字节倍数对齐。这意味着一个 64 位堆中只有一个字节成员的对象将占用 24 个字节的内存。这只是为了更有力地证明引用类型的内存密度开销,尤其是在批量创建小对象的情况下。
方法表
方法表指针指向称为方法表(MT)的内部 CLR 数据结构,方法表又指向另一个称为 EEClass 的内部结构(EE 代表执行引擎)。MT 和 EEClass 一起包含调度虚拟方法调用、接口方法调用、访问静态变量、确定运行时对象的类型、有效地访问基本类型方法以及服务于许多附加目的所需的信息。方法表包含频繁访问的信息,这是关键机制(如虚拟方法调度)的运行时操作所需要的,而 EEClass 包含不太频繁访问的信息,但仍由一些运行时机制(如反射)使用。我们可以通过使用!DumpMT 和!DumpClass SOS 命令和 Rotor (SSCLI)源代码,请记住,我们正在讨论的内部实现细节在不同的 CLR 版本之间可能会有很大的不同。
注 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。对象:
图 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()
注意与 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 字节的恒定偏移量。
图 3-4 。Employee 方法表的详细视图,包括用于虚拟方法调用的接口列表和方法列表的内部指针
注意到达该类型方法的代码地址表所需的额外解引用步骤允许该表与方法表对象分开存储在不同的内存位置。例如,如果您检查系统的方法表。对象,您可能会发现它的方法代码地址存储在单独的位置。此外,有许多虚方法的类将有几个一级表指针,允许在派生类中部分重用方法表。
调用引用类型实例上的方法
显然,方法表可以用来调用任意对象实例上的方法。假设堆栈位置 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 类实现了一个额外的接口;然而,“指向方法的指针”字段仍然在相同的偏移量处,并且适应这种差异:
图 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
注意对象布局是 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 ‘this’ by 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 ‘this’ by convention
cmp ecx, dword ptr [ecx]
call dword ptr [0x004a1260]
回想一下,CMP 指令从第一个操作数中减去第二个操作数,并根据操作结果设置 CPU 标志。上面的代码不使用存储在 CPU 标志中的比较结果,那么 CMP 指令如何帮助防止使用空对象引用调用方法呢?嗯,CMP 指令试图访问包含对象引用的 ECX 寄存器中的内存地址。如果对象引用为空,则此内存访问将因访问冲突而失败,因为在 Windows 进程中访问地址 0 总是非法的。CLR 将此访问冲突转换为在调用点引发的 NullReferenceException 这比在方法被调用后在方法内部发出空检查要好得多。此外,CMP 指令仅占用内存中的两个字节,并且具有能够检查除 null 之外的无效地址的优点。
注意调用虚方法时不需要类似的 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 事件在内部实现的。分配的同步块的数字索引存储在对象的头字中。随后使用该对象进行同步的尝试会识别现有的同步块索引,并使用相关的监视器对象进行同步。
图 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 实例的内存布局简单如下,没有额外的“开销”字段混乱:
图 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 所示:
图 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 编译器解释这个指令,并发出对分配堆存储的方法的调用,将值类型实例的内容复制到堆中,并用对象头-对象头字和方法表指针包装值类型内容。每当需要对象引用时,就使用这个“盒子”。请注意,该框是从原始值类型实例中分离出来的,对其中一个实例所做的更改不会影响另一个实例。
图 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 的中间类型。
注混淆地,系统地。ValueType 是引用类型–CLR 根据以下标准区分值类型和引用类型:值类型是从 System.ValueType 派生的类型。ValueType 是一种引用类型。
系统。ValueType 重写从 System 继承的 Equals 和 GetHashCode 虚方法。对象,这样做有一个很好的理由:值类型与引用类型具有不同的默认相等语义,这些默认值必须在某个地方实现。例如,系统中被重写的 Equals 方法。ValueType 确保值类型基于它们的内容进行比较,而系统中的原始 Equals 方法。对象只比较对象引用(标识)。
不管系统如何。ValueType 实现这些重写的虚方法,请考虑以下场景。您在列表
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 来获取方法表指针!
注意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
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)成立且不重写等于的值类型
FastEqualsCheck 方法同样是一个谜,但是它有效地执行了 memcmp 操作——比较两个值类型实例的内存(逐字节)。不幸的是,这两种方法仍然是内部实现细节,依赖它们作为比较值类型实例的高性能方法是一个非常糟糕的想法。
GetHashCode 方法
最后一个重要的方法是 GetHashCode。在展示合适的实现之前,让我们先复习一下它的用途。哈希代码最常与哈希表结合使用,哈希表是一种(在特定条件下)允许对任意数据进行常数时间( O (1))插入、查找和删除操作的数据结构。中常见的哈希表类。NET 框架包括字典< TKey,TValue >,Hashtable,HashSet < T >。典型的哈希表实现由动态长度的桶数组组成,每个桶包含一个项目链表。为了将一个条目放入哈希表,它首先计算一个数值(通过使用 GetHashCode 方法),然后对其应用一个哈希函数,指定条目映射到哪个桶。该项被插入到其存储桶的链表中。
图 3-10。一种哈希表,由存储项目的链表(桶)数组组成。一些桶可能是空的;其他存储桶可能包含相当多的项目
哈希表的性能保证在很大程度上依赖于哈希表实现使用的哈希函数,但也需要 GetHashCode 方法的几个属性:
- 如果两个对象相等,则它们的哈希代码相等。
- 如果两个对象不相等,则它们的哈希代码不太可能相等。
- GetHashCode 应该很快(虽然通常在对象大小上是线性的)。
- 对象的哈希代码不应改变。
注意属性(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 中(作为一种优化,只有在第一次访问哈希代码时才会这样做;毕竟很多对象从来不用做哈希表键)。要计算散列码,不需要依赖随机数的生成,也不需要考虑对象的内容;一个简单的柜台就可以了。
注哈希码怎么能和同步块索引一起共存在对象头字里?正如您所记得的,大多数对象从不使用它们的对象头字来存储同步块索引,因为它们不用于同步。在极少数情况下,对象通过存储其索引的对象头字链接到同步块,散列码被复制到同步块并存储在那里,直到同步块与对象分离。为了确定散列码或同步块索引当前是否存储在对象头字中,该字段的一个位被用作标记。
使用默认 Equals 和 GetHashCode 实现的引用类型不需要关心上面强调的四个属性中的任何一个——它们是免费获得的。但是,如果你的引用类型应该选择覆盖默认的相等行为(这是什么系统。String 是这样的),那么如果您将引用类型用作哈希表中的键,您应该考虑使其不可变。
使用值类型的最佳实践
下面是一些最佳实践,当您考虑为某个任务使用值类型时,这些实践应该会引导您朝着正确的方向前进:
- 如果您的对象很小,并且您打算创建大量的对象,请使用值类型。
- 如果需要高密度的内存集合,请使用值类型。
- 重载等于,重载等于,实现 IEquatable
,重载运算符==,重载运算符!=在值类型上。 - 在值类型上重写 GetHashCode。
- 考虑让你的值类型不可变。
摘要
在这一章中,我们已经揭示了引用类型和值类型的实现细节,以及这些细节如何影响应用的性能。值类型表现出极高的内存密度,这使它们成为大型集合的理想选择,但不具备对象所需的特性,如多态、同步支持和引用语义。CLR 引入了两类类型,以便在需要时提供面向对象的高性能替代方案,但仍然需要开发人员付出巨大努力来正确实现值类型。
四、垃圾收集
在本章中,我们将研究。垃圾收集器(GC)是影响。NET 应用。在让开发人员不必担心内存释放的同时,GC 为构建性能至上的行为良好的程序带来了新的挑战。首先,我们将回顾 CLR 中可用的 GC 类型,并了解如何使应用适应 GC,这对整体 GC 性能和暂停时间非常有益。接下来,我们将了解代如何影响 GC 性能,以及如何相应地调优应用。在本章的最后,我们将研究可用于直接控制 GC 的 API,以及正确使用非确定性终结所涉及的微妙之处。
本章中的许多例子都是基于作者在现实系统中的个人经验。在可能的情况下,我们试图为您提供案例研究,甚至是您在阅读本章时可以使用的示例应用,以说明主要的性能难点。本章末尾的“最佳实践”部分充满了这样的案例研究和例子。但是,您应该意识到,这些要点中的一些很难用简短的代码片段甚至一个示例程序来演示,因为出现性能差异的情况通常发生在内存中有数千种类型和数百万个对象的大型项目中。
为什么要收集垃圾?
垃圾收集是一种高级抽象,它让开发人员不再需要关心内存释放的管理。在垃圾收集环境中,内存分配依赖于对象的创建,当应用不再引用这些对象时,就可以释放内存。垃圾回收器还为不驻留在托管堆上的非托管资源提供终结接口,以便在不再需要这些资源时可以执行自定义清理代码。的两个主要设计目标。网络垃圾收集器有:
- 消除内存管理错误和缺陷的负担
- 提供匹配或超过手动本机分配器性能的内存管理性能
现有的编程语言和框架使用几种不同的内存管理策略。我们将简要分析其中的两个:自由列表管理(可以在 C 标准分配器中找到)和引用计数垃圾收集。这将为我们了解的内部结构提供一个参考点。网络垃圾收集器。
自由列表管理
自由列表管理是 C 运行时库中的底层内存管理机制,默认情况下,C++内存管理 API(如 new 和 delete)也会使用它。这是一个确定性的内存管理器,依赖于开发人员在他们认为合适的时候分配和释放内存。内存的空闲块存储在一个链表中,从中分配得到满足(见图 4-1 )。被释放的内存块被返回到空闲列表。
图 4-1 。自由列表管理器管理未使用内存块的列表,并满足分配和解除分配请求。应用分发通常包含块大小的内存块
自由列表管理不能摆脱影响使用分配器的应用性能的战略和战术决策。在这些决定中:
- 一个使用自由列表管理的应用从一个由自由列表组成的小内存块池开始。该列表可以按照大小、使用时间、由应用确定的分配场所等来组织。
- 当来自应用的分配请求到达时,在空闲列表中找到一个匹配的块。可以通过选择第一拟合、最佳拟合或使用更复杂的替代策略来定位匹配块。
- 当空闲列表用尽时,内存管理器向操作系统请求添加到空闲列表中的另一组空闲块。当来自应用的释放请求到达时,被释放的内存块被添加到空闲列表中。这个阶段的优化包括将相邻的空闲块连接在一起,整理碎片和调整列表,等等。
与自由列表内存管理器相关的主要问题如下:
- 分配成本 :寻找一个合适的区块来满足分配请求是非常耗时的,即使使用了最适合的方法。此外,块通常被分成多个部分来满足分配请求。在多处理器的情况下,除非使用多个列表,否则对空闲列表的争用和分配请求的同步是不可避免的。另一方面,多重列表恶化了列表的碎片化。
- 解除分配成本 :将一个空闲内存块返回到空闲列表是很耗时的,并且还需要多处理器同步多个解除分配请求。
- 管理成本 :对空闲列表进行碎片整理和修整是避免内存耗尽情况所必需的,但是这项工作必须在一个单独的线程中进行,并获取空闲列表上的锁,这阻碍了分配和解除分配性能。通过使用固定大小的分配桶来维护多个自由列表,可以最小化碎片,但是这需要更多的管理,并且会给每个分配和释放请求增加少量成本。
引用计数垃圾收集
引用计数垃圾收集器 将每个对象与一个整数变量相关联——它的引用计数。创建对象时,其引用计数被初始化为 1。当应用创建一个新的对象引用时,其引用计数增加 1(见图 4-2 )。当应用移除对该对象的现有引用时,其引用计数减 1。当对象的引用计数达到 0 时,可以确定性地销毁该对象,并回收其内存。
图 4-2 。每个对象都包含一个引用计数
Windows 生态系统中引用计数垃圾收集的一个例子是 COM(组件对象模型)。COM 对象与影响其生存期的引用计数相关联。当 COM 对象的引用计数达到 0 时,通常由该对象负责回收自己的内存。通过显式的 AddRef 和释演法调用,管理引用计数的负担主要由开发人员承担,尽管大多数语言都有在创建和销毁引用时自动调用这些方法的自动包装器。
与引用计数垃圾收集相关的主要问题如下:
- 管理成本:每当创建或销毁对一个对象的引用时,必须更新该对象的引用计数。这意味着诸如引用赋值(a = b)或通过值将引用传递给函数之类的琐碎操作会导致更新引用计数的开销。在多处理器系统上,这些更新会围绕引用计数引入争用和同步,并且如果多个处理器更新同一个对象的引用计数,会导致缓存抖动。(有关单处理器和多处理器缓存考虑事项的更多信息,请参见第五章和第六章。)
- 内存使用:对象的引用计数必须存储在内存中,并与对象相关联。根据每个对象预期的引用数量,这会将对象的大小增加几个字节,使得引用计数对于 flyweight 对象来说不值得。(这对于 CLR 来说不是什么问题,因为对象已经有了 8 或 16 字节的“开销”,正如我们在《??》第三章中看到的。)
- 正确性:在引用计数垃圾收集下,对象的断开循环不能被回收。如果应用不再有对两个对象的引用,但是每个对象都有对另一个对象的引用,引用计数应用将经历内存泄漏(见图 4-3 )。COM 记录了这种行为,并要求手动中断循环。其他平台,如 Python 编程语言,引入了一种额外的机制来检测和消除这种循环,从而导致额外的不确定性收集成本。
图 4-3 。当一个对象周期不再被应用引用时,它们的内部引用计数保持为 1,并且不会被销毁,从而产生内存泄漏。(图中的虚线参考已被删除。)
跟踪垃圾收集
跟踪垃圾回收是。NET CLR、Java VM 和各种其他托管环境——这些环境不使用任何形式的引用计数垃圾收集。开发人员不需要发出显式的内存释放请求;这由垃圾收集器负责。跟踪 GC 不会将对象与引用计数相关联,通常在达到内存使用阈值之前不会产生任何释放开销。
当垃圾收集发生时,收集器从标记阶段 开始,在此期间,它解析应用仍然引用的所有对象(活动对象)。在构建了一组活动对象之后,收集器转到扫描阶段,此时它将回收未使用的对象所占用的空间。最后,收集器以压缩阶段结束,在这个阶段,它移动活动对象,使它们在内存中保持连续。
在本章中,我们将详细研究与跟踪垃圾收集相关的各种问题。但是,现在可以提供这些问题的大致轮廓:
- 分配成本:分配成本与基于堆栈的分配相当,因为没有与自由对象相关联的维护。分配包括递增指针。
- 解除分配成本:每当 GC 周期发生时发生,而不是均匀分布在应用的执行配置文件中。这有它的优点和缺点(特别是对于低延迟的场景),我们将在本章的后面讨论。
- 标记阶段:定位被引用的对象需要来自托管执行环境的大量规则。对对象的引用可以存储在静态变量、线程堆栈上的局部变量中,作为指针传递给非托管代码,等等。跟踪每个可访问对象的每个可能的引用路径一点也不简单,而且经常会在收集周期之外产生运行时开销。
- 扫描阶段:在内存中移动对象会耗费时间,并且可能不适用于大型对象。另一方面,消除对象之间未使用的空间有助于引用的局部性,因为被一起分配的对象被一起放置在内存中。此外,这消除了对额外碎片整理机制的需要,因为对象总是连续存储的。最后,这意味着分配代码在寻找空闲空间时不需要考虑对象之间的“洞”;可以使用简单的基于指针的分配策略。
在接下来的部分中,我们将研究。NET GC 内存管理范式,从理解 GC 标记和清除阶段开始,到更重要的优化,如代。
标记阶段
在跟踪垃圾收集周期的标记阶段,GC 遍历应用当前引用的所有对象的图。为了成功地遍历这个图并防止假阳性和假阴性(将在本章后面讨论),GC 需要一组起始点,引用遍历可以从这些起始点开始。这些起始点被称为根,顾名思义,它们构成了 GC 构建的有向引用图的根。
在建立了一组根之后,垃圾收集器在标记阶段的工作就相对容易理解了。它考虑每个根中的每个内部引用字段,并继续遍历图,直到所有被引用的对象都被访问过。因为参考周期是允许的。NET 应用中,GC 标记被访问的对象,这样每个对象只被访问一次——因此得名标记阶段。
本地根
最明显的根类型之一是局部变量;单个局部变量可以构成应用引用的整个对象图的根。例如,考虑创建系统的应用的 Main 方法中的以下代码片段。Xml.XmlDocument 对象并继续调用其 Load 方法:
static void Main(string[] args) {
XmlDocument doc = new XmlDocument();
doc.Load("Data.xml");
Console.WriteLine(doc.OuterXml);
}
我们不控制垃圾收集器的计时,因此必须假设在 Load 方法调用期间可能会发生垃圾收集。如果发生这种情况,我们不希望 XmlDocument 对象被回收 Main 方法中的本地引用是垃圾收集器必须考虑的文档对象图的根。因此,当其方法在堆栈上时,每个可能保存对对象的引用的局部变量(即,引用类型的每个局部变量)可以表现为活动根。
然而,我们不需要引用保持一个活动的根,直到其封闭方法的结束。例如,在加载并显示文档之后,我们可能希望在同一个方法中引入额外的代码,不再需要将文档保存在内存中。这段代码可能需要很长时间才能完成,如果同时发生垃圾收集,我们希望文档的内存被回收。
是不是?NET 垃圾收集器提供了这种急切的收集功能?让我们检查下面的代码片段,它创建了一个系统。Timer,并用一个回调来初始化它,这个回调通过调用 GC 来引发垃圾收集。收集(我们将在后面更详细地研究这个 API):
using System;
using System.Threading;
class Program {
static void Main(string[] args) {
Timer timer = new Timer(OnTimer, null, 0, 1000);
Console.ReadLine();
}
static void OnTimer(object state) {
Console.WriteLine(DateTime.Now.TimeOfDay);
GC.Collect();
}
}
如果您在调试模式下运行上述代码(如果从命令行编译,没有/optimize +编译器开关),您将看到计时器回调每秒被调用一次,正如预期的那样,这意味着计时器没有被垃圾收集。但是,如果在发布模式下运行它(使用/optimize +编译器开关),计时器回调只被调用一次!换句话说,计时器被收集并停止调用回调。这是合法的(甚至是期望的)行为,因为一旦到达控制台,我们对计时器的本地引用就不再作为根相关了。ReadLine 方法调用。因为它不再相关,所以计时器被收集起来,如果您没有遵循前面关于本地根的讨论,就会产生一个意想不到的结果!
急切的根集合
这种对本地根的急切收集功能实际上是由。NET 实时编译器(JIT) 。垃圾收集器无法自行知道局部变量是否仍有可能被其封闭方法使用。当 JIT 编译方法时,这个信息被嵌入到特殊的表中。对于每个局部变量,JIT 将最早和最晚指令指针的地址嵌入到表中,其中变量仍然作为根相关。然后,GC 在执行堆栈审核时使用这些表。(注意,局部变量可以存储在堆栈上或 CPU 寄存器中;JIT 表必须指出这一点。)
//Original C# code:
static void Main() {
Widget a = new Widget();
a.Use();
//...additional code
Widget b = new Widget();
b.Use();
//...additional code
Foo(); //static method call
}
//Compiled x86 assembly code:
; prologue omitted for brevity
call 0x0a890a30; Widget..ctor
+0x14 mov esi, eax ; esi now contains the object's reference
mov ecx, esi
mov eax, dword ptr [ecx]
; the rest of the function call sequence
+0x24 mov dword ptr [ebp-12], eax ; ebp-12 now contains the object's reference
mov ecx, dword ptr [ebp-12]
mov eax, dword ptr [ecx]
; the rest of the function call sequence
+0x34 call 0x0a880480 ; Foo method call
; method epilogue omitted for brevity
//JIT-generated tables that the GC consults:
Register or stack Begin offset End offset
ESI 0x14 0x24
EBP - 12 0x24 0x34
上面的讨论暗示了将你的代码分解成更小的方法和使用更少的局部变量不仅仅是一个好的设计方法或者软件工程技术。与。NET 垃圾收集器,它也可以提供一个性能优势,因为你有更少的本地根!这意味着编译方法时 JIT 的工作量更少,根 IP 表占用的空间更少,执行堆栈审核时 GC 的工作量也更少。
如果我们想显式地延长计时器的生命周期,直到它的封装方法结束,该怎么办?有多种方法可以实现这一点。我们可以使用一个静态变量(这是一种不同类型的根,稍后将讨论)。或者,我们可以在方法的终止语句之前使用计时器(例如,调用计时器。Dispose())。但是完成这项工作最干净的方法是使用 GC。KeepAlive 方法,它保证对对象的引用将被视为根。
GC 是如何?KeepAlive 工作?这似乎是一个从 CLR 内部提升的神奇方法。然而,它是相当琐碎的——我们可以自己写,我们会的。如果我们将对象引用传递给任何不能被内联的方法(参见第三章关于内联的讨论),JIT 必须自动假定该对象被使用。因此,下面的方法可以代替 GC。KeepAlive 如果我们想:
[MethodImpl(MethodImplOptions.NoInlining)]
static void MyKeepAlive(object obj) {
//Intentionally left blank: the method doesn't have to do *anything*
}
静态根
根的另一个类别是静态变量。类型的静态成员是在类型被加载时创建的(我们已经在第三章中看到了这个过程),并且在应用域的整个生命周期中被认为是潜在的根。例如,考虑这个短程序,它不断地创建对象,这些对象又注册到一个静态事件:
class Button {
public void OnClick(object sender, EventArgs e) {
//Implementation omitted
}
}
class Program {
static event EventHandler ButtonClick;
static void Main(string[] args) {
while (true) {
Button button = new Button();
ButtonClick += button.OnClick;
}
}
}
这被证明是内存泄漏,因为静态事件包含一个委托列表,该列表又包含对我们创建的对象的引用。事实上最常见的一种。净内存泄漏是从一个静态变量引用你的对象!
其他词根
刚刚描述的两类根是最常见的,但是还存在其他类别。比如 GC 处理 (由系统表示。runtime . interop services . GC handle 类型)也被垃圾收集器视为根。f-可达队列 是另一个微妙的根类型的例子——等待终结的对象仍然被 GC 认为是可达的。我们将在本章后面考虑这两种根类型;中调试内存泄漏时,理解其他类别的根是很重要的。NET 应用,因为通常没有普通的(静态的或本地的)根引用你的对象,但是由于其他原因,它仍然存在。
使用 SOS.DLL 检查根部
我们在第三章中看到的调试器扩展 SOS.DLL 可以用来检查负责保持特定对象存活的根链。其!gcroot 命令提供了根类型和引用链的简洁信息。以下是其输出的一些示例:
0:004> !gcroot 02512370
HandleTable:
001513ec (pinned handle)
-> 03513310 System.Object[]
-> 0251237c System.EventHandler
-> 02512370 Employee
0:004> !gcroot 0251239c
Thread 3068:
003df31c 002900dc Program.Main(System.String[]) [d:\...\Ch04\Program.cs @ 38]
esi:
-> 0251239c Employee
0:004> !gcroot 0227239c
Finalizer Queue:
0227239c
-> 0227239c Employee
这个输出中的第一类根可能是一个静态字段——尽管确定这一点需要一些工作。不管怎样,它是一个固定的 GC 句柄(GC 句柄将在本章后面讨论)。第二种类型的根是线程 3068 中的 ESI 寄存器,它在 Main 方法中存储一个局部变量。最后一种根是 f-可达队列。
性能影响
垃圾收集周期的标记阶段是一个“几乎只读”的阶段,在这个阶段,没有对象被转移到内存中或者从内存中释放。尽管如此,在标记阶段所做的工作会产生重大的性能影响:
- 在满标记期间,垃圾收集器必须接触每个被引用的对象。如果内存不再位于工作集中,这会导致页面错误,并在遍历对象时导致缓存未命中和缓存抖动。
- 在多处理器系统上,由于收集器通过在对象头中设置一个位来标记对象,这会导致缓存中有该对象的其他处理器的缓存失效。
- 在这个阶段,未引用对象的成本较低,因此标记阶段的性能在收集效率因子中是线性的:收集空间中引用对象和未引用对象之间的比率。
- 标记阶段的性能还取决于图形中对象的数量,而不是这些对象消耗的内存。不包含很多引用的大型对象更容易遍历,并且开销也更少。这意味着标记阶段的性能与图中活动对象的数量成线性关系。
一旦所有被引用的对象都被标记,垃圾收集器就有了所有被引用对象及其引用的完整图形(见图 4-4 )。现在可以进入扫描阶段。
图 4-4 。具有各种类型根的对象图。允许循环引用
扫描和压缩阶段
在清扫和压缩阶段,垃圾收集器回收内存,通常是通过移动活动对象,使它们连续地放在堆中。为了理解移动对象的机制,我们必须首先检查分配机制,它为清扫阶段执行的工作提供了动力。
在我们目前正在研究的简单 GC 模型中,来自应用的分配请求通过增加一个指针来满足,该指针总是指向下一个可用的内存槽(参见图 4-5 )。这个指针被称为下一个对象指针 (或新对象指针),它在应用启动时构建垃圾收集堆时被初始化。
图 4-5 。GC 堆和下一个对象指针
在这种模型中,满足分配请求的代价非常低:它只涉及单个指针的原子增量。多处理器系统很可能会经历对这个指针的争用(这个问题将在本章后面讨论)。
如果内存是无限的,分配请求可以通过增加新的对象指针来无限地满足。然而,在某个时间点,我们达到了触发垃圾收集的阈值。阈值是动态的和可配置的,我们将在本章的后面研究控制它们的方法。
在压缩阶段,垃圾收集器移动内存中的活动对象,使它们占据空间中的连续区域(见图 4-6 )。这有助于引用的局部性,因为一起分配的对象也可能一起使用,所以最好在内存中将它们放在一起。另一方面,移动物体至少有两个性能痛点:
- 移动对象意味着复制内存,对于大型对象来说,这是一个开销很大的操作。即使副本被优化,在每个垃圾收集周期中复制几兆字节的内存也会导致不合理的开销。(这就是为什么大对象被区别对待的原因,我们将在后面看到。)
- 移动对象时,必须更新对它们的引用,以反映它们的新位置。对于经常被引用的对象,这种分散的内存访问(当引用被更新时)可能是代价很高的。
图 4-6 。左边的阴影对象在垃圾收集中幸存下来,并在内存中四处移动。这意味着必须更新对 A(虚线)的引用。(图中未显示更新后的参考。)
扫描阶段的总体性能与图中的对象数量成线性,并且对收集效率因子特别敏感。如果发现大多数对象是未被引用的,那么 GC 只需移动内存中的少数对象。这同样适用于大多数对象仍然被引用的场景,因为需要填充的漏洞相对较少。另一方面,如果堆中的所有其他对象都没有被引用,GC 可能不得不移动几乎所有活动的对象来填补这些漏洞。
注意与普遍的看法相反,垃圾收集器并不总是四处移动对象(也就是说,有一些只进行清扫的收集不会进入压缩阶段),即使它们没有被锁定(见下文),即使对象之间有空闲空间。有一个实现定义的试探法,它确定在扫描阶段移动对象来填充空闲空间是否值得。例如,在作者的 32 位系统上执行的一个测试套件中,如果空闲空间大于 16 个字节,由多个对象组成,并且自上次垃圾收集以来分配的空间超过 16KB,则垃圾收集器决定四处移动对象。你不能指望这些结果是可重复的,但这确实证明了优化的存在。
前面几节中描述的标记和清除模型有一个重大缺陷,我们将在本章后面讨论分代 GC 模型时解决这个问题。每当发生收集时,堆中的所有对象都会被遍历,即使它们可以根据收集效率的可能性进行分区。如果我们事先知道一些对象比其他对象更有可能死亡,我们也许能够相应地调整我们的收集算法,并获得更低的分摊收集成本。
钉住
上面介绍的垃圾收集模型没有解决托管对象的常见用例。这个用例围绕着传递托管对象供非托管代码使用。有两种不同的方法可以用来解决这个问题:
- 每个应该传递给非托管代码的对象在传递给非托管代码时都通过值进行封送(复制),并在返回时封送回去。
- 指向对象的指针被传递给非托管代码,而不是复制该对象。
每当我们需要与非托管代码交互时,复制内存是不现实的。考虑软实时视频处理软件的情况,该软件需要以每秒 30 帧的速度将高分辨率图像从非托管代码传播到托管代码,反之亦然。每次进行微小的更改时复制多兆字节的内存将会使性能下降到不可接受的程度。
那个。NET 内存管理模型 提供了获取被管理对象内存地址的工具。但是,在垃圾回收器存在的情况下将该地址传递给非托管代码会引发一个重要的问题:如果对象在非托管代码仍在执行并使用该指针时被 GC 移动,会发生什么情况?
这种情况可能会带来灾难性的后果——内存很容易被破坏。这个问题的一个可靠解决方案是在非托管代码有一个指向托管对象的指针时关闭垃圾收集器。然而,如果对象经常在托管和非托管代码 之间传递,这种方法就不够细粒度。如果线程从非托管代码中进入长时间等待,它还可能导致死锁或内存耗尽。
除了关闭垃圾收集,每个可以获得地址的托管对象也必须固定在内存中。锁定一个对象可以防止垃圾收集器在清理阶段移动它,直到它被解除锁定。
锁定操作 本身并不昂贵——有多种机制可以相当便宜地执行它。锁定一个对象最直接的方法是用 GCHandleType 创建一个 GC 句柄 ??。别着的旗帜。创建一个 GC 句柄会在进程' GC 句柄表中创建一个新的根,它会告诉 GC 对象应该被保留并固定在内存中。其他替代方法包括 P/Invoke 封送拆收器使用的 magic sauce,以及通过 fixed 关键字(或 C++/CLI 中的 pin_ptr < T >)在 C# 中公开的 pinned pointers 机制,它依赖于以特殊方式标记 pinning 局部变量以便 GC 查看。(有关更多详细信息,请参考第八章。)
然而,当我们考虑固定如何影响垃圾收集本身时,固定的性能成本就变得很明显了。当垃圾收集器在压缩阶段遇到固定对象时,它必须绕过该对象,以确保它不会在内存中移动。这使收集算法变得复杂,但最直接的影响是将碎片 引入到托管堆中。一个严重碎片化的堆直接使使垃圾收集可行的假设无效:它导致连续的分配在内存中被碎片化(以及局部性的损失),给分配过程带来复杂性,并且由于碎片不能被填充而导致内存浪费。
注意钉扎副作用可以用多种工具来诊断,包括微软 CLR 探查器。CLR 探查器可以按地址显示对象的图形,将空闲(碎片)区域显示为未使用的空白。或者,SOS.DLL(托管调试扩展)可用于显示“自由”类型的对象,这是由于碎片造成的漏洞。最后,固定对象的 # 性能计数器(在中)。NET CLR Memory 性能计数器类别)可以用来确定在 GC 检查的最后一个区域中固定了多少对象。
尽管有上述缺点 ,但在许多应用中,钉扎是必要的。当有一个抽象层(比如 P/Invoke)代表我们处理细节时,我们常常不能直接控制钉住。在这一章的后面,我们将提出一系列的建议来最小化钉扎的负面影响。
我们已经回顾了垃圾收集器在收集周期中采取的基本步骤。我们还看到了必须传递给非托管代码的对象会发生什么。在前面的章节中,我们已经看到了许多需要优化的地方。经常提到的一点是,在多处理器机器上,争用和同步需求可能是影响内存密集型应用性能的一个非常重要的因素。在接下来的章节中,我们将研究多种优化,包括针对多处理器系统的优化。
垃圾收集风味
那个。NET 垃圾收集器有几种风格 ,尽管它可能看起来像是一个庞大的整体代码,几乎没有定制的空间。这些风格用于区分多种场景:面向客户端的应用、高性能服务器应用等等。为了理解这些不同的味道是如何彼此不同的,我们必须看看垃圾收集器与其他应用线程(通常称为 mutator 线程 )的交互。
暂停线程以进行垃圾收集
当垃圾收集发生时,应用线程 正常执行。毕竟,垃圾收集请求通常是应用代码中进行新分配的结果——所以它当然愿意运行。GC 执行的工作会影响对象的内存位置以及对这些对象的引用。当应用代码正在使用对象时,在内存中移动对象并更改它们的引用很容易出现问题。
另一方面,在某些情况下,与其他应用线程同时执行垃圾收集进程至关重要。例如,考虑一个经典的 GUI 应用。如果垃圾收集过程是在后台线程上触发的,我们希望能够在收集过程中保持 UI 的响应。即使收集本身可能需要更长的时间才能完成(因为 UI 与 GC 争夺 CPU 资源),用户也会更高兴,因为应用的响应速度更快了。
如果垃圾收集器与其他应用线程同时执行,会出现两类问题:
- 假阴性:一个对象被认为是活的,即使它符合垃圾收集的条件。这是一个不希望的结果,但是如果这个对象将在下一个周期被收集,我们可以接受这个结果。
- 误报:一个对象被认为是死的,即使它仍然被应用引用。这是一个调试噩梦,垃圾收集器必须尽一切努力防止这种情况发生。
让我们考虑一下垃圾收集的两个阶段,看看我们是否能够承受与 GC 进程同时运行应用线程。请注意,无论我们可能得出什么结论,仍然有一些场景需要在垃圾收集过程中暂停线程。例如,如果进程真的耗尽了内存,那么在回收内存时就有必要挂起线程。然而,我们将审查较少例外的情况,这相当于大多数情况。
挂起 GC 的线程
挂起线程进行垃圾收集是在安全点执行的。不是每两个指令的集合都可以被中断来执行收集。JIT 发出额外的信息,以便在安全执行收集时发生挂起,CLR 尝试优雅地挂起线程——它不会在挂起后不验证线程是否安全的情况下公然调用 suspend thread Win32 API。
在 CLR 2.0 中,可能会出现这样一种情况:一个被 CPU 限制得非常紧的循环缠住的托管线程会绕过安全点很长一段时间,导致 GC 启动延迟长达 1500 毫秒(这反过来又会延迟任何已经被阻塞等待 GC 完成的线程)。此问题已在 CLR 4.0 中修复;如果你对细节感到好奇,可以看看萨沙·戈尔德施泰因的博文《垃圾收集线程挂起延迟》(blog . sashag . net/archive/2009/07/31/Garbage-Collection-Thread-Suspension-Delay-250 ms-multiples . aspx
,2009)。
请注意,非托管线程在返回托管代码之前不受线程挂起的影响,这由 P/Invoke 转换存根负责。
在标记阶段暂停线程
在标记阶段 期间,垃圾收集器的工作几乎是只读的。尽管如此,假阴性和假阳性还是会发生。
新创建的对象可能被收集器认为是死的,即使它被应用引用。如果收集器已经考虑了创建对象时更新的图形部分,这是可能的(见图 4-7 )。这可以通过拦截新引用(和新对象)的创建并确保标记它们来解决。它需要同步并增加分配成本,但允许其他线程与收集进程并发执行。
图 4-7 。在图形的该部分已经被标记(虚线对象已经被标记)之后,对象被引入到图形中。这导致对象被错误地认为是不可到达的
如果在标记阶段删除了对对象的最后一个引用,则已经被收集器标记的对象有资格进行垃圾收集(参见图 4-8 )。这不是一个需要考虑的严重问题;毕竟,如果对象真的不可到达,它将在下一个 GC 周期被收集——死对象没有办法再次变得可到达。
图 4-8 。在图形的该部分已经被标记(虚线对象已经被标记)之后,从图形中移除对象。这导致对象被错误地认为是可到达的
在清理阶段暂停线程
在扫描阶段 ,不仅引用会改变,对象也会在内存中移动。这给并发执行的应用线程带来了一系列新问题。这些问题包括:
- 复制对象不是原子操作。这意味着在复制了对象的一部分后,应用仍在修改原始对象。
- 更新对对象的引用不是原子操作。这意味着应用的某些部分可能使用旧的对象引用,而其他一些部分可能使用新的对象引用。
解决这些问题是可能的(JVM 的 Azul Pauseless GC,www.azulsystems.com/zing/pgc
就是一个例子),但是在 CLR GC 中还没有这样做。声明清扫阶段不支持与垃圾收集器并发执行的应用线程更简单。
提示要确定并发 GC 是否能为您的应用带来任何好处,您必须首先确定它通常花费多少时间来执行垃圾收集。如果您的应用花费 50%的时间来回收内存,那么还有很大的优化空间。另一方面,如果您只在几分钟内执行一次收集,您可能应该坚持对您有效的方法,并在其他地方进行重大优化。您可以通过。NET CLR 内存性能类别。
既然我们已经回顾了垃圾收集过程中其他应用线程的行为,我们可以更详细地研究各种 GC 风格。
工作站 GC
我们将研究的第一种 GC 风格被称为工作站 GC 。又进一步分为两个子口味:并发工作站 GC 和非并发工作站 GC 。
在工作站 GC 下,有一个执行垃圾收集的线程,垃圾收集不是并行运行的。请注意,在多个处理器上并行运行收集进程本身和与其他应用线程并发运行收集进程是有区别的。
并发工作站 GC
并发工作站 GC 风格是默认风格。在并发工作站 GC 下,有一个单独的专用 GC 线程,标记为 THREAD_PRIORITY_HIGHEST,从头到尾执行垃圾收集。此外,CLR 可以决定它希望垃圾收集过程的某些阶段与应用线程并发运行(正如我们在上面看到的,大多数标记阶段可以并发执行)。请注意,这个决定仍然取决于 CLR——正如我们将在后面看到的,一些收集足够快,足以保证完全暂停,例如第 0 代收集。无论如何,当执行扫描阶段时,所有应用线程都被挂起。
如果垃圾收集是由 UI 线程触发的,那么使用并发工作站 GC 的响应性优势可能会被超越。在这种情况下,应用的后台线程将与 UI 正在等待的垃圾收集竞争。这实际上会降低UI 的响应性,因为 UI 线程会被阻塞,直到 GC 完成,并且还有其他应用线程与 GC 争夺资源。(参见图 4-9 。)
图 4-9 。上半部分显示了 UI 线程触发收集时的并发 GC。下半部分显示了当一个后台线程触发收集时的并发 GC。(虚线表示阻塞的线程。)
因此,使用并发工作站 GC 的 UI 应用应该非常小心,防止 UI 线程上发生垃圾收集。这归结为在后台线程上执行分配,并避免显式调用 GC。在 UI 线程上收集。
所有的默认 GC 风格。NET 应用(保存 ASP.NET 应用),不管它们是运行在用户的工作站上还是运行在功能强大的多处理器服务器上,都是并发工作站 GC。这个缺省值很少适合服务器应用,我们很快就会看到。正如我们刚刚看到的,如果 UI 应用倾向于在 UI 线程上触发垃圾收集,这也不一定是合适的默认设置。
非并发工作站 GC
非并发工作站 GC 风格,顾名思义,在标记和清除阶段都会挂起应用线程。非并发工作站 GC 的主要使用场景是上一节提到的情况,此时 UI 线程倾向于触发垃圾收集。在这种情况下,非并发 GC 可能会提供更好的响应,因为后台线程不会与 UI 线程正在等待的垃圾收集竞争,从而更快地释放 UI 线程。(参见图 4-10 。)
图 4-10 。UI 线程触发非并发 GC 下的收集。在收集期间,其他线程不会争用资源
服务器 GC
服务器 GC 风格针对完全不同类型的应用进行了优化——服务器应用,顾名思义。在这种情况下,服务器应用需要高吞吐量的场景(通常以单个操作的延迟为代价)。服务器应用还需要轻松扩展到多个处理器,内存管理也必须能够扩展到多个处理器。
使用服务器 GC 的应用具有以下特征:
- 的关联掩码中,每个处理器都有一个单独的托管堆。NET 进程。特定处理器上的线程的分配请求从属于该特定处理器的托管堆中得到满足。这种分离的目的是在进行分配时最大限度地减少托管堆上的争用:大多数时候,下一个对象指针上没有争用,多个线程可以真正并行地执行分配。如果应用创建手动工作线程并为它们分配硬 CPU 关联,这种架构需要动态调整堆大小和 GC 阈值,以确保公平性。在典型的服务器应用中,服务请求来自线程池工作线程,很可能所有堆的大小都大致相同。
- 垃圾收集不会发生在触发垃圾收集的线程上。相反,垃圾收集发生在一组专用的 GC 线程上,这些线程是在应用启动期间创建的,并被标记为 THREAD_PRIORITY_HIGHEST。的关联掩码中的每个处理器都有一个 GC 线程。NET 进程。这允许每个线程在分配给其处理器的托管堆上并行执行垃圾收集。由于引用的局部性,很可能每个 GC 线程几乎只在自己的堆中执行标记阶段,堆的一部分保证在 CPU 的缓存中。
- 在垃圾收集的两个阶段,所有应用线程都被挂起。这允许 GC 及时完成,并允许应用线程尽快继续处理请求。它以延迟为代价最大化了吞吐量:当垃圾收集正在进行时,一些请求可能需要更长的时间来处理,但是总的来说,应用可以处理更多的请求,因为在垃圾收集正在进行时引入了更少的上下文切换。
使用服务器 GC 时,CLR 会尝试在处理器堆之间平衡对象分配。直到 CLR 4.0,只有小对象堆是平衡的;从 CLR 4.5 开始,大对象堆(稍后讨论)也得到了平衡。因此,所有堆的分配率、填充率和垃圾收集频率都是相似的。
使用服务器 GC 风格的唯一限制是机器上物理处理器的数量。如果机器上只有一个物理处理器,那么唯一可用的 GC 风格就是工作站 GC。这是一个合理的选择,因为如果只有一个处理器可用,就会有一个托管堆和一个 GC 线程,这会妨碍服务器 GC 架构的有效性。
注意从 NT 6.1 (Windows 7 和 Windows Server 2008 R2)开始,Windows 通过使用处理器组支持超过 64 个逻辑处理器。从 CLR 4.5 开始,GC 也可以使用超过 64 个逻辑处理器。这需要在应用配置文件中放置
服务器应用很可能受益于服务器 GC 风格。然而,正如我们之前看到的,默认的 GC 风格是工作站并发 GC。对于在控制台应用、Windows 应用和 Windows 服务中的默认 CLR 宿主下承载的应用来说,情况确实如此。非默认 CLR 主机可以选择使用不同的 GC 风格。这就是 IIS ASP.NET 主机所做的:它在服务器 GC 风格下运行应用,因为 IIS 通常安装在服务器机器上(尽管这种行为仍然可以通过 Web.config 进行定制)。
控制 GC 味道是下一节的主题。这是一个有趣的性能测试实验,尤其是对于内存密集型应用。在各种 GC 风格下测试这些应用的行为是一个好主意,看看在高内存负载下哪一种能产生最佳性能。
在 GC 风格之间切换
有可能用 CLR 托管接口来控制 GC 风格,这将在本章后面讨论。但是,对于默认主机,也可以使用应用配置文件(App.config)来控制 GC 风格选择。以下 XML 应用配置文件可用于在各种 GC 风格和子风格 之间进行选择:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<runtime>
<gcServer enabled="true" />
<gcConcurrent enabled="false" />
</runtime>
</configuration>
gcServer 元素控制服务器 GC 的选择,而不是工作站 GC 。gcConcurrent 元素控制工作站 GC 子风格的选择。
。NET 3.5(包括。NET 2.0 SP1 和。NET 3.0 SP1)增加了一个额外的 API,可以在运行时改变 GC 风格。它作为系统提供。这个类有两个属性:IsServerGC 和 LatencyMode。
GCSettings。IsServerGC 是一个只读属性,它指定应用是否在服务器 GC 下运行。它不能用于在运行时选择加入服务器 GC,并且只反映应用的配置状态或 CLR 主机的 GC 风格定义。
另一方面,LatencyMode 属性采用 GCLatencyMode 枚举的值,即:Batch、Interactive、LowLatency 和 SustainedLowLatency。批处理对应于非并发 GC;Interactive 对应于并发 GC。LatencyMode 属性可用于在运行时在并发和非并发 GC 之间切换。
GCLatencyMode 枚举的最后一个最有趣的值是 LowLatency 和 SustainedLowLatency。这些值向垃圾收集器发出信号,表明您的代码当前正处于一个时间敏感的操作中,此时垃圾收集可能是有害的。低延迟值是在中引入的。NET 3.5,仅在并发工作站 GC 上受支持,并且是为短时间敏感区域设计的。另一方面,SustainedLowLatency 是在 CLR 4.5 中引入的,它在服务器和工作站 GC 上都受支持,并且是为更长时间而设计的,在这段时间内,应用不应该暂停以进行完整的垃圾收集。低延迟不适用于您即将执行导弹制导代码的场景,原因很快就会看到。但是,如果您正在执行 UI 动画,并且垃圾收集会破坏用户体验,这是很有用的。
低延迟垃圾收集模式指示垃圾收集器避免执行完全收集,除非绝对必要,例如,如果操作系统的物理内存不足(分页的影响可能比执行完全收集的影响更糟)。低延迟并不意味着垃圾收集器关闭;部分收集(我们将在讨论代时考虑)仍将被执行,但是垃圾收集器在应用处理时间中所占的份额将显著降低。
安全使用低延迟 GC
使用低延迟 GC 模式的唯一安全方式是在受约束的执行区域内(CER ) 。CER 限定了一段代码,在这一段代码中,CLR 被限制不能抛出带外异常(如线程中止),这将阻止该段代码完整执行。放置在 CER 中的代码必须只调用具有强可靠性保证的代码。使用 CER 是保证延迟模式恢复到其先前值的唯一方法。下面的代码演示了如何实现这一点(您应该导入系统。编译器服务和系统。编译这段代码的运行时名称空间):
GCLatencyMode oldMode = GCSettings.LatencyMode;
RuntimeHelpers.PrepareConstrainedRegions();
try
{
GCSettings.LatencyMode = GCLatencyMode.LowLatency;
//Perform time-sensitive work here
}
finally
{
GCSettings.LatencyMode = oldMode;
}
您希望花费在低延迟 GC 模式上的时间必须保持在最低限度——一旦您退出低延迟模式,GC 开始积极回收未使用的内存,其长期影响可能会影响应用的性能。如果您不能完全控制进程中发生的所有分配(例如,如果您托管插件或有多个后台线程独立工作),请记住,切换到低延迟 GC 模式会影响整个进程,并可能对其他分配路径造成不良影响。
选择正确的 GC 风格并不是一项简单的任务,大多数时候我们只能通过实验来找到合适的模式。然而,对于内存密集型应用来说,这种试验是必须的——我们不能在一个处理器上花费我们宝贵的 CPU 时间的 50%来执行垃圾收集,而其他 15 个处理器却高高兴兴地闲坐着,等待收集完成。
当我们仔细检查上面展示的模型时,一些严重的性能问题仍然困扰着我们。以下是最重要的问题:
- 大对象:复制大对象是一个非常昂贵的操作,但是在扫描阶段,大对象可以一直被复制。在某些情况下,复制内存可能成为执行垃圾收集的主要成本。我们得出的结论是,在扫描阶段,对象必须根据大小进行区分。
- 收集效率因子:每一次收集都是一次完全收集,这意味着一个拥有相对稳定的对象集的应用将会付出巨大的代价来执行标记并清扫整个堆,即使大部分对象仍然被引用。为了防止低收集效率因素,我们必须追求一种优化,这种优化可以通过对象的收集可能性来区分对象:它们是否可能在下一个 GC 周期被收集。
这些问题的大多数方面都可以通过正确使用代来解决,这是我们在下一节讨论的主题。我们还将涉及一些在与交互时需要考虑的其他性能问题。网络垃圾收集器。
世代
的世代模型。NET 垃圾收集器通过执行部分垃圾收集来优化收集性能。部分垃圾收集具有更高的收集效率因子,收集器遍历的对象是那些具有最佳收集可能性的对象。根据收集可能性划分对象的主要决定因素是它们的年龄—该模型假设对象的年龄与其预期寿命之间存在固有的相关性。
世代模型假设
与人类和动物世界相反,年轻。NET 对象预计会很快死亡,而旧的。NET 对象预期寿命更长。这两个假设迫使目标预期寿命分布在图 4-11 中的两个角上。
图 4-11 。对象预期寿命是对象年龄的函数,分为三个区域
注意“新”和“旧”的定义取决于应用引发的垃圾收集的频率。如果垃圾收集每分钟进行一次,则 5 秒钟前创建的对象将被视为年轻对象。在另一个系统中,它会被认为是旧的,因为该系统非常占用内存,每秒钟会导致几十次收集。然而,在大多数应用中,临时对象(例如,作为方法调用的一部分在本地分配的对象)往往很年轻就死去,而在应用初始化时分配的对象往往存活得更久。
在世代模型下,大多数新对象被期望表现出暂时的行为——为特定的、短暂的目的而分配,并在不久之后变成垃圾。另一方面,存活了很长时间的对象(例如,应用初始化时分配的单例对象或众所周知的对象)预计会存活更长时间。
不是每个应用都遵循世代模型强加的假设。很容易想象这样一个系统,其中临时对象在几次垃圾收集后仍然存在,然后变得不被引用,并创建更多的临时对象。这种现象,其中一个对象的预期寿命不在世代模型预测的范围内,被非正式地称为中年危机 。表现出这种现象的对象超过了世代模型提供的性能优化的好处。我们将在这一部分的后面考察中年危机。
。NET 实现 的几代
在分代模型中,垃圾收集堆被划分为三个区域:第 0 代、第 1 代和第 2 代。这些区域反映了它们所包含的对象的预期寿命:第 0 代包含最年轻的对象,第 2 代包含已经存活了一段时间的旧对象。
第 0 代
第 0 代是所有新对象的游乐场(在本节的后面,我们将看到对象也是按大小划分的,这使得这种说法只是部分正确)。它非常小,甚至不能容纳最小的应用的所有内存使用。第 0 代开始时的预算通常在 256 KB 到 4 MB 之间,如果需要,可能会略有增长。
注意除了操作系统位之外,L2 和三级高速缓存的大小也会影响第 0 代的大小,因为这一代的主要目标是包含短期内频繁访问和一起访问的对象。它还在运行时由垃圾收集器动态控制,并且可以在应用启动时由 CLR 宿主通过设置 GC 启动限制来控制。第 0 代和第 1 代的预算合计不能超过单个段的大小(稍后讨论)。
当新的分配请求由于已满而无法从第 0 代得到满足时,将在第 0 代内启动垃圾收集。在这个过程中,垃圾收集器只接触那些在标记和清除阶段属于第 0 代的对象。这是很难实现的,因为在根和代之间没有先验的相关性,并且总是存在第 0 代之外的对象引用第 0 代之内的对象的可能性。我们将很快研究这个困难。
第 0 代内的垃圾收集是一个非常廉价和高效的过程,原因有几个:
- 第 0 代非常小,因此它不包含很多对象。遍历这么少量的内存只需要很少的时间。在我们的一台测试机器上,在 2%的对象存活的情况下执行第 0 代收集大约需要 70 秒(微秒)。
- 缓存大小影响第 0 代的大小,这使得第 0 代中的所有对象更有可能在缓存中找到。正如我们将在第五章中看到的,遍历已经在缓存中的内存要比从主存中访问它或者从磁盘中调页快得多。
- 由于时间局部性,在第 0 代中分配的对象很可能引用了第 0 代中的其他对象。也有可能这些物体在空间上相互靠近。这使得在标记阶段遍历图形更有效,如果高速缓存未命中被捕获的话。
- 因为新对象预计会很快死亡,所以遇到的每个单独对象的收集可能性都非常高。这反过来意味着第 0 代中的大多数对象不必被触及,它们只是未使用的内存,可以回收供其他对象使用。这也意味着我们没有浪费时间来执行这种垃圾收集;大多数对象实际上是不被引用的,它们的内存可以重用。
- 当垃圾收集结束时,回收的内存将用于满足新的分配请求。因为它刚刚被遍历,所以它很可能在 CPU 缓存中,从而使分配和后续的对象访问稍微快一些。
正如我们所观察到的,当收集完成时,几乎所有的对象都将从第 0 代中消失。但是,由于各种原因,有些对象可能会保留下来:
- 应用可能表现不佳,并且执行临时对象的分配,这些临时对象在一次垃圾收集之后仍然存在。
- 应用处于初始化阶段,此时正在分配长期存在的对象。
- 该应用创建了一些临时的短期对象,这些对象在垃圾收集被触发时正好在使用中。
在第 0 级垃圾回收中幸存的对象不会被清除到第 0 级的开头。相反,他们被提升到第一代,以反映他们的预期寿命更长的事实。作为升级的一部分,它们被从第 0 代占用的内存区域复制到第 1 代占用的内存区域(见图 4-12 )。这个副本可能看起来很昂贵,但无论如何它都是清扫操作的一部分。此外,因为第 0 代中的收集效率因子非常高,所以与执行部分收集而不是完整收集所带来的性能提升相比,该副本的摊余成本应该可以忽略不计。
图 4-12 。垃圾收集完成后,第 0 代的活动(幸存)对象被提升到第 1 代
跨代移动锁定的对象
固定一个对象可以防止它被垃圾收集器移动。在分代模型中,它防止在代之间提升固定对象。这在较年轻的一代中尤其显著,例如第 0 代,因为第 0 代的大小非常小。在第 0 代中导致碎片的钉住对象比我们在引入第 0 代之前检查钉住对象可能造成的危害更大。幸运的是,CLR 能够使用以下技巧提升固定对象:如果第 0 代被固定对象严重碎片化,CLR 可以将第 0 代的整个空间声明为更高的第 0 代,并从将成为第 0 代的新内存区域分配新对象。这是通过改变短暂段来实现的,这将在本章后面讨论。
下面的代码演示了通过使用 GC,锁定的对象可以跨代移动。本章稍后讨论的 GetGeneration 方法:
static void Main(string[] args) {
byte[] bytes = new byte[128];
GCHandle gch = GCHandle.Alloc(bytes, GCHandleType.Pinned);
GC.Collect();
Console.WriteLine("Generation: " + GC.GetGeneration(bytes));
gch.Free();
GC.KeepAlive(bytes);
}
如果我们在垃圾收集之前检查 GC 堆,层代的排列如下所示:
Generation 0 starts at 0x02791030
Generation 1 starts at 0x02791018
Generation 2 starts at 0x02791000
如果我们在垃圾收集后检查 GC 堆,代将在同一个段内重新对齐,如下所示:
Generation 0 starts at 0x02795df8
Generation 1 starts at 0x02791018
Generation 2 starts at 0x02791000
该对象的地址(在本例中为 0x02791be0)没有发生变化,因为它是固定的,但是通过移动层代边界,CLR 保持了该对象在层代之间升级的假象。
第 1 代
第 1 代是第 0 代和第 2 代之间的缓冲。它包含在一次垃圾收集中幸存下来的对象。它比第 0 代稍大,但仍比整个可用内存空间小几个数量级。第 1 代的典型起始预算范围为 512 KB-4 MB。
当第 1 代变满时,在第 1 代中触发垃圾收集。这仍然是部分垃圾收集;垃圾收集器只标记和清除第 1 代中的对象。请注意,第 1 代中的收集的唯一自然触发器是第 0 代中的前一个收集,因为对象从第 0 代提升到第 1 代(手动引发垃圾收集是另一个触发器)。
第 1 代中的垃圾收集仍然是一个相对廉价的过程。执行一次收集最多需要几兆字节的内存。收集效率因子也仍然很高,因为到达第 1 代的大多数对象应该是临时的短命对象——在第 0 代中没有被回收的对象,但是不会比另一次垃圾收集更长寿。例如,带有终结器的短期对象保证会到达第 1 代。(我们将在本章后面讨论终结化。)
第 1 代的幸存对象被提升到第 2 代。这种提升反映了它们现在被认为是旧对象的事实。分代模型中的一个主要风险是临时对象会蔓延到第 2 代,并在不久后死亡;这就是中年危机。确保临时对象不会到达第二代是非常重要的。在这一节的后面,我们将研究中年危机现象的可怕影响,并探讨诊断和预防措施。
第二代
第二代(Generation 2)是那些至少经历了两次垃圾收集的对象的最终内存区域(对于大对象,我们将在后面看到)。在分代模型中,这些对象被认为是旧的,并且根据我们的假设,在不久的将来不应该有资格进行垃圾收集。
第二代在尺寸上没有人为的限制。它的大小可以扩展专用于操作系统进程的整个内存空间,即在 32 位系统上最多 2 GB 的内存,或者在 64 位系统上最多 8 TB 的内存。
如果系统上的每个应用都可以运行,直到内存空间耗尽,然后 GC 才会回收未使用的内存,那么分页效应会使系统陷入停顿。
当垃圾收集发生在第 2 代时,它是一个完整的垃圾收集。这是最昂贵的一种垃圾收集,可能需要最长的时间才能完成。在我们的一台测试机器上,对 100MB 的被引用对象执行一次完整的垃圾收集大约需要 30ms(毫秒)——比年轻一代的收集慢几个数量级。
此外,如果应用根据分代模型假设运行,第 2 代中的垃圾收集也可能表现出非常低的收集效率因子,因为第 2 代中的大多数对象将比多个垃圾收集周期更长寿。因此,第 2 代中的垃圾收集应该是一种罕见的情况—与年轻一代的部分收集相比,它非常慢,并且效率低下,因为大多数遍历的对象仍然被引用,几乎没有任何内存可以回收。
如果应用分配的所有临时对象都很快死亡,它们就没有机会在多次垃圾收集后存活下来并到达第 2 代。在这个乐观的场景中,第 2 代中没有收集,垃圾收集器对应用性能的影响降低了几个数量级。
通过小心地使用代,我们已经成功地解决了我们在前面几节中概述的关于天真的垃圾收集器的一个主要问题:根据对象的收集可能性对其进行分区。如果我们根据对象的当前寿命成功地预测了它们的预期寿命,我们就可以执行廉价的部分垃圾收集,并且很少求助于昂贵的完全收集。然而,另一个问题仍然没有解决,甚至恶化了:在扫描阶段复制大对象,这在 CPU 和内存工作方面可能非常昂贵。此外,在分代模型中,不清楚第 0 代如何包含 10,000,000 个整数的数组,这明显大于其大小。
大型对象堆
大对象堆 (LOH) 是为大对象保留的特殊区域。大对象是指占用超过 85KB 内存的对象。该阈值适用于对象本身,而不适用于以该对象为根的整个对象图的大小,因此包含 1,000 个字符串(每个字符串的大小为 100 个字符)的数组不是大对象,因为数组本身只包含对字符串的 4 字节或 8 字节引用,但是包含 50,000 个整数的数组是大对象。
大型对象直接从 LOH 分配,不经过第 0 代、第 1 代或第 2 代。这使得跨代提升他们的成本最小化,这意味着四处复制他们的记忆。然而,当垃圾收集发生在 LOH 中时,清扫阶段可能不得不四处复制对象,从而导致相同的性能损失。为了避免这种性能损失,大型对象堆中的对象不受标准扫描算法的约束。
垃圾收集器在收集 LOH 时采用不同的策略,而不是扫描大对象并四处复制它们。维护所有未使用的存储块的链表,并且可以从该列表中满足分配请求。这种策略与本章开始时讨论的空闲列表内存管理策略非常相似,并且具有相同的性能成本:分配成本(找到一个合适的空闲块,将空闲块分成几部分)、解除分配成本(将内存区域返回到空闲列表)和管理成本(将相邻块连接在一起)。然而,使用自由列表管理比在内存中复制大对象更便宜——这是一个典型的场景,其中为了获得更好的性能而牺牲了实现的纯度。
注意因为 LOH 中的对象不会移动,所以在获取大对象的内存地址时,似乎没有必要锁定。这是错误的,并且依赖于实现细节。您不能假设大型对象在其整个生命周期中都保持相同的内存位置,并且大型对象的阈值可能会在将来发生变化,而不会有任何通知!然而,从实践的角度来看,有理由认为固定大对象比固定小的年轻对象会产生更少的性能开销。事实上,在固定数组的情况下,通常建议分配一个大数组,将其固定在内存中,并从数组中分配块,而不是为每个需要固定的操作分配一个新的小数组。
当达到第 2 代中收集的阈值时,收集 LOH。类似地,当达到 LOH 中收集的阈值时,也收集第 2 代。因此,创建许多大型临时对象会导致与中年危机现象相同的问题——将执行完全收集来回收这些对象。大型对象堆中的碎片是另一个潜在的问题,因为对象之间的漏洞不会通过对堆进行清扫和碎片整理而自动消除。
LOH 模型意味着应用开发人员必须非常注意大内存分配,这通常接近手动内存管理。一个有效的策略是将大对象集中起来并重用它们,而不是将它们释放给 GC。维护池的成本可能小于执行完整收集的成本。另一种可能的方法(如果涉及相同类型的数组)是分配一个非常大的对象,并根据需要手动将其分成块(见图 4-13 )。
图 4-13 。分配一个大对象并手动将其分成块,通过 flyweight“窗口”对象向客户端公开
代间引用
在讨论世代模型时,我们忽略了一个重要的细节,它可能会损害模型的正确性和性能。回想一下,年轻一代的部分收集是廉价的,因为在收集期间只遍历年轻一代中的对象。GC 如何保证它只接触这些较年轻的对象?
考虑第 0 代收集期间的标记阶段。在标记阶段,GC 确定当前活动的根,并开始构建根引用的所有对象的图形。在这个过程中,我们希望丢弃任何不属于第 0 代的对象。然而,如果我们在构建了整个图之后丢弃它们,那么我们已经触及了所有被引用的对象,这使得标记阶段与完全收集一样昂贵。或者,我们可以在到达不在第 0 代的对象时停止遍历图。这种方法的风险是,我们将永远无法到达第 0 代的对象,这些对象只被更高代的对象引用,如图 4-14 所示!
图 4-14 。如果在标记阶段,一旦到达更高代中的对象,我们就停止跟踪引用,则可能会错过代之间的引用
这个问题似乎需要在正确性和性能之间进行折衷。当老一代的对象引用年轻一代的对象时,我们可以通过获得特定场景的先验知识来解决这个问题。如果 GC 在执行标记阶段之前有这样的知识,它可以在构建图时将这些旧对象添加到根集中。这将使 GC 在遇到不属于第 0 代的对象时停止遍历图。
这种先验知识可以在 JIT 编译器的帮助下获得。来自老一代的对象引用来自年轻一代的对象的场景可能只出现在一类语句中:对引用类型的实例字段的非空引用类型赋值(或数组元素写)。
class Customer {
public Order LastOrder { get; set; }
}
class Order { }
class Program {
static void Main(string[] args) {
Customer customer = new Customer();
GC.Collect();
GC.Collect();
//customer is now in generation 2
customer.LastOrder = new Order();
}
}
当 JIT 编译这种形式的语句时,它发出一个写屏障,在运行时拦截引用写,并将辅助信息记录在一个名为卡表 的数据结构中。写屏障是一个轻量级 CLR 函数,它检查被分配的对象是否属于比第 0 代更老的一代。如果是这种情况,它更新卡表中对应于分配目标周围 1024 字节地址范围的一个字节(见图 4-15 )。
图 4-15 。对参考字段的赋值通过写屏障,写屏障更新卡表中的相关位,匹配参考被更新的区域
使用调试器跟踪写屏障代码相当容易。首先,Main 中的实际赋值语句由 JIT 编译器编译成如下代码:
; ESI contains a pointer to 'customer', ESI+4 is 'LastOrder', EAX is 'new Order()'
lea edx, [esi+4]
call clr!JIT_WriteBarrierEAX
在写屏障内,进行检查以查看被分配的对象是否具有低于第 1 代(即,在第 1 代或第 2 代中)的起始地址的地址。如果是这种情况,则通过将 0xFF 分配给偏移量处的字节来更新卡表,偏移量是通过将对象的地址向右移动 10 位获得的。(如果该字节之前被设置为 0xFF,则不会再次被设置,以防止其他处理器高速缓存无效;更多详情见第六章。)
mov dword ptr [edx], eax ; the actual write
cmp eax, 0x272237C ; start address of generation 1
jb NoNeedToUpdate
shr edx, 0xA ; shift 10 bits to the right
cmp byte ptr [edx+0x48639C], 0xFF ; 0x48639C is the start of the card table
jne NeedUpdate
NoNeedToUpdate:
ret
NeedUpdate:
mov byte ptr [edx+0x48639C], 0xFF ;update the card table
ret
垃圾收集器在执行标记阶段时使用这些辅助信息。它检查卡表,以查看在收集年轻一代时哪些地址范围必须被视为根。收集器遍历这些地址范围内的对象,并定位它们对年轻一代中对象的引用。这实现了上面提到的性能优化,其中收集器可以在遇到不属于第 0 代的对象时停止遍历图形。
卡表可能会增长到为堆中的每 KB 内存占用一个字节。这似乎浪费了 0.1%的空间,但是在收集年轻一代时提供了很大的性能提升。在 card 表中为每个单独的对象引用使用一个条目会更快(GC 只需要考虑那个特定的对象,而不是 1024 字节的范围),但是在运行时为每个对象引用存储额外的信息是负担不起的。现有的卡表方法完美地反映了存储空间和执行时间之间的这种折衷。
注意虽然这样的微优化很少是值得的,但是我们可以主动最小化与更新和遍历卡表相关的成本。一种可能的方法是池化对象并重用它们,而不是创建它们。这将最大限度地减少垃圾收集。另一种方法是尽可能使用值类型,并尽量减少图中引用的数量。值类型赋值不需要写屏障,因为堆上的值类型总是某个引用类型的一部分(或者,在装箱的形式中,它只是自身的一部分)。
后台 GC
CLR 1.0 中引入的工作站并发 GC 风格有一个主要缺点。虽然在第 2 代并发 GC 应用线程被允许继续分配,但是它们只能分配适合第 0 代和第 1 代的内存。一旦达到这个限制,应用线程必须阻塞并等待收集完成。
CLR 4.0 中引入的后台垃圾收集(GC)使 CLR 能够在第 0 代和第 1 代中执行垃圾收集,即使完全收集正在进行。为此,CLR 创建了两个 GC 线程:一个前台 GC 线程和一个后台 GC 线程。后台 GC 线程在后台执行第 2 代收集,并定期检查在第 0 代和第 1 代中执行快速收集的请求。当一个请求到达时(因为应用已经耗尽了年轻的代),后台 GC 线程挂起自己,并将执行交给前台 GC 线程,前台 GC 线程执行快速收集并释放应用线程。
在 CLR 4.0 中,后台 GC 是为任何使用并发工作站 GC 的应用自动提供的。没有办法选择退出后台 GC 或者将后台 GC 与其他 GC 风格一起使用。
在 CLR 4.5 中,后台 GC 扩展到了服务器 GC 风格。此外,服务器 GC 也进行了改进,以支持并发收集。当在使用 N 个逻辑处理器的进程中使用并发服务器 GC 时,CLR 会创建 N 个前台 GC 线程和 N 个后台 GC 线程。后台 GC 线程负责第 2 代收集,并允许应用代码在前台并发执行。如果 CLR 认为合适,只要需要执行阻塞收集,执行压缩(后台 GC 线程不压缩),或者在后台线程上的完整收集过程中在年轻代中执行收集,就会调用前台 GC 线程。总而言之,从 CLR 4.5 开始,有四种不同的 GC 风格可供您使用应用配置文件进行选择:
- 并发工作站 GC——默认风格;有后台 GC。
- 非并发工作站 GC—没有后台 GC。
- 非并发服务器 GC—没有后台 GC。
- 并发服务器 GC—具有后台 GC。
GC 段和虚拟内存
在我们对基本 GC 模型和分代 GC 模型的讨论中,我们反复假设. NET 进程占用了其宿主进程的全部可用内存空间,并将这些内存用作垃圾收集堆的后备存储。考虑到托管应用在不使用非托管代码的情况下无法独立生存的事实,这种假设显然是错误的。CLR 本身是在非托管代码中实现的。NET 基础类库(BCL) 往往包装 Win32 和 COM 接口,用非托管代码开发的自定义组件可以加载到原本“托管 的进程中。
注意即使托管代码可以完全隔离,垃圾收集器立即提交整个可用地址空间也是没有意义的。尽管在不使用内存的情况下提交内存并不意味着立即需要该内存的后备存储(RAM 或磁盘空间),但这不是免费的操作。通常建议只分配比您可能需要的内存量稍微多一点的内存。正如我们将在后面看到的,CLR 预先保留了大量的内存区域,但只在必要时才提交它们,并强调将未使用的内存返回给 Windows。
鉴于上述情况,我们必须细化垃圾收集器与虚拟内存管理器的交互。在一个进程内的 CLR 启动期间,从虚拟内存中分配了两个称为 GC 段的内存块(更准确地说,是请求 CLR 主机提供这个内存块)。第一段用于第 0 代、第 1 代和第 2 代(称为短暂段 )。第二段用于大对象堆。如果在 CLR 主机下运行,段的大小取决于 GC 风格和 GC 启动限制。在带有工作站 GC 的 32 位系统上,典型的段大小是 16MB,而对于服务器 GC,它在 16-64MB 范围内。在 64 位系统上,CLR 对服务器 GC 使用 128MB-2 GB 段,对工作站 GC 使用 128MB-256MB 段。(CLR 不会一次提交整个段;它只保留地址范围,并在需要时提交部分数据段。)
当段变满并且更多的分配请求到达时,CLR 分配另一个段。在任何给定时间,只有一个段可以包含层代 0 和层代 1。然而,它不必是相同的段!我们之前已经观察到,由于这些小内存区域中的碎片效应,将对象长时间固定在第 0 代或第 1 代中是特别危险的。CLR 通过将另一个段声明为临时段来处理 这些问题,这有效地将先前在年轻代中的对象直接提升到第 2 代(因为只能有一个临时段)。
图 4-16。GC 段占用了进程的虚拟地址空间。包含年轻世代的段被称为短暂段
当某个段由于垃圾回收而变空时,CLR 通常会释放该段的内存并将其返回给操作系统。这是大多数应用所希望的行为,尤其是内存使用量不经常出现大峰值的应用。但是,可以指示 CLR 将空段保留在备用列表中,而不将其返回给操作系统。这种行为被称为段囤积或 VM 囤积 ,可以通过 CLR 托管用 CorBindToRuntimeEx 函数的启动标志启用。段囤积可以提高频繁分配和释放段的应用的性能(内存使用频繁的内存密集型应用),并减少由于虚拟内存碎片导致的内存不足异常(稍后将讨论)。默认情况下,它由 ASP.NET 应用使用。自定义 CLR 宿主可以通过使用 IHostMemoryManager 接口来满足来自内存池或任何其他来源的段分配请求,从而进一步自定义此行为。
管理内存空间的分段模型引入了一个极其严重的问题,这个问题与外部(虚拟内存)碎片 有关。因为段在为空时会返回给操作系统,所以非托管分配可能发生在曾经是 GC 段的内存区域的中间。这种分配会导致碎片,因为段在内存中必须是连续的。
虚拟内存碎片最常见的原因是后期加载的动态程序集(如 XML 序列化程序集或 ASP.NET 调试编译的页面),动态加载的 COM 对象和非托管代码 执行分散的内存分配。虚拟内存碎片可能会导致内存不足的情况,即使感知到的进程内存使用量远未达到 2 GB 的限制。长时间运行的进程(如 web 服务器)执行内存密集型工作,内存分配频繁出现峰值,在执行几个小时、几天或几周后往往会表现出这种行为。在非关键场景中,故障转移是可用的(比如负载平衡的服务器群),这通常通过进程回收来解决。
注意从理论上讲,如果段大小为 64MB,虚拟内存分配粒度为 4KB,32 位进程地址空间为 2GB(其中只有 32 个段的空间),则可能只分配 4KB × 32 = 128KB 的非托管内存,但仍然会将地址空间分段,从而无法分配一个连续的段!
Sysinternals VMMap 实用程序可以非常容易地诊断内存碎片情况。它不仅可以精确地报告哪个内存区域用于什么目的,它还有一个碎片视图选项,可以显示地址空间的图片,并使碎片问题可视化变得容易。图 4-17 显示了一个地址空间快照的例子,其中有将近 500MB 的空闲空间,但是没有一个单独的空闲片段大到足以容纳一个 16MB 的 GC 段。在截图中,白色区域是空闲内存 区域。
图 4-17 。严重碎片化的地址空间快照。几乎有 500MB 的可用内存,但是没有足够大的块来容纳一个 GC 段
使用 VMMAP 进行实验
您可以在一个示例应用中尝试使用 VMMap ,看看它如何快速地为您指出正确的方向。具体来说,VMMap 使得确定您遇到的内存问题是源自托管堆还是其他地方变得非常容易,并且有助于诊断碎片问题。
- 从微软 TechNet(
TechNet . Microsoft . com/en-us/sys internals/DD 535533 . aspx
)下载 VMMap,并将其存储在您的机器上。 - 从本章的示例代码文件夹中运行 OOM2.exe 应用。应用很快耗尽所有可用内存,并因异常而崩溃。当 Windows 错误报告对话框出现时,不要关闭它—保持应用运行。
- 运行 VMMap 并选择要监视的 OOM2.exe 进程。注意可用内存的数量(汇总表中的“空闲”行)。打开碎片视图(从视图菜单中),直观地检查地址空间。检查“空闲”类型内存块的详细列表,查看应用地址空间中最大的空闲块。
- 正如您所看到的,应用没有耗尽所有的虚拟地址空间,但是没有足够的空间来分配新的 GC 段——最大的可用空闲块小于 16 MB。
- 对本章示例代码文件夹中的 OOM3.exe 应用重复步骤 2 和 3。内存耗尽稍微慢一些,并且由于不同的原因而发生。
每当您在 Windows 应用中遇到与内存相关的问题时,您应该随时准备好 VMMap。它可以指出托管内存泄漏、堆碎片、过多的程序集加载以及许多其他问题。它甚至可以分析非托管分配,帮助检测内存泄漏:参见 Sasha Goldshtein 关于 VMMap 分配分析的博客文章(blog . sashag . net/archive/2011/08/27/VM map-allocation-profiling-and-leak-detection . aspx
)。
解决虚拟内存碎片问题有两种方法:
- 减少动态程序集的数量,减少或集中非托管内存分配,集中托管内存分配或囤积 GC 段。这类方法通常会延长问题再次出现的时间,但不会完全消除问题。
- 切换到 64 位操作系统并在 64 位进程中运行代码。一个 64 位进程有 8TB 的地址空间,这实际上完全消除了这个问题。因为这个问题与可用的物理内存量无关,而是与虚拟地址空间量密切相关,所以不管物理内存量有多少,切换到 64 位就足够了。
对分段 GC 模型的研究到此结束,该模型定义了托管内存与其底层虚拟内存存储之间的交互。大多数应用永远不需要定制这种交互;如果您在上面概述的特定场景中被迫这样做,CLR 宿主提供了最完整和可定制的解决方案。
最终确定
到目前为止,这一章已经足够详细地讨论了管理一类资源的细节,即托管内存。然而,在现实世界中,存在许多其他类型的资源,它们可以统称为非托管资源 ,因为它们不受 CLR 或垃圾收集器的管理(如内核对象句柄、数据库连接、非托管内存等)。).它们的分配和释放不受 GC 规则的控制,当涉及到它们时,上面概述的标准内存回收技术是不够的。
释放非托管资源需要一个叫做终结的附加特性,它将一个对象(代表一个非托管资源)与不再需要该对象时必须执行的代码相关联。通常,当资源符合释放条件时,应该以确定的方式执行这段代码;在其他时候,它可以被延迟到稍后的非确定性时间点。
手动确定性终结
考虑一个虚构的文件类,它充当 Win32 文件句柄的包装。该类有一个 System 类型的成员字段。持有句柄本身的 IntPtr。当不再需要该文件时,必须调用 CloseHandle Win32 API 来关闭句柄并释放基础资源。
确定性终结方法需要向 File 类添加一个关闭底层句柄的方法。然后,调用此方法是客户端的责任,即使面对异常也是如此,以便确定性地关闭句柄并释放非托管资源。
class File {
private IntPtr handle;
public File(string fileName) {
handle = CreateFile(...); //P/Invoke call to Win32 CreateFile API
}
public void Close() {
CloseHandle(handle); //P/Invoke call to Win32 CloseHandle API
}
}
这种方法非常简单,并且在非托管环境(如 C++)中被证明是有效的,在这种环境中,释放资源是客户端的责任。然而,。习惯于自动资源回收实践的. NET 开发人员可能会发现这种模式不方便。CLR 应该为非托管资源的自动终结提供一种机制。
自动非确定性终结
自动机制 不能是确定性的,因为它必须依赖垃圾收集器来发现对象是否被引用。反过来,GC 的不确定性意味着终结也是不确定的。有时,这种不确定的行为是一种阻碍,因为临时的“资源泄漏”或者将共享资源锁定的时间比需要的时间稍长都可能是不可接受的行为。在其他时候,这是可以接受的,我们试图专注于它所在的场景。
任何类型都可以重写由系统定义的受保护的 Finalize 方法。对象指示它需要自动终结。在 File 类上请求自动终结的 C# 语法是 File 方法。这个方法被称为终结器,当对象被销毁时,它必须被调用。
注意顺便说一句,在 C# 中只有引用类型(类)可以定义终结器,尽管 CLR 没有这种限制。然而,通常只有引用类型定义一个终结行为才有意义,因为值类型只有在被装箱时才有资格进行垃圾收集(关于装箱的详细处理,参见第三章)。当在堆栈上分配值类型时,它永远不会添加到终结队列中。当堆栈作为从方法返回或由于异常而终止帧的一部分展开时,不会调用值类型终结器。
当一个具有终结器的对象被创建时,对它的引用被添加到一个特殊的运行时管理的队列中,这个队列叫做终结队列 。垃圾收集器将该队列视为根队列,这意味着即使应用没有对该对象的未完成引用,它仍然由终结队列保持活动。
当应用不再引用该对象并且发生垃圾回收时,GC 检测到对该对象的唯一引用是来自终结队列的引用。因此,GC 将对象引用移动到另一个运行时管理的队列中,这个队列叫做 f-reachable 队列。这个队列也被认为是一个根,所以此时这个对象仍然被引用并被认为是活动的。
垃圾回收期间不运行对象的终结器。相反,在 CLR 初始化期间会创建一个名为终结器线程 的特殊线程(不管 GC 风格如何,每个进程都有一个终结线程,但它运行在 THREAD_PRIORITY_HIGHEST)。该线程反复等待完成事件?? 被终止。如果对象被移动到 f-reachable 队列,则垃圾回收完成后,GC 会发出此事件的信号,结果是终结器线程被唤醒。终结器线程从 f-reachable 队列中移除对象引用,并同步执行由对象定义的终结器方法。当下一次垃圾回收发生时,对象不再被引用,因此 GC 可以回收它的内存。图 4-18 包含了所有的运动部件:
图 4-18 。终结队列保存对具有终结器的对象的引用。当应用停止引用它们时,GC 将对象引用移动到 f-reachable 队列。终结器线程唤醒并执行这些对象上的终结器,然后释放它们
为什么 GC 不执行对象的终结器,而是将工作委托给异步线程?这样做的动机是在没有 f-reachable 队列或额外的终结器线程的情况下关闭循环,这看起来更便宜。然而,与在 GC 期间运行终结器相关的主要风险是,终结器(本质上是用户定义的)可能需要很长时间才能完成,从而阻塞垃圾收集进程,进而阻塞所有应用线程。此外,处理发生在 GC 中间的异常并不简单,处理终结器可能在 GC 中间触发的内存分配也不简单。总之,由于可靠性的原因,GC 不执行终结代码,而是将该处理委托给一个特殊的专用线程。
非确定性终结的陷阱
刚刚描述的终结模型带来了一系列性能损失。其中一些无关紧要,但另一些则需要重新考虑终结是否适用于您的资源 。
- 具有终结器的对象保证至少到达第 1 代,这使它们更容易受到中年危机现象的影响。这增加了执行多次完全收集的机会。
- 具有终结器的对象的分配成本稍高,因为它们被添加到终结队列中。这在多处理器场景中引入了争用。一般来说,与其他问题相比,这个成本可以忽略不计。
- 终结器线程上的压力(许多对象需要终结)可能会导致内存泄漏。如果应用线程分配对象的速率高于终结器线程能够完成的速率,那么应用将不断地从等待完成的对象中泄漏内存。
下面的代码演示了一个应用线程,由于阻止代码在终结器中执行,该应用线程分配对象的速率高于它们被终结的速率。这会导致持续的内存泄漏。
class File2 {
public File2() {
Thread.Sleep(10);
}
∼File2() {
Thread.Sleep(20);
}
//Data to leak:
private byte[] data = new byte[1024];
}
class Program {
static void Main(string[] args) {
while (true) {
File2 f = new File2();
}
}
}
尝试与终结相关的泄漏
在这个实验中,您将运行一个显示内存泄漏的示例应用,并在查看源代码之前执行部分诊断。没有完全暴露问题,泄漏与终结的不正确使用有关,本质上类似于 File2 类的代码清单。
- 从本章的源代码文件夹中运行 MemoryLeak.exe 应用。
- 运行性能监视器,并从监视下列性能计数器。此应用的. NET CLR 内存类别(有关性能监视器以及如何运行它的更多信息,请参考第二章):所有堆中的字节数、# Gen 0 集合、# Gen 1 集合、# Gen 2 集合、% GC 时间、每秒分配的字节数、终止幸存者、提升的终止-来自 Gen 0 的内存。
- 监视这些计数器几分钟,直到模式出现。例如,您应该看到所有堆中的# Bytes 计数器逐渐上升,尽管它有时会略微下降。总的来说,应用的内存使用量在上升,这表明可能存在内存泄漏。
- 请注意,应用以 1MB/s 的平均速率分配内存。这不是一个很高的分配速率,实际上 GC 中的时间比例非常低,这不是垃圾收集器努力跟上应用的情况。
- 最后,请注意,无论何时更新终结存活计数器,它都非常高。此计数器表示在最近一次垃圾回收中幸存的对象数,这些对象只是因为注册了终结,并且它们的终结器尚未运行(换句话说,这些对象的根是 f-reachable 队列)。Promoted Finalization-Memory from Gen 0 计数器指向由这些对象保留的大量内存。
将这些信息加在一起,您可以推断出应用很可能正在泄漏内存,因为它给终结器线程带来了无法承受的压力。例如,它创建(和释放)可终结资源的速度可能快于终结器线程清理它们的速度。现在您可以检查应用的源代码(使用。NET Reflector、ILSpy 或任何其他用于此目的的反编译器),并验证泄漏源与终结化相关,特别是 Employee 和 Schedule 类。
除了性能问题之外,使用自动非确定性终结也是错误的来源,这些错误往往非常难以发现和解决。这些错误的发生是因为终结根据定义是异步的,并且因为多个对象之间的终结顺序是未定义的。
考虑一个可终结对象 A ,它保存了对另一个可终结对象 B 的引用。因为终结的顺序是未定义的, A 不能假设当它的终结器被调用时, B 是有效的——它的终结器可能已经执行了。例如,系统的实例。StreamWriter 类可以保存对系统实例的引用。IO.FileStream 类。两个实例都有可终结的资源:流编写器包含一个必须刷新到基础流的缓冲区,文件流有一个必须关闭的文件句柄。如果流编写器首先完成,它将把缓冲区刷新到有效的基础流,当文件流完成时,它将关闭文件句柄。但是,由于终结顺序未定义,可能会发生相反的情况:文件流首先被终结并关闭文件句柄,当流编写器被终结时,它将缓冲区刷新到先前关闭的无效文件流。这是一个无法解决的问题。NET Framework 的一个缺点是 StreamWriter 不定义终结器,只依赖确定性终结。如果客户端忘记关闭流编写器,其内部缓冲区就会丢失。
提示It如果其中一个资源来自系统,资源对可以在它们之间定义终结顺序。runtime . constrainedexecution . criticalfinalizeobject 抽象类,它将其终结器定义为一个关键终结器。这个特殊的基类保证了它的终结器将在所有其他非关键终结器被调用之后被调用。它由资源对(如 System)使用。微软的 IO.FileStream。win32 . safe handles . safe file handle 和系统。使用 Microsoft . win32 . safe handles . safe wait handle。
*另一个问题与发生在专用线程中的终结的异步特性有关。终结器可能试图获取应用代码持有的锁,应用可能通过调用 GC 来等待终结完成。WaitForPendingFinalizers()。解决这个问题的唯一方法是超时获取锁,如果无法获取,则正常失败。
另一种情况是垃圾收集器急于尽快回收内存。考虑下面的代码,它代表了一个带有关闭文件句柄的终结器的 File 类的简单实现:
class File3 {
Handle handle;
public File3(string filename) {
handle = new Handle(filename);
}
public byte[] Read(int bytes) {
return Util.InternalRead(handle, bytes);
}
∼File3() {
handle.Close();
}
}
class Program {
static void Main() {
File3 file = new File3("File.txt");
byte[] data = file.Read(100);
Console.WriteLine(Encoding.ASCII.GetString(data));
}
}
这段无害的代码可能会以非常恶劣的方式被破坏。Read 方法可能需要很长时间才能完成,它只使用包含在对象中的句柄,而不使用对象本身。用于确定局部变量何时被认为是活动根的规则规定,在已经分派读取调用之后,由客户端持有的局部变量不再相关。因此,该对象被视为符合垃圾回收条件,其终结器可能会在 Read 方法返回之前执行!如果发生这种情况,我们可能会在使用句柄时关闭它,或者在使用之前关闭它。
终结器可能永远不会被调用
尽管终结通常被认为是保证资源释放的防弹特性 ,CLR 实际上并不保证在任何可能的情况下都会调用终结器。
一个明显的不会发生终结的场景是一个残酷的流程关闭。如果用户通过任务管理器关闭进程,或者应用调用 TerminateProcess Win32 API,终结器就没有机会回收资源。因此,盲目依赖终结器来清理跨进程边界的资源(如删除磁盘上的文件或将特定数据写入数据库)是不正确的。
当应用遇到内存不足的情况并且即将关闭时,情况就不那么明显了。通常,我们希望终结器即使在遇到异常时也能运行,但是如果某个类的终结器从未被调用过,并且必须被 JIT 化,那该怎么办呢?JIT 需要内存分配来编译终结器,但是没有可用的内存。这可以通过使用来解决。NET 预编译(NGEN)或从 CriticalFinalizerObject 派生,这保证了在加载类型时终结器将被急切地 JIT 化。
最后,CLR 对作为进程关闭或 AppDomain 卸载方案的一部分运行的终结器施加了时间限制。在这些情况下(可以通过环境检测到。HasShutdownStarted 或 AppDomain。IsFinalizingForUnload()),每个终结器有(大约)两秒钟的时间来完成其执行,所有终结器加起来有(大约)40 秒的时间来完成其执行。如果违反了这些时间限制,终结器可能不会执行。这种情况可以在运行时使用 BreakOnFinalizeTimeout 注册表值进行诊断。详见 Sasha Goldshtein 的博文《调试关机终结超时》(blog . sashag . net/archive/2008/08/27/Debugging-shut down-Finalization-time out . aspx
,2008)。
处置模式
我们已经讨论了非确定性终结实现的多个问题和限制。现在应该重新考虑前面提到的替代方法——确定性终结。
确定性终结的主要问题是客户端负责正确使用对象。这与面向对象的范式相矛盾,在面向对象的范式中,对象对自己的状态和不变量负责。这个问题无法完全解决,因为自动终结总是不确定的。但是,我们可以引入一种契约机制,努力确保确定性终结的发生,这将使客户端更容易使用它。在特殊情况下,我们将不得不提供自动终结,尽管前面提到了所有的成本。
由。NET Framework 规定需要确定性终结的对象必须用一个 Dispose 方法实现 IDisposable 接口。此方法应执行确定性终止来释放非托管资源。
实现 IDisposable 接口的对象的客户端负责在使用完 Dispose 后调用它。在 C# 中,这可以通过 using 块来实现,该块在 try 中包装对象用法...finally 块并在 finally 块内调用 Dispose。
如果我们完全信任我们的客户,这种合同模式是足够公平的。然而,我们通常不能相信我们的客户端会确定性地调用 Dispose,而必须提供备份行为来防止资源泄漏。这可以使用自动终结来完成,但是带来了一个新的问题:如果客户端调用 Dispose,然后调用终结器,我们将释放资源两次。此外,实现确定性终结的想法是为了避免自动终结的缺陷!
我们需要的是一种机制,用于指示垃圾收集器非托管资源已经被释放,并且特定对象不再需要自动终结。这可以使用 GC 来完成。SuppressFinalize 方法,通过在对象的头字中设置一个位来禁用终结(关于对象头的更多细节,参见第三章)。该对象仍保留在终结队列中,但不会产生大部分终结开销,因为该对象的内存在第一次收集后会立即被回收,并且它永远不会被终结器线程看到。
最后,我们可能希望有一种机制在调用终结器时提醒我们的客户,因为这意味着他们没有使用(更高效、更可预测和更可靠的)确定性终结机制。这可以使用系统来完成。Diagnostics.Debug.Assert 或某种日志框架。
下面的代码是一个类的粗略草稿,该类包装了一个遵循这些准则的非托管资源(如果该类是从另一个也管理非托管资源的类派生的,则需要考虑更多的细节):
class File3 : IDisposable {
Handle handle;
public File3(string filename) {
handle = new Handle(filename);
}
public byte[] Read(int bytes) {
Util.InternalRead(handle, bytes);
}
∼File3() {
Debug.Assert(false, "Do not rely on finalization! Use Dispose!");
handle.Close();
}
public void Dispose() {
handle.Close();
GC.SuppressFinalize(this);
}
}
注意本节中描述的终结模式被称为 Dispose 模式 ,它涵盖了其他领域,比如需要终结的派生类和基类之间的交互。有关 Dispose 模式的更多信息,请参考 MSDN 文档。顺便提一下,C++/CLI 将 Dispose 模式作为其本机语法的一部分来实现:!File 是 C++/CLI 终结器,File 是 C++/CLI IDisposable。处置实现。调用基类和确保终结被抑制的细节由编译器自动处理。
确保 Dispose 模式的实现是正确的,并不像确保使用您的类的客户端将使用确定性终结而不是自动终结那样困难。前面概述的断言方法是一个有效的强力选项。或者,可以使用静态代码分析来检测对可支配资源的不当使用。
复活
终结为对象提供了在应用不再引用它之后执行任意代码的机会。这个机会可以用来创建一个从应用到对象的新引用,在对象被认为是死的之后恢复它。这个能力叫做复活。
复活在少数情况下很有用,应该小心使用。主要风险是您的对象引用的其他对象可能具有无效状态,因为它们的终结器可能已经运行。如果不重新初始化您的对象引用的所有对象,就无法解决此问题。另一个问题是,除非使用模糊的 GC,否则对象的终结器不会再次运行。RegisterForFinalize 方法,将对您的对象(通常是这个)的引用作为参数传递。
使用复活的一个适用场景是对象池。对象池意味着从池中分配对象,并在不再使用时将其返回到池中,而不是进行垃圾收集和重新创建。将对象返回到池中可以确定性地执行,也可以延迟到终结时执行,这是使用复活的典型场景。
弱引用
弱引用是处理对托管对象的引用的一种补充机制。典型的对象引用(也称为强引用 )是非常确定的:只要你有一个对对象的引用,对象就会保持活动状态。这是垃圾收集器的正确性承诺。
然而,在某些情况下,我们希望将一个不可见的字符串附加到一个对象上,而不影响垃圾收集器回收该对象内存的能力。如果 GC 回收了内存,我们的字符串就变得独立,我们可以检测到这一点。如果 GC 还没有接触到对象,我们可以取出字符串并检索一个对对象的强引用来再次使用它。
该功能适用于各种场景 ,其中最常见的有:
- 提供外部服务而不保持对象存活。诸如定时器和事件之类的服务可以提供给对象,而不需要保持对它们的引用,这可以解决许多典型的内存泄漏问题。
- 自动管理缓存或池策略。缓存可以保留对最近最少使用的对象的弱引用,而不会阻止它们被收集;可以将池划分为包含强引用的最小大小和包含弱引用的可选大小。
- 保存一个大的物体,希望它不会被收集。应用可以保存一个对大型对象的弱引用,该对象需要很长时间来创建和初始化。对象可能被收集,在这种情况下,应用将重新初始化它。否则可以在下次需要的时候使用。
弱引用通过系统暴露给应用代码 。WeakReference 类,这是系统的一个特例。runtime . interop services . GC handle 类型。弱引用具有 IsAlive 布尔属性,该属性指示基础对象是否尚未被收集,还具有可用于检索基础对象的 Target 属性(如果已被收集,则返回 null)。
注意注意,获得对弱引用目标的强引用的唯一安全方法是使用 target 属性。如果 IsAlive 属性返回 true,则可能紧接着该对象将被收集。为了防止这种竞争情况,必须使用 Target 属性,将返回值赋给强引用(局部变量、字段等)。)然后检查返回值是否为 null。当您只对对象死亡的情况感兴趣时,请使用 IsAlive 属性;例如,从高速缓存中移除弱引用。
以下代码显示了基于弱引用的事件的实现草案(见图 4-19 )。事件本身不能直接使用. NET 委托,因为委托对其目标有强引用,这是不可定制的。但是,它可以存储委托的目标(作为弱引用)及其方法。这防止了一个最常见的。网络内存泄漏——忘记从事件中注销!
public class Button {
private class WeakDelegate {
public WeakReference Target;
public MethodInfo Method;
}
private List<WeakDelegate> clickSubscribers = new List<WeakDelegate>();
public event EventHandler Click {
add {
clickSubscribers.Add(new WeakDelegate {
Target = new WeakReference(value.Target),
Method = value.Method
});
}
remove {
//...Implementation omitted for brevity
}
}
public void FireClick() {
List<WeakDelegate> toRemove = new List<WeakDelegate>();
foreach (WeakDelegate subscriber in clickSubscribers) {
object target = subscriber.Target.Target;
if (target == null) {
toRemove.Add(subscriber);
} else {
subscriber.Method.Invoke(target, new object[] { this, EventArgs.Empty });
}
}
clickSubscribers.RemoveAll(toRemove);
}
}
图 4-19 。弱事件对每个订阅者都有弱引用。如果应用无法到达订户,弱事件可以检测到这一点,因为弱引用被清空了
默认情况下,弱引用不跟踪对象复活。若要启用复活跟踪,请使用重载的构造函数,该构造函数接受一个布尔参数并传递 true 以指示应该跟踪复活。追踪复活的弱引用称为长弱引用;不跟踪复活的弱引用称为短弱引用 。
GC 句柄
弱引用是 GC 句柄的特例。GC 句柄 是一种特殊的低级值类型,可以为引用对象提供多种便利:
- 保持对对象的标准(强)引用,防止它被收集。这由 GCHandleType 表示。正常枚举值。
- 保持对对象的短弱引用。这由 GCHandleType 表示。弱枚举值。
- 保持对对象的长弱引用。这由 GCHandleType 表示。WeakTrackResurrection 枚举值。
- 保持对一个对象的引用,锁定它使它不能在内存中移动,并在必要时获取它的地址。这由 GCHandleType 表示。固定枚举值。
很少需要直接使用 GC 句柄,但是它们经常作为另一种可以保留托管对象的根出现在分析结果中。
与垃圾收集器交互
到目前为止,我们将我们的应用视为 GC 故事中的被动参与者。我们深入研究了垃圾收集器的实现,并回顾了重要的优化,所有这些都是自动执行的,几乎不需要我们参与。在这一节中,我们将研究与垃圾收集器进行主动交互的可用方法,以便调整应用的性能,并接收否则无法获得的诊断信息。
系统。GC 类
系统。GC 类是与。来自托管代码的. NET 垃圾收集器。它包含一组方法,这些方法控制垃圾收集器的行为,并获得关于其目前工作的诊断信息。
诊断方法
系统的诊断方法。GC 类提供关于垃圾收集器工作的信息。它们旨在用于诊断或调试场景;不要依赖他们在正常运行时做出决定。这些诊断方法返回的信息集可以作为性能计数器在下使用。NET CLR 内存性能类别。
- GC。CollectionCount 方法返回自应用启动以来指定代的垃圾回收次数。它可用于确定指定代的收集是否发生在两个时间点之间。
- GC。GetTotalMemory 方法返回垃圾回收堆消耗的总内存,以字节为单位。如果其布尔参数为 true,则在返回结果之前执行完整的垃圾收集,以确保只考虑无法回收的内存。
- GC。GetGeneration 方法返回特定对象所属的层代。请注意,在当前的 CLR 实现下,当垃圾回收发生时,不能保证对象在各代之间得到提升。
通知
引进于。NET 3.5 SP1 中,GC notifications API 为应用提供了一个提前了解即将进行全面垃圾收集的机会。该 API 只有在使用非并发 GC 时才可用,并且面向那些 GC 暂停时间长,并且希望在感觉到即将暂停时重新分配工作或通知其执行环境的应用。
首先,对 GC 通知感兴趣的应用调用 GC。RegisterForFullGCNotification 方法,并向其传递两个阈值(1 到 99 之间的数字)。这些阈值根据第 2 代中的阈值和大对象堆来指示应用希望多早得到通知。简而言之,较大的阈值可以确保您收到通知,但实际的收集可能会延迟一段时间,而较小的阈值则有可能根本收不到通知,因为收集太接近通知触发器。
接下来,应用使用 GC。WaitForFullGCApproach 方法同步阻塞,直到通知发生,为 GC 做好所有准备,然后调用 GC。WaitForFullGCComplete 同步阻止,直到 GC 完成。因为这些 API 是同步的,所以您可能希望在后台线程上调用它们,并向主处理代码引发事件,如下所示:
public class GCWatcher {
private Thread watcherThread;
public event EventHandler GCApproaches;
public event EventHandler GCComplete;
public void Watch() {
GC.RegisterForFullGCNotification(50, 50);
watcherThread = new Thread(() => {
while (true) {
GCNotificationStatus status = GC.WaitForFullGCApproach();
//Omitted error handling code here
if (GCApproaches != null) {
GCApproaches(this, EventArgs.Empty);
}
status = GC.WaitForFullGCComplete();
//Omitted error handling code here
if (GCComplete != null) {
GCComplete(this, EventArgs.Empty);
} }
});
watcherThread.IsBackground = true;
watcherThread.Start();
}
public void Cancel() {
GC.CancelFullGCNotification();
watcherThread.Join();
}
}
要了解更多信息和使用 GC notifications API 重新分配服务器负载的完整示例,请参考位于 http://msdn.microsoft.com/en-us/library/cc713687.aspx 的 MSDN 文档。
控制方法
GC。方法指示垃圾回收器执行指定代(包括所有较年轻的代)的垃圾回收。从……开始。NET 3.5(也可在。NET 2.0 SP1 和。NET 3.0 SP1),GC。Collect 方法被枚举类型 GCCollectionMode 的参数重载。该枚举具有以下可能值:
- 使用 GCCollectionMode 时。强制,垃圾回收器立即执行回收,并与当前线程同步。当方法返回时,保证垃圾回收完成。
- 使用 GCCollectionMode 时。优化后,垃圾收集器可以决定此时收集是否有效。是否执行收集的最终决定被延迟到运行时,并且不能由实现来保证。如果您试图通过提供提示来帮助垃圾收集器了解何时进行收集可能是有益的,建议使用这种模式。在诊断场景中,或者当您希望强制进行完全垃圾回收以回收您感兴趣的特定对象时,您应该使用 GCCollectionMode。反而被逼的。
- 从 CLR 4.5 开始,使用 GCCollectionMode。默认等效于 GCCollectionMode.Forced。
强制垃圾收集器执行收集并不是一项常见的任务。本章中描述的优化很大程度上基于动态调优和启发式方法,这些方法已经针对各种应用场景进行了彻底的测试。不建议强制进行垃圾收集,甚至不建议进行垃圾收集(使用 GCCollectionMode。优化)在非特殊情况下。也就是说,我们可以概括出几个场景,这些场景需要仔细考虑强制垃圾收集:
- 当需要大量长期内存的不经常发生的可重复操作完成时,这些内存就有资格进行垃圾收集。如果应用不经常导致完全垃圾收集,那么在被回收之前,这些内存可能会在第 2 代(或 LOH)中保留很长时间。在这种情况下,当已知内存未被引用时,强制进行垃圾收集是有意义的,这样就不会占用工作集或页面文件中不必要的空间。
- 当使用低延迟 GC 模式时,如果知道对时间敏感的工作已经空闲并且可以暂停以执行收集,建议在安全点强制进行垃圾收集。长时间停留在低延迟模式下而不执行收集可能会导致内存不足的情况。一般来说,如果应用对垃圾收集时间很敏感,那么在空闲时间强制进行收集,以影响垃圾收集开销在空闲运行时区域的有偏向的重新分配,这是合理的。
- 当使用非确定性终结来回收非托管资源时,通常需要阻塞,直到所有这样的资源都被回收。这可以通过遵循 GC 来完成。用 GC 打对方付费电话。WaitForPendingFinalizers 调用。在这些场景中,确定性终结总是首选,但我们通常无法控制实际执行终结工作的内部类。
注截至。NET 4.5,GC。Collect 方法有一个带有尾随布尔参数的额外重载:GC。收集(int 生成,GCCollectionMode 模式,布尔阻塞)。此参数控制是否需要阻塞垃圾收集(默认),或者是否可以通过启动后台垃圾收集来异步满足请求。
其他控制方法如下:
- GC。AddMemoryPressure 和 GC。RemoveMemoryPressure 方法可用于向垃圾回收器通知当前进程中发生的非托管内存分配。增加内存压力向垃圾回收器表明已经分配了指定数量的非托管内存。垃圾收集器可以使用该信息来调整垃圾收集的积极性和频率,或者完全忽略它。当已知非托管分配将被回收时,通知垃圾回收器可以移除内存压力。
- GC。WaitForPendingFinalizers 方法阻塞当前线程,直到所有终结器都执行完。应谨慎使用此方法,因为它可能会引入死锁;例如,如果主线程阻塞在 GC 内部。当持有某个锁,并且其中一个活动终结器需要该锁时,就会发生死锁。因为 GC。WaitForPendingFinalizers 不接受超时参数,终结器内部的锁定代码必须使用超时来进行正常的错误处理。
- GC。SuppressFinalize 和 GC。RegisterForFinalize 方法与终结和恢复功能结合使用。本章的最后一节将对它们进行讨论。
- 从……开始。NET 3.5(也可在。NET 2.0 SP1 和。NET 3.0 SP1),垃圾收集器的另一个接口由前面讨论过的 GCSettings 类提供,用于控制 GC 风格和切换到低延迟 GC 模式。
对于系统的其他方法和属性。本节没有提到的 GC 类,请参考 MSDN 文档。
使用 CLR 托管与 GC 交互
在上一节中,我们研究了可用于从托管代码与垃圾收集器交互的诊断和控制方法。然而,这些方法所提供的控制程度还有许多不足之处;此外,没有通知机制可以让应用知道发生了垃圾收集。
这些不足不能由托管代码单独解决,需要 CLR 托管来进一步控制与垃圾收集器的交互。CLR 宿主提供了多种控制机制。网络内存管理:
- IHostMemoryManager 接口及其关联的 IHostMalloc 接口提供回调,CLR 使用这些回调为 GC 段分配虚拟内存,在内存不足时接收通知,执行非 GC 内存分配(例如,为 JITted 代码)以及估计可用内存量。例如,此接口可用于确保所有 CLR 内存分配请求都从不能被分页到磁盘的物理内存中得到满足。这就是非分页 CLR 主机开源项目的本质(
nonpagedclrhost.codeplex.com/
,2008)。 - ICLRGCManager 接口提供了控制垃圾收集器和获取有关其操作的统计信息的方法。它可用于从主机代码启动垃圾收集,检索统计数据(也可作为性能计数器在下使用)。NET CLR Memory 性能类别)并初始化 GC 启动限制,包括 GC 段大小和第 0 代的最大大小。
- IHostGCManager 接口提供了在垃圾收集开始或结束时,以及在线程挂起以便垃圾收集可以继续时接收通知的方法。
下面是来自非分页 CLR 主机开源项目的一小段代码摘录,它显示了 CLR 主机如何定制 CLR 请求的段分配,并确保任何提交的页面都被锁定到物理内存中:
HRESULT __stdcall HostControl::VirtualAlloc(
void* pAddress, SIZE_T dwSize, DWORD flAllocationType,
DWORD flProtect, EMemoryCriticalLevel dwCriticalLevel, void** ppMem) {
*ppMem = VirtualAlloc(pAddress, dwSize, flAllocationType, flProtect);
if (*ppMem == NULL) {
return E_OUTOFMEMORY;
}
if (flAllocationType & MEM_COMMIT) {
VirtualLock(*ppMem, dwSize);
return S_OK;
}
}
HRESULT __stdcall HostControl::VirtualFree(
LPVOID lpAddress, SIZE_T dwSize, DWORD dwFreeType) {
VirtualUnlock(lpAddress, dwSize);
if (FALSE == VirtualFree(lpAddress, dwSize, dwFreeType)) {
return E_FAIL;
}
return S_OK;
}
有关其他与 GC 相关的 CLR 宿主接口(包括 IGCHost、IGCHostControl 和 IGCThreadControl)的信息,请参考 MSDN 文档。
GC 触发器
我们已经看到了 GC 触发的几个原因,但是从来没有在一个地方列出它们。以下是 CLR 用来确定是否有必要执行 GC 的触发器,按可能性顺序列出:
- 第 0 代填充。当应用在小对象堆中分配新对象时,这种情况经常发生。
- 大对象堆达到阈值。当应用分配大型对象时会发生这种情况。
- 应用调用 GC。显式收集。
- 操作系统报告内存不足。CLR 使用内存资源通知 Win32 APIs 来监视系统内存使用情况,并在整个系统的资源不足时成为好公民。
- AppDomain 已卸载。
- 进程(或 CLR)正在关闭。这是一种退化的垃圾收集——没有东西被认为是根,对象没有被提升,堆没有被压缩。此集合的主要目的是运行终结器。
垃圾收集性能最佳实践
在本节中,我们将总结与交互的最佳实践。网络垃圾收集器。我们将研究展示这些最佳实践的多个场景,并指出必须避免的陷阱。
世代模型
我们回顾了分代模型,它为之前讨论的简单 GC 模型带来了两个显著的性能优化。分代堆根据托管对象的预期寿命对其进行分区,这使得 GC 能够频繁地收集寿命短且收集可能性高的对象。此外,独立的大对象堆通过对回收的大内存块采用自由列表管理策略,解决了复制大对象的问题。
我们现在可以总结用于与世代模型交互的最佳实践 ,然后回顾几个例子。
- 临时对象应该是短命的。最大的风险是进入第 2 代的临时对象,因为这会导致频繁的完全收集。
- 大对象应该是长寿命的或池化的。LOH 集合相当于完全集合。
- 各代之间的引用应该保持在最低限度。
以下案例研究代表了中年危机现象的风险。在我们的一个客户实现的一个监控 UI 应用中,20,000 条日志记录持续显示在应用的主屏幕上。每个单独的记录都包含一个严重级别、一条简短的描述消息和附加的(可能很大的)上下文信息。随着新的日志记录流入系统,这 20 000 条记录不断被替换。
由于显示了大量的日志记录,大多数日志记录将在两次垃圾收集后保存下来,并到达第 2 代。然而,日志记录并不是长期存在的对象,不久之后,它们就会被新记录所取代,而新记录又会蔓延到第 2 代,表现出中年危机现象。最终结果是,应用每分钟执行数百次完整的垃圾收集,将近 50%的时间花费在 GC 代码上。
在最初的分析阶段之后,我们得出结论,在 UI 中显示 20,000 条日志记录是不必要的。我们调整了显示,以显示 1000 条最近的记录,并实现了池化,以重用和重新初始化现有的记录对象,而不是创建新的记录对象。这些措施最大限度地减少了应用的内存占用,但更重要的是,将 GC 的时间减少到了 0.1%,完全收集只需几分钟。
中年危机的另一个例子是我们的一个 web 服务器遇到的情况。web 服务器系统被划分为一组接收 web 请求的前端服务器。这些前端服务器使用对一组后端服务器的同步 web 服务调用来处理各个请求。
在 QA 实验室环境中,前端和后端层之间的 web 服务调用将在几毫秒内返回。这导致 HTTP 请求很快被驳回,因此请求对象及其关联的对象图确实是短暂的。
在生产环境中,由于网络条件、后端服务器负载和其他因素,web 服务调用通常需要更长的时间来执行。请求仍然在几分之一秒内返回给客户机,这不值得优化,因为人类无法观察到差异。然而,随着每秒钟有许多请求流入系统,每个请求对象及其相关对象图的生命周期被延长,使得这些对象在多次垃圾收集中幸存下来,并进入第 2 代。
注意到服务器处理请求的能力并没有因为请求的生存时间稍微长一点而受到损害是很重要的:内存负载仍然是可接受的,客户端并没有感觉到有什么不同,因为请求仍然在不到一秒的时间内被返回。然而,服务器的伸缩能力受到了极大的损害,因为前端应用 70%的时间都花在 GC 代码上。
解决这种情况需要切换到异步 web 服务调用,或者尽可能快地释放与请求相关联的大多数对象(在执行同步服务调用之前)。两者的结合将 GC 时间降低到了 3%,将站点的伸缩能力提高了 3 倍!
最后,考虑一个简单的基于 2D 像素的图形渲染系统的设计场景。在这种系统中,绘图表面是一个长期存在的实体,它通过放置和替换不同颜色和不透明度的短期像素来不断地重新绘制自己。
如果这些像素由引用类型来表示,那么我们不仅会将应用的内存占用增加一倍或三倍;我们还会在各代之间创建引用,并创建一个包含所有像素的巨大对象图。唯一可行的方法是使用值类型来表示像素,这可以将内存占用量减少 2 到 3 倍,并将花在 GC 上的时间减少几个数量级。
销连接
我们之前已经讨论过将 钉住作为一种正确性度量,必须使用它来确保托管对象的地址可以安全地传递给非托管代码。锁定一个对象使其保持在内存中的同一位置,从而降低了垃圾收集器通过四处清除对象来整理堆碎片的固有能力。
记住这一点,我们可以总结在需要 pinning 的应用中使用它的最佳实践。
- 用尽可能短的时间锁定对象。如果在锁定对象时不进行垃圾收集,那么锁定的成本会很低。如果调用需要无限期固定对象的非托管代码(如异步调用),请考虑复制或非托管内存分配,而不是固定托管对象。
- 固定一些大的缓冲区,而不是许多小的缓冲区,即使这意味着您必须自己管理小的缓冲区大小。大型对象不会在内存中移动,从而最大限度地降低了固定的碎片成本。
- 锁定并重用应用启动路径中已分配的旧对象,而不是为锁定分配新的缓冲区。旧对象很少移动,从而最小化了固定的碎片成本。
- 如果应用严重依赖于固定,请考虑分配非托管内存。非托管代码可以直接操作非托管内存,而不会产生任何垃圾回收开销。使用不安全代码(C# 指针)可以方便地从托管代码中操作非托管内存块,而无需将数据复制到托管数据结构中。从托管代码分配非托管内存通常是使用系统执行的。runtime . interop services . marshal 类。(详见第八章。)
敲定
finalization 部分的精神非常清楚地表明。网络还有许多不尽如人意的地方。关于终结,最好的建议是尽可能使它具有确定性,并将异常情况委托给非确定性终结器。
下面的实践总结了在应用中处理终结的正确方法:
- 首选确定性终结并实现 IDisposable,以确保客户端知道从您的类中可以得到什么。使用 GC。以确保在不必要时不会调用终结器。
- 提供一个终结器并使用 Debug。Assert 或一个日志语句来确保客户意识到他们没有正确使用你的类。
- 实现复杂对象时,将可终结资源包装在单独的类(系统。类型是一个典型的例子)。这将确保只有包装非托管资源的小类型在额外的垃圾回收中幸存,并且当对象不再被引用时,对象的其余部分可以被销毁。
杂项提示和最佳实践
在这一节中,我们将简要检查不属于本章中讨论的任何其他主要部分的各种最佳实践和性能提示。
值类型
可能的话,优先选择值类型而不是引用类型。在第三章中,我们已经从一个总体的角度研究了值类型和引用类型的各种特征。此外,值类型有几个影响应用中垃圾收集成本的特征:
- 当用作局部堆栈变量时,值类型的分配开销可以忽略不计。这种分配与堆栈框架的扩展相关联,堆栈框架是在每次进入方法时创建的。
- 用作局部堆栈变量的值类型没有释放(垃圾收集)开销—当方法返回并且其堆栈帧被销毁时,它们会被自动释放。
- 嵌入在引用类型中的值类型最小化了垃圾回收两个阶段的开销:如果对象较大,则标记的对象较少,如果对象较大,则扫描阶段每次复制更多的内存,这减少了复制许多小对象的开销。
- 值类型减少了应用的内存占用,因为它们占用的内存更少。此外,当嵌入引用类型时,它们不需要引用来访问它们,从而消除了存储额外引用的需要。最后,嵌入在引用类型中的值类型表现出访问的局部性—如果对象在缓存中被分页并且是热的,那么它的嵌入值类型字段也可能在缓存中被分页并且是热的。
- 值类型减少了代之间的引用,因为对象图中引入的引用更少。
对象图形
减小对象图的大小会直接影响垃圾收集器必须执行的工作量。大对象的简单图形比许多小对象的复杂图形标记和扫描更快。我们前面已经提到了这种情况的一个具体场景。
此外,引入更少的引用类型的局部变量减小了 JIT 生成的局部根表的大小,这改善了编译时间并节省了少量内存。
池对象
对象池是一种机制,旨在手动管理内存和资源,而不是依赖于执行环境提供的工具。当对象池生效时,分配一个新对象意味着从一个未使用的对象池中检索它,释放一个对象意味着将它返回到池中。
如果分配和取消分配的成本(不包括初始化和取消初始化)比手动管理对象的生存期更高,那么池化可以提高性能。例如,池化大对象而不是使用垃圾收集器分配和释放它们可能会提高性能,因为释放频繁分配的大对象需要完整的垃圾收集。
注Windows Communication Foundation(WCF)实现了用于存储和传输消息的字节数组池。系统。service model . channels . buffer manager 抽象类充当池的外观,提供从池中获取字节数组并将其返回到池中的工具。抽象基本操作的两个内部实现提供了一个基于 GC 的分配和取消分配机制,以及一个管理缓冲池的机制。池化实现(截止到撰写本文时)在内部为不同大小的缓冲区管理多个池,并考虑分配线程。Windows XP 中引入的 Windows 低碎片堆也使用了类似的技术。
实施高效池至少需要考虑以下因素:
- 分配和取消分配操作的同步必须保持在最低限度。例如,一个无锁(无等待)数据结构可以用来实现这个池(见第六章关于无锁同步的处理)。
- 不应该允许池无限增长,这意味着在某些情况下,对象将被返回给垃圾收集器。
- 池不应该频繁耗尽,这意味着需要一个增长试探来根据分配请求的频率和数量平衡池大小。
大多数池实现也将受益于实现从池中检索对象的最近最少使用(LRU)机制,因为最近最少使用的对象可能会被分页并在 CPU 缓存中处于热状态。
在中实现池化。NET 要求挂钩池类型实例的分配和释放请求。没有办法直接挂钩分配(new 操作符不能重载),而是使用 Pool 之类的替代 API。可以使用 GetInstance。将对象返回到池中最好使用 Dispose 模式来实现,并将终结作为备份。
下面的代码显示了一个极其简化的. NET 池实现框架和一个匹配的可池化对象库:
public class Pool<T> {
private ConcurrentBag<T> pool = new ConcurrentBag<T>();
private Func<T> objectFactory;
public Pool(Func<T> factory) {
objectFactory = factory;
}
public T GetInstance() {
T result;
if (!pool.TryTake(out result)) {
result = objectFactory();
}
return result;
}
public void ReturnToPool(T instance) {
pool.Add(instance);
}
}
public class PoolableObjectBase<T> : IDisposable {
private static Pool<T> pool = new Pool<T>();
public void Dispose() {
pool.ReturnToPool(this);
}
∼PoolableObjectBase() {
GC.ReRegisterForFinalize(this);
pool.ReturnToPool(this);
}
}
public class MyPoolableObjectExample : PoolableObjectBase<MyPoolableObjectExample> {
...
}
分页和分配非托管内存
那个。NET 垃圾收集器自动回收未使用的内存。因此,根据定义,它不能为现实应用中可能出现的每个内存管理需求提供完美的解决方案。
在前面的小节中,我们研究了许多垃圾收集器表现不佳的场景,必须对这些场景进行调整以提供足够的性能。另一个让应用崩溃的例子是在物理内存不足以容纳所有对象的情况下使用垃圾收集器。
考虑一个在具有 8GB 物理内存(RAM)的机器上运行的应用。这种机器很快就会消失,但这种情况很容易扩展到任何数量的物理内存,只要应用能够寻址它(在 64 位系统上,这是很大的内存)。应用分配 12GB 的内存,其中最多 8GB 将驻留在工作集(物理内存)中,至少 4GB 将被调出到磁盘。Windows 工作集管理器将确保包含应用经常访问的对象的页面保留在物理内存中,而包含应用很少访问的对象的页面将被调出到磁盘。
在正常操作期间,应用可能根本不会出现分页,因为它很少访问调出的对象。但是,当发生完全垃圾收集时,GC 必须遍历每个可到达的对象,以在标记阶段构造图。遍历每个可到达的对象意味着从磁盘执行 4GB 的读取来访问它们。因为物理内存已满,这也意味着必须执行 4GB 的磁盘写入操作,以便为换出的对象释放物理内存。最后,在垃圾收集完成后,应用将尝试访问其经常使用的对象,这些对象可能已经被换出到磁盘,从而导致额外的页面错误。
在写入时,典型的硬盘驱动器为顺序读取和写入提供大约 150MB/s 的传输速率(即使是快速固态驱动器也不会超过 2 倍)。因此,在从磁盘调入和调出磁盘时执行 8GB 的传输可能需要大约 55 秒。在此期间,应用正在等待 GC 完成(除非它正在使用并发 GC);添加更多处理器(即使用服务器 GC)不会有帮助,因为磁盘是瓶颈。系统上的其他应用将遭受性能的大幅下降,因为物理磁盘将被分页请求所饱和。
解决这种情况的唯一方法是分配非托管内存来存储可能会被换出到磁盘的对象。非托管内存不受垃圾回收的影响,只有在应用需要时才会被访问。
另一个与控制分页和工作集管理有关的例子是将页面锁定到物理内存中。Windows 应用有一个文档化的系统接口,请求不要将特定的内存区域换出到磁盘(忽略异常情况)。这种机制不能直接与。NET 垃圾回收器,因为托管应用不能直接控制 CLR 执行的虚拟内存分配。但是,自定义 CLR 宿主可以将页面锁定到内存中,作为来自 CLR 的虚拟内存分配请求的一部分。
静态代码分析(FxCop)规则
Visual Studio 的静态代码分析(FxCop) 有一组针对与垃圾收集相关的常见性能和正确性问题的规则。我们推荐使用这些规则,因为它们经常在编码阶段捕获错误,这是识别和修复最便宜的。有关使用或不使用 Visual Studio 的托管代码分析的更多信息,请参考 MSDN 在线文档。
Visual Studio 11 附带的与 GC 相关的规则有:
- 设计规则—ca 1001—拥有可处置字段的类型应该是可处置的。此规则确保通过类型成员的聚合类型来确定类型成员的终结。
- 设计规则—ca 1049—拥有本机资源的类型应该是可处置的。该规则确保了提供对本机资源(如 System。runtime . interop services . handleref)正确实现 Dispose 模式。
- 设计规则—ca 1063—正确实现 IDisposable。此规则确保 Dispose 模式由可释放类型正确实现。
- 性能规则—ca 1821—删除空终结器。此规则确保类型没有空的终结器,空的终结器会降低性能并导致中年危机。
- 可靠性规则—ca 2000—在失去范围之前处置对象。该规则确保 IDisposable 类型的所有局部变量在它们消失在范围之外之前被释放。
- 可靠性规则—ca 2006—使用 SafeHandle 封装本机资源。此规则确保在可能的情况下,使用 SafeHandle 类或其派生类之一,而不是直接句柄(如 System。IntPtr)转换为非托管资源。
- 使用规则—ca 1816—调用 GC。SuppressFinalize 正确。此规则确保可释放类型调用抑制其终结器内的终结,并且不抑制不相关对象的终结。
- 使用规则—ca 2213—应处置可处置字段。此规则确保实现 IDisposable 的类型应该依次对其实现 IDisposable 的所有字段调用 Dispose。
- 使用规则— CA2215 —Dispose 方法应该调用基类 Dispose。此规则确保通过调用基类的 Dispose 方法(如果它也是 IDisposable)来正确实现 Dispose 模式。
- 使用规则—ca 2216—可处置类型应该声明终结器。此规则确保当类用户忽略确定性地终结对象时,可释放类型提供一个终结器作为备份。
摘要
通过本章的课程,我们回顾了。NET 垃圾收集器,负责自动回收未使用的内存的实体。我们研究了跟踪垃圾收集的替代方法,包括引用计数垃圾收集和手动自由列表管理。
的核心。NET 垃圾收集器包含以下协调概念,我们将对其进行详细分析:
- 根为构建所有可达对象的图形提供了起点。
- 标记是垃圾收集器构建所有可到达对象的图并将它们标记为已用的阶段。标记阶段可以与应用线程执行同时进行。
- 清扫是垃圾收集器转移可到达对象并更新对这些对象的引用的阶段。扫描阶段要求在继续之前停止所有应用线程。
- 钉住 是一种将对象锁定在某个位置的机制,这样垃圾收集器就无法移动它。与需要指向托管对象的指针的非托管代码一起使用,可能会导致碎片。
- GC 风味 为特定应用中的垃圾收集器的行为提供静态调整,以更好地适应其内存分配和释放模式。
- 世代模型 根据对象的当前年龄描述其预期寿命。较年轻的物体预计会死得很快;旧物件预期寿命更长。
- 世代是内存的概念区域,它根据对象的预期寿命来划分对象。代便于频繁执行具有较高收集可能性的廉价部分收集,并且很少执行昂贵且效率较低的完全收集。
- 大型对象堆是为大型对象保留的内存区域。LOH 可能会变得支离破碎,但对象不会四处移动,从而减少了扫描阶段的成本。
- 段是由 CLR 分配的虚拟内存区域。虚拟内存可能会变成碎片,因为段大小是固定的。
- 终结是一种备份机制,用于以不确定的方式自动释放非托管资源。尽可能地选择确定性终结而不是自动终结,但是向客户端提供两种选择。
与垃圾收集相关的常见陷阱通常与其最强大的优化的优点有关:
- 分代模型为行为良好的应用提供了性能优势,但是会表现出直接影响性能的中年危机现象。
- 每当托管对象通过引用传递给非托管代码时,固定是必要的,但是会将内部碎片引入 GC 堆,包括较低代。
- 段确保虚拟内存以大块的形式分配,但是可能会出现虚拟内存空间的外部碎片。
- 自动终结为非托管资源的处置提供了便利,但是与高性能成本相关联,并且经常导致中年危机、高压内存泄漏和竞争情况。
下面是一些最佳实践的列表,用于充分利用。网络垃圾收集器:
- 分配临时对象,使它们快速消亡,但在应用的整个生命周期中保持旧对象的活力。
- 在应用初始化时固定大数组,并根据需要将它们分成小的缓冲区。
- 在 GC 失败的情况下,使用池化或非托管分配来管理内存。
- 实现确定性终结,仅将自动终结作为备份策略。
- 使用 GC 风格调优您的应用,找出哪一种最适合各种类型的硬件和软件配置。
以下是一些工具,可用于诊断与内存相关的问题,并从内存管理的角度检查应用的行为:
- CLR Profiler 可用于诊断内部碎片,确定应用中的大量内存分配路径,查看每次垃圾收集时回收的对象,并获得保留对象的大小和年龄的一般统计信息。
- SOS.DLL可用于诊断内存泄漏、分析外部和内部碎片、获取垃圾收集时间、列出托管堆中的对象、检查终结队列以及查看 GC 线程和终结器线程的状态。
- CLR 性能计数器可用于获取垃圾收集的一般统计信息,包括每代的大小、分配率、终结信息、固定对象计数等。
- CLR hosting 可用作诊断实用程序,分析段分配、垃圾收集频率、导致垃圾收集的线程以及源自 CLR 的非 GC 相关内存分配请求。
在垃圾收集理论、所有相关机制的微妙之处、常见缺陷和最佳性能实践、诊断工具和场景的武装下,您现在已经准备好开始探索优化应用中的内存管理,并使用适当的内存管理策略来设计它们。*
五、集合和泛型
几乎没有一个代码样本不使用列表
泛型
经常需要创建一个类或方法,可以很好地处理任何数据类型。多态和继承只能在一定程度上有所帮助;要求其参数完全泛型的方法被强制与 System 一起工作。对象,这导致了泛型编程在。之前的净值。NET 2.0:
- 类型安全 :如何在编译时验证泛型数据类型上的操作,并明确禁止任何可能在运行时失败的操作?
- 不装箱 :当方法的参数为系统时,如何避免装箱值类型。对象引用?
这些都不是小问题。要了解原因,请考虑。NET 1.1,数组列表。下面是一个平凡的实现,尽管如此,它展示了上面提到的问题:
public class ArrayList : IEnumerable, ICollection, IList, ... {
private object[] items;
private int size;
public ArrayList(int initialCapacity) {
items = new object[initialCapacity];
}
public void Add(object item) {
if (size < items.Length – 1) {
items[size] = item;
++size;
} else {
//Allocate a larger array, copy the elements, then place ‘item’ in it
}
}
public object this[int index] {
get {
if (index < 0 || index >= size) throw IndexOutOfBoundsException(index);
return items[index];
}
set {
if (index < 0 || index >= size) throw IndexOutOfBoundsException(index);
items[index] = value;
}
}
//Many more methods omitted for brevity
}
我们在整个代码中突出显示了 System。对象,它是集合所基于的“泛型”类型。尽管这可能看起来是一个完美有效的解决方案,但实际使用并不那么完美:
ArrayList employees = new ArrayList(7);
employees.Add(new Employee(“Kate”));
employees.Add(new Employee(“Mike”));
Employee first = (Employee)employees[0];
这种难看的对 Employee 的向下转换是必需的,因为 ArrayList 不保留任何关于其中元素类型的信息。此外,它不约束插入其中的对象具有任何共同的特征。考虑一下:
employees.Add(42); //Compiles and works at runtime!
Employee third = (Employee)employees[2]; //Compiles and throws an exception at runtime...
事实上,数字 42 不属于 employees 集合,但是我们没有指定 ArrayList 仅限于特定类型的实例。尽管可以创建一个 ArrayList 实现,将其项限制为特定类型,但这仍然是一个开销很大的运行时操作和一个类似 employees 的语句。Add(42)不会编译失败。
这就是型安全的问题;基于系统的“通用”集合。对象不能保证编译时类型安全并将所有检查(如果有)推迟到运行时。然而,从性能的角度来看,这可能是我们最不关心的——但事实证明,当涉及值类型时,会出现严重的性能问题。检查下面的代码,它使用了第三章中的 Point2D 结构(一个带有 X 和 Y 整数坐标的简单值类型):
ArrayList line = new ArrayList(1000000);
for (int i = 0; i < 1000000; ++i) {
line.Add(new Point2D(i, i));
}
插入 ArrayList 的 Point2D 的每个实例都被装箱,因为它的 Add 方法接受引用类型参数(System。对象)。对于 boxed Point2D 对象,这会导致 1,000,000 次堆分配的成本。正如我们在第三章中看到的,在 32 位堆上,1,000,000 个 boxed Point2D 对象将占用 16,000,000 字节的内存(相比之下,普通值类型占用 8,000,000 字节的内存)。此外,数组列表中的条目引用数组至少有 1,000,000 个引用,这相当于另外 4,000,000 字节——总共 20,000,000 字节(见图 5-1 ),其中 8,000,000 字节就足够了。事实上,正是这个问题导致我们放弃了将 Point2D 作为引用类型的想法;ArrayList 向我们强加了一个只能很好地处理引用类型的集合!
图 5-1 。包含装箱的 Point2D 对象的 ArrayList 存储占用额外内存的引用,并迫使 Point2D 对象在堆上装箱,这也增加了它们的开销
还有改进的空间吗?事实上,我们可以为二维点编写一个专门的集合,如下所示(尽管我们还必须为点编写专门的 IEnumerable、ICollection 和 IList 接口...).它与“泛型”数组列表完全相同,但是在我们之前有 object 的地方有 Point2D:
public class Point2DArrayList : IPoint2DEnumerable, IPoint2DCollection, IPoint2DList, ... {
private Point2D[] items;
private int size;
public ArrayList(int initialCapacity) {
items = new Point2D[initialCapacity];
}
public void Add(Point2D item) {
if (size < items.Length – 1) {
items[size] = item;
++size;
} else {
//Allocate a larger array, copy the elements, then place ‘item’ in it
}
}
public Point2D this[int index] {
get {
if (index < 0 || index >= size) throw IndexOutOfBoundsException(index);
return items[index];
}
set {
if (index < 0 || index >= size) throw IndexOutOfBoundsException(index);
items[index] = value;
}
}
//Many more methods omitted for brevity
}
一个类似的 Employee 对象的专用集合可以解决我们前面考虑过的类型安全问题。不幸的是,为每种数据类型开发一个专门的集合几乎是不切实际的。这正是语言编译器的作用。NET 2.0——允许在类和方法中使用泛型数据类型,同时实现类型安全并取消装箱。
。网络泛型
泛型类和方法允许真正的泛型代码,它不会退回到系统。对象,另一方面不需要对每种数据类型进行专门化。下面是一个泛型类型的草图,List < T > ,它取代了我们之前的 ArrayList 实验,并解决了类型安全和装箱问题:
public class List<T> : IEnumerable<T>, ICollection<T>, IList<T>, ... {
private T[] items;
private int size;
public List(int initialCapacity) {
items = new T[initialCapacity];
}
public void Add(T item) {
if (size < items.Length – 1) {
items[size] = item;
++size;
} else {
//Allocate a larger array, copy the elements, then place ‘item’ in it
}
}
public T this[int index] {
get {
if (index < 0 || index >= size) throw IndexOutOfBoundsException(index);
return items[index];
}
set {
if (index < 0 || index >= size) throw IndexOutOfBoundsException(index);
items[index] = value;
}
}
//Many more methods omitted for brevity
}
注如果你对 C# 泛型的语法一点都不熟悉,Jon Skeet 的《C# 深度》(Manning,2010)是一本很好的教材。在本章的其余部分,我们假设你已经编写了一个泛型类或者至少使用了泛型类,比如。NET Framework 的集合。
如果您曾经编写过泛型类或方法,您就会知道基于 System 转换伪泛型代码是多么容易。对象实例到真正的通用代码,带有一个或多个通用类型参数。通过在必要的地方替换泛型类型参数,使用泛型类和方法也异常容易:
List<Employee> employees = new List<Employee>(7);
employees.Add(new Employee(“Kate”));
Employee first = employees[0]; //No downcast required, completely type-safe
employees.Add(42); //Does not compile!
List<Point2D> line = new List<Point2D>(1000000);
for (int i = 0; i < 1000000; ++i) {
line.Add(new Point2D(i, i)); //No boxing, no references stored
}
几乎神奇的是,泛型集合是类型安全的(因为它不允许不适当的元素存储在其中),并且不需要对值类型进行装箱。甚至内部存储——items 数组——也相应地适应泛型类型参数:当 T 是 Point2D 时,items 数组是 Point2D[],它存储值而不是引用。稍后我们将展示这种魔力,但是现在我们有一个有效的语言级解决方案来解决泛型编程的基本问题。
当我们需要通用参数的一些额外功能时,这个解决方案本身似乎是不够的。考虑一个在排序数组中执行二分搜索法的方法。一个完全通用的版本将无法进行搜索,因为系统。对象没有配备任何与比较相关的工具:
public static int BinarySearch<T>(T[] array, T element) {
//At some point in the algorithm, we need to compare:
if (array[x] < array[y]) {
...
}
}
系统。对象没有静态操作符<,这就是这个方法无法编译的原因!事实上,我们必须向编译器证明,对于我们提供给方法的每一个泛型类型参数,它都能够解析对它的所有方法调用(包括操作符)。这就是通用约束进入画面的地方。
通用约束
泛型约束向编译器表明,在使用泛型类型时,只允许将某些类型作为泛型类型参数。有五种类型的约束:
//T must implement an interface:
public int Format(T instance) where T : IFormattable {
return instance.ToString(“N”, CultureInfo.CurrentUICulture);
//OK, T must have IFormattable.ToString(string, IFormatProvider)
}
//T must derive from a base class:
public void Display<T>(T widget) where T : Widget {
widget.Display(0, 0);
//OK, T derives from Widget which has the Display(int, int) method
}
//T must have a parameterless constructor:
public T Create<T>() where T : new() {
return new T();
//OK, T has a parameterless constructor
//The C# compiler compiles ’new T()’ to a call to Activator.CreateInstance<T>(),
//which is sub-optimal, but there is no IL equivalent for this constraint
}
//T must be a reference type:
public void ReferencesOnly<T>(T reference) where T : class
//T must be a value type:
public void ValuesOnly<T>(T value) where T : struct
如果我们重温一下二分搜索法的例子,接口约束被证明是非常有用的(事实上,它是最常用的一种约束)。具体来说,我们可以要求 T 实现 IComparable ,并使用 IComparable 比较数组元素。比较法。但是,IComparable 不是泛型接口,它的 CompareTo 方法接受一个系统。对象参数,导致值类型的装箱成本。按理说,应该有一个通用版本的 IComparable,IComparable < T >,它在这里完美地服务了:
//From the .NET Framework:
public interface IComparable<T> {
int CompareTo(T other);
}
public static int BinarySearch<T>(T[] array, T element) where T : IComparable<T> {
//At some point in the algorithm, we need to compare:
if (array[x].CompareTo(array[y]) < 0) {
...
}
}
这个二分搜索法版本在比较值类型实例时不会引发装箱,可以处理任何实现 IComparable
接口约束和等式
在第三章中,我们已经看到了值类型的关键性能优化是覆盖 Equals 方法并实现 IEquatable < T >接口。为什么这个界面如此重要?考虑以下代码:
公共静态 void CallEquals
实例。Equals(实例);
}
方法内部的 Equals 调用遵从对 Object 的虚拟调用。Equals,它接受一个系统。对象参数并导致对值类型进行装箱。这是 C# 编译器认为保证在我们使用的每个 T 上都存在的唯一选择。如果我们想让编译器相信 T 有一个接受 T 的 Equals 方法,我们需要使用一个显式约束:
//从。NET 框架:
公共接口资格
bool 等于(T other);
}
public static void call equals
实例。Equals(实例);
}
最后,您可能希望允许调用者使用任何类型作为 T,但是如果 T 提供了 IEquatable
当列出
List
提示如您所见,没有通用的约束来表达数学运算符,如加法或乘法。这意味着您不能编写在泛型参数上使用表达式(如 a+b)的泛型方法。编写泛型数值算法的标准解决方案是使用一个助手结构,该结构通过所需的算术运算实现 IMath < T >接口 ,并在泛型方法中实例化该结构。有关更多详细信息,请参见 Rüdiger Klaehn 的 CodeProject 文章“使用泛型进行计算”,可在www . code project . com/Articles/8531/Using-generics-for-calculation
获得。
在研究了 C# 中泛型的大多数语法特性之后,我们转向它们的运行时实现。在我们关心这个问题之前,最重要的是问一下是否是泛型的运行时表示——我们很快就会看到,C++模板,一种类似的机制,没有运行时表示可言。如果你看看反射在运行时对泛型类型所能做的奇迹,这个问题很容易回答:
Type openList = typeof(List<>);
Type listOfInt = openList.MakeGenericType(typeof(int));
IEnumerable<int> ints = (IEnumerable<int>)Activator.CreateInstance(listOfInt);
Dictionary<string, int> frequencies = new Dictionary<string, int>();
Type openDictionary = frequencies.GetType().GetGenericTypeDefinition();
Type dictStringToDouble = openDictionary.MakeGenericType(typeof(string), typeof(double));
如您所见,我们可以从现有的泛型类型动态创建泛型类型,并参数化“开放”泛型类型以创建“封闭”泛型类型的实例。这表明泛型是一等公民,并且有运行时表示,我们现在将对此进行研究。
CLR 泛型的实现
CLR 泛型的语法特征非常类似于 Java 泛型,甚至有点类似于 C++模板。然而,事实证明,它们的内部实现和对使用它们的程序的限制与 Java 和 C++非常不同。为了理解这些差异,我们应该简要回顾一下 Java 泛型和 C++模板。
Java 泛型
Java 中的泛型类可以有泛型类型参数,甚至还有一个与。NET 必须提供的(有界类型参数和通配符)。例如,下面是将我们的列表
public class List<E> {
private E[] items;
private int size;
public List(int initialCapacity) {
items = new E[initialCapacity];
}
public void Add(E item) {
if (size < items.Length – 1) {
items[size] = item;
++size;
} else {
//Allocate a larger array, copy the elements, then place ‘item’ in it
}
}
public E getAt(int index) {
if (index < 0 || index >= size) throw IndexOutOfBoundsException(index);
return items[index];
}
//Many more methods omitted for brevity
}
不幸的是,这段代码无法编译。具体来说,表达式 new E[initialCapacity]在 Java 中是不合法的。原因与 Java 编译通用代码的方式有关。Java 编译器删除了所有提到的泛型类型参数,并用 java.lang.Object 替换它们,这个过程称为类型擦除 。因此,运行时只有一种类型——List、 raw 类型——并且所提供的关于泛型类型参数的任何信息都将丢失。(公平地说,通过使用类型擦除,Java 保留了与泛型之前创建的库和应用的二进制兼容性。NET 2.0 不提供。NET 1.1 代码。)
然而,并不是所有的都失去了。通过使用对象数组 来代替,我们可以协调编译器,并且仍然有一个在编译时工作良好的类型安全泛型类:
public class List<E> {
private Object[] items;
private int size;
public void List(int initialCapacity) {
items = new Object[initialCapacity];
}
//The rest of the code is unmodified
}
List<Employee> employees = new List<Employee>(7);
employees.Add(new Employee(“Kate”));
employees.Add(42); //Does not compile!
然而,在 CLR 中采用这种方法引发了一个问题:值类型会变成什么样?引入泛型的两个原因之一是我们想不惜任何代价避免装箱。将值类型插入对象数组需要装箱,这是不可接受的。
C++模板
与 Java 泛型相比,C++模板可能看起来很吸引人。(它们也极其强大:你可能听说过模板解析机制 本身就是图灵完备的。)C++编译器不执行类型擦除——恰恰相反——也不需要约束,因为编译器很乐意编译任何妨碍它的东西。让我们从列表示例开始,然后考虑约束会发生什么:
template <typename T>
class list {
private:
T* items;
int size;
int capacity;
public:
list(int initialCapacity) : size(0), capacity(initialCapacity) {
items = new T[initialCapacity];
}
void add(const T& item) {
if (size < capacity) {
items[size] = item;
++size;
} else {
//Allocate a larger array, copy the elements, then place ‘item’ in it
}
}
const T& operator[](int index) const {
if (index < 0 || index >= size) throw exception(“Index out of bounds”);
return items[index];
}
//Many more methods omitted for brevity
};
列表模板类 是完全类型安全的:模板的每个实例化都会创建一个新类,它使用模板定义作为...模板。虽然这发生在引擎盖下,这里有一个它可能样子的例子:
//Original C++ code:
list<int> listOfInts(14);
//Expanded by the compiler to:
class __list__int {
private:
int* items;
int size;
int capacity;
public:
__list__int(int initialCapacity) : size(0), capacity(initialCapacity) {
items = new int[initialCapacity];
}
};
__list__int listOfInts(14);
请注意,add 和 operator[]方法没有扩展——调用代码没有使用它们,编译器只生成模板定义中用于特定实例化的部分。还要注意,编译器不会从模板定义中生成任何东西;它在生成任何代码之前等待特定的实例化。
这就是为什么 C++模板中不需要约束。回到我们的二分搜索法例子,下面是一个非常合理的实现:
template <typename T>
int BinarySearch(T* array, int size, const T& element) {
//At some point in the algorithm, we need to compare:
if (array[x] < array[y]) {
...
}
}
不需要向 C++编译器证明什么。毕竟模板定义是没有意义的;编译器会仔细等待任何实例化:
int numbers[10];
BinarySearch(numbers, 10, 42); //Compiles, int has an operator <
class empty {};
empty empties[10];
BinarySearch(empties, 10, empty()); //Does not compile, empty does not have an operator <
虽然这种设计非常诱人,但是 C++模板有着不吸引人的成本和限制 ,这对于 CLR 泛型来说是不可取的:
- 因为模板扩展发生在编译时,所以无法在不同的二进制文件之间共享模板实例。例如,加载到同一个进程中的两个 dll 可能有不同的 list
编译版本。这消耗了大量的内存,导致编译时间过长,而这正是 C++的著名之处。 - 出于同样的原因,两个不同二进制文件中的模板实例化被认为是不兼容的。从 dll 导出模板实例化没有干净和受支持的机制(比如返回 list
的导出函数)。 - 没有办法生成包含模板定义的二进制库。模板定义仅存在于源代码中,作为头文件,可以# 包含在 C++文件中。
泛型内部
在充分考虑了 Java 泛型 C++模板的设计之后,我们可以更好地理解 CLR 泛型的实现选择。CLR 泛型实现如下。泛型类型——即使是开放类型,如 List<>——也是一流的运行时公民。每个通用类型和系统都有一个方法表和一个 EEClass(见第三章)。也可以生成类型实例。泛型类型可以从程序集中导出,并且在编译时只存在泛型类型的一个定义。泛型类型在编译时不会扩展,但是正如我们所看到的,编译器会确保在泛型类型参数实例上尝试的任何操作都与指定的泛型约束兼容。
当 CLR 需要创建一个封闭泛型类型的实例时,比如 List
//C# code:
public void Add(T item) {
if (size < items.Length – 1) {
items[size] = item;
++size;
} else {
AllocateAndAddSlow(item);
}
}
; x86 assembly when T is a reference type
; Assuming that ECX contains ‘this’ and EDX contains ‘item’, prologue and epilogue omitted
mov eax, dword ptr [ecx+4] ; items
mov eax, dword ptr [eax+4] ; items.Length
dec eax
cmp dword ptr [ecx+8], eax ; size < items.Length - 1
jge AllocateAndAddSlow
mov eax, dword ptr [ecx+4]
mov ebx, dword ptr [ecx+8]
mov dword ptr [eax+4*ebx+4], edx ; items[size] = item
inc dword ptr [eax+8] ; ++size
很明显,该方法的代码不以任何方式依赖于 T,并且对任何引用类型都同样有效。这个观察允许 JIT 编译器保存资源(时间和内存)并共享 List
注这个想法需要一些进一步的完善,我们不会执行。例如,如果方法体包含一个新的 T[10]表达式,它将需要为每个 T 提供一个单独的代码路径,或者至少需要一种在运行时获取 T 的方法(例如,通过传递给方法的附加隐藏参数)。此外,我们还没有考虑约束如何影响代码生成——但是现在您应该相信,通过基类调用接口方法或虚方法需要相同的代码,而不管类型如何。
同样的想法不适用于值类型。例如,当 T 很长时,赋值语句 items[size] = item 需要不同的指令,因为必须复制 8 个字节而不是 4 个字节。更大的值类型甚至可能需要不止一条指令;等等。
为了在一个简单的设置中演示图 5-2 ,我们可以使用 SOS 来检查封闭泛型类型的方法表,这些方法表都是同一个开放泛型类型的实现。例如,考虑一个只有 Push 和 Pop 方法的 BasicStack < T >类,如下所示:
图 5-2 。列表< T >的引用类型实现的添加方法表条目具有指向单个方法实现的共享指针,而值类型实现的条目具有单独的代码版本
class BasicStack<T> {
private T[] items;
private int topIndex;
public BasicStack(int capacity = 42) {
items = new T[capacity];
}
public void Push(T item) {
items[topIndex++] = item;
}
public T Pop() {
return items[--topIndex];
}
}
基本堆栈
0:004> !dumpheap –stat
...
00173b40 1 16 BasicStack`1[[System.Double, mscorlib]]
00173a98 1 16 BasicStack`1[[System.Int32, mscorlib]]
00173a04 1 16 BasicStack`1[[System.Int32[], mscorlib]]
001739b0 1 16 BasicStack`1[[System.String, mscorlib]]
...
0:004> !dumpmt -md 001739b0
EEClass: 001714e0
Module: 00172e7c
Name: BasicStack`1[[System.String, mscorlib]]
...
MethodDesc Table
Entry MethodDe JIT Name
...
00260360 00173924 JIT BasicStack`1[[System.__Canon, mscorlib]].Push(System.__Canon)
00260390 0017392c JIT BasicStack`1[[System.__Canon, mscorlib]].Pop()
0:004> !dumpmt -md 00173a04
EEClass: 001714e0
Module: 00172e7c
Name: BasicStack`1[[System.Int32[], mscorlib]]
...
MethodDesc Table
Entry MethodDe JIT Name
...
00260360 00173924 JIT BasicStack`1[[System.__Canon, mscorlib]].Push(System.__Canon)
00260390 0017392c JIT BasicStack`1[[System.__Canon, mscorlib]].Pop()
0:004> !dumpmt -md 00173a98
EEClass: 0017158c
Module: 00172e7c
Name: BasicStack`1[[System.Int32, mscorlib]]
...
MethodDesc Table
Entry MethodDe JIT Name
...
002603c0 00173a7c JIT BasicStack`1[[System.Int32, mscorlib]].Push(Int32)
002603f0 00173a84 JIT BasicStack`1[[System.Int32, mscorlib]].Pop()
0:004> !dumpmt -md 00173b40
EEClass: 001715ec
Module: 00172e7c
Name: BasicStack`1[[System.Double, mscorlib]]
...
MethodDesc Table
Entry MethodDe JIT Name
...
00260420 00173b24 JIT BasicStack`1[[System.Double, mscorlib]].Push(Double)
00260458 00173b2c JIT BasicStack`1[[System.Double, mscorlib]].Pop()
最后,如果我们检查实际的方法体,很明显引用类型版本根本不依赖于实际类型(它们所做的只是四处移动引用),而值类型版本依赖于类型。复制一个整数,毕竟不同于复制一个双浮点数。下面是 Push 方法的反汇编版本,突出显示了实际移动数据的行:
0:004> !u 00260360
Normal JIT generated code
BasicStack`1[[System.__Canon, mscorlib]].Push(System.__Canon)
00260360 57 push edi
00260361 56 push push esi
00260362 8b7104 push mov esi,dword ptr [ecx+4]
00260365 8b7908 push mov edi,dword ptr [ecx+8]
00260368 8d4701 push lea eax,[edi+1]
0026036b 894108 push mov dword ptr [ecx+8],eax
0026036e 52 push edx
0026036f 8bce push mov ecx,esi
00260371 8bd7 push mov edx,edi
00260373 e8f4cb3870 push call clr!JIT_Stelem_Ref (705ecf6c)
00260378 5e push pop esi
00260379 5f push pop edi
0026037a c3 push ret
0:004> !u 002603c0
Normal JIT generated code
BasicStack`1[[System.Int32, mscorlib]].Push(Int32)
002603c0 57 push edi
002603c1 56 push esi
002603c2 8b7104 mov esi,dword ptr [ecx+4]
002603c5 8b7908 mov edi,dword ptr [ecx+8]
002603c8 8d4701 lea eax,[edi+1]
002603cb 894108 mov dword ptr [ecx+8],eax
002603ce 3b7e04 cmp edi,dword ptr [esi+4]
002603d1 7307 jae 002603da
002603d3 8954be08 mov dword ptr [esi+edi*4+8],edx
002603d7 5e pop esi
002603d8 5f pop edi
002603d9 c3 ret
002603da e877446170 call clr!JIT_RngChkFail (70874856)
002603df cc int 3
0:004> !u 00260420
Normal JIT generated code
BasicStack`1[[System.Double, mscorlib]].Push(Double)
00260420 56 push esi
00260421 8b5104 mov edx,dword ptr [ecx+4]
00260424 8b7108 mov esi,dword ptr [ecx+8]
00260427 8d4601 lea eax,[esi+1]
0026042a 894108 mov dword ptr [ecx+8],eax
0026042d 3b7204 cmpyg esi,dword ptr [edx+4]
00260430 730c jae 0026043e
00260432 dd442408 fld qword ptr [esp+8]
00260436 dd5cf208 fstp qword ptr [edx+esi*8+8]
0026043a 5e pop esi
0026043b c20800 ret 8
0026043e e813446170 call clr!JIT_RngChkFail (70874856)
00260443 cc int 3
我们已经看到。NET 泛型实现在编译时是完全类型安全的。唯一要做的就是确定在泛型集合中使用值类型时不会产生装箱成本。事实上,因为 JIT 编译器为每个封闭的泛型类型编译单独的方法体,其中泛型类型参数是值类型,所以不需要装箱。
综上所述,。与 Java 泛型或 C++模板相比,NET 泛型提供了显著的优势。与 C++提供的狂野西部相比,泛型约束机制有些受限,但是跨程序集共享泛型类型带来的灵活性和按需生成(并共享)代码带来的性能优势是压倒性的。
收藏
那个。NET Framework 附带了大量的集合,本章的目的并不是对它们一一进行回顾,这是一项最好留给 MSDN 文档来完成的任务。但是,在选择集合时,需要考虑一些重要的因素,尤其是对性能敏感的代码。我们将在本节中探讨这些考虑事项。
注意除了内置数组,一些开发者对使用任何集合类都很谨慎。数组非常不灵活,不可调整大小,因此很难高效地实现某些操作,但众所周知,它们是所有集合实现中开销最小的。只要你配备了良好的测量工具,比如我们在第二章中考虑的那些工具,你就不应该害怕使用内置集合。的内部实现细节。本节中讨论的 NET 集合也将有助于做出好的决策。琐事的一个例子:在 foreach 循环中迭代 List < T >比在 for 循环中花费的时间稍长,因为 foreach- enumeration 必须验证底层集合在整个循环中没有被更改。
首先,回忆一下附带的集合类。NET 4.5(不包括我们单独讨论的并发集合)及其运行时性能特征。比较集合的插入、删除和查找性能是获得满足您需求的最佳候选项的合理方法。下表仅列出了泛型集合(非泛型对应集合已在中停用)。NET 2.0):
我们可以从系列设计中学到一些东西,包括入选的系列选择。NET 框架,以及一些集合的实现细节:
- 不同系列的存储要求差别很大。在本章的后面,我们将通过 List
和 LinkedList < T >来了解内部集合布局如何影响缓存性能。又如 SortedSet < T >和 List < T >类;前者是根据具有用于 n 元素的 n 节点的二叉查找树来实现的,而后者是根据 n 元素的连续阵列来实现的。在 32 位系统上,在一个有序集合中存储大小为 s 的 n 值类型需要(20 + s ) n 字节的存储,而链表只需要 sn 字节。 - 一些集合对它们的元素有额外的要求,以达到令人满意的性能。例如,正如我们在第三章中看到的,任何哈希表的实现都需要访问哈希表元素的良好哈希代码。
- (1)成本归集的摊销在执行得好的情况下,很难与 (1)成本归集的真实区分开来。毕竟很少有程序员(和程序!)都警惕 List < T >的事实。Add 有时会导致大量的内存分配开销,并且运行的时间与列表元素的数量成线性关系。摊销时间分析是一个有用的工具;它已经被用来证明许多算法和集合的最优性界限。
- 无处不在的时空权衡出现在集合设计中,甚至出现在选择将哪些集合包含在。NET 框架。SortedList < K,V >以线性时间插入和删除为代价,提供了非常紧凑和有序的元素存储,而 SortedDictionary < K,V > 占用了更多的空间,并且是无序的,但提供了所有操作的对数界限。
注意没有比提到字符串更好的机会了,字符串也是一个简单的集合类型——字符的集合。系统内部。String 类实现为不可变的、不可调整大小的字符数组。对字符串的所有操作都会导致一个新对象的创建。这就是为什么通过将数千个较小的字符串连接在一起来创建长字符串是非常低效的。系统。StringBuilder 类解决了这些问题,因为它的实现类似于 List < T >,当它变异时,它的内部存储加倍。每当您需要从大量(或未知)较小的字符串中构造一个字符串时,请使用 StringBuilder 进行中间操作。
集合类的丰富性可能看起来让人不知所措,但是有时没有一个内置集合是合适的。我们将在后面考虑几个例子。直到。NET 4.0 中,对内置集合抱怨的一个常见原因是缺乏线程安全性:表 5-1 中的集合对于多线程的并发访问都是不安全的。英寸 NET 4.0 系统。Concurrent 命名空间引入了几个新的集合,它们是为并发编程环境而全新设计的。
表 5-1。收藏在。NET 框架
*本例中的“摊销”是指有些操作可能花费 O ( n )时间,但大多数操作将花费 O (1)时间,因此跨越 n 操作的平均时间为 O (1)。
**除非数据按排序顺序插入,在这种情况下, O (1)。
并发收款
随着任务并行库的出现。NET 4.0 之后,对线程安全集合的需求变得更加迫切。在第六章中,我们将看到几个从多线程并发访问数据源或输出缓冲区的例子。现在,我们将重点放在可用的并发集合及其性能特征上,这与我们之前研究的标准(非线程安全)集合的精神是一致的。
表 5-2。中的并发集合。NET 框架
“详细信息”栏的说明:
1.在第六章中,我们将看到一个使用 CAS 的无锁栈的草图实现,并讨论 CAS 原子原语本身的优点。
2.ConcurrentQueue
3.ConcurrentBag
4.ConcurrentDictionary
尽管大多数并发集合与其非线程安全的对应集合非常相似,但它们的 API 略有不同,这是由它们的并发特性决定的。例如,ConcurrentDictionary
//This code is prone to a race condition between the ContainsKey and Add method calls:
Dictionary<string, int> expenses = ...;
if (!expenses.ContainsKey(“ParisExpenses”)) {
expenses.Add(“ParisExpenses”, currentAmount);
} else {
//This code is prone to a race condition if multiple threads execute it:
expenses[“ParisExpenses”] += currentAmount;
}
//The following code uses the AddOrUpdate method to ensure proper synchronization when
//adding an item or updating an existing item:
ConcurrentDictionary<string, int> expenses = ...;
expenses.AddOrUpdate(“ParisExpenses”, currentAmount, (key, amount) => amount + currentAmount);
AddOrUpdate 方法保证了围绕复合“添加或更新”操作的必要同步;有一个类似的 GetOrAdd helper 方法,它可以检索一个现有的值,或者如果它不存在就将其添加到字典中,然后返回它。
高速缓存注意事项
选择正确的集合不仅仅是考虑其性能。对于 CPU 受限的应用来说,数据在内存中的布局方式通常比任何其他标准都更为关键,集合对这种布局有很大的影响。仔细检查内存中的数据布局背后的主要因素是 CPU 缓存。
现代系统配有大容量的主存储器。8gb 内存在中档工作站或游戏笔记本电脑上是相当标准的。快速 DDR3 SDRAM 内存单元的内存访问延迟为 15 ns,理论传输速率为 15 GB/s。另一方面,快速处理器每秒可发出数十亿条指令;从理论上讲,在等待内存访问时拖延 15 ns 可以阻止几十条(有时甚至上百条)CPU 指令的执行。内存访问停顿是一种被称为撞上内存墙 的现象。
为了让应用远离这道墙,现代处理器配备了几级高速缓冲存储器,其内部特征与主存储器不同,往往非常昂贵且相对较小。例如,作者的英特尔 i7-860 处理器配备了三个高速缓存级别(参见图 5-3 ):
图 5-3 。英特尔 i7-860 高速缓存、内核和内存关系示意图
- 用于程序指令的一级高速缓存,32 KB,每个内核一个(总共 4 个高速缓存)。
- 用于数据的一级缓存,32 KB,每个内核一个(总共 4 个缓存)。
- 数据的二级缓存,256 KB,每个内核一个(总共 4 个缓存)。
- 用于数据的 3 级缓存,8 MB,共享(共 1 个缓存)。
当处理器试图访问一个内存地址时,它首先检查数据是否已经在它的 L1 缓存中。如果是,则从缓存中满足内存访问,这需要 5 个 CPU 周期(这称为缓存命中 )。如果不是,则检查 L2 缓存;满足来自 L2 高速缓存的访问需要 10 个周期。类似地,满足 L3 缓存的访问需要 40 个周期。最后,如果数据不在任何一级高速缓存中,处理器将暂停系统的主内存(这被称为高速缓存未命中)。当处理器访问主存储器时,它读取的不是单个字节或字,而是一个缓存行 ,在现代系统中,它由 32 或 64 个字节组成。访问同一高速缓存行上的任何字都不会涉及另一次高速缓存未命中,直到该行被从高速缓存中逐出。
虽然这种描述并没有公正对待 SRAM 高速缓存和 DRAM 存储器如何工作的真正硬件复杂性,但它为思考和讨论我们的高级软件算法如何受到存储器中数据布局的影响提供了足够的素材。我们现在考虑一个简单的例子,它涉及单个内核的高速缓存;在第六章中,我们将看到多处理器程序会因不恰当地利用多核缓存而遭受额外的性能损失。
假设手头的任务是遍历一个大的整数集合,并对它们执行一些聚合,比如求它们的和或平均值。下面是两种选择;一个从 LinkedList < int >中访问数据,另一个从整数数组(int[])中访问数据,其中两个是内置的。净收藏。
LinkedList<int> numbers = new LinkedList<int>(Enumerable.Range(0, 20000000));
int sum = 0;
for (LinkedListNode<int> curr = numbers.First; curr != null; curr = curr.Next) {
sum += curr.Value;
}
int[] numbers = Enumerable.Range(0, 20000000).ToArray();
int sum = 0;
for (int curr = 0; curr < numbers.Length; ++curr) {
sum += numbers[curr];
}
在上面提到的系统上,第二个版本的代码运行速度比第一个版本快 2 倍。这是一个不可忽略的差异,如果您只考虑发出的 CPU 指令的数量,您可能不相信应该有任何差异。毕竟,遍历链表需要从一个节点移动到下一个节点,而遍历数组需要在数组中增加一个索引。(事实上,如果没有 JIT 优化,访问数组元素也需要范围检查,以确保索引在数组的边界内。)
; x86 assembly for the first loop, assume ‘sum’ is in EAX and ‘numbers’ is in ECX
xor eax, eax
mov ecx, dword ptr [ecx+4] ; curr = numbers.First
test ecx, ecx
jz LOOP_END
LOOP_BEGIN:
add eax, dword ptr [ecx+10] ; sum += curr.Value
mov ecx, dword ptr [ecx+8] ; curr = curr.Next
test ecx, ecx
jnz LOOP_BEGIN ; total of 4 instructions per iteration
LOOP_END:
...
; x86 assembly for the second loop, assume ‘sum’ is in EAX and ‘numbers’ is in ECX
mov edi, dword ptr [ecx+4] ; numbers.Length
test edi, edi
jz LOOP_END
xor edx, edx ; loop index
LOOP_BEGIN:
add eax, dword ptr [ecx+edx*4+8] ; sum += numbers[i], no bounds check
inc edx
cmp esi, edx
jg LOOP_BEGIN ; total of 4 instructions per iteration
LOOP_END:
...
考虑到这两个循环的代码生成 (并禁止优化,例如使用 SIMD 指令遍历内存中连续的数组),很难通过只检查执行的指令来解释显著的性能差异。事实上,我们必须分析这段代码的内存访问模式,才能得出任何可接受的结论。
在这两个循环中,每个整数只被访问一次,缓存考虑似乎并不重要,因为没有可重用的数据可以从缓存命中中受益。尽管如此,数据在内存中的布局方式极大地影响了这个程序的性能——不是因为数据被重用,而是因为它被放入内存的方式。当访问数组元素时,高速缓存行开始处的高速缓存未命中会将 16 个连续整数带入高速缓存(高速缓存行= 64 字节= 16 个整数)。因为数组访问是顺序的,所以接下来的 15 个整数现在在缓存中,可以被访问而不会出现缓存缺失。这是一个近乎理想的场景,缓存缺失率为 1:16。另一方面,当访问链接列表元素时,在高速缓存行开始的高速缓存未命中可以将最多 3 个连续的链接列表节点带入高速缓存,1:4 的高速缓存未命中比率!(一个节点由后向指针、前向指针和整数数据组成,在 32 位系统上占用 12 个字节;引用类型头使计数达到每个节点 20 个字节。)
高得多的缓存缺失率是我们上面测试的两段代码之间的大部分性能差异的原因。此外,我们假设所有链表节点在内存中顺序定位的理想场景,只有在它们被同时分配且没有其他分配发生的情况下才会出现这种情况,这是相当不可能的。如果链表节点在内存中的分布不太理想,缓存未命中比率会更高,性能会更差。
展示高速缓存相关效应的另一方面的结论性实例是通过分块的矩阵乘法的已知算法。矩阵乘法(在讨论 C++ AMP 时,我们将在第六章中再次讨论)是一个相当简单的算法,它可以从 CPU 缓存中受益匪浅,因为元素可以多次重用。下面是简单的算法实现:
public static int[,] MultiplyNaive(int[,] A, int[,] B) {
int[,] C = new int[N, N];
for (int i = 0; i < N; ++i)
for (int j = 0; j < N; ++j)
for (int k = 0; k < N; ++k)
C[i, j] += A[i, k] * B[k, j];
return C;
}
在内部循环的中心,存在第一矩阵的第 i 行与第二矩阵的第 j 列的标量积;遍历整个第 i 行和第 j 列。缓存重用的可能性源于这样一个事实,即通过重复遍历第一个矩阵的第 i 行来计算输出矩阵中的第 i 行。相同的元素被多次重复使用。第一个矩阵以一种非常缓存友好的方式被遍历:它的第一行被完全迭代 N 次,然后它的第二行被完全迭代 N 次,依此类推。但是这并没有帮助,因为在外部循环使用迭代 i 完成后,该方法不再使用第 i 行。不幸的是,第二个矩阵是以一种非常不利于缓存的方式迭代的:它的第一个列被完全迭代了 N 次,然后是它的第二个列,依此类推。(这种缓存不友好的原因是矩阵,一个 int[,],是以行优先的顺序存储在内存中的,如图 5-4 所示。)
图 5-4 。二维数组的内存布局(int[,])。在内存中,行是连续的,列不是
如果高速缓存足够大以容纳整个第二矩阵,那么在外部循环的单次迭代之后,整个第二矩阵将在高速缓存中,并且以列优先顺序对其的后续访问仍将从高速缓存中得到满足。然而,如果第二个矩阵不适合高速缓存,高速缓存未命中将非常频繁地发生:元素 (i,j) 的高速缓存未命中将产生包含来自行 i 的元素但不包含来自列 j 的额外元素的高速缓存行,这意味着每次访问都有高速缓存未命中!
分块矩阵乘法引入了以下思想。将两个矩阵相乘可以通过上面的简单算法来实现,或者将它们分割成更小的矩阵(块),将这些块相乘,然后对结果执行一些额外的运算。
图 5-5 。以块的形式给出矩阵 A 和 B,每个矩阵有 k × k 个块
具体来说,如果矩阵 A 和 B 以分块形式给出,如图图 5-5 ,那么矩阵 C = AB 可以分块计算,这样Cij=Ai1B...+AikBkj。在实践中,这会导致以下代码:
public static int[,] MultiplyBlocked(int[,] A, int[,] B, int bs) {
int[,] C = new int[N, N];
for (int ii = 0; ii < N; ii += bs)
for (int jj = 0; jj < N; jj += bs)
for (int kk = 0; kk < N; kk += bs)
for (int i = ii; i < ii + bs; ++i)
for (int j = jj; j < jj + bs; ++j)
for (int k = kk; k < kk + bs; ++k)
C[i, j] += A[i, k] * B[k, j];
return C;
}
看似复杂的六个嵌套循环非常简单——最里面的三个循环执行两个块的简单矩阵乘法,最外面的三个循环迭代这些块。为了测试分块乘法算法,我们使用了前面示例中的同一台机器(它有一个 8 MB 的 L3 缓存),并对 2048 × 2048 个整数矩阵进行乘法运算。两个矩阵的总大小为 2048 × 2048 × 4 × 2 = 32 MB,缓存中放不下。不同块尺寸的结果如表 5-3 所示。在表 5-3 中,你可以看到分块有很大的帮助,找到最佳的分块大小对性能有显著的次要影响:
表 5-3 。不同块大小的块乘法的时序结果
即使在算法设计和集合优化领域之外,缓存考虑因素也是非常重要的,还有很多其他例子。还有一些关于高速缓存和内存相关的更精细的方面:不同级别的高速缓存之间的关系、高速缓存关联性的影响、内存访问依赖性和排序,以及许多其他方面。如需更多示例,请考虑阅读 Igor Ostrovsky 的简明文章“处理器缓存效果图库”(igoro . com/archive/Gallery-of-Processor-Cache-Effects/
,2010)。
定制收藏
在计算机科学文献中有许多著名的集合,但它们没有被列入。NET 框架。其中一些是相当常见的,您的应用可能会受益于使用它们而不是内置的。此外,它们中的大多数都提供了足够简单的算法,可以在合理的时间内实现。尽管我们并不打算探究这些种类繁多的收藏,但下面是两个与现有收藏有很大不同的例子。NET 集合,并深入了解自定义集合可能有用的情况。
不相交集数据结构(通常称为联合发现)是一个集合,其中存储了划分为不相交子集的元素。它与众不同。NET 集合,因为您没有在其中存储元素。相反,有一个元素域,其中每个元素形成一个集合,对数据结构的连续操作将集合连接在一起,形成更大的集合。该数据结构被设计成有效地执行两种操作:
- 联合:将两个子集连接在一起,形成一个子集。
- Find :确定一个特定的元素属于哪个子集。(最常用于确定两个元素是否属于同一个子集。)
通常,集合被作为代表元素来处理,每个集合有一个代表。然后,union 和 find 操作接收并返回代表,而不是整个集合。
union-find 的一个简单实现包括使用一个集合来表示每个集合,并在必要时将集合合并在一起。例如,当使用链表存储每个集合时,如果每个元素都有一个指向集合代表的指针,则合并需要线性时间,而查找操作可以在常数时间内实现。
Galler-Fischer 实现具有更好的运行时复杂性。集合存储在森林(树的集合)中;在每棵树中,每个节点都包含一个指向其父节点的指针,树的根是集合代表。为了确保生成的树是平衡的,当合并树时,较小的树总是附加到较大的树的根上(这需要跟踪树的深度)。此外,当 find 操作执行时,它会压缩从所需元素到其代表的路径。下面是一个草图实现:
public class Set<T> {
public Set Parent;
public int Rank;
public T Data;
public Set(T data) {
Parent = this;
Data = data;
}
public static Set Find(Set x) {
if (x.Parent != x) {
x.Parent = Find(x.Parent);
}
return x.Parent;
}
public static void Union(Set x, Set y) {
Set xRep = Find(x);
Set yRep = Find(y);
if (xRep == yRep) return; //It’s the same set
if (xRep.Rank < yRep.Rank) xRep.Parent = yRep;
else if (xRep.Rank > yRep.Rank) yRep.Parent = xRep;
else {
yRep.Parent = xRep;
++xRep.Rank; //Merged two trees of equal rank, so rank increases
}
}
}
图 5-6 。两个集合 x 和 y 的合并,其中 y 的集合较小。虚线箭头是合并的结果
对这种数据结构进行准确的运行时分析相当复杂;一个简单的上限是,在具有 n 个元素的森林中,每个操作的分摊时间是 O (log n ),其中 log **n(nn的重对数)是必须应用对数函数以获得小于 1 的结果的次数,即“log”必须出现在不等对数 log 中的最小次数...log n ≤ 1。对于 n 的实际值,例如 n ≤ 10 50 ,这不超过 5,即“有效常数”
跳过列表
跳转列表是一种数据结构,它存储元素的排序链表,并允许在 O (对数 n )时间内查找,类似于数组中的二分搜索法或平衡二叉树中的查找。显然,在链表中执行二分搜索法的主要问题是链表不允许通过索引进行随机访问。跳过列表通过使用逐渐稀疏的链接列表的层次结构来解决这个限制:第一个链接列表将所有节点链接在一起;第二链表将节点 0、2、4,...;第三个链表将节点 0、4、8,...;第四个链表将节点 0、8、16,...;等等。
为了对跳过列表中的元素执行查找,该过程首先迭代最稀疏的列表。当遇到大于或等于所需元素的元素时,该过程返回到上一个元素,并转到层次结构中的下一个列表。重复此过程,直到找到该元素。通过使用层次结构中的 O (log n )列表,可以保证 O (log n )查找时间。
不幸的是,维护跳转列表元素一点也不简单。如果在添加或删除元素时必须重新分配整个链表层次结构,那么它与简单的数据结构(如 SortedList
图 5-7 。层次结构中有四个随机化列表的 Skip 列表结构(图片来自维基百科:upload . wikimedia . org/Wikipedia/commons/8/86/Skip _ list . SVG
,发布到公共领域。)
一次性收藏
也可能是您有一个独特的情况,需要使用完全自定义的集合。我们称这些一次性集合为,因为它们可能是为你的特定领域量身定制的无可否认的新发明。随着时间的推移,您可能会发现您实现的一些一次性集合实际上是非常可重用的;在这一小节中,我们将看一个例子。
考虑下面的应用。您正在运行一个糖果交易系统,该系统让糖果交易商了解各种糖果的最新价格。您的主数据表存储在内存中,包含每种类型糖果的一行,列出其当前价格。表 5-4 是某一时刻的数据示例:
表 5-4 。糖果交换系统的数据表示例
糖果的类型 | 价格($) |
---|---|
Twix 先生 | Zero point nine three |
火星 | Zero point eight eight |
士力架 | One point zero two |
吻 | Zero point six six |
您的系统中有两种类型的客户端:
- 糖果商通过 TCP 套接字连接到您的系统,并定期向您询问某种糖果的最新信息。交易者的典型要求是“Twix 的价格是多少?”而你的回答是“$0.93”。每秒钟有几万个这样的请求。
- 糖果供应商通过 UDP 套接字连接到您的系统,并定期向您发送糖果价格更新。有两种类型的请求:
- “将火星的价格更新为 0.91 美元”。不需要回应。每秒钟有数千个这样的请求。
- “新增一款糖果,雪花,起价 0.49 美元”。不需要回应。每天不超过几十个这样的请求。
还知道 99.9 %的操作读取或更新在交易开始时存在的糖果类型的价格;只有 0.1 %的操作访问由 add 操作添加的糖果类型。
有了这些信息,您开始设计一个数据结构——一个集合——来将数据表存储在内存中。这个数据结构必须是线程安全的,因为在给定的时间内,数百个线程可能会竞争对它的访问。你不需要关心把数据复制到持久存储器;我们只检查它在内存中的性能特征。
我们的系统所服务的数据形状和请求类型强烈建议我们应该使用散列表来存储糖果价格。同步对哈希表的访问最好留给 ConcurrentDictionary
这个问题的一个可能的解决方案是安全-不安全缓存。这个集合是一组两个哈希表,即安全表和不安全表。在交易开始时,保险箱的桌子上已经放满了各种糖果;不安全的表开始是空的。在没有任何锁的情况下,安全表上的操作是令人满意的,因为它没有发生变异;新的糖果类型被添加到不安全的桌子上。下面是使用字典< K,V >和并发字典< K,V >的可能实现:
//Assumes that writes of TValue can be satisfied atomically, i.e. it must be a reference
//type or a sufficiently small value type (4 bytes on 32-bit systems).
public class SafeUnsafeCache<TKey, TValue> {
private Dictionary<TKey, TValue> safeTable;
private ConcurrentDictionary<TKey, TValue> unsafeTable;
public SafeUnsafeCache(IDictionary<TKey, TValue> initialData) {
safeTable = new Dictionary<TKey, TValue>(initialData);
unsafeTable = new ConcurrentDictionary<TKey, TValue>();
}
public bool Get(TKey key, out TValue value) {
return safeTable.TryGetValue(key, out value) || unsafeTable.TryGetValue(key, out value);
}
public void AddOrUpdate(TKey key, TValue value) {
if (safeTable.ContainsKey(key)) {
safeTable[key] = value;
} else {
unsafeTable.AddOrUpdate(key, value, (k, v) => value);
}
}
}
进一步的改进是定期停止所有交易操作,并将不安全表合并到安全表中。这将进一步改善糖果数据操作所需的预期同步。
实现 IENUMERABLE
几乎任何集合最终都会实现 IEnumerable
不幸的是,在集合中天真地实现 IEnumerable
列表
IEnumerator
长乘积= 1;
while(枚举器。MoveNext()) {
乘积*=枚举器。当前;
}
这里每次迭代有两次接口方法调用,对于遍历一个列表并寻找其元素的乘积来说,这是一个不合理的开销。你可能还记得第三章中的内容,内联接口方法调用并不简单,如果 JIT 不能成功内联它们,代价会很高。
有几种方法可以帮助避免接口方法调用的开销。当在值类型变量上直接调用接口方法时,可以直接调度接口方法。因此,如果上面例子中的枚举器变量是值类型(而不是 IEnumerator
为了实现这一点,List
公共类列表
公共枚举器 GetEnumerator() {
返回新的枚举器(this);
}
英数字元
返回新的枚举器(this);
}
...
公共结构枚举器{...}
}
这将启用以下调用代码,该代码完全消除了接口方法调用:
列表
列表。枚举器
长乘积= 1;
while(枚举器。MoveNext()) {
乘积*=枚举器。当前;
}
另一种方法是将枚举数设为引用类型,但对其 MoveNext 方法和 Current 属性重复相同的显式接口实现技巧。这也将允许调用者直接使用该类来避免接口调用成本。
摘要
在本章中,我们已经看到了十几个集合实现,并从内存密度、运行时复杂性、空间需求和线程安全的角度对它们进行了比较。你现在应该对选择收藏和证明你的选择的最优性有了更好的直觉,不应该害怕偏离。NET Framework 必须提供并实现一次性集合,或者使用计算机科学文献中的思想。
六、并发与并行
多年来,计算机系统的处理能力呈指数增长。每个型号的处理器都变得越来越快,旨在挑战昂贵工作站硬件资源的程序被移植到笔记本电脑和手持设备上。这个时代在几年前就结束了,今天处理器的速度并没有指数级增长;他们的数量呈指数增长。当多处理器系统罕见且昂贵时,编写程序以利用多处理核心的 并不容易,今天,当智能手机配备双核和四核处理器时,这也变得不容易。
*在本章中,我们将开始在. NET 中的现代并行编程的世界中进行一次旋风式的旅行。尽管这一适度的章节不能开始描述并行编程今天的所有 API、框架、工具、缺陷、设计模式和架构模型,但是没有一本关于性能优化的书是不完整的,除非讨论一种明显最便宜的提高应用性能的方法,即扩展到多个处理器。
挑战和收获
利用并行性 的另一个挑战是多处理器系统日益增长的异构性。CPU 制造商以提供价格合理的面向消费者的四核或八核处理系统以及几十核高端服务器系统而自豪。然而,如今中档工作站或高端笔记本电脑通常配备了强大的图形处理单元(GPU),支持数百并发线程。似乎这两种并行还不够,基础设施即服务(IaaS)的价格每周都在下降,使得一眨眼就可以访问千核云。
注 Herb Sutter 在他的文章《欢迎来到丛林》(2011)中对等待并行框架的异构世界进行了出色的概述。在 2005 年的另一篇文章“免费的午餐结束了”中,他塑造了日常编程中对并发和并行框架的兴趣的复苏。如果您发现自己渴望获得比这一章所能提供的更多的关于并行编程的信息,我们可以推荐以下关于并行编程主题的优秀书籍。特别是. NET 并行框架:Joe Duffy,“Windows 上的并发编程”(Addison-Wesley,2008);Joseph Albahari,“C# 中的线程化”(在线,2011)。为了更详细地了解操作系统围绕线程调度和同步机制的内部工作,马克·鲁西诺维奇、大卫·所罗门和亚历克斯·约内斯库的《Windows Internals,5 th Edition》(微软出版社,2009 年)是一篇很好的文章。最后,MSDN 是我们将在本章看到的 API 的一个很好的信息来源,比如任务并行库。
并行性带来的性能提升不容忽视。通过将 I/O 卸载到单独的线程,执行异步 I/O 以提供更高的响应能力,以及通过发出多个 I/O 操作 进行扩展,I/O 绑定的应用可以受益匪浅。通过利用所有可用的 CPU 内核,CPU 受限的应用可以在典型的消费类硬件上扩展一个数量级,或者通过利用所有可用的 GPU 内核扩展两个数量级。在这一章的后面,你将会看到一个执行矩阵乘法的简单算法是如何通过仅仅改变几行在 GPU 上运行的代码而被加速 130 倍的。
和往常一样,通往并行的道路充满了陷阱——死锁、竞争条件、饥饿和内存损坏在每一步都等着我们。最近的并行框架,包括任务并行库(。NET 4.0)和 C++ AMP ,我们将在本章中使用,目的是降低编写并行应用的复杂性,并获得成熟的性能收益。
为什么是并发和并行?
在应用中引入多线程控制有很多原因。这本书致力于提高应用的性能,事实上并发和并行的大部分原因在于性能领域。下面是一些例子:
- 发出异步 I/O 操作可以提高应用的响应能力。大多数 GUI 应用都有一个负责所有 UI 更新的控制线程;这个线程绝不能被长时间占用,以免 UI 对用户操作没有反应。
- 跨多个线程并行化工作可以更好地利用系统资源。配备了多个 CPU 内核甚至更多 GPU 内核的现代系统可以通过简单 CPU 限制算法的并行化获得数量级的性能提升。
- 一次执行几个 I/O 操作(例如,同时从多个旅游网站检索价格,或者更新几个分布式 Web 存储库中的文件)有助于提高整体吞吐量,因为大部分时间都在等待 I/O 操作完成,并且可以用于发出额外的 I/O 操作或者对已经完成的操作执行结果处理。
从线程到线程池再到任务
起初有线。线程是并行化应用和分配异步工作的最基本手段;它们是用户模式程序可用的最低级的抽象。线程在结构和控制方面提供的很少,编程线程直接类似于很久以前的非结构化编程,那时子例程、对象和代理还没有普及。
考虑下面这个简单的任务:给你一个大范围的自然数,要求你找出这个范围内的所有质数,并将它们存储在一个集合中。这是一个纯粹受 CPU 限制的任务,看起来很容易并行化。首先,让我们编写一个在单个 CPU 线程上运行的代码的简单版本:
//Returns all the prime numbers in the range [start, end)
public static IEnumerable < uint > PrimesInRange(uint start, uint end) {
List < uint > primes = new List < uint > ();
for (uint number = start; number < end; ++number) {
if (IsPrime(number)) {
primes.Add(number);
}
}
return primes;
}
private static bool IsPrime(uint number) {
//This is a very inefficient O(n) algorithm, but it will do for our expository purposes
if (number == 2) return true;
if (number % 2 == 0) return false;
for (uint divisor = 3; divisor < number; divisor += 2) {
if (number % divisor == 0) return false;
}
return true;
}
这里有什么需要改进的地方吗?也许算法太快了,试图优化它没有任何好处?嗯,对于一个相当大的范围,比如[100,200000],上面的代码在现代处理器上运行几秒钟,为优化留下了足够的空间。
您可能对算法的效率有很大的保留(例如,有一个微不足道的优化使它在 O (√ n )时间内运行,而不是线性时间),但不管算法的最优性如何,它似乎很可能很好地适应并行化。毕竟,发现 4977 是否是质数与发现 3221 是否是质数 是独立的,所以并行化上述代码的一个明显简单的方法是将范围划分为许多块,并创建一个单独的线程来处理每个块(如图 6-1 所示)。显然,我们必须同步对素数集合的访问,以防止它被多线程破坏。一种简单的方法是遵循以下原则:
public static IEnumerable < uint > PrimesInRange(uint start, uint end) {
List < uint > primes = new List < uint > ();
uint range = end - start;
uint numThreads = (uint)Environment.ProcessorCount; //is this a good idea?
uint chunk = range / numThreads; //hopefully, there is no remainder
Thread[] threads = new Thread[numThreads];
for (uint i = 0; i < numThreads; ++i) {
uint chunkStart = start + i*chunk;
uint chunkEnd = chunkStart + chunk;
threads[i] = new Thread(() = > {
for (uint number = chunkStart; number < chunkEnd; ++number) {
if (IsPrime(number)) {
lock(primes) {
primes.Add(number);
}
}
}
});
threads[i].Start();
}
foreach (Thread thread in threads) {
thread.Join();
}
return primes;
}
图 6-1 。在多线程之间划分质数的范围
在英特尔 i7 系统 上,顺序代码遍历范围【100,200000】平均需要 2950 ms,而并行版本平均需要 950 ms。从具有 8 个 CPU 内核的系统中,您期望获得更好的结果,但是这种特殊的 i7 处理器使用超线程,这意味着只有 4 个物理内核(每个物理内核托管两个逻辑内核)。4 倍的加速比是更合理的预期,我们获得了 3 倍的加速,这仍然是不可忽略的。然而,正如图 6-2 和 6-3 中并发分析器的报告所示,一些线程比其他线程完成得更快,导致整体 CPU 利用率远低于 100%(要在您的应用上运行并发分析器,请参考第二章)。
图 6-2 。总体 CPU 利用率上升到几乎 8 个逻辑核心(100%),然后在运行结束时下降到只有一个逻辑核心
图 6-3 。有些线程比其他线程完成得快得多。线程 9428 运行时间不到 200 毫秒,而线程 5488 运行时间超过 800 毫秒
事实上,这个程序可能比顺序版本运行得更快(尽管不会线性扩展),特别是如果你在混合中加入了很多内核。这招致几个疑问 ,然而:
- 多少线程是最佳的?如果系统有八个 CPU 核心,我们应该创建八个线程吗?
- 我们如何确保不独占系统资源或造成超额订阅?例如,如果我们的进程中有另一个线程需要计算质数,并试图运行与我们相同的并行算法,该怎么办?
- 线程如何同步对结果集合的访问?从多个线程访问一个列表< uint >是不安全的,会导致数据损坏,我们将在后面的章节中看到。然而,每当我们向集合中添加一个质数时(这是上面的天真解决方案所做的)获取一个锁将被证明是极其昂贵的,并且抑制了我们将算法扩展到更多处理核心的能力。
- 对于一个小范围的数字,是否值得产生几个新线程,或者在一个线程上同步执行整个操作可能是一个更好的主意?(在 Windows 上创建和销毁一个线程很便宜,但不如找出 20 个小数字是质数还是合数便宜。)
- 我们如何确保所有线程的工作量相等?有些线程可能比其他线程完成得更快,尤其是那些处理较小数量的线程。对于分成四等份的范围[100,100000],负责范围[100,25075]的线程的完成速度将是负责范围[75025,100000]的线程的两倍多,因为我们的素性测试算法在遇到大素数时会变得越来越慢。
- 我们应该如何处理其他线程可能出现的异常?在这种特殊情况下,IsPrime 方法似乎不会出现错误,但在现实世界的示例中,并行化工作可能会遇到潜在的陷阱和异常情况。(CLR 的默认行为是当线程因未处理的异常而失败时终止整个进程,这通常是一个好主意—快速失败语义—但根本不允许 PrimesInRange 的调用方处理异常。)
这些问题的好答案远非微不足道,开发一个允许并行工作执行而不产生太多线程的框架,避免超额订阅并确保工作在所有线程中均匀分布,可靠地报告错误和结果,并与流程中的其他并行资源合作,这正是任务并行库的设计者的任务,我们将在接下来处理。
从手动线程管理开始,自然的第一步是走向线程池 。线程池 ?? 是一个组件,它管理一组可用于工作项目执行的线程。不是创建一个线程来执行某个任务,而是将该任务排队到线程池中,线程池选择一个可用的线程并分派该任务来执行。线程池有助于解决上面强调的一些问题-它们降低了为极短的任务创建和销毁线程的成本,通过限制应用使用的线程总数来帮助避免资源独占和超额订阅,并自动决定给定任务的最佳线程数。
在我们的特殊情况下,我们可能决定将这个数字范围分成更大数量的块(在极端情况下,每个循环迭代一个块),并将它们排队到线程池中。下面是一个块大小为 100 的方法示例:
public static IEnumerable < uint > PrimesInRange(uint start, uint end) {
List < uint > primes = new List < uint > ();
const uint ChunkSize = 100;
int completed = 0;
ManualResetEvent allDone = new ManualResetEvent(initialState: false);
uint chunks = (end - start) / ChunkSize; //again, this should divide evenly
for (uint i = 0; i < chunks; ++i) {
uint chunkStart = start + i*ChunkSize;
uint chunkEnd = chunkStart + ChunkSize;
ThreadPool.QueueUserWorkItem(_ => {
for (uint number = chunkStart; number < chunkEnd; ++number) {
if (IsPrime(number)) {
lock(primes) {
primes.Add(number);
}
}
}
if (Interlocked.Increment(ref completed) == chunks) {
allDone.Set();
}
});
}
allDone.WaitOne();
return primes;
}
这个版本的代码比我们之前考虑的版本更具可伸缩性,执行速度也更快。它改进了简单的基于线程版本所需的 950 ms(范围为[100,300000]),平均在 800 ms 内完成(与顺序版本相比,几乎提高了 4 倍)。此外,正如图 6-4 的中的 所示,CPU 使用率一直保持在接近 100%的水平。
图 6-4 。在程序执行期间,CLR 线程池使用了 8 个线程(每个逻辑内核一个线程)。每个线程几乎运行了整个持续时间
从 CLR 4.0 开始,CLR 线程池由几个协同工作的组件组成。当一个不属于线程池的线程(比如应用的主线程)将工作项目分派到线程池时,它们会被放入一个全局 FIFO(先进先出)队列。每个线程池线程都有一个本地 LIFO(后进先出)队列,它会将在该线程上创建的工作项排入队列(参见图 6-5 )。当线程池线程寻找工作时,它首先查询自己的 LIFO 队列,只要工作项可用,就执行其中的工作项。如果一个线程的 LIFO 队列耗尽,它将尝试工作窃取——查询其他线程的本地队列,并从它们那里获取工作项目,按照 FIFO 的顺序。最后,如果所有的本地队列都是空的,线程将查询全局(FIFO)队列,并从那里执行工作项目。
图 6-5 。线程#2 当前正在执行工作项目# 5;完成执行后,它将从全局 FIFO 队列中借用工作。线程#1 将在处理任何其他工作之前清空其本地队列
线程池 FIFO 和 LIFO 语义
明显古怪的 FIFO 和 LIFO 队列语义背后的原因如下:当工作在全局队列中排队时,没有特定的线程对执行该工作有任何偏好,公平是选择执行工作的唯一标准。这就是 FIFO 语义适合全局队列的原因。然而,当线程池线程将工作项排队以供执行时,它很可能使用与当前执行的工作项相同的数据和相同的指令;这就是为什么将它放入属于同一线程的 LIFO 队列是有意义的——它将在当前执行的工作项之后不久执行,并利用 CPU 数据和指令缓存。
此外,与访问全局队列相比,访问线程的本地队列上的工作项需要较少的同步,并且不太可能遇到来自其他线程的争用。类似地,当一个线程从另一个线程的队列中窃取工作时,它会按照 FIFO 的顺序进行窃取,这样就可以保持相对于原始线程处理器上的 CPU 缓存的 LIFO 优化。这种线程池结构对工作项层次结构非常友好,在这种层次结构中,加入全局队列的单个工作项将产生许多额外的工作项,并为几个线程池线程提供工作。
与任何抽象 一样,线程池从应用开发人员手中夺走了对线程生命周期和工作项目调度的一些粒度控制。虽然 CLR 线程池有一些控件 API,比如 thread pool。控制线程数量的 SetMinThreads 和 SetMaxThreads,它没有内置的 API 来控制其线程或任务的优先级。然而,通常情况下,应用在更强大的系统上自动伸缩的能力,以及不必为短期任务创建和销毁线程所带来的性能提升,大大弥补了这种控制的损失。
排队到线程池中的工作项目是极其不称职的;它们没有状态,不能携带异常信息,不支持异步延续和取消,也没有任何从已经完成的任务中获取结果的机制。任务平行库 中。NET 4.0 引入了任务*,这是线程池工作项之上的强大抽象。任务是线程和线程池工作项的结构化替代,就像对象和子例程是基于 goto 的汇编语言编程的结构化替代一样。
任务并行度
任务并行性是一种范式和一组 API,用于将一个大任务分解成一组较小的任务,并在多个线程上执行它们。任务并行库(TPL)拥有一流的 API,可以同时管理数百万个任务(通过 CLR 线程池)。第三方物流的核心是系统。Threading.Tasks.Task 类,代表一个任务。任务类 提供了以下功能:
- 为在未指定的线程上独立执行调度工作。(执行给定任务的特定线程由任务调度器决定;默认的任务计划程序将任务排入 CLR 线程池,但也有一些计划程序将任务发送到特定的线程,如 UI 线程。)
- 等待任务完成并获得其执行结果。
- 提供应该在任务完成后立即运行的延续。(这通常被称为回调,但我们将在本章通篇使用术语延续。)
- 处理单个任务中出现的异常,甚至是调度任务执行的原始线程上的任务层次结构中出现的异常,或者是对任务结果感兴趣的任何其他线程中出现的异常。
- 取消尚未开始的任务,并将取消请求传达给正在执行的任务。
因为我们可以将任务视为线程之上的高级抽象,所以我们可以重写用于素数计算的代码,以使用任务而不是线程。事实上,这将使代码更短——至少,我们不需要已完成任务计数器和 ManualResetEvent 对象来跟踪任务执行。然而,正如我们将在下一节中看到的,TPL 提供的数据并行性 API 甚至更适合于并行化在一个范围内寻找所有质数的循环。相反,我们应该考虑一个不同的问题。
有一种众所周知的基于递归比较的排序算法叫做 QuickSort,它非常容易实现并行化(并且平均 case 运行时复杂度为 O ( n log( n )),这是最优的——尽管目前很少有大型框架使用 QuickSort 来排序任何东西)。快速排序算法 如下进行:
public static void QuickSort < T > (T[] items) where T : IComparable < T > {
QuickSort(items, 0, items.Length);
}
private static void QuickSort < T > (T[] items, int left, int right) where T : IComparable < T > {
if (left == right) return;
int pivot = Partition(items, left, right);
QuickSort(items, left, pivot);
QuickSort(items, pivot + 1, right);
}
private static int Partition < T > (T[] items, int left, int right) where T : IComparable < T > {
int pivotPos = . . .; //often a random index between left and right is used
T pivotValue = items[pivotPos];
Swap(ref items[right-1], ref items[pivotPos]);
int store = left;
for (int i = left; i < right - 1; ++i) {
if (items[i].CompareTo(pivotValue) < 0) {
Swap(ref items[i], ref items[store]);
++store;
}
}
Swap(ref items[right-1], ref items[store]);
return store;
}
private static void Swap < T > (ref T a, ref T b) {
T temp = a;
a = b;
b = temp;
}
图 6-6 是分割方法 ?? 的一个单独步骤的示意图。选择第四个元素(其值为 5)作为轴心。首先,它被移到数组的最右边。接下来,所有大于轴心的元素都向数组的右侧传播。最后,定位枢轴,使其右侧的所有元素严格大于它,其左侧的所有元素小于或等于它。
图 6-6 。对 Partition 方法的单次调用的说明。
QuickSort 在每一步进行的递归调用必须设置并行化警报。对数组的左右部分进行排序是独立的任务,它们之间不需要同步,Task 类非常适合表达这一点。下面是使用任务并行化快速排序的首次尝试:
public static void QuickSort < T > (T[] items) where T : IComparable < T > {
QuickSort(items, 0, items.Length);
}
private static void QuickSort < T > (T[] items, int left, int right) where T : IComparable < T > {
if (right - left < 2) return;
int pivot = Partition(items, left, right);
Task leftTask = Task.Run(() => QuickSort(items, left, pivot));
Task rightTask = Task.Run(() => QuickSort(items, pivot + 1, right));
Task.WaitAll(leftTask, rightTask);
}
private static int Partition < T > (T[] items, int left, int right) where T : IComparable < T > {
//Implementation omitted for brevity
}
任务。Run 方法 创建一个新任务(相当于调用 new task())并调度它执行(相当于新创建任务的 Start 方法)。任务。WaitAll 静态方法等待两个任务都完成,然后返回。请注意,我们不必指定如何等待任务完成,也不必指定何时创建线程以及何时销毁线程。
有一种有用的实用方法叫做并行。调用 ,它执行提供给它的一组任务,并在所有任务完成后返回。这将允许我们用下面的代码重写快速排序方法的核心:
Parallel.Invoke(
() => QuickSort(items, left, pivot),
() => QuickSort(items, pivot + 1, right)
);
不管我们是否使用并行。手动调用或创建任务,如果我们尝试将这个版本与简单的顺序版本进行比较,我们会发现它的运行速度明显慢,尽管它似乎利用了所有可用的处理器资源。事实上,使用 1,000,000 个随机整数的数组,顺序版本运行(在我们的测试系统上)平均需要 250 ms,而并行版本平均需要将近 650 ms 才能完成!
问题是并行需要足够粗的粒度;尝试对三元素数组进行并行排序是徒劳的,因为创建任务对象、将工作项目调度到线程池以及等待它们完成执行所带来的开销完全超过了所需的少量比较操作。
递归算法中的节流并行
你打算如何抑制并行性,以防止这种开销减少我们优化的任何回报?有几种可行的方法:
- 只要要排序的数组的大小大于某个阈值(比如说 500 项),就使用并行版本,一旦它变小,就切换到顺序版本。
- 只要递归深度小于某个阈值,就使用并行版本,一旦递归非常深,就切换到顺序版本。(这个选项比前一个稍逊一筹,除非轴心总是正好位于数组的中间。)
- 只要未完成任务的数量(该方法必须手动维护)小于某个阈值,就使用并行版本,否则切换到顺序版本。(当没有其他限制并行性的标准(如递归深度或输入大小)时,这是唯一的选择。)
事实上,在上面的案例中,限制大于 500 个元素的数组的并行化在作者的英特尔 i7 处理器上产生了出色的结果,与顺序版本相比,执行时间提高了 4 倍。代码更改非常简单,尽管在生产质量的实现中不应该硬编码阈值:
private static void QuickSort < T > (T[] items, int left, int right) where T : IComparable < T > {
if (right - left < 2) return;
int pivot = Partition(items, left, right);
if (right - left > 500) {
Parallel.Invoke(
() => QuickSort(items, left, pivot),
() => QuickSort(items, pivot + 1, right)
);
} else {
QuickSort(items, left, pivot);
QuickSort(items, pivot + 1, right);
}
}
递归分解的更多例子
通过应用类似的递归分解 ,可以并行化许多附加算法。事实上,几乎所有将输入分成几个部分的递归算法都被设计成在每个部分上独立执行,然后组合结果。在本章的后半部分,我们将考虑不那么容易屈服于并行化的例子,但首先让我们来看几个这样做的例子:
- 斯特拉森矩阵乘法算法(概述见
http://en.wikipedia.org/wiki/Strassen_algorithm
)。这种矩阵乘法的算法比我们将在本章后面看到的简单的立方算法提供了更好的性能。Strassen 的算法将一个大小为 2 n × 2 n 的矩阵递归分解成大小为 2 n-1 × 2 n-1 的四个相等的分块矩阵,并使用了一个依靠七次乘法而不是八次乘法的聪明绝招来获得渐近运行时间∽O(n2.807)。在快速排序的例子中,对于足够小的矩阵,Strassen 算法的实际实现通常会退回到标准的立方算法;当使用递归分解并行化 Strassen 算法时,为较小的矩阵设置一个并行化阈值甚至更为重要。 - 快速傅立叶变换(库利-图基算法,见
http://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm
)。该算法通过将长度为 2 n 的向量递归分解为两个大小为 2 n-1 的向量来计算该向量的 DFT(离散傅立叶变换)。并行化这种计算是相当容易的,但是同样重要的是要警惕为足够小的向量设置并行化的阈值。 - 图形遍历(深度优先搜索或广度优先搜索)。正如我们在第四章中看到的,CLR 垃圾收集器遍历一个图,其中对象是顶点,对象之间的引用是边。使用 DFS 或 BFS 的图遍历可以从并行化以及我们考虑过的其他递归算法中受益匪浅;然而,与快速排序或 FFT 不同,当并行化图遍历的分支时,很难预先估计递归调用所代表的工作量。这种困难需要启发式方法来决定如何将搜索空间划分给多线程:我们已经看到服务器 GC 风格相当粗糙地执行这种划分,基于每个处理器分配对象的独立堆。
如果您正在寻找更多的例子来练习您的并行编程技能,也可以考虑 Karatsuba 的乘法算法,该算法依靠递归分解将∽O(n1.585)运算中的 n 位数字相乘;依赖递归分解进行排序的归并排序,类似于快速排序;以及许多动态编程算法,这些算法通常需要高级技巧来在并行计算的不同分支中采用记忆化(我们将在后面研究一个例子)。
例外和取消
我们还没有挖掘出任务类的全部能力。假设我们想要处理递归调用 QuickSort 时可能出现的异常,并在排序操作尚未完成时提供取消整个排序操作的支持。
任务执行环境提供了基础结构,用于将任务中出现的异常封送回到被认为适合接收它的任何线程。假设快速排序任务的一个递归调用遇到了一个异常,可能是因为我们没有仔细考虑数组的边界,给数组的任何一边引入了一个差 1 的错误。这个异常会在线程池线程上出现,线程池线程不受我们的显式控制,并且不允许任何总体异常处理行为。幸运的是,TPL 将捕获异常,并将其存储在 Task 对象中,供以后传播。
当程序试图等待任务完成(使用任务)时,任务中出现的异常将被重新抛出(包装在 AggregateException 对象中)。等待实例方法)或检索其结果(使用任务。结果属性)。这允许在创建任务的代码中进行自动和集中的异常处理,并且不需要将错误手动传播到中央位置和同步错误报告活动。下面的最小代码示例演示了 TPL 中的异常处理范例:
int i = 0;
Task < int > divideTask = Task.Run(() = > { return 5/i; });
try {
Console.WriteLine(divideTask.Result); //accessing the Result property eventually throws
} catch (AggregateException ex) {
foreach (Exception inner in ex.InnerExceptions) {
Console.WriteLine(inner.Message);
}
}
注意当从现有任务的主体中创建任务时,TaskCreationOptions。AttachedToParent 枚举值在新子任务和创建它的父任务之间建立关系。我们将在本章后面看到,任务之间的父子关系影响任务执行的取消、继续和调试方面。然而,就异常处理而言,等待父任务完成意味着等待所有子任务完成,并且子任务的任何异常也会传播到父任务。这就是为什么 TPL 抛出一个 AggregateException 实例,该实例包含一个层次结构的异常,这些异常可能是从一个层次结构的任务中产生的。
取消现有工作是另一个需要考虑的问题。假设我们有一个任务层次结构,比如使用 TaskCreationOptions 时由 QuickSort 创建的层次结构。AttachedToParent 枚举值。即使可能有数百个任务同时运行,我们也可能希望向用户提供取消语义,例如,如果不再需要排序后的数据。在其他场景中,取消未完成的工作可能是任务执行的一个组成部分。例如,考虑使用 DFS 或 BFS 在图中查找节点的并行算法。当找到所需的节点时,应该调用执行查找的整个任务层次结构。
取消任务涉及 CancellationTokenSource 和 CancellationToken 类型,协同执行。换句话说,如果一个任务的执行已经在进行中,就不能使用 TPL 的取消机制粗暴地终止它。取消已经执行的工作需要执行该工作的代码的配合。然而,尚未开始执行的任务可以被完全取消,而不会产生任何不良后果。
下面的代码演示了一个二叉树查找,其中每个节点包含一个可能很长的需要线性遍历的元素数组;呼叫者可以使用 TPL 的取消机制来取消整个查找。一方面,未开始的任务将被 TPL 自动取消;另一方面,已经启动的任务将周期性地监视它们的取消令牌以获得取消指令,并在需要时协作停止。
public class TreeNode < T > {
public TreeNode < T > Left, Right;
public T[] Data;
}
public static void TreeLookup < T > (
TreeNode < T > root, Predicate < T > condition, CancellationTokenSource cts) {
if (root == null) {
return;
}
//Start the recursive tasks, passing to them the cancellation token so that they are
//cancelled automatically if they haven't started yet and cancellation is requested
Task.Run(() => TreeLookup(root.Left, condition, cts), cts.Token);
Task.Run(() => TreeLookup(root.Right, condition, cts), cts.Token);
foreach (T element in root.Data) {
if (cts.IsCancellationRequested) break; //abort cooperatively
if (condition(element)) {
cts.Cancel(); //cancels all outstanding work
//Do something with the interesting element
}
}
}
//Example of calling code:
CancellationTokenSource cts = new CancellationTokenSource();
Task.Run(() = > TreeLookup(treeRoot, i = > i % 77 == 0, cts);
//After a while, e.g. if the user is no longer interested in the operation:
cts.Cancel();
不可避免地,会有一些算法的例子需要一种更简单的方法来表达并行性。考虑我们开始的素性测试例子。我们可以手动将该范围划分为块,为每个块创建一个任务,然后等待所有任务完成。事实上,有一个完整的算法家族,其中有一个特定操作所应用的数据范围。这些算法要求比任务并行更高层次的抽象。我们现在转向这个抽象概念。
数据并行度
任务并行性主要处理任务,而数据并行性旨在将任务从直接视图中移除,并用更高级的抽象(并行循环)来取代它们。换句话说,并行性的来源不是算法的代码,而是它操作的数据。任务并行库提供了几个提供数据并行的 API。
平行。对于和平行。ForEach
for 和 foreach 循环 通常是并行化的绝佳选择。事实上,自从并行计算出现以来,已经有人尝试自动并行化这种循环。一些尝试已经走上了语言变化或语言扩展的道路,例如 OpenMP 标准(引入了#pragma omp parallel for 等指令来并行化 for 循环)。任务并行库通过显式 API 提供了循环并行性,尽管如此,它还是非常接近于它们的语言对应物。这些 API 是并行的。对于和平行。foreach,尽可能匹配语言中 for 和 ForEach 循环的行为。
回到并行化素性测试的例子,我们有一个在大范围数字上迭代的循环,检查每个数字的素性并将其插入到一个集合中,如下所示:
for (int number = start; number < end; ++number) {
if (IsPrime(number)) {
primes.Add(number);
}
}
将此代码转换为使用并行。因为几乎是一个机械的任务,尽管同步访问素数集合需要一些小心(还有更好的方法,比如聚合,我们稍后会考虑):
Parallel.For(start, end, number => {
if (IsPrime(number)) {
lock(primes) {
primes.Add(number);
}
}
});
通过用 API 调用替换语言级循环,我们获得了循环迭代的自动并行化。此外,平行。For API 不是一个直接的循环,它在每次迭代中生成一个任务,或者为范围内每个硬编码的块大小的部分生成一个任务。而是平行。For 缓慢适应单个迭代的执行速度,考虑当前正在执行的任务数量,并通过动态划分迭代范围来防止过于细粒度的行为。手动实现这些优化并不简单,但是您可以使用另一个并行重载来应用特定的定制(比如控制并发执行任务的最大数量)。这需要一个 ParallelOptions 对象,或者使用一个定制的分割器来确定如何在不同的任务之间划分迭代范围。
一个类似的 API 处理 foreach 循环,其中当循环开始时,数据源可能没有被完全枚举,并且实际上可能不是有限的。假设我们需要从 Web 上下载一组 RSS 提要,指定为 IEnumerable < string>。循环的骨架将具有以下形状:
IEnumerable < string > rssFeeds = . . .;
WebClient webClient = new WebClient();
foreach (string url in rssFeeds) {
Process(webClient.DownloadString(url));
}
这个循环可以通过机械转换来并行化,其中 foreach 循环被对 Parallel 的 API 调用所取代。ForEach 。注意,数据源(rssFeeds 集合)不必是线程安全的,因为它是并行的。ForEach 将在从几个线程访问它时使用同步。
IEnumerable < string > rssFeeds = . . .; //The data source need
*not*be thread-safe
WebClient webClient = new WebClient();
Parallel.ForEach(rssFeeds, url => {
Process(webClient.DownloadString(url));
});
注意您可以表达对在无限数据源上执行操作的担忧。然而,事实证明,开始这样的操作并期望在满足某些条件时尽早终止它是非常方便的。例如,考虑一个无限的数据源,比如所有的自然数(在代码中由返回 IEnumerable < BigInteger >的方法指定)。我们可以编写并并行化一个循环,寻找一个数字和为 477 但不能被 133 整除的数。希望有这样一个数,我们的循环会终止。
并行化循环并不像上面讨论的那样简单。在我们将这个工具牢牢地系在腰带上之前,我们需要考虑几个“缺失”的特征。首先,C# 循环有 break 关键字,它可以提前终止循环。当我们甚至不知道哪个迭代正在我们自己的线程之外的线程上执行时,我们如何终止一个已经跨多个线程并行化的循环呢?
ParallelLoopState 类表示并行循环的执行状态,并允许从循环中提前中断。这里有一个简单的例子:
int invitedToParty = 0;
Parallel.ForEach(customers, (customer, loopState) = > {
if (customer.Orders.Count > 10 && customer.City == "Portland") {
if (Interlocked.Increment(ref invitedToParty) > = 25) {
loopState.Stop(); //no attempt will be made to execute any additional iterations
}
}
});
注意,Stop 方法并不保证最后执行的迭代就是调用它的那个——已经开始执行的迭代将运行到完成(除非它们轮询 ParallelLoopState)。ShouldExitCurrentIteration 属性)。但是,已经排队的额外迭代将不会开始执行。
ParallelLoopState 的缺点之一。Stop 的一个缺点是,它不能保证直到某一次迭代的所有迭代都已执行。例如,如果有 1,000 个客户,则可能客户 1–100 已被完全处理,客户 101–110 根本没有被处理,而客户 111 是在调用 Stop 之前最后被处理的。如果您希望保证某个迭代之前的所有迭代都已经执行(即使它们还没有开始!),应该使用 ParallelLoopState。请改用 Break 方法。
平行 LINQ (PLINQ)
并行计算的最高抽象层次可能是这样的:你声明:“我希望这段代码并行运行”,剩下的留给框架来实现。这就是平行 LINQ 的意义所在。但是首先,应该对 LINQ 进行一个简短的回顾。LINQ(语言集成查询)是在 C# 3.0 和中引入的一个框架和一组语言扩展。NET 3.5,模糊了命令式编程和声明式编程在数据迭代方面的界限。例如,下面的 LINQ 查询从名为 customers 的数据源(可能是内存中的集合、数据库表或更奇特的来源)中检索在过去十个月中至少购买了三次 10 美元以上商品的华盛顿客户的姓名和年龄,并将其打印到控制台:
var results = from customer in customers
where customer.State == "WA"
let custOrders = (from order in orders
where customer.ID == order.ID
select new { order.Date, order.Amount })
where custOrders.Count(co => co.Amount >= 10 &&
co.Date >= DateTime.Now.AddMonths(−10)) >= 3
select new { customer.Name, customer.Age };
foreach (var result in results) {
Console.WriteLine("{0} {1}", result.Name, result.Age);
}
这里要注意的主要事情是,大多数查询都是以声明方式指定的——非常像 SQL 查询。它不使用循环来过滤对象或将来自不同数据源的对象组合在一起。通常,您不应该担心同步查询的不同迭代,因为大多数 LINQ 查询都是纯函数性的,没有副作用——它们将一个集合(IEnumerable < T>)转换为另一个集合,而不修改过程中的任何其他对象。
为了并行执行上述查询,唯一需要的代码更改是将源集合从通用 IEnumerable < T >修改为 ParallelQuery < T>。AsParallel 扩展方法 负责这一点,并允许以下优雅的语法:
var results = from customer in customers.AsParallel()
where customer.State == "WA"
let custOrders = (from order in orders
where customer.ID == order.ID
select new { order.Date, order.Amount })
where custOrders.Count(co => co.Amount >= 10 &&
co.Date > = DateTime.Now.AddMonths(−10)) >= 3
select new { customer.Name, customer.Age };
foreach (var result in results) {
Console.WriteLine("{0} {1}", result.Name, result.Age);
}
PLINQ 使用三级处理流水线来执行并行查询 ,如图图 6-7 所示。首先,PLINQ 决定应该使用多少线程来并行执行查询。接下来,工作线程从源集合中检索工作块,确保它在锁定状态下被访问。每个线程继续独立执行它的工作项,并且结果在每个线程内本地排队。最后,所有本地结果都被缓冲到一个结果集合中,在上面的示例中,这个结果集合由一个 foreach 循环进行轮询。
图 6-7 。PLINQ 中的工作项执行。实心灰色的工作项已经完成并被放置在线程本地缓冲区 中,它们随后从那里被移动到调用者可用的最终输出缓冲区。虚线工作项当前正在执行
PLINQ 相比并行的主要优势 。ForEach 源于这样一个事实,即 PLINQ 在执行查询的每个线程中本地自动处理临时处理结果的聚合。使用并行时。为了找到质数,我们必须访问一个质数的全局集合来聚合结果(在本章的后面,我们将考虑使用聚合的优化)。这种全局访问需要连续的同步,并且引入了大量的开销。我们可以通过使用 PLINQ 实现相同的结果,如下所示:
List < int > primes = (from n in Enumerable.Range(3, 200000).AsParallel()
where IsPrime(n)
select n).ToList();
//Could have used ParallelEnumerable.Range instead of Enumerable.Range(. . .).AsParallel()
定制并行循环和 PLINQ
并联回路 (并联。对于和平行。ForEach)和 PLINQ 有几个定制 API,这使它们非常灵活,在丰富性和表达性方面接近我们之前考虑过的显式任务并行 API。并行循环 API 接受具有各种属性的 ParallelOptions 对象,而 PLINQ 依赖于 ParallelQuery < T >的附加方法。这些选项包括:
- 限制并行度(允许并发执行的任务数量)
- 提供用于取消并行执行的取消令牌
- 强制并行查询的输出排序
- 控制并行查询的输出缓冲(合并模式)
对于并行循环,最常见的是使用 ParallelOptions 类来限制并行度,而对于 PLINQ,您通常会自定义查询的合并模式和排序语义。有关这些定制选项的更多信息,请参考 MSDN 文档。
C# 5 异步方法
到目前为止,我们考虑了丰富的 API,这些 API 允许使用任务并行库的类和方法来表达各种并行解决方案。然而,在 API 笨拙或不够简洁的地方,其他并行编程环境有时依赖语言扩展来获得更好的表达能力。在这一节中,我们将看到 C# 5 如何通过提供一种更容易表达延续的语言扩展来适应并发编程世界的挑战。但是首先,我们必须考虑异步编程世界中的延续。
通常,您会希望将一个延续 (或回调)与一个特定的任务相关联;当任务完成时,应该执行延续。如果您可以控制任务,也就是说,您可以调度任务的执行,那么您可以将回调嵌入到任务本身中,但是如果您从另一个方法接收任务,那么显式的 continuation API 是理想的。TPL 提供了 ContinueWith instance 方法和 continue when all/continue when any 静态方法(不言自明)来控制几种设置中的延续。可以仅在特定情况下(例如,仅当任务运行完成时或仅当任务遇到异常时)调度该继续,并且可以使用 TaskScheduler API 在特定线程或线程组上调度该继续。以下是各种 API 的一些示例:
Task < string > weatherTask = DownloadWeatherInfoAsync(. . .);
weatherTask.ContinueWith(_ => DisplayWeather(weatherTask.Result), TaskScheduler.Current);
Task left = ProcessLeftPart(. . .);
Task right = ProcessRightPart(. . .);
TaskFactory.ContinueWhenAll(
new Task[] { left, right },
CleanupResources
);
TaskFactory.ContinueWhenAny(
new Task[] { left, right },
HandleError,
TaskContinuationOptions.OnlyOnFaulted
);
延续是编写异步应用的一种合理方式,在 GUI 环境中执行异步 I/O 时非常有价值。例如,为了确保 Windows 8 Metro 风格的应用保持响应迅速的用户界面,Windows 8 中的 WinRT (Windows 运行时)API 只提供所有可能运行超过 50 毫秒的操作的异步版本。由于多个异步调用链接在一起,嵌套的延续变得有些笨拙,如下例所示:
//Synchronous version:
private void updateButton_Clicked(. . .) {
using (LocationService location = new LocationService())
using (WeatherService weather = new WeatherService()) {
Location loc = location.GetCurrentLocation();
Forecast forecast = weather.GetForecast(loc.City);
MessageDialog msg = new MessageDialog(forecast.Summary);
msg.Display();
}
}
//Asynchronous version:
private void updateButton_Clicked(. . .) {
TaskScheduler uiScheduler = TaskScheduler.Current;
LocationService location = new LocationService();
Task < Location > locTask = location.GetCurrentLocationAsync();
locTask.ContinueWith(_ => {
WeatherService weather = new WeatherService();
Task < Forecast > forTask = weather.GetForecastAsync(locTask.Result.City);
forTask.ContinueWith(__ => {
MessageDialog message = new MessageDialog(forTask.Result.Summary);
Task msgTask = message.DisplayAsync();
msgTask.ContinueWith(___ => {
weather.Dispose();
location.Dispose();
});
}, uiScheduler);
});
}
这种深度嵌套并不是显式基于延续的编程的唯一危险。考虑以下需要转换到异步版本 的同步循环:
//Synchronous version:
private Forecast[] GetForecastForAllCities(City[] cities) {
Forecast[] forecasts = new Forecast[cities.Length];
using (WeatherService weather = new WeatherService()) {
for (int i = 0; i < cities.Length; ++i) {
forecasts[i] = weather.GetForecast(cities[i]);
}
}
return forecasts;
}
//Asynchronous version:
private Task < Forecast[] > GetForecastsForAllCitiesAsync(City[] cities) {
if (cities.Length == 0) {
return Task.Run(() = > new Forecast[0]);
}
WeatherService weather = new WeatherService();
Forecast[] forecasts = new Forecast[cities.Length];
return GetForecastHelper(weather, 0, cities, forecasts).ContinueWith(_ => forecasts);
}
private Task GetForecastHelper( WeatherService weather, int i, City[] cities, Forecast[] forecasts) {
if (i >= cities.Length) return Task.Run(() => { });
Task < Forecast > forecast = weather.GetForecastAsync(cities[i]);
forecast.ContinueWith(task => {
forecasts[i] = task.Result;
GetForecastHelper(weather, i + 1, cities, forecasts);
});
return forecast;
}
转换这个循环需要完全重写原来的方法,并安排一个延续,本质上是以一种相当不直观和递归的方式执行下一次迭代。这是 C# 5 的设计者通过引入两个新的关键字 async 和 await 选择在语言层面上解决的问题。
异步方法必须用 async 关键字标记,并且可能返回 void、Task 或 Task < T>。在异步方法中,await 运算符可用于表示延续,而无需使用 ContinueWith API。考虑以下示例:
private async void updateButton_Clicked(. . .) {
using (LocationService location = new LocationService()) {
Task < Location > locTask = location.GetCurrentLocationAsync();
Location loc = await locTask;
cityTextBox.Text = loc.City.Name;
}
}
在此示例中,await locTask 表达式为 GetCurrentLocationAsync 返回的任务提供了延续。continuation 的主体是方法的其余部分(从对 loc 变量的赋值开始),await 表达式计算任务返回的内容,在本例中是 Location 对象。此外,延续是在 UI 线程上隐式调度的,这是我们之前使用 TaskScheduler API 时必须明确处理的事情。
C# 编译器负责与方法体相关的所有相关语法特性。比如在我们刚刚写的方法中,有一个尝试。。。隐藏在 using 语句后面的 finally 块。编译器重写延续,以便调用位置变量上的处置方法 ,而不管任务是成功完成还是发生异常。
这种智能重写允许将同步 API 调用转换为异步调用。编译器支持异常处理 、复杂循环、递归方法调用——难以与显式延续传递 API 结合的语言构造。例如,下面是先前给我们带来麻烦的预测-检索循环的异步版本:
private async Task < Forecast[] > GetForecastForAllCitiesAsync(City[] cities) {
Forecast[] forecasts = new Forecast[cities.Length];
using (WeatherService weather = new WeatherService()) {
for (int i = 0; i < cities.Length; ++i) {
forecasts[i] = await weather.GetForecastAsync(cities[i]);
}
}
return forecasts;
}
请注意,变化很小,编译器处理的细节是获取预测变量 (类型为 Forecast[])我们的方法返回并围绕它创建任务< Forecast[] >支架。
只有两个简单的语言特性 (其实现一点也不简单!),C# 5 极大地降低了异步编程的门槛,并使使用返回和操作任务的 API 变得更加容易。此外,await 操作符的语言实现没有绑定到任务并行库;Windows 8 中的本机 WinRT API 返回 IAsyncOperation < T >,而不是任务实例(这是一个托管概念),但仍可以等待,如下例所示,它使用了一个真正的 WinRT API:
using Windows.Devices.Geolocation;
. . .
private async void updateButton_Clicked(. . .) {
Geolocator locator = new Geolocator();
Geoposition position = await locator.GetGeopositionAsync();
statusTextBox.Text = position.CivicAddress.ToString();
}
第三方物流中的高级模式
到目前为止,在这一章中,我们已经考虑了相当简单的并行算法的例子。在本节中,我们将简要介绍一些高级技巧,您可能会发现这些技巧在处理现实世界的问题时很有用;在一些情况下,我们可以从非常令人惊讶的地方获得性能提升。
当并行化具有共享状态的循环时,首先要考虑的优化是聚合 (有时也称为缩减 )。当在并行循环中使用共享状态时,由于共享状态访问上的同步,可伸缩性通常会丢失;添加到组合中的 CPU 内核越多,由于同步的原因,增益就越小(这种现象是阿姆达尔定律的直接推论,通常被称为收益递减定律)。在执行并行循环的每个线程或任务中聚合本地状态,并在循环执行结束时组合本地状态以获得最终结果,通常可以大幅提升性能。处理循环执行的 TPL APIs 配备了处理这种本地聚合的重载。
例如,考虑我们之前实现的素数计算。可伸缩性的主要障碍之一是需要将新发现的素数插入到共享列表中,这需要同步。相反,我们可以在每个线程中使用一个本地列表,并在循环完成时将这些列表聚集在一起:
List < int > primes = new List < int > ();
Parallel.For(3, 200000,
() => new List < int > (), //initialize the local copy
(i, pls, localPrimes) => { //single computation step, returns new local state
if (IsPrime(i)) {
localPrimes.Add(i); //no synchronization necessary, thread-local state
}
return localPrimes;
},
localPrimes => { //combine the local lists to the global one
lock(primes) { //synchronization is required
primes.AddRange(localPrimes);
}
}
);
在上面的例子中,使用的锁的数量比以前少得多——我们只需要为每个执行并行循环的线程使用一次锁,而不是为我们发现的每个素数使用一次锁。我们确实引入了将列表组合在一起的额外成本,但是与本地聚合获得的可伸缩性相比,这个成本可以忽略不计。
优化的另一个来源是循环迭代太小,无法有效地并行化。即使数据并行性 API 将多次迭代组合在一起,也可能有循环体完成得如此之快,以至于它们被调用每次迭代的循环体所需的委托调用所控制。在这种情况下,Partitioner API 可用于手动提取迭代块,从而最大限度地减少委托调用的数量:
Parallel.For(Partitioner.Create(3, 200000), range => { //range is a Tuple < int,int>
for (int i = range.Item1; i < range.Item2; ++i) . . . //loop body with no delegate invocation
});
有关自定义分区的更多信息,这也是数据并行程序可用的一个重要优化,请参考 MSDN 的文章“用于 PLINQ 和 TPL 的自定义分区器”,位于http://msdn.microsoft.com/en-us/library/dd997411.aspx
。
最后,有些应用可以从定制的任务调度程序中受益。一些例子包括在 UI 线程上调度工作(我们已经使用 TaskScheduler 完成了一些工作。当前对 UI 线程的延续进行排队),通过将任务调度到更高优先级的调度器来区分任务的优先级,以及通过将任务调度到使用具有特定 CPU 关联的线程的调度器来将任务关联到特定 CPU。可以扩展 TaskScheduler 类来创建自定义任务计划程序。关于自定义任务调度器的例子,请参考 MSDN 的文章“如何:创建一个限制并发度的任务调度器”,位于http://msdn.microsoft.com/en-us/library/ee789351.aspx
。
同步
对并行编程的处理至少需要粗略地提及同步这个庞大的主题。在贯穿本文的简单示例中,我们已经看到了许多多线程访问共享内存位置的情况,无论是复杂的集合还是单个整数。除了只读数据,对共享内存位置的每次访问都需要同步,但并非所有同步机制都具有相同的性能和可伸缩性成本。
在开始之前,让我们回顾一下访问少量数据时同步的必要性。现代的 CPU 可以对内存进行原子读写;例如,一个 32 位整数的写操作总是自动执行。这意味着,如果一个处理器将值 0xDEADBEEF 写入先前用值 0 初始化的存储器位置,另一个处理器将不会观察到部分更新的存储器位置,例如 0xDEAD0000 或 0x0000BEEF。不幸的是,对于较大的内存位置,情况就不一样了;例如,即使在 64 位处理器上,将 20 个字节写入内存也不是原子操作,不能以原子方式执行。
然而,即使在访问 32 位存储单元但发出多个操作时,同步问题也会立即出现。例如,操作++i(其中 I 是 int 类型的堆栈变量)通常被翻译成三个机器指令的序列:
mov eax, dword ptr [ebp-64] ;copy from stack to register
inc eax ;increment value in register
mov dword ptr [ebp-64], eax ;copy from register to stack
这些指令中的每一个都是自动执行的,但是如果没有额外的同步,两个处理器可能同时执行指令序列的一部分,导致丢失更新。假设变量的初始值是 100,检查下面的执行历史:
1 号处理器 2 号处理器
mov eax, dword ptr [ebp-64]
mov eax, dword ptr [ebp-64] inc eax
inc eax
mov dword ptr [ebp-64], eax
mov dword ptr [ebp-64], eax
在这种情况下,该变量的最终值将是 101,尽管两个处理器已经执行了递增操作,并且应该将它带到 102。这种竞争情况——希望是显而易见且容易检测到的——是保证小心同步的情况的典型例子。
其他方向
许多研究人员和编程语言设计人员认为,如果不完全改变编程语言、并行性框架或处理器内存模型的语义,就无法解决管理共享内存同步的情况。这一领域有几个有趣的方向:
- 硬件或软件中的事务性内存提出了一个围绕内存操作的显式或隐式隔离模型,以及一系列内存操作的回滚语义。目前,这种方法的性能成本阻碍了它们在主流编程语言和框架中的广泛采用。
- 基于代理的语言将并发模型深植于语言之中,并要求代理(对象)之间在消息传递方面进行显式通信,而不是共享内存访问。
- 消息传递处理器和内存架构使用私有内存范例来组织系统,其中对共享内存位置的访问必须通过硬件级的消息传递来明确。
在本节的其余部分,我们将假设一个更实用的观点,并试图通过提供一组同步机制和模式来解决共享内存同步的问题。然而,作者坚信同步比它应该的更困难;我们的共享经验表明,当今软件中的大多数疑难错误都源于通过不正确地同步并行程序来破坏共享状态的简单性。我们希望在几年或几十年后,计算社区会提出更好的替代方案。
无锁代码
同步的一种方法是将负担放在操作系统上。毕竟,操作系统提供了创建和管理线程的工具,并承担了调度线程执行的全部责任。然后很自然地期望它提供一组同步原语。尽管我们将很快讨论 Windows 同步机制,这种方法回避了操作系统如何实现 ?? 这些同步机制的问题。当然,Windows 本身需要同步访问其内部数据结构——甚至是代表其他同步机制的数据结构——并且它不能通过递归地遵从它们来实现同步机制。事实还证明,Windows 同步机制通常需要一个系统调用(用户模式到内核模式的转换)和线程上下文切换来确保同步,如果需要同步的操作非常廉价(例如增加一个数字或向链表中插入一个项目),这将是相对昂贵的。
所有可以运行 Windows 的处理器家族都实现了一个叫做比较交换(CAS) 的硬件同步原语。CAS 具有以下语义(在伪代码中),并且原子地执行:
WORD CAS(WORD* location, WORD value, WORD comparand) {
WORD old = *location;
if (old == comparand) {
*location = value;
}
return old;
}
简单地说,CAS 将内存位置与提供的值进行比较。如果内存位置包含提供的值,则用另一个值替换它;否则不变。在任何情况下,操作之前的存储单元的内容被返回。
例如,在 Intel x86 处理器上,LOCK CMPXCHG 指令实现了这个原语。翻译 CAS(&a,b,c)调用来锁定 CMPXCHG 是一个简单的机械过程,这就是为什么我们将满足于在本节的剩余部分使用 CAS。在。NET Framework 中,CAS 是使用一组称为 Interlocked 的重载实现的。比较交换。
//C# code:
int n = . . .;
if (Interlocked.CompareExchange(ref n, 1, 0) == 0) { //attempt to replace 0 with 1
//. . .do something
}
//x86 assembly instructions:
mov eax, 0 ;the comparand
mov edx, 1 ;the new value
lock cmpxchg dword ptr [ebp-64], edx ;assume that n is in [ebp-64]
test eax, eax ;if eax = 0, the replace took place
jnz not_taken
;. . .do something
not_taken:
单个 CAS 操作通常不足以确保任何有用的同步,除非理想的语义是执行一次性的检查和替换操作。但是,当与循环结构结合使用时,CAS 可以用于各种不可忽略的同步任务。首先,我们考虑一个简单的原地乘法的例子。我们希望以原子方式执行操作 x * = y,其中 x 是可能被其他线程同时写入的共享内存位置,y 是不被其他线程修改的常量值。以下基于 CAS 的 C# 方法执行此任务:
public static void InterlockedMultiplyInPlace(ref int x, int y) {
int temp, mult;
do {
temp = x;
mult = temp * y;
} while(Interlocked.CompareExchange(ref x, mult, temp) ! = temp);
}
每次循环迭代都是从将 x 的值读入一个临时堆栈变量开始的,该变量不能被另一个线程修改。接下来,我们找到乘法结果,准备放入 x 中。最后,当且仅当 CompareExchange 报告它成功地用乘法结果替换了 x 的值时,循环终止,假设原始值没有被修改。我们不能保证循环将在有限的迭代次数内终止;然而,即使在其他处理器的压力下,当试图用新值替换 x 时,单个处理器也不太可能被跳过多次。尽管如此,循环必须准备好面对这种情况(并重试)。考虑以下在两个处理器上 x = 3,y = 5 的执行历史:
Processor #1 Processor #2
temp = x; (3)
temp = x; (3)
mult = temp * y; (15)
mult = temp * y; (15)
CAS(ref x, mult, temp) == 3 (== temp)
CAS(ref x, mult, temp) == 15 (! = temp)
即使是这个极其简单的例子也非常容易出错。例如,以下循环可能会导致更新丢失:
public static void InterlockedMultiplyInPlace(ref int x, int y) {
int temp, mult;
do {
temp = x;
mult = x * y;
} while(Interlocked.CompareExchange(ref x, mult, temp) ! = temp);
}
为什么呢?快速连续读取 x 的值两次并不能保证我们看到相同的值!下面的执行历史演示了在两个处理器上 x = 3,y = 5 的情况下,如何产生不正确的结果——在执行结束时 x = 60!
Processor #1 Processor #2
temp = x; (3)
x = 12;
mult = x * y; (60!)
x = 3;
CAS(ref x, mult, temp) == 3 (== temp)
我们可以将这个结果推广到任何只需要读取一个变异的内存位置并用一个新值替换它的算法,不管它有多复杂。最通用的版本如下:
public static void DoWithCAS < T > (ref T location, Func < T,T > generator) where T : class {
T temp, replace;
do {
temp = location;
replace = generator(temp);
} while (Interlocked.CompareExchange(ref location, replace, temp) ! = temp);
}
用这个通用版本来表达乘法方法非常容易:
public static void InterlockedMultiplyInPlace(ref int x, int y) {
DoWithCAS(ref x, t => t * y);
}
具体来说,有一个简单的同步机制叫做自旋锁 ,可以使用 CAS 来实现。这里的想法如下:获取锁是为了确保试图获取它的任何其他线程都将失败并重试。因此,自旋锁是一种锁,它允许单个线程获取它,而所有其他线程在试图获取它时旋转(“浪费”CPU 周期):
public class SpinLock {
private volatile int locked;
public void Acquire() {
while (Interlocked.CompareExchange(ref locked, 1, 0) ! = 0);
}
public void Release() {
locked = 0;
}
}
记忆模型和易变变量
对同步的完整论述将包括对记忆模型的讨论和对易变变量的需求。然而,我们缺乏足够的空间来涵盖这一主题,只提供一个简短的说明。Joe Duffy 的书《Windows 上的并发编程》(Addison-Wesley,2008)提供了深入详细的描述。
一般来说,特定语言/环境的内存模型描述了编译器和处理器硬件如何对不同线程执行的内存操作进行重新排序——线程通过共享内存的交互。尽管大多数内存模型都同意在相同的内存位置上的读和写操作不能被重新排序,但是在不同的内存位置上的读和写操作的语义上很少有一致意见。例如,当从状态 f = 0,x = 13 开始时,下面的程序可能输出 13:
Processor #1 Processor #2
while (f == 0); x = 42;
print(x); f = 1;
产生这种不直观结果的原因是,编译器和处理器可以自由地对处理器#2 上的指令进行重新排序,使得对 f 的写入在对 x 的写入之前完成,并对处理器#1 上的指令进行重新排序,使得对 x 的读取在对 f 的读取之前完成。
在处理内存重新排序问题时,C# 开发人员可以采取几种补救措施。首先是 volatile 关键字,它防止编译器重新排序和大多数处理器围绕特定变量的操作重新排序。其次是一组互锁的 API 和线程。MemoryBarrier,它引入了一个就重新排序而言不能单向或双向跨越的栅栏。幸运的是,Windows 同步机制(包括一个系统调用)以及 TPL 中的任何无锁同步原语都会在必要时发出内存屏障。但是,如果您尝试实现您自己的低级同步的已经有风险的任务,您应该投入大量的时间来理解您的目标环境的内存模型的细节。
我们不能再强调这一点:如果你选择直接处理内存排序,那么理解你用来编写多线程应用的每种语言和硬件组合的内存模型是绝对重要的。将没有框架来守护你的脚步。
在我们的 spinlock 实现中,0 代表一个自由锁,1 代表一个被占用的锁。我们的实现试图用 1 替换它的内部值,前提是它的当前值为 0,即获取锁,前提是它当前没有被获取。因为不能保证拥有它的线程会很快释放锁,所以使用自旋锁意味着您可能会让一组线程不停地旋转,浪费 CPU 周期,等待锁变得可用。这使得自旋锁不适用于保护数据库访问、将大文件写到磁盘、通过网络发送数据包以及类似的长时间运行的操作。然而,当受保护的代码段非常快时,自旋锁非常有用——修改一个对象上的一组字段,在一行中增加几个变量,或者将一个项目插入一个简单的集合。
事实上,Windows 内核本身广泛使用自旋锁来实现内部同步。内核数据结构,例如调度程序数据库、文件系统高速缓存块列表、内存页帧号数据库和其他数据结构,由一个或多个自旋锁保护。此外,Windows 内核对上面描述的简单自旋锁实现引入了额外的优化,这带来了两个问题:
- 就 FIFO 语义而言,自旋锁是不公平的。一个处理器可能是十个处理器中最后一个调用 Acquire 方法并在其中旋转的,但也可能是第一个在它被所有者释放后实际获取它的。
- 当自旋锁所有者释放自旋锁时,它会使当前在 Acquire 方法中旋转的所有处理器的缓存失效,尽管实际上只有一个处理器会获取它。(我们将在本章后面重新讨论高速缓存失效。)
Windows 内核使用栈内排队自旋锁;栈内排队自旋锁维护一个等待锁的处理器队列,并且每个等待锁的处理器围绕一个独立的存储器位置旋转,该存储器位置不在其他处理器的高速缓存中。当自旋锁的所有者释放该锁时,它会找到队列中的第一个处理器,并向该特定处理器正在等待的位发出信号。这保证了 FIFO 语义,并防止了除成功获得锁的处理器之外的所有处理器上的缓存失效。
注意自旋锁的生产级实现在遇到故障时可以更加健壮,避免自旋超过合理的阈值(通过将自旋转换为阻塞等待),跟踪拥有线程以确保自旋锁被正确获取和释放,允许递归获取锁,并提供额外的功能。任务并行库中的自旋锁类型是一个推荐的实现。
有了 CAS 同步原语,我们现在实现了一个令人难以置信的工程壮举——无锁堆栈。在第五章中,我们已经考虑了一些并发集合,不再重复讨论,但是 ConcurrentStack < T >的实现仍然有些神秘。几乎不可思议的是,ConcurrentStack < T > 允许多个线程从其中推送和弹出项目,但从不需要阻塞同步机制(我们接下来会考虑)来这样做。
我们将通过使用一个单链表来实现一个无锁堆栈。堆栈的顶部元素是列表的头部;将一个项目压入堆栈或从堆栈中弹出一个项目意味着替换列表的头部。为了以同步的方式做到这一点,我们依赖 CAS 原语;事实上,我们可以使用之前介绍的 DoWithCAS < T> 助手:
public class LockFreeStack < T > {
private class Node {
public T Data;
public Node Next;
}
private Node head;
public void Push(T element) {
Node node = new Node { Data = element };
DoWithCAS(ref head, h => {
node.Next = h;
return node;
});
}
public bool TryPop(out T element) {
//DoWithCAS does not work here because we need early termination semantics
Node node;
do {
node = head;
if (node == null) {
element = default(T);
return false; //bail out – nothing to return
}
} while (Interlocked.CompareExchange(ref head, node.Next, node) ! = node);
element = node.Data;
return true;
}
}
Push 方法试图用一个新节点替换列表头,新节点的下一个指针指向当前列表头。同样,TryPop 方法试图用当前头的下一个指针所指向的节点替换列表头,如图图 6-8 所示。
图 6-8 。TryPop 操作试图用新列表头替换当前列表头
您可能会认为世界上的每一个数据结构都可以使用 CAS 和类似的无锁原语来实现。事实上,现在还有一些无锁集合被广泛使用的例子:
- 无锁双向链表
- 无锁队列(有头有尾)
- 无锁简单优先级队列
然而,有很多种集合无法使用无锁代码轻松实现,并且仍然依赖于阻塞同步机制。此外,有相当多的代码需要同步,但不能使用 CAS,因为执行时间太长。我们现在来讨论“真正的”同步机制,它包括由操作系统实现的阻塞。
Windows 同步机制
Windows 为用户模式程序提供了许多同步机制,比如事件、信号量、互斥和条件变量。我们的程序可以通过句柄和 Win32 API 调用来访问这些同步机制,它们代表我们发出相应的系统调用。那个。NET Framework 将大多数 Windows 同步机制包装在面向对象的瘦包中,如 ManualResetEvent、Mutex、Semaphore 等。在现有的同步机制之上。NET 提供了几个新的,比如 ReaderWriterLockSlim 和 Monitor。我们不会详尽地检查每一种同步机制,这是最好留给 API 文档来完成的任务;然而,理解它们的一般性能特征是很重要的。
当锁不可用时,Windows 内核通过阻塞试图获取锁的线程来实现我们现在讨论的同步机制。阻塞一个线程包括将它从 CPU 中移除,将其标记为等待,并调度另一个线程来执行。该操作涉及一个系统调用,这是一个用户模式到内核模式的转换,两个线程之间的上下文切换,以及在内核中执行的一小组数据结构更新(参见图 6-9 )以将线程标记为等待,并将其与它所等待的同步机制相关联。
图 6-9 。由操作系统调度程序维护的数据。准备执行的线程放在 FIFO 队列中,按优先级排序。被阻塞的线程通过称为等待块的内部结构引用它们的同步机制
总的来说,阻塞一个线程可能会花费数千个 CPU 周期,当同步机制可用时,需要类似数量的周期来解除阻塞。很明显,如果使用内核同步机制来保护长时间运行的操作,比如向文件写入一个大的缓冲区或执行网络往返,这种开销是可以忽略的,但是如果使用内核同步机制来保护++i 这样的操作,这种开销会导致不可原谅的速度下降。
同步机制窗口和。NET 提供给应用不同之处主要在于它们的获取和释放语义,也称为它们的信号状态。当同步机制收到信号时,它会唤醒一个线程(或一组线程)等待它变得可用。下面是当前可访问的一些同步机制的信号状态语义。网络应用:
表 6-1。一些同步机制的信号状态语义
同步机制 | 它什么时候变得有信号? | 哪些线程被唤醒? |
---|---|---|
互斥(体)… | 当一个线程调用互斥时。释放互斥 | 正在等待互斥体的线程之一 |
旗语 | 当一个线程调用信号量时。释放;排放;发布 | 正在等待信号量的线程之一 |
手动重置事件 | 当线程调用 ManualResetEvent 时。一组 | 等待事件的所有线程 |
自动重置事件 | 当线程调用 AutoResetEvent 时。一组 | 正在等待事件的线程之一 |
班长 | 当线程调用 Monitor 时。出口 | 正在等待监视器的线程之一 |
屏障 | 当所有参与线程都调用了 Barrier。信号和等待 | 等待屏障的所有线程 |
ReaderWriterLock—用于阅读 | 当没有编写器线程,或者最后一个编写器线程已经释放了用于写入的锁时 | 等待进入锁进行读取的所有线程 |
ReaderWriterLock—用于写作 | 当没有读线程或写线程时 | 等待进入锁进行写入的线程之一 |
除了信号状态语义,一些同步机制在内部实现方面也有所不同。例如,Win32 临界区和 CLR 监视器实现了对当前可用锁的优化。通过这种优化,试图获取可用锁的线程可以直接获取它,而无需执行系统调用。另一方面,同步机制的读取器-写入器锁家族区分访问某个对象的读取器和写入器,当数据最常被读取时,这允许更好的可伸缩性。
从窗口和列表中选择合适的同步机制。NET 必须提供的功能通常是困难的,有时自定义同步机制可能提供比现有机制更好的性能特征或更方便的语义。我们将不再考虑同步机制;在编写并发应用时,您有责任在无锁同步原语和阻塞同步机制之间做出负责任的选择,并确定要使用的同步机制的最佳组合。
注意如果不强调为并发性而从头设计的数据结构(集合),任何关于同步的讨论都是不完整的。这种集合是线程安全的——它们允许来自多个线程的安全访问——并且是可伸缩的,不会由于锁定而导致不合理的性能下降。关于并发集合的讨论,以及并发集合的设计,请参考第五章。
高速缓存注意事项
我们之前已经在集合实现和内存密度的上下文中讨论过处理器缓存的主题。在并行程序中,考虑单个处理器上的缓存大小和命中率同样重要,但考虑多个处理器的缓存如何交互更为重要。我们现在将考虑一个有代表性的例子,它展示了面向缓存的优化的重要性,并强调了好工具在总体性能优化方面的价值。
首先,检查下面的顺序方法。它执行对二维整数数组中的所有元素求和的基本任务,并返回结果。
public static int MatrixSumSequential(int[,] matrix) {
int sum = 0;
int rows = matrix.GetUpperBound(0);
int cols = matrix.GetUpperBound(1);
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < cols; ++j) {
sum + = matrix[i,j];
}
}
return sum;
}
我们的武器库中有一大套用于并行化这类程序的工具。然而,想象一下,我们没有 TPL,而是选择直接使用线程。以下并行化尝试可能看起来非常合理,足以收获多核执行的果实,甚至实现了一个粗略的聚合,以避免共享总和变量上的同步:
public static int MatrixSumParallel(int[,] matrix) {
int sum = 0;
int rows = matrix.GetUpperBound(0);
int cols = matrix.GetUpperBound(1);
const int THREADS = 4;
int chunk = rows/THREADS; //should divide evenly
int[] localSums = new int[THREADS];
Thread[] threads = new Thread[THREADS];
for (int i = 0; i < THREADS; ++i) {
int start = chunk*i;
int end = chunk*(i + 1);
int threadNum = i; //prevent the compiler from hoisting the variable in the lambda capture
threads[i] = new Thread(() => {
for (int row = start; row < end; ++row) {
for (int col = 0; col < cols; ++col) {
localSums[threadNum] + = matrix[row,col];
}
}
});
threads[i].Start();
}
foreach (Thread thread in threads) {
thread.Join();
}
sum = localSums.Sum();
return sum;
}
在英特尔 i7 处理器上分别执行这两种方法 25 次,对于 2,000 × 2,000 的整数矩阵产生了以下结果:顺序方法平均在 325 毫秒内完成,而并行方法平均耗时 935 毫秒,比顺序方法慢三倍!
这显然是不可接受的,但为什么呢?这不是另一个太细粒度并行的例子,因为线程的数量只有 4 个。如果您接受这个问题在某种程度上与缓存相关的前提(因为这个例子出现在“缓存注意事项”一节),那么测量由这两种方法引入的缓存未命中的数量是有意义的。Visual Studio 探查器(在每 2,000 次缓存未命中时采样)在并行版本中报告了 963 个独占样本,而在顺序版本中仅报告了 659 个独占样本;绝大多数样本位于从矩阵读取的内环线上。
再问一次,为什么?为什么写入 localSums 数组的代码行将比写入 sum 局部变量的代码行引入更多的缓存未命中?简单的答案是,对共享阵列的写入使其他处理器的缓存线无效,导致阵列上的每个+ =操作都是缓存未命中。
正如您在第五章中回忆的那样,处理器缓存是按缓存行组织的,相邻的内存位置共享同一个缓存行。当一个处理器写入另一个处理器的缓存中的内存位置时,硬件会导致缓存失效,将另一个处理器的缓存中的缓存线标记为无效。访问无效的高速缓存线会导致高速缓存未命中。在我们上面的例子中,很可能整个 localSums 数组适合一个缓存行,并且同时驻留在应用的线程正在其上执行的所有四个处理器的缓存中。在任一处理器上对数组的任何元素执行的每一次写操作都会使所有其他处理器上的缓存行无效,从而导致缓存无效的持续往复(参见图 6-10 )。
图 6-10 。CPU 1 写入 localSums[1],而 CPU 2 写入 localSums[2]。因为两个数组元素是相邻的,并且适合两个处理器高速缓存中的同一高速缓存行,所以每次这样的写入都会导致另一个处理器上的高速缓存无效
为了确保问题完全与缓存失效相关,可以使阵列跨度足够大,以使缓存失效不会发生,或者将对阵列的直接写入替换为对每个线程中的局部变量的写入,该写入最终在线程完成时刷新到阵列。这两种优化都恢复了世界的健全性,并使并行版本在足够多的内核上比顺序版本更快。
缓存失效(或缓存冲突)是一个令人讨厌的问题,在实际的应用中非常难以检测,即使有强大的分析器帮助。在设计受 CPU 限制的算法时,提前考虑这一点将会为您节省大量时间,并减少以后的麻烦。
注意作者在一个生产场景中遇到了一个类似的缓存失效案例,在两个不同的处理器上执行的两个线程之间有一个共享的工作项队列。当对队列类的字段的内部结构进行了某些微小的更改时,在后续的构建中会检测到显著的性能下降(大约 20%)。经过长时间的详细检查,很明显,对 queue 类中的字段进行重新排序是导致性能下降的原因;由不同线程写入的两个字段靠得太近,被放置在同一高速缓存行上。在字段之间添加填充符将队列的性能恢复到可接受的水平。
通用图形处理器计算
到目前为止,我们对并行编程的报道只局限于 CPU 内核。事实上,我们掌握了多种技能,可以在多个内核上并行化程序,同步访问共享资源,并使用高速 CPU 原语实现无锁同步。正如我们在本章开始时提到的,我们的程序还有另一个并行来源——GPU,它在现代硬件上提供了比高端 CPU 多得多的内核。GPU 核非常适合数据并行算法,其庞大的数量弥补了在其上运行程序的笨拙。在这一节中,我们将研究一种在 GPU 上运行程序的方法,使用一组名为 C++ AMP 的 C++语言扩展。
注意 C++ AMP 是基于 C++的,这也是为什么本节会使用 C++代码示例的原因。然而,通过使用适量的。NET 互操作性,您可以在您的。NET 应用也是如此。我们将在这一节的最后回到这个主题。
C++简介 AMP
从本质上来说,GPU 是一种与其他处理器一样的处理器,具有特定的指令集、众多内核和内存访问协议。然而,现代 GPU 和 CPU 之间存在显著差异,理解它们对于编写高效的基于 GPU 的程序至关重要:
- 现代 CPU 只有一小部分指令可以在 GPU 上使用。这意味着一些限制:没有函数调用,数据类型有限,库函数缺失,等等。其他操作,如分支,可能会带来 CPU 无法比拟的性能成本。显然,这使得将大量代码从 CPU 移植到 GPU 是一项相当大的工作。
- 与中档 CPU 插槽相比,中档显卡上的内核数量要多得多。有些工作单元太小,或者不能分成足够多的部分,无法从 GPU 上的并行化中适当受益。
- 很少支持执行任务的 GPU 核之间的同步,也不支持执行不同任务的 GPU 核之间的同步。这需要在 CPU 上执行 GPU 工作的同步和编排。
什么任务适合在 GPU 上执行?
并不是每个算法都适合在 GPU 上执行。例如,GPU 不能访问其他 I/O 设备,所以你几乎不能通过使用 GPU 来提高从 Web 获取 RSS 提要的程序的性能。然而,许多 CPU 绑定的数据并行算法可以移植到 GPU,并从中获得大规模并行化。以下是一些例子(该列表绝非详尽无遗):
- 图像模糊、锐化和其他变换
- 快速傅里叶变换
- 矩阵转置和乘法
- 数字排序
- 强力哈希反转
Microsoft Native Concurrency team blog(http://blogs.msdn.com/b/nativeconcurrency/
)是其他示例的一个很好的来源,其中有示例代码和对已经移植到 C++ AMP 的各种算法的解释。
C++ AMP 是 Visual Studio 2012 附带的一个框架,它为 C++开发人员提供了在 GPU 上运行计算的简单方法,并且只需要运行 DirectX 11 驱动程序。微软已经发布了 C++ AMP 作为一个开放的规范(在撰写本文时可以在线获得),任何编译器供应商都可以实现它。在 C++ AMP 中,代码可以在代表计算设备的加速器上执行。C++ AMP 使用 DirectX 11 驱动程序动态发现所有加速器。开箱即用,C++ AMP 还附带了一个执行软件仿真的参考加速器和一个基于 CPU 的加速器 WARP,WARP 是没有 GPU 或有 GPU 但没有 DirectX 11 驱动程序并使用多核和 SIMD 指令的计算机上的合理后备。
事不宜迟,让我们考虑一个可以在 GPU 上轻松并行化的算法。下面的算法取两个相同长度的向量,计算逐点结果。没有什么比这更简单的了:
void VectorAddExpPointwise(float* first, float* second, float* result, int length) {
for (int i = 0; i < length; ++i) {
result[i] = first[i] + exp(second[i]);
}
}
在 CPU 上并行化该算法需要将迭代范围分成几个块,并创建一个线程来处理每个部分。事实上,我们已经花了相当多的时间在我们的素性测试示例上——我们已经看到了如何通过手动创建线程、向线程池发出工作项以及使用并行来实现并行化。用于自动并行化功能。此外,回想一下,当在 CPU 上并行化类似的算法时,我们非常小心地避免了过于细粒度的工作项目(例如,每次迭代一个工作项目是不行的)。
在 GPU 上,不需要这样的小心。GPU 配备了许多可以非常快速地执行线程的内核,上下文切换的成本明显低于 CPU。下面是使用 C++ AMP 的 parallel_foreach API 所需的代码:
#include < amp.h>
#include < amp_math.h>
using namespace concurrency;
void VectorAddExpPointwise(float* first, float* second, float* result, int length) {
array_view < const float,1 > avFirst (length, first);
array_view < const float,1 > avSecond(length, second);
array_view < float,1> avResult(length, result);
avResult.discard_data();
parallel_for_each(avResult.extent, = restrict(amp) {
avResult[i] = avFirst[i] + fast_math::exp(avSecond[i]);
});
avResult.synchronize();
}
我们现在分别检查代码的每个部分。首先,保持了主循环的一般形状,尽管原来的 for 循环被替换为对 parallel_foreach 的 API 调用。事实上,将循环转换成 API 调用的原理并不新鲜——我们已经在 TPL 的 Parallel 中看到了同样的原理。对于和平行。ForEach APIs。
接下来,传递给方法的原始数据(第一个、第二个和结果参数)被包装在 array_view 实例中。array_view 类包装必须移动到加速器(GPU)的数据。它的模板参数是数据的类型及其维度。如果我们希望 GPU 执行访问最初在 CPU 上的数据的指令,一些实体必须负责将数据复制到 GPU,因为当今的大多数 GPU 都是具有自己的内存的分立设备。这是 array_view 实例的任务,它们确保按需复制数据,并且只在需要时复制。
当 GPU 上的工作完成时,数据被复制回其原始位置。通过创建带有 const 模板类型参数的 array_view 实例,我们确保第一个和第二个实例仅从 GPU 的复制到,而不必从 GPU 的复制回。类似地,通过调用 discard_data 方法,我们确保 result 不会从 CPU 复制到 GPU,而只会在有值得复制的结果时从 GPU 复制到 CPU。
parallel_foreach API 接受一个范围,这是我们正在处理的数据的形状,以及一个为该范围中的每个元素执行的函数。我们在上面的代码中使用了 lambda 函数,这是 2011 ISO C++标准(C++11)中对 C++的一个受欢迎的补充。restrict(amp)关键字指示编译器验证函数体是否可以在 GPU 上执行,从而禁止大部分 C++语法——不能编译为 GPU 指令的语法。
lambda 函数的参数是一个 index < 1 >对象,表示一个一维索引。这必须与我们使用的范围相匹配—如果我们声明一个二维范围(如矩阵的数据形状),索引也必须是二维的。我们很快就会看到这样的例子。
最后,方法末尾的 synchronize 方法调用确保在 VectorAdd 返回时,CPU 上对 avResult array_view 所做的更改被复制回其原始容器 Result array 中。
这就结束了我们对 C++ AMP 世界的第一次探索,我们准备更深入地研究正在发生的事情,以及一个更好的示例,它将从 GPU 并行化中获得好处。向量加法不是最令人兴奋的算法,也不是卸载到 GPU 的好选择,因为内存传输超过了计算的并行化。在接下来的小节中,我们来看两个更有趣的例子。
矩阵乘法
我们要考虑的第一个“真实世界”的例子是矩阵乘法。我们将优化矩阵乘法的简单立方时间算法,而不是运行在亚立方时间的 Strassen 算法。给定两个适当维数的矩阵,A 是 m 乘 w,B 是 w 乘 n,下面的顺序程序产生它们的乘积,矩阵 C 是 m 乘 n:
void MatrixMultiply(int* A, int m, int w, int* B, int n, int* C) {
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
int sum = 0;
for (int k = 0; k < w; ++k) {
sum + = A[i*w + k] * B[k*w + j];
}
C[i*n + j] = sum;
}
}
}
这里有几个并行性的来源,如果您愿意在 CPU 上并行化这段代码,那么建议我们并行化外部循环并完成它可能是正确的。然而,在 GPU 上,有足够多的内核,如果我们只并行化外部循环,我们可能无法为所有内核创建足够的工作。因此,并行化两个外部循环是有意义的,同时为内部循环留下一个丰富的算法:
void MatrixMultiply(int* A, int m, int w, int* B, int n, int* C) {
array_view < const int,2 > avA(m, w, A);
array_view < const int,2 > avB(w, n, B);
array_view < int,2> avC(m, n, C);
avC.discard_data();
parallel_for_each(avC.extent, = restrict(amp) {
int sum = 0;
for (int k = 0; k < w; ++k) {
sum + = avA(idx[0]*w, k) * avB(k*w, idx[1]);
}
avC[idx] = sum;
});
}
除了索引是二维的,由内部循环使用[]操作符访问之外,一切都与我们前面看到的顺序乘法和向量加法非常相似。与顺序 CPU 相比,这个版本怎么样?为了乘以两个 1024 × 1024 的整数矩阵,CPU 版本平均需要 7350 毫秒,而 GPU 版本——抓紧——平均需要 50 毫秒,提高了 147 倍!
n-体模拟
到目前为止,我们看到的例子在 GPU 上调度的内部循环中有非常琐碎的代码。显然,情况不一定总是如此。我们提到的 Native Concurrency team 博客上的一个例子演示了一个 N 体模拟,它模拟了重力作用下粒子之间的相互作用。模拟由无限数量的步骤组成;在每一步中,它必须确定每个粒子的更新的加速度矢量,然后确定它的新位置。这里的可并行化组件是粒子向量——有了足够多的粒子(几千个或更多),所有 GPU 核心都有足够的工作要同时完成。
决定两个物体之间交互结果的内核是以下代码,这些代码可以非常容易地移植到 GPU:
//float4 here is a four-component vector with pointwise operations
void bodybody_interaction(
float4& acceleration, const float4 p1, const float4 p2) restrict(amp) {
float4 dist = p2 – p1;
float absDist = dist.x*dist.x + dist.y*dist.y + dist.z*dist.z; //w is unused here
float invDist = 1.0f / sqrt(absDist);
float invDistCube = invDist*invDist*invDist;
acceleration + = dist*PARTICLE_MASS*invDistCube;
}
每个模拟步骤都采用一组粒子位置和速度,并根据模拟结果生成一组新的粒子位置和速度:
struct particle {
float4 position, velocity;
//ctor, copy ctor, and operator = with restrict(amp) omitted for brevity
};
void simulation_step(array < particle,1 > & previous, array < particle,1 > & next, int bodies) {
extent < 1 > ext(bodies);
parallel_for_each(ext, & restrict(amp) {
particle p = previous[idx];
float4 acceleration(0, 0, 0, 0);
for (int body = 0; body < bodies; ++body) {
bodybody_interaction(acceleration, p.position, previous[body].position);
}
p.velocity + = acceleration*DELTA_TIME;
p.position + = p.velocity*DELTA_TIME;
next[idx] = p;
});
}
有了合适的 GUI,这个模拟非常有趣。C++ AMP 团队提供的完整示例可以在 Native Concurrency 博客上找到。在作者的系统上,英特尔 i7 处理器配有 ATI 镭龙 HD 5800 显卡,10,000 个粒子的模拟从顺序 CPU 版本产生了 2.5 帧每秒(步数)和从优化 GPU 版本产生了 160 帧每秒(步数)(见图 6-11 ),这是一个令人难以置信的改进。
图 6-11 。N-body 模拟 UI 演示,展示了使用优化的 C++ AMP 实现和 10,240 个模拟粒子时>每秒 160 帧(模拟步骤)
图块和共享内存
在我们结束本节之前,C++ AMP 有一个非常重要的优化,可以进一步提高我们的 GPU 代码的性能。GPU 提供可编程数据缓存(通常称为共享内存)。存储在其中的值在同一个瓦片中的所有线程之间共享。通过使用分块内存,C++ AMP 程序可以将数据从 GPU 的主内存中一次性读取到共享的分块内存中,然后从同一个分块中的多个线程快速访问它,而无需从 GPU 的主内存中重新读取它。访问共享的 tile 内存比访问 GPU 的主内存快 10 倍左右——换句话说,你有理由继续阅读。
为了执行并行循环的分块版本,parallel_for_each 方法接受 tiled_extent 域和 tiled_index lambda 参数,前者将多维范围细分为多维分块,后者指定范围内的全局线程 ID 和分块内的本地线程 ID。例如,一个 16 × 16 的矩阵可以被细分成 2 × 2 的块(见图 6-12 ),然后被传递到 parallel_for_each :
extent < 2 > matrix(16,16);
tiled_extent < 2,2 > tiledMatrix = matrix.tile < 2,2 > ();
parallel_for_each(tiledMatrix,=restrict(amp){。。。});
图 6-12 。一个 16 × 16 的矩阵被分成 2 × 2 的小块。属于同一瓦片的每四个线程可以在它们之间共享数据
在 GPU 内核中,idx.global 可以用来代替我们之前在矩阵上执行操作时看到的标准索引< 2 >。但是,巧妙使用本地图块内存和本地图块索引可以获得显著的性能优势。为了声明在同一个 tile 中的所有线程之间共享的 tile 特定的内存,tile_static storage 说明符可以应用于内核内部的局部变量。通常声明一个共享内存位置,并让 tile 中的每个线程初始化其中的一小部分:
parallel_for_each(tiledMatrix, = restrict(amp) {
tile_static int local[2][2]; //32 bytes shared between all threads in the tile
local[idx.local[0]][idx.local[1]] = 42; //assign to this thread's location in the array
});
显然,只有当所有线程都能够同步它们对共享内存的访问时,才可能从同一个块中的其他线程共享的内存中获得任何好处;即,在它们的相邻线程初始化它们之前,它们不应该试图访问共享存储器位置。tile_barrier 对象同步 tile 中所有线程的执行——它们可以在调用 tile_barrier.wait 之后继续执行,只有在 tile 中的所有线程也调用 tile_barrier.wait 之后(这类似于 TPL 的 barrier 类)。例如:
parallel_for_each(tiledMatrix, [](tiled_index < 2,2 > idx) restrict(amp) {
tile_static int local[2][2]; //32 bytes shared between all threads in the tile
local[idx.local[0]][idx.local[1]] = 42; //assign to this thread's location in the array
idx.barrier.wait(); //idx.barrier is a tile_barrier instance
//Now this thread can access "local" at other threads' indices!
});
现在是时候将所有这些知识应用到一个具体的例子中了。我们将重新讨论之前在没有平铺的情况下实现的矩阵乘法算法,并在其中引入基于平铺的优化。让我们假设矩阵维数可被 256 整除——这允许我们使用 16 × 16 的线程块。矩阵乘法包含固有的阻塞,这可以为我们所用(事实上,CPU 上极大矩阵乘法最常见的优化之一是通过使用阻塞来获得更好的缓存行为)。主要的观察可以归结为以下几点。为了找到 C i,j (结果矩阵中行 i 和列 j 的元素),我们必须找到 A i,** (第一个矩阵的整个I-第行)和 B **,j 之间的标量积但是,这相当于求部分行和部分列的标量积,并将结果相加。我们可以用它来将我们的矩阵乘法算法转换成平铺的版本:
void MatrixMultiply(int* A, int m, int w, int* B, int n, int* C) {
array_view < const int,2 > avA(m, w, A);
array_view < const int,2 > avB(w, n, B);
array_view < int,2> avC(m, n, C);
avC.discard_data();
parallel_for_each(avC.extent.tile < 16,16 > (), = restrict(amp) {
int sum = 0;
int localRow = idx.local[0], localCol = idx.local[1];
for (int k = 0; k < w; k += 16) {
tile_static int localA[16][16], localB[16][16];
localA[localRow][localCol] = avA(idx.global[0], localCol + k);
localB[localRow][localCol] = avB(localRow + k, idx.global[1]);
idx.barrier.wait();
for (int t = 0; t < 16; ++t) {
sum + = localA[localRow][t]*localB[t][localCol];
}
idx.barrier.wait(); //to avoid having the next iteration overwrite the shared memory
}
avC[idx.global] = sum;
});
}
分块优化的本质是分块中的每个线程(有 256 个线程,分块为 16 × 16)在来自 A 和 B 输入矩阵的子块的 16 × 16 本地副本中初始化它自己的元素(参见图 6-13 )。瓦片中的每个线程只需要这些子块的一行和一列,但是所有线程一起将访问每行 16 次和每列 16 次,显著减少了主存储器访问的次数。
图 6-13 。为了找到结果矩阵中的元素(I,j ),该算法需要第一个矩阵的整个第 I 行和第二个矩阵的第 j 列。当图中所示的 16 × 16 tile 中的线程执行且 k = 0 时,第一个和第二个矩阵中的阴影区域最终将被读入共享内存。负责结果矩阵中第(I,j)个元素的线程将具有来自第 I 行的前 k 个元素与来自第 j 行的前 k 个元素的部分标量积
在这种情况下,平铺是一种值得的优化。矩阵乘法的平铺版本比简单版本的执行速度快得多,平均需要 17 毫秒才能完成(使用相同的 1024 × 1024 矩阵)。与 CPU 版本相比, 的速度提升了 430 倍!
在我们结束 C++ AMP 之前,有必要提一下可供 C++ AMP 开发人员使用的开发工具(Visual Studio)。Visual Studio 2012 有一个 GPU 调试器,可以用来在 GPU 内核中放置断点,检查模拟调用栈,读取和修改局部变量(有些加速器支持 GPU 调试;对于其他应用,Visual Studio 使用软件仿真器)和一个分析器,该分析器可用于评估您的应用从使用 GPU 并行化中获得了什么。有关 Visual Studio GPU 调试体验的更多信息,请参考 MSDN 的文章“演练:调试 C++ AMP 应用”,位于http://msdn.microsoft.com/en-us/library/hh368280(v=VS.110).aspx
。
。GPGPU 计算的 NET 替代方案
尽管到目前为止,这一整节都专门讨论了 C++,但是从托管应用中利用 GPU 的能力有几种选择。一种选择是使用托管-本机互操作性(在第八章中讨论过),遵从本机 C++组件来实现 GPU 内核。如果您喜欢 C++ AMP,或者有一个可重用的 C++ AMP 组件,既可以在托管应用中使用,也可以在本地应用中使用,那么这是一个合理的选择。
另一种选择是使用一个库,直接从托管代码中使用 GPU。有几个这样的库可用,例如 GPU.NET 和 CUDAfy.NET(都是商业提供)。这里有一个来自 GPU.NET GitHub 库的例子,展示了两个向量的标量积:
[Kernel]
public static void MultiplyAddGpu(double[] a, double[] b, double[] c) {
int ThreadId = BlockDimension.X * BlockIndex.X + ThreadIndex.X;
int TotalThreads = BlockDimension.X * GridDimension.X;
for (int ElementIdx = ThreadId; ElementIdx < a.Length; ElementIdx += TotalThreads) {
c[ElementIdx] = a[ElementIdx] * b[ElementIdx];
}
}
在作者看来,语言扩展(C++ AMP 方法)比纯粹在库级别上试图弥合差距或通过引入重要的 IL 重写更有效,也更容易学习。
这一节几乎没有触及 C++ AMP 提供的可能性的表面。我们只看了一些 API,并行化了一两个算法。如果你对 C++ AMP 的更多细节感兴趣,我们强烈推荐 Kate Gregory 和 Ade Miller 的书《C++ AMP:用 Microsoft Visual C++加速大规模并行性》(微软出版社,2012)。
摘要
通过本章的学习,并行化已经成为性能优化工作的重要工具。在世界各地的许多服务器和工作站上,CPU 和 GPU 闲置并浪费了宝贵的硬件资源,因为应用未能发挥机器的全部计算能力。有了任务并行库,利用所有可用的 CPU 内核比以前更容易了,尽管同步问题、超额订阅和不平等的工作分配留下了一些有趣的问题和陷阱需要处理。在 GPU 方面,C++ AMP 和其他通用 GPU 计算库正在蓬勃发展,它们的算法和 API 可以在数百个 GPU 核心上并行化您的代码。最后,本章未探讨的是分布式计算带来的性能提升,即云,这是当今 IT 的最大趋势。****
七、网络、I/O、串行化
本书的大部分内容集中在优化应用性能的计算方面。我们已经看到了无数的例子,比如调优垃圾收集,并行化循环和递归算法,甚至通过提出更好的算法来降低运行时成本。
对于某些应用,只优化计算方面会导致有限的性能提升,因为性能瓶颈在于 I/O 工作,如网络传输或磁盘访问。根据我们的经验,现场遇到的相当一部分性能问题并不是由未优化的算法或过度的 CPU 利用率引起的,而是由于系统 I/O 设备的低效利用。让我们考虑优化 I/O 可以带来性能提升的两种情况:
- 由于 I/O 的低效使用,应用可能会产生大量的计算(CPU)开销,这是以有用功为代价的。更糟糕的是,这种开销可能非常高,以至于成为实现 I/O 设备全部潜在容量的限制因素。
- I/O 设备可能未得到充分利用,或者由于低效的使用模式而浪费了其容量,例如进行许多小型 I/O 传输,或者未能保持通道得到充分利用。
本章讨论提高 I/O 性能的一般策略,特别是网络 I/O 性能。此外,我们还讨论了序列化性能,并比较了几种序列化程序。
通用输入/输出概念
本节探讨了 I/O 概念,并提供了与任何类型的 I/O 相关的性能指南。这个建议适用于网络应用、繁重的磁盘访问过程,甚至是为访问定制的高带宽硬件设备而设计的软件。
同步和异步输入/输出
对于同步 I/O,I/O 传递函数(例如 ReadFile、WriteFile 或 DeviceIoControl Win32 API 函数)会一直阻塞,直到 I/O 操作完成。这种模式虽然用起来很方便,但是效率不是很高。在发出连续 I/O 请求的时间间隔内,设备可能处于空闲状态,因此可能未得到充分利用。同步 I/O 的另一个问题是,对于每个并发的 I/O 请求,线程都被“浪费”了。例如,在一个并发服务于许多客户机的服务器应用中,您可能最终会为每个会话创建一个线程。这些线程基本上都是空闲的,它们正在浪费内存,并可能造成一种称为线程颠簸的情况,其中许多线程在 I/O 完成时醒来,并相互竞争 CPU 时间,导致许多上下文切换和较差的可伸缩性。
Windows I/O 子系统(包括设备驱动程序)是内部异步的——当 I/O 操作正在进行时,程序流的执行可以继续。几乎所有现代硬件本质上都是异步的,不需要轮询来传输数据或确定 I/O 操作是否完成。相反,大多数设备依靠直接内存访问(DMA)控制器在设备和计算机 RAM 之间传输数据,在传输过程中不需要 CPU 的注意,然后发出中断信号以表示数据传输完成。只有在应用级别,Windows 才允许内部实际异步的同步 I/O。
在 Win32 中,异步 I/O 被称为重叠I/O(参见图 7-1 比较同步和重叠 I/O)。一旦应用发出重叠的 I/O,Windows 要么立即完成 I/O 操作,要么返回指示 I/O 操作仍处于挂起状态的状态代码。然后线程可以发出更多的 I/O 操作,或者它可以做一些计算工作。程序员有几种选择来接收关于 I/O 操作完成的通知:
图 7-1 。同步和重叠 I/O 的比较
- Win32 事件的信号:当 I/O 完成时,对此事件的等待操作也将完成。
- 通过异步过程调用(APC)机制调用用户回调例程:发布线程必须处于 alertable wait 状态才能允许 APC。
- 通过 I/O 完成端口通知:这通常是最有效的机制。我们将在本章后面详细探讨 I/O 完成端口。
注意如果应用可以保持少量 I/O 请求挂起,一些 I/O 设备(例如以无缓冲模式打开的文件)会受益(通过提高设备利用率)。推荐的策略是预先发出一定数量的 I/O 请求,并且对于每个完成的请求,重新发出另一个请求。这确保了设备驱动程序可以尽快启动下一个 I/O,而无需等待应用发出下一个 I/O 响应。但是,不要夸大挂起数据的数量,因为它会消耗有限的内核内存资源。
输入输出完成端口
Windows 提供了一种高效的异步 I/O 完成通知机制,称为 I/O 完成端口 (IOCP) 。它是通过。NET 线程池。BindHandle 方法。几个。处理 I/O 的. NET 类型在内部利用这种功能:FileStream、Socket、SerialPort、HttpListener、PipeStream 等等。NET 远程处理信道。
一个 IOCP(见图 7-2 )与零个或多个以重叠模式打开的 I/O 句柄(套接字、文件和专用设备驱动程序对象)以及用户创建的线程相关联。一旦相关联的 I/O 句柄的 I/O 操作完成,Windows 将完成通知排队到适当的 IOCP,并且相关联的线程处理完成通知 。通过拥有服务于完成的线程池和智能地控制线程唤醒,减少了上下文切换并最大化了多处理器并发性。高性能服务器(如 Microsoft SQL Server)使用 I/O 完成端口不足为奇。
图 7-2 。I/O 完成端口的结构和操作
通过调用 CreateIoCompletionPortWin32 API 函数、传递最大并发值、完成键并可选地将其与支持 I/O 的句柄相关联,来创建完成端口。完成键是用户指定的值,用于在完成时区分不同的 I/O 句柄。通过再次调用 CreateIoCompletionPort 并指定现有的完成端口句柄,可以将更多的 I/O 句柄与相同或不同的 IOCP 相关联。
然后,用户创建的线程调用 GetCompletionStatus 绑定到指定的 IOCP 并等待完成。一个线程一次只能绑定到一个 IOCP。GetQueuedCompletionStatus 会一直阻塞,直到有可用的 I/O 完成通知(或超时时间已过),此时它会返回 I/O 操作的详细信息,如传输的字节数、完成键和 I/O 期间传递的重叠结构。如果另一个 I/O 在所有关联线程都忙时完成(即,在 GetQueuedCompletionStatus 上没有阻塞),IOCP 会按 LIFO 顺序唤醒另一个线程,直到达到最大并发值。如果线程调用 GetQueuedCompletionStatus 并且通知队列不为空,则调用会立即返回,而不会在 OS 内核中阻塞线程。
还可以通过调用 PostQueuedCompletionStatus 来手动发布完成通知,而不涉及 I/O。
下面的代码清单显示了一个使用 ThreadPool 的示例。Win32 文件句柄上的 BindHandle。首先来看 TestIOCP 方法 。在这里,我们调用 CreateFile,这是一个 P/Invoke'd Win32 函数,用于打开或创建文件或设备。我们必须指定 EFileAttributes。调用中的 Overlapped 标志,以使用任何类型的异步 I/o。create file 如果成功,将返回 Win32 文件句柄,然后我们将绑定到该句柄。我们通过调用 ThreadPool.BindHandle 来创建一个自动重置事件,该事件用于在进行中的 I/O 操作太多的情况下临时阻止线程发出 I/O 操作(限制由 MaxPendingIos 常量设置)。
然后,我们开始一个异步写操作循环。在每次迭代中,我们分配一个包含要写入的数据的缓冲区。我们还分配了一个 Overlapped 结构,其中包含文件偏移量(在这里,我们总是写入偏移量 0)、一个在 I/O 完成时发出信号的事件句柄(不用于 I/O 完成端口)以及一个可选的用户创建的 IAsyncResult 对象,该对象可用于将状态传递给完成函数。然后我们调用 Overlapped structure 的 Pack 方法 ,它以完成函数和数据缓冲区作为参数。它从非托管内存中分配一个等效的本机重叠结构,并固定数据缓冲区。本机结构必须手动释放,以释放它所占用的非托管内存,并取消固定托管缓冲区。
如果没有太多正在进行的 I/O 操作,我们调用 WriteFile,同时指定本机重叠结构。否则,我们会一直等待,直到事件变为有信号状态,这表明挂起的 I/O 操作数已降至限制以下。
I/O 完成函数 WriteComplete 由调用。当 I/O 完成时,NET I/O 完成线程池线程。它接收指向本机重叠结构的指针,该指针可以被解包以将其转换回托管重叠结构 。
using System;
using System.Threading;
using Microsoft.Win32.SafeHandles;
using System.Runtime.InteropServices;
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
internal static extern SafeFileHandle CreateFile(
string lpFileName,
EFileAccess dwDesiredAccess,
EFileShare dwShareMode,
IntPtr lpSecurityAttributes,
ECreationDisposition dwCreationDisposition,
EFileAttributes dwFlagsAndAttributes,
IntPtr hTemplateFile);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
static unsafe extern bool WriteFile(SafeFileHandle hFile, byte[] lpBuffer,
uint nNumberOfBytesToWrite, out uint lpNumberOfBytesWritten,
System.Threading.NativeOverlapped *lpOverlapped);
[Flags]
enum EFileShare : uint {
None = 0x00000000,
Read = 0x00000001,
Write = 0x00000002,
Delete = 0x00000004
}
enum ECreationDisposition : uint {
New = 1,
CreateAlways = 2,
OpenExisting = 3,
OpenAlways = 4,
TruncateExisting = 5
}
[Flags]
enum EFileAttributes : uint {
//Some flags not present for brevity
Normal = 0x00000080,
Overlapped = 0x40000000,
NoBuffering = 0x20000000,
}
[Flags]
enum EFileAccess : uint {
//Some flags not present for brevity
GenericRead = 0x80000000,
GenericWrite = 0x40000000,
}
static long _numBytesWritten;
static AutoResetEvent _waterMarkFullEvent; // throttles writer thread
static int _pendingIosCount;
const int MaxPendingIos = 10;
//Completion routine called by .NET ThreadPool I/O completion threads
static unsafe void WriteComplete(uint errorCode, uint numBytes, NativeOverlapped* pOVERLAP) {
_numBytesWritten + = numBytes;
Overlapped ovl = Overlapped.Unpack(pOVERLAP);
Overlapped.Free(pOVERLAP);
//Notify writer thread that pending I/O count fell below watermark
if (Interlocked.Decrement(ref _pendingIosCount) < MaxPendingIos)
_waterMarkFullEvent.Set();
}
static unsafe void TestIOCP() {
//Open file in overlapped mode
var handle = CreateFile(@"F:\largefile.bin",
EFileAccess.GenericRead | EFileAccess.GenericWrite,
EFileShare.Read | EFileShare.Write,
IntPtr.Zero, ECreationDisposition.CreateAlways,
EFileAttributes.Normal | EFileAttributes.Overlapped, IntPtr.Zero);
_waterMarkFullEvent = new AutoResetEvent(false);
ThreadPool.BindHandle(handle);
for (int k = 0; k < 1000000; k++) {
byte[] fbuffer = new byte[4096];
//Args: file offset low & high, event handle, IAsyncResult object
Overlapped ovl = new Overlapped(0, 0, IntPtr.Zero, null);
//The CLR takes care to pin the buffer
NativeOverlapped* pNativeOVL = ovl.Pack(WriteComplete, fbuffer);
uint numBytesWritten;
//Check if too many I/O requests are pending
if (Interlocked.Increment(ref _pendingIosCount) < MaxPendingIos) {
if (WriteFile(handle, fbuffer, (uint)fbuffer.Length, out numBytesWritten,
pNativeOVL)) {
//I/O completed synchronously
_numBytesWritten + = numBytesWritten;
Interlocked.Decrement(ref _pendingIosCount);
} else {
if (Marshal.GetLastWin32Error() ! = ERROR_IO_PENDING) {
return; //Handle error
}
}
} else {
Interlocked.Decrement(ref _pendingIosCount);
while (_pendingIosCount > = MaxPendingIos) {
_waterMarkFullEvent.WaitOne();
}
}
}
}
总之,当使用高吞吐量 I/O 设备时,使用带有完成端口的重叠 I/O,方法是直接在非托管库中创建和使用自己的完成端口,或者将 Win32 句柄与。NET 的完成端口。
网络线程池
那个。NET 线程池 有多种用途,每种用途由不同种类的线程提供服务。第六章展示了线程池 API,我们用它来挖掘线程池的能力,以并行处理 CPU 受限的计算。然而,线程池适用于许多类型的工作:
- 工作线程处理用户委托的异步调用(例如 BeginInvoke 或 ThreadPool。QueueUserWorkItem)。
- I/O 完成线程处理全局 IOCP 的完成。
- 等待线程处理注册等待。注册等待通过将几个等待合并为一个等待(使用 WaitForMultipleObjects)来节省线程,最多可达 Windows 限制(MAXIMUM_WAIT_OBJECTS = 64)。注册等待用于不使用 I/O 完成端口的重叠 I/O。
- 定时器线程组合等待多个定时器。
- Gate thread 监控线程池线程的 CPU 使用情况,并增加或减少线程数量(在预设限制内)以获得最佳性能。
注意你可以发出一个看似异步的 I/O 操作,尽管它实际上并不是。比如调用 ThreadPool。委托上的 QueueUserWorkItem,然后执行同步 I/O 操作并不能使它成为真正的异步,并不比在常规线程上执行更好。
复制内存
通常,从硬件设备接收的数据缓冲区被一遍又一遍地复制,直到应用完成对它的处理。复制会成为 CPU 开销的重要来源,因此,对于高吞吐量 I/O 代码路径,应该避免复制。我们现在调查一些复制数据的场景以及如何避免复制。
非托管内存
英寸 NET 中,使用非托管内存缓冲区比使用托管字节【】更麻烦,所以程序员通常采取简单的方法,只是将缓冲区复制到托管内存中。
如果您的 API 或库允许您指定自己的内存缓冲区或具有用户定义的分配器回调,请分配一个托管缓冲区并固定它,以便可以通过指针和托管引用来访问它。如果缓冲区太大(> 85,000 字节),它被分配在大型对象堆中,请尝试重用该缓冲区。如果由于不确定的对象生存期而导致重用不重要,那么使用内存池,如第八章中的所述。
在其他情况下,API 或库坚持分配自己的(非托管)内存缓冲区。您可以使用指针(需要不安全的代码)或使用包装类(如 UnmanagedMemoryStream 或 UnmanagedMemoryAccessor)直接访问它。但是,如果您需要将缓冲区传递给一些只处理 byte[]或 string 对象的代码,复制可能是不可避免的。
即使您无法避免复制内存,如果您的部分或大部分数据在早期被过滤(例如网络数据包),也可以通过先检查数据是否有用而不复制它来避免不必要的内存复制。
暴露缓冲区的部分
正如第八章所解释的,程序员有时会假设一个字节[]只包含所需的数据,并且从开始一直延续到结束,迫使调用者拼接缓冲区(分配一个新的字节[]并只复制所需的部分)。这种情况经常在解析协议栈时出现。相比之下,等效的非托管代码将接受一个指针,不知道它是否指向分配的开始,并且必须接受一个长度参数来告诉它数据的结束位置。
为了避免不必要的内存复制,在使用 byte[]参数的地方使用 offset 和 length 参数。使用 length 参数代替数组的 Length 属性,并将偏移量值添加到索引中。
分散–收集 I/O
scatter–gather 是一种 Windows I/O 功能,支持 I/O 在一组不连续的内存位置之间来回传输,就像它们是连续的一样。Win32 通过 ReadFileScatter 和 WriteFileGather 函数公开了这一功能。Windows Sockets 库也通过自己的函数支持分散-聚集:WSASend、WSARecv 以及其他函数。
分散-聚集在以下情况下很有用:
- 每个数据包的有效载荷前都有一个固定的报头。这使您不必每次都复制头来创建连续的缓冲区。
- 您希望通过在一个系统调用中对多个缓冲区执行 I/O 来节省系统调用开销。
虽然 ReadFileScatter 和 WriteFileGather 有局限性,因为每个缓冲区必须正好是系统页面大小,并且这些函数要求句柄以重叠和无缓冲的方式打开(这施加了更多的约束),但基于套接字的分散收集更实用,因为它没有这些限制。那个。NET Framework 通过套接字的 Send 和 Receive 方法的重载来公开套接字分散-收集,但不公开一般的分散/收集函数。
分散-聚集用法的一个例子是 HttpWebRequest。它将 HTTP 头和有效负载结合在一起,而不需要构建一个连续的缓冲区来保存这两者。
文件输入/输出
通常,文件 I/O 通过文件系统缓存,这有一些性能优势:缓存最近访问的数据、预读(推测性地从磁盘预取数据)、后写(异步地将数据写入磁盘)以及合并小型写入。通过向 Windows 提示您期望的文件访问模式,您可以获得更高的性能。如果您的应用确实有重叠的 I/O,并且能够智能地处理一些复杂的缓冲区,那么完全绕过缓存会更有效。
缓存提示
创建或打开文件时,您可以为 CreateFile Win32 API 函数指定标志和属性,其中一些会影响缓存行为:
- FILE_FLAG_SEQUENTIAL_SCAN 向缓存管理器提示文件是顺序访问的,可能会跳过某些部分,但很少随机访问。缓存将进一步提前读取。
- FILE_FLAG_RANDOM_ACCESS 提示文件是以随机顺序访问的,因此缓存管理器提前读取的数据较少,因为应用实际上不太可能会请求这些数据。
- FILE_ATTRIBUTE_TEMPORARY 提示文件是临时的,因此可以延迟刷新磁盘写入(以防止数据丢失)。
NET 通过接受 FileOptions 枚举参数的 FileStream 构造函数重载来公开这些选项(最后一个选项除外)。
注意随机存取不利于性能,尤其是在磁盘介质上,因为读/写磁头必须物理移动。从历史上看,磁盘吞吐量随着平均存储密度的增加而提高,但延迟却没有。现代磁盘可以智能地(将磁盘旋转考虑在内)对随机存取 I/O 进行重新排序,从而最大限度地减少磁头移动的总时间。这被称为本地命令队列(NCQ)。为了有效地工作,磁盘控制器必须预先知道几个 I/O 请求。换句话说,如果可能的话,应该有几个异步 I/O 请求挂起。
无缓冲输入/输出
无缓冲 I/O 完全绕过 Windows 缓存。这既有好处也有坏处。与缓存提示一样,无缓冲 I/O 是在文件创建期间通过“标志和属性”参数启用的,但是。NET 不公开此功能:
- FILE_FLAG_NO_BUFFERING 防止读取或写入的数据被缓存,但对磁盘控制器的硬件缓存没有影响。这避免了内存复制(从用户缓冲区到缓存)并防止缓存污染(以牺牲更重要的数据为代价用无用的数据填充缓存)。但是,读取和写入必须遵守对齐要求。以下参数必须与磁盘扇区大小对齐,或者其大小是磁盘扇区大小的整数倍:I/O 传输大小、文件偏移量和内存缓冲区地址。通常,扇区大小为 512 字节长。最近的高容量磁盘驱动器具有 4,096 字节扇区(称为“高级格式”),但它们可以在模拟 512 字节扇区的兼容模式下运行(以性能为代价)。
- FILE_FLAG_WRITE_THROUGH 指示缓存管理器刷新缓存的写入(如果 FILE_FLAG_NO_BUFFERING 未指定),并指示磁盘控制器立即将写入提交到物理介质,而不是将它们存储在硬件缓存中。
预读通过保持磁盘利用率来提高性能,即使应用进行同步读取,并且读取之间有延迟。这取决于 Windows 正确预测应用下一步将请求文件的哪一部分。通过禁用缓冲,您还可以禁用预读,并通过挂起多个重叠的 I/O 操作来保持磁盘繁忙。
Write-behind 还通过给人一种磁盘写入很快完成的错觉,提高了进行同步写入的应用的性能。应用可以更好地利用 CPU,因为它阻塞的时间更少。当禁用缓冲时,写入会在将其写入磁盘的实际时间内完成。因此,当使用无缓冲 I/O 时,进行异步 I/O 变得更加重要。
建立关系网
网络访问是大多数现代应用的基本功能。处理客户端请求的服务器应用努力最大化可伸缩性及其吞吐能力,以便更快地为客户端提供服务,并在每台服务器上为更多的客户端提供服务,而客户端的目标是最小化网络访问延迟或减轻其影响。本节提供了最大限度提高网络性能的建议和提示。
网络协议
应用网络协议(OSI 第 7 层)的构建方式对性能有着深远的影响。本节探讨了一些优化技术,以更好地利用可用的网络容量并最小化开销。
流水线作业
在非流水线协议中,客户端向服务器发送请求,然后等待响应到达,然后才能发送下一个请求。使用这种协议,网络容量没有得到充分利用,因为在网络往返时间(即网络数据包到达服务器并返回所需的时间)期间,网络是空闲的。相反,在管道连接中,客户端可以继续发送更多的请求,甚至在服务器处理完之前的请求之前。更好的是,服务器可以决定不按顺序响应请求,首先响应琐碎的请求,而推迟处理计算要求更高的请求。
管道化 越来越重要,因为尽管互联网带宽在全球范围内持续增长,但延迟的改善速度却慢得多,因为它受到光速所施加的物理限制的限制。
HTTP 1.1 是真实协议中管道的一个例子,但是由于兼容性问题,它在大多数服务器和 web 浏览器上通常是默认禁用的。Google SPDY,一个实验性的类似 HTTP 的协议,由 Chrome 和 Firefox web 浏览器以及一些 HTTP 服务器支持,以及即将到来的 HTTP 2.0 协议要求管道支持。
流式传输
流媒体不仅仅用于视频和音频,还可以用于信息传递。通过流式传输,应用甚至在完成之前就开始通过网络发送数据。流式传输减少了延迟并提高了网络通道利用率。
例如,如果服务器应用为响应请求而从数据库中获取数据,它可以将数据一个一个地读入数据集(这会消耗大量内存),也可以使用 DataReader 一次检索一条记录。在前一种方法中,服务器必须等到整个数据集到达后才能开始向客户机发送响应,而在后一种方法中,服务器可以在第一个 DB 记录到达后立即开始向客户机发送响应。
消息分块
通过网络一次发送一小块数据是一种浪费。以太网、IP 和 TCP/UDP 报头并没有变小,因为有效负载变小了,所以尽管带宽利用率仍然很高,但最终您会将它浪费在报头上,而不是实际的数据上。此外,Windows 本身的每次调用开销与数据块大小无关或很少相关。一个协议可以通过允许几个请求被组合来减轻这个问题。例如,域名服务(DNS)协议允许客户端在一个请求中解析多个域名。
闲聊协议
有时,即使协议允许,客户端也不能通过管道发送请求,因为下一个请求取决于前面的回复的内容。
考虑一个聊天式协议会话 的例子。当您浏览到一个网页时,浏览器通过 TCP 连接到 web 服务器,发送一个 HTTP GET 请求来请求您要访问的 URL,并接收一个 HTML 页面作为响应。然后,浏览器解析 HTML,确定需要检索哪些 JavaScript、CSS 和图像资源,并分别下载它们。然后执行 JavaScript 脚本,它可以获取更多的内容。总之,客户机并不立即知道它必须检索来呈现页面的所有内容。相反,它必须反复获取内容,直到发现并下载所有内容。
为了缓解这个问题,服务器可能会提示客户端需要检索哪些 URL 来呈现页面,甚至可能在客户端没有请求的情况下发送内容。
消息编码和冗余
网络带宽通常是一种有限的资源,而浪费的消息格式对性能没有帮助。以下是优化消息格式的一些技巧:
- 不要一遍又一遍地发送相同的内容,保持标题较小。
- 对数据使用智能编码或表示。例如,字符串可以用 UTF 8 编码,而不是 UTF-16。二进制协议比人类可读的协议要简洁许多倍。如果可能,避免封装,如 Base64 编码。
- 对高度可压缩的数据(如文本)使用压缩。对于不可压缩的数据,如已经压缩的视频、图像和音频,请避免使用它。
网络插座
套接字 API 是应用使用网络协议(如 TCP 和 UDP)的标准方式。最初,sockets API 是在 BSD UNIX 操作系统中引入的,此后几乎成为所有操作系统的标准,有时还带有专有扩展,如微软的 WinSock。在 Windows 中有许多方法可以实现套接字 I/O:阻塞、带有轮询的非阻塞和异步。使用正确的 I/O 模型和套接字参数可以实现更高的吞吐量、更低的延迟和更好的可扩展性。本节概述了与 Windows 套接字相关的性能优化。
异步套接字
。NET 通过 Socket 类支持异步 I/O。然而,异步 API 有两个家族:BeginXXX 和 XXXAsync,其中 XXX 代表接受、连接、接收、发送和其他操作。前者使用。NET 线程池的注册等待功能来等待重叠的 I/O 完成,而后者使用。NET 线程池的 I/O 完成端口机制,这是更高的性能和可伸缩性。后面的 API 是在中引入的。NET 框架 2.0 SP1。
套接字缓冲区
Socket 对象公开了两个可设置的缓冲区大小:ReceiveBufferSize 和 SendBufferSize,它们指定 TCP/IP 堆栈分配的缓冲区大小(在 OS 内存空间中)。默认情况下,两者都设置为 8,192 字节。接收缓冲区用于保存应用尚未读取的接收数据。发送缓冲区用于保存应用已经发送但尚未被接收方确认的数据。如果需要重新传输,来自发送缓冲区的数据将被重新传输。
当应用从套接字读取数据时,它会根据读取的数据量来填充接收缓冲区。当接收缓冲区变空时,调用要么阻塞,要么挂起,这取决于使用的是同步还是异步 I/O。
当应用写入套接字时,它可以无阻塞地写入数据,直到发送缓冲区已满而无法容纳数据,或者直到接收方的接收缓冲区变满。接收方通过每个确认通告其接收缓冲区大小有多满。
对于高带宽、高延迟的连接,如卫星链路,默认的缓冲区大小可能太小。发送端很快填满其发送缓冲区,并不得不等待确认,由于等待时间长,确认到达的速度很慢。等待时,管道不会保持满,端点仅利用可用带宽的一部分。
在完全可靠的网络中,理想的缓冲区大小是带宽和延迟的乘积。例如,在往返时间为 5 毫秒的 100Mbps 连接中,理想的缓冲区窗口大小应为(100,000,000 / 8) × 0.005 = 62,500 字节。数据包丢失会降低该值。
纳格尔算法
如前所述,小数据包是一种浪费,因为与有效负载相比,数据包报头可能很大。Nagle 的算法 通过将应用的多次写入合并成一个完整数据包的数据,提高了 TCP 套接字的性能。然而,这项服务并不是免费的,因为它会在发送数据之前引入延迟。对延迟敏感的应用应该通过设置套接字来禁用 Nagle 的算法。NoDelay 属性设置为 true。一个编写良好的应用一次会发送大量缓冲区,不会从 Nagle 的算法中受益。
注册输入输出
Registered I/O (RIO) 是 WinSock 在 Windows Server 2012 中的新扩展,提供了非常高效的缓冲区注册和通知机制。RIO 消除了 Windows I/O 中最严重的低效问题:
- 用户缓冲区探测(检查页面访问权限)、锁定和解锁(确保缓冲区驻留在 RAM 中)。
- 句柄查找(将 Win32 句柄翻译成内核对象指针)。
- 进行的系统调用(例如,将 I/O 完成通知出队)。
这些是为了将应用与操作系统和其他应用隔离开来而支付的“税”,目的是确保安全性和可靠性。如果没有力拓,你需要为每笔交易支付这些税,在高 I/O 率的情况下,这些税变得很重要。相反,在 RIO 中,您只需在初始化期间支付一次“税收”成本。
RIO 要求注册缓冲区,这将缓冲区锁定在物理内存中,直到它们被注销(当应用或子系统取消初始化时)。由于缓冲区保持分配并驻留在内存中,Windows 可以跳过每次调用的探测、锁定和解锁。
RIO 请求和完成队列驻留在进程的内存空间中,并且可以被它访问,这意味着不再需要系统调用来轮询队列或使完成通知出队。
里约支持三种通知机制:
- 轮询:这具有最低的延迟,但意味着逻辑处理器专用于轮询网络缓冲区。
- 输入输出完成端口。
- 发出 Windows 事件的信号。
在写这篇文章的时候,力拓还没有被曝光。NET 框架,但它可以通过标准。NET 互操作性机制(在第八章中讨论)。
数据序列化和反序列化
序列化是以一种可以写入磁盘或通过网络发送的格式来表示对象的行为。反序列化是从序列化表示中重建对象的行为。例如,哈希表可以序列化为键值记录的数组。
串行器基准
那个。NET Framework 附带了几个通用序列化程序,可以序列化和反序列化用户定义的类型。本节从序列化吞吐量和序列化消息大小的角度衡量了每种基准序列化程序的优缺点。
首先,我们回顾一下可用的序列化器:
-
系统。XML . serialize . XML serializer
-
序列化为 XML,文本或二进制。
-
处理子对象,但不支持循环引用。
-
仅适用于公共字段和属性,明确排除的除外。
-
只使用一次反射来代码生成序列化程序集,以提高操作效率。您可以使用 sgen.exe 工具预先创建序列化程序集。
-
允许自定义 XML 架构。
-
要求事先知道参与序列化的所有类型:它自动推断出这些信息,除非使用继承类型。
-
系统。runtime . serialization . formatters . binary . binary formatter
-
序列化为专有的二进制格式,只能由使用。NET BinaryFormatter。
-
由使用。NET 远程处理,但也可以独立用于一般序列化。
-
在公共和非公共领域工作。
-
处理循环引用。
-
不需要要序列化的类型的先验知识。
-
要求通过应用[Serializable]属性将类型标记为可序列化。
-
系统。runtime . serialization . formatters . soap . soap formatter
-
在功能上类似于 BinaryFormatter,但序列化为 SOAP XML 格式,这种格式更具互操作性,但不太紧凑。
-
不支持泛型和泛型集合,因此在。NET 框架。
-
系统。runtime . serialization . datacontractserializer
-
序列化为 XML,文本或二进制。
-
由 WCF 使用,但也可以独立用于常规序列化。
-
通过使用[DataContract]和[DataMember]属性将类型和字段序列化为选择性加入:如果类由[Serializable]属性标记,则所有字段都将被序列化。
-
要求事先知道参与序列化的所有类型:它自动推断出这些信息,除非使用继承类型。
-
系统。runtime . serialization . netdatacontractserializer
-
类似于 DataContractSerializer,只是它嵌入了。序列化数据中特定于. NET 的类型信息。
-
不需要参与序列化的类型的先验知识。
-
需要共享包含序列化类型的程序集。
-
系统。runtime . serialization . datacontractjsonserializer
-
类似于 DataContractSerializer,但序列化为 JSON 格式而不是 XML 格式。
图 7-3 展示了前面列出的序列化程序的基准测试结果。有些序列化程序针对文本 XML 输出和二进制 XML 输出测试了两次。基准测试涉及高复杂性对象图的序列化和反序列化,该对象图由 5 种类型的 3,600 个实例组成,具有树状引用模式。每种类型都由 string 和 double 字段及其数组组成。不存在循环引用,因为并非所有序列化程序都支持循环引用。然而,那些支持循环引用的序列化器在它们存在的情况下运行速度要慢得多。此处显示的基准测试结果运行于。NET Framework 4.5 RC,它比。NET Framework 3.5,用于使用二进制 XML 的测试,但在其他方面没有明显的区别。
基准测试结果显示,DataContractSerializer 和 XmlSerializer 在处理二进制 XML 格式时总体上是最快的。
图 7-3 。序列化程序吞吐量基准结果,以操作/秒为单位
接下来,我们比较序列化器的序列化数据大小(参见图 7-4 )。在这个度量中,有几个彼此非常接近的序列化器。这可能是因为对象树的大部分数据都是字符串形式的,这在所有序列化程序中都以相同的方式表示。
最紧凑的序列化表示由 DataContractJsonSerializer 生成,当与二进制 XML 编写器一起使用时,紧随其后的是 XmlSerializer 和 DataContractSerializer。也许令人惊讶的是,BinaryFormatter 的表现优于大多数其他序列化程序。
图 7-4 。序列化数据大小的比较
数据集序列化
数据集是通过 DataAdapter 从数据库中检索的数据的内存缓存。它包含 DataTable 对象的集合,这些对象包含数据库架构和数据行,每个数据行包含序列化对象的集合。数据集对象很复杂,消耗大量内存,并且序列化时计算量很大。然而,许多应用在应用的不同层之间传递它们。减少序列化开销的技巧包括:
- 调用数据集。序列化数据集之前应用 Changes 方法。数据集存储原始值和更改后的值。如果不需要序列化旧值,请调用 ApplyChanges 来丢弃它们。
- 仅序列化您需要的数据表。如果数据集包含您不需要的其他表,请考虑只将所需的表复制到新的 DataSet 对象中,并将其序列化。
- 使用列名别名(作为关键字)来给出较短的名称并减少序列化的长度。例如,考虑以下 SQL 语句:选择 EmployeeID 作为 I,Name 作为 N,Age 作为 A
Windows 通信基础
Windows 通信基金会(WCF),发布于。NET 3.0 正迅速成为大多数网络需求的事实上的标准。NET 应用。它提供了无与伦比的网络协议和定制选择,并不断通过新的。净释放量。本节介绍 WCF 性能优化。
节流
WCF,尤其是以前。默认情况下,NET Framework 4.0 具有保守的限制值。这些都是为了防止拒绝服务(DoS)攻击而设计的,但不幸的是,在现实世界中,它们通常被设置得太低而没有用。
可以通过编辑 app.config(针对桌面应用)或 web.config(针对 ASP.NET 应用)中的 system.serviceModel 部分来修改限制设置:
<system.serviceModel>
<behaviors>
<serviceBehaviors>
<behavior>
<serviceThrottling>
<serviceThrottling maxConcurrentCalls = "16"
maxConcurrentSessions = "10" maxConcurrentInstances = "26" />
更改这些参数的另一种方法是在服务创建期间设置 ServiceThrottling 对象的属性:
Uri baseAddress = new Uri("http://localhost:8001/Simple");
ServiceHost serviceHost = new ServiceHost(typeof(CalculatorService), baseAddress);
serviceHost.AddServiceEndpoint(
typeof(ICalculator),
new WSHttpBinding(),
"CalculatorServiceObject");
serviceHost.Open();
IChannelListener icl = serviceHost.ChannelDispatchers[0].Listener;
ChannelDispatcher dispatcher = new ChannelDispatcher(icl);
ServiceThrottle throttle = dispatcher.ServiceThrottle;
油门。MaxConcurrentSessions=10;
油门。MaxConcurrentCalls=16;
油门。MaxConcurrentInstances=26;
让我们来理解这些参数是什么意思。
- maxConcurrentSessions 限制 ServiceHost 上当前处理的消息数。超过限制的呼叫将被排队。的默认值为 10。NET 中处理器数量的 3.5 倍和 100 倍。NET 4。
- maxConcurrentCalls 限制在 ServiceHost 上一次执行的 InstanceContext 对象的数量。创建额外实例的请求被排队,并在低于限制的位置可用时完成。
- 的默认值为 16。NET 中处理器数量的 3.5 和 16 倍。NET 4。
- maxConcurrentInstances 限制 ServiceHost 对象可以接受的会话数。该服务接受超过限制的连接,但只有低于限制的通道是活动的(从通道中读取消息)。
- 的默认值是 26。NET 中处理器数量的 3.5 倍和 116 倍。NET 4。
另一个重要的限制是每个主机允许的应用并发连接数,默认情况下是两个。如果您的 ASP.NET 应用调用外部 WCF 服务,这个限制可能是一个严重的瓶颈。这是一个设置这些限制的配置示例:
<system.net>
<connectionManagement>
<add address = "*" maxconnection = "100" />
</connectionManagement>
</system.net>
流程模型
编写 WCF 服务时,您需要确定其激活和并发模型。这分别由 ServiceBehavior 属性的 InstanceContextMode 和 ConcurrencyMode 属性控制。InstanceContextMode 值的含义如下:
- per call–为每个呼叫创建一个服务对象实例。
- PerSession(默认)–为每个会话创建一个服务对象实例。如果通道不支持会话,这类似于 PerCall。
- 单一–单一服务实例可重复用于所有呼叫。
并发模式值的含义如下:
- Single(默认)–服务对象是单线程的,不支持重入。如果 InstanceContextMode 设置为 Single,并且它已经为一个请求提供了服务,那么其他请求必须等待轮到它们。
- 可重入——服务对象是单线程的,但可重入。如果该服务调用另一个服务,它可能会被重新输入。在调用另一个服务之前,您有责任确保对象状态保持一致。
- multiple——不保证同步,服务必须自己处理同步,以确保状态的一致性。
不要将 Single 或 Reentrant ConcurrencyMode 与 Single InstanceContextMode 一起使用。如果使用多并发模式,请使用细粒度的锁定,以便实现更好的并发性。
WCF 从。NET 线程池 I/O 完成线程,在本章前面已经介绍过。如果在服务期间执行同步 I/O 或 do 等待,您可能需要通过编辑 ASP.NET 应用的 system.web 配置部分(见下文)或调用 thread pool 来增加线程池线程的数量。SetMinThreads 和 ThreadPool。桌面应用中的 SetMaxThreads。
<system.web>
<processModel
...
enable = "true"
autoConfig = "false"
maxworkerthread =****80
maxIoThreads =****80
minWorkerThreads ="40
miniothhreads =****40
/>
缓存
WCF 没有内置缓存支持。即使您将 WCF 服务托管在 IIS 中,默认情况下它仍无法使用其缓存。要启用缓存,请使用 aspnetcompatibility requirements 属性标记您的 WCF 服务。
[AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Allowed)]
此外,通过编辑 web.config 并在 system.serviceModel 部分下添加以下元素来启用 ASP.NET 兼容性:
<serviceHostingEnvironment aspNetCompatibilityEnabled = "true" />
从……开始。NET Framework 4.0,可以使用新系统。实现缓存的缓存类型。它不依赖于系统。网络大会,所以它不仅限于 ASP.NET。
异步 WCF 客户端和服务器
WCF 允许你在客户端和服务器端发布异步操作。每一方都可以独立地决定是同步操作还是异步操作。
在客户端,有两种异步调用服务的方式:基于事件的和。基于 NET 异步模式。基于事件的模型与使用 ChannelFactory 创建的通道不兼容。要使用基于事件的模型,请使用带有/async 和/tcv:Version35 开关的 svcutil.exe 工具来生成服务代理:
svcutil /n:http://Microsoft.ServiceModel.Samples,Microsoft.ServiceModel.Sampleshttp://localhost:8000/servicemodelsamples/service/mex /async /tcv:Version35
然后,可以按如下方式使用生成的代理:
// Asynchronous callbacks for displaying results.
static void AddCallback(object sender, AddCompletedEventArgs e) {
Console.WriteLine("Add Result: {0}", e.Result);
}
static void Main(String[] args) {
CalculatorClient client = new CalculatorClient();
client.AddCompleted + = new EventHandler < AddCompletedEventArgs > (AddCallback);
client.AddAsync(100.0, 200.0);
}
在基于 IAsyncResult 的模型中,您使用 svcutil 创建一个指定/async 开关但不指定/tcv:Version35 开关的代理。然后调用代理上的 BeginXXX 方法,并提供一个完成回调,如下所示:
static void AddCallback(IAsyncResult ar) {
double result = ((CalculatorClient)ar.AsyncState).EndAdd(ar);
Console.WriteLine("Add Result: {0}", result);
}
static void Main(String[] args) {
ChannelFactory < ICalculatorChannel > factory = new ChannelFactory < ICalculatorChannel > ();
ICalculatorChannel channelClient = factory.CreateChannel();
IAsyncResult arAdd = channelClient.BeginAdd(100.0, 200.0, AddCallback, channelClient);
}
在服务器上,异步是通过创建契约操作的 BeginXX 和 EndXX 版本来实现的。您不应该有另一个名称相同但没有开始/结束前缀的操作,因为 WCF 将调用它。遵循这些命名约定,因为 WCF 要求这样做。
BeginXX 方法应该接受输入参数并返回 IAsyncResult,几乎不做任何处理;I/O 应该异步完成。BeginXX 方法(只有它)应该应用 OperationContract 属性,并将 AsyncPattern 参数设置为 true。
EndXX 方法应该接受一个 IAsyncResult,具有所需的返回值,并具有所需的输出参数。IAsyncResult 对象(从 BeginXX 返回)应该包含返回结果所需的所有信息。
此外,WCF 4.5 在服务器和客户端代码中都支持新的基于任务的异步/等待模式。例如:
//Task-based asynchronous service
public class StockQuoteService : IStockQuoteService {
async public Task<double> GetStockPrice(string stockSymbol) {
double price = await FetchStockPriceFromDB();
return price;
}
}
//Task-based asynchronous client
public class TestServiceClient : ClientBase < IStockQuoteService>, IStockQuoteService {
public Task<double> GetStockPriceAsync(string stockSymbol) {
return Channel.GetStockPriceAsync();
}
}
绑定
设计 WCF 服务时,选择正确的绑定非常重要。每个绑定都有自己的功能和性能特征。选择最简单的绑定,并使用满足您需求的最少数量的绑定功能。可靠性、安全性和身份验证等特性会增加大量开销,所以只在必要时才使用它们。
对于同一台机器上的进程之间的通信,命名管道绑定提供了最佳性能。对于跨机器双向通信,Net TCP 绑定提供了最佳性能。但是,它不能互操作,只能与 WCF 客户端一起工作。它也不是负载平衡器友好的,因为会话变得与特定的服务器地址密切相关。
您可以使用自定义二进制 HTTP 绑定来获得 TCP 绑定的大部分性能优势,同时保持与负载平衡器的兼容性。下面是配置此类绑定的示例:
<bindings>
< customBinding>
<binding name = "NetHttpBinding">
<reliableSession />
<compositeDuplex />
<oneWay />
<binaryMessageEncoding />
<httpTransport />
</binding>
</customBinding>
<basicHttpBinding>
<binding name = "BasicMtom" messageEncoding = "Mtom" />
</basicHttpBinding>
<wsHttpBinding>
<binding name = "NoSecurityBinding">
<security mode = "None" />
</binding>
</wsHttpBinding>
</bindings>
<services>
<service name = "MyServices.CalculatorService">
<endpoint address = " " binding = "customBinding" bindingConfiguration = "NetHttpBinding"
contract = "MyServices.ICalculator" />
</service>
</services>
最后,选择基本的 HTTP 绑定,而不是 WS 兼容的。后者的消息格式更加冗长。
摘要
正如您在本章中所看到的,通过提高应用的 I/O 性能,您可以带来巨大的变化,并避免任何与计算相关的优化。本章内容:
- 研究了同步和异步 I/O 之间的区别。
- 探索了各种 I/O 完成通知机制。
- 给出了关于 I/O 的一般技巧,比如最小化内存缓冲区复制。
- 讨论了特定于文件 I/O 的优化。
- 检查了特定于套接字的优化。
- 展示了如何优化网络协议以充分利用可用的网络容量。
- 比较和基准测试了内置于。NET 框架。
- 涵盖 WCF 优化。
八、不安全代码和互操作性
很少有真实世界的应用是严格由托管代码组成的。取而代之的是,他们经常使用内部的或者第三方的用本地代码实现的 ?? 库。那个。NET 框架提供了多种机制来与本地代码进行互操作,这些本地代码是通过多种广泛使用的技术 实现的:
- P/Invoke:支持与导出 C 风格函数的 dll 的互操作性。
- COM Interop:允许托管代码使用 COM 对象以及公开。NET 类作为供本机代码使用的 COM 对象。
- C++/CLI 语言:通过混合编程语言实现与 C 和 C++的互操作性。
事实上,基础类库(BCL)是。NET 框架(mscorlib.dll 是主要的)包含。NET Framework 的内置类型使用所有上述机制。因此,可以说,任何重要的托管应用实际上都是一个混合应用,在某种意义上,它调用本机库。
虽然这些机制非常有用,但是理解与每个互操作机制相关的性能含义以及如何最小化它们的影响是很重要的。
不安全代码
托管代码提供了类型安全、内存安全和安全保证,从而消除了本机代码中普遍存在的一些最难诊断的错误和安全漏洞,如堆损坏和缓冲区溢出。通过禁止使用指针直接访问内存,转而使用强类型引用,检查数组访问边界,并确保只对对象进行合法的强制转换,就可以做到这一点。
但是,在某些情况下,这些约束可能会使原本简单的任务变得复杂,并通过迫使您使用安全的替代方法来降低性能。例如,一个人可能将数据从一个文件读入一个 byte[]中,但希望将该数据解释为一个双精度值数组。在 C/C++中,您可以简单地将 char 指针转换为 double 指针。相比之下在保险箱里。NET 代码中,可以用 MemoryStream 对象包装缓冲区,并在前者之上使用 BinaryReader 对象将每个内存位置作为双精度值读取;另一种选择是使用 BitConverter 类。这些解决方案是可行的,但是它们比在非托管代码中实现要慢。幸运的是,C# 和 CLR 通过指针和指针转换支持不安全的内存访问。其他不安全的特性是堆栈内存分配和结构中的嵌入式数组。不安全代码的缺点是安全性受到损害,这可能导致内存损坏和安全漏洞,因此在编写不安全代码时应该非常小心。
要使用不安全代码,必须先在 C# 项目 设置中启用编译不安全代码(参见图 8-1 ),这导致将/unsafe 命令行参数传递给 C# 编译器。接下来,您应该标记允许不安全代码或不安全变量的区域,这可以是整个类或结构、整个方法或方法中的一个区域。
图 8-1 。在 C# 项目设置中启用不安全代码(Visual Studio 2012)
锁定和垃圾收集句柄
因为位于 GC 堆上的托管对象在不可预知的时间发生垃圾收集期间可能会被重新定位,所以您必须固定它们,以便获得它们的地址,并防止它们在内存中被四处移动。
锁定可以通过使用 C# 中的固定作用域(参见清单 8-1 中的例子)或者分配一个锁定 GC 句柄(参见清单 8-2 中的)来完成。 P/Invoke 存根,我们将在后面介绍,也以一种等同于 fixed 语句的方式固定对象。如果固定要求可以限制在函数的范围内,请使用 fixed,因为它比 GC 句柄方法更有效。否则,使用 GCHandle。Alloc 分配一个锁定句柄来无限期锁定一个对象(直到您通过调用 GC handle 显式释放 GC 句柄。免费)。堆栈对象(值类型)不需要固定,因为它们不受垃圾收集的影响。通过使用&符号(&)引用操作符,可以直接获得堆栈定位对象的指针。
清单 8-1。 使用固定范围和指针强制转换来重新解释缓冲区中的数据
using (var fs = new FileStream(@"C:\Dev\samples.dat", FileMode.Open)) {
var buffer = new byte[4096];
int bytesRead = fs.Read(buffer, 0, buffer.Length);
unsafe {
double sum = 0.0;
fixed (byte* pBuff = buffer) {
double* pDblBuff = (double*)pBuff;
for (int i = 0; i < bytesRead / sizeof(double); i++)
sum + = pDblBuff[i];
}
}
}
注意从 fixed 语句中获得的指针一定不能在 fixed 作用域之外使用,因为当作用域结束时,被钉住的对象会被解除钉住。您可以在值类型数组、字符串和托管类的特定值类型字段上使用 fixed 关键字。请务必指定结构内存布局。
GC 句柄是一种通过不可变的指针大小的句柄值(即使对象的地址发生变化)来引用驻留在 GC 堆上的托管对象的方法,该句柄值甚至可以由本机代码存储。GC 句柄有四种类型,由 GCHandleType 枚举指定:弱、WeakTrackRessurection、普通和固定。Normal 和 Pinned 类型防止对象被垃圾回收,即使没有对它的其他引用。Pinned 类型还会固定对象,并允许获取其内存地址。Weak 和 WeakTrackResurrection 不会阻止对象被回收,但是如果对象还没有被垃圾回收,则可以获得正常(强)引用。它由 WeakReference 类型使用。
清单 8-2。 使用锁定 GCHandle 进行锁定和指针转换来重新解释缓冲区中的数据
using (var fs = new FileStream(@"C:\Dev\samples.dat", FileMode.Open)) {
var buffer = new byte[4096];
int bytesRead = fs.Read(buffer, 0, buffer.Length);
GCHandle gch = GCHandle.Alloc(buffer, GCHandleType.Pinned);
unsafe {
double sum = 0.0;
double* pDblBuff = (double *)(void *)gch.AddrOfPinnedObject();
for (int i = 0; i < bytesRead / sizeof(double); i++)
sum + = pDblBuff[i];
gch.Free();
}
}
警告如果触发了垃圾收集(即使是由另一个并发运行的线程触发),钉住可能会导致托管堆碎片。碎片浪费内存并降低垃圾收集器算法的效率。为了最大限度地减少碎片,不要将对象固定得过长。
生命周期管理
在许多情况下,本机代码在函数调用中继续持有非托管资源,并且需要显式调用来释放资源。如果是这种情况,除了终结器之外,还要在包装托管类中实现 IDisposable 接口。这将使客户端能够确定性地释放非托管资源,而终结器应该是在您忘记显式释放时的最后一道安全屏障。
分配非托管内存
占用超过 85,000 字节的托管对象(通常是字节缓冲区和字符串)被放在大对象堆(LOH)上,它与 GC 堆的 Gen2 一起被垃圾收集,这是非常昂贵的。LOH 也经常变得支离破碎,因为它从未被压缩;如果可能的话,相当自由的空间被重新使用。这两个问题都会增加垃圾收集器对内存和 CPU 的使用。因此,使用托管内存池或从非托管内存中分配这些缓冲区(例如,通过调用 Marshal)会更有效。AllocHGlobal)。如果以后需要从托管代码中访问非托管缓冲区,请使用“流”方法,即将非托管缓冲区的小块复制到托管内存中,一次处理一个块。你可以使用系统。UnmanagedMemoryStream 和 System。UnmanagedMemoryAccessor 使工作更容易。
内存池
如果您大量使用缓冲区与本机代码通信,您可以从 GC 堆或从非托管堆分配它们。对于高分配率和缓冲区不是很小的情况,前一种方法变得低效。需要固定托管缓冲区,这会导致碎片。后一种方法也有问题,因为大多数托管代码希望缓冲区是托管字节数组(byte[])而不是指针。如果不复制,就不能将指针转换为托管数组,但这对性能不利。
提示你可以在 GC 下的性能计数器中查找% Time。NET CLR 内存性能计数器类别来估计被 GC“浪费”的 CPU 时间,但是这并不能告诉您是什么代码造成的。在投入优化工作之前,使用一个分析器(参见第二章),并参见第四章以获得更多关于垃圾收集性能的提示。
我们提出了一个解决方案(见图 8-2 ),它提供了从托管和非托管代码的免复制访问,并且不会给 GC 带来压力。其思想是分配位于大型对象堆上的大型托管内存缓冲区(段)。固定这些段不会带来任何损失,因为它们已经是不可重定位的了。
一个简单的分配器,其中一个段的分配指针(实际上是一个索引)在每次分配时只向前移动,然后分配不同大小的缓冲区(直到段大小),并返回这些缓冲区周围的包装器对象。一旦指针接近末尾,分配失败,就从段池中获得一个新的段,并再次尝试分配。
段有一个引用计数,该计数在每次分配时递增,在包装对象被释放时递减。一旦它的引用计数达到零,就可以通过将指针设置为零来重置它,并且可以选择用零填充存储器,然后将其返回到段池。
包装对象存储段的 byte[]、数据开始的偏移量、长度和一个非托管指针。实际上,包装器是进入段的大缓冲区的窗口。它还将引用该段,以便在包装被释放后减少段使用计数。包装器可以提供方便的方法,例如安全的索引器访问,它考虑了偏移量并验证访问是否在界限内。
自从。NET 开发人员习惯于假设缓冲区数据总是从索引 0 开始,并持续整个数组长度,您将需要修改代码,而不是假设,而是依赖于将与缓冲区一起传递的附加偏移量和长度参数。大多数。使用缓冲区的. NET BCL 方法具有显式接受偏移量和长度的重载。
这种方法的主要缺点是失去了自动内存管理。为了回收段,您必须显式地释放包装对象。实现终结器不是一个好的解决方案,因为这将抵消更多的性能优势。
图 8-2 。提出内存池方案
p/调用
平台调用,更好的说法是 P/Invoke ,支持调用 C 风格的函数,这些函数由 dll 从托管代码中导出。要使用 P/Invoke,托管调用方声明一个静态 extern 方法,其签名(参数类型和返回值类型)等同于 C 函数的签名。然后用 DllImport 属性标记该方法,同时至少指定导出该函数的 DLL。
// Native declaration from WinBase.h:
HMODULE WINAPI LoadLibraryW(LPCWSTR lpLibFileName);
// C# declaration:
class MyInteropFunctions {
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr LoadLibrary(string fileName);
}
在前面的代码中,我们将 LoadLibrary 定义为一个函数,它接受一个字符串并返回一个 IntPtr ,这是一个不能直接取消引用的指针类型,因此使用它不会导致代码不安全。DllImport 属性指定该函数由 kernel32.dll(它是主 Win32 API DLL)导出,并且 Win32 上一个错误代码应保存在线程本地存储中,以便不会被未显式完成的对 Win32 函数的调用覆盖(例如,在 CLR 内部)。DllImport 属性 也可以用来指定 C 函数的调用约定、字符串编码、导出名称解析选项等。
如果本机函数的签名包含复杂类型,如 C 结构,则等效的结构或类必须由托管代码定义,对每个字段使用等效的类型。相对结构字段顺序、字段类型和对齐方式必须符合 C 代码的要求。在某些情况下,您需要对字段、函数参数或返回值应用 MarshalAs 属性来修改默认的封送处理行为。例如,受管系统。布尔(bool)类型在本机代码中可以有多种表示形式:Win32 BOOL 类型有四个字节长,true 值是任何非零值,而在 C++中,BOOL 值有一个字节长,true 值等于 1。
在下面的代码清单中,应用于 WIN32 _ FIND _ DATAstruct 的 StructLayout 属性指定需要一个连续的内存中字段布局。没有它,CLR 可以自由地重新排列字段以提高效率。应用于 cFileName 和 cAlternativeFileName 字段的 MarshalAs 属性指定字符串应该作为嵌入在结构中的固定大小的字符串进行封送,而不仅仅是指向结构外部的字符串的指针。
// Native declaration from WinBase.h:
typedef struct _WIN32_FIND_DATAW {
DWORD dwFileAttributes;
FILETIME ftCreationTime;
FILETIME ftLastAccessTime;
FILETIME ftLastWriteTime;
DWORD nFileSizeHigh;
DWORD nFileSizeLow;
DWORD dwReserved0;
DWORD dwReserved1;
WCHAR cFileName[MAX_PATH];
WCHAR cAlternateFileName[14];
} WIN32_FIND_DATAW;
HANDLE WINAPI FindFirstFileW(__in LPCWSTR lpFileName,
__out LPWIN32_FIND_DATAW lpFindFileData);
// C# declaration:
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
struct WIN32_FIND_DATA {
public uint dwFileAttributes;
public FILETIME ftCreationTime;
public FILETIME ftLastAccessTime;
public FILETIME ftLastWriteTime;
public uint nFileSizeHigh;
public uint nFileSizeLow;
public uint dwReserved0;
public uint dwReserved1;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string cFileName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)]
public string cAlternateFileName;
}
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
static extern IntPtr FindFirstFile(string lpFileName, out WIN32_FIND_DATA lpFindFileData);
当您在前面的代码清单中调用 FindFirstFile 方法 时,CLR 会加载导出函数的 DLL(kernel 32 . DLL),定位所需的函数(FindFirstFile),并将参数类型从其托管表示形式转换为本机表示形式(反之亦然)。在此示例中,输入 lpFileName 字符串参数被转换为本机字符串,而对 lpFindFileData 参数所指向的 WIN32_FIND_DATAW 本机结构的写入被转换为对托管 WIN32_FIND_DATA 结构的写入。在下面的章节中,我们将详细描述每个阶段。
PInvoke.net 和 P/调用互操作助手
创建 P/Invoke 签名可能既困难又乏味。有很多规则要遵守,有很多细微差别要知道。产生不正确的签名会导致难以诊断的错误。幸运的是,有两个资源可以使这变得更容易:PInvoke.net 网站和 P/Invoke Interop Assistant 工具。
PInvoke.net 是一个非常有用的维基风格的网站,在那里你可以找到并贡献各种微软 API 的 P/Invoke 签名。PInvoke.net 是由亚当·内森创造的,他是微软的高级软件开发工程师,曾在。NET CLR 质量保证小组,并撰写了大量关于 COM 互操作性的书籍。您还可以下载一个免费的 Visual Studio 加载项,以便在不离开 Visual Studio 的情况下访问 P/Invoke 签名。
P/Invoke Interop Assistant 是微软的一个免费工具,可从 CodePlex 下载,并附带源代码。它包含一个数据库(一个 XML 文件),描述用于生成 P/Invoke 签名的 Win32 函数、结构和常数。给定 C 函数声明,它还可以生成 P/Invoke 签名;给定托管程序集,它还可以生成本机回调函数声明和本机 COM 接口签名。
图 8-3 。显示 CreateFile 的 P/Invoke 签名的 P/Invoke 互操作实例的屏幕截图
图 8-3 显示了微软的 P/Invole Interop Assistant 工具,在左侧显示了“CreateFile”的搜索结果,P/Invoke 签名以及相关的结构显示在右侧。P/Invoke Interop Assistant 工具(以及其他有用的 CLR interop 相关工具)可以从 http://clrinterop.codeplex.com/获得。
装订
当您第一次调用 P/Invoke 函数时,本机 DLL 及其依赖项通过 Win32 LoadLibrary 函数加载到进程中(如果它们尚未加载)。接下来,搜索所需的导出函数,可能首先搜索损坏的变量。搜索行为取决于 DllImport 的 CharSet 和 ExactSpelling 字段的值。
-
如果 ExactSpelling 为 true,P/Invoke 只搜索具有确切名称的函数,只考虑调用约定混乱。如果失败,P/Invoke 将不会继续搜索其他名称变体,并将抛出 EntryPointNotFoundException。
-
如果 ExactSpelling 为 false,则行为由 CharSet 属性决定:
-
如果设置为字符集。Ansi(默认),P/Invoke 首先搜索精确的(未混淆的)名称,然后搜索被破坏的名称(附加“A”)。
-
如果设置为字符集。Unicode,P/Invoke 首先搜索损坏的名称(附加“W”),然后搜索非托管名称。
对于 C# 来说,ExactSpelling 的默认值是 false,对于 value 来说是 True。字符集。Auto value 的行为类似于 CharSet。任何现代操作系统(比 Windows ME 更晚)上的 Unicode。
提示使用 Unicode 版本的 Win32 函数。Windows NT 和更高版本本身是 Unicode (UTF16)。如果调用 ANSI 版本的 Win32 函数,字符串将被转换为 Unicode,这会导致性能下降,并且调用 Unicode 版本的函数。那个。NET 字符串表示形式本身也是 UTF16,因此如果字符串参数已经是 UTF16,则封送字符串参数会更快。设计您的代码,尤其是接口要与 Unicode 兼容,这也有全球化的好处。将 ExactSpelling 设置为 true,这将通过消除不必要的函数搜索来加快初始加载时间。
编组员存根
当您第一次调用 P/Invoke'd 函数时,在加载本机 DLL 之后,将根据需要生成 P/Invoke 封送拆收器存根,并将在后续调用中重用。一旦被调用,编组器执行以下步骤:
- 检查调用方的非托管代码执行权限。
- 将托管参数转换为其适当的本机内存表示形式,可能会分配内存。
- 将线程的垃圾收集模式设置为先发制人,这样垃圾收集无需等待线程到达安全点即可发生。
- 调用本机函数。
- 将线程 GC 模式恢复为合作模式。
- 可以选择将 Win32 错误代码保存在线程本地存储中,供 Marshal.GetLastWin32Error 以后检索。
- 可以选择将 HRESULT 转换为异常并引发它。
- 如果引发到托管异常,则转换本机异常。
- 将返回值和输出参数转换回它们的托管内存表示形式。
- 清理所有临时分配的内存。
P/Invoke 也可以用来从本机代码调用托管代码。可以为委托生成反向封送拆收器存根(通过封送。GetFunctionPointerForDelegate),如果它作为参数在对本机函数的 P/Invoke 调用中传递。本机函数将接收一个代替委托的函数指针,它可以调用该指针来调用托管方法。函数指针指向一个动态生成的存根,除了参数封送,它还知道目标对象的地址(这个指针)。
英寸 NET Framework 1.x 中,封送拆收器存根由生成的汇编代码(用于简单签名)或生成的 ML(封送处理语言)代码(用于复杂签名)组成。ML 是内部字节码,由内部解释器执行。随着中 AMD64 和安腾支持的引入。NET Framework 2.0 之后,微软意识到为每个 CPU 架构实现并行 ML 基础设施将是一个巨大的负担。相反,64 位版本的。NET Framework 2.0 专门在生成的 IL 代码中实现。虽然 IL 存根比解释的 ML 存根快得多,但它们仍然比 x86 生成的程序集存根慢,所以微软选择保留 x86 实现。英寸 NET Framework 4.0 中,IL 存根生成基础结构得到了显著优化,这使得 IL 存根甚至比 x86 程序集存根更快。这允许微软完全移除 x86 特定的存根实现,并在所有架构上统一存根生成。
提示跨越托管到本机边界的函数调用至少比相同环境中的直接调用慢一个数量级。如果您同时控制本机代码和托管代码,则以最小化本机代码到托管代码的往返行程的方式构造接口(聊天式接口)。尝试将几个“工作项目”合并成一个呼叫(分块接口)。类似地,将几个简单函数的调用(例如简单的 Get/Set 函数)组合成一个外观,在一个调用中完成相同的工作。
微软从 CodePlex 提供了一个名为 IL Stub Diagnostics 的免费下载工具以及源代码。它订阅 CLR ETW IL 存根生成/缓存命中事件,并在 UI 中显示生成的 IL 存根代码。
下面我们展示一个带注释的示例 IL 封送处理存根,由五个代码部分组成:初始化、输入参数的封送处理、调用、返回值和/或输出参数的封送处理以及清理。封送拆收器存根用于以下签名:
// Managed signature:
[DllImport("Server.dll")]static extern int Marshal_String_In(string s);
// Native Signature:
unmanaged int __stdcall Marshal_String_In(char *s)
在初始化部分,存根声明局部(堆栈)变量,获取存根上下文并要求非托管代码执行权限。
// IL Stub:
// Code size 153 (0x0099)
.maxstack 3
// Local variables are:
// IsSuccessful, pNativeStrPtr, SizeInBytes, pStackAllocPtr, result, result, result
.locals (int32,native int,int32,native int,int32,int32,int32)
call native int [mscorlib] System.StubHelpers.StubHelpers::GetStubContext()
// Demand unmanaged code execution permissioncall void [mscorlib] System.StubHelpers.StubHelpers::DemandPermission(native int)
在封送处理部分,存根封送处理输入参数本机函数。在这个例子中,我们封送一个字符串输入参数。封送拆收器可以调用系统下的帮助器类型。StubHelpersnamespace 或系统。帮助将特定类型和类型类别从托管表示形式转换到本机表示形式以及从本机表示形式转换回来的类。在本例中,我们调用 CSTRMarshaler::convert native 来封送字符串。
这里有一个小小的优化:如果托管字符串足够短,它将被封送到堆栈上分配给的内存中(这样更快)。否则,必须从堆中分配内存。
ldc.i4 0x0 // IsSuccessful = 0 [push 0 to stack]
stloc.0 // [store to IsSuccessful]
IL_0010:
nop // argument {
ldc.i4 0x0 // pNativeStrPtr = null [push 0 to stack]
conv.i // [convert to an int32 to "native int" (pointer)]
stloc.3 // [store result to pNativeStrPtr]
ldarg.0 // if (managedString == null)
brfalse IL_0042 // goto IL_0042
ldarg.0 // [push managedString instance to stack]
// call the get Length property (returns num of chars)
call instance int32 [mscorlib] System.String::get_Length()
ldc.i4 0x2 // Add 2 to length, one for null char in managedString and
// one for an extra null we put in [push constant 2 to stack]
add // [actual add, result pushed to stack]
// load static field, value depends on lang. for non-Unicode
// apps system setting
ldsfld System.Runtime.InteropServices.Marshal::SystemMaxDBCSCharSize
mul // Multiply length by SystemMaxDBCSCharSize to get amount of
// bytes
stloc.2 // Store to SizeInBytes
ldc.i4 0x105 // Compare SizeInBytes to 0x105, to avoid allocating too much
// stack memory [push constant 0x105]
// CSTRMarshaler::ConvertToNative will handle the case of
// pStackAllocPtr == null and will do a CoTaskMemAlloc of the
// greater size
ldloc.2 // [Push SizeInBytes]
clt // [If SizeInBytes > 0x105, push 1 else push 0]
brtrue IL_0042 // [If 1 goto IL_0042]
ldloc.2 // Push SizeInBytes (argument of localloc)
localloc // Do stack allocation, result pointer is on top of stack
stloc.3 // Save to pStackAllocPtr
IL_0042:
ldc.i4 0x1 // Push constant 1 (flags parameter)
ldarg.0 // Push managedString argument
ldloc.3 // Push pStackAllocPtr (this can be null)
// Call helper to convert to Unicode to ANSI
call native int [mscorlib]System.StubHelpers.CSTRMarshaler::ConvertToNative(int32,string, native int)
stloc.1 // Store result in pNativeStrPtr,
// can be equal to pStackAllocPtr
ldc.i4 0x1 // IsSuccessful = 1 [push 1 to stack]
stloc.0 // [store to IsSuccessful]
nop
nop
nop
在下一节中,存根从存根上下文获得本机函数指针并调用它。call 指令实际上做了比我们在这里看到的更多的工作,例如改变 GC 模式和捕捉本机函数的返回,以便在 GC 正在进行并且处于需要暂停托管代码执行的阶段时暂停托管代码的执行。
ldloc.1 // Push pStackAllocPtr to stack,
// for the user function, not for GetStubContext
call native int [mscorlib] System.StubHelpers.StubHelpers::GetStubContext()
ldc.i4 0x14 // Add 0x14 to context ptr
add // [actual add, result is on stack]
ldind.i // [deref ptr, result is on stack]
ldind.i // [deref function ptr, result is on stack]
calli unmanaged stdcall int32(native int) // Call user function
下面的部分实际上由两部分组成,分别处理返回值和输出参数的“解组”(本机类型到托管类型的转换)。在本例中,本机函数返回一个不需要封送处理的 int,它只是按原样复制到一个局部变量。由于没有输出参数,后一部分是空的。
// UnmarshalReturn {
nop // return {
stloc.s 0x5 // Store user function result (int) into x, y and z
ldloc.s 0x5
stloc.s 0x4
ldloc.s 0x4
nop // } return
stloc.s 0x6
// } UnmarshalReturn
// Unmarshal {
nop // argument {
nop // } argument
leave IL_007e // Exit try protected block
IL_007e:
ldloc.s 0x6 // Push z
ret // Return z
// } Unmarshal
最后,清理部分释放为了封送而临时分配的内存。它在 finally 块中执行清理,这样即使本机函数抛出异常,清理也会发生。它也可能只在出现异常的情况下执行一些清理。在 COM interop 中,它可以将指示错误的 HRESULT 返回值转换为异常。
// ExceptionCleanup {
IL_0081:
// } ExceptionCleanup
// Cleanup {
ldloc.0 // If (IsSuccessful && !pStackAllocPtr)
ldc.i4 0x0 // Call ClearNative(pNativeStrPtr)
ble IL_0098
ldloc.3
brtrue IL_0098
ldloc.1
call void [mscorlib] System.StubHelpers.CSTRMarshaler::ClearNative(native int)
IL_0098:
endfinally
IL_0099:
// } Cleanup
.try IL_0010 to IL_007e finally handler IL_0081 to IL_0099
总之,即使对于这个简单的函数签名,IL 封送拆收器存根也不是简单的。复杂的签名会导致更长更慢的 IL 封送拆收器存根。
可直接复制到本机结构中的类型
大多数本机类型与托管代码共享一个公共的内存表示形式。这些类型称为可直接复制到本机结构中的类型,不需要转换,并且按原样跨托管到本机的边界传递,这比封送非可直接复制到本机结构中的类型要快得多。事实上,封送拆收器存根可以通过固定托管对象并将指向托管对象的直接指针传递给本机代码来进一步优化这种情况,从而避免一两次内存复制操作(每个所需的封送方向一次)。
可直接复制到本机结构中的类型是下列类型之一:
- 系统。字节(字节)
- 系统。SByte
- 系统。Int16(短)
- 系统。UInt16 (ushort)
- 系统。Int32(整数)
- 系统。UInt32 (uint)
- -系统。Int64(长)
- 系统。UInt64 (ulong)
- 系统。句柄
- System.UIntPtr
- 系统。单一(浮动)
- 系统。双倍(双倍)
此外,可直接复制到本机结构中的类型的一维数组(其中所有元素的类型都相同)也是可直接复制到本机结构中的,只包含可直接复制到本机结构中的字段的结构或类也是如此。
一个系统。Boolean (bool)不是 blittable,因为它在本机代码(系统)中可以有 1、2 或 4 个字节的表示形式。Char (char)不是 blittable,因为它可以表示 ANSI 或 Unicode 字符和系统。String(字符串)不是 blittable,因为它的本机表示可以是 ANSI 或 Unicode,它可以是 C 风格的字符串或 COM BSTR,并且托管字符串需要是不可变的(如果本机代码修改了字符串,这是有风险的,会破坏不变性)。包含对象引用字段的类型不是可直接复制到本机结构中的,即使它是对可直接复制到本机结构中的类型或其数组的引用。封送非直接复制到本机结构中的类型包括分配内存来保存参数的转换版本,适当地填充它,最后释放以前分配的内存。
通过手动封送字符串输入参数,可以获得更好的性能(有关示例,请参见下面的代码)。本机被调用方必须接受一个 C 样式的 UTF-16 字符串,并且它不应该写入该字符串所占用的内存,因此这种优化并不总是可行的。手动封送处理包括固定输入字符串,修改 P/Invoke 签名以采用 IntPtr 而不是字符串,并传递一个指向固定字符串对象的指针。
class Win32Interop {
[DllImport("NativeDLL.DLL", CallingConvention = CallingConvention.Cdecl)]
public static extern void NativeFunc(IntPtr pStr); // takes IntPtr instead of string
*}*
//Managed caller calls the P/Invoke function inside a fixed scope which does string pinning:
unsafe
{
string str = "MyString";
fixed (char *pStr = str) {
//You can reuse pStr for multiple calls.
Win32Interop.NativeFunc((IntPtr)pStr);
}
}
将本机 C 样式的 UTF-16 字符串转换为托管字符串也可以通过使用 System。String 的构造函数以 char*作为参数。系统。字符串构造函数将制作缓冲区的副本,以便在创建托管字符串后可以释放本机指针。请注意,没有进行任何验证来确保字符串只包含有效的 Unicode 字符。
封送方向、值和引用类型
如前所述,封送拆收器存根可以单向或双向封送函数参数。参数的封送方向由许多因素决定:
- 参数是值类型还是引用类型。
- 参数是通过值传递还是通过引用传递。
- 该类型是否可直接复制到本机结构中。
- 是否封送方向修改属性(系统。属性和系统。RuntimeInteropService . out attribute)应用于该参数。
出于讨论的目的,我们将“in”方向定义为托管到本机封送方向;相反,“向外”方向是管理方向的原生方向。下面是默认封送方向规则的列表:
-
通过值传递的参数,不管它们是值类型还是引用类型,都只按“入”的方向进行封送。
-
您不需要手动应用 In 属性。
-
StringBuilder 是这个规则的一个例外,它总是被“in/out”封送。
-
通过引用传递的参数(通过 ref C# 关键字或 ByRef VB。NET 关键字),不管它们是值类型还是引用类型,都被“入/出”封送。
单独指定 OutAttribute 将禁止“in”封送,因此本机被调用方可能看不到调用方完成的初始化。C# out 关键字的行为类似于 ref 关键字,但增加了一个 OutAttribute。
提示如果参数在 P/Invoke 调用中不可直接复制到本机结构中,并且您只需要在“out”方向上封送,那么您可以通过使用 out C# 关键字而不是 ref 关键字来避免不必要的封送。
由于上面提到的可直接复制到本机结构中的参数固定优化,可直接复制到本机结构中的引用类型将获得有效的“入/出”封送,即使上面的规则另有说明。如果您需要“out”或“in/out”封送处理行为,则不应该依赖于此行为,而是应该显式指定方向属性,因为如果您稍后添加了一个非直接复制到本机结构中的字段,或者这是一个跨越单元边界的 COM 调用,则此优化将停止工作。
封送值类型和引用类型之间的差异体现在它们在堆栈上的传递方式上。
- 由值传递的值类型作为副本被推送到堆栈上,因此不管修改属性如何,它们总是被有效地封送到“in”中。
- 通过引用传递的值类型和通过值传递的引用类型通过指针传递。
- 通过引用传递的引用类型作为指针传递给指针。
注意通过值传递大的值类型参数(十几个字节长)比通过引用传递开销更大。对于大的返回值也是如此,其中 out 参数是一种可能的选择。
代码访问安全性
的。NET 代码访问安全 机制支持在沙箱中运行部分受信任的代码,对运行时功能(例如 P/Invoke)和 BCL 功能(例如文件和注册表访问)的访问受到限制。调用本机代码时,CAS 要求其方法出现在调用堆栈中的所有程序集都具有 UnmanagedCode 权限。封送拆收器存根将为每个调用要求此权限,这包括遍历调用堆栈以确保所有代码都具有此权限。
提示如果您只运行完全受信任的代码,或者您有其他方法来确保安全性,您可以通过将 SuppressUnmanagedCodeSecurityAttribute 放在 P/Invoke 方法声明、类(在这种情况下,它适用于包含的方法)、接口或委托上来获得显著的性能提升。
COM 互操作性
COM 的设计目的就是用任何支持 COM 的语言/平台编写组件,并在任何(其他)支持 COM 的语言/平台上使用这些组件。。NET 也不例外,它允许您轻松地使用 COM 对象并公开。NET 类型作为 COM 对象。
对于 COM interop,基本思想与 P/Invoke 相同:声明 COM 对象的托管表示,CLR 创建处理封送处理的包装对象。有两种包装器:运行时可调用包装器(RCW ) ,使托管代码能够使用 COM 对象(参见图 8-4 ),以及 COM 可调用包装器(CCW) ,使 COM 代码能够调用托管类型(参见图 8-5 )。第三方 COM 组件通常附带一个主互操作程序集,该程序集包含供应商批准的互操作定义,并且具有强名称(签名)并安装在 GAC 中。其他时候,您可以使用 tlbimp.exe 工具,它是 Windows SDK 的一部分,根据类型库中包含的信息自动生成互操作程序集。
COM interop 重用 P/Invoke 参数封送处理基础结构,但在默认情况下有一些更改(例如,默认情况下将字符串封送到 BSTR),因此本章 P/Invoke 部分提供的建议也适用于此处。
COM 有其自身的性能问题,这是由特定于 COM 的特性造成的,如单元线程模型和 COM 的引用计数特性与。NET 垃圾收集方法。
图 8-4 。托管客户端调用非托管 COM 对象
图 8-5 。非托管客户端调用托管 COM 对象
生命周期管理
中保存对 COM 对象的引用时。NET,您实际上持有对 RCW 的引用。RCW 总是保存对基础 COM 对象的单个引用,并且每个 COM 对象只有一个 RCW 实例。RCW 维护自己的引用计数,与 COM 引用计数分开。该引用计数的值通常为 1,但是如果已经封送了多个接口指针,或者如果同一接口已经被多个线程封送,则该值可以更大。
通常,当对 RCW 的最后一个托管引用消失,并且在 RCW 驻留的代上有后续的垃圾收集时;RCW 的终结器运行,并通过调用 COM 基础对象的 IUnknown 接口指针上的释演法来减少 COM 对象的引用计数(为 1)。COM 对象随后自我销毁并释放其内存。
自从。NET GC 在不确定的时间运行,并且不知道由它保持 rcw 和随后的 COM 对象活动所引起的非托管内存负担,它不会加速垃圾回收,并且内存使用率可能会变得非常高。
如果有必要,你可以打电话给法警。方法显式释放对象。每次调用都会减少 RCW 的引用计数,当它达到零时,底层 COM 对象的引用计数也会减少(就像 RCW 的终结器正在运行的情况一样),从而释放它。您必须确保在调用 Marshal.ReleaseComObject 后不继续使用 RCW。如果 RCW 引用计数大于零,您将需要调用 Marshal。循环中的 ReleaseComObject,直到返回值等于零。最佳做法是调用 Marshal。finally 块中的 ReleaseComObject,以确保即使在 COM 对象的实例化和释放之间的某个地方引发了异常,也会发生释放。
公寓编组
COM 实现了自己的线程同步机制来管理跨线程调用,甚至是针对不是为多线程设计的对象。如果没有意识到这些机制,它们会降低性能。虽然这个问题不是与的互操作性所特有的。尽管如此,它仍然值得讨论,因为这是一个常见的陷阱,可能是因为开发人员习惯了典型的。NET 线程同步约定可能不知道 COM 在幕后做什么。
COM 分配对象和线程单元 ,它们是 COM 调度调用的边界。COM 有几种公寓类型:
- 单线程单元(STA) ,每个单元都托管一个线程,但是可以托管任意数量的对象。一个流程中可以有任意数量的 STA 单元。
- 多线程单元(MTA) ,托管任意数量的线程和任意数量的对象,但是在一个进程中只有一个 MTA 单元。这是的默认设置。网螺纹。
- 中性线程单元(NTA) ,托管对象而不是线程。只有一个 NTA 公寓在处理中。
当调用 CoInitialize 或 CoInitializeEx 来初始化线程的 COM 时,该线程被分配给一个单元。调用 CoInitialize 会将线程分配给一个新的 STA 单元,而 CoInitializeEx 允许您指定 STA 或 MTA 分配。英寸 NET 中,不直接调用这些函数,而是用 STAThread 或 MTAThread 属性标记线程的入口点(或 Main)。或者,您可以调用 Thread。SetApartmentState 方法或线程。线程启动前的 ApartmentState 属性。如果没有特别说明,。NET 将线程(包括主线程)初始化为 MTA。
COM 对象根据 ThreadingModel 注册表值分配给单元,该值可以是:
- 单一对象驻留在默认 STA 中。
- 单元(STA)-对象必须驻留在任何 STA 中,并且只允许该 STA 的线程直接调用该对象。不同的实例可以驻留在不同的 STA 中。
- 自由(MTA)-对象位于 MTA。任何数量的 MTA 线程都可以直接并发地调用它。对象必须确保线程安全。
- 两者——对象位于创建者的公寓(STA 或 MTA)。本质上,它一旦被创造出来,就变成类似 STA 或类似 MTA 的物体。
- neutral–对象驻留在中性单元中,从不需要封送。这是最有效的模式。
关于单元、线程和对象的可视化表示,参见图 8-6 。
图 8-6 。进程划分成 COM 单元
如果你创建一个对象,它的线程模型与创建者线程单元的线程模型不兼容,你将收到一个接口指针,它实际上指向一个代理。如果需要将 COM 对象的接口传递给属于不同单元的不同线程,则不应直接传递接口指针,而是需要封送处理。COM 将根据需要返回一个代理对象。
封送处理包括将函数调用(包括参数)转换成消息,该消息将被发送到接收方 STA 单元的消息队列。对于 STA 对象,这被实现为一个隐藏窗口,其窗口过程接收消息并通过存根将调用发送到 COM 对象。这样,STA COM 对象总是由同一个线程调用,这显然是线程安全的。
当调用者的单元与 COM 对象的单元不兼容时,会发生线程切换和跨线程参数封送处理。
提示通过将 COM 对象的单元与创建线程的单元相匹配来避免线程间的性能损失。在 STA 线程上创建和使用单元线程(STA) COM 对象,在 MTA 线程上创建和使用自由线程 COM 对象。标记为支持这两种模式的 COM 对象可以在任一线程中使用,而不会受到影响。
从 ASP.NET 调用 STA 对象
默认情况下,ASP.NET 在 MTA 线程上执行页面。如果您调用 STA 对象,它们将经历封送处理。如果您主要调用 STA 对象,这将降低性能。您可以通过用 ASPCOMPAT 属性标记页面来解决这个问题,如下所示:
<%@Page Language = "vb" AspCompat = "true" %>
请注意,页面构造函数仍然在 MTA 线程中执行,因此将 STA 对象的创建推迟到 Page_Load 和 Page_Init 事件。
TLB 导入和代码访问安全性
代码访问安全性执行与 P/Invoke 中相同的安全检查。您可以将/unsafe 开关与 tlbimp.exe 实用工具一起使用,该实用工具将对生成的类型发出 SuppressUnmanagedCodeSecurity 属性。仅在完全信任的环境中使用此选项,因为这可能会带来安全问题。
诺比
之前。在. NET Framework 4.0 中,您不得不将互操作程序集或主互操作程序集(PIA)与您的应用或外接程序一起分发。这些程序集往往很大(与使用它们的代码相比更是如此),并且它们通常不是由 COM 组件的供应商安装的;相反,它们是作为可再发行的软件包安装的,因为 COM 组件本身的操作并不需要它们。不安装 pia 的另一个原因是,它们必须安装在 GAC 中,这使得. NET Framework 依赖于完全本机应用的安装程序。
从……开始。NET Framework 4.0 中,C# 和 VB.NET 编译器可以检查哪些 COM 接口和其中的哪些方法是必需的,并且可以仅将必需的接口定义复制和嵌入到调用程序集中,从而消除了分发 PIA DLLs 的需要并减小了代码大小。微软将这一功能称为 NoPIA。它对主互操作程序集和一般的互操作程序集都有效。
PIA 程序集有一个重要的特性,叫做类型等价。由于它们有一个强名称并被放入 GAC,不同的托管组件可以交换 rcw,并从。从. NET 的角度来看,它们应该有等价的类型。相比之下,由 tlbimp.exe 生成的互操作程序集没有此功能,因为每个组件都有自己独特的互操作程序集。对于 NoPIA,因为没有使用强名称程序集,所以 Microsoft 提出了一个解决方案,只要接口具有相同的 GUID,就将来自不同程序集的 rcw 视为相同的类型。
要启用 NoPIA,选择引用下的互操作程序集的属性,并将“嵌入互操作类型”设置为真(参见图 8-7 )。
图 8-7 。在互操作程序集引用属性中启用 NoPIA
例外情况
大多数 COM 接口方法通过 HRESULT 返回值报告成功或失败。负的 HRESULT 值(设置了最高有效位)表示失败,而零(S_OK)或正值表示成功。此外,通过调用 SetErrorInfo 函数,传递通过调用 CreateErrorInfo 创建的 IErrorInfo 对象,COM 对象可以提供更丰富的错误信息。当通过 COM interop 调用 COM 方法时,封送拆收器存根根据 HRESULT 值和 IErrorInfo 对象中包含的数据将 HRESULT 转换为托管异常。由于引发异常的代价相对较高,因此频繁失败的 COM 函数会对性能产生负面影响。可以通过用 PreserveSigAttribute 标记方法来禁止自动异常转换。您必须更改托管签名以返回一个 int,retval 参数将成为一个“out”参数。
C++/CLI 语言扩展
C++/CLI 是一组 C++语言扩展,支持创建混合托管和本机 dll。在 C++/CLI 中,即使在同一个中,也可以有托管和非托管类或函数。cpp 文件。您可以使用托管类型以及本机 C 和 C++类型,就像在普通 C++中一样,即通过包含一个头文件并链接到库。这些强大的功能可用于构造可从任何。NET 语言以及本机包装类和函数(公开为。dll,。lib 和。h 文件),这些文件可由本机 C/C++代码调用。
C++/CLI 中的封送处理是手动完成的,开发人员可以更好地控制封送处理,并且更清楚封送处理的性能损失。C++/CLI 可以成功用于 P/Invoke 无法应对的场景,比如可变长度结构的封送。C++/CLI 的另一个优势 是,即使您不控制被调用者的代码,您也可以通过重复调用本机方法来模拟粗块接口方法,而不必每次都跨越托管到本机的界限。
在下面的代码清单中,我们实现了一个本机 NativeEmployee 类和一个托管 Employee 类,包装了前者。只有后者可以从托管代码中访问。
如果查看清单,您会看到 Employee 的构造函数展示了两种托管到本机字符串转换的技术:一种是分配需要显式释放的 GlobalAlloc 内存 ,另一种是将托管字符串临时固定在内存中并返回一个直接指针。后一种方法速度更快,但只有在本机代码需要 UTF-16 空终止字符串的情况下才有效,并且您可以保证指针所指向的内存不会发生写操作。此外,长时间锁定被管理对象会导致内存碎片(见第四章),所以如果不能满足上述要求,你将不得不求助于复制。
Employee 的 GetName 方法 展示了三种本机到托管字符串转换的技术:一种使用系统。runtime . interop services . marshal 类,一个使用在 msclr/marshal.h 头文件中定义的 marshal_as 模板函数(我们将在后面讨论),最后一个使用 System。String 的构造函数,这是最快的。
Employee 的 DoWork 方法 接受一个托管数组或托管字符串,并将其转换为一个 wchar_t 指针数组,每个指针指向一个字符串;本质上,它是一个 C 风格的字符串数组。托管到原生字符串的转换是通过 marshal_context 的 marshal_as 方法完成的。与 marshal_as 全局函数不同,marshal_context 用于需要清理的转换。通常这些都是托管到非托管转换,在调用 marshal_as 期间分配非托管内存,一旦不再需要就需要释放。marshal_context 对象包含一个清理操作的链表,当它被销毁时执行这些操作。
#include <msclr/marshal.h>
#include <string>
#include <wchar.h>
#include <time.h>
using namespace System;
using namespace System::Runtime::InteropServices;
class NativeEmployee {
public:
NativeEmployee(const wchar_t *employeeName, int age)
: _employeeName(employeeName), _employeeAge(age) { }
void DoWork(const wchar_t **tasks, int numTasks) {
for (int i = 0; i < numTasks; i++) {
wprintf(L"Employee %s is working on task %s\n",
_employeeName.c_str(), tasks[i]);
}
}
int GetAge() const {
return _employeeAge;
}
const wchar_t *GetName() const {
return _employeeName.c_str();
}
private:
std::wstring _employeeName;
int _employeeAge;
};
#pragma managed
namespace EmployeeLib {
public ref class Employee {
public:
Employee(String ^employeeName, int age) {
//OPTION 1:
//IntPtr pEmployeeName = Marshal::StringToHGlobalUni(employeeName);
//m_pEmployee = new NativeEmployee(
// reinterpret_cast<wchar_t *>(pEmployeeName.ToPointer()), age);
//Marshal::FreeHGlobal(pEmployeeName);
//OPTION 2 (direct pointer to pinned managed string, faster):
pin_ptr<const wchar_t> ppEmployeeName = PtrToStringChars(employeeName);
_employee = new NativeEmployee(ppEmployeeName, age);
}
∼Employee() {
delete _employee;
_employee = nullptr;
}
int GetAge() {
return _employee->GetAge();
}
String ^GetName() {
//OPTION 1:
//return Marshal::PtrToStringUni(
// (IntPtr)(void *) _employee->GetName());
//OPTION 2:
return msclr::interop::marshal_as<String ^>(_employee->GetName());
//OPTION 3 (faster):
return gcnew String(_employee->GetName());
}
void DoWork(array<String^>^ tasks) {
//marshal_context is a managed class allocated (on the GC heap)
//using stack-like semantics. Its IDisposable::Dispose()/d’tor will
//run when exiting scope of this function.
msclr::interop::marshal_context ctx;
const wchar_t **pTasks = new const wchar_t*[tasks->Length];
for (int i = 0; i < tasks->Length; i++) {
String ^t = tasks[i];
pTasks[i] = ctx.marshal_as<const wchar_t *>(t);
}
m_pEmployee->DoWork(pTasks, tasks->Length);
//context d’tor will release native memory allocated by marshal_as
delete[] pTasks;
}
private:
NativeEmployee *_employee;
};
}
总之,C++/CLI 提供了对封送处理的精细控制,并且不需要容易出错的重复函数声明,尤其是当您经常更改本机函数签名时。
marshal_as 助手库
在本节中,我们将详细介绍作为 Visual C++ 2008 和更高版本的一部分提供的 marshal_as 帮助器库。
marshal_as 是一个模板库,用于简化和方便地将托管类型封送到本机类型,反之亦然。它可以将许多本机字符串类型(如 char *、wchar_t *、std::string、std::wstring、CStringT
该库是在 marshal.h(对于基类型)、marshal_windows.h(对于 windows 类型)、marshal_cppstd.h(对于 STL 数据类型)和 marshal_atl.h(对于 atl 数据类型)中内联声明和实现的。
marshal_as 可以扩展为处理用户定义类型的转换。这有助于在许多地方封送同一类型时避免代码重复,并允许对不同类型的封送使用统一的语法。
下面的代码是一个扩展 marshal_as 以处理托管字符串数组到等效的本机字符串数组的转换的示例。
namespace msclr {
namespace interop {
template<>
ref class context_node<const wchar_t**, array<String^>^> : public context_node_base {
private:
const wchar_t** _tasks;
marshal_context _context;
public:
context_node(const wchar_t**& toObject, array<String^>^ fromObject) {
//Conversion logic starts here
_tasks = NULL;
const wchar_t **pTasks = new const wchar_t*[fromObject->Length];
for (int i = 0; i < fromObject->Length; i++) {
String ^t = fromObject[i];
pTasks[i] = _context.marshal_as<const wchar_t *>(t);
}
toObject = _tasks = pTasks;
}
∼context_node() {
this->!context_node();
}
protected:
!context_node() {
//When the context is deleted, it will free the memory
//allocated for the strings (belongs to marshal_context),
//so the array is the only memory that needs to be freed.
if (_tasks != nullptr) {
delete[] _tasks;
_tasks = nullptr;
}
}
};
}
}
//You can now rewrite Employee::DoWork like this:
void DoWork(array<String^>^ tasks) {
//All unmanaged memory is freed automatically once marshal_context
//gets out of scope.
msclr::interop::marshal_context ctx;
_employee->DoWork(ctx.marshal_as<const wchar_t **>(tasks), tasks->Length);
}
IL 代码与本机代码
默认情况下,非托管类将被编译为 C++/CLI 中的 IL 代码,而不是机器代码。相对于优化的本机代码,这可能会降低性能,因为 Visual C++编译器比 JIT 更能优化代码。
您可以在一段代码前使用#pragma unmanaged 和#pragma managed 来重写复杂行为。此外,在 VC++项目中,您还可以为单个编译单元启用 C++/CLI 支持。cpp 文件)。
Windows 8 WinRT Interop
Windows Runtime (WinRT) 是专为 Windows 8 Metro 风格应用设计的新平台。WinRT 以本机代码实现(即 WinRT 不使用. NET Framework),但是可以从 C++/CX 针对 WinRT,。NET 语言或 JavaScript。WinRT 取代了 Win32 和。NET BCL,它变得不可访问。WinRT 强调异步,对于任何可能需要 50 毫秒以上才能完成的操作,它都是强制性的。这样做是为了确保流畅的 UI 性能,这对于像 Metro 这样基于触摸的用户界面尤其重要。
WinRT 是建立在 COM 的高级版本之上的。以下是 WinRT 和 COM 之间的一些区别:
- 对象是使用 RoCreateInstance 创建的。
- 所有对象都实现 IInspectable 接口,该接口又派生自我们熟悉的 IUnknown 接口。
- 支持。NET 样式的属性、委托和事件(而不是接收器)。
- 支持参数化接口(“泛型”)。
- 使用。NET 元数据格式(。winmd 文件)而不是 TLB 和 IDL。
- 所有类型都是从 Platform::Object 派生的。
尽管借鉴了很多。由于 WinRT 完全是在本机代码中实现的,因此从非。NET 语言。
微软实现了语言投影,将 WinRT 概念映射到特定于语言的概念,无论是 C++/CX、C# 还是 JavaScript。例如,C++/CX 是 C++的新语言扩展,它自动管理引用计数,将 WinRT 对象激活(RoActivateInstance)转换为 C++构造函数,将 HRESULTs 转换为异常,将“retval”参数转换为返回值,等等。
当调用者和被调用者都被管理时,CLR 足够聪明,可以直接进行调用,而不涉及内部操作。对于跨越本机到托管边界的调用,会涉及常规 COM 互操作。当调用方和被调用方都用 C++实现,并且被调用方的头文件可供调用方使用时,不涉及 COM 互操作,调用非常快,否则,需要进行 COM 查询接口。
互操作的最佳实践
以下是高性能互操作的最佳实践摘要列表:
- 通过分块(组合)工作,设计接口以避免本机管理的转换。
- 用立面减少往返行程。
- 如果非托管资源跨调用持有,则实现 IDisposable。
- 考虑使用内存池或非托管内存。
- 考虑使用不安全代码来重新解释数据(例如在网络协议中)。
- 显式命名您调用的函数,并使用 ExactSpelling=true。
- 尽可能使用 blittable 参数类型。
- 尽可能避免 Unicode 到 ANSI 的转换。
- 手动整理进出 IntPtr 的字符串。
- 使用 C++/CLI 可以更好地控制 C/C++和 COM 互操作并提高性能。
- 指定[In]和[Out]属性以避免不必要的封送处理。
- 避免固定对象的长寿命。
- 考虑调用 ReleaseComObject。
- 考虑将 SuppressUnmanagedCodeSecurityAttribute 用于性能关键的完全信任方案。
- 对于性能关键的完全信任方案,考虑使用 TLBIMP /unsafe。
- 减少或避免跨公寓呼叫。
- 如果合适,在 ASP.NET 中使用 ASPCOMPAT 属性来减少或避免 COM 跨单元调用。
摘要
在本章中,您已经了解了不安全代码,各种互操作机制是如何实现的,每个实现细节如何对性能产生深远的影响,以及如何减轻这种影响。已经向您介绍了提高互操作性能和使编码更容易、更不容易出错的最佳实践和技术(例如,帮助生成 P/Invoke 签名和 marshal_as 库)。
九、算法优化
一些应用的核心是为特定领域设计的专门算法,这些算法基于并不普遍适用的假设。其他应用依赖于经过充分测试的算法,这些算法适用于许多领域,并且在整个计算机软件领域已经存在了几十年。我们相信,每个软件开发人员都可以从算法研究皇冠上的宝石中受益并获得洞察力,以及软件框架所基于的算法类别。虽然如果你没有很强的数学背景,这一章的某些部分可能会有些困难,但是它们是值得你努力的。
这一章轻轻拂过计算机科学的一些支柱,并回顾了不朽算法及其复杂性分析的几个例子。有了这些例子,你应该对使用现有的算法感到更舒服,使它们适应你的需要,并发明你自己的算法。
注这不是一本关于算法研究的教科书,也不是现代计算机科学中最重要的算法的介绍性文本。这一章故意写得很短,清楚地表明你不能从中学到所有你需要知道的东西。我们没有深入研究正式定义的任何细节。比如我们对图灵机和语言的处理一点都不严谨。关于算法的教科书介绍,考虑一下科尔曼、莱瑟森、里维斯特和斯坦的“算法介绍”(麻省理工学院出版社,2001 年)和达斯古普塔、帕帕迪米特里奥和瓦齐拉尼的“算法”(即将出版,目前在网上有草稿)。
复杂性分类
第五章曾有机会简要提及上某些操作的复杂性。NET 框架的集合和我们自己实现的一些集合。本节更准确地定义了 Big-Oh 复杂性的含义,并回顾了可计算性和复杂性理论中已知的主要复杂性类别。
Big-Oh 符号
当我们在第五章的中讨论一个列表
假设函数 thatn)返回在 n 个元素的输入大小上执行算法 A 所需的计算步骤的数量,设 f(n)是从正整数到正整数的单调函数。然后,T(A;n)是 O(f(n)),如果存在一个常数 c 使得对于所有的 n,thatn) ≤ cf(n)。
简而言之,我们可以说一个算法的运行时复杂度是 O ( f ( n )),如果 f ( n )是在大小为 n 的输入上执行该算法所需的实际步骤数的上界。界限不必太紧;比如我们也可以说 List < T > lookup 有运行时复杂度O(n4)。然而,使用这样一个宽松的上限是没有帮助的,因为它没有捕捉到这样一个事实:即使列表中有 1,000,000 个元素,在列表中搜索一个元素也是现实的。如果 List < T > lookup 有严格的运行时复杂性O(n4),那么即使在有几千个元素的列表上执行查找也会非常低效。
此外,对于某些输入,界限可能很紧,但对于其他输入,界限可能不紧;例如,如果我们在列表中搜索一个恰好是它的第一个元素的项目,步骤的数量显然是恒定的(一个!)对于所有的列表大小——这就是为什么我们在前面的段落中提到了最差情况下的运行时间。
这种表示法如何使推理运行时间和比较不同算法变得更容易的一些例子包括:
- 如果一个算法的运行时复杂度为 2 n 3 + 4,而另一个算法的运行时复杂度为n3-n2,我们可以说这两个算法的运行时复杂度都为O(n3),所以就 Big-Oh 复杂度而言,它们是等价的(试着找出常数很容易证明,当谈到大-哦复杂性时,除了最大项,我们可以省略所有的项。
- 如果一个算法的运行时复杂度为 n 2 ,而另一个算法的运行时复杂度为 100 n + 5000,我们仍然可以断言第一个算法在大输入时较慢,因为它具有大的复杂度 O ( n 2 ),而不是 O ( n )。事实上,对于n= 1000,第一种算法的运行速度明显慢于第二种算法。
类似于运行时复杂度上界的定义,也有下界(用ω(f(n)表示)和紧界(用θ(f(n)表示)。然而,它们很少被用来讨论算法复杂性,所以我们在这里省略了它们。
主定理
主定理 是一个简单的结果,为分析许多递归算法的复杂性提供了现成的解决方案,这些算法将原问题分解成更小的块。例如,考虑下面的代码,它实现了合并排序算法:
公共静态列表
如果(列表。Count <= 1)返回列表;
int middle = list。count/2;
List
列表
left = MergeSort(左);
right = MergeSort(右);
返回合并(左,右);
}
私有列表< T >合并(左侧列表< T >,右侧列表< T >其中 T : IComparable < T > {
列表< T >结果=新列表< T >();
int i = 0,j = 0;
而(我
如果(我
如果(左[我]。CompareTo(right[j]) <= 0)
结果。Add(左[i++]);
其他
结果。Add(右[j++]);
} else if (i < left。计数){
结果。Add(左[i++];
} else {
结果。Add(右++);
}
}
返回结果;
}
分析该算法的运行时间复杂度,需要求解递推方程的运行时间 T ( n ),递推给出为T(n)= 2T(n/2)+O(n)。解释是,对于 MergeSort 的每次调用,我们递归到 MergeSort 中,对原始列表的每一半进行合并,并执行线性时间的列表合并工作(显然,Merge helper 方法对大小为 n 的原始列表执行精确的 n 操作)。
求解递归方程的一种方法是猜测结果,然后试图证明(通常通过数学归纳法)结果的正确性。在这种情况下,我们可以扩展一些术语,看看是否出现了一种模式:
t(n)= 2T(n/2)+O(n)= 2(2T(n/4)+O(n/2))+O(n)= 2(2(2T(n/8)+O(n/4))+O(n/2))+O(n)=)。。。
主定理为这个递推方程和许多其它方程提供了一个封闭形式的解。根据主定理,T(n) = O(n logn),这是一个众所周知的关于归并排序复杂度的结果(其实对θ成立,对 O 也成立)。关于主定理的更多细节,请查阅维基百科在 http://en.wikipedia.org/wiki/Master_theorem 的文章。
图灵机和复杂类
通常将算法和问题称为“在 P 或“在 NP 或“NP-完成”。这些指的是不同的复杂性类别。将问题分成复杂的类别有助于计算机科学家轻松识别出有合理(易处理)解决方案的问题,并拒绝或寻找不合理问题的简化方案。
一台图灵机 ( TM ) 是一台理论计算设备,模拟一台机器在一个无限带的符号上运行。机器的磁头可以从磁带上一次读取或写入一个符号,机器内部可以处于有限数量的状态中的一种。设备的操作完全由一组有限的规则(一种算法)决定,例如“当处于状态 Q 且磁带上的符号为‘A’时,写入‘A’”或“当处于状态 P 且磁带上的符号为‘A’时,将磁头向右移动并切换到状态 S”。还有两种特殊状态:机器开始运行的开始状态和结束状态。当机器到达结束状态时,通常说它永远循环或者简单地停止执行。图 9-1 显示了图灵机定义的一个例子——圆圈是状态,箭头表示语法读取中的状态转换;写;头部 _ 移动 _ 方向。
图 9-1 。一个简单图灵机的状态图。最左边的箭头指向初始状态 Q。环形箭头表示当机器处于状态 Q 并读取符号 A 时,它向右移动并保持在状态 Q
在图灵机上讨论算法的复杂性时,不需要在含糊的“迭代”中推理——TM 的一个计算步骤就是一个单一的状态转移(包括从一个状态到自身的转移)。例如,当图 9-1 中的 TM 从其磁带上的输入“AAAa#”开始时,它正好执行四个计算步骤。我们可以概括地说,对于一个后面跟有“a#”的 n 的输入,机器执行 O ( n )步。(实际上,对于这样一个大小为 n,的输入,机器正好执行 n + 2 步,因此 O ( n )的定义成立,例如,常数 c = 3。)
使用图灵机对真实世界的计算进行建模是非常困难的——这在自动机理论的本科课程中是一个很好的练习,但没有实际用途。令人惊讶的结果是,每一个 C# 程序(事实上,每一个可以在现代计算机上执行的算法)都可以被翻译成图灵机,虽然很费力。粗略来说,如果一个用 C# 写的算法的运行时复杂度为 O ( f ( n ),那么同样的算法翻译成图灵机的运行时复杂度为O(f2(n))。这对于分析算法复杂性有非常有用的含义:如果一个问题对于图灵机有一个有效的算法,那么它对于现代计算机也有一个有效的算法;如果一个问题没有图灵机的有效算法,那么它通常也没有现代计算机的有效算法。
尽管我们可以称 O ( n 2 )算法高效,称任何“较慢”的算法低效,但复杂性理论的立场略有不同。 P 是图灵机可以在多项式时间内解决的所有问题的集合——换句话说,如果 A 是 P 中的一个问题(输入大小为 n ),那么存在一个图灵机,它在多项式时间内(即在O(nk内)在其磁带上产生期望的结果在复杂性理论的许多子领域中, P 中的问题被认为是简单的,在多项式时间内运行的算法被认为是有效的,即使 k 也是如此,因此,对于某些算法来说,运行时间可能非常长。
如果我们接受这个定义,本书迄今为止考虑的所有算法都是有效的。尽管如此,有些是“非常”有效的,而有些则不那么有效,这表明这种区分不够微妙。你甚至可能会问,是否有不在 P 中的问题,没有有效解决方案的问题。答案是响亮的“是”——事实上,从理论的角度来看,没有有效解决方案的问题比有有效解决方案的问题多。
首先,我们考虑一个图灵机无法解决的问题,不考虑效率。然后,我们看到了图灵机可以解决的问题,但不是在多项式时间内。最后,我们转向那些我们不知道是否有图灵机可以在多项式时间内解决它们,但强烈怀疑没有的问题。
磕磕绊绊的问题
从数学角度看,问题比图灵机多(我们说图灵机“可数”,问题不可数),这意味着一定有无限多的问题是图灵机解决不了的。这类问题通常被称为不可判定问题。
你说的“可数”是什么意思?
在数学中,“无限”有很多种。很容易看出图灵机的数量是无限的——毕竟,你可以添加一个对任何图灵机都没有作用的虚拟状态,并获得一个新的、更大的图灵机。类似地,很容易看到有无限多的问题——这需要一个问题的正式定义(作为一种“语言”),但会导致相同的结果。然而,为什么存在比图灵机“更多”的问题并不明显,特别是因为两者的数量都是无限的。
图灵机集合之所以说是可数 ,是因为从自然数(1,2,3,.。。)到图灵机。如何构建这种对应关系可能不会立即显而易见,但这是可能的,因为图灵机可以被描述为有限字符串,并且所有有限字符串的集合是可数的。
但是,问题(语言)的集合是不可数的,因为自然数到语言的一一对应并不存在。一个可能的证明如下:考虑对应于所有实数的问题集,其中,对于任何实数 r,问题是打印该数或识别该数是否已作为输入被提供。一个众所周知的结果(康托定理)是实数是不可数的,因此,这组问题也是不可数的。
总而言之,这似乎是一个不幸的结论。不仅有图灵机不能解决(决定)的问题,而且有比图灵机能解决的问题多得多的问题。幸运的是,许多问题可以被图灵机解决,正如 20 世纪和计算机不可思议的进化所示,尽管理论结果如此。
我们现在介绍的暂停问题是不可判定的。问题如下:接收作为输入的程序 T (或者图灵机的描述)和程序的输入w;在 w 上执行时,如果 T 停止,则返回 TRUE,如果没有停止(进入无限循环),则返回 FALSE。
你甚至可以把这个问题转化成一个 C# 方法,它接受一个程序的代码作为一个字符串:
public static bool DoesHaltOnInput(string programCode, string input) { . . . }
。。。或者甚至是一个 C# 方法,它接受一个委托和一个输入来支持它:
public static bool DoesHaltOnInput(Action< string > program, string input) { . . . }
虽然似乎有一种方法可以分析程序并确定它是否暂停(例如,通过检查它的循环、它对其他方法的调用等等),但事实证明既没有图灵机也没有 C# 程序可以解决这个问题。如何得出这个结论?显然,要说图灵机可以解决问题,我们只需要证明图灵机——但是,要说不存在解决特定问题的图灵机,似乎我们必须检查所有可能的图灵机,这些图灵机的数量是无限的。
就像数学中经常出现的情况一样,我们用反证法来证明。假设有人提出了 DoesHaltOnInput 方法,该方法测试我们是否可以编写下面的 C# 方法:
public static void Helper(string programCode, string input) {
bool doesHalt = DoesHaltOnInput(programCode, input);
if (doesHalt) {
while (true) {} //Enter an infinite loop
}
}
现在只需要在 Helper 方法的源上调用 DoesHaltOnInput (第二个参数没有意义)。如果 DoesHaltOnInput 返回 true,则 Helper 方法进入无限循环;如果 DoesHaltOnInput 返回 false,则 Helper 方法不会进入无限循环。这个矛盾说明 DoesHaltOnInput 方法不存在。
注磕磕绊绊的问题是一个令人羞愧的结果;简而言之,它证明了我们的计算设备的计算能力是有限的。下一次,当你责怪编译器没有找到一个明显微不足道的优化,或者你最喜欢的静态分析工具给了你一个永远不会发生的错误警告时,请记住,静态分析一个程序并对结果采取行动通常是不可决定的。优化、暂停分析、确定是否使用变量以及许多其他问题都是如此,这些问题对于给定特定程序的开发人员来说可能很容易,但通常无法由机器解决。
还有很多无法决定的问题。另一个简单的例子也源于计数论证。C# 程序的数量是可数的,因为每个 C# 程序都是符号的有限组合。然而,区间[0,1]中有不可计数的多个实数。因此,一定存在一个 C# 程序无法打印出来的实数。
NP-完全问题
即使在可判定问题的领域内——那些可以被图灵机解决的问题——也有没有有效解决方案的问题。在 n × n 棋盘上计算一局棋的完美策略需要 n 中的时间指数,这就把求解广义棋局的问题放在了 P 之外。(如果你喜欢跳棋,并且不喜欢计算机程序比人类更擅长玩跳棋,那么你应该感到欣慰的是,广义跳棋也不在 P 中。)
然而,有些问题被认为不太复杂,但是我们还没有多项式算法。这些问题中的一些在现实生活场景中非常有用:
- 旅行推销员问题:为一个必须走访 n 个不同城市的推销员找到一条成本最小的路径。
- 最大团问题:在一个图中找到节点的最大子集,使得子集中的每两个节点由一条边连接。
- 最小割问题:找到一种方法将一个图分成两个节点子集,使得从一个子集到另一个子集的边数最少。
- 布尔可满足性问题:确定一个特定形式的布尔公式(如“ A 和 B 或 C 而非 A ”)是否可以通过给其变量赋值真值来满足。
- 缓存放置问题:给定应用执行的内存访问的完整历史,决定将哪些数据放入缓存,哪些数据从缓存中清除。
这些问题属于另一组称为 NP 的问题。 NP 中的问题被刻画为:如果 A 是 NP 中的问题,那么存在一个图灵机能够在多项式时间内验证 A 对于大小为 n 的输入的解。例如,验证变量的真值赋值是合法的并且解决了布尔可满足性问题显然是非常容易的,并且变量的数量是线性的。类似地,验证一个节点子集是一个集团也非常容易。换句话说,这些问题有容易验证的解决方案,但不知道是否有一种方法可以有效地提出这些解决方案。
上述问题(以及许多其他问题)的另一个有趣的方面是,如果任何一个有一个有效的解决方案,那么它们所有的都有一个有效的解决方案。原因是它们可以从一个减少到另一个。此外,如果这些问题中的任何一个有一个有效的解决方案,这意味着问题在 P 中,那么整个 NP 复杂性类就折叠成 P 使得 P = NP 。可以说,今天理论计算机科学中最大的谜团是 P = NP (大多数计算机科学家认为这些复杂性类别是不相等的)。
对 P 和 NP 有这种崩塌效应的问题称为NP-完全问题。对于大多数计算机科学家来说,证明一个问题是 NP 完备的,就足以拒绝任何为它设计有效算法的尝试。随后的章节考虑一些 NP 的例子——具有可接受的近似或概率解的完整问题。
记忆和动态编程
记忆是一种保留中间计算结果的技术,如果稍后需要它们,而不是重新计算它们。它可以被认为是一种缓存形式。经典的例子来自计算斐波那契数,这通常是用来教授递归 的第一个例子:
public static ulong FibonacciNumber(uint which) {
if (which == 1 || which == 2) return 1;
return FibonacciNumber(which-2) + FibonacciNumber(which-1);
}
这种方法看起来很吸引人,但它的性能非常糟糕。对于小至 45°的输入,此方法需要几秒钟才能完成;用这种方法找到第 100 个斐波纳契数是不切实际的,因为它的复杂性呈指数增长。
这种低效率的原因之一是中间结果被多次计算。例如,通过递归计算 fibonaccinnumber(10)来查找 fibonaccinnumber(11)和 fibonaccinnumber(12),并再次查找 fibonaccinnumber(12)和 fibonaccinnumber(13),以此类推。将中间结果存储在数组中可以显著提高该方法的性能:
public static ulong FibonacciNumberMemoization(uint which) {
if (which == 1 || which == 2) return 1;
ulong[] array = new ulong[which];
array[0] = 1; array[1] = 1;
return FibonacciNumberMemoization(which, array);
}
private static ulong FibonacciNumberMemoization(uint which, ulong[] array) {
if (array[which-3] == 0) {
array[which-3] = FibonacciNumberMemoization(which-2, array);
}
if (array[which-2] == 0) {
array[which-2] = FibonacciNumberMemoization(which-1, array);
}
array[which-1] = array[which-3] + array[which-2];
return array[which-1];
}
这个版本在不到一秒的时间内找到第 10,000 个斐波纳契数,并线性缩放。顺便提一下,这个计算可以用更简单的术语来表达,只存储最后两个计算的数字 :
public static ulong FibonacciNumberIteration(ulong which) {
if (which == 1 || which == 2) return 1;
ulong a = 1, b = 1;
for (ulong i = 2; i < which; ++i) {
ulong c = a + b;
a = b;
b = c;
}
return b;
}
注值得注意的是,斐波那契数列有一个基于黄金比例的封闭公式(详见en . Wikipedia . org/wiki/Fibonacci _ number # Closed-form _ expression
)。然而,使用这个封闭的公式来找到一个准确的值可能涉及到不平凡的算术。
存储后续计算所需结果的简单想法在许多将大问题分解为一组较小问题的算法中非常有用。这种技术通常被称为动态编程 。我们现在考虑两个例子。
编辑距离
两个字符串之间的编辑距离 是将一个字符串转换成另一个字符串所需的字符替换(删除、插入和替换)次数。例如,“猫”和“帽子”之间的编辑距离是 1(用“h”替换“c”),而“猫”和“groat”之间的编辑距离是 3(插入“g”,插入“r”,用“o”替换“c”)。在许多情况下,有效地找到两个字符串之间的编辑距离是很重要的,例如纠错和带有替换建议的拼写检查。
高效算法的关键是将大问题分解成小问题。例如,如果我们知道“猫”和“帽子”之间的编辑距离是 1,那么我们也知道“猫”和“帽子”之间的编辑距离是 2——我们使用已经解决的子问题来设计更大问题的解决方案。在实践中,我们更小心地运用这种技术。给定两个数组形式的字符串, s[1。。。m] 和 t[1。。。n] ,以下持有:
-
空字符串与 t 的编辑距离为 n , s 与空字符串的编辑距离为 m (通过添加或删除所有字符)。
-
如果s【I】=t【j】和s【1】之间的编辑距离。。。i-1] 和 t[1。。。j-1] 是 k ,那么我们可以保留第 i 个字符和s【1】之间的编辑距离。。。i] 和 t[1。。。j] 是 k 。
-
如果s【I】≦t【j】,那么s【1。。。i] 和 t[1。。。j] 的最小值是:
-
s[1]之间的编辑距离。。。我] 和 t[1。。。j-1] ,+1 插入t【j】;
-
s[1]之间的编辑距离。。。i-1] 和 t[1。。。j] ,+1 删除s【I】;
-
s[1]之间的编辑距离。。。i-1] 和 t[1。。。j-1] ,+1 用t【j】替换s【I】。
下面的 C# 方法通过为每个子字符串构造一个编辑距离表,然后在表的最后一个单元格中返回编辑距离,来查找两个字符串之间的编辑距离:
public static int EditDistance(string s, string t) {
int m = s.Length, n = t.Length;
int[,] ed = new int[m,n];
for (int i = 0; i < m; ++i) {
ed[i,0] = i + 1;
}
for (int j = 0; j < n; ++j) {
ed[0,j] = j + 1;
}
for (int j = 1; j < n; ++j) {
for (int i = 1; i < m; ++i) {
if (s[i] == t[j]) {
ed[i,j] = ed[i-1,j-1]; //No operation required
} else { //Minimum between deletion, insertion, and substitution
ed[i,j] = Math.Min(ed[i-1,j] + 1, Math.Min(ed[i,j-1] + 1, ed[i-1,j-1] + 1));
}
}
}
return ed[m-1,n-1];
}
该算法逐列填充编辑距离表,因此它从不尝试使用尚未计算的数据。图 9-2 显示了在输入“口吃”和“暴食”上运行时,算法构建的编辑距离表。
图 9-2 。完整填写的编辑距离表
该算法使用 O ( mn )空间,运行时间复杂度为 O ( mn )。不使用记忆化的类似递归解决方案将具有指数级的运行时间复杂度,并且即使对于中等大小的字符串也不能充分执行。
所有对最短路径
所有对最短路径问题是寻找图中每两对顶点之间的最短距离。这对于规划工厂车间、估计城市间的旅行距离、评估所需的燃料成本以及许多其他现实生活场景 都很有用。作者在一次咨询活动中遇到了这个问题。这是客户提供的问题描述(原故事见萨沙·戈尔德施泰因在的博文 http://blog . sashag . net/archive/2010/12/16/all-pairs-shortest-paths-algorithm-in-real-life . aspx):
- 我们正在实施一项管理一组物理备份设备的服务。有一组交叉的传送带和机械手,它们在房间内操纵备份磁带。该服务得到请求,例如“将一盘新磁带 X 从存储柜 13 转移到备份柜 89,并确保通过格式化站 C 或 D ”。
- 当系统启动时,我们计算从每个机柜到其他机柜的所有最短路径,包括特殊请求,例如通过特定的计算机。该信息存储在一个大的哈希表中,该哈希表由路由描述索引,并以路由作为值。
- 具有 1,000 个节点和 250 个交叉点的系统启动需要 30 多分钟,内存消耗达到大约 5GB 的峰值。这是不可接受的。
首先,我们观察到约束“确保通过格式化计算机 C 或 D ”并没有带来显著的额外挑战。从 A 经过 C 到 B 的最短路径是从 A 到 C 的最短路径,其次是从 C 到 B 的最短路径(这个证明几乎是一个重言式)。
Floyd-Warshall 算法 寻找图中每对顶点之间的最短路径,并使用分解成更小的问题,与我们之前看到的非常相似。这一次,递归公式使用了与上面相同的观察结果:从 A 到 B 的最短路径经过某个顶点 V 。然后为了找到从 A 到 B、的最短路径,我们需要首先找到从 A 到 V 的最短路径,然后找到从 V 到 B 的最短路径,并将它们连接在一起。因为我们不知道什么是 V ,我们需要考虑所有可能的中间顶点——一种方法是从 1 到 n 对它们进行编号。
现在,仅使用顶点 1、.。。, k 由以下递推公式给出,假设从 i 到 j 没有边:
SP ( i 、 j 、k= min {sp(I、 j
要了解原因,请考虑顶点 k 。从 i 到 j 的最短路径要么使用这个顶点,要么不使用。如果最短路径不使用顶点 k ,那么我们不必使用这个顶点,并且可以依赖于仅使用顶点 1,.。。, k -1。如果最短路径使用顶点 k ,那么我们有我们的分解——最短路径可以从从 i 到 k 的最短路径缝合在一起(只使用顶点 1,.。。, k -1)以及从 k 到 j 的最短路径(仅使用顶点 1,.。。, k -1)。参见图 9–3 中的示例。
图 9-3 。在图的上半部分,从 I 到 j 的最短路径(通过顶点 2 和 5)不使用顶点 k。因此,我们可以使用顶点 1,.。。,k-1。在下半部分,从 I 到 j 的最短路径使用顶点 k。因此,从 I 到 j 的最短路径是从 I 到 k 的最短路径,与从 k 到 j 的最短路径缝合在一起
为了摆脱递归调用,我们使用记忆化——这次我们有一个三维表要填充,在找到所有顶点对的 SP 的值和 k 的所有值之后,我们就有了所有对最短路径问题的解决方案。
我们可以通过注意到,对于每一对顶点 i 、 j、,我们实际上不需要从 1 到 n 的 k 的所有值——我们只需要迄今为止获得的最小值,来进一步减少存储空间的量。这使得表格是二维的,存储只有O(n2)。运行时间复杂度保持O(n3),考虑到我们在图中每两个顶点之间寻找最短路径,这是相当不可思议的。
最后,在填充表格时,我们需要为每对顶点 i 、 j 记录下一个顶点 x ,如果我们想要找到顶点之间的实际最短路径,我们应该继续进行。将这些想法翻译成 C# 代码非常容易,基于最后一个观察 重构路径也是如此:
static short[,] costs;
static short[,] next;
public static void AllPairsShortestPaths(short[] vertices, bool[,] hasEdge) {
int N = vertices.Length;
costs = new short[N, N];
next = new short[N, N];
for (short i = 0; i < N; ++i) {
for (short j = 0; j < N; ++j) {
costs[i, j] = hasEdge[i, j] ? (short)1 : short.MaxValue;
if (costs[i, j] == 1)
next[i, j] = −1; //Marker for direct edge
}
}
for (short k = 0; k < N; ++k) {
for (short i = 0; i < N; ++i) {
for (short j = 0; j < N; ++j) {
if (costs[i, k] + costs[k, j] < costs[i, j]) {
costs[i, j] = (short)(costs[i, k] + costs[k, j]);
next[i, j] = k;
}
}
}
}
}
public string GetPath(short src, short dst) {
if (costs[src, dst] == short.MaxValue) return " < no path > ";
short intermediate = next[src, dst];
if (intermediate == −1)
return "- > "; //Direct path
return GetPath(src, intermediate) + intermediate + GetPath(intermediate, dst);
}
这个简单的算法极大地提高了应用的性能。在一个有 300 个节点且每个节点平均扇出 3 条边的模拟中,构建完整的路径集需要 3 秒,回答 100,000 个关于最短路径的查询需要 120 毫秒,仅使用 600KB 的内存。
近似值
本节考虑了两种算法,这两种算法不能为提出的问题提供精确的解决方案,但是它们给出的解决方案可以是适当的近似。如果我们寻找某个函数 f ( x )的最大值,那么一个返回的结果总是在实际值 c 的一个因子内(这可能很难找到)的算法被称为 c 近似算法 。
近似对于没有已知多项式算法的 NP 完全问题尤其有用。在其他情况下,使用近似比完全解决问题更有效地找到解决方案,牺牲了过程中的一些准确性。例如, O (对数n)2-逼近算法可能比 O ( n 3 )精确算法更适用于大输入。
旅行推销员
为了执行一个正式的分析,我们需要将前面提到的旅行推销员问题形式化。我们得到了一个带有权重函数 w 的图,它给图的边赋予了正值——你可以把这个权重函数想象成城市之间的距离。权重函数满足三角形不等式,如果我们继续类比在欧几里得表面上的城市间旅行,则该不等式肯定成立:
对于所有顶点 x,y,z w ( x 、和 ) + w ( y 、z)*
那么,任务就是精确地访问图中的每个顶点(推销员地图上的每个城市)一次,然后返回起始顶点(公司总部),确保沿着这条路径的边权重之和最小。设 wOPT 为这个最小重量。(等价的决策问题是NP——完全,如我们所见。)
近似算法如下进行。首先,为图构造一棵最小生成树 (MST) 。设 wMST 为树的总重量。(最小生成树是一个子图,它接触每个顶点,没有圈,并且在所有这样的树中具有最小的总边权。)
我们可以断言 wMST ≤ wOPT,,因为 wOPT 是一条循环路径访问每个顶点的总权重;去掉任何一条边都会生成一棵生成树,而 wMST 就是最小生成树的总重量。有了这个观察,我们使用最小生成树产生一个对 wOPT 的 2-近似,如下所示:
-
构建一个 MST。对此有一个已知的 O ( n log n )贪婪算法。
-
通过访问每个节点并返回到根节点,从根节点开始遍历 MST。这条路径的总权重为 2 wMST ≤ 2 wOPT 。
-
Fix the resulting path such that no vertex is visited more than once. If the situation in Figure 9-4 arises—the vertex y was visited more than once—then fix the path by removing the edges (x, y) and (y, z) and replacing them with the edge (x, z). This can only decrease the total weight of the path because of the triangle inequality.
图 9-4 。我们可以用路径(X,Z)代替路径(X,Y,Z ),而不是遍历 Y 两次
结果是一个 2-近似算法,因为产生的路径的总权重仍然最多是最佳路径权重的两倍。
最大切削量
给我们一个图,我们需要找到一个割——将它的顶点分成两个不相交的集合——使得在集合之间交叉的边的数量(穿过割)是最大的。这就是所谓的最大割问题,解决它对许多工程领域的规划都非常有用。
我们提出了一个非常简单直观的算法,它可以产生一个 2-近似值:
- 将顶点分成两个任意不相交的集合, A 和 B 。
- 在 A 中找到一个顶点 v ,它在 A 中的邻居比在 B 中的邻居多。如果找不到,停止。
- 将 v 从 A 移动到 B 并返回步骤 2。
首先,设 A 是顶点的子集, v 是 A 中的一个顶点。我们用 degA(v)表示 A 中与 v 有边的顶点的数量(即 A 中它的邻居的数量)。接下来,给定顶点的两个子集 A 、 B ,我们用 e ( A 、 B )表示两个不同集合中顶点之间的边数,用 e ( A )表示集合 A 中顶点之间的边数。
当算法终止时,对于 A、中的每个 v ,保持 degB(v)≥degA(v)——否则算法将重复步骤 2。对所有顶点求和,得到 e ( A ,B)≥degB(v1)+。。+degB(vk)≥degA(v1)+。。+degA(vk)≥2e(A),因为右手边的每一条边都数了两次。同样, e ( A ,B)≥2e(B),因此,2 e ( A ,B)≥2e(A 由此我们得到 2 个 e ( A ,B)≥e(A,B)+e(A)+e(B),但是右手边是总因此,穿过切口的边数至少是图中边总数的一半。穿过切割的边的数量不能大于图中边的总数,所以我们有一个 2-近似值。
最后,值得注意的是,该算法运行了许多步骤,这些步骤与图中的边数成线性关系。每当步骤 2 重复时,穿过切割的边的数量至少增加 1,并且它由图中的边的总数从上面限制,因此步骤的数量也由该数量从上面限制。
概率算法
当考虑近似算法时,我们仍然受到产生确定性解的要求的约束。然而,在某些情况下,将随机来源引入算法可以提供概率上合理的结果,尽管不再可能绝对保证算法的正确性或有限的运行时间。
概率最大割
原来,随机选取两个不相交的集合,就可以得到最大割问题的一个 2-近似(具体来说,就是对每个顶点抛硬币,决定它是进入 A 还是 B )。通过概率分析,穿过切口的预期边数是边的总数。
为了表明穿过切口的预期边数是边的总数,考虑特定边( u , v )穿过切口的概率。有四个等概率的备选方案:边在A;边缘在B; v 在 A、内 u 在 B 内;并且 v 在 B、内 u 在 A 内。因此,边缘穿过切口的概率为。
对于一条边 e ,指示变量Xe的期望值(当边穿过切口时等于 1)为。通过线性期望,期望的切割边数就是图中的边数。
请注意,我们不能再相信单轮的结果,但有一些随机化技术(如条件概率法)可以在少量恒定轮次后非常有可能成功。我们必须证明穿过切割的边的数量小于图中的边的数量的概率的一个界限——有几个概率工具,包括马尔可夫不等式,可以用于这个目的。但是,我们在这里不做这个练习。
费马素性检验
在一个范围内寻找质数是我们在第六章中并行化的一个操作,但是我们还没有找到一个更好的算法来测试一个数的质数。这个操作在应用密码学中很重要。例如,在互联网上普遍使用的 RSA 非对称加密算法依赖于寻找大素数来生成加密密钥。
一个简单的数论结果被称为费马小定理 陈述,如果 p 是质数,那么对于所有的数 1 ≤ a ≤ p ,数ap-1除以 p (表示为at 20】p-1)时余数为 1 我们可以用这个想法为候选人 n 设计下面的概率素性测试:
- 在区间[1, n 中任选一个随机数 a ,看费马小定理等式是否成立(即 a p -1 除以 p 是否有余数 1)。
- 如果等式不成立,则拒绝该数为合成数。
- 如果相等,接受该数为质数或重复步骤 1,直到达到所需的置信水平。
对于大多数合数,通过该算法的少量迭代检测到它是合数并拒绝它。当然,所有的质数重复多少次都会通过测试。
不幸的是,有无限多的数(称为 Carmichael 数)不是质数,但对于每一个值 a 和任何迭代次数都将通过测试。尽管 Carmichael 数非常罕见,但它们足以引起人们的关注,以便用能够检测 Carmichael 数的额外测试来改进 Fermat 素性测试。米勒-拉宾素性检验就是一个例子。
对于不是 Carmichael 的合数,选择等式不成立的数 a 的概率大于。因此,随着迭代次数的增加,错误地将一个合数识别为素数的概率呈指数下降:使用足够的迭代次数可以任意降低接受一个合数的概率。
索引和压缩
当存储大量数据时,例如搜索引擎的索引网页,压缩数据并在磁盘上有效地访问数据通常比纯粹的运行时复杂性更重要。本节考虑了两个简单的示例,这两个示例在保持高效访问时间的同时,最大限度地减少了特定类型数据所需的存储量。
可变长度编码
假设您有 50,000,000 个正整数存储在磁盘上,并且您可以保证每个整数都适合 32 位 int 变量。一个简单的解决方案是在磁盘上存储 50,000,000 个 32 位整数,总共 200,000,000 字节。我们寻求一种更好的替代方案,它将使用更少的磁盘空间。(在磁盘上压缩数据的一个原因可能是为了更快地将数据加载到内存中。)
可变长度编码是一种压缩技术,适用于包含许多小值的数字序列。在我们考虑它是如何工作的之前,我们需要保证在序列中有将有个小值——目前似乎不是这样。如果这 50,000,000 个整数均匀分布在[0,2 32 范围内,那么 99%以上都装不下 3 个字节,需要整整 4 个字节的存储。然而,我们可以在将数字存储到磁盘上之前对它们进行排序,并存储间隔,而不是存储数字。这种技巧被称为间隙压缩 ,可能会使数字变得更小,同时仍然允许我们重建原始数据。
例如,序列(38,14,77,5,90)首先排序为(5,14,38,77,90),然后使用间隙压缩编码为(5,9,24,39,13)。请注意,当使用间隙压缩时,这些数字要小得多,存储它们所需的平均位数也显著下降。在我们的例子中,如果 50,000,000 个整数均匀分布在范围[0,232中,那么许多间隙很可能适合一个字节,它可以包含范围[0,256]中的值。
接下来,我们转向可变字节长度编码的核心,它只是信息论中大量可以压缩数据的方法之一。其思想是使用每个字节的最高有效位来表示它是否是编码单个整数的最后一个字节。如果该位为 off,则继续到下一个字节以重建数字;如果该位为 on,则停止并使用目前读取的字节来解码该值。
例如,数字 13 编码为 10001101,高位为 on,因此该字节包含一个完整的整数,其余部分只是二进制数字 13。接下来,数字 132 被编码为 00000001’10000100。第一个字节的高位为 off,所以记住 7 位 0000001,第二个字节的高位为 on,所以追加 7 位 0000000,得到 10000100,也就是二进制数 132。在本例中,其中一个数字仅用 1 个字节存储,另一个用 2 个字节存储。使用这种技术存储上一步中获得的间隙可能会将原始数据压缩近四倍。(您可以用随机生成的整数来建立这个结果。)
索引压缩
为了以有效的方式存储出现在网页上的单词的索引——这是粗糙的搜索引擎的基础——我们需要为每个单词存储它出现的页码(或 URL ),压缩数据,同时保持对它的有效访问。在典型的设置中,一个单词出现的页码不适合主存,但是单词字典可能适合。
在磁盘上存储字典单词出现的页码是一项最好留给可变长度编码的任务——我们刚刚考虑过。然而,存储字典本身有些复杂。理想情况下,字典是一个简单的条目数组,包含单词本身和单词所在页码的磁盘偏移量。为了实现对这些数据的高效访问,应该对其进行排序——这保证了 O (log n )的访问时间。
假设每个条目都是以下 C# 值类型的内存表示形式,并且整个字典都是它们的数组:
struct DictionaryEntry {
public string Word;
public ulong DiskOffset;
}
DictionaryEntry[] dictionary = . . .;
正如第三章所示,值类型数组只由值类型实例组成。但是,每个值类型都包含对字符串的引用;对于 n 个条目,这些引用连同磁盘偏移量,在 64 位系统上占据了 16 n 个字节。此外,字典单词本身占用了宝贵的空间,每个字典单词(作为单独的字符串存储)都有额外的 24 个字节的开销(16 个字节的对象开销+ 4 个字节用于存储字符串长度+ 4 个字节用于存储字符串内部使用的字符缓冲区中的字符数)。
我们可以通过将所有词典单词连接成一个长字符串,并将偏移量存储到 DictionaryEntry 结构的字符串中,从而大大减少存储词典条目所需的空间(见图 9-5 )。这些串联的字典字符串很少长于 2 24 字节= 16MB,这意味着索引字段可以是 3 字节的整数,而不是 8 字节的内存地址:
[StructLayout(LayoutKind.Sequential, Pack = 1, Size = 3)]
struct ThreeByteInteger {
private byte a, b, c;
public ThreeByteInteger() {}
public ThreeByteInteger(uint integer) . . .
public static implicit operator int(ThreeByteInteger tbi) . . .
}
struct DictionaryEntry {
public ThreeByteInteger LongStringOffset;
public ulong DiskOffset;
}
class Dictionary {
public DictionaryEntry[] Entries = . . .;
public string LongString;
}
图 9-5 。内存字典和字符串目录的一般结构
结果是,我们在数组上保持了二分搜索法语义——因为条目具有统一的大小——但是存储的数据量要小得多。我们在内存中为字符串对象保存了大约 24 个 n 字节(现在只有一个长字符串),为被偏移指针替换的字符串引用保存了另外 5 个 n 字节。
摘要
本章考察了理论计算机科学的一些支柱,包括运行时复杂性分析、可计算性、算法设计和算法优化。如你所见,算法优化并不局限于学术界的象牙塔;在现实生活中,选择适当的算法或使用压缩技术可以产生相当大的性能差异。具体来说,关于动态编程、索引存储和近似算法的部分包含了一些配方,您可以根据自己的应用进行调整。
本章中的例子甚至不是复杂性理论和算法研究的冰山一角。我们的主要希望是,通过阅读这一章,你学会了欣赏理论计算机科学和实际应用所使用的实际算法背后的一些思想。我们知道很多。NET 开发人员很少遇到需要发明全新算法的情况,但我们仍然认为理解该领域的分类并在您的工具带上有一些算法示例是很重要的。
十、性能模式
这一章包含了我们在别处没有机会讨论的各种主题。虽然很小,但它们对于高性能应用极其重要。本章没有统一的思路来指导你,但是你希望在简单的紧循环和复杂的应用中获得一流的性能。
我们从 JIT 编译器优化开始这一章,这对于 CPU 受限的应用的良好性能至关重要。接下来,我们讨论启动性能,这对锻炼用户耐心的客户端应用至关重要。最后,我们讨论特定于处理器的优化,包括数据和指令级并行性,以及几个较小的主题。
JIT 编译器优化
在本书的前面,我们已经看到了 JIT 编译器执行的一些优化的重要性。具体来说,在第三章的中,我们已经在研究虚拟和非虚拟方法的方法分派序列时详细讨论了内联。在这一节中,我们总结了 JIT 编译器执行的主要优化,以及如何确保您的代码不会妨碍 JIT 编译器执行这些优化的能力。JIT 优化主要影响受 CPU 限制的应用的性能,但在某种程度上也与其他类型的程序相关。
要检查这些优化,您必须将调试器附加到一个正在运行的进程 JIT 编译器在检测到调试器存在时不会执行优化。具体来说,当附加到进程时,必须确保要检查的方法已经被调用和编译。
如果出于某种原因,您想要禁用 JIT 优化—例如,为了在面对内联和尾部调用(稍后讨论)时使调试更容易—您不必修改代码或使用调试版本。相反,您可以创建一个。ini 文件 ,该文件与您的应用可执行文件同名(例如,MyApp.ini ),并将以下三行代码放入其中。当下次启动时放在可执行文件旁边时,它将禁止 JIT 编译器执行任何优化。
[.NET Framework Debugging Control]
GenerateTrackingInfo = 1
AllowOptimize = 0
标准优化
每个优化编译器都会执行一些标准的优化 ,甚至是简单的优化。例如,JIT 能够将下面的 C# 代码减少到只有几条 x86 机器代码指令:
//Original C# code:
static int Add(int i, int j) {
return i + j;
}
static void Main() {
int i = 4;
int j = 3*i + 11;
Console.WriteLine(Add(i, j));
}
; Optimized x86 assembly code
call 682789a0 ; System.Console.get_Out()
mov ecx,eax
mov edx,1Bh ; Add(i,j) was folded into its result, 27 (0x1B)
mov eax,dword ptr [ecx] ; the rest is standard invocation sequence for the
mov eax,dword ptr [eax + 38h] ; TextWriter.WriteLine virtual method
call dword ptr [eax + 14h]
注意执行这种优化的不是 C# 编译器。如果您检查生成的 IL 代码,局部变量就在那里,就像对 Add 方法的调用一样。JIT 编译器负责所有的优化。
这种优化叫做常数折叠 ,类似的简单优化还有很多,比如常用子表达式约简 (像 a+(b * a)–(a * b * c)这样的语句中,a * b 的值只需要计算一次)。JIT 编译器执行这些标准的优化,但与优化编译器(如 Microsoft Visual C++编译器)相比,通常要差得多。原因是 JIT 编译器有一个非常严格的执行环境,必须能够快速编译方法,以防止在第一次调用方法时出现明显的延迟。
方法内联
这种优化通常会减少代码大小,并且通过用被调用者的主体替换方法调用位置,几乎总是会减少执行时间。正如我们在第三章中看到的,虚拟方法没有被 JIT 编译器内联(即使在派生类型上调用了密封方法);接口方法通过推测性执行进行部分内联;只有静态和非虚方法可以总是被内联。对于对性能至关重要的代码,例如非常常访问的基础结构类上的简单属性和方法,请确保避免使用虚方法和接口实现。
JIT 编译器用来确定内联哪些方法的确切标准是不公开的。一些启发法可以通过实验发现:
- 具有复杂调用图(例如循环)的方法不被内联。
- 具有异常处理的方法不被内联。
- 递归方法不是内联的。
- 具有非基元值类型参数、局部变量或返回值的方法不会被内联。
- 具有大于 32 字节 IL 的主体的方法不会被内联。(MethodImplOptions。[MethodImpl]属性的 AggressiveInlining 值会覆盖此限制。)
在 CLR 的最新版本中,移除了对内联的一些人为限制。例如,截至。NET 3.5 SP1,32 位 JIT 编译器能够内联接受一些非原始值类型参数的方法,像第三章的 Point2D。该版本中所做的更改在特定条件下用基元类型上的等效操作替换了值类型上的操作(Point2D 被转换为两个 int ),并允许更好地优化与结构相关的代码,如复制传播、冗余赋值消除等。例如,考虑以下简单的代码:
private static void MethodThatTakesAPoint(Point2D pt) {
pt.Y = pt.X ^ pt.Y;
Console.WriteLine(pt.Y);
}
Point2D pt;
pt.X = 3;
pt.Y = 5;
MethodThatTakesAPoint(pt);
使用 CLR 4.5 JIT 编译器,这一整段代码被编译成道德上等同于控制台的代码。WriteLine(6),这是 3 ^ 5 的结果。JIT 编译器能够在自定义值类型上使用内联和常数传播。使用 CLR 2.0 JIT 编译器时,对方法的实际调用是在调用位置发出的,并且在方法内部没有可见的优化:
; calling code
mov eax,3
lea edx,[eax + 2]
push edx
push eax
call dword ptr ds:[1F3350h] (Program.MethodThatTakesAPoint(Point2D), mdToken: 06000003)
; method code
push ebp
mov ebp,esp
mov eax,dword ptr [ebp + 8]
xor dword ptr [ebp + 0Ch],eax
call mscorlib_ni + 0x22d400 (715ed400) (System.Console.get_Out(), mdToken: 06000773)
mov ecx,eax
mov edx,dword ptr [ebp + 0Ch]
mov eax,dword ptr [ecx]
call dword ptr [eax + 0BCh]
pop ebp
ret 8
虽然如果 JIT 编译器不愿意执行的话,没有办法让强制内联,但是有一种方法可以关闭内联。MethodImplOptions。[MethodImpl]属性的 NoInlining 值禁用它所在的特定方法的内联——顺便提一下,这对于微基准测试非常有用,在第二章中讨论过。
范围检查消除
当访问数组元素时,CLR 必须确保用于访问数组的索引在数组的界限内。如果不进行这种检查,内存安全就会受到威胁;你可以初始化一个 byte[]对象,并用正负索引对其进行索引,以读/写内存中的任何位置。虽然绝对有必要,但这个范围检查需要几条指令的性能代价。下面是 JIT 编译器为标准数组访问发出的代码:
//Original C# code:
uint[] array = new uint[100];
array[4] = 0xBADC0FFE;
; Emitted x86 assembly instructions
mov ecx,offset 67fa33aa ; type of array element
mov edx,64h ; array size
call 0036215c ; creates a new array (CORINFO_HELP_NEWARR_1_VC)
cmp dword ptr [eax + 4],4 ; eax + 4 contains the array length, 4 is the index
jbe NOT_IN_RANGE ; if the length is less than or equal the index, jump away
mov dword ptr [eax + 18h],0BADC0FFEh ; the offset was calculated at JIT time (0x18 = 8 + 4*4)
; Rest of the program's code, jumping over the NOT_IN_RANGE label
NOT_IN_RANGE:
call clr!JIT_RngChkFail ; throws an exception
有一种特殊情况,JIT 编译器可以消除访问数组元素的范围检查——访问每个数组元素的索引 for 循环。如果没有这种优化,访问数组总是比在非托管代码中慢,这对科学应用和内存受限的工作来说是不可接受的性能损失。对于下面的循环,JIT 编译器将消除范围检查:
//Original C# code:
for (int k = 0; k < array.Length; ++k) {
array[k] = (uint)k;
}
; Emitted x86 assembly instructions (optimized)
xor edx,edx ; edx = k = 0
mov eax,dword ptr [esi + 4] ; esi = array, eax = array.Length
test eax,eax ; if the array is empty,
jle END_LOOP ; skip the loop
NEXT_ITERATION:
mov dword ptr [esi + edx*4 +8],edx ; array[k] = k
inc edx ; ++k
cmp eax,edx ; as long as array.Length > k,
jg NEXT_ITERATION ; jump to the next iteration
END_LOOP:
在循环过程中只有一个检查,这个检查确保循环终止。然而,循环内部的数组访问是而不是检查的——突出显示的行写入数组中的第 k 个元素,而没有确保(再次)k 在数组边界内。
不幸的是,阻碍这种优化也相当容易。对循环所做的一些看似无害的更改,可能会在访问数组时产生强制范围检查的负面影响:
//The range-check elimination occurs
for (int k = 0; k < array.Length - 1; ++k) {
array[k] = (uint)k;
}
//The range-check elimination occurs
for (int k = 7; k < array.Length; ++k) {
array[k] = (uint)k;
}
//The range-check elimination occurs
//The JIT removes the -1 from the bounds check and starts from the second element
for (int k = 0; k < array.Length - 1; ++k) {
array[k + 1] = (uint)k;
}
//The range-check elimination does not occur
for (int k = 0; k < array.Length / 2; ++k) {
array[k * 2] = (uint)k;
}
//The range-check elimination does not occur
staticArray = array; //"staticArray" is a static field of the enclosing class
for (int k = 0; k < staticArray.Length; ++k) {
staticArray[k] = (uint)k;
}
总而言之,范围检查消除是一种脆弱的优化,您应该确保代码中对性能至关重要的部分享受这种优化,即使这意味着您必须检查为您的程序生成的汇编代码。有关范围检查消除和其他极限情况的更多细节,请参见 Dave Detlefs 在http://blogs.msdn.com/b/clrcodegeneration/archive/2009/08/13/array-bounds-check-elimination-in-the-clr.aspx
上发表的文章“CLR 中的数组边界检查消除”。
尾部呼叫
尾调用 是重用一个已有方法的堆栈框架来调用另一个方法的优化。这种优化对于许多类型的递归算法非常有用。事实上,如果广泛使用尾部调用优化,一些递归方法可以和基于迭代的方法一样有效。考虑下面的递归方法,它计算两个整数的最大公约数:
public static int GCD(int a, int b) {
if (b == 0) return a;
return GCD(b, a % b);
}
显然,GCD(b,a % b)的递归调用不受内联的影响——它毕竟是一个递归调用。然而,因为调用者和被调用者的堆栈框架是完全兼容的,并且因为调用者在递归调用之后不做任何事情,一个可能的优化是将这个方法重写如下:
public static int GCD(int a, int b) {
START:
if (b == 0) return a;
int temp = a % b;
a = b;
b = temp;
goto START;
}
这种重写消除了所有的方法调用——实际上,递归算法已经变成了迭代算法。尽管每次遇到这种可能性时,您都可以手动执行这种重写,但是在某些情况下,JIT 编译器会自动执行。下面是两个版本的 GCD 方法—第一个使用 CLR 4.5 32 位 JIT 编译器编译,第二个使用 CLR 4.5 64 位 JIT 编译器编译:
; 32-bit version, parameters are in ECX and EDX
push ebp
mov ebp,esp
push esi
mov eax,ecx ; EAX = a
mov ecx,edx ; ECX = b
test ecx,ecx ; if b == 0, returning a
jne PROCEED
pop esi
pop ebp
ret
PROCEED:
cdq
idiv eax,ecx ; EAX = a / b, EDX = a % b
mov esi,edx
test esi,esi ; if a % b == 0, returning b (inlined base of recursion)
jne PROCEED2
mov eax,ecx
jmp EXIT
PROCEED2:
mov eax,ecx
cdq
idiv eax,esi
mov ecx,esi ; recursive call on the next line
call dword ptr ds:[3237A0h] (Program.GCD(Int32, Int32), mdToken: 06000004)
EXIT:
pop esi
pop ebp
ret ; reuses return value (in EAX) from recursive return
; 64-bit version, parameters in ECX and EDX
sub rsp,28h ; construct stack frame – happens only once!
START:
mov r8d,edx
test r8d,r8d ; if b == 0, return a
jne PROCEED
mov eax,ecx
jmp EXIT
PROCEED:
cmp ecx,80000000h
jne PROCEED2:
cmp r8d,0FFFFFFFFh
je OVERFLOW ; miscellaneous overflow checks for arithmetic
xchg ax,ax ; two-byte NOP (0x66 0x90) for alignment purposes
PROCEED2:
mov eax,ecx
cdq
idiv eax,r8d ; EAX = a / b, EDX = a % b
mov ecx,r8d ; reinitialize parameters
mov r8d,edx ; . . .
jmp START ; and jump back to the beginning (no function call)
xchg ax,ax ; two-byte NOP (0x66 0x90) for alignment purposes
EXIT:
add rsp,28h
ret
OVERFLOW:
call clr!JIT_Overflow
nop
显而易见,64 位 JIT 编译器使用尾部调用优化来消除递归方法调用,而 32 位 JIT 编译器则没有。对两个 JIT 编译器用来确定是否可以使用尾部调用的条件的详细处理超出了本书的范围——下面是一些启发性的方法:
-
64 位 JIT 编译器在尾部调用方面非常宽松,即使语言编译器(例如 C# 编译器)没有建议使用尾部,它也会经常执行尾部调用。IL 前缀。
-
后跟附加代码(而不是从方法返回)的调用不受尾部调用的影响。(CLR 4.0 中略有放宽。)
-
调用返回与调用方不同类型的方法。
-
对具有太多参数、未对齐参数或大值类型的参数/返回类型的方法的调用。(在 CLR 4.0 中放宽了很多。)
-
32 位 JIT 编译器不太倾向于执行这种优化,只有在尾部要求时才会发出尾部调用。IL 前缀。
注意尾部调用含义的一个奇怪方面是无限递归的情况。如果递归的基本情况有一个会导致无限递归的错误,但是 JIT 编译器能够将递归方法调用转换为尾部调用,那么作为无限递归结果的常见 StackOverflowException 结果就会变成无限循环!
更多关于尾部的细节。用于向不情愿的 JIT 编译器建议尾部调用的 IL 前缀以及 JIT 编译器用来执行尾部调用的标准可在线获得:
- 尾巴。IL 前缀是 C# 编译器不发出的,但函数式语言编译器(包括 F#)经常使用,它在 MSDN 上被描述为系统的一部分。Reflection.Emit 类页面:
http://msdn.microsoft.com/en-us/library/system.reflection.emit.opcodes.tailcall.aspx
。 - 在 JIT 编译器(CLR 4.0 之前)中执行尾部调用的条件列表在 David Broman 的文章“尾部调用 JIT 条件”中有详细介绍,位于
http://blogs.msdn.com/b/davbr/archive/2007/06/20/tail-call-jit-conditions.aspx
。 - CLR 4.0 JIT 编译器中的尾部调用优化更改在文章“中的尾部调用改进”中有详细描述。NET 框架 4”,在
http://blogs.msdn.com/b/clrcodegeneration/archive/2009/05/11/tail-call-improvements-in-net-framework-4.aspx
。
启动性能
对于客户端应用,快速启动是给用户或评估产品或进行演示的潜在客户留下的很好的第一印象。然而,应用越复杂,保持可接受的启动时间就越困难。区分冷启动和热启动非常重要,前者是指系统刚刚完成引导后首次启动应用,后者是指系统使用一段时间后启动应用(不是第一次)。始终在线的系统服务或后台代理需要快速的冷启动时间,以防止系统启动序列和用户登录花费太长时间。典型的客户端应用,如电子邮件客户端和网络浏览器,可能会有稍长的冷启动时间,但用户会希望它们在系统使用一段时间后快速启动。在这两种情况下,大多数用户都希望启动时间短。
启动时间长有几个因素。其中一些仅与冷启动相关;其他的与两种类型的启动都相关。
- I/O 操作—要启动应用,Windows 和 CLR 必须从磁盘加载应用的程序集以及。NET Framework 程序集、CLR DLLs 和 Windows DLLs。这个因素主要与冷启动有关。
- JIT 编译—应用启动期间第一次调用的每个方法都必须由 JIT 编译器编译。因为当应用终止时,由 JIT 编译的代码不会被保留,所以这个因素与冷启动和热启动都相关。
- GUI 初始化——取决于您的 GUI 框架(Metro、WPF、Windows 窗体等)。),要显示用户界面,必须执行特定于 GUI 的初始化步骤。这个因素与两种类型的启动都相关。
- 加载特定于应用的数据—您的应用可能需要来自文件、数据库或 Web 服务的一些信息来显示其初始屏幕。这个因素与两种类型的启动都相关,除非您的应用对这些数据采用某种缓存方案。
我们有几个测量工具可以用来诊断长启动时间及其可能的原因(见第二章)。Sysinternals 进程监视器可以指向应用进程执行的 I/O 操作,而不管是 Windows、CLR 还是应用代码启动了这些操作。PerfMonitor 和。NET CLR JIT 性能计数器类别可以帮助诊断应用启动期间的过度 JIT 编译。最后,“标准”分析器(在采样或检测模式下)可以决定应用在启动时是如何花费时间的。
此外,您可以执行一个简单的实验来告诉您 I/O 是否是启动时间缓慢的主要原因:对应用的冷启动和热启动场景进行计时(您应该为这些测试使用干净的硬件环境,并确保冷启动测试中没有不必要的服务或其他初始化)。如果热启动明显快于冷启动,I/O 就是罪魁祸首。
提高特定于应用的数据加载取决于您;我们发布的每一条指导方针都太笼统,无法在实践中使用(除了提供一个闪屏来考验用户的耐心……)。然而,我们可以为 I/O 操作和 JIT 编译导致的启动性能不佳提供一些补救措施。在某些情况下,优化应用的启动时间可以减少一半或更多。
用 NGen 进行预 JIT 编译 (原生映像生成器)
尽管 JIT 编译器非常方便,并且只在调用方法时才编译它们,但是每当 JIT 运行时,您的应用仍然要付出性能代价。那个。NET Framework 提供了一个优化工具,叫做原生映像生成器(NGen.exe),可以在运行前把你的程序集编译成机器码(原生映像)。如果应用需要的每个程序集都是以这种方式预编译的,那么就不需要加载 JIT 编译器,也不需要在应用启动时使用它。尽管生成的本机映像可能比原始程序集大,但在大多数情况下,冷启动时的磁盘 I/O 量实际上会减少,因为 JIT 编译器本身(clrjit.dll)和引用程序集的元数据不是从磁盘中读取的。
预编译还有另一个好处——与 JIT 编译器在运行时发出的代码不同,本机映像可以在进程间共享。如果同一台计算机上的几个进程对某个程序集使用本机映像,则物理内存消耗比 JIT 编译的情况要低。这在共享系统场景中尤其重要,此时多个用户通过终端服务会话连接到一台服务器,并运行相同的应用。
要预编译你的应用,你需要做的就是指向 NGen.exe 工具,它位于。NET Framework 的目录添加到应用的主程序集(通常是一个。exe 文件)。NGen 将继续定位您的主程序集拥有的所有静态依赖项,并将它们全部预编译为本机映像。生成的本机映像存储在您不必管理的缓存中,默认情况下,它存储在 GAC 旁边的 C:\ Windows \ Assembly \ native images _ *文件夹中。
提示因为 CLR 和 NGen 自动管理本机映像缓存,所以千万不要将本机映像从一台机器复制到另一台机器。在特定系统上预编译托管程序集的唯一支持方式是在那个系统上运行NGen 工具。实现这一点的理想时间是在应用安装期间(NGen 甚至支持“defer”命令,该命令会将预编译排队到后台服务)。这就是。NET Framework 安装程序为经常使用的。NET 程序集。
下面是使用 NGen 预编译一个简单应用的完整示例,该应用由两个程序集组成 main。exe 文件和一个辅助。它引用的 dll。NGen 成功检测到依赖项,并将两个程序集预编译为本机映像:
> c:\windows\microsoft.net\framework\v4.0.30319\ngen install Ch10.exe
Microsoft (R) CLR Native Image Generator - Version 4.0.30319.17379
Copyright (c) Microsoft Corporation. All rights reserved.
Installing assembly D:\Code\Ch10.exe
1> Compiling assembly D:\Code\Ch10.exe (CLR v4.0.30319) . . .
2> Compiling assembly HelperLibrary, . . . (CLR v4.0.30319) . . .
在运行时,CLR 使用本机映像,根本不需要加载 clrjit.dll(在下面 lm 命令的输出中,没有列出 clrjit.dll)。类型方法表(见第三章)也存储在本地映像中,指向本地映像边界内的预编译版本。
0:007 > lm
start end module name
01350000 01358000 Ch10 (deferred)
2f460000 2f466000 Ch10_ni (deferred)
30b10000 30b16000 HelperLibrary_ni (deferred)
67fa0000 68eef000 mscorlib_ni (deferred)
6b240000 6b8bf000 clr (deferred)
6f250000 6f322000 MSVCR110_CLR0400 (deferred)
72190000 7220a000 mscoreei (deferred)
72210000 7225a000 MSCOREE (deferred)
74cb0000 74cbc000 CRYPTBASE (deferred)
74cc0000 74d20000 SspiCli (deferred)
74d20000 74d39000 sechost (deferred)
74d40000 74d86000 KERNELBASE (deferred)
74e50000 74f50000 USER32 (deferred)
74fb0000 7507c000 MSCTF (deferred)
75080000 7512c000 msvcrt (deferred)
75150000 751ed000 USP10 (deferred)
753e0000 75480000 ADVAPI32 (deferred)
75480000 75570000 RPCRT4 (deferred)
75570000 756cc000 ole32 (deferred)
75730000 75787000 SHLWAPI (deferred)
75790000 757f0000 IMM32 (deferred)
76800000 7680a000 LPK (deferred)
76810000 76920000 KERNEL32 (deferred)
76920000 769b0000 GDI32 (deferred)
775e0000 77760000 ntdll (pdb symbols)
0:007 > !dumpmt -md 2f4642dc
EEClass: 2f4614c8
Module: 2f461000
Name: Ch10.Program
mdToken: 02000002
File: D:\Code\Ch10.exe
BaseSize: 0xc
ComponentSize: 0x0
Slots in VTable: 6
Number of IFaces in IFaceMap: 0
--------------------------------------
MethodDesc Table
Entry MethodDe JIT Name
68275450 68013524 PreJIT System.Object.ToString()
682606b0 6801352c PreJIT System.Object.Equals(System.Object)
68260270 6801354c PreJIT System.Object.GetHashCode()
68260230 68013560 PreJIT System.Object.Finalize()
2f464268 2f46151c PreJIT Ch10.Program..ctor()
2f462048 2f461508 PreJIT Ch10.Program.Main(System.String[])
0:007 > !dumpmt -md 30b141c0
EEClass: 30b114c4
Module: 30b11000
Name: HelperLibrary.UtilityClass
mdToken: 02000002
File: D:\Code\HelperLibrary.dll
BaseSize: 0xc
ComponentSize: 0x0
Slots in VTable: 6
Number of IFaces in IFaceMap: 0
--------------------------------------
MethodDesc Table
Entry MethodDe JIT Name
68275450 68013524 PreJIT System.Object.ToString()
682606b0 6801352c PreJIT System.Object.Equals(System.Object)
68260270 6801354c PreJIT System.Object.GetHashCode()
68260230 68013560 PreJIT System.Object.Finalize()
30b14158 30b11518 PreJIT HelperLibrary.UtilityClass..ctor()
30b12048 30b11504 PreJIT HelperLibrary.UtilityClass.SayHello()
另一个有用的选项是“update”命令,它强制 NGen 重新计算缓存中所有本机映像的依赖关系,并再次预编译任何修改过的程序集。在目标系统上安装更新后,或者在开发过程中,您会用到它。
注意理论上,NGen 可以使用与 JIT 编译器在运行时使用的完全不同的——更大的——优化集。毕竟 NGen 不像 JIT 编译器那样有时间限制。然而,在撰写本文时,除了 JIT 编译器使用的那些优化之外,NGen 没有其他优化方法。
在 Windows 8 上使用 CLR 4.5 时,NGen 不会被动地等待您的指令来预编译应用程序集。相反,CLR 生成由 NGen 的后台维护任务处理的程序集使用日志。该任务定期决定哪些程序集将受益于预编译,并运行 NGen 为它们创建本机映像。您仍然可以使用 NGen 的“display”命令来检查本机映像缓存(或者从命令行提示符检查其文件),但是决定哪些程序集将受益于预编译的大部分负担现在都由 CLR 来决定。
多核后台 JIT 编译
从 CLR 4.5 开始,您可以指示 JIT 编译器生成在应用启动期间执行的方法的配置文件,并在随后的应用启动(包括冷启动场景)中使用该配置文件在后台编译这些方法。换句话说,当应用的主线程正在初始化时,JIT 编译器在后台线程上执行,这样方法很可能在需要时已经编译好了。配置文件信息会在每次运行时更新,因此即使应用以不同的配置启动了数百次,它也会保持最新。
注意在 ASP.NET 和 Silverlight 5 应用中默认启用该功能。
要选择使用多核后台 JIT 编译 ,您需要在系统上调用两个方法。Runtime 优化类。第一个方法告诉探查器可以存储分析信息的位置,第二个方法告诉探查器正在执行哪个启动场景。第二种方法的目的是区分相当不同的场景,以便为特定场景定制优化。例如,可以使用“显示存档中的文件”参数调用存档实用程序,这需要一组方法,还可以使用“从目录创建压缩存档”参数,这需要一组完全不同的方法:
public static void Main(string[] args) {
System.Runtime.ProfileOptimization.SetProfileRoot(
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location));
if (args[0] == "display") {
System.Runtime.ProfileOptimization.StartProfile("DisplayArchive.prof");
} else if (args[0] == "compress") {
System.Runtime.ProfileOptimization.StartProfile("CompressDirectory.prof");
}
//. . .More startup scenarios
//The rest of the application's code goes here, after determining the startup scenario
}
图像打包器
最小化 I/O 传输成本的一种常见方法是压缩原始数据。毕竟,如果一张 DVD 可以容纳 15GB 的未压缩 Windows 安装,那么下载它就没有什么意义了。同样的想法可以用于存储在磁盘上的托管应用。通过减少启动时执行的 I/O 操作的数量,压缩应用并仅在将其加载到内存中后再解压缩,可以显著降低冷启动成本。压缩是一把双刃剑,因为在内存中解压缩应用的代码和数据需要 CPU 时间,但是当降低冷启动成本至关重要时,付出 CPU 时间的代价是值得的,例如对于必须在系统打开后尽快启动的 kiosk 应用。
有几个商业的和开源的应用压缩工具(通常称为打包器)。如果你使用封隔器,确保它可以压缩。NET 应用——一些打包程序只能很好地处理非托管二进制文件。压缩工具的一个例子。NET applications 是 MPress,可以在http://www.matcode.com/mpress.htm. Another example is the Rugland Packer for .NET Executables (RPX), an open source utility published at http://rpx.codeplex.com/
免费在线获得。下面是在一个非常小的应用上运行时,RPX 的一些示例输出:
> Rpx.exe Shlook.TestServer.exe Shlook.Common.dll Shlook.Server.dll
Rugland Packer for (.Net) eXecutables 1.3.4399.43191
Unpacked size :..............27.00 KB
Packed size :..............13.89 KB
Compression :..............48.55%
─────────────────────────────────────────
Application target is the console
─────────────────────────────────────────
Uncompressed size :..............27.00 KB
Startup overhead :...............5.11 KB
Final size :..............19.00 KB
─────────────────────────────────────────
Total compression :..............29.63%
受管理的档案导向优化(MPGO )
managed profile guided optimization(MPGO)是 Visual Studio 11 和 CLR 4.5 中引入的工具,用于优化 NGen 生成的本机映像的磁盘布局。MPGO 从应用执行的指定时间段生成配置文件信息,并将其嵌入程序集中。随后,NGen 使用这些信息来优化生成的本机映像的布局。
MPGO 以两种主要方式优化本机映像的布局。首先,MPGO 确保频繁使用的代码和数据(热数据)一起放在磁盘上。因此,热数据的页面错误和磁盘读取将会减少,因为单个页面上可以容纳更多的热数据。其次,MPGO 将潜在的可写数据一起放在磁盘上。当与其他进程共享的数据页被修改时,Windows 会创建该页的私有副本供修改进程使用(这称为写时复制)。由于 MPGO 的优化,更少的共享页面被修改,从而导致更少的副本和更低的内存利用率。
要在您的应用上运行 MPGO,您需要提供要检测的程序集列表、放置优化的二进制文件的输出目录,以及一个超时值,在该超时值之后分析应该停止。MPGO 检测应用,运行它,分析结果,并为您指定的程序集创建优化的本机映像:
> mpgo.exe -scenario Ch10.exe -assemblylist Ch10.exe HelperLibrary.dll -OutDir . –NoClean
Successfully instrumented assembly D:\Code\Ch10.exe
Successfully instrumented assembly D:\Code\HelperLibrary.dll
< output from the application removed >
Successfully removed instrumented assembly D:\Code\Ch10.exe
Successfully removed instrumented assembly D:\Code\HelperLibrary.dll
Reading IBC data file: D:\Code\Ch10.ibc
The module D:\Code\Ch10-1.exe, did not contain an IBC resource
Writing profile data in module D:\Code\Ch10-1.exe
Data from one or more input files has been upgraded to a newer version.
Successfully merged profile data into new file D:\Code\Ch10-1.exe
Reading IBC data file: D:\Code\HelperLibrary.ibc
The module D:\Code\HelperLibrary-1.dll, did not contain an IBC resource
Writing profile data in module D:\Code\HelperLibrary-1.dll
Data from one or more input files has been upgraded to a newer version.
Successfully merged profile data into new file D:\Code\HelperLibrary-1.dll
注意优化完成后,您需要在优化后的程序集上再次运行 NGen,以创建受益于 MPGO 的最终本机映像。本章前面已经介绍了在程序集上运行 NGen。
在撰写本文时,还没有将 MPGO 引入 Visual Studio 2012 用户界面的计划。命令行工具是为您的应用增加这些性能的唯一方法。因为它依赖于 NGen,所以这是另一个最好在目标机器上安装之后执行的优化。
关于启动性能的其他提示
有几个我们之前没有提到的额外的技巧可能会让你的应用的启动时间缩短几秒钟。
强名称程序集属于 GAC
如果您的程序集具有强名称,请确保将它们放在全局程序集缓存(GAC ) 中。否则,加载程序集需要接触几乎每一页来验证其数字签名。当程序集不在 GAC 中时验证强名称也会降低 NGen 的性能。
确保您的本机映像不需要重设基础
当使用 NGen 时,确保您的本机映像没有基址冲突,这需要重新设置基址。重置基础是一项开销很大的操作,涉及到在运行时修改代码地址,并创建共享代码页的副本。要查看本机映像的基址,请使用带有/headers 标志的 dumpbin.exe 实用程序,如下所示:
> dumpbin.exe /headers Ch10.ni.exe
Microsoft (R) COFF/PE Dumper Version 11.00.50214.1
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file Ch10.ni.exe
PE signature found
File Type: DLL
FILE HEADER VALUES
14C machine (x86)
4 number of sections
4F842B2C time date stamp Tue Apr 10 15:44:28 2012
0 file pointer to symbol table
0 number of symbols
E0 size of optional header
2102 characteristics
Executable
32 bit word machine
DLL
OPTIONAL HEADER VALUES
10B magic # (PE32)
11.00 linker version
0 size of code
0 size of initialized data
0 size of uninitialized data
0 entry point
0 base of code
0 base of data
30000000 image base (30000000 to 30005FFF)
1000 section alignment
200 file alignment
5.00 operating system version
0.00 image version
5.00 subsystem version
0 Win32 version
6000 size of image
< more output omitted for brevity>
若要更改本机映像的基址,请在 Visual Studio 的项目属性中更改基址。打开高级对话框后,基址可位于构建选项卡上(见图 10-1 )。
图 10-1 。Visual Studio 高级生成设置对话框,该对话框允许修改 NGen 生成的本机映像的基址
截至。NET 3.5 SP1,当应用在 Windows Vista 或更新的平台上运行时,NGen 选择使用地址空间布局随机化(ASLR)。当使用 ASLR 时,出于安全原因,映像的基本加载地址在运行时是随机的。在这种配置下,在 Windows Vista 和更新的平台上,为避免基址冲突而重设程序集的基并不重要。
减少组件的总数
减少应用加载的程序集的数量。无论大小如何,每次程序集加载都会产生固定的开销,程序集间引用和方法调用在运行时的开销可能更大。加载数百个程序集的大型应用并不少见,如果将程序集合并到几个二进制文件中,它们的启动时间可以减少几秒钟。
处理器特定优化
理论上,。NET 开发人员不应该关心针对特定处理器或指令集的优化。毕竟,IL 和 JIT 编译的目的是允许托管应用在任何具有。NET Framework,并对操作系统位、处理器特性和指令集保持中立。然而,正如我们在本书中所看到的,从托管应用中挤出最后一点性能可能需要汇编语言级别的推理。在其他时候,理解处理器特定的特性是获得更大性能提升的第一步。
在这一小段中,我们将回顾一些针对特定处理器特性的优化示例,这些优化可能在一台机器上运行良好,但在另一台机器上可能会失效。我们主要关注英特尔处理器,尤其是 Nehalem、Sandy Bridge 和 Ivy Bridge 系列,但大多数指南也与 AMD 处理器相关。因为这些优化是危险的,并且有时是不可重复的,所以您不应该将这些例子作为明确的指导,而只是作为从您的应用中获得更多性能的动机。
单指令多数据(SIMD)
数据级并行,也称为单指令多数据 (SIMD ),是现代处理器的一个特性,能够对一大组数据(大于机器字)执行单个指令。SIMD 指令集事实上的标准是 SSE(SIMD 流扩展),自奔腾 III 以来,英特尔处理器一直在使用 SSE。该指令集增加了新的 128 位寄存器(带有 XMM 前缀)以及可以对其进行操作的指令。最近的英特尔处理器推出了高级向量扩展 (AVX),这是 SSE 的一个扩展,提供 256 位寄存器和更多 SIMD 指令。SSE 指令的一些例子包括:
- 整数和浮点运算
- 比较、洗牌、数据类型转换(整数到浮点)
- 位运算
- 最小、最大、条件副本、CRC32、群体计数(在 SSE4 和更高版本中引入)
您可能想知道在这些“新”寄存器上运行的指令是否比它们的标准对应物慢。如果是这样的话,任何性能提升都是骗人的。幸运的是,事实并非如此。在英特尔 i7 处理器上,32 位寄存器上的浮点加法(FADD ) 指令的吞吐量为每周期一条指令,延迟为 3 个周期。128 位寄存器上的等效 ADDPS 指令也具有每周期一条指令的吞吐量和 3 个周期的延迟。
晚期 NCY 和吞吐量
延迟和吞吐量是一般性能测量中的常用术语,但在讨论处理器指令的“速度”时尤其如此:
- 指令的延迟是指从开始到结束执行一个指令实例所花费的时间(通常以时钟周期来衡量)。
- 指令的吞吐量是指单位时间内(通常以时钟周期为单位)可以执行的相同类型指令的数量。
如果我们说 FADD 有 3 个周期的延迟,这意味着单个 FADD 操作将需要 3 个周期才能完成。如果我们说 FADD 具有每周期一条指令的吞吐量,这意味着通过并发地发出多个 FADD 实例,处理器能够维持每周期一条指令的执行速率,这将需要三条这样的指令并发地执行。
通常情况下,一条指令的吞吐量明显好于其延迟,因为处理器可以并行发出和执行多条指令(我们稍后将回到这个主题)。
与一次处理单个浮点或整数值的简单顺序程序相比,在高性能循环中使用这些指令可以提供高达 8 倍的性能提升。例如,考虑下面的(公认的琐碎)代码:
//Assume that A, B, C are equal-size float arrays
for (int i = 0; i < A.length; ++i) {
C[i] = A[i] + B[i];
}
在这种情况下,JIT 发出的标准代码如下:
; ESI has A, EDI has B, ECX has C, EDX is the iteration variable
xor edx,edx
cmp dword ptr [esi + 4],0
jle END_LOOP
NEXT_ITERATION:
fld dword ptr [esi + edx*4 + 8] ; load A[i], no range check
cmp edx,dword ptr [edi + 4] ; range check before accessing B[i]
jae OUT_OF_RANGE
fadd dword ptr [edi + edx*4 + 8] ; add B[i]
cmp edx,dword ptr [ecx + 4] ; range check before accessing C[i]
jae OUT_OF_RANGE
fstp dword ptr [ecx + edx*4 + 8] ; store into C[i]
inc edx
cmp dword ptr [esi + 4],edx ; are we done yet?
jg NEXT_ITERATION
END_LOOP:
每次循环迭代执行一条 FADD 指令,将两个 32 位浮点数相加。但是,通过使用 128 位 SSE 指令,可以一次发出四次循环迭代,如下所示(下面的代码不执行范围检查,并假设迭代次数可被 4 整除):
xor edx, edx
NEXT_ITERATION:
movups xmm1, xmmword ptr [edi + edx*4 + 8] ; copy 16 bytes from B to xmm1
movups xmm0, xmmword ptr [esi + edx*4 + 8] ; copy 16 bytes from A to xmm0
addps xmm1, xmm0 ; add xmm0 to xmm1 and store the result in xmm1
movups xmmword ptr [ecx + edx*4 + 8], xmm1 ; copy 16 bytes from xmm1 to C
add edx, 4 ; increase loop index by 4
cmp edx, dword ptr [esi + 4]
jg NEXT_ITERATION
在 AVX 处理器上,我们可以在每次迭代中移动更多的数据(使用 256 位 YMM*寄存器),从而实现更大的性能提升:
xor edx, edx
NEXT_ITERATION:
vmovups ymm1, ymmword ptr [edi + edx*4 + 8] ; copy 32 bytes from B to ymm1
vmovups ymm0, ymmword ptr [esi + edx*4 + 8] ; copy 32 bytes from A to ymm0
vaddps ymm1, ymm1, ymm0 ; add ymm0 to ymm1 and store the result in ymm1
vmovups ymmword ptr [ecx + edx*4 + 8], ymm1 ; copy 32 bytes from ymm1 to C
add edx, 8 ; increase loop index by 8
cmp edx, dword ptr [esi + 4]
jg NEXT_ITERATION
注意这些例子中使用的 SIMD 指令只是冰山一角。现代应用和游戏使用 SIMD 指令来执行复杂的操作,包括标量积、在寄存器和内存中来回移动数据、校验和计算以及许多其他操作。英特尔的 AVX 门户是全面了解 AVX 所能提供的服务的好方法:http://software.intel.com/en-us/avx/
JIT 编译器只使用了少量的 SSE 指令,尽管它们在过去 10 年制造的几乎所有处理器上都可用。具体来说,JIT 编译器使用 SSE MOVQ 指令通过 XMM寄存器复制中等大小的结构(对于大型结构,使用 REP MOVS 代替),并使用 SSE2 指令进行浮点到整数的转换和其他极限情况。JIT 编译器不会*通过统一迭代来自动矢量化循环,就像我们在前面的代码清单中手动做的那样,而现代 C++编译器(包括 Visual Studio 2012)会。
不幸的是,C# 没有提供任何将内联汇编代码嵌入托管程序的关键字。尽管您可以将对性能敏感的部分分解到 C++模块中并使用。NET 互操作性来访问它,这通常是笨拙的。还有另外两种嵌入 SIMD 码的方法,不需要借助单独的模块。
从托管应用中运行任意机器码的一种强力方法(尽管有一个轻量级互操作层)是动态发出机器码,然后调用它。法警。GetDelegateForFunctionPointer 方法是关键,因为它返回指向非托管内存位置的托管委托,该位置可能包含任意代码。下面的代码使用 EXECUTE_READWRITE 页面保护来分配虚拟内存,这使我们能够将代码字节复制到内存中,然后执行它们。结果,在英特尔 i7-860 CPU 上,执行时间提高了 2 倍以上!
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate void VectorAddDelegate(float[] C, float[] B, float[] A, int length);
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr VirtualAlloc(
IntPtr lpAddress, UIntPtr dwSize, IntPtr flAllocationType, IntPtr flProtect);
//This array of bytes has been produced from the SSE assembly version – it is a complete
//function that accepts four parameters (three vectors and length) and adds the vectors
byte[] sseAssemblyBytes = { 0x8b, 0x5c, 0x24, 0x10, 0x8b, 0x74, 0x24, 0x0c, 0x8b, 0x7c, 0x24,
0x08, 0x8b, 0x4c, 0x24, 0x04, 0x31, 0xd2, 0x0f, 0x10, 0x0c, 0x97,
0x0f, 0x10, 0x04, 0x96, 0x0f, 0x58, 0xc8, 0x0f, 0x11, 0x0c, 0x91,
0x83, 0xc2, 0x04, 0x39, 0xda, 0x7f, 0xea, 0xc2, 0x10, 0x00 };
IntPtr codeBuffer = VirtualAlloc(
IntPtr.Zero,
new UIntPtr((uint)sseAssemblyBytes.Length),
0x1000 | 0x2000, //MEM_COMMIT | MEM_RESERVE
0x40 //EXECUTE_READWRITE
);
Marshal.Copy(sseAssemblyBytes, 0, codeBuffer, sseAssemblyBytes.Length);
VectorAddDelegate addVectors = (VectorAddDelegate)
Marshal.GetDelegateForFunctionPointer(codeBuffer, typeof(VectorAddDelegate));
//We can now use 'addVectors' to add vectors!
一种完全不同的方法是扩展 JIT 编译器来发出 SIMD 指令,不幸的是,这种方法在 Microsoft CLR 上不可用。这是 Mono 采取的方法。Simd 。使用 Mono 的托管代码开发人员。NET 运行库可以引用单声道。Simd 汇编并使用 JIT 编译器支持,将 Vector16b 或 Vector4f 等类型上的操作转换为适当的 SSE 指令。有关 Mono 的更多信息。Simd,见官方文档http://docs.go-mono.com/index.aspx?link=N:Mono.Simd
。
指令级并行
与依赖特定指令一次对较大数据块进行操作的数据级并行性不同,指令级并行性 (ILP )是一种在同一处理器上同时执行多条指令的机制。现代处理器有一个很深的流水线,其中包含几种类型的执行单元,例如访问内存的单元、执行算术运算的单元和解码 CPU 指令的单元。只要它们不竞争流水线的相同部分,并且只要它们之间没有数据依赖性,流水线就能够使多个指令的执行重叠。当一条指令需要在它之前执行的另一条指令的结果时,就会产生数据依赖性;例如,当一条指令从前一条指令已经写入的存储器位置读取时。
注指令级并行是而不是与并行编程有关,我们在第六章中讨论过。当使用并行编程 API 时,应用在多个处理器上运行多个线程。指令级并行使单个处理器上的单个线程能够同时执行多条指令。与并行编程不同,ILP 更难控制,并且严重依赖于程序优化。
除了流水线之外,处理器还采用所谓的超标量执行 ,它使用同一处理器上的多个冗余单元来一次执行多个相同类型的操作。此外,为了最小化数据依赖性对并行指令执行的影响,只要不违反任何数据依赖性,处理器就会不按其原始顺序执行指令。通过添加推测性执行(主要通过尝试猜测分支的哪一侧将被采用,但也通过其他方式),处理器很可能能够执行额外的指令,即使原始程序顺序中的下一条指令由于数据依赖性而无法执行。
优化编译器以组织指令序列以最大化指令级并行性而闻名。JIT 编译器做得不是特别好,但是现代处理器的无序执行能力可能会抵消它。然而,指定不当的程序会引入不必要的数据依赖性,特别是在循环中,从而限制指令级并行性,从而显著影响性能。
考虑以下三个循环:
for (int k = 1; k < 100; ++k) {
first[k] = a * second[k] + third[k];
}
for (int k = 1; k < 100; ++k) {
first[k] = a * second[k] + first[k - 1];
}
for (int k = 1; k < 100; ++k) {
first[k] = a * first[k - 1] + third[k];
}
我们在一台测试机器上执行这些循环,每一次都有 100 个整数的数组,迭代一百万次。第一个循环运行了 190 毫秒,第二个运行了 210 毫秒,第三个运行了 270 毫秒。这是一个源于指令级并行性的重大性能差异。第一个循环的迭代没有任何数据依赖性——多个迭代可以以任何顺序在处理器上发出,并在处理器的流水线中并发执行。第二个循环的迭代引入了一个数据依赖——为了分配 first[k],代码依赖 first[k-1]。然而,至少乘法(必须在加法发生之前完成)可以在没有数据依赖性的情况下发出。在第三个循环中,情况非常糟糕:如果不等待来自上一次迭代的数据依赖,甚至连乘法都不能发出。
另一个例子是寻找整数数组中的最大值。在一个简单的实现中,每次迭代都依赖于前一次迭代中当前建立的最大值。奇怪的是,我们可以在这里应用在第六章中遇到的同样的想法——聚集,然后对局部结果求和。具体来说,查找整个数组的最大值相当于查找偶数和奇数元素上的最大值,然后执行一个额外的操作来查找全局最大值。两种方法如下所示:
//Naïve algorithm that carries a dependency from each loop iteration to the next
int max = arr[0];
for (int k = 1; k < 100; ++k) {
max = Math.Max(max, arr[k]);
}
//ILP-optimized algorithm, which breaks down some of the dependencies such that within the
//loop iteration, the two lines can proceed concurrently inside the processor
int max0 = arr[0];
int max1 = arr[1];
for (int k = 3; k < 100; k + = 2) {
max0 = Math.Max(max0, arr[k-1]);
max1 = Math.Max(max1, arr[k]);
}
int max = Math.Max(max0, max1);
不幸的是,CLR JIT 编译器通过为第二个循环发出次优的机器码破坏了这种特殊的优化。在第一个循环中,重要的值放在寄存器中,max 和 k 存储在寄存器中。在第二个循环中,JIT 编译器不能容纳寄存器中的所有值;如果将 max1 或 max0 放在内存中,循环的性能会大大降低。相应的 C++实现提供了预期的性能增益——第一次展开操作将执行时间提高了两倍,再次展开(使用四个局部最大值)又节省了 25%。
指令级并行可以结合数据级并行。这里考虑的两个例子(乘加循环和最大值计算)都可以受益于使用 SIMD 指令的额外加速。在最大值的情况下,PMAXSD SSE4 指令对两组四个压缩的 32 位整数进行操作,并找到这两组中每对整数各自的最大值。以下代码(使用来自< smmintrin.h >的 Visual C++内部函数)的运行速度比之前最好的版本快 3 倍,比原始版本快 7 倍:
__m128i max0 = *(__m128i*)arr;
for (int k = 4; k < 100; k + = 4) {
max0 = _mm_max_epi32(max0, *(__m128i*)(arr + k)); //Emits PMAXSD
}
int part0 = _mm_extract_epi32(max0, 0);
int part1 = _mm_extract_epi32(max0, 1);
int part2 = _mm_extract_epi32(max0, 2);
int part3 = _mm_extract_epi32(max0, 3);
int finalmax = max(part0, max(part1, max(part2, part3)));
当您最大限度地减少数据依赖性以从指令级并行性中获益时,数据级并行化(有时称为矢量化)通常会瞬间实现更大的性能优势。
托管代码与非托管代码
表达了一个共同的关注。CLR 的托管方面引入了性能成本,使得使用 C#、c# 和。NET 框架和 CLR。在整本书中,甚至在这一章中,我们已经看到了几个性能陷阱,如果您想从您的托管应用中获取每一点性能,您必须了解这些陷阱。不幸的是,总会有这样的情况:非托管代码(用 C++、C 甚至手工汇编编写)比托管代码有更好的性能。
我们不打算对网上的每个例子进行分析和分类,在这些例子中,C++算法被证明比它的 C# 版本更有效。尽管如此,还是有一些比其他主题更经常出现的共同主题:
- 严格受 CPU 限制的数值算法在 C++中往往运行得更快,即使在 C# 中应用了特定的优化之后也是如此。原因往往在数组边界检查(JIT 编译器仅在某些情况下进行优化,并且仅针对一维数组)、C++编译器使用的 SIMD 指令以及 C++编译器擅长的其他优化(如复杂的内联和智能寄存器分配)之间波动。
- 某些内存管理模式对 GC 的性能是有害的(正如我们在第四章中看到的那样)。有时,C++代码可以通过使用池或重用从其他来源获得的非托管内存来“正确”管理内存。NET 代码将会很困难。
- C++代码享有对 Win32 APIs 更直接的访问,并且不需要互操作性支持,例如参数封送和线程状态转换(在第八章的中讨论)。高性能的应用与操作系统的交互比较繁琐,在中可能会运行较慢。由于这个互用性层。
David Piepgrass 的优秀 CodeProject 文章“头对头的基准测试:C++ vs. NET”(可在http://www.codeproject.com/Articles/212856/Head-to-head-benchmark-Csharp-vs-NET
获得),打破了一些关于托管代码性能的误解。例如,皮普格拉斯证明了。NET 集合在某些情况下比它们的 C++ STL 等价物快得多;这同样适用于使用 ifstream 与 StreamReader 逐行读取文件数据。另一方面,他的一些基准测试强调了 64 位 JIT 编译器中仍然存在的缺陷,CLR 中缺乏 SIMD 内部函数(我们在前面讨论过)是 C++具有优势的另一个因素。
例外
如果正确并谨慎地使用,异常 并不是一个昂贵的机制。有一些简单的准则可以遵循,以避免抛出太多异常并导致巨大性能成本的风险:
- 针对异常情况使用异常:如果您预计异常会频繁发生,请考虑防御性编程,而不是抛出异常。这个规则也有例外(双关语),但是在高性能场景中,10%的情况不应该通过抛出异常来处理。
- 在调用可能引发异常的方法之前,检查异常情况。这种方法的例子是流。CanRead 属性和 TryParse 系列的静态方法(例如 int。TryParse)。
- 不要抛出异常作为控制流机制:不要抛出异常来退出循环、停止读取文件或从方法返回信息。
与抛出和处理异常相关的最大性能成本可以分为几类:
- 构造一个异常需要一个堆栈审核(以填充堆栈跟踪),堆栈越深,成本就越高。
- 抛出和处理异常需要与非托管代码(Windows 结构化异常处理(SEH)基础结构)进行交互,并通过堆栈上的 SEH 处理程序链运行。
- 异常将控制流和数据流从热区域转移,导致在访问冷数据和代码时出现页面错误和缓存缺失。
若要了解异常是否会导致性能问题,可以使用。NET CLR Exceptions 性能计数器类别(有关性能计数器的更多信息,请参见第二章)。具体来说,如果每秒抛出数千个异常,每秒抛出的异常数计数器可以帮助查明潜在的性能问题。
反射
Reflection 在许多复杂的应用中有着性能猪的坏名声。这种名声有些是有道理的:使用反射可以执行代价极其昂贵的操作,比如使用类型通过函数名调用函数。通过反射调用方法或设置字段值时的主要开销来自于必须在后台进行的工作,而不是可以由 JIT 编译成机器指令的强类型代码,使用反射的代码通过一系列代价高昂的方法调用在运行时被有效地解释。
例如,使用类型调用方法。InvokeMember 需要使用元数据和重载解析来确定要调用的方法,确保指定的参数与方法的参数匹配,必要时执行类型强制,验证任何安全问题,最后执行方法调用。因为反射很大程度上基于对象参数和返回值,所以装箱和取消装箱可能会增加额外的成本。
注了解周边更多性能技巧。从内部的角度来看,考虑一下 Joel Pobar 在《MSDN》杂志上发表的文章“避开常见的性能陷阱,打造快速的应用”,这篇文章可以在网上的http://msdn.microsoft.com/en-us/magazine/cc163759.aspx
找到。
通常,通过使用某种形式的代码生成 ,可以从性能关键的场景中消除反射——而不是反射未知类型和动态调用方法/属性,您可以以强类型的方式生成代码(针对每种类型)。
代码生成
代码生成通常由序列化框架、对象/关系映射器(ORM)、动态代理和其他需要处理动态未知类型的性能敏感代码使用。控件中动态生成代码有几种方法。NET 框架,还有很多第三方代码生成框架 ,比如 LLBLGen 和 ?? 模板。
- 轻量级代码生成(LCG ) ,又名动态方法。此 API 可用于生成方法,而无需创建类型和程序集来包含它。对于小块代码,这是最有效的代码生成机制。在 LCG 方法中发出代码需要 ILGenerator 类,它直接使用 IL 指令。
- 系统。Reflection.Emit 命名空间包含可用于在 IL 级别生成程序集、类型和方法的 API。
- 表达式树(在系统中。Linq.Expression 命名空间)可用于从序列化表示创建轻量级表达式。
- CSharpCodeProvider 类可用于将 C# 源代码(以字符串形式提供或从文件中读取)直接编译成程序集。
从源代码生成代码
假设您正在实现一个序列化框架,它写出任意对象的 XML 表示。使用反射来获得非空的公共字段值并递归地写出它们是相当昂贵的,但是有利于一个简单的实现:
//Rudimentary XML serializer – does not support collections, cyclic references, etc.
public static string XmlSerialize(object obj) {
StringBuilder builder = new StringBuilder();
Type type = obj.GetType();
builder.AppendFormat(" < {0} Type = '{1}'" > ", type.Name, type.AssemblyQualifiedName);
if (type.IsPrimitive || type == typeof(string)) {
builder.Append(obj.ToString());
} else {
foreach (FieldInfo field in type.GetFields()) {
object value = field.GetValue(obj);
if (value ! = null) {
builder.AppendFormat(" < {0} > {1}</{0} > ", field.Name, XmlSerialize(value));
}
}
}
builder.AppendFormat("</{0} > ", type.Name);
return builder.ToString();
}
相反,我们可以生成强类型代码来序列化特定类型并调用该代码。使用 CSharpCodeProvider,实现的要点如下:
public static string XmlSerialize<T>(T obj){
Func<T,string> serializer = XmlSerializationCache<T>.Serializer;
if (serializer == null){
serializer = XmlSerializationCache<T>.GenerateSerializer();
}
return serializer(obj);
}
private static class XmlSerializationCache < T > {
public static Func < T,string > Serializer;
public static Func < T,string > GenerateSerializer() {
StringBuilder code = new StringBuilder();
code.AppendLine("using System;");
code.AppendLine("using System.Text;");
code.AppendLine("public static class SerializationHelper {");
code.AppendFormat("public static string XmlSerialize({0} obj) {{", typeof(T).FullName);
code.AppendLine("StringBuilder result = new StringBuilder();");
code.AppendFormat("result.Append(\" < {0} Type = '{1}'" > \");", typeof(T).Name, typeof(T).AssemblyQualifiedName);
if (typeof(T).IsPrimitive || typeof(T) == typeof(string)) {
code.AppendLine("result.AppendLine(obj.ToString());");
} else {
foreach (FieldInfo field in typeof(T).GetFields()) {
code.AppendFormat("result.Append(\" < {0} > \");", field.Name);
code.AppendFormat("result.Append(XmlSerialize(obj.{0}));", field.Name);
code.AppendFormat("result.Append(\"</{0} > \");", field.Name);
}
}
code.AppendFormat("result.Append(\"</{0} > \");", typeof(T).Name);
code.AppendLine("return result.ToString();");
code.AppendLine("}");
code.AppendLine("}");
CSharpCodeProvider compiler = new CSharpCodeProvider();
CompilerParameters parameters = new CompilerParameters();
parameters.ReferencedAssemblies.Add(typeof(T).Assembly.Location);
parameters.CompilerOptions = "/optimize + ";
CompilerResults results = compiler.CompileAssemblyFromSource(parameters, code.ToString());
Type serializationHelper = results.CompiledAssembly.GetType("SerializationHelper");
MethodInfo method = serializationHelper.GetMethod("XmlSerialize");
Serializer = (Func <T,string>)Delegate.CreateDelegate(typeof(Func <T,string>), method);
return Serializer;
}
}
基于反射的部分已经移动,因此只使用一次来生成强类型代码——结果缓存在静态字段中,并在每次必须序列化某个类型时重用。请注意,上面的序列化程序代码没有经过广泛的测试;这仅仅是一个概念证明,展示了代码生成的思想。简单的测量表明,基于代码生成的方法比原始的仅反射代码快两倍以上。
使用动态轻量级代码生成代码生成
另一个例子源于网络协议解析领域。假设您有一个很大的二进制数据流,比如网络数据包,您必须解析它以从中检索数据包报头并选择部分有效负载。例如,考虑下面的数据包报头结构(这是一个完全虚构的例子—TCP 数据包报头不是这样排列的):
public struct TcpHeader {
public uint SourceIP;
public uint DestIP;
public ushort SourcePort;
public ushort DestPort;
public uint Flags;
public uint Checksum;
}
在 C/C++中,从字节流中检索这样一个结构是一项简单的任务,如果通过指针访问,甚至不需要复制任何内存。事实上,从一个字节流中检索任何结构都是微不足道的:
template < typename T>
const T* get_pointer(const unsigned char* data, int offset) {
return (T*)(data + offset);
}
template < typename T>
const T get_value(const unsigned char* data, int offset) {
return *get_pointer(data, offset);
}
不幸的是,在 C# 中事情更复杂。从流中读取任意数据的方法有很多种。一种可能是使用反射来检查类型的字段,并从字节流中单独读取它们:
//Supports only some primitive fields, does not recurse
public static void ReadReflectionBitConverter < T > (byte[] data, int offset, out T value) {
object box = default(T);
int current = offset;
foreach (FieldInfo field in typeof(T).GetFields()) {
if (field.FieldType == typeof(int)) {
field.SetValue(box, BitConverter.ToInt32(data, current));
current + = 4;
} else if (field.FieldType == typeof(uint)) {
field.SetValue(box, BitConverter.ToUInt32(data, current));
current + = 4;
} else if (field.FieldType == typeof(short)) {
field.SetValue(box, BitConverter.ToInt16(data, current));
current + = 2;
} else if (field.FieldType == typeof(ushort)) {
field.SetValue(box, BitConverter.ToUInt16(data, current));
current + = 2;
}
//. . .many more types omitted for brevity
value = (T)box;
}
当在我们的一台测试机器上对一个 20 字节的 TcpHeader 结构执行 1,000,000 次时,这个方法平均需要 170 毫秒来执行。虽然运行时间看起来不算太长,但是所有装箱操作分配的内存量是相当大的。此外,如果您考虑 1Gb/s 的实际网络速率,预计每秒数千万个包是合理的,这意味着我们将不得不花费大部分 CPU 时间从传入数据中读取结构。
一个更好的方法是使用元帅。PtrToStructure 方法 ,用于将非托管内存块转换为托管结构。使用它需要固定原始数据以检索指向其内存的指针:
public static void ReadMarshalPtrToStructure < T > (byte[] data, int offset, out T value) {
GCHandle gch = GCHandle.Alloc(data, GCHandleType.Pinned);
try {
IntPtr ptr = gch.AddrOfPinnedObject();
ptr + = offset;
value = (T)Marshal.PtrToStructure(ptr, typeof(T));
} finally {
gch.Free();
}
}
这个版本要好得多,100 万个数据包平均耗时 39 毫秒。这是一个显著的性能改进,但是封送。PtrToStructure 仍然强制进行堆内存分配,因为它返回一个对象引用,对于每秒数千万个包来说,这仍然不够快。
在第八章中,我们讨论了 C# 指针和不安全代码,这似乎是一个使用它们的好机会。毕竟,C++版本之所以如此简单,正是因为它使用了指针。事实上,下面的代码对于 1,000,000 个数据包来说要快得多,只需 0.45 毫秒,这是一个令人难以置信的改进!
public static unsafe void ReadPointer(byte[] data, int offset, out TcpHeader header) {
fixed (byte* pData = &data[offset]) {
header = *(TcpHeader*)pData;
}
}
为什么这个方法这么快?因为负责四处复制数据的实体不再是 Marshal 这样的 API 调用。ptrto structure—它是 JIT 编译器本身。为该方法生成的汇编代码可以被内联(实际上,64 位 JIT 编译器选择这样做),并且可以使用 3-4 条指令来复制内存(例如,在 32 位系统上使用 MOVQ 指令一次复制 64 位)。唯一的问题是我们设计的 ReadPointer 方法不是泛型的,不像它的 C++对应物。下意识的反应是实现它的通用版本—
public static unsafe void ReadPointerGeneric < T > (byte[] data, int offset, out T value) {
fixed (byte* pData = &data[offset]) {
value = *(T*)pData;
}
}
—不编译!具体来说,T*不是你可以在任何地方用 C# 编写的东西,因为没有通用约束来保证指向 T 的指针可以被获取(只有在第八章中讨论的可直接复制到本机结构中的类型可以被固定和指向)。因为没有通用的约束来表达我们的意图,所以看起来我们必须为每种类型编写单独版本的 ReadPointer,这就是代码生成重新发挥作用的地方。
TYPEDREFERENCE 和两个未记录的 C# 关键字
绝望的时候需要绝望的措施,在这种情况下,绝望的措施是抽出两个未记录的 C# 关键字,__makeref 和 __refvalue (由同样未记录的 IL 操作码支持)。这些关键字与 TypedReference struct 一起用于一些具有 C 风格可变长度方法参数列表(需要另一个未记录的关键字 __arglist)的低级互操作性场景中。
TypedReference 是一个小结构,它有两个 IntPtr 字段——类型和值。值字段是指向值的指针,该值可以是值类型或引用类型,类型字段是其方法表指针。通过创建指向值类型位置的 TypedReference,我们可以按照我们的场景要求,以强类型的方式重新解释内存,并使用 JIT 编译器复制内存,就像 ReadPointer 的情况一样。
//We are taking the parameter by ref and not by out because we need to take its address,
//and __makeref requires an initialized value.
public static unsafe void ReadPointerTypedRef < T > (byte[] data, int offset, ref T value) {
//We aren't actually modifying 'value' -- just need an lvalue to start with
TypedReference tr = __makeref(value);
fixed (byte* ptr = &data[offset]) {
//The first pointer-sized field of TypedReference is the object address, so we
//overwrite it with a pointer into the right location in the data array:
*(IntPtr*)&tr = (IntPtr)ptr;
//__refvalue copies the pointee from the TypedReference to 'value'
value = __refvalue(tr, T);
}
}
This nasty compiler magic still has a cost, unfortunately. Specifically, the __makeref operator is compiled by the JIT compiler to call clr!JIT_GetRefAny, which is an extra cost compared to the fully-inlinable ReadPointer version. The result is an almost 2× slowdown—this method takes 0.83ms to execute 1,000,000 iterations. Incidentally, this is still the fastest *generic* approach we will see in this section.
为了避免为每种类型编写单独的 ReadPointer 方法副本,我们将使用轻量级代码生成(DynamicMethod 类)来生成代码。首先,我们检查为 ReadPointer 方法生成的 IL:
.method public hidebysig static void ReadPointer( uint8[] data, int32 offset, [out] valuetype TcpHeader& header) cil managed
{
.maxstack 2
.locals init ([0] uint8& pinned pData)
ldarg.0
ldarg.1
ldelema uint8
stloc.0
ldarg.2
ldloc.0
conv.i
ldobj TcpHeader
stobj TcpHeader
ldc.i4.0
conv.u
stloc.0
ret
}
现在我们要做的就是发出 IL,其中 TcpHeader 被泛型类型参数替换。事实上,感谢优秀的 ReflectionEmitLanguage 插件。NET Reflector(在http://reflectoraddins.codeplex.com/wikipage?title=ReflectionEmitLanguage
可用),它将方法转换成反射。发出生成它们所需的 API 调用,我们甚至不必手动编写代码——尽管它确实需要一些小的修正:
static class DelegateHolder < T>
{
public static ReadDelegate < T > Value;
public static ReadDelegate < T > CreateDelegate() {
DynamicMethod dm = new DynamicMethod("Read", null,
new Type[] { typeof(byte[]), typeof(int), typeof(T).MakeByRefType() },
Assembly.GetExecutingAssembly().ManifestModule);
dm.DefineParameter(1, ParameterAttributes.None, "data");
dm.DefineParameter(2, ParameterAttributes.None, "offset");
dm.DefineParameter(3, ParameterAttributes.Out, "value");
ILGenerator generator = dm.GetILGenerator();
generator.DeclareLocal(typeof(byte).MakePointerType(), pinned: true);
generator.Emit(OpCodes.Ldarg_0);
generator.Emit(OpCodes.Ldarg_1);
generator.Emit(OpCodes.Ldelema, typeof(byte));
generator.Emit(OpCodes.Stloc_0);
generator.Emit(OpCodes.Ldarg_2);
generator.Emit(OpCodes.Ldloc_0);
generator.Emit(OpCodes.Conv_I);
generator.Emit(OpCodes.Ldobj, typeof(T));
generator.Emit(OpCodes.Stobj, typeof(T));
generator.Emit(OpCodes.Ldc_I4_0);
generator.Emit(OpCodes.Conv_U);
generator.Emit(OpCodes.Stloc_0);
generator.Emit(OpCodes.Ret);
Value = (ReadDelegate < T>)dm.CreateDelegate(typeof(ReadDelegate < T>));
return Value;
}
}
public static void ReadPointerLCG < T > (byte[] data, int offset, out T value)
{
ReadDelegate < T > del = DelegateHolder < T > .Value;
if (del == null) {
del = DelegateHolder<T>.CreateDelegate();
}
del(data, offset, out value);
}
这个版本处理 1,000,000 个数据包需要 1.05 毫秒,是 ReadPointer 的两倍多,但仍然比最初的基于反射的方法快两个数量级以上,这是代码生成的另一个胜利。(与 ReadPointer 相比,性能损失是因为需要从静态字段获取委托,检查是否为空,并通过委托调用方法。)
摘要
无论如何不同,本章中讨论的优化技巧和技术对于实现高性能 CPU 限制的算法和设计复杂系统的架构是至关重要的。通过确保您的代码利用内置的 JIT 优化以及处理器必须提供的任何特定指令,通过尽可能减少客户端应用的启动时间,以及通过避开反射和异常等昂贵的 CLR 机制,您肯定会从托管软件中榨取每一点性能。
在下一章也是最后一章,我们将讨论 Web 应用的性能特征,主要是在 ASP.NET 领域,并介绍仅与 Web 服务器相关的特定优化。
十一、Web 应用性能
Web 应用被设计为每秒处理数百甚至数千个请求。为了成功地构建这样的应用,识别潜在的性能瓶颈并尽一切努力防止其发生是非常重要的。但是处理和防止 ASP.NET 应用中的瓶颈不仅仅局限于您的代码。从一个 web 请求到达服务器 到到达你的应用代码,它通过一个 HTTP 管道,然后通过 IIS 管道,最后到达另一个管道,ASP。NET 的管道,只有这样它才能到达你的代码。当您处理完请求后,响应会沿着这些管道发送,直到最终被客户端的机器接收。这些管道中的每一个都是潜在的瓶颈,所以提高 ASP.NET 应用的性能实际上意味着提高你的代码和管道的性能。
当讨论如何提高 ASP.NET 应用 的性能时,人们必须看得更远,而不仅仅是应用本身,并检查构成 web 应用的各个部分如何影响其整体性能。web 应用的整体性能包括以下几个方面:
- 应用的代码
- ASP.NET 环境
- 宿主环境(大多数情况下是 IIS)
- 网络
- 客户端(本书没有讨论)
在这一章中,我们将简要讨论 web 应用的性能测试工具,并从上述每个主题中探索各种方法,这些方法可以帮助我们提高 web 应用的整体性能。在本章的最后,我们将讨论扩展 web 应用的需要和含义,以及如何在扩展时避免已知的陷阱。
测试 Web 应用的性能
在开始对您的 web 应用进行更改之前,您需要知道您的应用是否运行良好——它是否满足 SLA(服务级别协议)中指定的要求?它在负载下的表现是否不同?有什么可以改进的一般性问题吗?为了了解所有这些以及更多,我们需要使用测试和监控工具来帮助我们识别 web 应用中的陷阱和瓶颈。
在第二章中,我们讨论了一些通用的分析工具来检测代码中的性能问题,例如 Visual Studio 和 ANTS profilers,但是还有其他工具可以帮助您测试、测量和调查应用的“web”部分。
这只是对 web 应用性能测试的一个简单介绍。关于如何计划、执行和分析 web 应用性能测试的更全面的描述和指导,你可以参考“Web 应用性能测试指南”MSDN 文章(msdn.microsoft.com/library/bb924375
)。
Visual Studio Web 性能测试和负载测试
Visual Studio Ultimate 中可用的测试特性 之一是 web 性能测试,它使您能够评估 Web 应用的响应时间和吞吐量。通过 web 性能测试,可以记录浏览 Web 应用时产生的 HTTP 请求和响应,如图图 11-1 所示。(这仅在 Internet Explorer 中直接支持。)
图 11-1 。使用 web 测试记录器记录 Web 应用
一旦进行了记录,您就可以使用该记录来测试 web 应用的性能,并通过将新的响应与先前记录的响应进行匹配来测试其正确性。
Web 性能测试允许您定制测试流程。您可以更改请求的顺序,添加您自己的新请求,在流程中插入循环和条件,更改请求的标题和内容,添加响应的验证规则,甚至通过将它转换为代码并编辑生成的代码来定制整个测试流程。
单独使用 web 性能测试有其好处,但是为了在压力下测试 Web 应用的性能,请将 Web 性能测试与 Visual Studio 的负载测试功能结合使用。Visual Studio 的这一功能使您能够模拟系统上的负载,其中多个用户同时调用它,对它执行不同的操作,并通过收集各种性能信息(如性能计数器和事件日志)来测试系统在此期间的行为。
警告建议不要对公共网站进行负载测试,只对自己的网站和网络应用进行负载测试。对公共网站进行负载测试可能会被解释为拒绝服务(DOS) 攻击,导致您的机器甚至您的本地网络被禁止访问该网站。
结合负载测试和 web 性能测试的记录,我们可以模拟几十甚至几百个用户同时访问我们的 Web 应用,模拟在每个请求中使用不同的参数调用不同的页面。
为了正确地模拟数百个用户,建议您使用测试代理。测试代理是从控制器机器接收测试指令、执行所需测试并将结果发送回控制器的机器。测试代理的使用有助于减少测试机器(不是被测试的机器)上的压力,因为模拟数百个用户的单个机器可能遭受性能下降,导致测试产生错误的结果。
在负载测试期间,我们可以监控各种性能计数器,这些计数器可以指出我们的应用在压力下的行为,例如,通过检查请求是否在 ASP.NET 中排队,请求的持续时间是否随着时间的推移而增加,以及请求是否由于错误的配置而超时。
通过在各种场景下运行负载测试,例如不同数量的并发用户或不同类型的网络(慢速/快速),我们可以了解很多关于我们的 web 应用如何在压力下工作的信息,并从记录的数据中得出关于我们可以改进其整体性能的方法的结论。
HTTP 监控工具
可以嗅探 HTTP 通信的网络监控工具,如 Wireshark、NetMon、HTTP Analyzer 和 Fiddler,可以帮助我们识别发送到 web 应用和从 web 应用接收的 HTTP 请求和响应的问题。在监控工具的帮助下,我们可以验证影响 web 应用性能的各种问题。例如:
- 正确使用浏览器的缓存。通过查看 HTTP 流量,我们可以确定哪些响应返回时没有缓存头,以及当请求的内容已经缓存时,请求是否使用正确的“匹配”头发送。
- 消息的数量和大小。监控工具显示每个请求和响应、接收每条消息所用的时间以及每条消息的大小,使您能够跟踪发送过于频繁的请求、大型请求和响应以及处理时间过长的请求。
- 施加压力。通过查看请求和响应,您可以验证请求是使用 Accept-Encoding 头发送的,以启用 GZip 压缩,并且您的 web 服务器相应地返回一个压缩的响应。
- 同步通信。一些 HTTP 监控工具可以显示请求的时间表,以及哪个进程生成了哪个请求,因此我们可以验证我们的客户端应用是否能够一次发送多个请求,或者由于缺少传出连接,请求是否被同步。例如,您可以使用此功能来检测浏览器可以向特定服务器打开多少个并发连接,或者检查您的。NET 客户端应用正在使用由 System.Net.ServicePointManager 强制实施的默认两个连接限制。
有些工具(如 Fiddler)还可以将记录的流量导出为 Visual Studio Web 性能测试,因此您可以使用 Web 测试和负载测试来测试从客户端应用和浏览器(而不是 Internet Explorer)调用的 Web 应用。例如,您可以从. NET 客户端应用监视基于 HTTP 的 WCF 调用,将它们导出为 Web 测试,并使用负载测试对您的 WCF 服务进行压力测试。
网络分析工具
另一组可用于识别 web 应用问题的工具是 web 分析工具,如 Yahoo!s YSlow 和谷歌的页面速度。Web 分析工具不仅仅分析流量本身,还会寻找丢失的缓存头和未压缩的响应。它们分析 HTML 页面本身,以检测可能影响加载和呈现页面的性能的问题,例如:
- 会影响渲染时间的大型 HTML 结构。
- HTML、CSS 和 JavaScript 内容可以通过使用缩小技术缩小大小。
- 可以缩小尺寸以匹配 HTML 页面尺寸的大图像。
- JavaScript 代码,可以在页面加载后执行,而不是在加载过程中执行,以加快页面加载速度。
提高服务器上的 Web 性能
有许多方法可以提高 ASP.NET 应用中代码的性能。可以使用对 ASP.NET 应用和桌面应用都有效的技术进行一些改进,例如使用线程或任务进行非依赖异步操作,但也有一些改进与您编写 ASP.NET 代码的方式有关,无论它是 WebForm 的代码隐藏,还是 ASP.NET MVC 控制器。这些变化,无论多小,都有助于更好地利用您的服务器,让应用运行得更快,并处理更多的并发请求。
缓存常用对象
在 web 应用中处理请求通常需要使用提取的数据,这些数据通常来自远程位置,如数据库或 web 服务。这些数据查找是昂贵的操作,通常会导致响应时间延迟。不需要为每个操作提取数据,数据可以被预先提取一次,并存储在内存中的某种缓存机制中。在获取数据后进入的新请求可以使用缓存的数据,而不是从其原始源再次获取数据。缓存范例通常被描述为:
- 如果数据已经被缓存,就使用它。
- 否则:
- a.获取数据。
- b.将其存储在缓存中。
- c.用它。
注意由于几个请求可以在任何给定的时间访问同一个缓存对象,导致同一个对象被多个线程引用,因此,无论是通过将缓存对象视为不可变的(对缓存对象的更改将需要克隆它,在新副本上进行更改,然后用克隆的对象更新缓存),还是通过使用锁定机制来确保它不被其他线程更新,对缓存对象的更新都应该是负责任的。
很多开发者使用 ASP。NET 的应用状态集合作为一种缓存机制,因为它提供了内存中的缓存存储,所有用户和会话都可以访问。使用应用集合非常简单:
Application["listOfCountries"] = countries; // Store a value in the collection
countries = (IEnumerable < string>)Application["listOfCountries"]; // Get the value back
当使用应用集合时,存储在内存中并随着时间的推移而累积的资源最终会填满服务器内存,导致 ASP.NET 应用开始使用来自磁盘的分页内存,甚至由于内存不足而失败。因此,ASP.NET 提供了一种特殊的缓存机制,这种机制提供了对缓存项的某种管理,在内存不足时释放未使用的项。
可通过 Cache 类访问的 ASP.NET 缓存提供了广泛的缓存机制,除了存储资源之外,还允许您:
- 通过指定 TimeSpan 或固定日期时间来定义缓存对象的过期时间。一旦缓存对象的分配寿命到期,该对象将自动从缓存中移除。
- 定义缓存对象的优先级。当内存不足,需要释放对象时,优先级可以帮助缓存机制决定哪些对象不太“重要”
- 通过添加依赖项(如 SQL 依赖项)来定义缓存对象的有效性。例如,如果缓存的对象是 SQL 查询的结果,则可以设置 SQL 依赖项,因此数据库中影响从查询返回的结果的更改将使缓存的对象无效。
- 将回调附加到缓存的对象,这样当一个对象从缓存中移除时,回调就会被调用。当缓存中发生过期或失效时,使用回调可以帮助检索更新的资源信息。
将项目添加到缓存中就像将项目添加到字典中一样简单:
Cache["listOfCountries"] = listOfCountries;
当使用上面的代码将一个项添加到缓存中时,缓存的项将具有默认的 Normal 优先级,并且不使用任何过期或依赖检查。例如,要向缓存中添加一个具有可变过期时间的项,请使用 Insert 方法:
Cache.Insert("products", productsList,
Cache.NoAbsoluteExpiration, TimeSpan.FromMinutes(60), dependencies: null);
注意Cache 类也提供了 Add 方法。与 Insert 方法不同,如果缓存已经包含具有相同 key 参数的项,Add 方法将引发异常。
缓存访问范例,使用 ASP。NET 的缓存类通常实现如下:
object retrievedObject = null;
retrievedObject = Cache["theKey"];
if (retrievedObject == null) {
//Lookup the data somewhere (database, web service, etc.)
object originalData = null;
. . .
//Store the newly retrieved data in the cache
Cache["theKey"] = originalData;
retrievedObject = originalData;
}
//Use the retrieved object (either from the cache or the one that was just cached)
. . .
您会注意到第一行代码试图从缓存中检索对象,而没有首先检查它是否存在于缓存中。这是因为对象可以在任何时候被其他请求或缓存机制本身从缓存中移除,所以在检查和检索之间可以从缓存中移除一个项目。
使用异步页面、模块和控制器
当 IIS 将一个请求传递给 ASP.NET 时,该请求被排队到线程池中,并分配一个工作线程来处理该请求,无论它是对简单 HTTP 处理程序的请求,还是对 ASP.NET web form 应用中的页面或 ASP.NET MVC 应用中的控制器的请求。
由于线程池中工作线程的数量是有限的(由 web.config 中 process modelmaxWorkerThreads 部分的值集定义),这意味着 ASP.NET 也受到它可以同时执行的线程或请求数量的限制。
线程限制通常很高,足以支持只需要处理几十个并发请求的中小型 web 应用。但是,如果您的 web 应用需要同时处理数百个请求,那么您应该继续阅读本节。
对并发执行请求数量的限制鼓励开发人员尝试最小化请求的执行时间,但是当一个请求的执行依赖于一些其他 I/O 操作时会发生什么呢,比如调用 web 服务,或者等待数据库操作完成?在这种情况下,请求的执行时间在很大程度上取决于从远程进程获取信息所需的时间,在此期间,附加到请求的工作线程被占用,无法为另一个请求释放。
最终,当当前执行的请求数量超过线程池的限制时,新的请求将被放置在一个特殊的等待队列中。当排队请求的数量超过队列的限制时,传入的请求将失败,返回 HTTP 503 响应(“服务不可用”)。
注意线程池和请求队列的限制是在 web.config 文件的 processModel 部分为 ASP.NET 应用定义的,部分由 processModel autoConfig 属性控制。
在现代 web 应用中,I/O 操作是我们系统设计中不可避免的一部分(调用 web 服务、查询数据库、从网络文件存储中读取数据等)。),这种行为通常会导致许多正在运行的线程等待 I/O,而只有几个线程实际执行消耗 CPU 的任务。这通常会导致服务器 CPU 的利用率很低,其他请求无法使用 CPU,因为没有更多的空闲线程来处理传入的请求。
在 web 应用中,许多请求都是从 web 服务或数据库获取数据开始的,即使用户负载很高,CPU 利用率也很低,这是很常见的。您可以使用性能计数器来检查 web 应用的 CPU 利用率,方法是将处理器% CPU 利用率计数器与 ASP.NET 应用\请求/秒和 ASP。NET\Requests 排队计数器。
如果您的一些请求正在执行冗长的 I/O 操作,那么就没有必要保持工作线程直到完成。使用 ASP.NET,您可以编写异步页面、控制器、处理程序和模块,这使您能够在代码等待 I/O 操作完成时将工作线程返回到线程池,并在完成后从线程池中抓取一个工作线程来完成请求的执行。从最终用户的角度来看,页面似乎仍然需要一些时间来加载,因为服务器会等待请求,直到处理完成,响应准备好发送回来。
通过将 I/O 绑定请求更改为使用异步处理而不是同步处理,您可以增加 CPU 密集型请求可用的工作线程数量,从而使您的服务器能够更好地利用其 CPU 并防止请求排队。
创建异步页面
如果您有一个 ASP.NET Web 窗体应用,并且希望创建一个异步页面,首先您需要将该页面标记为异步:
<%@ Page Async = "true" . . .
一旦标记为 async,就创建一个新的 PageAsyncTask 对象,并将 begin、end 和 timeout 方法的委托传递给它。创建 PageAsyncTask 对象后,调用页面。RegisterAsyncTask 方法来启动异步操作。
以下代码显示了如何使用 PageAsyncTask 启动一个冗长的 SQL 查询:
public partial class MyAsyncPage : System.Web.UI.Page {
private SqlConnection _sqlConnection;
private SqlCommand _sqlCommand;
private SqlDataReader _sqlReader;
IAsyncResult BeginAsyncOp(object sender, EventArgs e, AsyncCallback cb, object state) {
//This part of the code will execute in the original worker thread,
//so do not perform any lengthy operations in this method
_sqlCommand = CreateSqlCommand(_sqlConnection);
return _sqlCommand.BeginExecuteReader(cb, state);
}
void EndAsyncOp(IAsyncResult asyncResult) {
_sqlReader = _sqlCommand.EndExecuteReader(asyncResult);
//Read the data and build the page’s content
. . .
}
void TimeoutAsyncOp(IAsyncResult asyncResult) {
_sqlReader = _sqlCommand.EndExecuteReader(asyncResult);
//Read the data and build the page’s content
. . .
}
public override void Dispose() {
if (_sqlConnection ! = null) {
_sqlConnection.Close();
}
base.Dispose();
}
protected void btnClick_Click(object sender, EventArgs e) {
PageAsyncTask task = new PageAsyncTask(
new BeginEventHandler(BeginAsyncOp),
new EndEventHandler(EndAsyncOp),
new EndEventHandler(TimeoutAsyncOp),
state:null);
RegisterAsyncTask(task);
}
}
创建异步页面的另一种方法是使用完成事件,例如使用 web 服务或 WCF 服务生成的代理时创建的事件:
public partial class MyAsyncPage2 : System.Web.UI.Page {
protected void btnGetData_Click(object sender, EventArgs e) {
Services.MyService serviceProxy = new Services.MyService();
//Attach to the service’s xxCompleted event
serviceProxy.GetDataCompleted + = new
Services.GetDataCompletedEventHandler(GetData_Completed);
//Use the Async service call which executes on an I/O thread
serviceProxy.GetDataAsync();
}
void GetData_Completed (object sender, Services. GetDataCompletedEventArgs e) {
//Extract the result from the event args and build the page’s content
}
}
在上面的示例中,与第一个示例一样,页面也被标记为 Async,但是不需要创建 PageAsyncTask 对象,因为页面会在调用 xxAsync 方法时以及激发 xxCompleted 事件后自动接收通知。
注意当页面设置为异步时,async 改变页面实现 IHttpAsyncHandler,而不是同步 IHttpHandler。如果您希望创建自己的异步通用 HTTP 处理程序,请创建一个实现 IHttpAsyncHandler 接口的通用 HTTP 处理程序类。
创建异步控制器
ASP.NET MVC 中的控制器类也可以被创建为异步控制器,如果它们执行冗长的 I/O 操作的话。要创建异步控制器,您需要执行以下步骤:
- 创建一个从 AsyncController 类型继承的控制器类。
- 根据以下约定为每个异步操作实现一组动作方法,其中 xx 是动作的名称:xxAsync 和 xxCompleted。
- 在 xxAsync 方法中,调用 AsyncManager。用将要执行的异步操作的数量来递增方法。
- 在异步操作返回期间执行的代码中,调用 AsyncManager。outstanding operations . Decrement 方法通知操作已完成。
例如,下面的代码显示了一个名为 Index 的控制器,它调用一个为视图返回数据的服务:
public class MyController : AsyncController {
public void IndexAsync() {
//Notify the AsyncManager there is going to be only one Async operation
AsyncManager.OutstandingOperations.Increment();
MyService serviceProxy = new MyService();
//Register to the completed event
serviceProxy.GetDataCompleted + = (sender, e) = > {
AsyncManager.Parameters["result"] = e.Value;
AsyncManager.OutstandingOperations.Decrement();
};
serviceProxy.GetHeadlinesAsync();
}
public ActionResult IndexCompleted(MyData result) {
return View("Index", new MyViewModel { TheData = result });
}
}
调整 ASP.NET 的环境
除了我们的代码,每个传入的请求和传出的响应都必须通过 ASP。NET 的组件。一些 ASP。NET 的机制是为了满足开发人员的需求而创建的,比如 ViewState 机制,但是它会影响应用的整体性能。当微调 ASP.NET 应用的性能时,建议更改其中一些机制的默认行为,尽管更改它们有时可能需要更改应用代码的构造方式。
关闭 ASP.NET 跟踪调试
ASP.NET 跟踪使开发人员能够查看所请求页面的诊断信息,如执行时间和路径、会话状态和 HTTP 头列表。
虽然跟踪是一个很好的特性,并在开发和调试 ASP.NET 应用时提供了附加值,但由于跟踪机制和对每个请求执行的数据收集,它对应用的整体性能有一些影响。因此,如果在开发过程中启用了跟踪,请在将 web 应用部署到生产环境之前,通过更改 web.config:
<configuration>
<system.web>
<trace enabled = "false"/>
</system.web>
</configuration>
注意如果在 web.config 中没有另外指定,trace 的默认值是禁用的(enabled = "false "),因此从 web.config 文件中移除跟踪设置也会禁用它。
创建新的 ASP.NET web 应用时,自动添加到应用的 web.config 文件中的内容之一是 system.web 编译配置节,其 debug 属性设置为 true:
<configuration>
<system.web>
<compilation debug = "true" targetFramework = "4.5" />
</system.web>
</configuration>
注意这是在 Visual Studio 2012 或 2010 中创建 ASP.NET web 应用时的默认行为。在 Visual Studio 的早期版本中,默认行为是将调试设置设置为 false,当开发人员第一次尝试调试应用时,会出现一个对话框,询问是否允许将设置更改为 true。
这种设置的问题是,开发人员在将应用部署到生产环境中时,经常忽略将设置从 true 更改为 false,甚至故意这样做以获得更详细的异常信息。事实上,保持这种设置会导致几个性能问题:
- 浏览器不会缓存使用 WebResources.axd 处理程序下载的脚本,例如在页面中使用验证控件时。当将 debug 标志设置为 false 时,来自该处理程序的响应将返回缓存头,允许浏览器缓存响应以供将来访问。
- 当 debug 设置为 true 时,请求不会超时。虽然这在调试代码时非常方便,但在生产环境中不太需要这种行为,因为这种请求会导致服务器无法处理其他传入的请求,甚至会导致大量 CPU 使用、内存消耗增加和其他资源分配问题。
- 将调试标志设置为 false 将使 ASP.NET 能够根据 httpRuntimeexecution time out 配置设置(默认值为 110 秒)为请求定义超时。
- 当使用 debug = true 运行时,JIT 优化将不会应用于代码。JIT 优化是最重要的优势之一。NET,可以有效地提高您的 ASP.NET 应用的性能,而不需要您更改代码。将 debug 设置为 false 将允许 JIT 编译器执行它的行为,使您的应用执行得更快更有效。
- 当使用 debug = true 时,编译过程不使用批处理编译。如果没有批处理编译,将为每个页面和用户控件创建一个程序集,导致 web 应用在运行时加载几十个甚至几百个程序集;由于地址空间碎片化,加载这么多程序集可能会导致将来出现内存异常。当 debug 模式设置为 false 时,将使用批处理编译,为用户控件生成一个程序集,并为页面生成几个程序集(根据页面对用户控件的使用将页面分组为程序集)。
更改此设置非常简单:要么从配置中完全删除 debug 属性,要么将其设置为 false:
<configuration>
<system.web>
<compilation debug = "false" targetFramework = "4.5" />
</system.web>
</configuration>
如果您担心在将应用部署到生产服务器时忘记更改此设置,您可以通过在服务器的 machine.config 文件中添加以下配置来强制服务器中的所有 ASP.NET 应用忽略调试设置:
<configuration>
<system.web>
<deployment retail = "true"/>
</system.web>
</configuration>
禁用视图状态
视图状态 是 ASP.NET Web 窗体应用中使用的技术,用于将页面状态持久化到呈现的 HTML 输出中(ASP.NET MVC 应用不使用这种机制)。视图状态用于允许 ASP.NET 在用户执行回发之间保持页面的状态。视图状态数据通过以下方式存储在 HTML 输出中:对状态进行序列化、加密(默认情况下不设置)、编码为 Base64 字符串并存储在隐藏字段中。当用户回发页面时,内容被解码,然后反序列化回视图状态字典。许多服务器控件使用视图状态来保持它们自己的状态,例如将它们的属性值存储在视图状态中。
尽管这种机制非常有用和强大,但它会生成一个有效负载,当这个有效负载作为 Base64 字符串放在页面中时,会使响应的大小增加一个数量级。例如,包含带有分页的单个 GridView 的页面,绑定到 800 个客户的列表,将生成大小为 17 KB 的输出 HTML,其中 6 KB 是视图状态字段,这是因为 GridView 控件将它们的数据源存储在视图状态中。此外,使用视图状态需要对每个请求的视图状态进行序列化和反序列化,这增加了页面处理的额外开销。
提示使用视图状态创建的有效负载通常不会被访问自己局域网中的 web 服务器的客户端注意到。这是因为局域网通常非常快,能够在几毫秒内传输非常大的页面(最佳 1Gb 局域网的吞吐量可以达到 40–100 MB/s,具体取决于硬件)。但是,当使用慢速广域网(如 Internet)时,视图状态的负载最为显著。
如果不需要使用视图状态,建议禁用它。通过在 web.config 文件中禁用视图状态,可以禁用整个应用的视图状态:
<system.web>
<pages enableViewState = "false"/>
</system.web>
如果您不想禁用整个应用的视图状态,也可以禁用单个页面及其所有控件的视图状态:
<%@ PageEnableViewState = "false". . . %>
您还可以禁用每个控件的视图状态:
<asp:GridView ID = "gdvCustomers" runat = "server" DataSourceID = "mySqlDataSource"
AllowPaging = "True"EnableViewState = "false"/>
在 ASP.NET 4 之前,禁用页面中的视图状态使得无法为页面中的特定控件重新启用视图状态。从 ASP.NET 4 开始,增加了一种新的方法,允许在页面上禁用视图状态,但对特定的控件重新启用它。这在 ASP.NET 4 中是通过使用 ViewStateMode 属性来实现的。例如,下面的代码禁用整个页面的视图状态,GridView 控件除外:
<%@ Page EnableViewState = "true"ViewStateMode = "Disabled". . . %>
<asp:GridView ID = "gdvCustomers" runat = "server" DataSourceID = "mySqlDataSource"
AllowPaging = "True"ViewStateMode = "Enabled"/>
注意通过将 EnableViewState 设置为 false 来禁用视图状态将覆盖对 ViewStateMode 所做的任何设置。因此,如果希望使用 ViewStateMode,请确保 EnableViewState 设置为 true 或省略(默认值为 true)。
服务器端输出缓存
尽管 ASP.NET 页面的内容被认为是动态的,但是您经常会遇到页面的动态内容不一定会随着时间的推移而改变的情况。例如,页面可以接收产品的 ID,并返回描述该产品的 HTML 内容。页面本身是动态的,因为它可以为不同的产品返回不同的 HTML 内容,但是特定产品的产品页面不会经常改变,至少在产品细节本身在数据库中改变之前不会。
继续我们的产品示例,为了防止我们的页面在每次请求产品时重新查询数据库,我们可能希望在本地缓存中缓存该产品信息,以便我们可以更快地访问它,但是我们仍然需要每次都呈现 HTML 页面。ASP.NET 没有缓存我们需要的数据,而是提供了一种不同的缓存机制——ASP.NET 输出缓存,它自己缓存输出的 HTML。
通过使用输出缓存,ASP.NET 可以缓存呈现的 HTML,因此后续请求将自动接收呈现的 HTML,而无需执行我们的页面代码。输出缓存在 ASP.NET Web 窗体中支持缓存页面,在 ASP.NET MVC 中支持缓存控制器动作。
例如,以下代码使用输出缓存将 ASP.NET MVC 控制器的操作返回的视图缓存 30 秒:
public class ProductController : Controller {
[OutputCache(Duration = 30)]
public ActionResult Index() {
return View();
}
}
如果上面示例中的 index 操作接收到一个 ID 参数,并返回一个显示特定产品信息的视图,我们将需要根据该操作接收到的不同 ID 缓存输出的几个版本。因此,输出缓存不仅支持输出的单个缓存,还支持根据传递给该动作的参数缓存同一动作的不同输出。下面的代码演示如何根据传递给方法的 ID 参数更改缓存输出的操作:
public class ProductController : Controller {
[OutputCache(Duration = 30, VaryByParam = "id")]
public ActionResult Index(int id) {
//Retrieve the matching product and set the model accordingly . . .
return View();
}
}
注意除了根据查询字符串参数改变之外,输出缓存还可以根据请求的 HTTP 头改变缓存的输出,例如 Accept-Encoding 和 Accept-Language 头。例如,如果您的操作根据 Accept-Language HTTP 头返回不同语言的内容,您可以将输出缓存设置为随该头而变化,为每种请求的语言创建不同的输出缓存版本。
如果不同的页面或操作具有相同的缓存设置,可以创建一个缓存配置文件,并使用该配置文件,而不是一遍又一遍地重复缓存设置。缓存配置文件是在 web.config 的 system.web 缓存部分下创建的。例如,以下配置声明了一个我们希望在几个页面中使用的缓存配置文件:
<system.web>
<caching>
<outputCacheSettings>
<outputCacheProfiles>
<add name = "CacheFor30Seconds" duration = "30" varyByParam = "id"/>
</outputCacheProfiles>
</outputCacheSettings>
</caching>
</system.web>
现在,该配置文件可以用于我们的索引操作,而不是重复持续时间和参数:
public class ProductController : Controller {
[OutputCache(CacheProfile = "CacheFor30Seconds")]
public ActionResult Index(int id) {
//Retrieve the matching product and set the model
. . .
return View();
}
}
通过使用 OutputCache 指令,我们还可以在 ASP.NET web 表单中使用相同的缓存配置文件:
<%@ OutputCache CacheProfile = "CacheEntityFor30Seconds" %>
注意默认情况下,ASP.NET 输出缓存机制将缓存的内容保存在服务器的内存中。从 ASP.NET 4 开始,您可以创建自己的输出缓存提供程序来代替默认的输出缓存提供程序。例如,您可以编写自己的自定义提供程序,将输出缓存存储到磁盘。
预编译 ASP.NET 应用
当编译一个 ASP.NET Web 应用项目时,会创建一个单独的程序集来保存所有应用的代码。但是,网页(。aspx)和用户控件(。ascx)不进行编译,并按原样部署到服务器。web 应用第一次启动时(在第一次请求时),ASP.NET 动态编译网页和用户控件,并将编译后的文件放在 ASP.NET 临时文件夹中。这种动态编译增加了首次请求的响应时间,使用户体验到网站加载缓慢。
要解决这个问题,可以使用 Aspnet 编译工具(Aspnet_compiler.exe)预编译 web 应用,包括所有代码、页面和用户控件。在生产服务器上运行 ASP.NET 编译工具可以减少用户第一次请求时的延迟。要运行该工具,请按照下列步骤操作:
- 在生产服务器中打开命令提示符。
- 导航到% windir%\Microsoft。NET 文件夹
- 根据 web 应用的应用池是否配置为支持 32 位应用,导航到框架或框架 64 文件夹(对于 32 位操作系统,框架文件夹是唯一选项)。
- 根据定位到框架版本的文件夹。应用池使用的. NET framework 版本( v2.0.50727 或 v4.0.30319 )。
- 输入以下命令开始编译(用应用的虚拟路径替换 WebApplicationName ):
- aspnet _ compiler . exe-v/web application name
微调 ASP.NET 过程模型
当调用 ASP.NET 应用时,ASP.NET 使用一个工作线程来处理请求。有时,我们的应用中的代码可以自己创建一个新的线程,例如当调用一个服务时,这样就减少了线程池中自由线程的数量。
为了防止线程池耗尽,ASP.NET 会自动对线程池进行一些调整,并对在任何给定时间可以执行的请求数量进行一些限制。这些设置由三个主要配置部分控制——system . webprocess model 部分、system.web httpRuntime 部分和 system.netconnection management 部分。
注意httpRuntime 和 connectionManagement 部分可以在应用的 web.config 文件中设置。然而,processModel 部分只能在 machine.config 文件中更改。
processModel 部分控制线程池限制,例如最小和最大工作线程数,而 httpRuntime 部分定义与可用线程相关的限制,例如为了继续处理传入的请求而必须存在的最小可用线程数。connectionManagement 部分控制每个地址的最大传出 HTTP 连接数。
所有的设置都有默认值,但是,由于其中一些值设置得有点低,ASP.NET 包括另一个设置,自动配置设置,它调整一些设置以实现最佳性能。此设置是 processModel 配置部分的一部分,从 ASP.NET 2.0 开始就存在,并自动设置为 true。
自动配置设置控制以下设置(下面的默认值来自 http://support.microsoft.com/?id=821268 的微软知识库文章 KB821268):
- process modelmaxWorkerThreads。将线程池中工作线程的最大数量从核心数的 20 倍更改为核心数的 100 倍。
- 最大线程数。将线程池中 I/O 线程的最大数量从核心数的 20 倍更改为核心数的 100 倍。
- httpRuntime minFreeThreads。将允许执行新请求所需的最小可用线程数从内核数的 8 倍更改为 88 倍。
- httpRuntimeminLocalFreeThreads。将允许执行新的本地请求(来自本地主机)所需的最小可用线程数从内核数的 4 倍更改为 76 倍。
- 连接管理 maxConnections。将最大并发连接数从核心数的 10 倍更改为 12 倍。
尽管设置上述缺省值是为了获得优化的性能,但是在某些情况下,为了获得更好的性能,您可能需要更改这些缺省值,这取决于您在 web 应用中遇到的场景。例如,如果您的应用调用服务,您可能需要增加最大并发连接数,以允许更多的请求同时连接到后端服务。以下配置显示了如何增加最大连接数:
<configuration>
<system.net>
<connectionManagement>
<add address = "*" maxconnection = "200" />
</connectionManagement>
</system.net>
</configuration>
在其他情况下,例如,当 web 应用在启动时往往会收到许多请求,或者有突然爆发的请求时,您可能需要更改线程池中的最小工作线程数(您指定的值在运行时乘以计算机上的核心数)。若要执行此更改,请在 machine.config 文件中应用以下配置:
<configuration>
<system.web>
<processModel autoConfig = "true" minWorkerThreads = "10"/>
</system.web>
</configuration>
在您急于增加最小和最大线程的大小之前,请考虑这种变化可能对您的应用产生的副作用:如果您允许太多的请求并发运行,这可能会导致 CPU 的过度使用和高内存消耗,最终会使您的 web 应用崩溃。因此,在更改这些设置后,您必须执行负载测试,以验证计算机可以承受这么多请求。
配置 IIS
作为我们的 web 应用的托管环境,IIS 对它的整体性能有一些影响,例如,IIS 管道越小,每个请求上执行的代码就越少。IIS 中有一些机制可以用来提高我们的应用在延迟和吞吐量方面的性能,还有一些机制,如果调整得当,可以提高我们的应用的整体性能。
输出缓存
我们已经看到 ASP.NET 提供了自己的输出缓存机制,那么为什么 IIS 还需要另一种输出缓存机制呢?答案很简单:我们还想缓存其他类型的内容,不仅仅是 ASP.NET 页面。例如,我们可能希望缓存一组经常被请求的静态图像文件,或者自定义 HTTP 处理程序的输出。为此,我们可以使用 IIS 提供的输出缓存。
IIS 有两种输出缓存机制 :用户态缓存和内核态缓存。
用户模式缓存
就像 ASP.NET 一样,IIS 能够在内存中缓存响应,因此后续请求会自动从内存中得到响应,而无需从磁盘访问静态文件或调用服务器端代码。
要为您的 web 应用配置输出缓存,请打开 IIS 管理器应用,选择您的 web 应用,打开输出缓存功能。一旦打开,点击添加。。。从动作窗格链接添加一个新的缓存规则,或者选择一个现有的规则进行编辑。
要创建一个新的用户态缓存规则,添加一个新规则,输入你想要缓存的文件扩展名,在添加缓存规则对话框中勾选用户态缓存复选框,如图图 11-2 所示。
图 11-2 。“添加缓存规则”对话框
选中该复选框后,您可以选择何时从内存中移除缓存的项目,例如在文件更新后或在内容首次缓存后经过一段时间后。文件更改更适合静态文件,而时间间隔更适合动态内容。通过按下高级按钮,您还可以控制缓存将如何存储不同版本的输出(选项根据查询字符串或 HTTP 头)。
添加缓存规则后,其配置将存储在应用的 web.config 文件中,位于 system.webServer 缓存部分下。例如,将规则设置为缓存。aspx 页,使它们在 30 分钟后过期,并通过接受语言 HTTP 头改变输出,将生成以下配置:
<system.webServer>
<caching>
<profiles>
<add extension = ".aspx" policy = "CacheForTimePeriod" kernelCachePolicy = "DontCache"
duration = "00:00:30" varyByHeaders = "Accept-Language" />
</profiles>
</caching>
</system.webServer>
内核模式缓存
与将缓存内容存储在 IIS 工作进程内存中的用户模式缓存不同,内核模式缓存将缓存内容存储在 HTTP.sys 内核模式驱动程序中。使用内核模式缓存可以提供更快的响应时间,但是并不总是受支持。例如,当请求包含查询字符串或者请求不是匿名请求时,不能使用内核模式缓存。
为内核模式设置缓存规则的方式与用户模式缓存类似。在规则对话框中,选中内核模式缓存复选框,然后选择您希望使用的缓存监控设置。
您可以在同一个规则中同时使用内核模式和用户模式缓存。当两种模式都使用时,首先尝试内核模式缓存。如果不成功,例如,当请求包含查询字符串时,将应用用户模式缓存。
提示当使用内核模式和用户模式设置的时间间隔进行监控时,请确保这两种设置中的时间间隔相同,否则内核模式的时间间隔将用于这两种设置。
应用池配置
应用池控制 IIS 如何创建和维护最终承载我们代码的工作进程。当您安装 IIS 和 ASP.NET 时,根据。NET framework 版本,当您在服务器上安装更多 web 应用时,可以向其中添加新池。创建应用池时,它有一些控制其行为的默认设置。例如,每个应用池在创建时都有一个默认的空闲超时设置,在该设置之后,应用池将关闭。
理解其中一些设置的含义可以帮助您配置应用池的工作方式,这样它将更精确地满足您的应用的需求。
回收利用
通过更改回收设置,您可以控制应用池何时重启工作进程。例如,您可以设置工作进程每隔几个小时回收一次,或者当它超过一定的内存量时回收。如果您的 web 应用随着时间的推移消耗了大量内存(例如由于存储的对象),那么增加回收的次数有助于保持其整体性能。另一方面,如果您的 web 应用运行正常,减少回收次数将防止状态信息的丢失。
提示你可以使用 ASP。NET\Worker Process 重新启动性能计数器,以检查应用池自身回收的次数和回收频率。如果您看到许多没有明显原因的回收,请尝试将结果与应用的内存消耗和 CPU 使用相关联,以验证它没有超过应用池配置中定义的任何阈值。
空闲超时
应用池的默认设置是在 20 分钟不活动后关闭该池。如果您预计会有这样的空闲时间,例如当所有用户都出去吃午饭时,您可能希望增加超时时间,甚至取消它。
处理器关联
默认情况下,应用池配置为使用服务器中所有可用的核心。如果您有任何特殊的后台进程在服务器上运行,需要尽可能多的 CPU 时间,您可以调整池的关联性,只使用特定的核心,为后台进程释放其他核心。当然,这也需要您设置后台进程的亲缘关系,这样它就不会在相同的内核上与工作进程竞争。
网络花园
应用池的默认行为是启动一个工作进程来处理应用的所有请求。如果您的工作进程同时处理几个请求,并且这些请求通过锁定资源来竞争同一资源,您可能会以争用结束,从而导致返回响应的延迟。例如,如果您的应用使用专有的缓存机制,该机制具有防止并发请求将项目插入缓存的锁,则请求将开始一个接一个地同步,从而导致难以检测和修复的延迟。尽管我们有时可以修改代码以减少锁定,但这并不总是可行的。解决这种争用问题的另一种方法是启动多个工作进程,所有工作进程都运行同一个应用,每个工作进程都处理自己的一组请求,从而降低应用中的争用率。
运行同一个 web 应用的多个进程很有用的另一种情况是,当您有一个 64 位 IIS 服务器运行一个 32 位 web 应用时。64 位服务器通常拥有大量内存,但是 32 位应用最多只能使用 2 GB 内存,这通常会导致频繁的 GC 循环,并且可能会导致频繁的应用池回收。通过为一个 32 位 web 应用运行两个或三个工作进程,应用可以更好地利用服务器的可用内存,并减少它所需的 GC 周期和应用池回收的数量。
在 IIS 应用池配置中,您可以设置允许为请求提供服务的工作进程的最大数量。将该值增加到大于 1(这是默认值)将随着请求的到来旋转更多的工作进程,直到达到定义的最大值。拥有多个工作进程的应用池被称为“网络花园”每次从客户端建立连接时,它都被分配给一个工作进程,从现在开始该工作进程为来自该客户端的请求提供服务,从而允许来自多个用户的请求在进程之间得到平衡,有望降低争用率。
请注意,使用网络花园有其缺点。多个工作进程会占用更多内存,它们会阻止使用默认的进程内会话状态,并且当多个工作进程在同一台计算机上运行时,您可能会发现自己要处理本地资源争用问题,例如,如果两个工作进程都试图使用同一个日志文件。
优化网络
即使您编写的代码运行速度很快,并且您有一个高吞吐量的托管环境,web 应用的一个更成问题的瓶颈仍然是客户端的带宽以及客户端通过网络传递的数据量和请求数。有几种技术可以帮助减少请求的数量和响应的大小,其中一些很容易配置 IIS,而另一些则需要在应用代码中多加注意。
应用 HTTP 缓存头
节省带宽的方法之一是确保在一段时间内不会改变的所有内容都缓存在浏览器中。静态内容(如图像、脚本和 CSS 文件)是浏览器缓存的理想选择,动态内容(如。aspx 和。如果内容不经常更新,ashx 文件通常可以被缓存。
为静态内容设置缓存头
静态文件通常用两个缓存头发送回客户端:
- ETag 。IIS 将此 HTTP 头设置为包含根据所请求内容的上次修改日期计算的哈希。对于静态内容,如图像文件和 CSS 文件,IIS 根据文件的最后修改日期设置 ETag。当使用以前缓存的 ETag 值发送后续请求时,IIS 会为请求的文件计算 ETag,如果它与客户端的 ETag 不匹配,则请求的文件会被发回。如果 ETags 匹配,则发送回 HTTP 304(未修改)响应。对于后续请求,缓存的 ETag 的值放在 If-None-Match HTTP 头中。
- 最后修改时间。IIS 将此 HTTP 头设置为所请求文件的最后修改日期。这是一个额外的缓存头,在 IIS 的 ETag 支持被禁用的情况下提供备份。当包含上次修改日期的后续请求被发送到服务器时,IIS 将验证文件的上次修改时间,并决定是否用文件的内容(如果修改时间已更改)或 HTTP 304 响应进行响应。对于后续请求,缓存的 Last-Modified 的值放在 If-Modified-Since HTTP 头中。
如果客户端已经有了最新的版本,这些缓存头将确保内容不会被发送回客户端,但是仍然需要从客户端向服务器发送一个请求,以验证内容没有发生更改。如果您的应用中有静态文件,并且您知道这些文件在接下来的几周甚至几个月内可能不会更改,例如您公司的徽标或脚本文件,这些文件在应用的下一个版本发布之前不会更改,那么您可能希望设置缓存头,以允许客户端缓存该内容并重用它,而无需在每次请求该内容时向服务器验证该内容是否已更改。这种行为可以通过使用带有 max-age 的 Cache-Control HTTP 头或 Expires HTTP 头来实现。max-age 和 Expires 之间的区别在于,max-age 设置一个滑动的过期值,而 Expires 允许您设置内容将过期的固定时间点(日期+时间)。例如,将 max-age 设置为 3600 将允许浏览器自动使用缓存内容一小时(3600 秒= 60 分钟= 1 小时),而无需向服务器发送验证请求。一旦内容到期,无论是由于滑动窗口到期还是由于固定到期时间的到来,它都会被标记为陈旧。当对陈旧的内容提出新的请求时,浏览器将向服务器发送请求,要求更新的内容。
提示您可以通过使用 HTTP 监控工具(如 Fiddler)并检查哪些请求被发送到服务器,来验证没有请求被发送到缓存内容。如果您注意到发送了一个请求,尽管它应该被缓存,请检查该请求的响应,以验证 max-age / Expires 头是否存在。
将 max-age / Expires 与 ETag / Last-Modified 一起使用,可以确保在内容过期后发送的请求可以返回 HTTP 304 响应,如果服务器上的内容实际上没有更改的话。在这种情况下,响应将包含一个新的 max-age / Expires HTTP 头。
在大多数浏览器中,单击刷新按钮(或按 F5)将通过忽略 max-age / Expires 头来强制浏览器刷新缓存,并发送对缓存内容的请求,即使内容尚未过期。如果适用的话,请求仍然具有 If-Modified-Since/If-None-Match 头,这样,如果内容仍然是最新的,服务器就可以返回 304 响应。
若要设置 max-age,请将以下配置添加到 web.config 文件中:
<system.webServer>
<staticContent>
<clientCache cacheControlMode = "UseMaxAge" cacheControlMaxAge = "0:10:00" />
</staticContent>
</system.webServer>
上述配置将导致为静态内容发送的所有响应的 Cache-Control HTTP 头的 max-age 属性设置为 600 秒。
若要使用 Expires 标头,请更改 clientCache 元素配置,如下例所示:
<system.webServer>
<staticContent>
<clientCache cacheControlMode = "UseExpires" httpExpires = "Wed, 11 Jul 2013 6:00:00 GMT"/>
</staticContent>
</system.webServer>
上述配置将使所有静态内容在 2013 年 7 月 11 日早上 6 点过期。
如果您希望为不同的内容设置不同的最大期限或到期时间,例如为 JavaScript 文件设置固定的到期时间,为图像设置 100 天的滑动窗口,您可以使用位置部分将不同的配置应用于应用的不同部分,如以下示例所示:
<location path = "Scripts">
<system.webServer>
<staticContent>
<clientCache cacheControlMode = "UseExpires" httpExpires = "Wed, 11 Jul 2013 6:00:00 GMT" />
</staticContent>
</system.webServer>
</location>
<location path = "Images"> <system.webServer>
<staticContent>
<clientCache cacheControlMode = "UseMaxAge" cacheControlMaxAge = "100.0:00:0" />
</staticContent>
</system.webServer>
</location>
注意您必须使用完全格式化的日期和时间来设置 Expires 标头。此外,根据 HTTP 规范,Expires 头的日期不能超过当前日期的一年。
为动态内容设置缓存头
静态文件有修改日期,可以用来验证缓存的内容是否已经更改。但是,动态内容没有修改日期,因为每次请求动态内容时,都会重新创建它,并且它的修改日期实际上是当前日期,因此 ETag 和 Last-Modified 之类的头与处理动态内容无关。
话虽如此,如果您查看一个动态页面的内容,您可能会找到一种方法来表示该内容的修改日期,或者可能会为它计算一个 ETag。例如,如果发送一个从数据库中检索产品信息的请求,product 表可能会包含一个可用于设置 Last-Modified 标题的 last update date 列。如果数据库表没有 last update 列,您可以尝试从实体的字段中计算 MD5 散列,并将 ETag 设置为结果。当随后的请求被发送到服务器时,服务器可以重新计算实体的 MD5 散列,并且如果没有字段被改变,则 ETags 将是相同的,并且服务器可以返回 HTTP 304 响应。
例如,以下代码将动态页中的上次修改的缓存头设置为产品的上次更新日期:
Response.Cache.SetLastModified(product.LastUpdateDate);
如果没有最后更新日期,可以将 ETag 设置为由实体属性计算的 MD5 哈希,如以下代码所示:
Response.Cache.SetCacheability(HttpCacheability.ServerAndPrivate);
//Calculate MD5 hash
System.Security.Cryptography.MD5 md5 = System.Security.Cryptography.MD5.Create();
string contentForEtag = entity.PropertyA + entity.NumericProperty.ToString();
byte[] checksum = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(contentForEtag));
//Create an ETag string from the hash.
//ETag strings must be surrounded with double quotes, according to the standard
string etag = "\"" + Convert.ToBase64String(checksum, 0, checksum.Length) + "\"";
Response.Cache.SetETag(etag);
注意在 ASP.NET,请求的默认可缓存模式阻止使用 ETags。为了支持 ETags,我们需要将 cacheability 模式更改为 ServerAndPrivate,允许在服务器端和客户端缓存内容,但不能在共享机器(如代理)上缓存。
当接收到包含 ETag 的请求时,您可以将计算出的 ETag 与浏览器提供的 ETag 进行比较,如果它们匹配,则使用 304 响应进行响应,如以下代码所示:
if (Request.Headers["If-None-Match"] == calculatedETag) {
Response.Clear();
Response.StatusCode = (int)System.Net.HttpStatusCode.NotModified; Response.End();
}
如果您对动态内容的生命周期有任何假设,也可以将值应用到 max-age 或 Expires 头。例如,如果您假设不再生产的产品将不会被更改,您可以将为该产品返回的页面设置为一年后过期,如下所示:
if (productIsDiscontinued)
Response.Cache.SetExpires(DateTime.Now.AddYears(1));
您也可以使用 Cache-Control max-age 头进行同样的操作:
if (productIsDiscontinued)
Response.Cache.SetMaxAge(TimeSpan.FromDays(365));
您可以在。aspx 文件作为输出缓存指令。例如,如果产品页面中显示的产品信息可以在客户端缓存 10 分钟(600 秒),则可以将产品页面的输出缓存指令设置为:
<%@ Page . . . %>
<%@ OutputCache Duration = "600" Location = "Client"%>
使用 OutputCache 指令时,指定的持续时间以 max-age 和 expires 的形式输出到响应的 HTTP 头中(expires 从当前日期开始计算)。
打开 IIS 压缩
除了多媒体文件(声音、图像和视频)和二进制文件,如 Silverlight 和 Flash 组件,从我们的 web 服务器返回的大多数内容都是基于文本的,如 HTML、CSS、JavaScript、XML 和 JSON。通过使用 IIS 压缩,这些文本响应的大小可以缩小,从而允许使用较小的有效负载进行更快的响应。使用 IIS 压缩,响应的大小可以减少到原始大小的 50–60 %,有时甚至更多。IIS 支持两种类型的压缩,静态和动态。要使用 IIS 压缩,确保首先安装静态和动态压缩 IIS 组件 。
静态压缩
在 IIS 中使用静态压缩时,压缩的内容存储在磁盘上,因此对于后续的资源请求,将返回已经压缩的内容,而无需再次执行压缩。通过只压缩一次内容,您付出了磁盘空间的代价,但减少了 CPU 的使用和延迟,这通常是使用压缩的结果。
静态压缩对于通常不改变(因此是“静态的”)的文件很有用,例如 CSS 文件、JavaScript 文件,但是即使原始文件改变了,IIS 也会注意到这种改变,并且会重新压缩更新的文件。
请注意,压缩最适合文本文件(。htm,。txt,。css)甚至二进制文件,如 Microsoft Office 文档(。医生。xsl),但是对于已经压缩的文件,比如图像文件(。jpg,。png)和压缩的 Microsoft Office 文档(。docx,。xslx)。
动态压缩
当使用动态压缩时,IIS 在每次请求资源时执行压缩,而不存储压缩后的内容。这意味着每次请求资源时,在发送到客户端之前都会对其进行压缩,这会导致 CPU 使用量和压缩过程中的一些延迟。因此,动态压缩更适合经常变化的内容,如 ASP.NET 页面。
由于动态压缩会增加 CPU 使用率,建议您在打开压缩后检查 CPU 利用率,以验证它不会给 CPU 带来太多压力。
配置压缩
使用压缩需要做的第一件事是启用静态压缩和/或动态压缩。要在 IIS 中启用压缩,打开 IIS 管理器应用,选择您的机器,点击压缩选项,选择您希望使用的压缩特性,如图图 11-3 所示:
图 11-3 。在 IIS 管理器应用中启用动态和静态压缩
您还可以使用压缩对话框来设置静态压缩设置,例如存储缓存内容的文件夹,以及符合压缩条件的最小文件大小。
在选择了哪些压缩类型是活动的之后,您可以继续选择哪些 MIME 类型将被静态压缩,哪些将被动态压缩。不幸的是,IIS 不支持从 IIS 管理器应用中更改这些设置,所以您需要在 IIS 配置文件 applicationHost.config 中手动更改它,该文件位于% windir % \ System32 \ inetsrv \ config文件夹中。打开文件并搜索< httpCompression >部分,您应该已经看到为静态压缩定义的几种 MIME 类型和为动态压缩定义的几种其他类型。除了已经指定的 MIME 类型,您还可以添加在 web 应用中使用的其他类型。例如,如果您有返回 JSON 响应的 AJAX 调用,您可能希望为这些响应添加动态压缩支持。以下配置显示了如何对 JSON 进行动态压缩支持(为简洁起见,删除了现有内容):
<httpCompression>
<dynamicTypes>
<add mimeType = "application/json; charset = utf-8" enabled = "true" />
</dynamicTypes>
</httpCompression>
注意在向列表中添加新的 MIME 类型后,建议您通过使用 HTTP 嗅探工具(如 Fiddler)检查响应来验证压缩确实在工作。压缩响应应该有内容编码的 HTTP 头,并且应该设置为 gzip 或 deflate。
IIS 压缩和客户端应用
为了让 IIS 压缩传出的响应,它需要知道客户端应用可以处理压缩的响应。因此,当客户端应用向服务器发送请求时,它需要添加 Accept-Encoding HTTP 头,并将其设置为 gzip 或 deflate。
大多数已知的浏览器会自动添加这个头,所以当使用带有 web 应用或 Silverlight 应用的浏览器时,IIS 会以压缩的内容作为响应。然而,在。NET 应用中,当发送 HttpWebRequest 类型的 HTTP 请求时,Accept-Encoding 标头不会自动添加,您需要手动添加它。此外,HttpWebRequest 不会尝试解压缩响应,除非它被设置为期望压缩的响应。例如,如果您正在使用 HttpWebRequest 对象,您将需要添加以下代码,以便能够接收和解压缩压缩的响应:
var request = (HttpWebRequest)HttpWebRequest.Create(uri);
request.Headers.Add(HttpRequestHeader.AcceptEncoding, "gzip,deflate");
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
其他 HTTP 通信对象,如 ASMX web 服务代理或 WebClient 对象,也支持 IIS 压缩,但需要手动配置以发送标头并解压缩响应。至于基于 HTTP 的 WCF 服务,在 WCF 4 之前。使用服务引用或通道工厂的. NET 客户端不支持 IIS 压缩。从 WCF 4 开始,自动支持 IIS 压缩,包括发送报头和解压缩响应。
缩小和捆绑
使用 web 应用时,您经常会使用使用多个 JavaScript 和 CSS 文件的页面。当一个页面有几个到外部资源的链接时,在浏览器中加载该页面就变成了一个冗长的操作,因为用户通常需要等到该页面及其所有相关的样式和脚本都被下载并解析之后。使用外部资源时,我们面临两个问题:
- 浏览器需要发送并等待响应的请求数。请求越多,浏览器发送所有请求所需的时间就越长,因为浏览器受到它们可以与单个服务器的并发连接数的限制(例如,在 IE 9 中,每台服务器的并发请求数是 6)。
- 响应的大小,这会影响浏览器下载所有响应的总时间。响应越大,浏览器下载响应所需的时间就越长。如果达到最大并发请求数,这也可能会影响浏览器开始发送新请求的能力。
为了解决这个问题,我们需要一种技术,既能降低响应的大小,又能减少请求(以及响应)的数量。在 ASP.NET MVC 4 和 ASP.NET 4.5 中,这种技术现在被内置到框架中,被称为“捆绑和缩小”
Bundling 指的是将一组文件捆绑到一个 URL 中的能力,该 URL 在被请求时将所有文件连接起来作为一个响应返回,而 minification 指的是通过删除空格来减小样式或脚本文件的大小,对于脚本文件,重命名变量和函数,以便它们使用更少的字符,从而占用更少的空间。
与压缩一起使用缩小可以显著减小响应的大小。例如,缩小前的 jQuery 1.6.2 脚本文件的大小是 240 kb。压缩后,文件大小约为 68 kb。原始文件的缩小版本为 93 kb,比压缩版本稍大,但是在对缩小的文件应用压缩后,大小下降到只有 33 kb,大约是原始文件大小的 14%。
要创建一个缩小的包,首先要安装 Microsoft。AspNet.Web.Optimization NuGet 包,并添加对系统的引用。Web.Optimization 汇编。添加之后,您可以使用 BundleTable 静态类为脚本和样式创建新的包。应该在加载页面之前设置绑定,因此应该在 Application_Start 方法中的 global.asax 中放置绑定代码。例如,下面的代码创建了一个名为 MyScripts 的包(可从虚拟包文件夹中访问),其中包含三个将被自动缩小的脚本文件:
protected void Application_Start() {
Bundle myScriptsBundle = new ScriptBundle("∼/bundles/MyScripts").Include(
"∼/Scripts/myCustomJsFunctions.js",
"∼/Scripts/thirdPartyFunctions.js",
"∼/Scripts/myNewJsTypes.js");
BundleTable.Bundles.Add(myScriptsBundle);
BundleTable.EnableOptimizations = true;
}
注默认情况下,只有当 web 应用的编译模式设置为发布时,捆绑和缩小才起作用。为了在调试模式下启用绑定,我们将 EnableOptimizations 设置为 true。
要使用已创建的包,请在页面中添加以下脚本行:
<% = Scripts.Render("∼/bundles/MyScripts") %>
当页面被渲染时,上面的行将被一个指向包的< script >标签所替换,例如上面的行可能被翻译成下面的 HTML:
<script src = "/bundles/MyScript?v = XGaE5OlO_bpMLuETD5_XmgfU5dchi8G0SSBExK294I41"
type = "text/javascript" > </script>
默认情况下,捆绑包和缩小框架将响应设置为一年后过期,因此捆绑包将保留在浏览器的缓存中,并从缓存中提供服务。为了防止包变得陈旧,每个包都有一个令牌,放置在 URL 的查询字符串中。如果从捆绑包中删除了任何文件,如果添加了新文件,或者如果捆绑包中的文件发生了更改,令牌将会更改,下一个对页面的请求将会生成一个具有不同令牌的不同 URL,从而使浏览器请求新的捆绑包。
以类似的方式,我们可以为 CSS 文件创建一个包:
Bundle myStylesBundle = new StyleBundle("∼/bundles/MyStyles")
.Include("∼/Styles/defaultStyle.css",
"∼/Styles/extensions.css",
"∼/Styles/someMoreStyles.js");
BundleTable.Bundles.Add(myStylesBundle);
并在页面中使用包:
<% = Styles.Render("∼/bundles/MyStyles") %>
这将呈现一个< link >元素:
<link href = "/bundles/MyStyles?v = ji3nO1pdg6VLv3CVUWntxgZNf1zRciWDbm4YfW-y0RI1"
rel = "stylesheet" type = "text/css" />
捆绑和缩小框架还支持定制转换,允许创建专门的转换类,例如为 JavaScript 文件创建您自己的定制缩小。
使用内容交付网络(cdn)
与 web 应用相关的性能问题之一是通过网络访问资源所涉及的延迟。使用与 web 服务器相同的本地网络的最终用户通常有很好的响应时间,但是一旦您的 web 应用走向全球,来自世界各地的用户通过 Internet 访问它,远处的用户,例如来自其他大洲的用户,可能会由于网络问题而遭受更长的延迟和更慢的带宽。
位置问题的一个解决方案是将 web 服务器的多个实例分散在不同的位置,地理上是分散的,因此最终用户在地理上总是靠近其中一个服务器。当然,这产生了一个整体的管理问题,因为您将需要一直复制和同步服务器,并且可能指示每个最终用户根据他们在世界上的位置使用不同的 URL。
这就是内容交付网络(cdn)的用武之地。CDN 是放置在全球不同位置的 web 服务器的集合,允许最终用户始终接近您的 web 应用的内容。使用 CDN 时,您实际上在世界各地使用相同的 CDN 地址,但您所在地区的本地 DNs 会将该地址转换为离您最近的实际 CDN 服务器。各种互联网公司,如微软、亚马逊和 Akamai 都有自己的 cdn,你可以付费使用。
以下场景描述了 CDN 的常见用途:
- 你设置好你的 CDN,把它指向原始内容所在的地方。
- 最终用户第一次通过 CDN 访问内容时,本地 CDN 服务器会连接到您的 web 服务器,从中提取内容,将其缓存在 CDN 服务器中,然后将内容返回给最终用户。
- 对于后续请求,CDN 服务器会返回缓存的内容,而无需联系您的 web 服务器,从而实现更快的响应时间和可能更快的带宽。
注意除了更快地为您的最终用户提供服务,CDN 的使用还减少了您的 web 服务器需要处理的静态内容的请求数量,使其能够将大部分资源用于处理动态内容。
要完成第一步,您需要选择您希望使用的 CDN 提供商,并按照提供商的指示配置您的 CDN。一旦有了 CDN 的地址,只需更改静态内容(图像、样式、脚本)的链接,使其指向 CDN 地址。例如,如果您将静态内容上传到 Windows Azure blob 存储,并向 Windows Azure CDN 注册您的内容,您可以更改页面中的 URL 以指向 CDN,如下所示:
<link href = "http://az18253.vo.msecnd.net/static/Content/Site.css
" rel = "stylesheet" type = "text/css" />
出于调试目的,您可以用一个变量替换静态 URL,该变量允许您控制是使用本地地址还是 CDN 的地址。例如,以下 Razor 代码通过在 Url 前添加 CDN 地址来构造 URL,该地址由来自 web.config 文件的 CdnUrl 应用设置提供:
@using System.Web.Configuration
<script src = "@WebConfigurationManager.AppSettings["CdnUrl"]/Scripts/jquery-1.6.2.js"
type = "text/javascript" > </script>
调试 web 应用时,将 CdnUrl 更改为空字符串,以从本地 web 服务器获取内容。
扩展 ASP.NET 应用
因此,您已经通过整合您在这里学到的所有内容提高了 web 应用的性能,甚至可能应用了一些其他技术来提高性能,现在您的应用工作得非常好,并且尽可能地进行了优化。您将应用投入生产,最初几周一切都很好,但随后更多的用户开始使用该网站,每天都有更多的新用户加入,增加了您的服务器必须处理的请求数量,突然,您的服务器开始阻塞。开始时,请求花费的时间可能比平时长,您的工作进程开始使用更多的内存和 CPU,最终请求超时,HTTP 500(“内部服务器错误”)消息填满了您的日志文件。
出了什么问题?您应该再次尝试提高应用的性能吗?更多的用户会加入进来,你也会处于同样的情况。你应该增加机器的内存,还是增加更多的 CPU?单台机器上的纵向扩展是有限度的。是时候面对现实了,您需要扩展到更多服务器。
向外扩展 web 应用是一个自然的过程,在 web 应用生命周期的某个时刻必然会发生。一台服务器可以容纳几十甚至几千个并发用户,但它无法长时间处理这种压力。会话状态填满了服务器的内存,由于没有更多的线程可用,线程变得饥饿,过于频繁的上下文切换最终会增加延迟并降低单个服务器的吞吐量。
向外扩展
从架构的角度来看,扩展并不困难:只需再买一两台(或十台)机器,将服务器放在负载均衡器后面,从那以后,一切都应该没问题了。问题是通常没那么容易。
开发人员在扩展时面临的主要问题之一是如何处理服务器关联 。例如,当您使用单个 web 服务器时,用户的会话状态保存在内存中。如果添加另一个服务器,如何使这些会话对象对它可用?您将如何同步服务器之间的会话?一些 web 开发人员倾向于通过将状态保存在服务器中并在客户端和服务器之间建立关联来解决这个问题。一旦客户端通过负载均衡器连接到其中一个服务器,负载均衡器将从该点开始,继续将该客户端的所有请求发送到同一个 web 服务器,这也称为“粘性”会话。使用粘性会话是一种变通方法,而不是解决方案,因为它不能让您真正平衡服务器之间的工作。很容易出现这样的情况,其中一个服务器处理太多的用户,而其他服务器根本不处理请求,因为它们所有的客户端都已经断开连接。
因此,良好可伸缩性的真正解决方案是不依赖于机器的内存,无论是关于您为用户保存的状态,还是存储在内存中以提高性能的缓存。为什么在扩展时在机器上存储缓存会有问题?想象一下,当用户发送请求导致缓存更新时会发生什么:收到请求的服务器将更新其内存缓存,但其他服务器不知道这一变化,如果他们也有缓存对象的副本,它将变得陈旧并导致应用范围内的不一致。解决这个问题的方法之一是在服务器之间同步缓存的对象。尽管这是可能的,但是这种解决方案给你的 web 应用的架构增加了另一层复杂性,更不用说你的服务器之间会有大量的 chatter。
ASP.NET 标度机制
向外扩展到多台服务器需要进程外状态管理。ASP.NET 有两个内置的进程外机制 用于状态管理:
- 国服。状态服务是一种 Windows 服务,为多台计算机提供状态管理。安装时会自动安装此服务。NET framework,但默认情况下是关闭的。您可以简单地选择哪台机器将运行状态服务,并配置所有其他机器来使用它。尽管状态服务允许几台机器使用同一个会话存储,但它不提供持久性,因此如果托管服务的机器发生问题,您的 web 场的整个会话状态将会丢失。
- SQL Server 。ASP.NET 支持在 SQL Server 中存储会话状态。在 SQL Server 中存储状态提供了与状态服务相同的共享能力,但它还支持状态的持久性,因此即使您的 web 服务器出现故障,如果 SQL Server 出现故障,也可以恢复状态信息。
对于缓存,大多数情况下的解决方案是使用分布式缓存机制,如微软的 AppFabric Cache、NCache 或 Memcached,这是一种开源的分布式缓存。使用分布式缓存,您可以将几个服务器的内存合并到一个分布式内存中,用作缓存数据的缓存存储。分布式缓存提供了位置抽象,因此您不需要知道每个数据的位置;通知服务,因此您可以知道何时发生了变化;高可用性,确保即使其中一个缓存服务器出现故障,数据也不会丢失。
一些分布式缓存,如 AppFabric Cache 和 Memcached,也有用于 ASP.NET 的自定义会话状态和缓存提供程序。
跳出陷阱
虽然与性能无关,但这是一个很好的地方,可以提一下在扩展 web 应用时应该注意的一些其他问题。web 应用中的某些部分需要使用特殊的安全密钥来生成唯一的标识符,以防止篡改和欺骗 web 应用。例如,在创建窗体身份验证 cookies 和加密视图状态数据时,会使用唯一密钥。默认情况下,每次启动应用池时,都会生成 web 应用的安全密钥。对于单个服务器来说,这可能不是问题,但是当您将您的服务器扩展到多个服务器时,这将会带来问题,因为每个服务器都有自己唯一的键。考虑下面的场景:一个客户机向服务器 A 发送一个请求,并得到一个用服务器 A 的惟一密钥签名的 cookie,然后这个客户机用它以前收到的 cookie 向服务器 B 发送一个新的请求。因为服务器 B 有一个不同的唯一密钥,所以对 cookie 内容的验证将失败,并返回一个错误。
您可以通过在 web.config 中配置 machineKey 部分来控制这些密钥在 ASP.NET 中的生成方式。当将 web 应用扩展到多个服务器时,您需要通过将计算机密钥硬编码到应用的配置中,将它配置为在所有服务器中使用相同的预生成密钥。
与向外扩展和使用唯一键相关的另一个问题是加密 web.config 文件中的节的能力。当应用部署到生产服务器时,web.config 文件中的敏感信息通常会被加密。例如,可以对 connectionString 部分进行加密,以防止数据库的用户名和密码被发现。您可以生成一个加密的 web.config 文件并将其部署到所有服务器上,而不是在部署后在每台服务器上单独加密 web.config 文件,这样会使部署过程变得繁琐。要做到这一点,您只需创建一个 RSA 密钥容器,并在所有 web 服务器中导入一次。
注要了解更多关于生成机器密钥并将其应用到应用配置的信息,请查阅微软知识库文档 kb 312906(support.microsoft.com/?id=312906
)。有关生成 RSA 密钥容器的更多信息,请阅读“导入和导出受保护的配置 RSA 密钥容器”MSDN 文章(msdn.microsoft.com/library/yxw286t2
)。
摘要
在这一章的开始,我们讨论了一个 web 应用的整体性能不仅由你的代码控制,也由管道的不同部分控制。本章一开始,我们研究了一些测试和分析工具,它们可以帮助您定位 web 应用中的瓶颈。通过适当的测试,并使用监控和分析工具,您可以轻松地跟踪问题,并显著提高您的 web 应用的性能。从那里,我们检查了管道,确定了可以修改的不同部分,使您的应用工作得更快、更智能,或者提供更小的有效负载,以便可以更快地发送它们。阅读完本章后,您现在已经意识到小的变化(如正确使用客户端缓存)如何有助于减少服务器必须处理的请求数量,解决许多应用面临的一些瓶颈问题。
在本章的后面,我们意识到单个服务器无法处理所有的客户端请求,因此提前计划扩展并应用可扩展的解决方案,如分布式缓存和进程外状态管理,将使您在一个服务器不够用时更容易扩展。最后,在这一章中,我们只探讨了改进 web 应用服务器端的不同技术,剩下的另一方面留给你去探索——客户端。
这是这本书的最后一章。在这 11 章中,您已经了解了如何测量和提高应用性能,如何进行并行化。NET 代码并在 GPU 上运行您的算法。NET 类型系统和垃圾收集器,如何明智地选择收集以及何时实现您自己的收集,甚至如何使用最新和最强大的处理器功能来为您的 CPU 受限的软件增加额外的性能。感谢您跟随我们踏上这一旅程,并祝您好运,提高您应用的性能!