C#中HttpClient的使用小结
在之前的一周里,我利用业余时间帮人做视频下载工具,花了差不多20小时。期间发生的一些事情令我很不愉快,虽然到底是写好了相关类库,但最终成品是不打算做了。好在这段时间算是把 HttpClient 的相关知识复习了一遍,倒也不算白忙一场。
本文便是对此的总结。
------19.10.31 01.14AM-------
朋友联系我,说之前忙疯了,一直没回我。
所以我不生气了,打算认真把这东西做完。
但前面的字就不删了。当作某人的黑历史。
老子的心情还是很不愉悦很不愉悦不愉悦。
----------------------
============ HttpClient 部分 ============
1、创建尽可能少的HttpClient实例
以下代码明显是错误的:
using (var client = new HttpClient()) { /*todo*/ }
官方文档的 Remark 部分对此有详细的介绍。这么做的后果是频繁调用将耗尽socket数量,造成 SocketException 。
正确的做法是创建尽可能少的实例,将针对某一类请求的 HttpClient 放入类的静态变量中,甚至放入静态工具类中。
2、针对性地分配HttpClient实例
基于第一条,显然整个程序集只使用一个 HttpClient 实例是最理想的情况,例如实现一个只需要处理知乎网站访问请求的程序。
然而很多情况下,一个程序集可能会提供多个网站客户端的实现,此时应当针对性地为每个实现分配一个 HttpClient 实例:
因为 HttpClient 只有几个异步方法是线程安全的,其他成员都非线程安全。
必须采用针对性分配的内容是那些必须通过 HttpClientHandler (在.NET Core中应该使用SocketsHttpHandler)及其派生类来设置的内容。
比如 重定向 ,代理 等等。这种情况下,应当使用 HttpClient(HttpMessageHandler) 构造函数初始化实例。
一个特殊的情况是记录cookie。
虽然我直接使用 HttpClientHandler.UseCookies 来使 HttpClient 能够记录cookie,但另一种常见的做法是通过请求获取的 HttpResponseMessage 与 HttpRequestMessage 来手动记录cookie,并通过 HttpClient.SendAsync 方法发送请求。
这样的好处是只需要一个 HttpClient 实例,缺点在于但一旦需要记录更多的网站cookie,那么就需要很多额外的操作。
3、除非确定数据小于83kb,否则应当用流来读取响应内容
首先让我们来关注两个数字:85000,81920。
一个对象,如果它的大小大于85000字节,那么它将会分配在 大型对象堆(LOH) 上。分配LOH具有极大的开销,所以如果数据大于这个值的情况下,依然直接使用 HttpContent.ReadAsByteArrayAsync 或 HttpContent.ReadAsStringAsync 方法来获取数据,则将会造成巨大的性能损失。
正确的做法是使用 HttpContent.CopyToAsync 方法,或者先通过 HttpContent.ReadAsStreamAsync 方法获取流,然后再进行相关操作。
3.5、缓冲区池化。
CopyToAsync
方法会使用 stream
默认的缓冲区。这个缓冲区的大小就是之前提到的81920。
扩大缓冲区的值会提高IO的效率,而缓冲区的复用可以减少内存分配的开销,避免造成内存碎片化,所以我建议使用缓冲区池。
利用 ConcurrentBag<T> 可以在几行代码之内实现缓冲区池:
private static class BytesPool
{
private static readonly ConcurrentDictionary<int, ConcurrentBag<byte[]>> _BytesPool = new ConcurrentDictionary<int, ConcurrentBag<byte[]>>();
public static byte[] Rent(int size) => _BytesPool.GetOrAdd(size, new ConcurrentBag<byte[]>()).TryTake(out var bytes) ? bytes : new byte[size];
public static void Return(byte[] bytes) => _BytesPool.GetOrAdd(bytes.Length, new ConcurrentBag<byte[]>()).Add(bytes);
}
除此之外,我还为Stream
写了一个扩展方法:
public static async Task CopyToAsync(this Stream source, Stream dest, CancellationToken cancellationToken)
{
var buffer = BytesPool.Rent(256 * 256 * 256);
var totalBytes = 0L;
int bytesCopied;
try
{
do
{
bytesCopied = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
await dest.WriteAsync(buffer, 0, bytesCopied, cancellationToken).ConfigureAwait(false);
totalBytes += bytesCopied;
} while (bytesCopied > 0);
}
finally
{
BytesPool.Return(buffer);
}
}
这里我采用的缓冲区的大小是根据我的使用情况定的,实际过程中,可以根据自己的需求来调整。
至于ConcurrentDictionary
的初始化,ConcurrentDictionary<TKey,TValue>() 构造函数使用的默认并发数和初始容量大小分别等于CPU核心数和31,在我这里足够了。
4、“目前只要消息头”
摘录一段我写的代码:
public static async Task<DlStreamInfo> GetStreamInfoAsync(this HttpClient client, string requestUri)
{
var response = await client.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
return new DlStreamInfo(response, await response.EnsureSuccessStatusCode().Content.ReadAsStreamAsync().ConfigureAwait(false),
response.Content.Headers.ContentLength ?? -1);
}
没必要管那个DlStreamInfo
是什么,这里只关注 HttpCompletionOption.ResponseHeadersRead
即可。
HttpCompletionOption 枚举有两个值,除了 ResponseHeadersRead
,默认的情况下会使用它的另一个值ResponseContentRead
。
二者的差别在于,前者表示一旦获取消息头时即可完成相关的请求操作,而不必等到整个内容都完全响应才完成;而后者的意思是等全部内容读取完毕后再加载。
对于下载工具而言,ResponseHeadersRead
是必须的,否则就等着把巨大的数据慢慢加载到内存中吧。少年,听说过 OutOfMemoryException 吗……?
还记得第2节中提到的 HttpRequestMessage 吧?它不仅能够维护提交信息,还可以显示设置请求采用HTTP HEAD
协议,即:服务器在响应中只返回消息头即可。
再看一个扩展方法:
public static async Task<long> GetContentLengthAsync(this HttpClient client, string src)
{
using (var request = new HttpRequestMessage(HttpMethod.Head, src))
{
using (var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false))
{
return response.Content.Headers.ContentLength ?? -1;
}
}
}
此方法在获取源的大小,或者验证的源的可用性时很好用。
最后,我建议在任何时候都优先使用 ResponseHeadersRead
。
5、用分段读取替代永不超时
HttpClient 的请求的默认超时时间是100秒,在网络状况不佳的时候,遇到TaskCanceledException 再常见不过了。
为了缓解这类问题,很多人喜欢这么写:
HttpClient.Timeout = Timeout.InfiniteTimeSpan;
然而这却是个再错误不过的做法,因为这将使得程序变得非常不可控,并非使用`CancellationTokenSource 即可简单解决。
要处理这个问题,增加超时时间是一个方法,另外还可以通过设置 HttpRequestHeaders.Range 来进行分段读取:
public static async Task<DlStreamInfo> GetStreamInfoAsync(this HttpClient client, string requestUri, long? from, long? to)
{
using (var request = new HttpRequestMessage(HttpMethod.Get, requestUri))
{
request.Headers.Range = new RangeHeaderValue(from, to);
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
return new DlStreamInfo(response, await response.EnsureSuccessStatusCode().Content.ReadAsStreamAsync().ConfigureAwait(false),
response.Content.Headers.ContentLength ?? -1);
}
}
我们可以实现这样一个流:当某一段读取完毕后,再通过以上扩展方法请求一个新的流,继续读取余下内容。
6、使用IHttpClientFactory
这一节的内容基于. NET Standard 2.0及以上。
正如前文所言,HttpClient 应该作为静态变量存在,但是这样做有两个问题:首先是管理上的不便,其次是无法处理DNS的变动。
解决方法是使用 IHttpClientFactory 来获取 HttpClient 的实例。这个方式的优势在于,尽管每次都获取一个新的实例,但是在HttpMessageHandler
的生存周期之内,它将被多个 HttpClient 客户端池化并重用。
用法就是最基本的依赖注入:
var serviceProvider = new ServiceCollection().AddHttpClient("zhihu", client => {
//todo
});
var httpClientFactory = serviceProvider.GetService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient("zhihu");
剩下的不必说明太多,两篇官方文档写的很详细了:
Make HTTP requests using IHttpClientFactory in ASP.NET Core
Use HttpClientFactory to implement resilient HTTP requests
另外对于.NET而言,只有.NET4.6以上才可以使用 IHttpClientFactory 。前提是安装以下两个 Nuget 包:
几百M的大小,代价很大呀,还是写.NET Core的时候直接用比较好。。。。
============ 引申部分 ============
7、一些引申
这一节的内容本来打算写的,但后来忘记了,因为和 HttpClient 没有直接关系……感谢 羽毛 的提醒。
但是我很困了,就不像之前写的那么详细了。纯手打,有什么拼写错误请见谅。
7.1: ConfigureAwait(false)
和 GetAwaiter().GetResult()
上文中的代码中有大量的ConfigureAwait(false)
,为什么要加这个呢?
它的作用是不进行上下文信息的捕获,效率会有所提升。但需要注意两点:
- http://ASP.NET Core不需要它,但现在.NET 5还没破壳,为了兼容还是加上为好。
- 仅在异步方法不需要上下文时才使用此方法。
注1:相关的讨论详见Cleanup use of ConfigureAwait (Discussion) · Issue #1313
注2:感谢 羽毛 君的提醒。因为WFP和Winform中是使用上下文的,所以即便是.NET Core,扔应当在适当的地方加上ConfigureAwait(false)
。另一方面,在这种情况下使用这个方法应当更加谨慎。 简单的说就是,当你写的异步方法涉及到对于修改UI的委托的访问(即跨线程UI访问)时,不应使用此方法。
极少数的情况下需要将异步方法化为同步(比如并行处理,这个后面说),一种方法是直接用Task.Result
属性,另一种就是 GetAwaiter().GetResult()
,二者的区别在于前者会把异常封入AggregateException
,而后者直接抛出引发错误的异常,有利于异常的处理,所以应当选择后者。
7.2 异步的阻塞处理
以我这次写的东西为例,部分网站需要用户登录,而登录操作只能进行一次,且只能有一个线程执行此任务。
要处理这个问题当然可以采用各种锁。但对于异步方法而言,最佳做法是使用信号量 SemaphoreSlim
类来处理。
SemaphoreSlim
则是对windows中针对异步操作设计的API的封装,与lock
不同,它天然对异步友好。
而更多情况下要面对的问题是,需要实现某段代码最多可以同时有一定数量的线程来访问,这也是SemaphoreSlim
的用武之地。
7.5 异步化包装
在某些情况下,比如实现完全异步化,需要将一些同步的方法包装为异步方法。
直接使用Task.Run
是一个糟糕的方法,因为它专门分配了线程。
通常情况下建议使用Task.FromResult
来封装包装结果,它只是一种封装,并不会为此分配线程。
而在.NET Core中,更好的选择是ValueTask
。 它不仅不会分配线程,值类型的特点使得它不会在堆上分配内存。
在.NET中使用ValueTask
需要4.6及以上,引用Tasks.Extensions
包。
但要注意的是,它包含两个字段,所以在返回时会有额外的开销,这一点应当自己斟酌。
另外更进一步的做法是使用IValueTaskSource
,但我太困了懒得写了还TM有三节啊。
7.4 并行化处理
对于下载M3u8列表的视频而言,每个文件的体积都很小,但数量众多,一个个下载明显不智。利用 HttpClient 的异步方法线程安全的特点,可以做到同时下载多个文件。但此时有两个问题:
- 带宽上限。
- 线程数量。
解决的方法是,维持固定数量的Task
分批次进行下载。但这种实现方式需要额外写一些逻辑代码来控制和管理Task
, 所以如果想要偷懒可以采取一个不被推荐但简单有效的方式:并行化处理。
public async Task<bool> Download(string path, string name, IProgress<string> per)
{
await Task.Run(() => DoM3u8Download(_DownLoadItems, paths, null), _CancelSource.Token).ConfigureAwait(false);
ParallelLoopResult DoM3u8Download(string[] urlList, string[] pathList, params long[] sizes)
{
return Parallel.For(0, urlList.Length, new ParallelOptions() { CancellationToken = _CancelSource.Token, MaxDegreeOfParallelism = 5 }, i =>
{
ds.CopyToAsync(fs, _CancelSource.Token, per).GetAwaiter().GetResult();
});
}
}
上面我只摘录了最关键的代码,但已经能很好的说明这个思路了。
但需要注意的是,偷懒也要有基本法,悠着来,控制好并发数,否则直接使用GetAwaiter().GetResult()
容易导致线程池不足的情况。
----------------19.11.04 更新---------------------
告诉我了一个有趣的处理方法。比如我确定需要用5个线程进行下载任务,可以直接这么写:
await Task.WhenAll(Partitioner.Create(Urls, false).GetPartitions(5).Select(url=>
Task.Run(async()=>
{
while (url.MoveNext())
{
await Task.Delay(100).ConfigureAwait(false);
Console.WriteLine(url.Current);
}
}
));
代码是手打的,有错误请见谅,不过显而易见,这个方式很棒,外部不必加上丑陋的Task.Run包装了。这里唯一要做的就是数据分组,手动创建5个Task
,然后异步等待。
现在唯一的问题在于,虽然直觉上这种方式的开销和速度应该优于并行(Parallel
),但这货打我脸不是一次两次了。所以等有时间我会翻一下两者的代码然后做个测试。
不过再强调一次,无论结果如何,这个方法都非常适合我的场景,或者说类似的场景:以尽可能少的代码实现控制固定数量的线程处理长耗时高开销任务。
7.5 本地方法
在上一小节中我用到了本地方法,是因为有更复杂的重试流程要写,单独写个方法可读性更好。不过在大多数情况下,尽可能用本地方法取代lambda表达式确实是一个很好的选择。
简单的说,除非lambda表达式没有捕获外部的内容(比如局部变量,参数,实例),或者只捕获了静态对象时,才不会执行额外的堆分配操作。而设计良好的本地方法往往可以提供更少的堆分配操,并降低调用委托的开销。
7.4小节中虽然使用了本地方法,但并没有利用本地方法的优势,而是出于其他的考量。
- 本地方法被转化为委托,则会造成委托分配。
- 本地方法所使用的外部方法的局部变量一定要显式在方法声明中写出 ,否则会造成闭包分配与委托分配,与直接使用lambda表达式无异。
7.6 缓冲区池的另一个选择
羽毛 君提到的ArrayPool<T>
是比上文(3.5节)自己实现的缓冲区池更好的选择。
(错误1:原因在于这个类内部使用的是SpinLock
,)而上文(3.5节)提到的两个线程安全容器内部使用的是普通lock
机制。
不过在我看来ArrayPool<T>
有两个缺点:
- NET使用的话需要引入System.Buffers包,想要写个小程序就不合适了。
- (错误2:会额外分配一些不需要的容量的缓冲区存入缓冲区池的“桶”中)。
第一个缺点很好解决,ArrayPool<T>
的相关代码很简单,如果是.NET
用,不防直接把代码贴过去,之前很长一段时间里只能用.NET 3.5,那时我就是这么干的,实际上现在我用Concurrent系列容器实现池子时,用的rent/return
就是从这学的……
第二个缺点就仁者见仁智者见智了,有些大项目确实需要多种不同容量级别的同类型缓冲区,此时势必要用到ArrayPool<T>
。但对于小项目而言,我更希望节约一些。
错误1更正:随着代码的更新,ArrayPool
和以前的实现已经是完全不同的东西了,关键代码并未使用自旋锁,而是普通的lock。详见 dotnet/corefx 。
错误2更正: 新版的ArrayPool
使用了惰性分配机制。
7.7 修仙时遇到的问题。
我应该老老实实睡觉,不该睡前再刷次知乎。
五点了妈蛋好困我该怎么办。
===========================
19.10.31 05:30AM再次更新:
我发现对于这篇东西大家的关注点都在7.4节,也就是并行化的设计(或者说线程管理)上,看来大家对GetAwaiter().GetResult()
都特别看不惯啊……
其实我也是。但这里用这种方式进行处理,并不会损失异步的优势,因为Parallel
本身就是多线程的,也就是说并不会导致异步方法的阻塞,只是设计上的“不优雅”。在我看来这块最大的问题是使用了Task.Run
,这是一个可以避免的开销:委托分配,闭包分配,线程分配……等等。
强调一下场景:这是一个.NET的桌面程序,且我希望最后只有一个二进制文件。
在这种情况下,7.3中描述的方式是我能想到的最简单的方式了,虽然很难看。
要是不考虑体力问题,针对这个的场景我能想到的最好的方式是用TaskScheduler
, 也就是之前提到的实现线程管理,当然这意味着必须继承它然后实现一个类,我是懒得专门为了这个小工具搞这么一出了……
顺便说,我还试过用信号量来控制,但效率有些糟,不如直接并行。
最后,我不确信针对上文提到的场景,我目前的思考和选择是最优的,我甚至不能确定这是最简单的,期待大家能给我更多的建议。而这也是此次更新的重点。