从 ASPX 页面进行 Web 服务调用时的性能考虑[转载]
Matt Powell
Microsoft Corporation
2003 年 7 月 22 日
摘要:Matt Powell 介绍了如何通过异步方法消除使用 Microsoft ASP.NET 的 Web 服务调用的性能问题和线程池资源的消耗问题。(本文包含一些指向英文站点的链接。)
下载此专栏的相关示例代码。
在 Microsoft,围绕 Web 内容的创建正发生着一些有趣的变化。正如您可能想到的,您在 Microsoft.com 上看到的大量技术信息需要来自 Microsoft 不同部门的许多人员的参与。最近,负责创建这些内容的人员,例如来自产品文档组、Microsoft Product Support Services(大多数 KnowledgeBase 都是由该其生成的)、MSDN 及其他组的人员,正在重新考虑他们的内容范围。PSS 首先提出了这一常见问题,即从 ASP.NET Web 页面进行 Web 服务调用时的性能问题。我们觉得该问题超出了一篇 KnowledgeBase 文章所能涵盖的范围,但尚不足以构成一个产品文档。
不过,它对于“At Your Service”专栏却是一个很好的主题。
情况:从 ASP.NET 页面调用 Web 服务时的性能破坏我们在本专栏中讨论 Web 服务时,期望在各种情况下都可以享用 Web 服务。一个主要的情况是从中间层环境(如 ASP.NET Web 页面)访问 Web 服务。为 MapPoint .NET Web 服务的用户提供支持的人员经常收到这样的问题,即用户在使用其 Web 服务时,对 MapPoint .NET 的调用可能需要相当长的时间。这本身并不是什么问题,但某些其他因素可以使之成为比表面上要严重得多的大问题。
HTTP 双连接限制
HTTP 规范表明,一个 HTTP 客户端与任一服务器最多可以同时建立两个 TCP 连接。这可以防止单个浏览器在浏览某个页面(例如,具有 120 个嵌入的缩略图)时,由于连接请求过多而使服务器负载过重。此时,浏览器将仅创建 2 个连接,然后通过这两个管道开始发送 120 个 HTTP 请求,而不是创建 120 个 TCP 连接并通过每个连接来发送 HTTP 请求。对于中间层,此方法的问题在于,中间层可能会有 50 个同时请求连接的用户。如果不得不为每个用户进行一次 MapPoint .NET Web 服务调用,将会有 48 个用户等待两个管道中的一个空闲下来。
线程池限制
ASP.NET 处理传入的请求的方式是通过一个称为进程线程池的一组线程为其提供服务。正常情况下,请求传入后,池中某个空闲的线程将为其提供服务。这里的问题在于,进程线程池不会创建无数个线程来处理大量的请求。具有最大线程数限制是一件好事,因为如果我们无限地创建线程,计算机上的全部资源将只能用来管理这些线程了。通过限制所能创建的线程数,我们可以把线程管理的系统开销保持在一个可控的水平。如果某个请求传入时线程池中的所有线程都被占用,则该请求将排队等候,在忙线程完成任务后,空闲出来的线程才能处理新请求。此方法实际上比切换到某个新线程更有效,因为不需要在请求之间进行线程切换。但存在的问题是,如果线程的使用效率不高(尤其是在非常忙的 Web 服务器上),则等候的请求队列会变得很大。
考虑一下从 ASP.NET 页面进行 Web 服务调用的情况。如果进行同步调用,则正在运行的线程将被阻塞,直到 Web 服务调用完成为止。在调用期间,线程无法进行任何其他活动。它无法处理其他请求,只能等待。如果某个单处理器计算机上具有默认的工作线程数 20,则只需 20 个同时进行的请求即可用完全部线程,以后的请求必须排队等候。
该问题不仅限于 Web 服务
不仅调用 Web 服务的用户会遇到从 Web 页面进行调用时的拥堵且耗时较长的问题。进行任意数量的较长的调用都会遇到同样的问题,例如:SQL Server™ 请求、长文件的读取或写入、各种 Web 请求或访问某个并发资源(其中锁定会造成严重的延迟)。实际上,有许多使用 Web 服务的情况,其服务调用比较迅速,并不是什么问题。但您或许会理解,如果您想通过代理服务器调用 MapPoint .NET Web 服务,所使用的连接具有一定的延迟,同时相应的服务可能又要花费一些时间来处理请求,则您可能在各处位置都看到延迟的情况,并且如果站点很忙,便可能出现问题。
改善问题该问题的某些方面可以通过对环境进行某些配置设置来改善。我们看一下可用于改善该问题的某些配置设置。
maxconnections
连接到 Web 资源的默认双连接限制可以通过一个名为 connectionManagement 的配置元素来控制。connectionManagement 设置允许您添加要让其采用非默认连接限制的站点的名称。可以将以下内容添加到典型的 Web.config 文件中,将您连接的所有服务器的连接限制默认值增加到 40。
<configuration> <system.net> <connectionManagement> <add address="*" maxconnection="40" /> </connectionManagement> </system.net> <system.web> ...
应当注意的是,对本地计算机的连接数量从来都没有限制,因此,如果是连接到本地主机,则此设置无效。
maxWorkerThreads 和 minFreeThreads
如果收到 HTTP 503 错误(“服务暂时过载”),则表明线程池中的线程已全部占用,并且请求队列也已超出最大值(appRequestQueueLimit 的默认设置为 100)。对于 IIS 5.0 安装,可以简单地增加线程池的大小。而对于 IIS 6.0 安装(与 IIS 5.0 不兼容),这些设置将无效。
maxWorkerThreads 和 maxIoThreads 分别控制工作线程数以及处理新提交的 ASP.NET 请求的线程数。这些设置需要在您的 Machine.config 中进行配置,它们将影响您计算机上运行的所有 Web 应用程序。maxWorkerThreads 是 Machine.config 中的 processModel 元素的一部分,并且您在查看后会发现,该设置的默认值为每个处理器 20 个线程。
minFreeThreads 设置可以在 Machine.config 中进行配置,或者在您的应用程序的 Web.config 文件中的 httpRuntime 元素下进行配置。该设置的作用是,当空闲的线程数低于所设置的限制时,将禁止使用线程池中的线程来处理传入的 HTTP 请求。如果您需要某个进程线程池线程完成挂起的请求,这会很有用。如果所有的线程都被用来处理传入的 HTTP 请求,并且这些请求在等待另一个线程完成其处理,那么就会进入死锁状态。例如,如果您正在从 ASP.NET 应用程序进行对某个 Web 服务的异步 Web 服务调用,并且在等待回调函数完成该请求,就会出现这种情况。因为回调必须在进程线程池中的空闲线程上进行。如果查看一下您的 Machine.config,将会注意到 minFreeThreads 设置的默认值为 8,如果工作线程池的限制为 20,则该默认值还可以满足需要,但是,如果线程池的大小增加到 100,该默认值就太小了。
应当注意的是,如果您的 ASP.NET 应用程序对本地计算机进行 Web 服务调用,则线程池限制的问题将被激化。例如,我为此专栏创建的测试应用程序调用与 ASPX 页面同处一台计算机上的 Web 服务。因而,对于阻塞的调用,一个线程被同时用于 ASPX 页面和 ASMX Web 服务请求。这有效地使 Web 服务器处理的同时请求数增加了一倍。在同时进行两个 Web 服务请求(使用异步 Web 服务调用)的情况下,我们最终使同时进行的请求数增加了两倍。为避免在回调本地计算机时出现此类问题,您应当考虑您的应用程序的体系结构,使其简单地直接从 ASPX 代码来执行 Web 方法中的代码。
Windows XP 限制
我们必须要注意,如果您在一个 Windows® XP 计算机上进行某项测试,则所面临的另一个限制是 XP Web 服务器对所允许的同时连接数的人为限制。因为 Windows XP 不是服务器平台,其同时连接数被限制为 10。这对于开发环境中的测试通常没问题,但是如果试图进行任何复杂的测试,该限制问题就会比较严重。本地计算机的连接不受此限制影响。
真正的解决方案:异步请求处理调整配置设置是一种改善问题的方法,而在实际设计 Web 应用程序时通过某种方式彻底解决问题则是另一回事。等待阻塞的调用完成的线程永远也不会有更好的调整余地,因此,解决的办法是完全避免阻塞问题。异步处理请求就是一个适当的解决方案。这表现在两个方面:进行异步 Web 服务调用,以及在 ASP.NET Web 应用程序中异步处理请求。
异步解决方案的第一部分:异步 Web 服务调用
在以前的专栏中,我写了有关异步调用 Web 服务的问题。能够使线程不用等待 Web 服务调用完成是创建释放线程以便处理更多请求的异步页面处理模型的关键部分。此外,异步调用 Web 服务也比较简单。
请考虑以下 ASPX 页面的 Visual Basic® .NET 代码:
' 错用同步 Web 服务调用所造成的性能极差的 ' 页面! Public Class SyncPage Inherits System.Web.UI.Page Protected WithEvents Label1 As System.Web.UI.WebControls.Label Protected WithEvents Label2 As System.Web.UI.WebControls.Label Private Sub Page_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load '调用 Web 服务 Dim proxy As New localhost.Service1 Label1.Text = proxy.Method1(500) Label2.Text = proxy.Method1(200) End Sub End Class
此代码非常易懂。页面加载时将创建一个 Web 服务代理实例,然后用该实例两次调用一个名为 Method1 的 Web 方法。Method1 只返回包含传递给该方法的输入参数的字符串。为了向该系统添加一定程度的延迟,Method1 在返回字符串之前还休眠了 3 秒钟。从调用返回到 Method1 的字符串被放在 ASPX 页面上的两个标签的文本中。该页面提供的性能极差,并且像一块海绵一样从进程线程池中吸取线程。由于在 Method1 Web 方法中有 3 秒钟的延迟,对该页面的一个调用至少要 6 秒钟才能完成。
以下代码片段显示了一个类似 Web 页面的代码,只不过现在进行的是异步 Web 服务调用。
Public Class AsyncPage Inherits System.Web.UI.Page Protected WithEvents Label1 As System.Web.UI.WebControls.Label Protected WithEvents Label2 As System.Web.UI.WebControls.Label Private Sub Page_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load '调用 Web 服务 Dim proxy As New localhost.Service1 Dim res As IAsyncResult = proxy.BeginMethod1(500, Nothing, Nothing) Dim res2 As IAsyncResult = proxy.BeginMethod1(200, Nothing, Nothing) Label1.Text = proxy.EndMethod1(res) Label2.Text = proxy.EndMethod1(res2) End Sub End Class
同样,该页面将创建一个 Web 服务代理,然后两次调用 Method1 Web 方法。不同的是,现在调用的是 BeginMethod1,而不是直接调用 Method1。BeginMethod1 调用将立即返回,这样我们就可以开始第二次调用该方法。与第一个示例中等待第一个 Web 服务调用完成不同,现在我们可以同时开始这两个调用。对 EndMethod1 的调用只是在特定的调用完成前会造成阻塞。
值得注意的是,当我们从 ASPX 页面返回后,响应将发送给客户端。因此,在获得所需的数据之前,我们无法从 Page_Load 方法返回。这就是我们要阻塞 Web 服务调用直至其完成的原因。好的方面是两个调用可以同时执行,因此先前 6 秒钟的延迟现在将降到 3 秒钟左右。这虽然好一些,但仍然创建了阻塞的线程。我们真正需要的是在完成 Web 服务调用的同时,能够释放线程以便其处理 HTTP 请求。问题在于,ASPX 页面的处理模型没有一个异步执行模式。不过,ASP.NET 确实提供了一个解决此问题的方法。
异步解决方案的第二部分:异步 PreRequestHandler 执行
ASP.NET 支持称为 HttpHandlers 的类。HttpHandlers 是实现 IHttpHandler 接口的类,用于为带有特定扩展名的文件的 HTTP 请求提供服务。例如,如果查看一下 Machine.config 文件,您将注意到,有许多 HttpHandlers 服务于带有扩展名(如 .asmx、.aspx、.ashx 甚至 .config)的文件的请求。对于带有特定扩展名的文件的请求,ASP.NET 将查看其配置信息,然后调用与其相关联的 HttpHandler 为该请求提供服务。
ASP.NET 还支持写事件处理程序,在处理 Http 请求过程中的各个时候都可以发生这类事件。其中一个事件是 PreRequestHandlerExecute 事件,它恰好发生在某个特定请求的 HttpHandler 被调用之前。还有一个对 PreRequestHandlerExecute 通知的异步支持,可以注册这些通知以使用 HttpApplication 类的 AddOnPreRequestHandlerExecuteAsync 方法。HttpApplication 类源自基于 Global.asax 文件创建的事件处理程序。我们将使用异步 PreRequestHandler 选项为 Web 服务调用提供异步执行模式。
在调用 AddOnPreRequestHandlerExecuteAsync 之前要做的第一件事是创建一个 BeginEventHandler 和一个 EndEventHandler 函数。请求传入后,将调用 BeginEventHandler 函数。我们将在此时开始异步 Web 服务调用。BeginEventHandler 必须返回一个 IAsyncResult 接口。如果您正在进行一个 Web 服务调用,则可以只返回由 Web 服务 begin 函数返回的 IAsyncResult 接口(在我们的示例中,将由 BeginMethod1 方法返回一个 IAsyncResult 接口)。在我创建的示例中,我想执行与前面的 Web 页面示例(其中揭示了同步和异步 Web 服务调用)相同的操作。这就意味着我必须创建自己的 IAsyncResult 接口。我的 BeginEventHandler 代码如下所示:
Public Function BeginPreRequestHandlerExecute( ByVal sender As Object, _ ByVal e As EventArgs, _ ByVal cb As AsyncCallback, _ ByVal extraData As Object) As IAsyncResult If Request.Url.AbsolutePath _ = "/WebApp/PreRequestHandlerPage.aspx" Then Dim proxy As MyProxy = New MyProxy proxy.Res = New MyAsyncResult proxy.Res.result1 = proxy.BeginMethod1( _ 500, _ New AsyncCallback(AddressOf MyCallback), _ proxy) proxy.Res.result2 = proxy.BeginMethod1( _ 300, _ New AsyncCallback(AddressOf MyCallback), _ proxy) proxy.Res.Callback = cb proxy.Res.State = extraData proxy.Res.Proxy = proxy Return proxy.Res End If Return New MyAsyncResult End Function
关于此代码还有许多有趣的事情值得注意。首先,针对此虚拟目录处理的每个 HTTP 请求都将调用此代码。因此,我做的第一件事就是检查请求的实际路径,查看它是否是我要为其提供服务的页面的路径。
我的函数使用了一些有趣的输入参数来调用。cb 参数是 ASP.NET 传递给我的回调函数。ASP.NET 希望在我的异步工作完成后,可以调用由它提供给我的回调函数。它们就是通过这种方式知道何时调用我的 EndEventHandler。同样,如果我只进行一个 Web 服务调用,则只需将回调传递给 BeginMethod1 调用,然后 Web 服务调用将负责调用函数。但在本例中,我进行了两个单独的调用。因此,我创建了一个传递给两个 BeginMethod1 调用的中间回调函数,并且在回调代码中检查两个调用是否都已完成。如果没完成,我将返回;如果已完成,我将调用原始的回调。另一个有趣的参数是 extraData 参数,它在 ASP.NET 调用我时为 ASP.NET 保存了状态。我在调用由 cb 参数指定的回调函数时必须返回该状态信息,因此,我将其存储在所创建的 IAsyncResult 类中。我的回调代码如下所示:
Public Sub MyCallback(ByVal ar As IAsyncResult) Dim proxy As MyProxy = ar.AsyncState If proxy.Res.IsCompleted Then proxy.Res.Callback.Invoke(proxy.Res) End If End Sub
还应当提到的一点是,我创建的实现 IAsyncResult 的类(称为 MyAsyncResult)将在查询 IsCompleted 属性时检查两个挂起 Web 服务调用的完成情况。
在 EndEventHandler 中,我只是从 Web 服务调用获取结果,然后将其存储在当前的请求上下文中。该上下文与要传递给 HttpHandler 的上下文相同。在本例中,它是 .aspx 请求的处理程序,这样它便可以用于我的标准代码。我的 EndEventHandler 代码如下所示:
Public Sub EndPreRequestHandlerExecute(ByVal ar As IAsyncResult) If Request.Url.AbsolutePath _ = "/WebApp/PreRequestHandlerPage.aspx" Then Dim res As MyAsyncResult = ar Dim proxy As MyProxy = res.Proxy Dim retString As String retString = proxy.EndMethod1(proxy.Res.result1) Context.Items.Add("WebServiceResult1", retString) retString = proxy.EndMethod1(proxy.Res.result2) Context.Items.Add("WebServiceResult2", retString) End If End Sub
由于已经接收了 .aspx 页面的数据,因此实际的页面处理也就非常简单了。
Public Class PreRequestHandlerPage Inherits System.Web.UI.Page Protected WithEvents Label1 As System.Web.UI.WebControls.Label Protected WithEvents Label2 As System.Web.UI.WebControls.Label Private Sub Page_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Label1.Text = Context.Items("WebServiceResult1") Label2.Text = Context.Items("WebServiceResult2") End Sub End Class
这不仅仅是理论 -- 它确实起作用!
如果不考虑我没有阻塞了所有线程,至少也使得浪费的资源更少了,因而这还是有意义的。但实际的结果确实会有所不同吗?答案是肯定的“是”!我把此专栏中介绍的三种测试情况放在了一起:从 Web 页面代码进行 2 个阻塞的调用,从 Web 页面代码进行 2 个异步调用,以及从 PreRequestHandler 代码进行 2 个异步调用。我使用 Microsoft Application Center Test 对这三种情况进行了测试,在 60 秒钟内从 100 个虚拟客户端连续发送请求。下图显示的结果表明了在 60 秒钟内完成的请求数。
图 1:100 个同时进行请求的客户端在 60 秒钟内完成的请求
异步 PreRequestHandler 方法处理的请求数大约是排在第二位的方法处理的请求数的 8 倍。因此,该方法使您可以处理更多请求,但是对于单个请求,实际要多长时间才能完成呢?下图显示了这三种方法的平均响应时间。
图 2:100 个同时进行请求的客户端的平均完成响应时间
使用 PreRequestHandler 方法的平均请求响应时间仅为 3.2 秒。假设每个 Web 服务调用的内置延迟为 3 秒钟,则该方法是一种非常有效的解决办法。
我必须指出,这些并非科学的数字是在我的并非科学的办公室中运行的并非科学的计算机上获得的。当然,如果将空闲的线程释放出来,让它们做一些实际的工作确实会改善性能,因而这也很有意义。希望这些结果能够表明性能的改善其实是非常显著的。
PreRequestHandler 方法是很必要的,因为 .aspx 请求的处理程序中没有内置异步请求处理机制。但并非所有 ASP.NET HTTP 处理程序都是这样。我在以前的专栏中曾介绍过具有异步模型的 Web 服务的 .asmx 请求处理程序。PreRequestHandler 方法适用于所有 ASP.NET 请求类型,但使用将异步支持置于 .asmx 处理程序内的编程方式要比使用 PreRequestHandler 编程方式更容易一些。
小结无论何时遇到任何类型的进程耗时较长的性能问题,异步执行模型都是一个很好的方法。在从 .aspx 页面调用 Web 服务的情况下,我们认为可以将异步 Web 服务调用与 ASP.NET 提供的异步执行模式结合起来。这解决了在处理 .aspx 请求的过程中缺乏异步支持的问题。使用此异步方法可以消除性能问题以及线程池资源的消耗问题。