1.背景
最近團隊開發的數據庫組件需要通過HTTP請求方式從配置中心獲取連接字符串,該組件采用.NET 6進行開發。考慮到并發的情況,因此對獲取連接字符串的方法進行了加鎖,并進行了雙重檢測(double-checking)。 由于組件框架使用.NET 6,我們采用了HttpClient組件進行HTTP請求。在實際測試中發現,當請求壓力較大的場景下,程序容易出現“死鎖”。為解決此問題,我們對程序進行了簡單分析,并在本文中記錄了整個分析過程。
以下是模擬代碼:
using System.Diagnostics;
namespace HttpClientMultiInvokeTestConsole
{
internal class Program
{
static string flag = "";
static object lockObj = new object();
static void Main(string[] args)
{
var tasks = new List<Task>();
for (int i = 0; i < 100; i++)
{
var t = new Task(() => LockLock());
tasks.Add(t);
}
var sw = Stopwatch.StartNew();
foreach (var t in tasks)
{
t.Start();
}
Task.WaitAll(tasks.ToArray());
sw.Stop();
Console.WriteLine(flag);
Console.WriteLine(sw.ElapsedMilliseconds);
}
private static void LockLock()
{
if (string.IsNullOrEmpty(flag))
{
lock (lockObj)
{
if (string.IsNullOrEmpty(flag))
{
var content = GetConnectionString().Result;
flag = content;
}
}
}
}
private static async Task<string> GetConnectionString()
{
HttpClient client = new HttpClient();
var content = await client.GetStringAsync("http://www.baidu.com");
return content;
}
}
}
以上代碼模擬并發的場景,初始化了100個任務。經測試,該代碼在i7-7700K處理器機器上通常需要運行70秒以上,在i7-11800H處理器機器上差別不大。
關于HttpClient的介紹本文不再贅述,見參考資料[1][2]
2.問題分析
當我們的程序遭遇性能問題時,通常可能需要考慮以下幾個方面:
可以使用 Windows 任務管理器或者其他性能監控工具來檢查 CPU 利用率。如果 CPU 利用率很高,說明程序可能存在 CPU 密集型任務,需要優化算法或者減少計算量。
可以使用 Windows 任務管理器或者其他性能監控工具來檢查內存使用情況。如果內存使用量很高,說明程序可能存在內存泄漏或者大量的對象創建和銷毀操作,需要進行內存優化。
可以使用 Windows 任務管理器或者其他性能監控工具來檢查磁盤和網絡 I/O 操作的負載情況。如果 I/O 操作很頻繁,說明程序可能存在 I/O 密集型任務,需要優化讀寫操作,例如使用緩存來減少磁盤或者網絡訪問。
如果程序需要頻繁訪問數據庫,可以使用 SQL Server Profiler 或者其他數據庫性能監控工具來檢查 SQL 查詢的性能情況。如果查詢時間很長,說明可能需要進行優化,例如添加索引、優化查詢語句或者減少查詢次數等。
如果程序使用了多線程和鎖,需要檢查線程和鎖的使用情況,以及是否存在死鎖和競爭問題。可以使用 Visual Studio 調試器或者其他工具來檢查線程和鎖的狀態[3],以及分析線程和鎖的競爭情況。
從場景模擬代碼可以看出,其中并沒有數據庫操作,也不存在大量I/O操作。待運行程序后,我們使用任務管理器對程序的運行狀況進行了初步了解,發現CPU利用率以及內存使用都非常低,幾乎可以忽略,因此問題極有可能出在線程和鎖上。 為了進一步分析,我們使用 Process Explorer進程管理工具[4]對模擬程序進行了Full DUMP,以便后續使用WinDbg進行分析。如下圖所示:
(圖1)
得到了進程的完整DUMP文件后,我們便可以開始使用WinDbg調試工具進行調試了。 在WinDbg調試工具中,除了原生的調試指令之外,針對.NET程序的調試還有一些其他的常用擴展,例如:
SOS 是 .NET 框架提供的一個調試擴展,可以用于分析 .NET 程序的內存狀態和線程狀態。SOS 可以幫助分析和調試 .NET 中的對象、堆棧、線程、GC 和異常等內容。
Psscor4
SOS Ex 是 SOS 的擴展版本,提供了更多的調試命令和功能,例如查看對象的引用關系、分析 Finalizer 隊列、分析線程池、分析委托等。
Netext 是一個常用的 WinDbg 插件,可以用于分析和調試 .NET 程序的內存狀態和線程狀態。Netext 提供了一些有用的命令和功能,例如查看對象、分析堆棧、查看線程狀態、分析 GC 等。
ManagedXLL 是一個用于分析和調試 .NET 程序的 WinDbg 插件,它提供了一些有用的命令和功能,例如查看對象、分析堆棧、查看線程狀態、分析 GC 等。ManagedXLL 還提供了一些 Excel 函數,可以將調試信息輸出到 Excel 表格中。
MEX.dll 是一個用于輔助調試 .NET 應用程序的 WinDbg 擴展,它提供了一些有用的命令和功能,可以幫助分析和調試 .NET 應用程序的內存狀態和線程狀態。
這些擴展的使用方式都大同小異,其中最常用的莫過于SOS,SOSEx,MEX這三個。關于擴展的加載以及使用本文也不再贅述,見參考資料[6][7]。
為了簡便,在調試中我們采用了SOSEx擴展,它可以直接使用.dlk命令(DeadLock)來檢測程序中的死鎖。經過分析,程序中并未包含死鎖,如下圖所示:
(圖2)
這也是為什么在本文開頭提到的死鎖會加上一個雙引號的原因。實際上,我們在初期觀察程序運行情況時就懷疑程序中并沒有死鎖,因為程序并不是從頭到尾始終掛起,只是目標方法的運行時間過長,遠超預期而已。 既然程序中沒有死鎖,那只能是其他線程相關的問題了。
回過頭重新看看程序的代碼,為了模擬較高的并發量,其中使用Task類來創建了大量的任務。注意我的描述,這里說的是任務,并不是線程。因為創建一個Task實例并不一定會創建一個新的線程。在 .NET 中,Task 類可以利用線程池中的線程來執行任務,以提高系統的性能和吞吐量。Task 類的底層實現使用了 ThreadPool.QueueUserWorkItem 方法,將任務提交給線程池(TheadPool),由線程池中的線程來執行任務。當使用 Task.Factory.StartNew 或 Task.Run 方法創建一個新的任務時,Task 類會將任務封裝成一個委托對象,然后調用 ThreadPool.QueueUserWorkItem 方法將委托對象提交給線程池。線程池會在有可用的線程時,從線程池中取出一個線程來執行任務,任務執行完畢后,線程會自動返回線程池,等待下一個任務的到來[8] 。
綜上所述,難道程序運行緩慢是因為是因為線程池被打滿了?讓我們監測下程序的線程使用情況看看。
在 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這邊。
為了一探究竟,我們將代碼恢復為原始版本,利用VS的并行堆棧、任務列表等工具對程序進行了調試分析。在程序啟動片刻之后,按下Ctrl + Alt + Break 進行中斷。
首先打開并行堆棧視圖,如圖14所示。
(圖14)
觀察圖14的上半部分,視圖告訴我們分別有38個異步邏輯堆棧、1個異步邏輯堆棧、61個異步邏輯堆棧。讓我們做一個簡單的計算: 38 + 1 + 61 = ?, 還記得我們啟動了多少個任務嗎? 沒錯,正好是100個。一個異步邏輯堆棧對應著1個任務。
將鼠標移動到第一個框中的“LockLock”處會彈出一個懸浮框,如圖15所示:
(圖15)
從圖中可以看出這38個任務狀態均是“已阻止”。 任意選擇其中一個任務雙擊鼠標,這時會直接跳轉到對應的棧幀,無一例外地都是lock(lockObj)處。不難理解,這些任務都是在等待lockObj鎖對象的釋放。
Program.Main.AnonymousMethod__3_0 處也會彈出懸浮框,如圖16所示:
(圖16)
從上圖可以了解到這61個任務狀態均是“已計劃”,雙擊任意一個任務均會跳轉到for循環 var t = new Task(() => LockLock())處,表明這些任務正在計劃執行LockLock方法。那么“已計劃”到底是怎樣的一種狀態? 從任務數組中選出這些狀態為“已計劃”的任務,在即時窗口中計算可得知這些任務的TaskStatus均為WaitingToRun。微軟官方是這樣解釋WaitingToRun含義的:
The task has been scheduled for execution but has not yet begun executing.
翻譯過來即是:該任務已被計劃執行,但尚未開始執行。這表示任務已經被調度器接受,但尚未開始執行,因為沒有可用的線程來執行它。
接下來我們再看圖14中最大的一個視圖,從其中的堆棧信息可以得知第一個獲取到Lock鎖的線程上任務列表的一個等待鏈。棧頂部的HttpConnectionPool.GetHttp11ConnectionAsync引起了我們的注意,該方法正在異步獲取HTTP/1.1連接(HTTP版本跟我們請求的目標站點有關),棧底的方法在等待該方法的完成。其源碼如下:
private async ValueTask<HttpConnection> GetHttp11ConnectionAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken)
{
// Look for a usable idle connection.
TaskCompletionSourceWithCancellation<HttpConnection> waiter;
while (true)
{
HttpConnection? connection = null;
lock (SyncObj)
{
_usedSinceLastCleanup = true;
int availableConnectionCount = _availableHttp11Connections.Count;
if (availableConnectionCount > 0)
{
// We have a connection that we can attempt to use.
// Validate it below outside the lock, to avoid doing expensive operations while holding the lock.
connection = _availableHttp11Connections[availableConnectionCount - 1];
_availableHttp11Connections.RemoveAt(availableConnectionCount - 1);
}
else
{
// No available connections. Add to the request queue.
waiter = _http11RequestQueue.EnqueueRequest(request);
CheckForHttp11ConnectionInjection();
// Break out of the loop and continue processing below.
break;
}
}
if (CheckExpirationOnGet(connection))
{
if (NetEventSource.Log.IsEnabled()) connection.Trace("Found expired HTTP/1.1 connection in pool.");
connection.Dispose();
continue;
}
if (!connection.PrepareForReuse(async))
{
if (NetEventSource.Log.IsEnabled()) connection.Trace("Found invalid HTTP/1.1 connection in pool.");
connection.Dispose();
continue;
}
if (NetEventSource.Log.IsEnabled()) connection.Trace("Found usable HTTP/1.1 connection in pool.");
return connection;
}
// There were no available idle connections. This request has been added to the request queue.
if (NetEventSource.Log.IsEnabled()) Trace($"No available HTTP/1.1 connections; request queued.");
ValueStopwatch stopwatch = ValueStopwatch.StartNew();
try
{
return await waiter.WaitWithCancellationAsync(async, cancellationToken).ConfigureAwait(false);
}
finally
{
if (HttpTelemetry.Log.IsEnabled())
{
HttpTelemetry.Log.Http11RequestLeftQueue(stopwatch.GetElapsedTime().TotalMilliseconds);
}
}
}
該方法檢查HTTP連接集合中是否有可用連接,如果無可用連接則將請求添加到請求隊列,然后調用CheckForHttp11ConnectionInjection方法來創建新連接并加入到連接集合中。過程如圖17所示:
(圖17)
CheckForHttp11ConnectionInjection 方法中會使用Task.Run 新啟一個任務來完成HttpConnection的創建。Task.Run(() => AddHttp11ConnectionAsync(request));
綜上,我們可以得到如下的框圖:
(圖18)
為了更好地理解程序以上行為,這里需要引出TaskScheduler[9](任務調度器)的概念。在.NET中,所有的任務執行都是依靠任務調度器進行調度的。默認情況下該調度器的實例是ThreadPoolTaskScheduler[10],可以通過 TaskScheduler.Default進行獲取。 ThreadPoolTaskScheduler 是基于線程池 (ThreadPool[11]) 實現的。線程池是一種用于減少線程創建和銷毀開銷的技術,通過在一組線程中重用線程來提高性能。
以下是一些相關的核心概念:
-
工作項隊列:線程池維護一個工作項隊列,其中包含等待執行的任務。當任務被提交給線程池時,它將被添加到工作項隊列中。線程池中的線程會在隊列中檢索并執行任務。
-
全局隊列和本地隊列:為了減少線程間競爭,線程池實現了全局隊列和本地隊列。全局隊列包含所有待執行任務,而每個線程都有自己的本地隊列。當線程需要執行任務時,首先嘗試從本地隊列獲取任務,如果本地隊列為空,再嘗試從全局隊列獲取任務。這種設計可以減少鎖競爭,提高性能。
-
線程創建:線程池會根據需要創建新的線程。初始線程池可能為空,但當有任務需要執行時,線程池會創建一個新線程來處理任務。為了避免線程過度創建,線程池會限制最大線程數。
-
線程重用:當線程完成任務并返回到線程池時,它不會被銷毀,而是被重用以執行其他任務。這樣可以降低線程創建和銷毀的開銷,提高性能。
-
線程回收:如果線程池中的線程在一定時間內閑置,它們將被回收以釋放資源。線程回收策略可以根據配置進行調整。
-
工作竊取:當一個線程的本地隊列為空,并且全局隊列也沒有任務時,該線程可以嘗試從其他線程的本地隊列竊取任務。這種工作竊取算法可以平衡線程負載,提高資源利用率。
-
任務調度優先級:較高優先級的任務會被優先執行,而較低優先級的任務會被推遲執行。ThreadPoolTaskScheduler會根據任務的優先級和可用線程的數量來分配任務給線程池中的線程。它會盡量平衡任務的執行,避免某些線程過度占用任務而導致其他線程空閑。
了解了這些概念之后,再結合.NET Runtime源碼,我們可以得出一些結論了。 線程池初始只有幾個線程,數量在0 ~ Environment.ProcessorCount(處理器核心數)個之間。我們使用Task新建了100個任務,并且沒有指定優先級,這些任務會被ThreadPoolTaskScheduler調度進入線程池的全局隊列(WorkItems Queue)中,接著線程池中僅有的線程都會從全局隊列中獲取任務并執行。 由于這些Task執行的是同一個方法,并且方法中使用了鎖,因此第一個執行Task的線程將持有鎖,直到任務完成后將鎖釋放(圖18中Awaiting的任務),在此期間,其他執行Task的線程都將等待鎖的釋放(圖18中Blocked的任務)。然而,全局隊列中任務數量遠遠超過了當前線程池中線程數量,因此沒有足夠的線程執行剩余的Task,這些Task都是已計劃未執行的狀態(圖18中Scheduled的任務)。針對線程不足的情況,.NET運行時會單獨啟動一個System.Threading.PortableThreadPool.GateThread線程(如圖19所示)來動態調整線程池中線程的數量。在線程數量調整的初期(即線程數量小于Environment.ProcessorCount時),往線程池中注入線程的速度是很快的,幾乎是即時的。當超過這個數量后,則是平均500ms新增一個線程。 新增的線程再次從全局隊列中取得一個Task然后執行,但仍然被Block,需繼續等待第一個Task所在線程釋放持有的鎖。這時Scheduled的任務數量減1,Blocked的任務數量加1。由于每次新增的線程都是因為等待鎖釋放被占用,因此GateThread不得不持續新增線程來完成工作。通常來說,只要我們執行的Task是非阻塞的或者耗時很短的,可能只需要少量的線程即可完成大量的Task,因為線程池可以復用線程。但是不巧的是,從圖17的執行流程來看,第一個Task內部會使用Task.Run(() => AddHttp11ConnectionAsync(request));創建一個子任務來獲取HttpConnection,這個子任務同樣也沒有設置任何優先級,它會被調度進本線程的本地隊列,其TaskCreationOptions屬性值為DenyChildAttach,微軟官方是這樣解釋這個枚舉值的:
Specifies that any child task that attempts to execute as an attached child task (that is, it is created with the AttachedToParent option) will not be able to attach to the parent task and will execute instead as a detached child task.
翻譯過來就是:指定任何嘗試作為附加的子任務執行(即,使用 AttachedToParent 選項創建)的子任務都無法附加到父任務,會改成作為分離的子任務執行。 這意味著子任務將在它自己的線程上執行,不會附加到父任務的線程上,也就是說子任務需要等待一個新的線程來執行它。但是因為前面還有很多Scheduled的任務已計劃未執行,GateThread新注入線程池的線程也是按照FIFO的順序去執行這些排隊的任務。 因此,在Scheduled的任務數量變成0之前,上述子任務都等不到線程去執行它,而該子任務的父任務又在等待子任務的完成,其他任務又在等待“父任務”鎖的釋放。 到此,本程序的性能問題算是找到了原因。
(圖19)
3. 解決方案
3.1 提前預熱HttpClient
從上面的分析來看,既然首個任務的子任務是要獲取HttpConnection對象,假如HttpConnectionPool中已經有足夠連接的話是不是就斬斷了等待鏈?于是我們修改了源代碼,在程序開頭先實例化HttpClient,然后單獨請求一次目標站點進行預熱。經過測試,效果出奇地好,執行100個Task只需要200ms左右,線程池線程數量峰值12左右。 但是要注意的是,HttpClient預熱后需盡快執行我們的并發模擬代碼,否則HttpConnectionPool中的對象可能會被回收掉。
3.2 為任務指定TaskCreationOptions
我們知道,當指定任務的TaskCreationOptions為LongRunning時,該任務將使用單獨的線程執行,而不是線程池中的線程,這樣就能避免線程爭用的問題。ThreadPoolTaskScheduler中相關調度方法源碼如下:
/// <summary>
/// Schedules a task to the ThreadPool.
/// </summary>
/// <param name="task">The task to schedule.</param>
protected internal override void QueueTask(Task task)
{
TaskCreationOptions options = task.Options;
if (Thread.IsThreadStartSupported && (options & TaskCreationOptions.LongRunning) != 0)
{
// Run LongRunning tasks on their own dedicated thread.
new Thread(s_longRunningThreadWork)
{
IsBackground = true,
Name = ".NET Long Running Task"
}.UnsafeStart(task);
}
else
{
// Normal handling for non-LongRunning tasks.
ThreadPool.UnsafeQueueUserWorkItemInternal(task, (options & TaskCreationOptions.PreferFairness) == 0);
}
}
因此,可以修改我們程序中創建任務的代碼為: var t = new Task(() => LockLock(), TaskCreationOptions.LongRunning);經測試,執行100個Task需要960ms,線程池線程數量峰值為5左右,Window自帶的資源監視器監測線程數量峰值為120。
3.3 設置線程池最小線程數
從上文的實驗來看,充足的線程的確能夠較快地完成任務。所以我們可以調用線程池方法來設置最小線程數,如:ThreadPool.SetMinThreads(100, 100); 因為我們的任務數是100,我這里方法傳遞參數也是100。經測試,100個Task執行需要1.6秒。但是這種方法也是有缺點的,那就是我們并不知道我們面臨的并發數是多少。一旦并發數超過我們設置的最小線程數,那么又會面臨線程數不足的問題。超出得越多,響應時間越慢。
|