HttpClient使用問題淺析

 1.背景

  最近團隊開發的數據庫組件需要通過HTTP請求方式從配置中心獲取連接字符串,該組件採用.NET 6進行開發。考慮到併發的情況,因此對獲取連接字符串的方法進行了加鎖,並進行了雙重檢測(double-checking)。 由於組件框架使用.NET 6,我們採用了HttpClient組件進行HTTP請求。在實際測試中發現,當請求壓力較大的場景下,程序容易出現“死鎖”。爲解決此問題,我們對程序進行了簡單分析,並在本文中記錄了整個分析過程。

以下是模擬代碼:

 1 using System.Diagnostics;
 2 
 3 namespace HttpClientMultiInvokeTestConsole
 4 {
 5     internal class Program
 6     {
 7         static string flag = "";
 8         static object lockObj = new object();
 9 
10         static void Main(string[] args)
11         {
12             var tasks = new List<Task>();
13             for (int i = 0; i < 100; i++)
14             {
15                 var t = new Task(() => LockLock());
16                 tasks.Add(t);
17             }
18 
19             var sw = Stopwatch.StartNew();
20             foreach (var t in tasks)
21             {
22                 t.Start();
23             }
24 
25             Task.WaitAll(tasks.ToArray());
26 
27             sw.Stop();
28             Console.WriteLine(flag);
29             Console.WriteLine(sw.ElapsedMilliseconds);
30         }
31 
32         private static void LockLock()
33         {
34             if (string.IsNullOrEmpty(flag))
35             {
36                 lock (lockObj)
37                 {
38                     if (string.IsNullOrEmpty(flag))
39                     { 
40                         
41                        var content = GetConnectionString().Result;
42  
43 
44                         flag = content;
45                     }
46                 }
47             }
48         }
49 
50         private static async Task<string> GetConnectionString()
51         {
52             HttpClient client = new HttpClient();
53 
54             var content = await client.GetStringAsync("http://www.baidu.com");
55 
56             return content;
57         }
58     }
59 }

 

以上代碼模擬併發的場景,初始化了100個任務。經測試,該代碼在i7-7700K處理器機器上通常需要運行70秒以上,在i7-11800H處理器機器上差別不大。

關於HttpClient的介紹本文不再贅述,見參考資料[1]

2.問題分析

  當我們的程序遭遇性能問題時,通常可能需要考慮以下幾個方面:

  • CPU利用率

   可以使用 Windows 任務管理器或者其他性能監控工具來檢查 CPU 利用率。如果 CPU 利用率很高,說明程序可能存在 CPU 密集型任務,需要優化算法或者減少計算量。

  • 內存使用情況

      可以使用 Windows 任務管理器或者其他性能監控工具來檢查內存使用情況。如果內存使用量很高,說明程序可能存在內存泄漏或者大量的對象創建和銷燬操作,需要進行內存優化。

  • I/O 操作

     可以使用 Windows 任務管理器或者其他性能監控工具來檢查磁盤和網絡 I/O 操作的負載情況。如果 I/O 操作很頻繁,說明程序可能存在 I/O 密集型任務,需要優化讀寫操作,例如使用緩存來減少磁盤或者網絡訪問。

  • 數據庫操作

     如果程序需要頻繁訪問數據庫,可以使用 SQL Server Profiler 或者其他數據庫性能監控工具來檢查 SQL 查詢的性能情況。如果查詢時間很長,說明可能需要進行優化,例如添加索引、優化查詢語句或者減少查詢次數等。

  • 線程和鎖的使用

   如果程序使用了多線程和鎖,需要檢查線程和鎖的使用情況,以及是否存在死鎖和競爭問題。可以使用 Visual Studio 調試器或者其他工具來檢查線程和鎖的狀態[2],以及分析線程和鎖的競爭情況。

從場景模擬代碼可以看出,其中並沒有數據庫操作,也不存在大量I/O操作。待運行程序後,我們使用任務管理器對程序的運行狀況進行了初步瞭解,發現CPU利用率以及內存使用都非常低,幾乎可以忽略,因此問題極有可能出在線程和鎖上。 爲了進一步分析,我們使用 Process Explorer進程管理工具[3]對模擬程序進行了Full DUMP,以便後續使用WinDbg進行分析。如下圖所示:

(圖1)

  得到了進程的完整DUMP文件後,我們便可以開始使用WinDbg調試工具進行調試了。 在WinDbg調試工具中,除了原生的調試指令之外,針對.NET程序的調試還有一些其他的常用擴展,例如:

  • SOS

     SOS 是 .NET 框架提供的一個調試擴展,可以用於分析 .NET 程序的內存狀態和線程狀態。SOS 可以幫助分析和調試 .NET 中的對象、堆棧、線程、GC 和異常等內容。

  • Psscor4

     Psscor4 是一個常用的 WinDbg 插件,可以用於分析和調試 .NET 程序的內存狀態和線程狀態。Psscor4 的功能類似於 SOS,但是它提供了更多的調試命令和功能,例如查看線程狀態、分析 GC、查看對象和數組、分析堆棧和調用鏈等。

  • Son of Strike (SOS) Ex

     SOS Ex 是 SOS 的擴展版本,提供了更多的調試命令和功能,例如查看對象的引用關係、分析 Finalizer 隊列、分析線程池、分析委託等。

  • Netext

     Netext 是一個常用的 WinDbg 插件,可以用於分析和調試 .NET 程序的內存狀態和線程狀態。Netext 提供了一些有用的命令和功能,例如查看對象、分析堆棧、查看線程狀態、分析 GC 等。

  • ManagedXLL

     ManagedXLL 是一個用於分析和調試 .NET 程序的 WinDbg 插件,它提供了一些有用的命令和功能,例如查看對象、分析堆棧、查看線程狀態、分析 GC 等。ManagedXLL 還提供了一些 Excel 函數,可以將調試信息輸出到 Excel 表格中。

  • MEX.dll[4]

     MEX.dll 是一個用於輔助調試 .NET 應用程序的 WinDbg 擴展,它提供了一些有用的命令和功能,可以幫助分析和調試 .NET 應用程序的內存狀態和線程狀態。

這些擴展的使用方式都大同小異,其中最常用的莫過於SOS,SOSEx,MEX這三個。關於擴展的加載以及使用本文也不再贅述,見參考資料[5][6]。

  爲了簡便,在調試中我們採用了SOSEx擴展,它可以直接使用.dlk命令(DeadLock)來檢測程序中的死鎖。經過分析,程序中並未包含死鎖,如下圖所示:

(圖2)

這也是爲什麼在本文開頭提到的死鎖會加上一個雙引號的原因。實際上,我們在初期觀察程序運行情況時就懷疑程序中並沒有死鎖,因爲程序並不是從頭到尾始終掛起,只是目標方法的運行時間過長,遠超預期而已。 既然程序中沒有死鎖,那隻能是其他線程相關的問題了。

  回過頭重新看看程序的代碼,爲了模擬較高的併發量,其中使用Task類來創建了大量的任務。注意我的描述,這裏說的是任務,並不是線程。因爲創建一個Task實例並不一定會創建一個新的線程。在 .NET 中,Task 類可以利用線程池中的線程來執行任務,以提高系統的性能和吞吐量。Task 類的底層實現使用了 ThreadPool.QueueUserWorkItem 方法,將任務提交給線程池(TheadPool),由線程池中的線程來執行任務。當使用 Task.Factory.StartNew 或 Task.Run 方法創建一個新的任務時,Task 類會將任務封裝成一個委託對象,然後調用 ThreadPool.QueueUserWorkItem 方法將委託對象提交給線程池。線程池會在有可用的線程時,從線程池中取出一個線程來執行任務,任務執行完畢後,線程會自動返回線程池,等待下一個任務的到來。 在使用 Task 類時,可以通過 TaskCreationOptions 枚舉類型中的選項來控制任務的執行方式。例如,可以使用 TaskCreationOptions.LongRunning 選項來告訴 Task 類,任務是一個長時間運行的任務,需要創建一個新的線程來執行任務,而不是使用線程池中的線程。 需要注意的是,雖然 Task 類可以利用線程池中的線程來執行任務,但線程池中的線程數量是有限的,如果任務數量過多,可能會導致線程池中的線程被佔滿,從而影響系統的性能和響應時間。因此,在編寫多任務程序時,應該合理使用 Task 類和線程池,避免任務數量過多,以保證系統的性能和吞吐量[7]。  

  綜上所述,難道程序運行緩慢是因爲是因爲線程池被打滿了?讓我們監測下程序的線程使用情況看看。

  在Windows平臺上,可以使用其自帶的資源監視器工具進行資源監控(見圖3,圖4),也可以使用.NET自帶的計數器工具(dotnet-counters monitor),兩款工具均可實時監控程序的資源使用情況。

(圖3)

  

(圖4)

這裏我們使用.NET自帶的計數器工具進行監測。啓動模擬程序,打開命令行窗口或者Powershell窗口,鍵入 dotnet-counters monitor -n   【YourProcessName】,如圖5所示:

(圖5)

(圖6)

從圖6中可以看到,程序剛運行時,線程池線程數量僅爲1,線程池隊列長度爲0。接着,正式開始100個任務的執行。

(圖7)

(圖8)

從圖8中的監控面板觀察結果來看,隨着程序的運行,線程池隊列(ThreadPool Queue Length)開始慢慢減少,而線程池線程數量(ThreadPool Thead Count)則逐漸增多,呈現出一種此消彼長的現象。在此期間,程序則是保持掛起狀態,直到線程池隊列基本清空,程序開始返回了我們想要的結果,而這時候線程池線程數量已經增長到104。見圖9.

(圖9)

模擬程序打點測得整個執行時間爲 71729毫秒,約72秒,如圖10所示。

(圖10)

實際上,爲了印證一些猜想,我們對模擬程序做了一些改動。當首次執行完100個任務後,在未重啓程序的情況下,我們清空了定義的Task集合,並清空了返回的結果,然後立即開始再執行100個相同的任務。此時,線程池中線程很充足,再次執行100個任務耗時則非常短,只用了1112毫米,約1秒鐘。如圖11所示。

(圖11)

在執行時間上前後竟然存在72倍的差距!! 很明顯線程池中充足的線程可以很好地解決方法執行時間過長的問題。 那難道需要執行1000個任務就需要1000個線程嗎,這又明顯不合理。 說好的池化思想,重複利用呢,怎麼跟預期的不一樣? 

爲了驗證是不是HttpClient造成的這種情況,我們再次更改了模擬程序代碼。如圖12所示。

(圖12)

對程序編譯後再次執行,同時進行資源監控。

(圖13)

如圖13所示,更改後的程序執行兩次100個任務分別只需要2秒左右,用時基本持平,並且線程池中線程數量最大也才16(峯值截圖)。 這樣來看,問題必然出在HttpClient這邊。

....

(未完待續)

參考資料:

[1] 五維思考,.Net及.Net Core下HttpClient詳解,簡書

[2] Official Account , 在 Visual Studio 中調試多線程應用程序,  Microsoft

[3] Sysinternals, 進程資源管理器 v17.04,Microsoft

[4] Eric Zhou,Windbg程序調試系列1-Mex擴展使用總結,CNBLOGS

[5] Eric Zhou,  .NET高級調試系列-Windbg調試入門篇, CNBLOGS 

[6] Eric Zhou,  Windbg程序調試系列1-常用命令說明&示例, CNBLOGS

[7] 凌晨三點半,.NET中ThreadPool與Task的認識總結,CNBLOGS

[8] Official Account,託管線程處理的最佳做法, Microsoft

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