ASP.NET站点性能提升-减少长等待时间
如果服务器没有用尽内存、CPU或线程,请求依然花了很长时间才完成,就有可能是服务器等待外部资源,例如,数据库等外部资源的时间很长。
在这一章中,讨论这些问题:
- 如何使用自定义计数器度量长等待时间。
- 并行而不是串行等待。
- 提高会话状态性能。
- 减少线程锁延迟。
度量等待时间
度量等待外部资源响应的频率和时长有几种方法:
- 在调试器中运行代码,在请求外部资源的地方设置断点。但是,不能在生产环境中使用这个方法,并且这个方法只能提供少量请求的信息。
- 使用Trace类(在System.Diagnostics使用空间中)跟踪每个请求花费的时间。这个方法会提供详细的信息。但是,如果用在生产环境,处理跟踪消息的成本就太高了,必须合并跟踪数据,查找哪些请求的频率最后,花费的时间最长。
- 在代码中使用性能计数器,记录每个请求的频率和平均等待时间。这些计数器是轻量级的,所以可能用在生产环境中。
Windows提供了28种类型的性能计数器。增加自定义计数器也很容易,可以在perfmon中查看实时值,和内建的计数器一样。
计数器的运行开销很小。ASP.NET,SQL Server和Window已经发布了上百个计数器。即使增加了很多的计数器,CPU开销也会保持在1%以下。
本章只使用三种常用计数器:简单数字、每秒比率和时间。所有类型计数器和它们的使用方法可以在http://msdn.microsoft.com/en-us/library/system.diagnostics.performancecountertype.aspx?ppud=4找到。
使用计数器有三个步骤:
- 创建自定义计数器。
- 集成到代码中。
- 在perfmon中查看值。
创建自定义计数器
在这个例子中,有一个页面简单地等待一秒钟,模拟等待外部资源。我们会放置计数器在这个页面上。
Windows允许对计数器进行分类。我们为新计数器创建一个”Test Counters”新分类。
这里有一些我们将放到页面上计数器。它们是经常用到三种类型的计数器。
计数器名 | 计数器类型 | 描述 |
Nbr Page Hits | NumberOfItem64 | 64位计数器,记录网站启动后页面的访问次数。 |
Hits/second | RateOfCountsPerSecond32 | 每秒访问次数 |
Average Wait | AverageTime32 | 等待资源的间隔时间。 |
Average Wait Base | AverageBase | Average Wait需要的工具计数器 |
创建计数器和”Test Counters”分类有两种方法:
- 使用Visual Studio:这个方法比较快,但是如果希望在多个环境,如开发环境和生产环境应用同一个计数器,必须在多个环境中都要加入这个计数器。
- 编程:在多个环境中应用同一个计数器比较容易。
使用Visual Studio创建计数器
略
编程创建计数器
从维护的角度,最好在Global.asax文件中,当应用程序启动时创建计数器最好。但是,这样就必须考虑性能监视器用户组运行在哪个应用程序池下。
另一个方法在一个单独的控制台程序中创建计数器。管理员可以在服务器上运行这个程序创建计数器。代码如下:
using System; using System.Diagnostics; namespace CreateCounters { class Program { static void Main(string[] args) { CounterCreationDataCollection ccdc = new CounterCreationDataCollection(); CounterCreationData ccd = new CounterCreationData ("Nbr Page Hits", "Total number of page hits", PerformanceCounterType.NumberOfItems64); ccdc.Add(ccd); ccd = new CounterCreationData("Hits / second", "Total number of page hits /sec", PerformanceCounterType.RateOfCountsPerSecond32); ccdc.Add(ccd); ccd = new CounterCreationData("Average Wait", "Average wait in seconds", PerformanceCounterType.AverageTimer32); ccdc.Add(ccd); ccd = new CounterCreationData("Average Wait Base", "", PerformanceCounterType.AverageBase); ccdc.Add(ccd); if (PerformanceCounterCategory.Exists("Test Counters")) { PerformanceCounterCategory.Delete("Test Counters"); } PerformanceCounterCategory.Create("Test Counters", "Counters for test site",PerformanceCounterCategoryType. SingleInstance,ccdc); } } }
在代码中使用计数器
using System; using System.Diagnostics; public partial class _Default : System.Web.UI.Page { protected void Page_Load(object sender, EventArgs e) { PerformanceCounter nbrPageHitsCounter = new PerformanceCounter("Test Counters", "Nbr Page Hits", false); nbrPageHitsCounter.Increment(); PerformanceCounter nbrPageHitsPerSecCounter = new PerformanceCounter("Test Counters", "Hits / second", false); nbrPageHitsPerSecCounter.Increment(); Stopwatch sw = new Stopwatch(); sw.Start(); // Simulate actual operation System.Threading.Thread.Sleep(1000); sw.Stop(); PerformanceCounter waitTimeCounter = new PerformanceCounter("Test Counters", "Average Wait", false); waitTimeCounter.IncrementBy(sw.ElapsedTicks); PerformanceCounter waitTimeBaseCounter = new PerformanceCounter("Test Counters", "Average Wait Base", false); waitTimeBaseCounter.Increment(); } }
在Perfmon中查看自定义计数器
略。参考以前的文章。
并行等待
如果网站需要等待多个外部资源等待响应,并且这些请求间没有相互依赖,可以同时初始化这些请求,并行等待,而不是串行等待。如果需要三个web service的信息,每个需要5秒,使用并行等待只需要5秒,而不是15秒。
可以使用异步代码很容易实现并行等待。当注册每个异步任务时,传递true给PageAsyncTask构造器的executeInParallel参数:
bool executeInParallel = true; PageAsyncTask pageAsyncTask = new PageAsyncTask(BeginAsync, EndAsync, null, null, executeInParallel); RegisterAsyncTask(pageAsyncTask);
从数据库中获取多个结果集
参考以前的文章。
减少使用外部会话模式的开销
如果使用在服务器园上使用会话状态,可能使用StateServer或SqlServer状态,而不是InProc模式,因为来自同一个访问者的请求可能由不同的服务器处理。
当ASP.NET开始处理一个请求时,从StateServer或SQL Sever获取当前会话状态,然后反序列化。然后,在页面生命周期结束时,再序列化会话状态,存储在StateServer或SQL Server数据库中。在这个过程中,ASP.NET更新会话的最后访问时间,这样会可能清除超时的会话。如果使用SqlServer模式,就意味着每次请求有两个来回。
减少数据库访问
可以通过设置Page指令为False或ReadOnly,减少数据库访问:
<%@ Page EnableSessionState="True" ... %>
EnableSessionState可以取这些值:
- True:默认值。一个访问两次数据库。
- False:不读取Session,但为了防止Session过期,页面结束时,需要更新Session,所以只需要访问一次数据库。
- ReadOnly:当页面初始话时,获取并反序列化会话状态。但页面结束时,不更新会话状态。只需要访问一次数据库。另外,这种模式使用读锁,使得多个只读请求可以同时访问会话状态。因此,当来处理自同一个用户的请求时,就避免了锁竞争。
设置EnableSessionState
可以在Page指令中设置EnableSessionState:
<%@ Page EnableSessionState="ReadOnly" %>
也可以web.config中设置整个网站的会话模式:
<configuration> <system.web> <pages enableSessionState="ReadOnly" /> </system.web> </configuration>
在每个页面中的Page指令中可以重载默认值。
減少序列化和传输开销
不要在会话中存在有多个字段的对象,单独存储每个字段。这样有以下好处:
- 序列化。.NET简单类型,例如String、Boolean、DateTime、TimeSpan、Int16、Int32、Int64、Byte、Char、Single、Double、Decimal、SByte、UInt16、UInt32、UInt64、Guid和InPtr是非常快速和高效的。序列化对象类型,会使用BinaryFormatter,则非常慢。
- 允许只访问需要访问的单个字段。没有访问的字段不会更新,节省了序列化和传输时间。
假设有一个类:
[Serializable] private class Person { public string FirstName { get; set; } public string LastName { get; set; } }
在Session中这样获取和存储:
// Get object from session Person myPerson = (Person)Session["MyPerson"]; // Make changes myPerson.LastName = "Jones"; // Store object in session Session["MyPerson"] = myPerson;
这会使用BinaryFormatter反序列化/序列化整个myPerson对象并整个在会话存储服务器间传输。
另一种方式:
private class SessionBackedPerson { private string _id; public SessionBackedPerson(string id) { _id = id; } private string _firstName; public string FirstName { get { _firstName = HttpContext.Current.Session[_id + "_firstName"].ToString(); return _firstName; } set { if (value != _firstName) { _firstName = value; HttpContext.Current.Session[_id + "_firstName"] = value; } } } private string _lastName; public string LastName { get { _lastName = HttpContext.Current.Session[_id + "_lastName"].ToString(); return _lastName; } set { if (value != _lastName) { _lastName = value; HttpContext.Current.Session[_id + "_lastName"] = value; } } } }
这个类在会话中存储每个属性。它在创建时,必须知道它的ID,这样它才能构造一个唯一的session键。当设置属性时,只有当新值和旧值不同时,都会访问session对象。
这个方法的结果是只会存储单独的基本类型,它们比整个对象序列化更快。也只有当这些字段真正更新时,才会被更新session中的字段值。
页面中使用:
protected void Page_Load(object sender, EventArgs e) { SessionBackedPerson myPerson = new SessionBackedPerson("myPers on"); // Update object, and session, in one go. // Only touch LastName, not FirstName. myPerson.LastName = "Jones"; }
完全消除对会话的依赖
会话的最大好处是,会话状态存储在服务器上,未授权的用户很难访问和修改它。然后,如果这不是个问题,这里有一些其它选择去除会话状态和它的开销:
- 如果不是需要保存很多会议数据,使用cookie。
- 在ViewState中存储会话,这需要更大的带宽,但減少了数据库流量。
- 使用AJAX异步调用取代下整个页面刷新,这样就可以在页面上保存会话信息。
线程锁
如果使用锁保证只有一个线程可以访问资源,其它线程就必须等待这个锁释放。
使用.NET CLR LocksAndThreads分类中的以下计数器查看锁的相关情况:
分类:.NET CLR LocksAndThreads
Contention Rate/sec | 运行时试图获得托管锁,并失败的比率。 |
Current Queue Length | 上一次记录的正在等待托管锁的线程数 |
连续有线程申请锁失败,是造成延迟的一个原因。可以考虑使用以下方法减少这些延迟:
- 减少锁的持续时间
- 使用granular锁
- 使用System.Threading.Interlocked
- 使用ReaderWriterLock
减少锁的持续时间
在访问共享资源前申请锁,访问结束后,立即释放。
使用granular锁
如果使用C# lock语句,或Monitor对象,锁尽可能小的对象。
lock (protectedObject) { // protected code }
这时以下代码的简写:
try { Monitor.Enter(protectedObject); // protected code ... } finally { Monitor.Exit(protectedObject); }
这样写没有问题,只要锁住的对象只与被保护代码相关。只锁私有的或内部对象。否则,一些不相关的代码可能会锁相同的对象,以保护其它一段代码,这会导致不必须的延迟。例如,不要锁this:
lock (this) { // protected code ... }
锁一个私有对象:
private readonly object privateObject = new object(); public void MyMethod() { lock (privateObject) { // protected code ... } }
如果是保护静态代码,不要锁类:
lock (typeof(MyClass)) { // protected code ... }
锁静态对象:
private static readonly object privateStaticObject = new object(); public void MyMethod() { lock (privateStaticObject) { // protected code ... } }
使用System.Threading.Interlocked
如果被保护的代码只是增加或减少一个整数,将一个整数加到另一个整数,或者交换两个值,考虑使用System.Threading.Interlocked类代替锁。Interlocked执行速度要比锁快。
例如,不要
lock (privateObject) { counter++; }
使用:
Interlocked.Increment(ref counter);
使用ReaderWriterLock
如果大多数访问受保护对象线程只读那个对象,而相对少的线程更新对象,考虑使用ReadWriterLock。这允许多个只读线程访问受保护代码,而只有一个写线程访问。
请求读锁
当使用ReaderWriterLock时,在类级别声明ReaderWriterLock:
static ReaderWriterLock readerWriterLock = new ReaderWriterLock();
在一个方法中请求读锁,调用AcquireReaderLock。可以指定超时时间。当超时发生时,抛出一个ApplicationException。调用ReleaseReaderLock释放锁。只有在真正拥有锁时,才能释放锁,也就是锁还没超时,否则会抛出一个ApplicationException。
try { readerWriterLock.AcquireReaderLock(millisecondsTimeout); // Read the protected object } catch (ApplicationException) { // The reader lock request timed out. } finally { // Ensure that the lock is released, provided there was no // timeout. if (readerWriterLock.IsReaderLockHeld) { readerWriterLock.ReleaseReaderLock(); } }
请求写锁
调用AcquireWriterLock请求读锁。调用RealeaseWriterLock释放锁:
try { readerWriterLock.AcquireWriterLock(millisecondsTimeout); // Update the protected object } catch (ApplicationException) { // The writer lock request timed out. } finally { // Ensure that the lock is released, provided there was no // timeout. if (readerWriterLock.IsWriterLockHeld) { readerWriterLock.ReleaseWriterLock(); } }
如果代码持有一个读锁,然后需要更新受保护对象,可以先释放读锁,再请求写锁,或者调用UpdateToWriterLock方法。也可以调用DowngradeFromWriterLock从写锁降级到读锁,以允许其它读锁开始读。
读锁和写锁交替
虽然多个线程可以同时读受保护对象,但是更新受保护对象需要排他的访问。当一个线程更新对象时,其它线程不能读或更新同一个对象。等待读锁和等待写锁的线程分配在不同的队列中。当写锁释放锁时,所有等待读锁的线程都可以访问对象。当它们都释放了读锁后,下一个等待写锁的线程可能得到写锁。
为了保证在不断的有线程请求读锁时,写线程能够得到写锁,如果一个新线程请求读锁,而此时其它读线程已经在执行代码了,新线程必须等待,直到下一个写线程完成操作。当然,如果没有线程等待写锁,新读线程可以立刻得到读锁。
优化磁盘写
如果网站在磁盘上创建了很多新文件,例如访问者上传的文件,考虑这些性能提高措施:
- 避免磁盘头查找
- 使用FileStream.SetLength避免碎片
- 使用64K缓冲区
- 禁用8.3文件名
避免磁盘头查找
顺序写字节时不用移动读/头,要比随机访问快得多。如果只是写文件,不读它们,使用专用线程将它们写到专用驱动器上。这样,其它进程就不会移动那个驱动器的读/写头。
使用FileStream.SetLength避免碎片
如果多个线程在同一时间写文件,这些文件占用的空间是交错是,会导致碎片。
为了避免这种情况,使用FileStream.SetLength方法在开始写前预留足够的空间。
如果使用ASP.NET FileUpload控件接收文件,可以使用以下代码得到文件的长度:
int bufferLength = FileUpload1.PostedFile.ContentLength;
使用64K缓冲区
NTFS文件系统使用64KB的内部缓冲区。FileStream构造器允许设置文件写的缓冲区大小。通过设置FileStream缓存区大小为64KB,可能获得更高的性能。
禁用8.3文件名
为了保持与MS-DOS的向后兼容性,NTFS文件系统为每个文件或目录维护了一个8.3文件名。这会有一些额外开销,因为系统必须保证8.3文件名必须是唯一 的,所以必须检查目录中的其它文件名。如果一个目录中的文件超过20000个文件,这个开销就很显著了。
在禁用8.3文件名前,确保系统中没有程序依赖这些名称。在相同的操作系统上先进行测试。
在命令行中执行这个命令(先备份注册表):
fsutil behavior set disable8dot3
重启机器。
更多资源
- Why Disabling the Creation of 8.3 DOS File Names Will Not Improve Performance. Or Will It?
http://blogs.sepago.de/helge/2008/09/22/why-disabling-the-creation-of-83-dos-file-names-will-not-improve-performance-or-will-it/. - Fast, Scalable, and Secure Session State Management for Your Web Applications
http://msdn.microsoft.com/en-us/magazine/cc163730.aspx. - Performance Counter Type Reference
http://www.informit.com/guides/content.aspx?g=dotnet&seqNum=253.