某段使用 HttpWebRequest 呼叫 WebService 的程式偶發"A connection that was expected to be kept alive was closed by the server."錯誤,依字面理解,像是系統試圖重複使用某條被標為 keep-alive 的網路連線,但它被伺服器(或中間的網路設備)切斷掉了。斷線茶包是另一個故事,而這件事讓我興起一些疑問,當我們在 .NET 裡使用 HttpWebRequest 發送請求,跟網站間也會用完連線先不切斷留待稍後重複使用?決定來研究這個之前沒想過跟探索過的問題。

HTTP 協定允許客戶端在送出請求時加上 Connection: Keep-Alive Header,如此伺服器會在傳完回應後保持連線開啟狀態,後續的 GET/POST 請求可沿用連線,省去另開新連線的時間與消耗資源(重建 TCP 連線的成本蠻高的,尤其如果還涉及 HTTPS 加密交握程序的話),概念跟資料庫 Connection Pooling 相同。而從 HTTP 1.1,連線預設就會持續連線,如果想要用完即關,需加上 Connection: Close Header。

HttpWebRequest 是用 ServicePoint 管理 HTTP 連線,一般開發者可能跟它不熟,但講到 ServicePointManager 大家應不陌生,之前略過 TLS 憑證檢查 跟指定 TLS 1.2 通訊都靠它,當我們使用 HttpWebRequest 或 WebClient (註:WebClient 底層也是用 HttpWebRequest 實作) 連線網站時,也是由 ServicePointManager 負責建立及管理 ServicePoint。關於 ServicePoint 的運作方式,微軟有篇連線管理介紹值得一讀。

簡單來說,ServicePoint 以 Scheme Identifier (http / https) 、主機名稱及 Port (註:文件未寫到 Port,但依常理跟實測是有的)為單位共用,例如:ServicPointManager 會為 http://blog.darkthread.net、https://blog.darkthread.net、http://blog.darkthread.net:5000 建立三個 ServicePoint。每個 ServicePoint 的連線成為一個 Pool 共用及重複使用並有同時連線數量上限,超過時必須排隊使用。ServicePoint 如閒置過久或總數量超過上限時會被註銷,這部分均由 ServicePointManager 負責管理並有 MaxServicePointIdleTime、MaxServicePoints 可調控。依據 HTTP 1.1 規範,每個程序對特定網站的最大同時連線數為兩條,在一些重度使用情境下可透過 ServicePointManager.DefaultConnectionLimit 斟酌調整。

另外,Windows 整合式驗證(Kerberos)會與連線關聯,若程序修改 WebRequest.Credentials 同時以不同身份連上 IIS 主機,記得要指定 WebRequest.ConnectionGroupName 以免登入身分錯置,這部分的細節可參考微軟文件

理論說完,來做實驗眼見為憑。要觀察 HttpWebRequest 背後的 TCP 連線,用 Fiddler 或 Wireshark 之類的封包偵測工具是種做法,但這回我打算用更 Hacking 的方式,透過 System.Reflection 偷看 ServicePoint 的私有屬性、欄位值(Field),取得 Sytem.Net.Connetion 物件的 Socket 屬性,由其 EndPoint (IP:PortNumber) 判斷連線是不是同一條。偷看 Socket 的程式如下,原理是取得 ServicePoint.m_ConnectionGroupList (型別為 Hashtable),取其中第一組(本測試也只會有一組) ConnectionGroup (型別為 ArrayList),若 ConnectionGroup 只有一條 Connetion,就取得該 Connetion 的 Socket:

#region 使用 Reflection 提取 ServicePoint 內部 Connetion Socket 資訊
static FieldInfo m_ConnectionGroupList = typeof(ServicePoint)
    .GetField("m_ConnectionGroupList", BindingFlags.NonPublic | BindingFlags.Instance);
static FieldInfo m_ConnectionList = typeof(ServicePoint).Assembly.GetType("System.Net.ConnectionGroup")
    .GetField("m_ConnectionList", BindingFlags.NonPublic | BindingFlags.Instance);
static PropertyInfo Socket = typeof(ServicePoint).Assembly.GetType("System.Net.Connection")
    .GetProperty("Socket", BindingFlags.NonPublic | BindingFlags.Instance);

public static Socket TryGetSocket(ServicePoint svcPnt, WebRequest req)
{
    Hashtable connGrpList = m_ConnectionGroupList.GetValue(svcPnt) as Hashtable;
    var connGrp = connGrpList[connGrpList.Keys.Cast<string>().First()];
    var conns = (m_ConnectionList.GetValue(connGrp) as ArrayList);
    if (conns.Count > 1) throw new ApplicationException("More than one connection");
    return Socket.GetValue(conns[0]) as Socket;
}
#endregion

至於測試函式如下:(拆成傳回 string 及 Console.WriteLine() 兩段是為了配合多執行緒模式)

static void TestWebRequestAndShow(string url, bool keepAlive = true, bool showSocket = true)
{
    TestWebRequest(url, keepAlive, showSocket).Split('\n').ToList().ForEach(l =>
    {              
        if (l.StartsWith("[")) Console.ForegroundColor = ConsoleColor.Cyan;
        if (l.StartsWith(" #")) Console.ForegroundColor = ConsoleColor.Yellow;
        Console.WriteLine(l);
        Console.ResetColor();
    });
}

static string TestWebRequest(string url, bool keepAlive, bool showSocket)
{
    var sw = new Stopwatch();
    sw.Start();
    var sb = new StringBuilder();
    sb.AppendLine($"[{url}]");
    var req = (HttpWebRequest)HttpWebRequest.Create(url);
    req.Method = "GET";
    req.KeepAlive = keepAlive;
    using (var resp = req.GetResponse()) {
        using (var sr = new StreamReader(resp.GetResponseStream()))
        {
            var svcPnt = req.ServicePoint;
            //由 HashCode 判定是不是同一個 ServicePoint
            sb.AppendLine($"ServicePoint HashCode = 0x{svcPnt.GetHashCode():X0000}");
            sb.AppendLine($" * CurrentConnections (Before) = {svcPnt.CurrentConnections}");
            if (showSocket)
            {
                var socket = TryGetSocket(svcPnt, req);
                sb.AppendLine($" # Local TCP Port = { socket.LocalEndPoint }");
            }
            var html = sr.ReadToEnd();
            sb.AppendLine($" * CurrentConnections (After) = {svcPnt.CurrentConnections}");
            //Console.WriteLine(html.Substring(0, 100) + "...");
        }
    }
    sw.Stop();
    sb.AppendLine($" * Duration: {sw.ElapsedMilliseconds:n0}ms");
    return sb.ToString();
}

器材準備好了,來做實驗!

實驗1 Scheme、Host、Port 相同時會共用 ServicePoint

測試 http/https、localhost/127.0.0.1 以及不同 Port 號的組合:

static void TestDifferentUrls()
{
    TestWebRequestAndShow("http://localhost/aspnet/Forms/form1.html");
    TestWebRequestAndShow("http://localhost/aspnet/sites.xml");
    TestWebRequestAndShow("https://localhost/aspnet/sites.xml");
    TestWebRequestAndShow("http://127.0.0.1/aspnet/sites.xml");
    TestWebRequestAndShow("http://127.0.0.1:8088/aspnet/sites.xml");
}

一如預期,只有 httq://localhost/aspnet/Forms/form1.html 跟 httq://localhost/aspnet/sites.xml 共用 ServicePort 及連線 Port,只要 Scheme、Host 或 Port 不同就會建立新的 ServicePoint:

實驗2 Keep-Alive 是否會共用連線?

分成三組,第一組連續發四個 KeepAlive = false,第二組連續發四個 KeepAlive = true,第三組則是兩個 KeepAlive = true、一個 KeepAlive = false,再一個 KeepAlive = true:

static void TestKeepAlive()
{
    string url = "http://localhost/aspnet/sites.xml";
    Console.WriteLine("=== 4 KeepAlive=false ===");
    TestWebRequestAndShow(url, false);
    TestWebRequestAndShow(url, false);
    TestWebRequestAndShow(url, false);
    TestWebRequestAndShow(url, false);
    Console.WriteLine("=== 4 KeepAlive=true ===");
    TestWebRequestAndShow(url, true);
    TestWebRequestAndShow(url, true);
    TestWebRequestAndShow(url, true);
    TestWebRequestAndShow(url, true);
    Console.WriteLine("=== mixed ===");
    TestWebRequestAndShow(url, true);
    TestWebRequestAndShow(url, true);
    TestWebRequestAndShow(url, false);
    TestWebRequestAndShow(url, false);
}

實測結果如下,紅線為 KeepAlive = false、綠線為 KeepAlive = true:(這個超長擷圖是用 Snagit 的自動捲動抓圖功能抓的)

由測試結果可以看到,KeepAlive = true 時,使用 StreamReader 讀取 Response Stream 前後的現有連線數目都是 1,而 Local TCP Port 號也維持不變,證明 .NET一直在使用同一條 TCP 連線;而 KeepAlive = false 時,現有連線數目會在讀完 Repsonse Stream 資料後歸零,下次 Local TCP Port 號也將改變,證明 KeepAlive = false 時作業結束會關閉連線,下次再重新開啟。

實驗 3 對特定網站的最大同時連線數預設為 2

我在本機寫了一個 wait2sec.aspx,等待兩秒傳回 OK:

<%@Page Language="C#"%>
<script runat="server">
void Page_Load(object sender, EventArgs e)
{
	System.Threading.Thread.Sleep(2000);
	Response.Write("OK");
}
</script>

然後利用 Task 同時發出 10 個 Request:

static void TestConcurrentRequests()
{
    Console.WriteLine($"DefaultConnectionLimit={ServicePointManager.DefaultConnectionLimit}");
    string url = "http://localhost/aspnet/wait2sec.aspx";
    var tasks = new List<Task>();
    var res = new List<string>();
    for (int i = 0; i < 10; i++)
    {
        Func<int, Task> createTask = (n) =>
            Task.Factory.StartNew(() =>
            {
                res.Add(TestWebRequest(url + "?n=" + n, true, false));
            });
        tasks.Add(createTask(i));
    }
    Task.WaitAll(tasks.ToArray());
    foreach (var t in res) Console.WriteLine(t);
}

預期 10 個 Request 需輪流使用兩條連線發送,會出現排隊現象,但結果出乎意料! ServicePointManager.DefaultConnectionLimit 是 2 沒錯,但實際 ServicePoint 的最大連線數硬生生超過 2,且幾乎沒有 Request 被延遲:

查了一些文章,發現 ServicePointManager.DefaultConnectionLimit 是 2 沒有錯,但當連線對象為本機 IP 時,ServicePoint.ConnectionLimit 為 int32.MaxValue。透過 ServicePointManager.FindServicePoint() 找出不同 URL 對應的 ServicePoint,再查看其 ConnectionLimit 參數可以證明這點:

static void CheckConnectionLimit()
{
    Action<string> showConnLimit = (url) =>
    {
        var limit = ServicePointManager.FindServicePoint(new Uri(url)).ConnectionLimit;
        Console.WriteLine($"{url} => {limit}");
    };
    showConnLimit("http://localhost");
    showConnLimit("http://127.0.0.1");
    showConnLimit("http://192.168.50.7");
    showConnLimit("http://blog.darkthread.net");
    showConnLimit("http://www.google.com.tw");
}

由測試結果,當 URL 的主機為 localhost、127.0.0.1 或本機所屬 IP (本例為 192.168.50.7) 時,ConnectionLimit = int32.MaxValue,否則為 2。

改連外部網址,測試結果便符合預期,同時連線數不超過 2,若同時發出的大量 Request 需排隊消化。

了解這個特性,在需要發送大量 Request 的應用情境,便可透過適度放大 ServicePointManager.DefaultConnectionLimit 或針對特定 HttpWebRequest.ServicePoint.ConnectionLimit 來加快處理速率。

做完實驗,對於 .NET HttpWebRequest / WebClient 背後的連線行為,總算有較清楚的認識。

補充:至於 .NET Core 使用的 HttpClient,因應跨平台的要求,底層已不再使用 ServicePoint 實作,而是由 HttpClientFactory 處理連線 Pooling。

Experiments to obesrve how .NET ServicePoint handles HTTP connetion pooling and reusing.


Comments

Be the first to post a comment

Post a comment