.NET HttpClient的缺陷和文档错误让开发人员倍感沮丧

摘要

设计错误、缺陷及文档错误等导致正确使用.NET HttpClient变得出奇地困难。所以,即使是生产环境中看似 […]

设计错误、缺陷及文档错误等导致正确使用.NET HttpClient变得出奇地困难。所以,即使是生产环境中看似运行正常的应用程序,在负荷不满的情况下,也遭受着性能问题和运行时故障。

来自ASP.NET Monsters的Simon Timms就通过一篇题为“你正在错误地使用HttpClient,它会破坏软件的稳定性”的文章揭示了这个事实。

人们对这篇文章的反应有所不同,但大多数都显示出了失望和沮丧:

……我是唯一一个读到这种内容时会生气的人吗?我是说,如果我们发布了那样的代码,会产生什么样的后果呢?当然,我们会受到公开批评。但是,当它成为核心代码的一部分,我们只能接受它,设计变通方案,然后一次又一次地写同样的文章。

那严重破坏了最小惊讶原则。

--Voltrondemort

我想说,这表明,HttpClient要么Bug多,要么架构差。无法确定是哪一种。如果是第二种则会很有趣,就需要使用另外一种方法代替它发送Http请求。

-- Eirenarch

C#开发人员所受到的培训

为了理解我们如何陷入了这种境地,我们首先需要看下另外一个面向连接的类SqlConnection。在第一次接受如何使用IDisposable和using语句的培训时,绝大多数开发人员看到的都是类似下面这样的例子:

using (var con = new SqlConnection(connectionString)) {
    con.open();
    //这里使用连接
} //这里关闭连接

虽然针对这个示例的说明并不完善,但这个模式是正确的,而且多年来很好地服务了开发人员。然而,如果你试图将这个模式应用到另一个IDisposable类HttpClient上,则会遇到一些始料未及的问题。

具体来说,它会打开许多套接字,比你实际的需求多许多,这极大地增加了服务器的负载。而且,这些套接字实际上不会被using语句关闭。相反,它们是在应用程序停止使用它们几分钟之后才会关闭。

连接池

回到SqlConnection的例子,多数面向连接的资源都会放入连接池。当你“打开”一个数据库连接时,它首先会检查连接池中是否存在未使用的连接。如果找到了,就重用它,而不是创建一个新的连接。

同样,当你“关闭”一个SqlConnection连接时,它只是简单地将连接放回连接池。最后,一个单独的进程可以关闭长期未使用的连接,但通常来说,你可以认为它会正确地执行操作,实现性能和服务器负载的平衡。

HttpClient的工作机制并非如此。当你销毁它时,它就启动一个进程,关闭在它控制之下的套接字。也就是说,你下次请求连接时,必须重复整个连接新建过程。如果网络延迟很高,或者连接是受保护的(需要新一轮的SSL/TLS协商),就会非常痛苦。

关闭一个套接字需要花费4分钟

如上所述,关闭套接字的过程并不快。当“关闭”套接字时,你真正做的是将其状态置为TIME_WAIT。在一个预先配置好的时间窗口内,Windows将保持该套接字的状态不变,默认情况下是4分钟。这是为了防止有任何剩余的数据包仍在传输。

这大大增加了可用套接字耗尽的可能,导致运行时错误,比如“无法连接到远程服务器。System.Net.Sockets.SocketException:每个套接字地址(协议/网络地址/端口)通常只允许使用一次”。Simon Timms写到:

“通过谷歌搜索那个错误会得出一些有关缩短连接超时时间的糟糕建议。事实上,当服务器上运行的应用程序恰当地使用了HttpClient或者类似的结构,缩短超时时间会导致其他不利的结果。我们需要理解“恰当”是指什么,并修复底层的问题,而不是修改机器层的变量”。

.NET Core的性能影响

大多数仅仅使用.NET Framework完整版的开发人员不会注意到这些问题。不过,那些使用.NET Core的开发人员会有一个额外的问题,使得整个问题更加明显。

在.NET Core的RC1和RC2版本之间,引入了一个Bug,导致HttpClient.Dispose调用会产生一个介于1010毫秒和1030毫秒之间的延迟。在.NET Core 1.2之前,这个问题预计不会得到修复。

使用代理类作为解决方案

虽然HttpClient的文档没有提及,但微软模式&实践的GitHub站点介绍了一种模式。他们把HttpClient称为“代理类”,并作了如下描述:

那些代理类的创建成本很高。因此,它们应该只初始化一次,并在应用程序的整个生存期内重用。然而,这些类的使用方式经常会被误解,开发人员把它们当作资源对待,认为只能根据需要请求并快速释放[……]

Microsoft P&P建议创建一个HttpClient实例,把它存储在一个静态字段中,并在应用程序的生存期内共享该实例,而不是根据需求创建和销毁。

存在误导的文档

这将我们带回到了文档存在误导的问题。虽然是基本的样本文件,但官方文档v118(当前谷歌和必应搜索返回的结果)指出,HttpClient不支持跨线程共享。

该类型的任何公有静态(在Visual Basic中为Shared)成员都是线程安全的,而任何实例成员都不保证线程安全。

差不多就是这样。当然,如果你看一下官方文档v110,就会发现下面这段有用的描述。

HttpClient应该只初始化一次,并在应用程序的整个生存期内重用。在负载很高的情况下,为每个请求初始化一个HttpClient类会耗尽可用的套接字数量。这会导致SocketException错误。下面的例子展示了HttpClient的正确用法:

public class GoodController : ApiController
{
    // OK
    private static readonly HttpClient HttpClient;
    static GoodController()
    {
        HttpClient = new HttpClient();
    }
}

根据这份文档,以下方法是线程安全的。

  1. CancelPendingRequests
  2. DeleteAsync
  3. GetAsync
  4. GetByteArrayAsync
  5. GetStreamAsync
  6. GetStringAsync
  7. PostAsync
  8. PutAsync
  9. SendAsync

这似乎是MSDN文档一直存在的问题。要了解任何类的演进过程,都必须检查每个版本的文档,才能了解到新增或删除的重要段落。

DNS Bug

如果我们遵循目前为止的建议,则会出现其他的问题。Ali Kheyrollahi写道

但事实证明,有一个更严重的问题:HttpClient不遵循DNS变化,它会(通过HttpClientHandler)独占连接,直到套接字关闭。没有时间限制!那么,DNS什么时候会发生变化呢?每次你进行蓝绿部署的时候(在Azure云服务中,当你部署到过渡槽,然后切换生产/过渡槽);每次你改变Azure流量管理器的设置;故障转移场景;许多PaaS服务的内部。

在被报道出来之前,这种情况已经存在了两年多……我在想,我们到底使用.NET构建了怎样的应用程序?

现在,如果DNS变化的原因是故障转移,则连接应该是出现了某种形式的故障,因此,这时会打开一个到新服务器的连接。但是,如果变化的原因是蓝绿部署,你切换了过渡环境和生产环境,而调用仍然会转到过渡环境——这是我们见过的一种行为,但已经通过重启从属服务器修复,我们认为这可能是Azure的一个怪象。我真是个傻瓜——它就在代码里!谁的代码?好吧,起争执了……

这个问题并不是无法修复。理论上讲,HttpClient会遵循DNS TTL(生存期)值,默认为1小时。每次过期后,HttpClient会验证该DNS记录是否仍然有效,并在必要时新建一个连接指向更新后的IP地址。

但是,由于那种情况可能不会出现,所以Kheyrollahi为我们提供了一个更简单的变通方案。借助ServicePointManager,你可以告诉HttpClient自动回收连接。

var sp = ServicePointManager.FindServicePoint(new Uri("http://foo.bar"));
sp.ConnectionLeaseTimeout = 60*1000; // 1分钟

因此,你会希望只在应用程序启动时做这件事,只做一次,并且是针对应用程序将来会访问的所有端点(如果端点是运行时确定的,就需要在发现那个端点时设置那个值)。记住,路径和查询字符串会被忽略,只有主机、端口和模式是重要的。根据场景的不同,可以将该值设为1到5分钟。

查看英文原文Bugs and Documentation Errors in .NET's HttpClient Frustrate Developers

avatar

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: