在WebAPI客户端中,每次创建新的HttpClient会带来多大的额外开销?

190

一个WebAPI客户端的HttpClient生命周期应该是什么?
是为多个调用使用一个HttpClient实例更好吗?

像下面的示例(来自http://www.asp.net/web-api/overview/web-api-clients/calling-a-web-api-from-a-net-client)每个请求创建和处理HttpClient的开销是多少?

using (var client = new HttpClient())
{
    client.BaseAddress = new Uri("http://localhost:9000/");
    client.DefaultRequestHeaders.Accept.Clear();
    client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

    // New code:
    HttpResponseMessage response = await client.GetAsync("api/products/1");
    if (response.IsSuccessStatusCode)
    {
        Product product = await response.Content.ReadAsAsync<Product>();
        Console.WriteLine("{0}\t${1}\t{2}", product.Name, product.Price, product.Category);
    }
}

我不确定,你可以使用“Stopwatch”类来对其进行基准测试。但是我的估计是,在相同的上下文中使用所有这些实例,具有单个“HttpClient”更加合理。 - Matthew
1
相关链接:https://dev59.com/dWgu5IYBdhLWcg3wpYjI,https://dev59.com/WGUo5IYBdhLWcg3w3ymi。 - Ohad Schneider
7个回答

245
HttpClient 已被设计成可多次调用,即使跨越多个线程也是如此。 HttpClientHandler 具有应该在调用之间重复使用的凭据和 Cookie。创建新的 HttpClient 实例需要重新设置所有这些内容。此外,DefaultRequestHeaders 属性包含应用于多个调用的属性。在每个请求上重置这些值会破坏其意义。 HttpClient 的另一个主要优点是能够将 HttpMessageHandlers 添加到请求/响应管道中以应用交叉关注点。这些可以用于日志记录、审计、节流、重定向处理、离线处理、捕获指标等各种不同的事情。如果每个请求都创建新的 HttpClient,则所有这些消息处理程序需要在每个请求上设置,同时还需要提供这些处理程序之间共享的任何应用程序级状态。
您使用 HttpClient 的功能越多,就会看到重用现有实例是有意义的。
然而,我认为最大的问题是当 HttpClient 类被处理时,它会处理 HttpClientHandler,然后强制关闭由 ServicePointManager 管理的连接池中的 TCP/IP 连接。这意味着每个使用新 HttpClient 的请求都需要重新建立一个新的 TCP/IP 连接。
从我的测试中,在局域网上使用纯HTTP,性能损失相当小。我怀疑这是因为即使在 HttpClientHandler 尝试关闭连接时,底层的TCP keepalive也在保持连接打开状态。对于通过互联网进行的请求,我看到了不同的情况。由于每次都需要重新打开请求,所以会出现 40% 的性能损失。我怀疑在 HTTPS 连接上的影响甚至更严重。

我的建议是为你连接到的每个不同的API,在你的应用程序生命周期中保留一个HttpClient实例


5
你对这个陈述有多大把握?这很难相信。 HttpClient 对我来说看起来像一个应该经常实例化的工作单元,它会在由 ServicePointManager 管理的连接池中强制关闭 TCP/IP 连接。 - usr
2
@vkelman 是的,即使您使用 new HttpClientHandler 创建了一个实例,仍然可以重用 HttpClient 的实例。此外,请注意,HttpClient 还有一个特殊的构造函数,允许您重用 HttpClientHandler 并在不中断连接的情况下释放 HttpClient。 - Darrel Miller
2
@vkelman 我更喜欢保留HttpClient,但如果您更喜欢保留HttpClientHandler,则在第二个参数为false时它将保持连接打开。 - Darrel Miller
2
听起来连接是绑定到HttpClientHandler上了。我知道为了扩展,我不想销毁连接,因此我需要保留一个HttpClientHandler并从其中创建所有的HttpClient实例,或者创建一个静态的HttpClient实例。然而,如果CookieContainer绑定到HttpClientHandler上,并且我的cookies需要针对每个请求进行区分,你有什么建议?我想避免在静态HttpClientHandler上进行线程同步,通过修改其CookieContainer来处理每个请求。 - Dave Black
2
@Sana.91 你可以这样做。最好将其注册为服务集合中的单例,然后以这种方式访问它。 - Darrel Miller
显示剩余15条评论

87

如果您希望应用程序扩展性更好,效果差异将非常大!根据负载不同,您将看到非常不同的性能数据。正如Darrel Miller所提到的,HttpClient旨在在请求之间进行重复使用,这得到了编写它的BCL团队人员的确认。

我最近参与的一个项目是帮助一家非常大型和知名的在线电脑零售商为黑色星期五/假日流量扩展一些新系统。我们遇到了一些关于HttpClient使用的性能问题。由于它实现了IDisposable接口,开发人员按照通常的做法创建了一个实例,并将其放置在using()语句中。一旦我们开始加载测试,应用程序就会让服务器崩溃-是的,不仅是应用程序,还有服务器。原因是每个HttpClient实例都会在服务器上打开一个端口。由于GC的非确定性终止以及您正在使用跨越多个OSI层的计算机资源,关闭网络端口可能需要一段时间。事实上,Windows操作系统本身需要最多20秒钟来关闭一个端口(根据Microsoft)。我们正在比端口关闭更快地打开端口-服务器端口耗尽,从而使CPU占用率达到100%。我的解决方法是将HttpClient更改为静态实例,这解决了问题。是的,它是一种可丢弃的资源,但任何开销都远远超过性能差异。我鼓励您进行一些负载测试,以查看应用程序的行为。

您还可以查看WebAPI指南页面的文档和示例,网址为https://www.asp.net/web-api/overview/advanced/calling-a-web-api-from-a-net-client

请特别注意以下提示:

HttpClient旨在在应用程序的整个生命周期中实例化一次并重复使用。特别是在服务器应用程序中,为每个请求创建新的HttpClient实例会在重负载下耗尽可用的套接字数。这将导致SocketException错误。

如果您需要使用具有不同头信息、基础地址等的静态 HttpClient,那么您需要手动创建HttpRequestMessage并在其上设置这些值。然后,使用 HttpClient:SendAsync(HttpRequestMessage requestMessage, ...)方法。 .NET Core 更新: 您应该通过依赖注入使用 IHttpClientFactory 来创建 HttpClient 实例。它将为您管理生命周期,您无需显式释放它。请参见在ASP.NET Core中使用IHttpClientFactory进行HTTP请求

1
本帖包含对那些将进行压力测试的人有用的见解! - Sana.91
很好的强调了套接字耗尽的问题。我的团队遇到了"No file descriptors available"错误,因为我们在每个请求上都创建了一个新的HTTP客户端。 - Fearnbuster

10
正如其他答案所述,HttpClient 旨在复用。然而,在多线程应用程序中重用单个 HttpClient 实例意味着您不能更改其有状态属性的值,例如 BaseAddressDefaultRequestHeaders(因此只能在它们在应用程序中是常量的情况下使用)。
解决此限制的一种方法是使用一个类来包装 HttpClient,该类可以复制您需要的所有 HttpClient 方法(例如 GetAsyncPostAsync 等),并将它们委托给单例的 HttpClient。但这相当繁琐(您还需要包装扩展方法),幸运的是,还有另一种方法 - 不断创建新的 HttpClient 实例,但重用底层的 HttpClientHandler。只需确保不要释放处理程序即可。
HttpClientHandler _sharedHandler = new HttpClientHandler(); //never dispose this
HttpClient GetClient(string token)
{
    //client code can dispose these HttpClient instances
    return new HttpClient(_sharedHandler, disposeHandler: false)         
    {
       DefaultRequestHeaders = 
       {
            Authorization = new AuthenticationHeaderValue("Bearer", token) 
       } 
    };
}

2
更好的方法是保持一个HttpClient实例,然后创建自己的本地HttpRequestMessage实例,然后在HttpClient上使用.SendAsync()方法。这样它仍然是线程安全的。每个HttpRequestMessage将有自己的身份验证/URL值。 - Tim P.
@TimP,为什么这样更好?SendAsync 比专门的方法(如 PutAsyncPostAsJsonAsync 等)要不方便得多。 - Ohad Schneider
2
SendAsync允许您更改URL和其他属性(如标头),同时仍保持线程安全。 - Tim P.
2
是的,处理程序是关键。只要在HttpClient实例之间共享它,就没问题了。我之前误读了你的评论。 - Dave Black
1
如果我们保留一个共享处理程序,我们是否仍然需要注意过期的DNS问题? - shanti
显示剩余5条评论

5

与高流量的网站相关,但与HttpClient无直接关系。我们在所有服务中都有下面的代码片段。

        // number of milliseconds after which an active System.Net.ServicePoint connection is closed.
        const int DefaultConnectionLeaseTimeout = 60000;

        ServicePoint sp =
                ServicePointManager.FindServicePoint(new Uri("http://<yourServiceUrlHere>"));
        sp.ConnectionLeaseTimeout = DefaultConnectionLeaseTimeout;

https://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k(System.Net.ServicePoint.ConnectionLeaseTimeout);k(TargetFrameworkMoniker-.NETFramework,Version%3Dv4.5.2);k(DevLang-csharp)&rd=true 可以了解到:

您可以使用此属性来确保 ServicePoint 对象的活动连接不会无限期保持打开状态。此属性适用于需要定期关闭连接并重新建立连接的负载平衡场景。

默认情况下,对于请求而言,如果 KeepAlive 为 true,则 MaxIdleTime 属性设置因闲置而关闭 ServicePoint 连接的超时时间。如果 ServicePoint 具有活动连接,则 MaxIdleTime 不起作用,连接将无限期保持打开状态。

当ConnectionLeaseTimeout属性被设置为-1以外的值时,在经过指定的时间后,通过在请求中将KeepAlive设置为false来服务请求后,活动的ServicePoint连接将被关闭。 设置此值会影响ServicePoint对象管理的所有连接。这个设置有助于调用者跟随您到新的目标,特别是当您在CDN或其他终端后面拥有服务并进行故障转移时。在这个例子中,故障转移后60秒内,所有的调用者都应该重新连接到新的终端。但是,它需要您知道依赖的服务(也就是您所调用的那些服务)和它们的终端。

你仍然会通过打开和关闭连接给服务器带来很大的负荷。如果你使用基于实例的HttpClients和基于实例的HttpClientHandlers,如果不小心,你仍然会遇到端口耗尽的问题。 - Dave Black
不反对。一切都是权衡取舍。对我们来说,遵循重新路由的CDN或DNS意味着收入稳定,而不遵循则会损失收益。 - No Refunds No Returns

2

需要指出的一件事是,所有“不要使用using”博客都没有提到的是,你需要考虑的不仅仅是BaseAddress和DefaultHeader。一旦你将HttpClient设为静态,就会有内部状态跨请求传递。例如:你正在使用HttpClient进行身份验证以获取FedAuth令牌(忽略为什么不使用OAuth/OWIN等),该响应消息具有Set-Cookie头部用于FedAuth,这会添加到你的HttpClient状态中。下一个登录到你的API的用户将发送上一个人的FedAuth cookie,除非你在每个请求上管理这些cookie。


1
你可能还想参考Simon Timms的这篇博客文章:https://aspnetmonsters.com/2016/08/2016-08-27-httpclientwrong/

但是HttpClient不同。虽然它实现了IDisposable接口,但它实际上是一个共享对象。这意味着,在内部它是可重入和线程安全的。你应该分享一个单一的HttpClient实例在整个应用程序的生命周期中,而不是为每次执行创建一个新的HttpClient实例。让我们看看为什么。


0
作为第一个问题,虽然这个类是可处理的,但使用using语句并不是最好的选择,因为即使您处理了HttpClient对象,底层套接字也不会立即释放,可能会导致严重的“套接字耗尽”问题。
但是,当您将其用作单例或静态对象时,HttpClient还存在第二个问题。在这种情况下,单例或静态HttpClient不会遵守DNS更改。
.net core中,您可以使用HttpClientFactory来执行相同的操作,例如:
public interface IBuyService
{
    Task<Buy> GetBuyItems();
}
public class BuyService: IBuyService
{
    private readonly HttpClient _httpClient;

    public BuyService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<Buy> GetBuyItems()
    {
        var uri = "Uri";

        var responseString = await _httpClient.GetStringAsync(uri);

        var buy = JsonConvert.DeserializeObject<Buy>(responseString);
        return buy;
    }
}

ConfigureServices

services.AddHttpClient<IBuyService, BuyService>(client =>
{
     client.BaseAddress = new Uri(Configuration["BaseUrl"]);
});

文档和示例请参见此处


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接