ASP.NET站点性能提升-线程
当网站访问文件或外部资源例如数据库或web service时,处理请求的线程需要等待这些资源响应,它会被阻塞。这个线程不能做任何事情,此时,可能有其它请求等待这个线程。
可以使用异步方法访问文件或外部资源解决这个问题。这些方法当开始等待时,就释放线程,而不是阻塞它,所以线程可以由其它请求使用。当资源空闲时,从线程池中获取一个新线程进行处理。
这章演示如果将阻塞线程的同步代码转换成异步代码。每个例子首先展示同步版本,然后,将看到如何将它变成异步版本。主要讨论:
- 怎样转换访问web service的代码。
- 怎样转换访问数据库的代码,包括怎样创建异步数据层。
- 怎样转换访问文件的代码。
- 怎样改变线程相关配置和页面超时。
异步web service访问
以 http://www.webservicex.net为例。
同步版本
- 在网站中加入web service。右键单击网站选择“添加web引用”,在URL字段中,输入web service地址:http://www.webservicex.net/WeatherForecast.asmx?WSDL。注意web service描述和命名空间:net.webservicex.www。
- 在代码中加入web service的命名空间:using net.webservicex.www;
- 实例化加入web引用时自动生成的代理类WeatherForecast:
WeatherForecast weatherForecast = new WeatherForecast();
- 根据ZIP代码获取天气预报:
WeatherForecasts weatherForecasts = weatherForecast.GetWeatherByZipCode(zip);
如果打开http://www.webservicex.net/WeatherForecast.asmx?WSDL。就会发现GetWeatherByZipCode方法返回的WeatherForecasts类包括一个日期列表和它们的预报。可以使用Repeater控件进行绑定:
rpWeatherDetails.DataSource = weatherForecasts.Details; rpWeatherDetails.DataBind();
- Repeater的.aspx页面:
<asp:Repeater ID="rpWeatherDetails" runat="server"> <ItemTemplate> <p> <b><%# DataBinder.Eval(Container.DataItem, "Day")%></b><br /> Min Temperature: <%# DataBinder.Eval(Container.DataItem, "MinTemperatureF")%>F, Max Temperature: <%# DataBinder.Eval(Container.DataItem, "MaxTemperatureF")%>F </p> </ItemTemplate> </asp:Repeater>
异步版本
首先,在.aspx文件中,设置Page指令的Async=”true” 并且设置AsyncTimeout属性。如果响应超时,运行时会调用定义的超时事件处理程序。
<%@ Page Language="C#" Async="true" AsyncTimeout="30" AutoEventWireup="true" CodeFile="Default.aspx.cs" Inherits="_Default" %>
如果页面上有多点异步任务,超时时间是针对每个任务的。
如果不使用Page指令,也可以编程设置:
Page.AsyncTimeout = TimeSpan.FromSeconds(30);
然后,在代码中,注册PageAsyncTask对象。这个对象的构造器接收BeginAsync方法,这个方法开始web service访问。运行时,不会在注册后立即调用它,而是在pre-render页面事件后调用。构造器也会接收EndAsync方法,在操作完成后调用,TimeoutAsync在超时后调用。
可以使用构造器的第四个参数给BeginAsync方法传入额外的数据。最后,如果希望并行执行多个任务,一个一个注册它们,并且设置构造器的第五个参数为true。
protected void Page_Load(object sender, EventArgs e) { PageAsyncTask pageAsyncTask = new PageAsyncTask(BeginAsync, EndAsync, TimeoutAsync, null, false); RegisterAsyncTask(pageAsyncTask); }
一般情况下,在页面的OnLoad事件或其它事件中注册。如果需要在pre-render事件后注册,怎么办?如果需要根据一些任务的执行结果注册一个任务,怎么办?对于这些迟注册,使用ExecuteRegisteredAsyncTasks页面方法。这个方法手动运行所有已注册的,但是还未运行的任务。
在BeginAsync方法中,首先初始化创建web引用时自动生成的代理类WeatherForecast。然后,调用GetWeatherByZipCode web service方法的Begin版本。也是添加web引用时,Visual Studio自动生成的。
除了正常的GetWeatherByZipCode的ZIP参数,BeginGetWeatherByZipCode还接收BeginAsync传入的回调方法,和一个包括额外数据的对象。运行时将会传递给EndAsync。这里,传递weatherForecast代理对象,所以EndAsyn可以使用它调用EndGetWeatherByZipCode:
private IAsyncResult BeginAsync(object sender, EventArgs e, AsyncCallback cb, object extraData) { WeatherForecast weatherForecast = new WeatherForecast(); IAsyncResult asyncResult = weatherForecast.BeginGetWeatherByZipCode(zip, cb, weatherForecast); return asyncResult; }
在EndAsync方法中,首先获得weatherForecast代理,它是通过额外数据传递给BeginAsync的。然后调用GetWeatherByZipCode web service方法的End..版本获取天气预报。最后,将天气预报绑定到repeater控件,和同步版本中的一样:
private void EndAsync(IAsyncResult asyncResult) { object extraData = asyncResult.AsyncState; WeatherForecast weatherForecast = (WeatherForecast)extraData; WeatherForecasts weatherForecasts = weatherForecast.EndGetWeatherByZipCode(asyncResult); rpWeatherDetails.DataSource = weatherForecasts.Details; rpWeatherDetails.DataBind(); }
最后,TimeoutAsync方法响应超时,例如通过抛出一个异常警告访问者。这个方法和EndAsync方法接收相同的额外数据,所以可以访问在BeginAsync方法中传入的额外数据。
private static void TimeoutAsync(IAsyncResult asyncResult) { // Timeout processing }
异常版本的编程方式显示与正常的同步编程是不同的。首先,操作在pre-render事件后在BeginAsync方法中开始,而不是在页面的OnLoade事件中。而且也不是在调用web service方法后立即处理结果,处理过程在第二个方法EndAsync中进行。本质上,异步编程是处理事件,而不是编写顺序代码。
异步数据访问层
用法
为了保持简单性,异步数据层只访问一个包含书籍的数据库表,它只支持一个方法:根据bookId获取书籍。
不要忘记在使用异步操作的页面的Page指令中设置Async=”true”和设置AsnycTimeout。
<%@ Page Language="C#" Async="true" AsyncTimeout="30" AutoEventWireup="true" CodeFile="Default.aspx.cs" Inherits="_Default" %>
典型的访问异步数据层的代码片断如下:
DataAccessAsync.Books.GetById(bookId, delegate(BookDTO bookDTO) { // This anonymous method will be called when the // operation has been completed. Label1.Text = string.Format( "Id: {0}, Title: {1}, Author: {2}, Price: {3}", bookDTO.Id, bookDTO.Title, bookDTO.Author, bookDTO.Price); }, delegate() { // This anonymous method will be called when a timeout happens. Label1.Text = "Timeout happened"; });
GetById数据访问方法注册根据书籍ID获取书籍的任务。它接收书籍ID,一个当数据获取后调用的方法,一个超时后调用的方法。这里,不定义分离的方法,其后传递给数据访问层方法,而是直接传递匿名方法,使得代码在一个地方。
实现
public delegate void OnFinishedGetByIdDelegate(BookDTO bookDTO); public delegate void NoParamsDelegate(); public static void GetById(int bookId, OnFinishedGetByIdDelegate onFinished, NoParamsDelegate onTimedOut) { AsyncData asyncData = AsyncDataForGetById(bookId, onFinished,nTimedOut); PageAsyncTask pageAsyncTask = new PageAsyncTask(BeginAsync, EndAsync, TimeoutAsync, asyncData, false); Page currentPage; currentPage = (Page)System.Web.HttpContext.Current.Handler; currentPage.RegisterAsyncTask(pageAsyncTask); } private class AsyncData { public object onFinished { get; set; } public NoParamsDelegate onTimedOut { get; set; } public string commandText { get; set; } public List<SqlParameter> parameters { get; set; } public SqlCommand command { get; set; } } private static AsyncData AsyncDataForGetById(int bookId, OnFinishedGetByIdDelegate onFinished, NoParamsDelegate onTimedOut) { AsyncData asyncData = new AsyncData(); asyncData.onFinished = onFinished; asyncData.onTimedOut = onTimedOut; asyncData.commandText = "SELECT BookId, Author, Title, Price FROM dbo.Book WHERE BookId=@BookId; "; List<SqlParameter> parameters = new List<SqlParameter>(); parameters.Add(new SqlParameter("@BookId", bookId)); asyncData.parameters = parameters; return asyncData; } private static IAsyncResult BeginAsync(object sender, EventArgs e, AsyncCallback cb, object extraData) { AsyncData asyncData = (AsyncData)extraData; string connectionString = ConfigurationManager.ConnectionStrings["ConnectionString"].ConnectionString; SqlConnection connection = new SqlConnection(connectionString); connection.Open(); SqlCommand command = new SqlCommand(asyncData.commandText, connection); foreach (SqlParameter sqlParameter in asyncData.parameters) { command.Parameters.Add(sqlParameter); } asyncData.command = command; IAsyncResult asyncResult = command.BeginExecuteReader(cb, asyncData); return asyncResult; } private static void EndAsync(IAsyncResult asyncResult) { object extraData = asyncResult.AsyncState; AsyncData asyncData = (AsyncData)extraData; using (SqlCommand command = (SqlCommand)asyncData.command) { using (command.Connection) { using (SqlDataReader reader = command.EndExecuteReader(asyncResult)) { // GetbyId specific processing OnFinishedGetByIdDelegate onFinishedGetById = asyncData.onFinished as OnFinishedGetByIdDelegate; if (onFinishedGetById != null) { BookDTO bookDTO = null; if (reader.Read()) { bookDTO =new BookDTO(Convert.ToInt32(reader["BookId"]), reader["Author"].ToString(), reader["Title"].ToString(), Convert.ToDecimal(reader["Price"])); } onFinishedGetById(bookDTO); } } } } } private static void TimeoutAsync(IAsyncResult asyncResult) { object extraData = asyncResult.AsyncState; AsyncData asyncData = (AsyncData)extraData; SqlCommand command = (SqlCommand)asyncData.command; command.Connection.Dispose(); command.Dispose(); if (asyncData.onTimedOut != null) { asyncData.onTimedOut(); } }
你可以对这段代码进行改进,例如将BeginAsync、EndAsync和TimeoutAsync方法放到通用的基类中,使得加入对不同类型的对象进行操作更简单。
性能测试
同步、异步版本差异依赖于使用IIS6、IIS7经典模式或IIS7集成模式,硬件、负载等。在II7集成模式下,异步版本的吞吐量是同步版本的至少5倍。
异步写文件
使用FileUpload控件上传文件后,写文件到磁盘使用异步代码,可以带来性能提升。
同步版本
protected void btnUpload_Click(object sender, EventArgs e) { if (!FileUpload1.HasFile) { Label1.Text = "No file specified."; return; } string fileName = FileUpload1.FileName; FileUpload1.SaveAs(Server.MapPath(@"images\" + fileName)); }
异步版本
首先,在Page指令中加入Async=”true”。
<%@ Page Language="C#" Async="true" AutoEventWireup="true" CodeFile="Default.aspx.cs" Inherits="_Default" %>
然后,修改后端代码。很不幸,SaveAs方法没有异步版本,所以只好使用缓冲区保存文件内容,然后异步写入磁盘。
因为文件写入是在BeginAsync中初始化的,所以我们需要一个保存文件详细信息的类,这些信息就可以做为一个单独的对象传递给BeingAsync。
public class FileWriteData { public byte[] fileBytesBuffer { get; set; } public int bufferLength { get; set; } public string fileName { get; set; } public FileStream fs { get; set; } }
上传事件处理程序:
protected void btnUpload_Click(object sender, EventArgs e) { if (!FileUpload1.HasFile) { Label1.Text = "No file specified."; return; } FileWriteData fwd = new FileWriteData(); fwd.fileBytesBuffer = FileUpload1.FileBytes; fwd.bufferLength = FileUpload1.PostedFile.ContentLength; fwd.fileName = FileUpload1.FileName; PageAsyncTask pageAsyncTask = new PageAsyncTask(BeginAsync, EndAsync, null, fwd, false); RegisterAsyncTask(pageAsyncTask); } private IAsyncResult BeginAsync(object sender, EventArgs e, AsyncCallback cb, object extraData) { FileWriteData fwd = (FileWriteData)extraData; FileStream fs = new FileStream(Server.MapPath(@"images\" + fwd.fileName), FileMode.OpenOrCreate, FileAccess.Write, FileShare.Write, 64 * 1024, true); fs.SetLength(fwd.bufferLength); fwd.fs = fs; IAsyncResult asyncResult = fs.BeginWrite(fwd.fileBytesBuffer, 0, fwd.bufferLength, cb, fwd); return asyncResult; } private void EndAsync(IAsyncResult asyncResult) { object extraData = asyncResult.AsyncState; FileWriteData fwd = (FileWriteData)extraData; using (FileStream fs = fwd.fs) { fs.EndWrite(asyncResult); fs.Close(); } Label1.Text = "File saved."; }
读取文件遵循相似的模式,使用Stream方法BeginRead和EndRead。
注意
对异步读写文件要小心,因为异步I/O会有额外的开销,例如线程切换。使用异步I/O操作小文件可能使网站更慢。找出通常上传至网站的文件大小,然后使用典型文件大小进行测试。
异步文件读写并不总是异步执行的。使用压缩文件系统读写和写压缩文件总是同步执行的。扩展写文件也是同步的,但是可以使用FileStream.SetLength给出文件的最终大小解决这个问题。查看一个任务是同步还是异步执行的,检查EndAsync中的IAsyncResult.CompletedSynchronously标志位。
异步web请求
网站在服务器端使用代码加载另一个网页是很少见的,所以这里没有给出例子。通过缓存页面或者使用单独的程序定时读取页面存储到数据库,而不是在每个请求中读取页面,可以极大地提高性能。
如果仍然希望在处理请求时加载其它web页面,在http://msdn.microsoft.com/en-us/library/system.net.webrequest.begingetresponse.aspx可以找到例子。
修改配置
如果服务器上很多空闲的CPU和内存,可以调高执行请求的线程的最大数量。
运行时管理执行请求的线程的数量。所以,即使增加线程的最大数量,也一定会有更大的线程可用。
警告:小心配置。有可能花了很多时间进行配置,结果反而有害。在加入生产环境前,进行测试。
IIS6,IIS 7经典模式
在IIS6和使用经典模式的IIS 7中,线程最大数量由以下公式决定:
最大线程数量 = (最大工作线程 * CPU数量) - 最小空闲线程
CPU数量指服务器处理器核的数量。如果是双核CPU,可以看作两个CPU。
最大工作线程是在machine.config中的processModel元素的一个属性:
<processModel maxWorkerThreads="20" .... >
最小空闲线程是在machine.config中的httpRuntime元素的一个属性:
<httpRuntime minFreeThreads="8" .... >
I/O相关配置
如果调用很多web service或访问很多其它网站,下列设置可能有用:
- maxIoThreads:线程池中每个CPU用于I/O处理的线程的最大数量。它必须大于或等于最小空闲线程。
<processModel maxIoThreads="20" ... >
maxconnection:设置线程池中连接同一IP地址的最大线程数量。例如,下面的设置允许对http://www.example.com有4个连接,对其它地址有两个连接。
<configuration> <system.net> <connectionManagement> <add address = "http://www.example.com" maxconnection = "4" /> <add address = "*" maxconnection = "2" /> </connectionManagement> </system.net> </configuration>
ASP.NET 2.0
在.NET1.1时期,Microsoft总结出上一节中的默认配置对于大多数网站是太低了,因为这些设置使得线程最大值小于服务器硬件能够处理的数量。所以,在ASP.NET 2.0中,引入了autoConfig属性:
<processModel autoConfig="true"/>
当值为true时,autoConfig在运行时修改配置如下:
- 设置maxWorkerThreaders和maxIoThreads为100
- 设置maxconnection 为12*CPU数量
- 设置minFreeThreads为88*CPU数量
- 设置minWorkerThreads为50
每个CPU同时执行的线程数为12,这对于大多数网站是足够的。然而,如果网站有很多同步代码等候数据库、web services等,可以设置autoConfig为false,然后自己修改。
IIS 7集成模式
当使用集成模式时,同时执行的请求的最大数量就不是由上面几节描述的配置决定的,而是由下面的设置定义的:
- maxConcurrentRequestsPerCPU:限制每个CPU执行的请求的数量,即使有更多的线程可用。在.NET 3.5及以前的版本中,默认值是12,在.NET 4中是5000。如果设为0,没有限制。
- maxConcurrentThreadsPerCPU:限制每个CPU处理请求的线程的数量。默认值是0,没有限制。
这两个值必须一个为0,另一个不为0。
.NET 3.5 SP1以前的版本,如果需要修改这些值,必须在注册表的HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\ASP.NET\2.0.50727.0位置加入同名DWORD键。
.NET 3.5 SP1以前的版本,可以在C:\Windows\Microsoft.NET\Framework\v2.0.50727或C:\Windows\Microsoft.NET\Framework\v4.0.30128的aspnet.config中加入这些设置:
<system.web> <applicationPool maxConcurrentRequestsPerCPU="12" maxConcurrentThreadsPerCPU="0" requestQueueLimit="5000"/> </system.web>
在.NET4中,maxConcurrentRequestPerCPU的默认值是5000,这意味着实际没有做任何限制。
当maxConcurrentRequestPerCPU设为5000并且maxConcurrentThreadsPerCPU设置为0时,处理请求的最大线程数量实际时线程池中的线程数量。从ASP.NET 2.0起,限制是每CPU100线程,所以,在双核CPU机器上,就是200线程。
如果要提高这个限制,使用System.Threading命名空间的ThreadPool对象的SetMaxThreads属性进行设置。
警告:小心设置,更多的线程意味着更多的任务切换开销。
最大队列大小
当请求进入速度比处理速度快时,就会被排队。如果队列太长,ASP.NET返回给新请求“503—Server Too Busy”错误。
最大队列大小是通过machine.config中的ProcessModelSection元素的requestQueueLimit属性设置:
<processModel requestQueueLimit ="5000" … >
超时
如果页面同步访问外部资源并且外部资源非常慢,这些页面就可能超时。如果没有超时机制,页面就可能永久阻塞线程。
.NET 2.0及更高的默认超时时间是110秒,.NET 1.0和1.1是90秒。在一个繁忙的网站,阻塞线程110秒时间太长了。
设置超时时间为30秒,可能会导致更多的超时,但会更好的利用线程。
超时在web.config中的httpRuntime元素的executionTime属性设置:
<system.web> <httpRuntime executionTimeout="30" /> </system.web>
也可以单独设置在每个页面的超时时间:
<configuration> ... <location path="Page.aspx"> <system.web> <httpRuntime executionTimeout="30" /> </system.web> </location> ... </configuration>
另外一个选择时异步访问外部资源。
更多资源
- ASP.NET Thread Usage on IIS 7.0 and 6.0:http://blogs.msdn.com/b/tmarq/archive/2007/07/21/asp-net-thread-usage-on-iis-7-0-and-6-0.aspx。
- <applicationPool> Element (Web Settings):http://msdn.microsoft.com/en-us/library/dd560842.aspx。
- Performing Asynchronous Work, or Tasks, in ASP.NET Applications:http://blogs.msdn.com/b/tmarq/archive/2010/04/14/performing-asynchronous-work-or-tasks-in-asp-net-applications.aspx。
- Asynchronous Pages in ASP.NET 2.0:http://msdn.microsoft.com/en-us/magazine/cc163725.aspx。