1.背景
最近團(tuán)隊(duì)開發(fā)的數(shù)據(jù)庫組件需要通過HTTP請(qǐng)求方式從配置中心獲取連接字符串,該組件采用.NET 6進(jìn)行開發(fā)?紤]到并發(fā)的情況,因此對(duì)獲取連接字符串的方法進(jìn)行了加鎖,并進(jìn)行了雙重檢測(cè)(double-checking)。 由于組件框架使用.NET 6,我們采用了HttpClient組件進(jìn)行HTTP請(qǐng)求。在實(shí)際測(cè)試中發(fā)現(xiàn),當(dāng)請(qǐng)求壓力較大的場(chǎng)景下,程序容易出現(xiàn)“死鎖”。為解決此問題,我們對(duì)程序進(jìn)行了簡(jiǎn)單分析,并在本文中記錄了整個(gè)分析過程。
以下是模擬代碼:
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;
}
}
}
以上代碼模擬并發(fā)的場(chǎng)景,初始化了100個(gè)任務(wù)。經(jīng)測(cè)試,該代碼在i7-7700K處理器機(jī)器上通常需要運(yùn)行70秒以上,在i7-11800H處理器機(jī)器上差別不大。
關(guān)于HttpClient的介紹本文不再贅述,見參考資料[1][2]
2.問題分析
當(dāng)我們的程序遭遇性能問題時(shí),通?赡苄枰紤]以下幾個(gè)方面:
可以使用 Windows 任務(wù)管理器或者其他性能監(jiān)控工具來檢查 CPU 利用率。如果 CPU 利用率很高,說明程序可能存在 CPU 密集型任務(wù),需要優(yōu)化算法或者減少計(jì)算量。
可以使用 Windows 任務(wù)管理器或者其他性能監(jiān)控工具來檢查內(nèi)存使用情況。如果內(nèi)存使用量很高,說明程序可能存在內(nèi)存泄漏或者大量的對(duì)象創(chuàng)建和銷毀操作,需要進(jìn)行內(nèi)存優(yōu)化。
可以使用 Windows 任務(wù)管理器或者其他性能監(jiān)控工具來檢查磁盤和網(wǎng)絡(luò) I/O 操作的負(fù)載情況。如果 I/O 操作很頻繁,說明程序可能存在 I/O 密集型任務(wù),需要優(yōu)化讀寫操作,例如使用緩存來減少磁盤或者網(wǎng)絡(luò)訪問。
如果程序需要頻繁訪問數(shù)據(jù)庫,可以使用 SQL Server Profiler 或者其他數(shù)據(jù)庫性能監(jiān)控工具來檢查 SQL 查詢的性能情況。如果查詢時(shí)間很長(zhǎng),說明可能需要進(jìn)行優(yōu)化,例如添加索引、優(yōu)化查詢語句或者減少查詢次數(shù)等。
如果程序使用了多線程和鎖,需要檢查線程和鎖的使用情況,以及是否存在死鎖和競(jìng)爭(zhēng)問題?梢允褂 Visual Studio 調(diào)試器或者其他工具來檢查線程和鎖的狀態(tài)[3],以及分析線程和鎖的競(jìng)爭(zhēng)情況。
從場(chǎng)景模擬代碼可以看出,其中并沒有數(shù)據(jù)庫操作,也不存在大量I/O操作。待運(yùn)行程序后,我們使用任務(wù)管理器對(duì)程序的運(yùn)行狀況進(jìn)行了初步了解,發(fā)現(xiàn)CPU利用率以及內(nèi)存使用都非常低,幾乎可以忽略,因此問題極有可能出在線程和鎖上。 為了進(jìn)一步分析,我們使用 Process Explorer進(jìn)程管理工具[4]對(duì)模擬程序進(jìn)行了Full DUMP,以便后續(xù)使用WinDbg進(jìn)行分析。如下圖所示:
(圖1)
得到了進(jìn)程的完整DUMP文件后,我們便可以開始使用WinDbg調(diào)試工具進(jìn)行調(diào)試了。 在WinDbg調(diào)試工具中,除了原生的調(diào)試指令之外,針對(duì).NET程序的調(diào)試還有一些其他的常用擴(kuò)展,例如:
SOS 是 .NET 框架提供的一個(gè)調(diào)試擴(kuò)展,可以用于分析 .NET 程序的內(nèi)存狀態(tài)和線程狀態(tài)。SOS 可以幫助分析和調(diào)試 .NET 中的對(duì)象、堆棧、線程、GC 和異常等內(nèi)容。
Psscor4
SOS Ex 是 SOS 的擴(kuò)展版本,提供了更多的調(diào)試命令和功能,例如查看對(duì)象的引用關(guān)系、分析 Finalizer 隊(duì)列、分析線程池、分析委托等。
Netext 是一個(gè)常用的 WinDbg 插件,可以用于分析和調(diào)試 .NET 程序的內(nèi)存狀態(tài)和線程狀態(tài)。Netext 提供了一些有用的命令和功能,例如查看對(duì)象、分析堆棧、查看線程狀態(tài)、分析 GC 等。
ManagedXLL 是一個(gè)用于分析和調(diào)試 .NET 程序的 WinDbg 插件,它提供了一些有用的命令和功能,例如查看對(duì)象、分析堆棧、查看線程狀態(tài)、分析 GC 等。ManagedXLL 還提供了一些 Excel 函數(shù),可以將調(diào)試信息輸出到 Excel 表格中。
MEX.dll 是一個(gè)用于輔助調(diào)試 .NET 應(yīng)用程序的 WinDbg 擴(kuò)展,它提供了一些有用的命令和功能,可以幫助分析和調(diào)試 .NET 應(yīng)用程序的內(nèi)存狀態(tài)和線程狀態(tài)。
這些擴(kuò)展的使用方式都大同小異,其中最常用的莫過于SOS,SOSEx,MEX這三個(gè)。關(guān)于擴(kuò)展的加載以及使用本文也不再贅述,見參考資料[6][7]。
為了簡(jiǎn)便,在調(diào)試中我們采用了SOSEx擴(kuò)展,它可以直接使用.dlk命令(DeadLock)來檢測(cè)程序中的死鎖。經(jīng)過分析,程序中并未包含死鎖,如下圖所示:
(圖2)
這也是為什么在本文開頭提到的死鎖會(huì)加上一個(gè)雙引號(hào)的原因。實(shí)際上,我們?cè)诔跗谟^察程序運(yùn)行情況時(shí)就懷疑程序中并沒有死鎖,因?yàn)槌绦虿⒉皇菑念^到尾始終掛起,只是目標(biāo)方法的運(yùn)行時(shí)間過長(zhǎng),遠(yuǎn)超預(yù)期而已。 既然程序中沒有死鎖,那只能是其他線程相關(guān)的問題了。
回過頭重新看看程序的代碼,為了模擬較高的并發(fā)量,其中使用Task類來創(chuàng)建了大量的任務(wù)。注意我的描述,這里說的是任務(wù),并不是線程。因?yàn)閯?chuàng)建一個(gè)Task實(shí)例并不一定會(huì)創(chuàng)建一個(gè)新的線程。在 .NET 中,Task 類可以利用線程池中的線程來執(zhí)行任務(wù),以提高系統(tǒng)的性能和吞吐量。Task 類的底層實(shí)現(xiàn)使用了 ThreadPool.QueueUserWorkItem 方法,將任務(wù)提交給線程池(TheadPool),由線程池中的線程來執(zhí)行任務(wù)。當(dāng)使用 Task.Factory.StartNew 或 Task.Run 方法創(chuàng)建一個(gè)新的任務(wù)時(shí),Task 類會(huì)將任務(wù)封裝成一個(gè)委托對(duì)象,然后調(diào)用 ThreadPool.QueueUserWorkItem 方法將委托對(duì)象提交給線程池。線程池會(huì)在有可用的線程時(shí),從線程池中取出一個(gè)線程來執(zhí)行任務(wù),任務(wù)執(zhí)行完畢后,線程會(huì)自動(dòng)返回線程池,等待下一個(gè)任務(wù)的到來[8] 。
綜上所述,難道程序運(yùn)行緩慢是因?yàn)槭且驗(yàn)榫程池被打滿了?讓我們監(jiān)測(cè)下程序的線程使用情況看看。
在 Windows平臺(tái)上,可以使用其自帶的資源監(jiān)視器工具進(jìn)行資源監(jiān)控(見圖3,圖4),也可以使用.NET自帶的計(jì)數(shù)器工具(dotnet-counters monitor),兩款工具均可實(shí)時(shí)監(jiān)控程序的資源使用情況。
(圖3)
(圖4)
這里我們使用.NET自帶的計(jì)數(shù)器工具進(jìn)行監(jiān)測(cè)。啟動(dòng)模擬程序,打開命令行窗口或者Powershell窗口,鍵入 dotnet-counters monitor -n 【YourProcessName】,如圖5所示:
(圖5)
(圖6)
從圖6中可以看到,程序剛運(yùn)行時(shí),線程池線程數(shù)量?jī)H為1,線程池隊(duì)列長(zhǎng)度為0。接著,正式開始100個(gè)任務(wù)的執(zhí)行。
(圖7)
(圖8)
從圖8中的監(jiān)控面板觀察結(jié)果來看,隨著程序的運(yùn)行,線程池隊(duì)列(ThreadPool Queue Length)開始慢慢減少,而線程池線程數(shù)量(ThreadPool Thead Count)則逐漸增多,呈現(xiàn)出一種此消彼長(zhǎng)的現(xiàn)象。在此期間,程序則是保持掛起狀態(tài),直到線程池隊(duì)列基本清空,程序開始返回了我們想要的結(jié)果,而這時(shí)候線程池線程數(shù)量已經(jīng)增長(zhǎng)到104。如圖9所示:
(圖9)
模擬程序打點(diǎn)測(cè)得整個(gè)執(zhí)行時(shí)間為 71729毫秒,約72秒,如圖10所示:
(圖10)
為了驗(yàn)證是否線程不足導(dǎo)致程序運(yùn)行緩慢的猜想,我們對(duì)模擬程序做了一些改動(dòng)。當(dāng)首次執(zhí)行完100個(gè)任務(wù)后,在未重啟程序的情況下,我們清空了定義的Task集合,并清空了返回的結(jié)果,然后立即開始再執(zhí)行100個(gè)相同的任務(wù)。此時(shí),線程池中線程很充足,再次執(zhí)行100個(gè)任務(wù)耗時(shí)則非常短,只用了1112毫秒,約1秒鐘。如圖11所示:
(圖11)
在執(zhí)行時(shí)間上前后竟然存在72倍的差距。 很明顯線程池中充足的線程可以很好地解決方法執(zhí)行時(shí)間過長(zhǎng)的問題。 那難道需要執(zhí)行1000個(gè)任務(wù)就需要1000個(gè)線程嗎,這又明顯不合理。 會(huì)不會(huì)是HttpClient導(dǎo)致的線程不足呢?我們?cè)俅胃牧四M程序代碼。如圖12所示。
(圖12)
對(duì)程序編譯后再次執(zhí)行,同時(shí)進(jìn)行資源監(jiān)控。如圖13所示:
(圖13)
更改后的程序執(zhí)行兩次100個(gè)任務(wù)分別只需要2秒左右,用時(shí)基本持平,并且線程池中線程數(shù)量最大也才16(峰值截圖)。 這樣來看,問題必然出在HttpClient這邊。
為了一探究竟,我們將代碼恢復(fù)為原始版本,利用VS的并行堆棧、任務(wù)列表等工具對(duì)程序進(jìn)行了調(diào)試分析。在程序啟動(dòng)片刻之后,按下Ctrl + Alt + Break 進(jìn)行中斷。
首先打開并行堆棧視圖,如圖14所示。
(圖14)
觀察圖14的上半部分,視圖告訴我們分別有38個(gè)異步邏輯堆棧、1個(gè)異步邏輯堆棧、61個(gè)異步邏輯堆棧。讓我們做一個(gè)簡(jiǎn)單的計(jì)算: 38 + 1 + 61 = ?, 還記得我們啟動(dòng)了多少個(gè)任務(wù)嗎? 沒錯(cuò),正好是100個(gè)。一個(gè)異步邏輯堆棧對(duì)應(yīng)著1個(gè)任務(wù)。
將鼠標(biāo)移動(dòng)到第一個(gè)框中的“LockLock”處會(huì)彈出一個(gè)懸浮框,如圖15所示:
(圖15)
從圖中可以看出這38個(gè)任務(wù)狀態(tài)均是“已阻止”。 任意選擇其中一個(gè)任務(wù)雙擊鼠標(biāo),這時(shí)會(huì)直接跳轉(zhuǎn)到對(duì)應(yīng)的棧幀,無一例外地都是lock(lockObj)處。不難理解,這些任務(wù)都是在等待lockObj鎖對(duì)象的釋放。
Program.Main.AnonymousMethod__3_0 處也會(huì)彈出懸浮框,如圖16所示:
(圖16)
從上圖可以了解到這61個(gè)任務(wù)狀態(tài)均是“已計(jì)劃”,雙擊任意一個(gè)任務(wù)均會(huì)跳轉(zhuǎn)到for循環(huán) var t = new Task(() => LockLock())處,表明這些任務(wù)正在計(jì)劃執(zhí)行LockLock方法。那么“已計(jì)劃”到底是怎樣的一種狀態(tài)? 從任務(wù)數(shù)組中選出這些狀態(tài)為“已計(jì)劃”的任務(wù),在即時(shí)窗口中計(jì)算可得知這些任務(wù)的TaskStatus均為WaitingToRun。微軟官方是這樣解釋W(xué)aitingToRun含義的:
The task has been scheduled for execution but has not yet begun executing.
翻譯過來即是:該任務(wù)已被計(jì)劃執(zhí)行,但尚未開始執(zhí)行。這表示任務(wù)已經(jīng)被調(diào)度器接受,但尚未開始執(zhí)行,因?yàn)闆]有可用的線程來執(zhí)行它。
接下來我們?cè)倏磮D14中最大的一個(gè)視圖,從其中的堆棧信息可以得知第一個(gè)獲取到Lock鎖的線程上任務(wù)列表的一個(gè)等待鏈。棧頂部的HttpConnectionPool.GetHttp11ConnectionAsync引起了我們的注意,該方法正在異步獲取HTTP/1.1連接(HTTP版本跟我們請(qǐng)求的目標(biāo)站點(diǎn)有關(guān)),棧底的方法在等待該方法的完成。其源碼如下:
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連接集合中是否有可用連接,如果無可用連接則將請(qǐng)求添加到請(qǐng)求隊(duì)列,然后調(diào)用CheckForHttp11ConnectionInjection方法來創(chuàng)建新連接并加入到連接集合中。過程如圖17所示:
(圖17)
CheckForHttp11ConnectionInjection 方法中會(huì)使用Task.Run 新啟一個(gè)任務(wù)來完成HttpConnection的創(chuàng)建。Task.Run(() => AddHttp11ConnectionAsync(request));
綜上,我們可以得到如下的框圖:
(圖18)
為了更好地理解程序以上行為,這里需要引出TaskScheduler[9](任務(wù)調(diào)度器)的概念。在.NET中,所有的任務(wù)執(zhí)行都是依靠任務(wù)調(diào)度器進(jìn)行調(diào)度的。默認(rèn)情況下該調(diào)度器的實(shí)例是ThreadPoolTaskScheduler[10],可以通過 TaskScheduler.Default進(jìn)行獲取。 ThreadPoolTaskScheduler 是基于線程池 (ThreadPool[11]) 實(shí)現(xiàn)的。線程池是一種用于減少線程創(chuàng)建和銷毀開銷的技術(shù),通過在一組線程中重用線程來提高性能。
以下是一些相關(guān)的核心概念:
-
工作項(xiàng)隊(duì)列:線程池維護(hù)一個(gè)工作項(xiàng)隊(duì)列,其中包含等待執(zhí)行的任務(wù)。當(dāng)任務(wù)被提交給線程池時(shí),它將被添加到工作項(xiàng)隊(duì)列中。線程池中的線程會(huì)在隊(duì)列中檢索并執(zhí)行任務(wù)。
-
全局隊(duì)列和本地隊(duì)列:為了減少線程間競(jìng)爭(zhēng),線程池實(shí)現(xiàn)了全局隊(duì)列和本地隊(duì)列。全局隊(duì)列包含所有待執(zhí)行任務(wù),而每個(gè)線程都有自己的本地隊(duì)列。當(dāng)線程需要執(zhí)行任務(wù)時(shí),首先嘗試從本地隊(duì)列獲取任務(wù),如果本地隊(duì)列為空,再嘗試從全局隊(duì)列獲取任務(wù)。這種設(shè)計(jì)可以減少鎖競(jìng)爭(zhēng),提高性能。
-
線程創(chuàng)建:線程池會(huì)根據(jù)需要?jiǎng)?chuàng)建新的線程。初始線程池可能為空,但當(dāng)有任務(wù)需要執(zhí)行時(shí),線程池會(huì)創(chuàng)建一個(gè)新線程來處理任務(wù)。為了避免線程過度創(chuàng)建,線程池會(huì)限制最大線程數(shù)。
-
線程重用:當(dāng)線程完成任務(wù)并返回到線程池時(shí),它不會(huì)被銷毀,而是被重用以執(zhí)行其他任務(wù)。這樣可以降低線程創(chuàng)建和銷毀的開銷,提高性能。
-
線程回收:如果線程池中的線程在一定時(shí)間內(nèi)閑置,它們將被回收以釋放資源。線程回收策略可以根據(jù)配置進(jìn)行調(diào)整。
-
工作竊。寒(dāng)一個(gè)線程的本地隊(duì)列為空,并且全局隊(duì)列也沒有任務(wù)時(shí),該線程可以嘗試從其他線程的本地隊(duì)列竊取任務(wù)。這種工作竊取算法可以平衡線程負(fù)載,提高資源利用率。
-
任務(wù)調(diào)度優(yōu)先級(jí):較高優(yōu)先級(jí)的任務(wù)會(huì)被優(yōu)先執(zhí)行,而較低優(yōu)先級(jí)的任務(wù)會(huì)被推遲執(zhí)行。ThreadPoolTaskScheduler會(huì)根據(jù)任務(wù)的優(yōu)先級(jí)和可用線程的數(shù)量來分配任務(wù)給線程池中的線程。它會(huì)盡量平衡任務(wù)的執(zhí)行,避免某些線程過度占用任務(wù)而導(dǎo)致其他線程空閑。
了解了這些概念之后,再結(jié)合.NET Runtime源碼,我們可以得出一些結(jié)論了。 線程池初始只有幾個(gè)線程,數(shù)量在0 ~ Environment.ProcessorCount(處理器核心數(shù))個(gè)之間。我們使用Task新建了100個(gè)任務(wù),并且沒有指定優(yōu)先級(jí),這些任務(wù)會(huì)被ThreadPoolTaskScheduler調(diào)度進(jìn)入線程池的全局隊(duì)列(WorkItems Queue)中,接著線程池中僅有的線程都會(huì)從全局隊(duì)列中獲取任務(wù)并執(zhí)行。 由于這些Task執(zhí)行的是同一個(gè)方法,并且方法中使用了鎖,因此第一個(gè)執(zhí)行Task的線程將持有鎖,直到任務(wù)完成后將鎖釋放(圖18中Awaiting的任務(wù)),在此期間,其他執(zhí)行Task的線程都將等待鎖的釋放(圖18中Blocked的任務(wù))。然而,全局隊(duì)列中任務(wù)數(shù)量遠(yuǎn)遠(yuǎn)超過了當(dāng)前線程池中線程數(shù)量,因此沒有足夠的線程執(zhí)行剩余的Task,這些Task都是已計(jì)劃未執(zhí)行的狀態(tài)(圖18中Scheduled的任務(wù))。針對(duì)線程不足的情況,.NET運(yùn)行時(shí)會(huì)單獨(dú)啟動(dòng)一個(gè)System.Threading.PortableThreadPool.GateThread線程(如圖19所示)來動(dòng)態(tài)調(diào)整線程池中線程的數(shù)量。在線程數(shù)量調(diào)整的初期(即線程數(shù)量小于Environment.ProcessorCount時(shí)),往線程池中注入線程的速度是很快的,幾乎是即時(shí)的。當(dāng)超過這個(gè)數(shù)量后,則是平均500ms新增一個(gè)線程。 新增的線程再次從全局隊(duì)列中取得一個(gè)Task然后執(zhí)行,但仍然被Block,需繼續(xù)等待第一個(gè)Task所在線程釋放持有的鎖。這時(shí)Scheduled的任務(wù)數(shù)量減1,Blocked的任務(wù)數(shù)量加1。由于每次新增的線程都是因?yàn)榈却i釋放被占用,因此GateThread不得不持續(xù)新增線程來完成工作。通常來說,只要我們執(zhí)行的Task是非阻塞的或者耗時(shí)很短的,可能只需要少量的線程即可完成大量的Task,因?yàn)榫程池可以復(fù)用線程。但是不巧的是,從圖17的執(zhí)行流程來看,第一個(gè)Task內(nèi)部會(huì)使用Task.Run(() => AddHttp11ConnectionAsync(request));創(chuàng)建一個(gè)子任務(wù)來獲取HttpConnection,這個(gè)子任務(wù)同樣也沒有設(shè)置任何優(yōu)先級(jí),它會(huì)被調(diào)度進(jìn)本線程的本地隊(duì)列,其TaskCreationOptions屬性值為DenyChildAttach,微軟官方是這樣解釋這個(gè)枚舉值的:
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.
翻譯過來就是:指定任何嘗試作為附加的子任務(wù)執(zhí)行(即,使用 AttachedToParent 選項(xiàng)創(chuàng)建)的子任務(wù)都無法附加到父任務(wù),會(huì)改成作為分離的子任務(wù)執(zhí)行。 這意味著子任務(wù)將在它自己的線程上執(zhí)行,不會(huì)附加到父任務(wù)的線程上,也就是說子任務(wù)需要等待一個(gè)新的線程來執(zhí)行它。但是因?yàn)榍懊孢有很多Scheduled的任務(wù)已計(jì)劃未執(zhí)行,GateThread新注入線程池的線程也是按照FIFO的順序去執(zhí)行這些排隊(duì)的任務(wù)。 因此,在Scheduled的任務(wù)數(shù)量變成0之前,上述子任務(wù)都等不到線程去執(zhí)行它,而該子任務(wù)的父任務(wù)又在等待子任務(wù)的完成,其他任務(wù)又在等待“父任務(wù)”鎖的釋放。 到此,本程序的性能問題算是找到了原因。
(圖19)
3. 解決方案
3.1 提前預(yù)熱HttpClient
從上面的分析來看,既然首個(gè)任務(wù)的子任務(wù)是要獲取HttpConnection對(duì)象,假如HttpConnectionPool中已經(jīng)有足夠連接的話是不是就斬?cái)嗔说却?于是我們修改了源代碼,在程序開頭先實(shí)例化HttpClient,然后單獨(dú)請(qǐng)求一次目標(biāo)站點(diǎn)進(jìn)行預(yù)熱。經(jīng)過測(cè)試,效果出奇地好,執(zhí)行100個(gè)Task只需要200ms左右,線程池線程數(shù)量峰值12左右。 但是要注意的是,HttpClient預(yù)熱后需盡快執(zhí)行我們的并發(fā)模擬代碼,否則HttpConnectionPool中的對(duì)象可能會(huì)被回收掉。
3.2 為任務(wù)指定TaskCreationOptions
我們知道,當(dāng)指定任務(wù)的TaskCreationOptions為L(zhǎng)ongRunning時(shí),該任務(wù)將使用單獨(dú)的線程執(zhí)行,而不是線程池中的線程,這樣就能避免線程爭(zhēng)用的問題。ThreadPoolTaskScheduler中相關(guān)調(diào)度方法源碼如下:
/// <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);
}
}
因此,可以修改我們程序中創(chuàng)建任務(wù)的代碼為: var t = new Task(() => LockLock(), TaskCreationOptions.LongRunning);經(jīng)測(cè)試,執(zhí)行100個(gè)Task需要960ms,線程池線程數(shù)量峰值為5左右,Window自帶的資源監(jiān)視器監(jiān)測(cè)線程數(shù)量峰值為120。
3.3 設(shè)置線程池最小線程數(shù)
從上文的實(shí)驗(yàn)來看,充足的線程的確能夠較快地完成任務(wù)。所以我們可以調(diào)用線程池方法來設(shè)置最小線程數(shù),如:ThreadPool.SetMinThreads(100, 100); 因?yàn)槲覀兊娜蝿?wù)數(shù)是100,我這里方法傳遞參數(shù)也是100。經(jīng)測(cè)試,100個(gè)Task執(zhí)行需要1.6秒。但是這種方法也是有缺點(diǎn)的,那就是我們并不知道我們面臨的并發(fā)數(shù)是多少。一旦并發(fā)數(shù)超過我們?cè)O(shè)置的最小線程數(shù),那么又會(huì)面臨線程數(shù)不足的問題。超出得越多,響應(yīng)時(shí)間越慢。
|