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文件中,当应用程序启动时创建计数器最好。但是,这样就必须考虑性能监视器用户组运行在哪个应用程序池下。
另一个方法在一个单独的控制台程序中创建计数器。管理员可以在服务器上运行这个程序创建计数器。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | 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); } } } |
在代码中使用计数器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | 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参数:
1 2 3 4 | 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,减少数据库访问:
1 | <%@ Page EnableSessionState= "True" ... %> |
EnableSessionState可以取这些值:
- True:默认值。一个访问两次数据库。
- False:不读取Session,但为了防止Session过期,页面结束时,需要更新Session,所以只需要访问一次数据库。
- ReadOnly:当页面初始话时,获取并反序列化会话状态。但页面结束时,不更新会话状态。只需要访问一次数据库。另外,这种模式使用读锁,使得多个只读请求可以同时访问会话状态。因此,当来处理自同一个用户的请求时,就避免了锁竞争。
设置EnableSessionState
可以在Page指令中设置EnableSessionState:
1 | <%@ Page EnableSessionState= "ReadOnly" %> |
也可以web.config中设置整个网站的会话模式:
1 2 3 4 5 | <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,则非常慢。
- 允许只访问需要访问的单个字段。没有访问的字段不会更新,节省了序列化和传输时间。
假设有一个类:
1 2 3 4 5 6 | [Serializable] private class Person { public string FirstName { get ; set ; } public string LastName { get ; set ; } } |
在Session中这样获取和存储:
1 2 3 4 5 6 | // Get object from session Person myPerson = (Person)Session[ "MyPerson" ]; // Make changes myPerson.LastName = "Jones" ; // Store object in session Session[ "MyPerson" ] = myPerson; |
这会使用BinaryFormatter反序列化/序列化整个myPerson对象并整个在会话存储服务器间传输。
另一种方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | 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中的字段值。
页面中使用:
1 2 3 4 5 6 7 8 | 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对象,锁尽可能小的对象。
1 2 3 4 | lock (protectedObject) { // protected code } |
这时以下代码的简写:
1 2 3 4 5 6 7 8 9 | try { Monitor.Enter(protectedObject); // protected code ... } finally { Monitor.Exit(protectedObject); } |
这样写没有问题,只要锁住的对象只与被保护代码相关。只锁私有的或内部对象。否则,一些不相关的代码可能会锁相同的对象,以保护其它一段代码,这会导致不必须的延迟。例如,不要锁this:
1 2 3 4 | lock ( this ) { // protected code ... } |
锁一个私有对象:
1 2 3 4 5 6 7 8 | private readonly object privateObject = new object (); public void MyMethod() { lock (privateObject) { // protected code ... } } |
如果是保护静态代码,不要锁类:
1 2 3 4 | lock ( typeof (MyClass)) { // protected code ... } |
锁静态对象:
1 2 3 4 5 6 7 8 | private static readonly object privateStaticObject = new object (); public void MyMethod() { lock (privateStaticObject) { // protected code ... } } |
使用System.Threading.Interlocked
如果被保护的代码只是增加或减少一个整数,将一个整数加到另一个整数,或者交换两个值,考虑使用System.Threading.Interlocked类代替锁。Interlocked执行速度要比锁快。
例如,不要
1 2 3 4 | lock (privateObject) { counter++; } |
使用:
1 | Interlocked.Increment( ref counter); |
使用ReaderWriterLock
如果大多数访问受保护对象线程只读那个对象,而相对少的线程更新对象,考虑使用ReadWriterLock。这允许多个只读线程访问受保护代码,而只有一个写线程访问。
请求读锁
当使用ReaderWriterLock时,在类级别声明ReaderWriterLock:
1 | static ReaderWriterLock readerWriterLock = new ReaderWriterLock(); |
在一个方法中请求读锁,调用AcquireReaderLock。可以指定超时时间。当超时发生时,抛出一个ApplicationException。调用ReleaseReaderLock释放锁。只有在真正拥有锁时,才能释放锁,也就是锁还没超时,否则会抛出一个ApplicationException。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | 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释放锁:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | 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控件接收文件,可以使用以下代码得到文件的长度:
1 | int bufferLength = FileUpload1.PostedFile.ContentLength; |
使用64K缓冲区
NTFS文件系统使用64KB的内部缓冲区。FileStream构造器允许设置文件写的缓冲区大小。通过设置FileStream缓存区大小为64KB,可能获得更高的性能。
禁用8.3文件名
为了保持与MS-DOS的向后兼容性,NTFS文件系统为每个文件或目录维护了一个8.3文件名。这会有一些额外开销,因为系统必须保证8.3文件名必须是唯一 的,所以必须检查目录中的其它文件名。如果一个目录中的文件超过20000个文件,这个开销就很显著了。
在禁用8.3文件名前,确保系统中没有程序依赖这些名称。在相同的操作系统上先进行测试。
在命令行中执行这个命令(先备份注册表):
1 | 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.
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 提示词工程——AI应用必不可少的技术
· Open-Sora 2.0 重磅开源!
· 周边上新:园子的第一款马克杯温暖上架