很早之前看過一篇關於 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 感謝指正。

Post a comment


34 - 6 =