HttpClient參觀記:.net core 2.2 對HttpClient到底做了什麼?

.net core 於 10月17日發佈了 ASP.NET Core 2.2.0 -preview3,在這個版本中,我看到了一個很讓我驚喜的新特性:HTTP Client Performance Improvements ,而且在Linux上性能提升了60% !

之前就一直苦於 HttpClient 的糟糕特性,大家耳熟能詳的 You are using HttpClient wrong。 因爲 HttpClient 實現了 IDisposable 如果用完就釋放,Tcp 連接也會被斷開,並且一個HttpClient 通常會建立很多個 Tcp 連接 。 Tcp 連接斷開的過程是有一個 Time_Wait 狀態的,因爲要保證 Tcp 連接能夠斷開,以及防止斷開過程中還有數據包在傳送。這本身沒有毛病,但是如果你在使用 HttpClient 後就將其註銷,並且同時處於高併發的情況下,那麼你的 Time_Wait 狀態的 Tcp 連接就會爆炸的增長, 他們佔用端口和資源而且還遲遲不消失,就像是在 嘲諷 你。所以臨時解決方式是使用靜態的 HttpClient 對象,No Dispose No Time_Wait

後來在 .net core2.1 中,引入了 HttpClientFactory 來解決這一問題。 HttpClientFactory 直接負責給 HttpClient 輸入 全新的 HttpMessageHandle 對象,並且管理 HttpMessageHandle 的生殺大權,這樣斷開 Tcp 連接的操作都由 HttpClientFactory 來用一種良好的機制去解決。

上面說了一堆,其實和主題關係不大。 因爲我在實際生產環境中,無論使用靜態的 HttpClient 還是使用 HttpClientFactory ,在高併發下的情況下 Tcp 連接都陡然上升。直到我將 .net core 2.1 升級到 .net core 2.2 preview 問題似乎奇蹟般的解決了。在介紹 .net core 2.2 如何提升 HttpClient 性能的時候,需要先簡單介紹下 HttpClient :

上面說到了 HttpMessageHandle ( 顧名思義:Http消息處理器 ) 它是一個抽象類,用來幹嘛的呢? 處理請求,又是顧名思義。 HttpClient 的發送請求函數 :SendAsync()

   public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption,
            CancellationToken cancellationToken)
        {
                  ....
        }

最後調用的就是 HttpMessageHandle 的 SendAsync 抽象函數。

事實上通過閱讀源碼發現,幾乎所有繼承 HttpMessageHandle 的子類都有一個 HttpMessageHandle 類型的屬性 : _handle,而每個子類的 SendAsync 函數都調用 _handle 的 SendAsync()。我們知道在初始化一個 HttpClient 的時候或者使用 HttpClientFactory 創建一個HttpClient 的時候都需要新建 或者傳入一個 HttpMessageHandle 我把它叫做起始消息處理器。 很容易想像,HttpClient 的 SendAsync 函數是 一個 HttpMessageHandle 調用 下一個 HttpMessageHanlde 的SendAsync,而下一個 HttpMessageHandle 的SendAsync 是調用下下一個HttpMessageHandle 的 SendAsync 函數。每一個HttpMessageHandle 都有其自己的職責。 層層嵌套,環環相扣,循環往復,生生不息,額不對,這樣下去會死循環。 直到它到達終點,也就是Tcp 連接建立,拋棄回收,發送請求的地方。 所以 HttpClient 的核心 就是由這些 HttpMessageHandle 扣起來,打造成一個 消息通道。 每個請求都無一例外的 通過這個通道,找到它們的最終歸宿。

這其中的順序到底是啥,我並不關心,我只關心其中一個 環:SocketsHttpHandle 因爲.net core 2.2 就是從這個環開始動了手術刀,怎麼動的,按照上面的說法,我們從 SocketHttpHandle 開始順藤摸瓜。其實顧名思義 SocketsHttpHandle 已經很接近 HttpClient 的通道的末尾了。這是 摸出來的 鏈條 :

SocketsHttpHandle ----> HttpConnectionHandler/HttpAuthenticatedConnectionHandler ----> HttpConnectionPoolManager ----> HttpConnectionPoolManager

---> HttpConnectionPool

最後一個加粗是有原因的,因爲我們摸到尾巴了,HttpConnectionPool( 顧名思義 Http 連接 池) 已經不繼承 HttpMessageHandle 了 ,它就是我們要找的終極,也是請求最終獲取連接的地方,也是.net core 2.2 在這條鏈中的 操刀的地方。

接下來就要隆重介紹 手術過程。手術的位置在哪裏? 就是獲取 Tcp 連接的函數。我們看手術前的樣子,也就是System.Net.Http 4.3.3 版本的樣子。

  List<CachedConnection> list = _idleConnections;
 lock (SyncObj)
            {
       
                while (list.Count > 0)
                {
                    CachedConnection cachedConnection = list[list.Count - 1];
                    HttpConnection conn = cachedConnection._connection;

                    list.RemoveAt(list.Count - 1);
                    if (cachedConnection.IsUsable(now, pooledConnectionLifetime, pooledConnectionIdleTimeout) &&
                        !conn.EnsureReadAheadAndPollRead())
                    {
    
                        if (NetEventSource.IsEnabled) conn.Trace("Found usable connection in pool.");
                        return new ValueTask<(HttpConnection, HttpResponseMessage)>((conn, null));
                    }

                    
                    if (NetEventSource.IsEnabled) conn.Trace("Found invalid connection in pool.");
                    conn.Dispose();
                }
                if (_associatedConnectionCount < _maxConnections)
                {
                    if (NetEventSource.IsEnabled) Trace("Creating new connection for pool.");
                    IncrementConnectionCountNoLock();
                    return WaitForCreatedConnectionAsync(CreateConnectionAsync(request, cancellationToken));
                }
                else
                {
                  
                    if (NetEventSource.IsEnabled) Trace("Limit reached.  Waiting to create new connection.");
                    var waiter = new ConnectionWaiter(this, request, cancellationToken);
                    EnqueueWaiter(waiter);
                    if (cancellationToken.CanBeCanceled)
                    {
                        
                        waiter._cancellationTokenRegistration = cancellationToken.Register(s =>
                        {
                            var innerWaiter = (ConnectionWaiter)s;
                            lock (innerWaiter._pool.SyncObj)
                            {
                                if (innerWaiter._pool.RemoveWaiterForCancellation(innerWaiter))
                                {
                                    bool canceled = innerWaiter.TrySetCanceled(innerWaiter._cancellationToken);
                                    Debug.Assert(canceled);
                                }
                            }
                        }, waiter);
                    }
                    return new ValueTask<(HttpConnection, HttpResponseMessage)>(waiter.Task);
                }

整個過程一目瞭然,list 是存放 閒置的Tcp連接 的鏈表,當一個 請求 千辛萬苦到了這裏,它要開始在鏈表的末尾開始 查找有沒有可以用的 小跑車(Tcp連接),先把從小跑車 從 車庫(list)裏搬出來,然後檢查下動力系統,輪子啥的,如果發現壞了( 當前連接不可用 ,已經被服務端關閉的,或者有異常數據的 等等 ), 你需要用把這個壞的車給砸了( 銷燬Tcp連接 ),再去搬下一個小跑車。

如果可以用,那麼很幸運,這個請求可以立刻開着小跑車去飆車(發送數據)。如果這個車庫的車全是壞的或者一個車都沒有,那麼這個請求就要自己造一個小跑車 ( 建立新的TCP 連接 )。 這裏還有一個點,小跑車數量是有限制的。假如輪到你了,你發現車庫裏沒有車,你要造新車,但是系統顯示車子數量已經達到最大限制了,所以你就要等 小夥伴 ( 別的請求 ) 把 小跑車用完後開回來,或者等車庫裏的壞車 被別的小夥伴砸了。

整個過程看起來好像也挺高效的,但是請注意 lock (SyncObj) 上述所有操作的都被上鎖了,這些操作同時只能有一個小夥伴操作,這樣做的原因當然是爲了安全,防止兩個請求同時用了同一個Tcp連接,這樣的話車子會被擠壞掉的。 於是小夥伴們都一個一個的排着隊。 試想,當我們的請求很多很多的時候,隊伍很長很長,那每個請求執行的時間久會變長。

那有沒有什麼方法可以加快速度呢? 其實是有的,事實上危險的操作 只是從 list 中去取車,和造新車。防止搶車和兩個小夥伴造了同一個車。於是手術後的樣子是這樣的:

 while (true)
            {
                CachedConnection cachedConnection;
                lock (SyncObj)
                {
                    if (list.Count > 0)
                    {
                        cachedConnection = list[list.Count - 1];
                        list.RemoveAt(list.Count - 1);
                    }
                    else
                    {
      
                        if (_associatedConnectionCount < _maxConnections)
                        {
                    .
                            IncrementConnectionCountNoLock();
                            return new ValueTask<HttpConnection>((HttpConnection)null);
                        }
                        else
                        {
               
                            waiter = EnqueueWaiter();
                            break;
                        }
                 
                    }
                }

                HttpConnection conn = cachedConnection._connection;
                if (cachedConnection.IsUsable(now, pooledConnectionLifetime, pooledConnectionIdleTimeout) &&
                    !conn.EnsureReadAheadAndPollRead())
                {
                    if (NetEventSource.IsEnabled) conn.Trace("Found usable connection in pool.");
                    return new ValueTask<HttpConnection>(conn);
                }

                if (NetEventSource.IsEnabled) conn.Trace("Found invalid connection in pool.");
                conn.Dispose();
            }

可以看出,它把加鎖執行的內容減少了,將檢查車子的工作放到鎖外。此外 將 lock...while 變成了while...lock 這樣有什麼影響呢:可以減少線程之間的競爭,如評論所說,lock...while 是霸道的,一線程阻塞,萬線程等待競爭,而 while...lock 所有線程展開公平的競爭,大家持有鎖幾乎是相同的機率。

沒想到這樣一個操作,在Linux中提升了60% 的性能。減少了小夥伴之間的等待時間。

那麼 靜態的HttpClient 和 HttpClientFactory 的二者使用,哪個性能更好呢? 我認爲是前者,在高併發的實驗過程中也確實如此。因爲 靜態HttpClient 只有一個消息通道,從頭用到尾,這樣無疑是最高效的。而HttpClientFactory 需要銷燬 HttpMessageHandle 銷燬 HttpMessageHanlde 的過程是鏈條中的節點一個一個被摧毀的過程,直到最後的Tcp 連接池也被銷燬。但是 靜態HttpClient 有個DNS 解析無法更新的硬傷,所以還是應該 使用HttpClientFactory 。 在使用Service.AddHttpClient 時需要設置生存週期,這就是HttpMessageHandle 的生存時長,我認爲應該將其設置的長一些,這樣HttpMessageHandle 或者叫做消息通道 就可以多多的被重複利用,因爲HttpClientFactory 可以給不同HttpClient實例注入相同的HttpMessageHandle

看完這篇文章 還可以看下這篇文章的姊妹篇:工廠參觀記:.NET Core 中 HttpClientFactory 如何解決 HttpClient 臭名昭著的問題

當然我遇到的問題 是否真的是因爲 HttpClient 性能的提升而解決,現在也不能確定。還需要進一步檢測驗證。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章