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
, 也就是之前提到的實現線程管理,當然這意味着必須繼承它然后實現一個類,我是懶得專門為了這個小工具搞這么一出了……
順便說,我還試過用信號量來控制,但效率有些糟,不如直接並行。
最后,我不確信針對上文提到的場景,我目前的思考和選擇是最優的,我甚至不能確定這是最簡單的,期待大家能給我更多的建議。而這也是此次更新的重點。