.NET 应用程序的性能提示和技巧
伊曼纽尔·尚策
Microsoft Corporation
2001 年 8 月
总结: 本文适用于想要调整其应用程序以在托管环境中实现最佳性能的开发人员。 针对数据库、Windows 窗体和 ASP 应用程序,以及特定于语言的 Microsoft Visual Basic 和托管 C++ 提示,介绍了示例代码、说明和设计指南。 (25 个打印页)
目录
概述
所有应用程序的性能提示
有关数据库访问的提示
ASP.NET 应用程序的性能提示
有关在 Visual Basic 中移植和开发的提示
有关在托管 C++ 中移植和开发的提示
其他资源
附录:虚拟调用和分配成本
概述
本白皮书旨在作为编写适用于 .NET 的应用程序的开发人员的参考,并寻求各种方法来提高性能。 如果你是 .NET 新手的开发人员,则应熟悉平台和所选语言。 本文严格基于该知识,并假定程序员已经了解足够的知识来运行程序。 如果要将现有应用程序移植到 .NET,值得在开始移植之前阅读本文档。 此处的一些提示在设计阶段非常有用,并提供在开始移植之前应注意的信息。
本文分为多个部分,其中提示按项目和开发人员类型进行组织。 第一组提示是使用任何语言进行写作的必读内容,其中包含可帮助你在公共语言运行时 (CLR) 上任何目标语言的建议。 后面有一个相关部分,其中包含特定于 ASP 的提示。 第二组提示按语言进行组织,涉及有关使用托管 C++ 和 Microsoft® Visual Basic® 的特定提示。
由于计划限制,版本 1 (v1) 运行时必须首先面向最广泛的功能,然后再处理特殊情况优化。 这会导致出现一些性能成为问题的情况。 因此,本文介绍了几个旨在避免这种情况的提示。 这些提示在 vNext) (的下一个版本中不相关,因为这些情况经过系统地识别和优化。 当我们去时,我会指出他们,由你决定它是否值得努力。
所有应用程序的性能提示
使用任何语言的 CLR 时,有一些提示需要记住。 这些内容与每个人相关,在处理性能问题时应成为第一道防线。
引发的异常更少
引发异常可能非常昂贵,因此请确保不要引发大量异常。 使用 Perfmon 查看应用程序引发的异常数。 你可能会惊讶地发现,应用程序的某些区域引发的异常比预期多。 为了提高粒度,还可以使用性能计数器以编程方式检查异常数。
查找和设计大量异常的代码可以带来不错的性能。 请记住,这与 try/catch 块无关: 仅在引发实际异常时才会产生费用。 可以根据需要使用任意数量的 try/catch 块。 无端使用异常会丢失性能。 例如,应远离将异常用于控制流的操作。
下面是异常开销的简单示例:我们只需运行 For 循环,生成数千个或异常,然后终止。 尝试注释掉 throw 语句以查看速度差异:这些异常会导致巨大的开销。
public static void Main(string[] args){
int j = 0;
for(int i = 0; i < 10000; i++){
try{
j = i;
throw new System.Exception();
} catch {}
}
System.Console.Write(j);
return;
}
- 小心! 运行时可能会自行引发异常! 例如, Response.Redirect () 引发 ThreadAbort 异常。 即使未显式引发异常,也可以使用可以引发异常的函数。 请确保检查 Perfmon 获取真实故事,并确保调试器检查源。
- 对于 Visual Basic 开发人员:Visual Basic 默认启用 int 检查,以确保溢出和除零引发异常。 你可能想要关闭此功能以提高性能。
- 如果使用 COM,应记住 HRESULTS 可以作为异常返回。 请确保仔细跟踪这些内容。
进行区块呼叫
区块调用是执行多个任务的函数调用,例如初始化对象的多个字段的方法。 这是针对聊天调用进行查看的,这些调用执行非常简单的任务,需要多次调用才能完成 (例如使用不同的调用) 设置对象的每个字段。 请务必跨开销高于简单 AppDomain 方法调用的方法进行区块式调用,而不是聊天调用。 P/Invoke、互操作和远程处理调用都会产生开销,你希望谨慎使用它们。 在上述每种情况下,都应尝试设计应用程序,使其不依赖于会产生大量开销的小型频繁调用。
每当从非托管代码调用托管代码时,将发生转换,反之亦然。 运行时使程序员能够非常轻松地执行互操作,但这需要以性能为代价。 发生转换时,需要执行以下步骤:
- 执行数据封送处理
- 修复调用约定
- 保护被调用方保存的寄存器
- 切换线程模式,以便 GC 不会阻止非托管线程
- 在调用托管代码时建立异常处理框架
- 控制线程 (可选)
若要加快转换时间,请尝试尽可能使用 P/Invoke。 如果需要数据封送处理,开销只有 31 条指令加上封送的成本,否则只有 8 条。 COM 互操作的成本要高得多,需要多达 65 条指令。
数据封送并不总是成本高昂。 基元类型几乎完全不需要封送处理,并且具有显式布局的类也很便宜。 真正的减速发生在数据转换期间,例如从 ASCI 到 Unicode 的文本转换。 确保仅在需要时才转换通过托管边界传递的数据:可能只需在整个程序中就特定数据类型或格式达成一致,就可以减少大量封送处理开销。
以下类型称为 blittable,这意味着可以直接跨托管/非托管边界复制它们,而无需封送:sbyte、字节、short、ushort、int、uint、long、ulong、float 和 double。 可以免费传递这些类型,以及 ValueTypes 和包含 blittable 类型的单维数组。 可以在 MSDN 库上进一步了解 封送的坚韧细节 。 如果你花了很多时间封送,我建议仔细阅读它。
使用 ValueTypes 进行设计
如果可以,并且不执行大量装箱和拆箱操作,请使用简单的结构。 下面是一个演示速度差异的简单示例:
using System;
namespace ConsoleApplication{
public struct foo{
public foo(double arg){ this.y = arg; }
public double y;
}
public class bar{
public bar(double arg){ this.y = arg; }
public double y;
}
class Class1{
static void Main(string[] args){
System.Console.WriteLine("starting struct loop...");
for(int i = 0; i < 50000000; i++)
{foo test = new foo(3.14);}
System.Console.WriteLine("struct loop complete.
starting object loop...");
for(int i = 0; i < 50000000; i++)
{bar test2 = new bar(3.14); }
System.Console.WriteLine("All done");
}
}
}
运行此示例时,你将看到结构循环快了几个数量级。 但是,在将 ValueTypes 视为对象时,请务必注意使用 ValueTypes。 这会为程序增加额外的装箱和拆箱开销,最终可能会花费你比坚持使用对象时花费更多! 若要了解这一点,请修改上述代码以使用 foos 和柱形数组。 你会发现性能或多或少是相等的。
权衡 ValueType 的灵活性远不如 对象,如果使用不当,最终会损害性能。 你需要非常小心何时以及如何使用它们。
尝试修改上述示例,并将 foos 和条存储在数组或哈希表中。 你将看到速度增益消失,只需 一次 装箱和拆箱操作。
可以通过查看 GC 分配和集合来跟踪装箱和拆箱的量。 这可以在外部使用 Perfmon 或代码中的性能计数器来完成。
请参阅.NET FrameworkRun-Time技术性能注意事项中的 ValueTypes 的深入讨论。
使用 AddRange 添加组
使用 AddRange 添加整个集合,而不是以迭代方式添加集合中的每个项。 几乎所有的 Windows 控件和集合都具有 Add 和 AddRange 方法,并且每个方法都针对不同的目的进行优化。 Add 可用于添加单个项,而 AddRange 具有一些额外的开销,但在添加多个项时会胜出。 下面是支持 Add 和 AddRange 的一些类:
- StringCollection、TraceCollection 等。
- HttpWebRequest
- 用户控件
- ColumnHeader
剪裁工作集
尽量减少用于保持小型工作集的程序集数。 如果你加载整个程序集只是为了使用一种方法,你付出了巨大的成本,几乎没有好处。 查看是否可以使用已加载的代码复制该方法的功能。
跟踪工作集很困难,可能是整篇论文的主题。 下面是一些可帮助你解决的提示:
- 使用 vadump.exe 跟踪工作集。 另一份白皮书介绍了托管环境的各种工具,对此进行了讨论。
- 查看 Perfmon 或性能计数器。 他们可以提供有关所加载的类数或获取 JITed 的方法数量的详细反馈。 你可以获取有关你在加载程序中花费的时间,或者执行时间的百分比在分页中花费的读出。
使用 For 循环进行字符串迭代 - 版本 1
在 C# 中,foreach 关键字 (keyword) 允许遍查列表、字符串等中的项,并针对每个项执行操作。 这是一个非常强大的工具,因为它充当许多类型的常规用途枚举器。 这种通用化的权衡是速度,如果严重依赖字符串迭代,则应改用 For 循环。 由于字符串是简单的字符数组,因此可以使用比其他结构少得多的开销来遍行它们。 在许多情况下,JIT 足够智能, () 优化 For 循环中的边界检查和其他内容,但禁止在 foreach 步行时执行此操作。 最终结果是,在版本 1 中,针对字符串的 For 循环最多比使用 foreach 快五倍。 这将在未来版本中更改,但对于版本 1,这是提高性能的明确方法。
下面是用于演示速度差异的简单测试方法。 尝试运行它,然后删除 For 循环并取消注释 foreach 语句。 在我的计算机上, For 循环花费了大约一秒, foreach 语句大约需要 3 秒。
public static void Main(string[] args) {
string s = "monkeys!";
int dummy = 0;
System.Text.StringBuilder sb = new System.Text.StringBuilder(s);
for(int i = 0; i < 1000000; i++)
sb.Append(s);
s = sb.ToString();
//foreach (char c in s) dummy++;
for (int i = 0; i < 1000000; i++)
dummy++;
return;
}
}
权衡Foreach
的可读性要高得多,将来,对于字符串等特殊情况,它的速度将和 For 循环一样快。 除非字符串操作对你来说是一个真正的性能问题,否则稍微混乱的代码可能不值得。
将 StringBuilder 用于复杂字符串操作
修改字符串时,运行时将创建一个新字符串并返回它,将原始字符串保留为垃圾回收。 大多数情况下,这是一种快速而简单的方法来执行此操作,但当重复修改字符串时,它开始对性能造成负担:所有这些分配最终都会变得昂贵。 下面是一个简单的程序示例,该程序将追加到字符串 50,000 次,后跟一个使用 StringBuilder 对象就地修改字符串的程序。 StringBuilder 代码要快得多,如果运行它们,它会立即变得明显。
|
|
尝试查看 Perfmon,查看在不分配数千个字符串的情况下节省了多少时间。 查看 .NET CLR 内存列表下的“GC 时间百分比”计数器。 还可以跟踪保存的分配数以及集合统计信息。
权衡= 在时间和内存中创建 StringBuilder 对象会产生一些开销。 在具有快速内存的计算机上,如果要执行大约五个操作, StringBuilder 就变得值得。 根据经验法则,我会说 10 个或更多个字符串操作是任何计算机上开销的理由,即使是速度较慢的计算机。
预编译Windows 窗体应用程序
方法在首次使用时是 JITed 的,这意味着,如果应用程序在启动期间执行大量方法调用,则会支付更大的启动费用。 Windows 窗体操作系统中使用大量共享库,启动这些库的开销可能远高于其他类型的应用程序。 虽然并非总是如此,但预编译Windows 窗体应用程序通常会导致性能提升。 在其他情况下,通常最好让 JIT 处理它,但如果你是一名Windows 窗体开发人员,可能需要看看。
Microsoft 允许你通过调用 ngen.exe
来预编译应用程序。 可以选择在安装期间或分发应用程序之前运行ngen.exe。 在安装期间运行 ngen.exe 绝对最有意义,因为可以确保应用程序针对要安装该应用程序的计算机进行优化。 如果在交付程序之前运行ngen.exe,则会将优化限制 为计算机上可用的 优化。 为了让你了解预编译可以带来多大帮助,我在我的计算机上运行了一个非正式测试。 下面是 ShowFormComplex 的冷启动时间,ShowFormComplex 是一个包含大约 100 个控件的 winforms 应用程序。
代码状态 | 时间 |
---|---|
框架 JITed ShowFormComplex JITed |
3.4 秒 |
框架预编译,ShowFormComplex JITed | 2.5 秒 |
框架预编译、ShowFormComplex 预编译 | 2.1 秒 |
每个测试都是在重新启动后执行的。 如前所述,Windows 窗体应用程序使用大量方法,因此预编译在性能方面大有可为。
使用交错数组 — 版本 1
v1 JIT 优化交错数组 (简单的“数组”) 比矩形数组更高效,并且差异非常明显。 下表演示了在 C# 和 Visual Basic 中使用交错数组代替矩形数组所产生的性能提升, (数字越大,) 效果更好:
C# | Visual Basic 7 | |
---|---|---|
作业 (交错) 工作分配 (矩形) |
14.16 8.37 |
12.24 8.62 |
神经网络 (交错) 神经网络 (矩形) |
4.48 3.00 |
4.58 3.13 |
数字排序 (交错) 数字排序 (矩形) |
4.88 2.05 |
5.07 2.06 |
分配基准是一种简单的赋值算法,根据 商业定量决策 制定中的分步指南改编, (Gordon、Pressman 和 Cohn;Prentice-Hall;) 打印出。 神经网络测试在小型神经网络上运行一系列模式,数字排序不言自明。 综合起来,这些基准很好地指示了实际性能。
如你所看到的,使用交错数组可以显著提高性能。 对交错数组所做的优化将添加到 JIT 的未来版本,但对于 v1,可以使用交错数组为自己节省大量时间。
将 IO 缓冲区大小保持在 4KB 到 8KB 之间
对于几乎每个应用程序,4KB 到 8KB 之间的缓冲区可提供最佳性能。 对于非常具体的实例, (加载可预测大小的大型图像(例如) )时,可以从较大的缓冲区中获得改进,但在 99.99% 的情况下,只会浪费内存。 派生自 BufferedStream 的所有缓冲区都允许将大小设置为所需的任何内容,但在大多数情况下,4 和 8 将为你提供最佳性能。
关注异步 IO 机会
在极少数情况下,可以从异步 IO 中受益。 一个示例可能是下载和解压缩一系列文件:你可以从一个流中读取位,在 CPU 上解码它们,然后将其写出到另一个流。 有效使用异步 IO 需要花费大量精力,如果操作不正确,可能会导致性能 损失 。 优点是,正确应用时,异步 IO 可提供多达十倍的性能。
MSDN 库上提供了 使用异步 IO 的程序 的优秀示例。
- 需要注意的一点是,异步调用的安全开销很小:调用异步调用时,将捕获调用方堆栈的安全状态并将其传输到实际执行请求的线程。 如果回调执行大量代码,或者异步调用未过度使用,这可能无关紧要。
数据库访问提示
优化数据库访问的理念是仅使用所需的功能,并围绕“断开连接”方法进行设计:按顺序建立多个连接,而不是长时间打开单个连接。 应将此更改考虑在内,并围绕它进行设计。
Microsoft 建议采用 N 层策略以实现最佳性能,而不是直接进行客户端到数据库连接。 将此视为设计理念的一部分,因为许多技术都经过优化,以利用多疲劳方案。
使用最佳托管提供程序
正确选择托管提供程序,而不是依赖于泛型访问器。 有专为许多不同的数据库编写的托管提供程序,例如 SQL (System.Data.SqlClient) 。 如果在可能使用专用组件时使用更通用的接口(如 System.Data.Odbc),则处理增加的间接级别会丢失性能。 使用最佳提供程序还可以使你讲不同的语言:托管 SQL 客户端对 SQL 数据库使用 TDS,从而显著改进了泛型 OleDb 协议。
在可以时选择数据读取器,以选择数据集
每当不需要保留数据时,都使用数据读取器。 这允许快速读取数据,用户可以根据需要缓存这些数据。 读取器只是一种无状态流,可用于在数据到达时读取数据,然后将其丢弃,而无需将其存储到数据集中以便进行更多导航。 流方法速度更快,开销更少,因为可以立即开始使用数据。 应评估需要相同数据的频率,以确定导航缓存是否对你有意义。 下面是一个小表,演示从服务器拉取数据时,ODBC 和 SQL 提供程序上的 DataReader 和 DataSet 之间的差异 (数字越大,) 更好:
ADO | SQL | |
---|---|---|
数据集 | 801 | 2507 |
DataReader | 1083 | 4585 |
如你所看到的,使用最佳托管提供程序和数据读取器时,可实现最高性能。 当你不需要缓存数据时,使用数据读取器可以为你提供巨大的性能提升。
将 Mscorsvr.dll 用于 MP 计算机
对于独立的中间层和服务器应用程序,请确保 mscorsvr
用于多处理器计算机。 Mscorwks 未针对缩放或吞吐量进行优化,而服务器版本具有多项优化,允许它在多个处理器可用时进行良好缩放。
尽可能使用存储过程
存储过程是高度优化的工具,在有效使用时可带来出色的性能。 设置存储过程以使用数据适配器处理插入、更新和删除操作。 不必解释、编译甚至从客户端传输存储过程,并降低网络流量和服务器开销。 请务必使用 CommandType.StoredProcedure 而不是 CommandType.Text
注意动态连接字符串
连接池是一种有用的方法,可以重复使用多个请求的连接,而不是为每个请求打开和关闭连接的开销。 这是隐式完成的,但 每个唯一连接字符串有一个池。 如果要动态生成连接字符串,请确保每次都使用相同的字符串,以便进行池化。 另请注意,如果正在进行委派,则每个用户将获得一个池。 可以为连接池设置很多选项,并且可以使用 Perfmon 跟踪响应时间、事务数/秒等内容来跟踪池的性能。
关闭不使用的功能
如果不需要自动事务登记,请将其关闭。 对于 SQL 托管提供程序,它通过连接字符串完成:
SqlConnection conn = new SqlConnection(
"Server=mysrv01;
Integrated Security=true;
Enlist=false");
使用数据适配器填充数据集时,如果不必 (请不要获取主键信息,例如,不要设置 MissingSchemaAction.Add with key) :
public DataSet SelectSqlSrvRows(DataSet dataset,string connection,string query){
SqlConnection conn = new SqlConnection(connection);
SqlDataAdapter adapter = new SqlDataAdapter();
adapter.SelectCommand = new SqlCommand(query, conn);
adapter.MissingSchemaAction = MissingSchemaAction.AddWithKey;
adapter.Fill(dataset);
return dataset;
}
避免使用自动生成的命令
使用数据适配器时,请避免自动生成命令。 这些操作需要额外的服务器访问以检索元数据,并提供较低级别的交互控制。 虽然使用自动生成的命令很方便,但值得在性能关键型应用程序中自行执行。
小心 ADO 旧设计
请注意,在适配器上执行命令或调用填充时,将返回查询指定的每条记录。
如果绝对需要服务器游标,则可以通过 t-sql 中的存储过程实现它们。 尽可能避免,因为基于服务器游标的实现的缩放效果不太好。
如果需要,请以无状态和无连接方式实现分页。 可以通过以下方式向数据集添加其他记录:
- 确保 PK 信息存在
- 根据需要更改数据适配器的 select 命令,以及
- 呼叫填充
保持数据集精简
仅将所需的记录放入数据集中。 请记住,数据集将其所有数据存储在内存中,请求的数据越多,通过网络传输所需的时间就越长。
尽可能频繁地使用顺序访问
对于数据读取器,请使用 CommandBehavior.SequentialAccess。 这对于处理 Blob 数据类型至关重要,因为它允许以小区块形式从线路中读取数据。 虽然一次只能处理一段数据,但加载大型数据类型的延迟会消失。 如果不需要一次性处理整个对象,则使用顺序访问将为你提供更好的性能。
ASP.NET 应用程序的性能提示
主动缓存
使用 ASP.NET 设计应用时,请确保在设计时注意缓存。 在操作系统的服务器版本中,有很多选项可用于调整服务器和客户端上的缓存的使用。 ASP 中有多个功能和工具可用于提高性能。
输出缓存 - 存储 ASP 请求的静态结果。 使用 <@% OutputCache %>
指令指定:
- 持续时间 - 缓存中存在时间项
- VaryByParam - 按 Get/Post 参数改变缓存条目
- VaryByHeader - 按 Http 标头改变缓存条目
- VaryByCustom - 按浏览器改变缓存条目
- 重写以随所需值变化:
片段缓存 - 如果无法存储整个页面 (隐私、个性化、动态内容) ,则可以使用片段缓存来存储部分页面,以便以后更快地检索。
) VaryByControl - 根据控件的值改变缓存项
缓存 API - 通过将缓存对象的哈希表保留在内存中 (System.web.UI.caching) ,为缓存提供极其精细的粒度。 它还:
) 包括依赖项 (键、文件、时间)
b) 自动使未使用的项目过期
c) 支持回调
智能缓存可以提供出色的性能,并且请务必考虑需要哪种类型的缓存。 假设一个复杂的电子商务网站,其中包含多个用于登录的静态页面,然后是包含图像和文本的动态生成的大量页面。 你可能想要对这些登录页使用输出缓存,然后对动态页面使用片段缓存。 例如,工具栏可以缓存为片段。 为了获得更好的性能,可以使用缓存 API 缓存网站上经常出现的常用图像和样本文本。 有关使用示例代码) 缓存 (的详细信息,检查 ASP. NET 网站。
仅当需要时才使用会话状态
ASP.NET 的一项极其强大的功能是能够存储用户的会话状态,例如电子商务网站上的购物车或浏览器历史记录。 由于这是默认启用的,因此即使不使用内存,也会在内存中支付费用。 如果不使用会话状态,请将其关闭,并通过向 asp 添加 <@% EnabledSessionState = false %> 来节省开销。 这附带了几个其他选项,这些选项在 ASP. NET 网站上进行了说明。
对于仅读取会话状态的页面,可以选择 EnabledSessionState=readonly。 与完整读/写会话状态相比,这会产生更少的开销,并且当你只需要部分功能并且不想为写入功能付费时,这非常有用。
仅当需要时才使用视图状态
“视图状态”的一个示例可能是用户必须填写的长窗体:如果他们在浏览器中单击“ 返回 ”,然后返回,则表单将保持填充状态。 如果未使用此功能,此状态会消耗内存和性能。 这里最大的性能消耗可能是每次加载页面时都必须通过网络发送往返信号,以便更新和验证缓存。 由于它默认处于打开状态,因此需要指定不希望使用 @% EnabledViewState = false %>的视图状态<。 您应该阅读 有关 ASP.NET 网站上的视图状态的详细信息,以了解您有权访问的其他一些选项和设置。
避免使用 STA COM
单元 COM 旨在处理非托管环境中的线程。 有两种类型的单元 COM:单线程和多线程。 MTA COM 旨在处理多线程处理,而 STA COM 依赖于消息传送系统来序列化线程请求。 托管世界是自由线程的,使用单线程单元 COM 要求所有非托管线程实质上共享单个线程进行互操作。 这会导致 巨大的 性能损失,应尽可能避免。 如果无法将 Apartment COM 对象移植到托管世界,请对使用它们的页面使用 <@%AspCompat = “true” %。> 有关 STA COM 的更详细说明,请参阅 MSDN 库。
批量编译
在将大型页面部署到 Web 之前,始终进行批量编译。 可以通过对每个目录的页执行一个请求并等待 CPU 再次空闲来启动此操作。 这可以防止 Web 服务器陷入编译状态,同时尝试提供输出页面。
删除不必要的 Http 模块
根据使用的功能,从管道中删除未使用或不必要的 http 模块。 回收增加的内存和浪费的周期可以提供较小的速度提升。
避免使用 Autoeventwireup 功能
替代 Page 中的事件,而不是依赖于 autoeventwireup。 例如,尝试重载 public void OnLoad () 方法,而不是编写 Page_Load () 方法。 这允许运行时不必为每个页面执行 CreateDelegate () 。
不需要 UTF 时使用 ASCII 进行编码
默认情况下,ASP.NET 配置为将请求和响应编码为 UTF-8。 如果 ASCII 满足应用程序的所有需求,则消除 UTF 开销可能会返回几个周期。 请注意,这只能在每个应用程序的基础上完成。
使用最佳身份验证过程
有几种不同的方法可以验证用户身份,有些方法比其他方法更昂贵, (成本增加:无、Windows、窗体、Passport) 。 请确保使用最符合你需求的最便宜的一种。
有关在 Visual Basic 中移植和开发的提示
从 Microsoft Visual Basic 6 到®® Microsoft® Visual Basic® 7,性能映射也随之发生了很大变化。 由于 CLR 增加了功能和安全限制,某些函数无法像在 Visual Basic 6 中那样快速运行。 事实上,Visual Basic 7 在几个方面被其前身所困扰。 幸运的是,有两个好消息:
- 大多数最差的减速发生在一次性函数期间,例如首次加载控件。 成本在那里,但你只支付一次。
- 在很多方面,Visual Basic 7 速度更快,这些方面往往位于运行时重复的函数中。 这意味着,权益会随着时间的推移而增长,在一些情况下将超过一次性成本。
大多数性能问题都来自运行时不支持 Visual Basic 6 功能的区域,必须添加它才能在 Visual Basic 7 中保留该功能。 在运行时之外工作的速度较慢,使得某些功能的使用成本要高得多。 好的一面是,你可以避免这些问题,一点点努力。 有两个main方面需要工作来优化性能,而你可以在这里和那里进行一些简单的调整。 综合起来,可帮助你逐步解决性能消耗问题,并利用 Visual Basic 7 中速度更快的函数。
错误处理
第一个问题是错误处理。 这在 Visual Basic 7 中发生了很大变化,并且存在与更改相关的性能问题。 从本质上讲,实现 OnErrorGoto 和 Resume 所需的逻辑非常昂贵。 我建议快速查看代码,并突出显示使用 Err 对象的所有区域或任何错误处理机制。 现在查看其中每个实例,看看是否可以重写它们以使用 try/catch。 许多开发人员会发现,对于大多数此类情况,他们可以轻松地转换为 试用/捕获 ,并且他们应该在程序中看到良好的性能改进。 经验法则是“如果你能轻松看到翻译,就去做”。
下面是一个简单的 Visual Basic 程序示例,该程序使用 On Error Goto 与 try/catch 版本进行比较。
|
|
速度明显提高。 使用 OnErrorGoto 时 SubWithError () 需要 244 毫秒,使用 try/catch 只需 169 毫秒。 第二个函数需要 179 毫秒,而优化版本需要 164 毫秒。
使用早期绑定
第二个问题涉及对象和类型转换。 Visual Basic 6 在后台执行了大量工作来支持对象强制转换,许多程序员甚至不知道这一点。 在 Visual Basic 7 中,这是一个可以挤压大量性能的区域。 编译时,请使用 早期绑定。 这告知编译器仅在显式提及时插入类型强制。 这有两个主要影响:
- 奇怪的错误更易于跟踪。
- 不需要的强制措施被消除,从而大幅提高性能。
使用对象时,如果对象属于其他类型,Visual Basic 将强制你执行该对象(如果未指定)。 这很方便,因为程序员需要担心的代码更少。 缺点是,这些强制可能会执行意外操作,并且程序员无法控制它们。
在某些情况下,你必须使用后期绑定,但大多数情况下,如果你不确定,则可以摆脱早期绑定。 对于 Visual Basic 6 程序员来说,这一开始可能有点尴尬,因为与过去相比,你需要更担心类型。 对于新程序员来说,这应该很容易,熟悉 Visual Basic 6 的人会毫不时地拿起它。
打开选项严格和显式
启用 Option Strict 后,可以保护自己免受无意后期绑定的影响,并强制实施更高级别的编码规则。 有关 Option Strict 存在的限制列表,请参阅 MSDN 库。 需要注意的是,必须显式指定所有收缩类型强制。 但是,这本身可能会发现代码的其他部分,这些部分执行的工作比你之前想象的要多,并且可以帮助你在过程中出现一些 bug。
与 Option Strict 相比,Option Explicit 的限制较少,但它仍会强制程序员在其代码中提供更多信息。 具体而言,必须先声明变量,然后才能使用它。 这会将类型推理从运行时移到编译时。 这种消除检查为你提升性能。
建议从 Option Explicit 开始,然后打开 Option Strict。 这将保护你免受大量编译器错误的影响,并允许你在更严格的环境中逐渐开始工作。 使用这两个选项时,可确保应用程序的最佳性能。
对文本使用二进制比较
比较文本时,请使用二进制比较而不是文本比较。 在运行时,二进制文件的开销要轻得多。
最小化 Format () 的使用
如果可以,请使用 toString () 而不是 format () 。 在大多数情况下,它将提供所需的功能,开销要小得多。
使用 Charw
使用 charw 而不是 char。 CLR 在内部使用 Unicode,如果使用字符,则必须在运行时转换 char
。 这可能会导致大量性能损失,并且使用 charw)
将字符指定为全字长 (可消除此转换。
优化分配
使用 exp += val 而不是 exp = exp + val。 由于 exp
可能任意复杂,这可能会导致大量不必要的工作。 这强制 JIT 评估 exp 的两个副本,很多时候不需要这样做。 第一个语句的优化效果比第二个语句要好得多,因为 JIT 可以避免两次评估 exp 。
避免不必要的间接
使用 byRef 时,传递指针而不是实际对象。 很多时候,这 (副作用函数(例如) )有意义,但你并不总是需要它。 传递指针会产生更多的间接影响,这比访问堆栈上的值要慢。 当你不需要浏览堆时,最好避免它。
将串联放在一个表达式中
如果在多行上有多个串联,请尝试将它们全部粘在一个表达式上。 编译器可以通过就地修改字符串进行优化,从而提升速度和内存。 如果语句拆分为多行,则 Visual Basic 编译器不会生成 Microsoft 中间语言 (MSIL) 以允许就地串联。 请参阅前面讨论的 StringBuilder 示例。
包括 Return 语句
Visual Basic 允许函数返回值,而无需使用 return 语句。 虽然 Visual Basic 7 支持此功能,但显式使用 return 允许 JIT 执行略多一些优化。 如果没有 return 语句,每个函数都会在堆栈上获得多个局部变量,以透明方式支持返回值,而无需关键字 (keyword) 。 保留这些内容会使 JIT 更难进行优化,并可能影响代码的性能。 查看函数并根据需要插入 返回 。 它根本不会更改代码的语义,它可以帮助你从应用程序获得更高的速度。
有关在托管 C++ 中移植和开发的提示
Microsoft 面向一组特定开发人员的托管 C++ (MC++) 。 MC++ 并不是每个作业的最佳工具。 阅读本文档后,你可能会认为 C++ 不是最佳工具,并且权衡成本不值得带来好处。 如果不确定 MC++,则有许多很好的 资源 可帮助你做出决定本部分面向已决定希望以某种方式使用 MC++ 并想要了解 MC++ 性能方面的开发人员。
对于 C++ 开发人员来说,使用托管 C++ 需要做出几个决策。 是否移植了一些旧代码? 如果是这样,是要将整个内容移动到托管空间,还是计划实现包装器? 我将重点介绍“移植所有内容”选项,或者出于此讨论的目的,从头开始编写 MC++,因为这些方案是程序员会注意到性能差异的情况。
托管世界的好处
托管 C++ 最强大的功能是在 表达式级别混合和匹配托管和非托管代码的功能。 没有其他语言允许你执行此操作,如果正确使用,你可以从中获得一些强大的好处。 稍后我将介绍一些示例。
托管的世界也给你巨大的设计胜利,在很多常见问题都为你照顾。 如果需要,可以将内存管理、线程计划和类型强制保留到运行时,使你能够将精力集中在程序需要它的部分上。 使用 MC++,你可以确切地选择要保留多少控件。
MC++ 程序员在编译到 IL 时,能够使用 Microsoft Visual C7® (VC7) 后端,然后在其中使用 JIT。 习惯于使用 Microsoft C++ 编译器的程序员习惯于以闪电般的速度操作。 JIT 被设计成不同的目标,并具有不同的优缺点。 VC7 编译器不受 JIT 时间限制的约束,可以执行 JIT 无法执行的某些优化,例如全程序分析、更积极的内联和注册。 还有一些优化只能在类型安全环境中执行,为速度留出比 C++ 允许的更多空间。
由于 JIT 中的优先级不同,某些操作比以前更快,而其他操作则较慢。 你为安全性和语言灵活性做了一些权衡,其中一些并不便宜。 幸运的是,程序员可以执行一些操作来最大程度地降低成本。
移植:所有 C++ 代码都可以编译为 MSIL
在进一步操作之前,请务必注意,可以将 任何 C++ 代码编译为 MSIL。 一切都会正常工作,但无法保证类型安全性,如果执行大量互操作,则需要支付封送处理罚款。 如果没有获得任何好处,为什么编译为 MSIL 会有所帮助? 在移植大型代码库的情况下,这允许你逐步将代码分块移植。 如果使用 MC++,可以花时间移植更多代码,而不是编写特殊的包装器来将移植的代码和尚未移植的代码粘附在一起,这可能会导致大获成功。 它使移植应用程序成为一个非常干净的过程。 若要详细了解如何将 C++ 编译为 MSIL,请查看 /clr 编译器选项。
但是,简单地将 C++ 代码编译到 MSIL 并不能为你提供托管世界的安全性或灵活性。 你需要使用 MC++ 编写,在 v1 中,这意味着放弃一些功能。 以下列表在当前版本的 CLR 中不受支持,但将来可能支持。 Microsoft 首先选择支持最常见的功能,并且必须削减一些其他功能才能交付。 没有什么可以阻止以后添加它们,但在此期间,你需要执行以下操作:
- 多重继承
- 模板
- 确定性最终确定
如果需要这些功能,始终可以与不安全的代码进行互操作,但需要支付来回封送送数据的性能损失。 请记住,这些功能只能在非托管代码中使用。 托管空间不知道其存在。 如果决定移植代码,请考虑在设计中对这些功能的依赖程度。 在某些情况下,重新设计过于昂贵,需要坚持使用非托管代码。 这是在开始黑客攻击之前应该做出的第一个决定。
MC++ 优于 C# 或 Visual Basic 的优势
MC++ 来自非托管背景,保留了处理不安全代码的大量功能。 MC++ 能够顺利混合托管代码和非托管代码,这为开发人员提供了很大的功能,你可以在编写代码时选择要在渐变上放置的位置。 在一个极端的情况下,可以使用直接、未加用的 C++ 编写所有内容,只需使用 /clr 进行编译。 另一方面,可以将所有内容编写为托管对象,并处理上述语言限制和性能问题。
但是,当你在两者之间选择某个位置时,MC++ 的真正强大功能就来了。 MC++ 允许你通过精确控制何时使用不安全功能来调整托管代码中固有的一些性能影响。 C# 在不安全关键字 (keyword) 中具有一些此功能,但它不是该语言不可或缺的一部分,而且它远不如 MC++有用。 让我们逐步介绍一些示例,其中显示了 MC++ 中可用的更精细粒度,我们将讨论它派上用场的情况。
通用“byref”指针
在 C# 中,只能通过将某个类的某个成员的地址传递给 ref 参数来获取该类的地址。 在 MC++ 中,byref 指针是一类构造。 可以获取数组中间项的地址,并从函数返回该地址:
Byte* AddrInArray( Byte b[] ) {
return &b[5];
}
我们利用此功能通过帮助程序例程返回指向 System.String 中“字符”的指针,甚至可以使用这些指针循环访问数组:
System::Char* PtrToStringChars(System::String*);
for( Char*pC = PtrToStringChars(S"boo");
pC != NULL;
pC++ )
{
... *pC ...
}
还可以使用 MC++ 中的注入执行链接列表遍历,方法是获取在 C#) 中无法执行的“下一个”字段 (地址:
Node **w = &Head;
while(true) {
if( *w == 0 || val < (*w)->val ) {
Node *t = new Node(val,*w);
*w = t;
break;
}
w = &(*w)->next;
}
在 C# 中,不能指向“头”或获取“下一个”字段的地址,因此你已创建一个特殊情况,即插入第一个位置,或者如果“Head”为 null。 此外,必须在代码中一直向前看一个节点。 将此与一个好的 C# 将产生什么比较:
if( Head==null || val < Head.val ) {
Node t = new Node(val,Head);
Head = t;
}else{
// we know at least one node exists,
// so we can look 1 node ahead
Node w=Head;
while(true) {
if( w.next == null || val < w.next.val ){
Node t = new Node(val,w.next.next);
w.next = t;
break;
}
w = w.next;
}
}
用户对装箱类型的访问权限
OO 语言常见的性能问题是装箱和拆箱值所花费的时间。 MC++ 使你能够更好地控制此行为,因此无需动态 (或静态) 打开框来访问值。 这是另一项性能增强。 只需将__box关键字 (keyword) 放在任何类型之前即可表示其装箱形式:
__value struct V {
int i;
};
int main() {
V v = {10};
__box V *pbV = __box(v);
pbV->i += 10; // update without casting
}
在 C# 中,必须取消装箱到“v”,然后更新值并重新装箱回对象:
struct B { public int i; }
static void Main() {
B b = new B();
b.i = 5;
object o = b; // implicit box
B b2 = (B)o; // explicit unbox
b2.i++; // update
o = b2; // implicit re-box
}
STL 集合与托管集合 — v1
坏消息:在 C++ 中,使用 STL 集合的速度通常与手动编写该功能一样快。 CLR 框架速度非常快,但存在装箱和拆箱问题:一切都是对象,如果没有模板或通用支持,必须在运行时检查所有操作。
好消息:从长远来看,你可以打赌,随着泛型添加到运行时,此问题将消失。 你今天部署的代码将体验到速度提升,而无需进行任何更改。 短期内,可以使用静态强制转换来防止检查,但这不再安全。 建议在性能绝对关键的紧要代码中使用此方法,并且你已确定两个或三个热点。
使用 Stack 托管对象
在 C++ 中,指定对象应由堆栈或堆管理。 你仍然可以在 MC++ 中执行此操作,但应注意一些限制。 CLR 对所有堆栈托管对象使用 ValueType,并且 ValueTypes 可以执行的操作存在限制, (无继承,例如) 。 有关详细信息 ,请参阅 MSDN 库。
角案例:注意托管代码中的间接调用 - v1
在 v1 运行时,所有间接函数调用都是本机进行的,因此需要转换为非托管空间。 任何间接函数调用只能从本机模式进行,这意味着来自托管代码的所有间接调用都需要托管到非托管转换。 当表返回托管函数时,这是一个严重的问题,因为随后必须进行 第二 次转换才能执行该函数。 与执行单个 调用 指令的成本相比,成本比 C++ 慢 50 到 100 倍!
幸运的是,在调用驻留在垃圾回收类内的方法时,优化会删除此内容。 但是,在使用 /clr 编译的常规 C++ 文件的特定情况下,方法返回将被视为托管。 由于无法通过优化将其删除,因此会花费全部双转换成本。 下面是此类情况的示例。
//////////////////////// a.h: //////////////////////////
class X {
public:
void mf1();
void mf2();
};
typedef void (X::*pMFunc_t)();
////////////// a.cpp: compiled with /clr /////////////////
#include "a.h"
int main(){
pMFunc_t pmf1 = &X::mf1;
pMFunc_t pmf2 = &X::mf2;
X *pX = new X();
(pX->*pmf1)();
(pX->*pmf2)();
return 0;
}
////////////// b.cpp: compiled without /clr /////////////////
#include "a.h"
void X::mf1(){}
////////////// c.cpp: compiled with /clr ////////////////////
#include "a.h"
void X::mf2(){}
有几种方法可以避免这种情况:
- 将类设为托管类 (“__gc”)
- 如果可能,请删除间接调用
- 将类保留编译为非托管代码 (例如不要使用 /clr)
最大程度地减少性能命中数 - 版本 1
在版本 1 JIT 下,MC++ 中有几个操作或功能的成本更高。 我会列出它们并给出一些解释,然后我们将讨论你可以做些什么。
- 抽象 - 这是一个领域,强大的、缓慢的 C++ 后端编译器在 JIT 中大获全胜。 如果将 int 包装在类中用于抽象,并且严格以 int 的形式访问它,则 C++ 编译器可以将包装器的开销减少到几乎什么都没有。 可以向包装器添加多个抽象级别,而不会增加成本。 JIT 无法花费必要的时间消除此成本,从而使 MC++ 中的深度抽象成本更高。
- 浮点数 - v1 JIT 目前不执行 VC++ 后端执行的所有特定于 FP 的优化,因此浮点操作目前更加昂贵。
- 多维数组 - JIT 比多维数组更善于处理交错数组,因此请改用交错数组。
- 64 位算术 - 在未来版本中,64 位优化将添加到 JIT。
你可以执行的操作
在开发的每个阶段,你可以执行一些操作。 使用 MC++,设计阶段可能是最重要的领域,因为它将决定你最终完成多少工作以及获得多少性能作为回报。 坐下来编写或移植应用程序时,应考虑以下事项:
- 确定使用多个继承、模板或确定性最终确定性的区域。 你必须删除这些代码,否则将代码的这一部分保留在非托管空间中。 考虑重新设计的成本,并确定可以移植的区域。
- 查找性能热点,例如跨托管空间的深度抽象或虚拟函数调用。 这些也需要设计决策。
- 查找已指定为堆栈管理的对象。 确保它们可以转换为 ValueTypes。 标记其他对象以转换为堆托管对象。
在编码阶段,应了解成本更高的操作以及处理这些操作的选项。 MC++ 的一个好事是,在开始编码之前,你可以提前掌握所有性能问题:这有助于稍后分析工作。 但是,在编码和调试时,仍可以执行一些调整。
确定哪些区域使用浮点算术、多维数组或库函数。 其中哪些方面对性能至关重要? 使用探查器选取开销最大的片段,并选择最适合的选项:
- 将整个片段保存在非托管空间中。
- 对库访问使用静态强制转换。
- 请尝试调整装箱/拆箱行为, (稍后) 介绍。
- 编写自己的结构。
最后,努力将转换次数降至最低。 如果循环中有一些非托管代码或互操作调用,请将整个循环设为非托管。 这样一来,只需支付两次转换成本,而不是为循环的每次迭代付费。
其他资源
.NET Framework中性能的相关主题包括:
观看当前正在开发的未来文章,包括设计、体系结构和编码理念的概述、托管世界中性能分析工具的演练,以及 .NET 与目前提供的其他企业应用程序的性能比较。
附录:虚拟调用和分配成本
呼叫类型 | # 调用/秒 |
---|---|
ValueType 非虚拟调用 | 809971805.600 |
类非虚拟调用 | 268478412.546 |
类虚拟调用 | 109117738.369 |
ValueType Virtual (Obj 方法) 调用 | 3004286.205 |
ValueType Virtual (重写的 Obj 方法) 调用 | 2917140.844 |
通过新建 (非静态) 加载类型 | 1434.720 |
通过新建 (虚拟方法) 加载类型 | 1369.863 |
注意 测试计算机是 P III 733Mhz,运行 Windows 2000 Professional Service Pack 2。
此图表比较了与不同类型的方法调用相关的成本,以及实例化包含虚拟方法的类型的成本。 数字越高,每秒可以执行的调用/实例化次数就越多。 虽然这些数字肯定会因不同的计算机和配置而异,但执行一个调用的相对成本仍然很大。
- ValueType 非虚拟调用:此测试调用 ValueType 中包含的空非虚拟方法。
- 类非虚拟调用:此测试调用类中包含的空非虚拟方法。
- 类虚拟调用:此测试调用类中包含的空虚拟方法。
- ValueType Virtual (Obj 方法) 调用:此测试调用 ToString () (ValueType 上的虚拟方法) ,它采用默认对象方法。
- ValueType Virtual (Overridden Obj 方法) 调用:此测试调用 ToString () (已重写默认值的 ValueType) 虚拟方法。
- 加载类型由新建 (静态) :此测试为仅使用静态方法的类分配空间。
- 加载类型通过新建 (虚拟方法) :此测试为具有虚拟方法的类分配空间。
可以得出的一个结论是,在类中调用方法时 ,虚拟函数 调用的开销大约是常规调用的两倍。 请记住,呼叫一开始成本很低,因此我不会删除所有虚拟呼叫。 如果这样做有意义,应始终使用虚拟方法。
- JIT 无法内联虚拟方法,因此,如果删除非虚拟方法,将失去潜在的优化。
- 为具有虚拟方法的对象分配空间比为没有虚拟方法的对象分配空间要慢一些,因为必须执行额外的工作才能为虚拟表查找空间。
请注意,在 ValueType 中调用非虚拟方法的速度是类的三倍多,但一旦将其 视为类 ,就会丢失得非常严重。 这是 ValueTypes 的特征:将它们视为结构,并且其照明速度很快。 像上课一样对待他们,他们很慢。 ToString () 是一种虚拟方法,因此在调用它之前,必须将结构转换为堆上的 对象。 在 ValueType 上调用虚拟方法的速度不是慢的两倍,而是现在慢了 18 倍! 故事的道德? 不要将 ValueType 视为类。
如果对本文有疑问或意见,请联系项目经理 Claudio Caldato,解决.NET Framework性能问题。