代码改变世界

Lab:体会ASP.NET异步处理请求的效果

2009-01-19 13:21  Jeffrey Zhao  阅读(11500)  评论(37编辑  收藏  举报

关注我的朋友们一定记得,我不止一次强调过在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 10 10 10

从试验结果中我们可以发现,可同时执行的请求数比最大工作线程少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 10

结果再明显不过了:应用程序还是只能每次处理6个同步请求,但是对于异步请求来说似乎就“丝毫不受限制”了。为了更好的说明问题,我们再进行最后一个试验。

降低最小线程数量

之前提过,最小线程数量代表了线程池中所维护的最少线程数量。线程池将会根据需要来创建或销毁线程。

我们现在将最小线程数量设为1,最大线程数量设为20,使用同时发起50个请求。试验结果如下:

Max Request Executing 9 50
Execution Time (s) 18.27 3.37
perfmon Snapshot min-sync min-async

对于同步请求,同时处理的请求数目从1开始以每秒两个的速度增长,最终受限于“保护机制”而停止在9个线程。而对于异步请求,则是瞬间飙升至50个——因为这样的请求不需要占用工作线程,自然无需等待线程慢慢分配了。

看了以上的试验,不知道您是否有所感受?不如您也在自己的机器上试试看呢?