关注我的朋友们一定记得,我不止一次强调过在ASP.NET应用程序中使用异步方式处理请求对于提高吞吐量的作用。不过似乎很多朋友们一直没有理解这样做的原因,亦或是对这样做的效果没有一个实际的“体会”,甚至在质疑这么做的功效。现在我将向大家进行一个演示,我们一起来看一下这么做的实际效果如何。
限制最大工作线程数量
对于ASP.NET 2.0应用程序来说,一个工作线程即为一个客户端请求的处理单位,如果所有工作线程被占完,那么站点就无法处理其他请求。使用异步方式处理请求是ASP.NET 2.0中新增的高级特性,它充分利用操作系统和CLR的功能,使得应用程序在等待IO-Bound操作完成时不会占用线程池中的工作线程(Worker Thread)。关于这一点,我曾经在《正确使用异步操作》一文中进行了较为详细的描述。在CLR 2.0 SP1之后,最大工作线程的数量变成了CPU数 * 250——不过不同托管环境(如IIS或SQL Server),均可有不同体现,而我们的试验很难占用如此多的工作线程,因此进行试验的第一步便是限制应用程序中最大工作线程的数量。在.NET应用程序中,可以通过ThreadPool.SetMaxThreads静态方法设置线程池中最大工作线程数量。
与此同时,我们还应该使用ThreadPool.SetMinThreads方法来设置线程池中“必须保留”的最小线程数量。该值默认为1,它意味着在初始情况下线程池中只保留1个线程。如果同时来访多个请求,那么线程池就必须创建额外的线程。线程池创建线程的最大速度为500毫秒一个,因为实际上一个线程的工作往往能够很快完成,这样线程就能够“复用”了。如果没有这个限制,那么线程池就可能在短时间内分配太多线程反而导致性能降低。当空闲时,线程池也会逐渐销毁线程,以避免系统维护太多线程而导致的多余开销。在我们的试验中,必须马上能够动用足够的工作线程来处理请求,否则就会把大量的时间耗费在等待线程创建上,降低了试验结果的代表性。
ThreadPool.Get/SetMaxThreads方法都会涉及到Complete I/O Port Threads这个值,它在我们试验中并不会影响什么。具体原因目前我也不清楚,原本以为它应该限制了异步IO的数据,但是实验下来却不然。
我们可以使用以下方法来修改线程池中最大及最小线程数量:
void SetThreads(int min, int max) { int worker, io; ThreadPool.GetMaxThreads(out worker, out io); ThreadPool.SetMinThreads(min, io); ThreadPool.SetMaxThreads(max, io); }
试验同步请求
我的测试环境为Windows Server 2008 x86 Enterprise Edition下的IIS 7。当然,这个试验在IIS 6中也能进行——不过,Vista下的IIS 7限制了10个并发连接数量,因此您无法在Vista下进行这个试验。
我们使用最为普通的工具来进行测试:Tinyget、Powershell以及perfmon。Tinyget是IIS Resource Toolkit中的工具之一,可以用于模拟数量不多的并发请求,常常用于重现一些简单并发环境下出现的问题。Powershell,我们主要是使用它的Measure-Command命令来测试执行一条Tinyget语句所消耗的时间。Measure-Command最简单的语法是Measure-Command {...},其中大括号里包含的是被测量的脚本。permon自然广为人知,我们主要用其来检测ASP.NET Applications\Requests Executing的值,它表示了同时执行请求的数量。
现在我们准备一个Sync.ashx,它将会访问数据库,并执行一个WAITFOR函数,其目的是停留3秒钟:
public class Sync : IHttpHandler { public void ProcessRequest(HttpContext context) { using (SqlConnection conn = new SqlConnection("...")) { SqlCommand cmd = new SqlCommand("WAITFOR DELAY '00:00:03';", conn); conn.Open(); cmd.ExecuteNonQuery(); } context.Response.ContentType = "text/plain"; context.Response.Write("Sync"); } public bool IsReusable { get { return false; } } }
将最大及最小工作线程数量设为10,20,30,分别执行以下脚本:
Measure-Command {.\tinyget -srv:localhost -uri:/Sync.ashx -threads:30 -loop:1}
tinyget命令threads参数表明同时使用多少个线程进行请求,而loop参数表明“每个线程”将请求多少次。试验结果如下:
Max Worker Threads | 10 | 15 | 20 |
Max Request Executing | 6 | 11 | 16 |
Execution Time (s) | 15.14 | 9.10 | 6.13 |
permon Snapshot |
从试验结果中我们可以发现,可同时执行的请求数比最大工作线程少4(思考题:另外4个在做什么呢?),而同时执行的请求的数量越多,执行所有请求所消耗的时间也在越小。这和我们之前的想法基本一致。
试验异步请求
构建一个异步Handler:
public class Async : IHttpHandler, IHttpAsyncHandler { public void ProcessRequest(HttpContext context) { } public bool IsReusable { get { return false; } } private SqlConnection m_conn; private SqlCommand m_cmd; private HttpContext m_context; public IAsyncResult BeginProcessRequest( HttpContext context, AsyncCallback cb, object extraData) { this.m_context = context; this.m_conn = new SqlConnection("Data Source=...;...;Asynchronous Processing=true"); this.m_cmd = new SqlCommand("WAITFOR DELAY '00:00:03';", this.m_conn); this.m_conn.Open(); return this.m_cmd.BeginExecuteNonQuery(cb, extraData); } public void EndProcessRequest(IAsyncResult result) { this.m_cmd.EndExecuteNonQuery(result); this.m_conn.Dispose(); this.m_context.Response.ContentType = "text/plain"; this.m_context.Response.Write("Hello World"); } }
唯一可能值得提到的是,如果要对SQL Server进行异步数据访问,则必须在连接字符串里加上Asynchronous Processing标记。那么我们把最大和最小工作线程数量设为10个,并使用以下脚本进行测试:
Measure-Command {.\tinyget -srv:localhost -r:7788 -uri:/Sync.ashx -threads:30 -loop:1} [System.Threading.Thread]::Sleep(2000) Measure-Command {.\tinyget -srv:localhost -r:7788 -uri:/Async.ashx -threads:30 -loop:1} [System.Threading.Thread]::Sleep(2000) Measure-Command {.\tinyget -srv:localhost -r:7788 -uri:/Async.ashx -threads:40 -loop:1} [System.Threading.Thread]::Sleep(2000) Measure-Command {.\tinyget -srv:localhost -r:7788 -uri:/Async.ashx -threads:50 -loop:1}
上述脚本首先将同时发起30次同步请求,再发起三次异步请求,数目分别是30、40和50。试验结果如下:
Max Request Executing | 6 | 30 | 40 | 50 |
Execution Time (s) | 15.06 | 3.10 | 3.11 | 3.10 |
permon Snapshot |
结果再明显不过了:应用程序还是只能每次处理6个同步请求,但是对于异步请求来说似乎就“丝毫不受限制”了。为了更好的说明问题,我们再进行最后一个试验。
降低最小线程数量
之前提过,最小线程数量代表了线程池中所维护的最少线程数量。线程池将会根据需要来创建或销毁线程。
我们现在将最小线程数量设为1,最大线程数量设为20,使用同时发起50个请求。试验结果如下:
Max Request Executing | 9 | 50 |
Execution Time (s) | 18.27 | 3.37 |
perfmon Snapshot |
对于同步请求,同时处理的请求数目从1开始以每秒两个的速度增长,最终受限于“保护机制”而停止在9个线程。而对于异步请求,则是瞬间飙升至50个——因为这样的请求不需要占用工作线程,自然无需等待线程慢慢分配了。
看了以上的试验,不知道您是否有所感受?不如您也在自己的机器上试试看呢?