很早之前看過一篇關於 HttpClient 生命週期的討論,當時沒什麼感覺,這陣子研究 NSwag,注意到 NSwag 產生的客戶端程式庫,HttpClient 預設被當作建構式參數,由外界傳入生命週期自理。 但一方面它又提供參數允許採用每次呼叫自動建立的可拋做法,並可選擇是否使用完畢要不要 Dispose(),這個設計引發我的好奇。在 NSwag Github 找到解釋

由於 HttpClient 的最佳使用方式,江湖上尚無共識,一派主張因每個 HttpClient 都會建立新連線,為免耗盡 Socket 資源應儘量共用。另一派則認為長期使用現在連線,會遇到 DNS 異動將無法更新。 故設計人員將 HttpClient 何時建立及消滅設成選項,魚或熊掌讓開發者自己選。

這個發現喚起我的回憶,未來用 HttpClient 的機會愈來愈多,趁此釐清觀念也好,參考文章做了功課筆記備忘:

問題怎麼發生的?

HttpClient 推出於 .NET 4.5,我是幾年前升級 .NET Core 才注意到它 HttpClient 頗有取代 WebClient 之勢( .NET Core 早期版本只有 HttpClient 沒有 WebClient),與 WebClient 相比具有支援 DNS 解析快取、Cookie / 身分驗證設定,可同時發送多個 Request、基於 HttpWebRequest/HttpWebResponse(易於測試)、 IO 相關方法均採非同步等優點,缺點是不支援 FTP。

由於 HttpClient 有實作 IDisposable 介面,加上它涉及網路連線等 Unmanaged 資源,依據 .NET 開發人員多年所受訓練的反射動作:遇到 IDiposable 就該用 using 包覆使用範圍,用畢要儘早釋放。

這樣的概念用在 SqlConnection/OracleConnection 等 IDbConnection 物件完全沒問題,原因是資料庫連線在底層會實作 Connection Pool,當 using 結束 IDbConnection 被 Dispose(),並不真的切斷資料庫連線,而是放進 Pool 裡待用,再遇到 new IDbConnetion() 再不另建新連線,而是從 Pool 取出資料庫連線重複使用。 因此,以下寫法沒啥問題:(排除可合併為單一查詢的可能)

for (var i = 0; i < 1000; i++) 
{
    using (var cn = new SqlConnection(cnStr)) 
    {
        //資料庫作業    
    }
}

既然 HttpClient 也是 IDisposable,背後有使用網路連線,所以 .NET 開發者依直覺寫成這樣也是合情合理滴:

for (var i = 0; i < 1000; i++) 
{
    using (var c = new HttpClient()) 
    {
        //使用 HttpClient 連上同一個網站作業
    }
}

問題來了,由於 HttpClient 底層沒有實作 Connetion Pool 無法重複使用連線,以上程式將對同一個台網站開啟 1000 條 Socket 連線,而這 1000 條連線在 HttpClient Dispose() 後會以 TIME_WAIT 狀態繼續存活 240 秒才被真的釋放。當殘留連線數量龐大時,除了消耗記憶體資源,甚至可能用光可用 Socket Port 號碼,導致無法再建立新連線。TIME_WAIT 是 TCP/IP 協定的一部分,不該為此調整,重複使用連線才是治本之道。延伸閱讀:HttpClient 殘留連線與耗盡 Socket Port 實測

官方建議

好,HttpClient 不該 using,那該怎麼做?MSDN 文件有一段說明:

HttpClient 應該只建立一份並重複利用。為每次請求建立新的 HttpClient,重度使用下可能用光 Socket Port 導致 SocketException。以下是正確的寫法:

public class GoodController : ApiController
{
    // OK
    private static readonly HttpClient HttpClient;

    static GoodController()
    {
        HttpClient = new HttpClient();
    }
}

而 HttpClient 的以下方法是 Thread-Safe 的,在多執行緒下同時呼叫也不會打架,可安心服用:

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

共用 HttpClient 副作用

共用靜態 HttpClient 可共用連線避免 TIME_WAIT 連線殘留,但這也衍生新問題 - 當 HttpClient 使用 xxx.yyy.zzz DNS 名稱連上網站,它會記憶 DNS 解析結果,但因缺乏失效機制快取將永久有效,若 DNS 記錄修改,必須重新啟動程序才會重新解析 DNS 取得新 IP。 在一些實務情境,程式可沒法說重啟就重啟,針對此有個簡單解法是對特定網站指定 ConnectionLeaseTimeout,強迫 .NET 在一段時間後關閉連線,下次重建連線將可重新解析 DNS。

var sp = ServicePointManager.FindServicePoint(new Uri("http://xxx.yyy.zzz"));
sp.ConnectionLeaseTimeout = 600*1000; // 10分鐘

實務應用上,若 HttpClient 用於 Web API 客戶端,Web API URL 是固定的,可在 HttpClient 建構時一併指定 ConnectionLeaseTimeout;若為動態傳入 URL 參數,則需每次存取網站前針對該 URL 設定。除此之外,還有 Dispose HttpClient、指定 HttpClient.DefaultRequestHeaders.ConnectionClose = true 等做法,也可克服 HttpClient 不反映 DNS 異動問題。延伸閱讀:HttpClient 無法反應 DNS 異動的解決方式

那 WebClient 呢?

首先,WebClient 已是昨日黃花,WebClient 官方文件已明白揭示:

We don't recommend that you use the WebClient class for new development. Instead, use the System.Net.Http.HttpClient class. 白話:寫新程式就別再 WebClient 惹 ,用 HttpClient 吧! (除非是 .NET 3.5/4.0)

WebClient 沒被設計成可共用,由 Headers、QueryString、Credentials 這些與特定連線相關的屬性可證明,而 Download*/Upload* 方法均非 Thread-Safe (多緒執行時會丟 NotSupportedException),使用時採隨用隨建,用完即拋策略就對了,每次建立 WebClient 都會開新連線,但,就這樣吧。

HttpClient 的進化

ASP.NET Core 2.1 / .NET 4.6 起推出 HttpClientFactory,藉由 Connection Pool 機制一舉改善前述問題。詳情可參考 Yowko 的文章:在 .NET Core 與 .NET Framework 上使用 HttpClientFactory

特此感謝 LienFa Huang 、fredli 分享。

結論

總結心得如下:

  1. WebClient 會被 HttpClient 取代,已不建議再用
  2. HttpClient 不適合採取隨用隨建用完即拋策略,可能殘留大量 TIME_WAIT 連線甚至導致 Socket Port 耗盡出錯
  3. HttpClient 正確使用方法為只建立一份,以 static 方式共用
  4. HttpClient 支援 DNS 解析快取,但永久有效無法反映 DNS 異動,此點可透過指定 ConnectionLeaseTimeout 或強制關閉連線克服
  5. .NET Framework 4.6+ / .NET Core 2.1+ 起新增 HttpClientFactory,透過 ConnectionPool 概念一舉解決連線共用及 DNS 異動更新問題

Study of HttpClient singleton issue.


Comments

# by fredli

用 HttpClientFactory,Pool與DNS問題都解決了 https://docs.microsoft.com/zh-tw/dotnet/standard/microservices-architecture/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests

# by Jeffrey

to fredli,已補充於本文,感謝分享。

# by 胖賊

黑暗大大, 是不是筆誤啊? [HttpClient 的進化] ASP.NET 2.1 / .NET 4.6 應該是 ASP Core 2.1 / .NET 4.6 ??

# by Jeffrey

to 胖賊,身為以錯字見長的部落客,不經意間露個兩手也是很合情合理滴... Orz 感謝指正。

# by Victor

vy Yowko's Notes 應該是 by Yowko's Notes

# by Jeffrey

to Victor, 感謝指正,已修改。

# by xiang

黑大 請教一下 當GoodController被大量連線呼叫時, 此時HttpClient還是會不斷建立,這樣是否還是有問題? 還是說 HttpClient 直接用singleton方式注入 比較好?

# by Jeffrey

to xiang, 建立 HttpClient 的程式寫在靜態建構式 static GoodController(),從頭到尾只會建立一次。

# by JACKY

請問一下 static HttpClient 在asp.net 的action 中被使用 這樣URI 或 POST DATA 會被突然改掉嗎 例如有兩個request進來 一個要POST A網站 一個要POST B網站 目前我的CODE還是每一個都NEW client = new HttpClient (handler); response = client.PostAsJsonAsync (new Uri (wr.EXCUTE_URL), jsonobj).ConfigureAwait (false).GetAwaiter ( ).GetResult ( );

# by Jeffrey

to JACKY,HttpClient 的 Get**、Post** 方法基本上都是 Thread-Safe 的,同時叫用不會互相干擾,而依據官方建議,應避免每次建立新的以免拖累效能: HttpClient is intended to be instantiated once and re-used throughout the life of an application. Instantiating an HttpClient class for every request will exhaust the number of sockets available under heavy loads. 參考:https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclient?redirectedfrom=MSDN&view=net-5.0#remarks

# by JACKY

TO Jeffrey 我的程式 其實是一個轉介 所以每次 HttpClient 所帶的HEADER與憑證都會不同 會不同根據前端的需求而改變 如果使用static的情況下 如果在還沒發動前不知到是否會有被置換的風險 有拜讀您的文章我知道在POST** GET** 都是 Thread-Safe

# by Jeffrey

to JACKY, 若 Header 與憑證的種類是有限的,例如有五種,我會先建好五個 HttpClient,依需求選用其中一種,類似 HttpFactory Named Client 的概念。( https://docs.microsoft.com/zh-tw/aspnet/core/fundamentals/http-requests?view=aspnetcore-5.0#named-clients ) 若情境是每次的 Header 都不一樣,可以建立 HttpRequestMessage 每次客製: var request = new HttpRequestMessage(); request.Headers.Add("HeaderName", headerValue); var response = await client.SendAsync(request);

# by Ho.Chun

請問黑大,使用 HttpClient 時,有遇到過這個訊息嗎 ? 🤔 "System.IO.IOException: Unable to read data from the transport connection: 因為執行緒結束或應用程式要求,所以已中止 I/O 操作。" 我使用 static readonly HttpClient Client = new HttpClient() 然後並沒有使用任何 using() 和 .Dispose()

# by Jeffrey

to Ho.Chun, 由訊息判斷像是網路連線被外力切斷,直覺是偏網路面的問題,建議改用 PowerShell Invoke-WebRequest/curl、.NET WebClient 測試同一 URL 對照看看。

Post a comment