.net core 于 10月17日发布了 ,在这个版本中,我看到了一个很让我惊喜的新特性:HTTP Client Performance Improvements ,而且在Linux上性能提升了60% !
之前就一直苦于 HttpClient 的糟糕特性,大家耳熟能详的 。
因为 HttpClient 实现了IDisposable
如果用完就释放,Tcp 连接也会被断开,并且一个HttpClient 通常会建立很多个 Tcp 连接 。 Tcp 连接断开的过程是有一个 Time_Wait 状态的,因为要保证 Tcp 连接能够断开,以及防止断开过程中还有数据包在传送。这本身没有毛病,但是如果你在使用 HttpClient 后就将其注销,并且同时处于高并发的情况下,那么你的 Time_Wait 状态的 Tcp 连接就会爆炸的增长, 他们占用端口和资源而且还迟迟不消失,就像是在 嘲讽 你。所以临时解决方式是使用静态的 HttpClient 对象,No Dispose No Time_Wait 后来在 .net core2.1 中,引入了 HttpClientFactory
来解决这一问题。 HttpClientFactory 直接负责给 HttpClient 输入 全新的 HttpMessageHandle
对象,并且管理 HttpMessageHandle 的生杀大权,这样断开 Tcp 连接的操作都由 HttpClientFactory 来用一种良好的机制去解决。
上面说了一堆,其实和主题关系不大。 因为我在实际生产环境中,无论使用静态的 HttpClient 还是使用 HttpClientFactory ,在高并发下的情况下 Tcp 连接都陡然上升。直到我将 .net core 2.1 升级到 .net core 2.2 preview 问题似乎奇迹般的解决了。在介绍 .net core 2.2 如何提升 HttpClient 性能的时候,需要先简单介绍下 HttpClient :
上面说到了 HttpMessageHandle ( 顾名思义:Http消息处理器 ) 它是一个抽象类,用来干嘛的呢? 处理请求,又是顾名思义。 HttpClient 的发送请求函数 :SendAsync()
public TaskSendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) { .... }
最后调用的就是 HttpMessageHandle 的 SendAsync 抽象函数。
事实上通过阅读源码发现,几乎所有继承 HttpMessageHandle 的子类都有一个 HttpMessageHandle 类型的属性 : _handle
,而每个子类的 SendAsync 函数都调用 _handle 的 SendAsync()。我们知道在初始化一个 HttpClient 的时候或者使用 HttpClientFactory 创建一个HttpClient 的时候都需要新建 或者传入一个 HttpMessageHandle 我把它叫做起始消息处理器。 很容易想像,HttpClient 的 SendAsync 函数是 一个 HttpMessageHandle 调用 下一个 HttpMessageHanlde 的SendAsync,而下一个 HttpMessageHandle 的SendAsync 是调用下下一个HttpMessageHandle 的 SendAsync 函数。每一个HttpMessageHandle 都有其自己的职责。
这其中的顺序到底是啥,我并不关心,我只关心其中一个 环:SocketsHttpHandle 因为.net core 2.2 就是从这个环开始动了手术刀,怎么动的,按照上面的说法,我们从 SocketHttpHandle 开始顺藤摸瓜。其实顾名思义 SocketsHttpHandle 已经很接近 HttpClient 的通道的末尾了。这是 摸出来的 链条 :
SocketsHttpHandle
----> HttpConnectionHandler/HttpAuthenticatedConnectionHandler
----> HttpConnectionPoolManager
---> HttpConnectionPool
最后一个加粗是有原因的,因为我们摸到尾巴了,HttpConnectionPool
( 顾名思义 Http 连接 池) 已经不继承 HttpMessageHandle 了 ,它就是我们要找的终极,也是请求最终获取连接的地方,也是.net core 2.2 在这条链中的 操刀的地方。
接下来就要隆重介绍 手术过程。手术的位置在哪里? 就是获取 Tcp 连接的函数。我们看手术前的样子,也就是System.Net.Http 4.3.3 版本的样子。
Listlist = _idleConnections; lock (SyncObj) { while (list.Count > 0) { CachedConnection cachedConnection = list[list.Count - 1]; HttpConnection conn = cachedConnection._connection; list.RemoveAt(list.Count - 1); if (cachedConnection.IsUsable(now, pooledConnectionLifetime, pooledConnectionIdleTimeout) && !conn.EnsureReadAheadAndPollRead()) { if (NetEventSource.IsEnabled) conn.Trace("Found usable connection in pool."); return new ValueTask<(HttpConnection, HttpResponseMessage)>((conn, null)); } if (NetEventSource.IsEnabled) conn.Trace("Found invalid connection in pool."); conn.Dispose(); } if (_associatedConnectionCount < _maxConnections) { if (NetEventSource.IsEnabled) Trace("Creating new connection for pool."); IncrementConnectionCountNoLock(); return WaitForCreatedConnectionAsync(CreateConnectionAsync(request, cancellationToken)); } else { if (NetEventSource.IsEnabled) Trace("Limit reached. Waiting to create new connection."); var waiter = new ConnectionWaiter(this, request, cancellationToken); EnqueueWaiter(waiter); if (cancellationToken.CanBeCanceled) { waiter._cancellationTokenRegistration = cancellationToken.Register(s => { var innerWaiter = (ConnectionWaiter)s; lock (innerWaiter._pool.SyncObj) { if (innerWaiter._pool.RemoveWaiterForCancellation(innerWaiter)) { bool canceled = innerWaiter.TrySetCanceled(innerWaiter._cancellationToken); Debug.Assert(canceled); } } }, waiter); } return new ValueTask<(HttpConnection, HttpResponseMessage)>(waiter.Task); }
整个过程一目了然,list
是存放 闲置的Tcp连接 的链表,当一个 请求
千辛万苦到了这里,它要开始在链表的末尾开始 查找有没有可以用的 小跑车
(Tcp连接),先把从小跑车 从 车库
(list)里搬出来,然后检查下动力系统,轮子啥的,如果发现坏了( 当前连接不可用 ,已经被服务端关闭的,或者有异常数据的 等等 ), 你需要用把这个坏的车给砸了( 销毁Tcp连接 ),再去搬下一个小跑车。
如果可以用,那么很幸运,这个请求可以立刻开着小跑车去飙车(发送数据)。如果这个车库的车全是坏的或者一个车都没有,那么这个请求就要自己造一个小跑车 ( 建立新的TCP 连接 )。 这里还有一个点,小跑车数量是有限制的。假如轮到你了,你发现车库里没有车,你要造新车,但是系统显示车子数量已经达到最大限制了,所以你就要等 小伙伴 ( 别的请求 ) 把 小跑车用完后开回来,或者等车库里的坏车 被别的小伙伴砸了。
整个过程看起来好像也挺高效的,但是请注意 lock (SyncObj)
上述所有操作的都被上锁了,这些操作同时只能有一个小伙伴操作,这样做的原因当然是为了安全,防止两个请求同时用了同一个Tcp连接,这样的话车子会被挤坏掉的。 于是小伙伴们都一个一个的排着队。 试想,当我们的请求很多很多的时候,队伍很长很长,那每个请求执行的时间久会变长。
那有没有什么方法可以加快速度呢? 其实是有的,事实上危险的操作 只是从 list 中去取车,和造新车。防止抢车和两个小伙伴造了同一个车。于是手术后的样子是这样的:
while (true) { CachedConnection cachedConnection; lock (SyncObj) { if (list.Count > 0) { cachedConnection = list[list.Count - 1]; list.RemoveAt(list.Count - 1); } else { if (_associatedConnectionCount < _maxConnections) { . IncrementConnectionCountNoLock(); return new ValueTask((HttpConnection)null); } else { waiter = EnqueueWaiter(); break; } } } HttpConnection conn = cachedConnection._connection; if (cachedConnection.IsUsable(now, pooledConnectionLifetime, pooledConnectionIdleTimeout) && !conn.EnsureReadAheadAndPollRead()) { if (NetEventSource.IsEnabled) conn.Trace("Found usable connection in pool."); return new ValueTask (conn); } if (NetEventSource.IsEnabled) conn.Trace("Found invalid connection in pool."); conn.Dispose(); }
可以看出,它把加锁执行的内容减少了,将检查车子的工作放到锁外。此外 将 lock...while 变成了while...lock 这样有什么影响呢:可以减少线程之间的竞争,如评论所说,lock...while 是霸道的,一线程阻塞,万线程等待竞争,而 while...lock 所有线程展开公平的竞争,大家持有锁几乎是相同的几率。
没想到这样一个操作,在Linux中提升了60% 的性能。减少了小伙伴之间的等待时间。
那么 静态的HttpClient 和 HttpClientFactory 的二者使用,哪个性能更好呢? 我认为是前者,在高并发的实验过程中也确实如此。因为 静态HttpClient 只有一个消息通道,从头用到尾,这样无疑是最高效的。而HttpClientFactory 需要销毁 HttpMessageHandle 销毁 HttpMessageHanlde 的过程是链条中的节点一个一个被摧毁的过程,直到最后的Tcp 连接池也被销毁。但是 静态HttpClient 有个DNS 解析无法更新的硬伤,所以还是应该 使用HttpClientFactory 。 在使用Service.AddHttpClient 时需要设置生存周期,这就是HttpMessageHandle 的生存时长,我认为应该将其设置的长一些,这样HttpMessageHandle 或者叫做消息通道 就可以多多的被重复利用,因为HttpClientFactory 可以给不同HttpClient实例注入相同的HttpMessageHandle
看完这篇文章 还可以看下这篇文章的姊妹篇:
当然我遇到的问题 是否真的是因为 HttpClient 性能的提升而解决,现在也不能确定。还需要进一步检测验证。