赞
踩
一些应用的核心是为特定领域设计的专门算法,这些算法基于并不普遍适用的假设。其他应用依赖于经过充分测试的算法,这些算法适用于许多领域,并且在整个计算机软件领域已经存在了几十年。我们相信,每个软件开发人员都可以从算法研究皇冠上的宝石中受益并获得洞察力,以及软件框架所基于的算法类别。虽然如果你没有很强的数学背景,这一章的某些部分可能会有些困难,但是它们是值得你努力的。
这一章轻轻拂过计算机科学的一些支柱,并回顾了不朽算法及其复杂性分析的几个例子。有了这些例子,你应该对使用现有的算法感到更舒服,使它们适应你的需要,并发明你自己的算法。
注这不是一本关于算法研究的教科书,也不是现代计算机科学中最重要的算法的介绍性文本。这一章故意写得很短,清楚地表明你不能从中学到所有你需要知道的东西。我们没有深入研究正式定义的任何细节。比如我们对图灵机和语言的处理一点都不严谨。关于算法的教科书介绍,考虑一下科尔曼、莱瑟森、里维斯特和斯坦的“算法介绍”(麻省理工学院出版社,2001 年)和达斯古普塔、帕帕迪米特里奥和瓦齐拉尼的“算法”(即将出版,目前在网上有草稿)。
复杂性分类
第五章曾有机会简要提及上某些操作的复杂性。NET 框架的集合和我们自己实现的一些集合。本节更准确地定义了 Big-Oh 复杂性的含义,并回顾了可计算性和复杂性理论中已知的主要复杂性类别。
Big-Oh 符号
当我们在第五章的中讨论一个列表上的查找操作的复杂度时,我们说它有运行时复杂度 O ( n )。我们的意思是非正式地说,当你有一个 1000 个条目的列表,并试图在其中找到另一个条目*,时,那么该操作的最坏情况*运行时间是通过列表的 1000 次迭代——也就是说,如果该条目不在列表中。因此,当输入变大时,我们试图估计运行时间增长的数量级。然而,当正式指定时,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),那么即使在有几千个元素的列表上执行查找也会非常低效。
此外,对于某些输入,界限可能很紧,但对于其他输入,界限可能不紧;例如,如果我们在列表中搜索一个恰好是它的第一个元素的项目,步骤的数量显然是恒定的(一个!)对于所有的列表大小——这就是为什么我们在前面的段落中提到了最差情况下的运行时间。
这种表示法如何使推理运行时间和比较不同算法变得更容易的一些例子包括:
类似于运行时复杂度上界的定义,也有下界(用ω(f(n)表示)和紧界(用θ(f(n)表示)。然而,它们很少被用来讨论算法复杂性,所以我们在这里省略了它们。
主定理
主定理 是一个简单的结果,为分析许多递归算法的复杂性提供了现成的解决方案,这些算法将原问题分解成更小的块。例如,考虑下面的代码,它实现了合并排序算法:
公共静态列表 MergeSort(List list)其中 T : IComparable < T >
如果(列表。Count <= 1)返回列表;
int middle = list。count/2;
List left = list。取(中)。to list();
列表右=列表。跳过(中间)。to 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 中。)
然而,有些问题被认为不太复杂,但是我们还没有多项式算法。这些问题中的一些在现实生活场景中非常有用:
这些问题属于另一组称为 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):
首先,我们观察到约束“确保通过格式化计算机 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 是顶点的子集, 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 设计下面的概率素性测试:
对于大多数合数,通过该算法的少量迭代检测到它是合数并拒绝它。当然,所有的质数重复多少次都会通过测试。
不幸的是,有无限多的数(称为 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 编译器用来确定内联哪些方法的确切标准是不公开的。一些启发法可以通过实验发现:
在 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 编译器用来执行尾部调用的标准可在线获得:
http://msdn.microsoft.com/en-us/library/system.reflection.emit.opcodes.tailcall.aspx
。http://blogs.msdn.com/b/davbr/archive/2007/06/20/tail-call-jit-conditions.aspx
。http://blogs.msdn.com/b/clrcodegeneration/archive/2009/05/11/tail-call-improvements-in-net-framework-4.aspx
。启动性能
对于客户端应用,快速启动是给用户或评估产品或进行演示的潜在客户留下的很好的第一印象。然而,应用越复杂,保持可接受的启动时间就越困难。区分冷启动和热启动非常重要,前者是指系统刚刚完成引导后首次启动应用,后者是指系统使用一段时间后启动应用(不是第一次)。始终在线的系统服务或后台代理需要快速的冷启动时间,以防止系统启动序列和用户登录花费太长时间。典型的客户端应用,如电子邮件客户端和网络浏览器,可能会有稍长的冷启动时间,但用户会希望它们在系统使用一段时间后快速启动。在这两种情况下,大多数用户都希望启动时间短。
启动时间长有几个因素。其中一些仅与冷启动相关;其他的与两种类型的启动都相关。
我们有几个测量工具可以用来诊断长启动时间及其可能的原因(见第二章)。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 指令的一些例子包括:
您可能想知道在这些“新”寄存器上运行的指令是否比它们的标准对应物慢。如果是这样的话,任何性能提升都是骗人的。幸运的是,事实并非如此。在英特尔 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# 版本更有效。尽管如此,还是有一些比其他主题更经常出现的共同主题:
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++具有优势的另一个因素。
例外
如果正确并谨慎地使用,异常 并不是一个昂贵的机制。有一些简单的准则可以遵循,以避免抛出太多异常并导致巨大性能成本的风险:
与抛出和处理异常相关的最大性能成本可以分为几类:
若要了解异常是否会导致性能问题,可以使用。NET CLR Exceptions 性能计数器类别(有关性能计数器的更多信息,请参见第二章)。具体来说,如果每秒抛出数千个异常,每秒抛出的异常数计数器可以帮助查明潜在的性能问题。
反射
Reflection 在许多复杂的应用中有着性能猪的坏名声。这种名声有些是有道理的:使用反射可以执行代价极其昂贵的操作,比如使用类型通过函数名调用函数。通过反射调用方法或设置字段值时的主要开销来自于必须在后台进行的工作,而不是可以由 JIT 编译成机器指令的强类型代码,使用反射的代码通过一系列代价高昂的方法调用在运行时被有效地解释。
例如,使用类型调用方法。InvokeMember 需要使用元数据和重载解析来确定要调用的方法,确保指定的参数与方法的参数匹配,必要时执行类型强制,验证任何安全问题,最后执行方法调用。因为反射很大程度上基于对象参数和返回值,所以装箱和取消装箱可能会增加额外的成本。
注了解周边更多性能技巧。从内部的角度来看,考虑一下 Joel Pobar 在《MSDN》杂志上发表的文章“避开常见的性能陷阱,打造快速的应用”,这篇文章可以在网上的http://msdn.microsoft.com/en-us/magazine/cc163759.aspx
找到。
通常,通过使用某种形式的代码生成 ,可以从性能关键的场景中消除反射——而不是反射未知类型和动态调用方法/属性,您可以以强类型的方式生成代码(针对每种类型)。
代码生成
代码生成通常由序列化框架、对象/关系映射器(ORM)、动态代理和其他需要处理动态未知类型的性能敏感代码使用。控件中动态生成代码有几种方法。NET 框架,还有很多第三方代码生成框架 ,比如 LLBLGen 和 ?? 模板。
从源代码生成代码
假设您正在实现一个序列化框架,它写出任意对象的 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 应用被设计为每秒处理数百甚至数千个请求。为了成功地构建这样的应用,识别潜在的性能瓶颈并尽一切努力防止其发生是非常重要的。但是处理和防止 ASP.NET 应用中的瓶颈不仅仅局限于您的代码。从一个 web 请求到达服务器 到到达你的应用代码,它通过一个 HTTP 管道,然后通过 IIS 管道,最后到达另一个管道,ASP。NET 的管道,只有这样它才能到达你的代码。当您处理完请求后,响应会沿着这些管道发送,直到最终被客户端的机器接收。这些管道中的每一个都是潜在的瓶颈,所以提高 ASP.NET 应用的性能实际上意味着提高你的代码和管道的性能。
当讨论如何提高 ASP.NET 应用 的性能时,人们必须看得更远,而不仅仅是应用本身,并检查构成 web 应用的各个部分如何影响其整体性能。web 应用的整体性能包括以下几个方面:
在这一章中,我们将简要讨论 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 应用性能的各种问题。例如:
有些工具(如 Fiddler)还可以将记录的流量导出为 Visual Studio Web 性能测试,因此您可以使用 Web 测试和负载测试来测试从客户端应用和浏览器(而不是 Internet Explorer)调用的 Web 应用。例如,您可以从. NET 客户端应用监视基于 HTTP 的 WCF 调用,将它们导出为 Web 测试,并使用负载测试对您的 WCF 服务进行压力测试。
网络分析工具
另一组可用于识别 web 应用问题的工具是 web 分析工具,如 Yahoo!s YSlow 和谷歌的页面速度。Web 分析工具不仅仅分析流量本身,还会寻找丢失的缓存头和未压缩的响应。它们分析 HTML 页面本身,以检测可能影响加载和呈现页面的性能的问题,例如:
提高服务器上的 Web 性能
有许多方法可以提高 ASP.NET 应用中代码的性能。可以使用对 ASP.NET 应用和桌面应用都有效的技术进行一些改进,例如使用线程或任务进行非依赖异步操作,但也有一些改进与您编写 ASP.NET 代码的方式有关,无论它是 WebForm 的代码隐藏,还是 ASP.NET MVC 控制器。这些变化,无论多小,都有助于更好地利用您的服务器,让应用运行得更快,并处理更多的并发请求。
缓存常用对象
在 web 应用中处理请求通常需要使用提取的数据,这些数据通常来自远程位置,如数据库或 web 服务。这些数据查找是昂贵的操作,通常会导致响应时间延迟。不需要为每个操作提取数据,数据可以被预先提取一次,并存储在内存中的某种缓存机制中。在获取数据后进入的新请求可以使用缓存的数据,而不是从其原始源再次获取数据。缓存范例通常被描述为:
注意由于几个请求可以在任何给定的时间访问同一个缓存对象,导致同一个对象被多个线程引用,因此,无论是通过将缓存对象视为不可变的(对缓存对象的更改将需要克隆它,在新副本上进行更改,然后用克隆的对象更新缓存),还是通过使用锁定机制来确保它不被其他线程更新,对缓存对象的更新都应该是负责任的。
很多开发者使用 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 缓存提供了广泛的缓存机制,除了存储资源之外,还允许您:
将项目添加到缓存中就像将项目添加到字典中一样简单:
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 操作的话。要创建异步控制器,您需要执行以下步骤:
例如,下面的代码显示了一个名为 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,甚至故意这样做以获得更详细的异常信息。事实上,保持这种设置会导致几个性能问题:
更改此设置非常简单:要么从配置中完全删除 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 编译工具可以减少用户第一次请求时的延迟。要运行该工具,请按照下列步骤操作:
微调 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):
尽管设置上述缺省值是为了获得优化的性能,但是在某些情况下,为了获得更好的性能,您可能需要更改这些缺省值,这取决于您在 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 文件通常可以被缓存。
为静态内容设置缓存头
静态文件通常用两个缓存头发送回客户端:
如果客户端已经有了最新的版本,这些缓存头将确保内容不会被发送回客户端,但是仍然需要从客户端向服务器发送一个请求,以验证内容没有发生更改。如果您的应用中有静态文件,并且您知道这些文件在接下来的几周甚至几个月内可能不会更改,例如您公司的徽标或脚本文件,这些文件在应用的下一个版本发布之前不会更改,那么您可能希望设置缓存头,以允许客户端缓存该内容并重用它,而无需在每次请求该内容时向服务器验证该内容是否已更改。这种行为可以通过使用带有 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 文件的页面。当一个页面有几个到外部资源的链接时,在浏览器中加载该页面就变成了一个冗长的操作,因为用户通常需要等到该页面及其所有相关的样式和脚本都被下载并解析之后。使用外部资源时,我们面临两个问题:
为了解决这个问题,我们需要一种技术,既能降低响应的大小,又能减少请求(以及响应)的数量。在 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 的使用还减少了您的 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 有两个内置的进程外机制 用于状态管理:
对于缓存,大多数情况下的解决方案是使用分布式缓存机制,如微软的 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 受限的软件增加额外的性能。感谢您跟随我们踏上这一旅程,并祝您好运,提高您应用的性能!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。