本頁內容
簡介
謹慎地進行堆棧遍歷
同步和異步調用
總結
做出最佳的表現
夠了夠了
簡介
本文面向的是對構建用于檢查托管應用程序的分析器感興趣的讀者。我將描述如何編寫分析器,以在 .NET Framework 的公共語言運行庫 (CLR) 中遍歷托管堆棧。我將盡力保持輕松的心情,因為主題本身的進展有時會非常艱難。
在 CLR 的 2.0 版本中分析 API 有一個名為 DoStackSnapshot 的新方法,它允許分析器遍歷正在分析的應用程序的調用堆棧。CLR 的 1.1 版通過進程內調試接口提供了類似功能。但使用 DoStackSnapshot 遍歷調用堆棧更容易、更準確且更穩定。DoStackSnapshot 方法使用的堆棧遍歷器與垃圾收集器、安全系統、異常系統等使用的堆棧遍歷器相同。因此,您知道它必須運轉正常。
訪問完整的堆棧跟蹤可使分析器用戶在發生值得關注的事件時,對應用程序運行情況有一個全面的了解。根據應用程序及用戶想要分析的內容,您可以假設用戶在分配對象、加載類、引發異常時需要調用堆棧。即使所獲得的是應用程序事件以外事件(例如計時器事件)的調用堆棧,也仍然會引起采樣分析器的關注。如果您看到誰調用了包含熱點的函數,則查看代碼形式的熱點將會變得更加有啟迪作用。
我將側重于通過 DoStackSnapshot API 獲取堆棧跟蹤。獲取堆棧跟蹤的另一方法是通過構建影子堆棧:可掛接 FunctionEnter 和 FunctionLeave,以保存當前線程的托管調用堆棧的副本。如果您在應用程序執行期間始終需要堆棧信息,如果您不介意在每次執行托管調用及返回時運行分析器的代碼所產生的性能成本,則影子堆棧構建將會非常有用。如果很少需要報告堆棧(例如,為了響應事件),則 DoStackSnapshot 將是極佳的方法。即使采樣分析器每隔幾毫秒便拍一次堆?煺,其頻率也要比構建影子堆棧低。因此,DoStackSnapshot 非常適合采樣分析器。
返回頁首
謹慎地進行堆棧遍歷
如果您希望能夠在需要時隨時獲取調用堆棧,這將非常有用。但是與能力隨之而來的還有責任。分析器用戶不會希望堆棧遍歷在運行時導致訪問違例 (AV) 或死鎖。作為分析器編寫者,您必須謹慎行使您的權力。我將討論如何使用 DoStackSnapshot,以及如何小心地執行此操作。如您所見,您想利用此方法執行的操作越多,操作就越難以正確執行。
讓我們看一下我們的主題。以下是分析器調用的內容(可在 Corprof.idl 的 ICorProfilerInfo2 接口中找到):
HRESULT DoStackSnapshot( [in] ThreadID thread, [in] StackSnapshotCallback *callback, [in] ULONG32 infoFlags, [in] void *clientData, [in, size_is(contextSize), length_is(contextSize)] BYTE context[], [in] ULONG32 contextSize); |
下列代碼是 CLR 在分析器上調用的內容(也可在 Corprof.idl 中找到)。向上例的 callback 參數中的此函數實現傳遞指針。
typedef HRESULT __stdcall StackSnapshotCallback( FunctionID funcId, UINT_PTR ip, COR_PRF_FRAME_INFO frameInfo, ULONG32 contextSize, BYTE context[], void *clientData); |
這像是一塊三明治。在分析器想要遍歷堆棧時,調用 DoStackSnapshot。在 CLR 從該調用返回之前,它調用 StackSnapshotCallback 函數多次,即,為堆棧上的每一個托管幀或每一組非托管幀調用一次該函數。圖 1 顯示了此三明治結構。

圖 1. 分析期間的調用“三明治”
正如您從我的注釋中所看到的,CLR 會將這些幀告知給您,但告知的順序與這些幀被推入到堆棧中的順序正好相反。即,最先告知葉節點幀(被最后推入),最后告知主節點幀(被最先推入)。
這些函數的所有參數有何意義?我不準備對它們進行逐一討論,但我將從 DoStackSnapshot 開始討論其中的一部分(我將利用一小部分時間討論余下部分)。infoFlags 值來自 Corprof.idl 中的 COR_PRF_SNAPSHOT_INFO 枚舉,它允許您控制 CLR 是否為您提供它所報告的幀的寄存器上下文。您可為 clientData 指定您所需要的任何值,并且 CLR 將在 StackSnapshotCallback 調用中返回該值。
在 StackSnapshotCallback 中,CLR 使用 funcId 參數向您傳遞當前遍歷的幀的 FunctionID 值。如果當前幀是一組非托管幀,則該值為 0(我稍后會加以介紹)。如果 funcId 值是一個非零值,則您可向其他方法(例如 GetFunctionInfo2 和 GetCodeInfo2)傳遞 funcId 和 frameInfo,以獲得有關該函數的更多信息。您可在堆棧遍歷過程中立即獲得此函數信息,或者保存 funcId 值并在以后獲取函數信息,以減少對運行中的應用程序的影響。如果您是在以后獲取函數信息,請記住 frameInfo 值僅在為您提供的回調內有效。盡管可以保存 funcId 值以供以后使用,但是切勿保存 frameInfo 值以備日后使用。
當您從 StackSnapshotCallback 返回時,通常會返回 S_OK,并且 CLR 將繼續遍歷堆棧。如果需要的話,也可返回 S_FALSE,這將停止堆棧遍歷。然后,DoStackSnapshot 調用會返回 CORPROF_E_STACKSNAPSHOT_ABORTED。
返回頁首
同步和異步調用
您可通過同步和異步這兩種方式調用 DoStackSnapshot。同步調用最容易執行正確。您在 CLR 調用分析器的 ICorProfilerCallback(2) 方法之一時進行同步調用,作為響應您可調用 DoStackSnapshot 來遍歷當前線程的堆棧。當您要在關注的通知點(如 ObjectAllocated)查看堆棧的外觀時,這將非常有用。要執行同步調用,可從 ICorProfilerCallback(2) 方法中調用 DoStackSnapshot,為我尚未談到的參數傳遞零或空值。
當您遍歷不同線程的堆;驈娭菩灾袛嗄硞線程以執行堆棧遍歷(在其本身或另一線程上)時,將會發生異步堆棧遍歷。中斷線程將涉及攻擊線程的指令指針以強制其任意執行您自己的代碼。這是非常危險的,其原因太多,無法在此一一列出。所以,請不要這樣做。我會將對于異步堆棧遍歷的描述限制為以非攻擊方式使用 DoStackSnapshot 來遍歷單獨的目標線程。我將此稱之為“異步”是因為目標線程是在堆棧遍歷開始時在任意點執行的。該技術通常由采樣分析器使用。
掛起其他操作遍歷全部內容
讓我們將跨線程(即,異步)堆棧遍歷稍微分解。您有兩種線程:當前線程和目標線程。當前線程是執行 DoStackSnapshot 的線程。目標線程是其堆棧正在被 DoStackSnapshot 遍歷的線程。您可通過在 thread 參數中向 DoStackSnapshot 傳遞其線程 ID 來指定目標線程。接下來發生的事情與本文中心無關。請記住,當您要求遍歷目標線程的堆棧時,目標線程正在執行任意代碼。因此,CLR 會掛起目標線程,且在其被遍歷的整個過程中目標線程會保持掛起。這可以安全地完成嗎?
很高興您會這樣問。這確實很危險,我稍后會介紹如何安全地執行此操作。但首先,我將介紹如何獲得混合模式的堆棧。
返回頁首
總結
托管應用程序不會在托管代碼上花費其全部時間。PInvoke 調用和 COM 互操作允許托管代碼調用到非托管代碼中,并且有時會使用委托返回。托管代碼會直接調用到非托管運行庫 (CLR) 中以執行 JIT 編譯、處理異常情況、進行垃圾收集等。因此,在進行堆棧遍歷時,您可能會遇到混合模式堆棧,即有些幀是托管函數,有些則是非托管函數。
已經變大了!
在繼續之前,有一個簡短的插曲。眾所周知,我們現代 PC 上的堆棧已變為(即,“壓入”)較小的地址。但當我們的腦海中或白色書寫板上出現這些地址時,我們會對如何將其垂直分類持有不同的意見。我們中有些人認為堆棧變大了(小地址在頂部);而有些人則認為它變小了(小地址在底部)。對此問題,我們的團隊中也出現了分歧。我通過自己曾經使用過的任何一個調試器(調用堆棧跟蹤和內存轉儲),來告訴自己小地址位于大地址的“上面”。由此,堆棧便變大了;主節點位于底部,葉節點則位于頂部。如果對此無法茍同,則您將不得不重新整理一下您的思維,才能了解文章的這部分內容。
服務員,我的堆棧存在漏洞
既然我們講述的是同一種語言,那么讓我們看一下混合模式堆棧。圖 2 舉例說明了混合模式堆棧。

圖 2. 帶有托管幀和非托管幀的堆棧
讓我們后退一步,很有必要先來了解一下為什么 DoStackSnapshot 會處于首要位置。它的存在可以幫助遍歷堆棧上的托管幀。如果您嘗試自己遍歷托管幀,則會得到不可靠的結果,尤其是在 32 位系統上,因為在托管代碼中使用了一些古怪的調用規則。CLR 了解這些調用規則,所以 DoStackSnapshot 可以幫助您對其進行解碼。但是,如果您希望能夠遍歷整個堆棧(包括非托管幀),則 DoStackSnapshot 并不是一個完整的解決方案。
在此,有如下選擇:
選項 1:什么也不做并向用戶報告堆棧帶有“非托管漏洞”,或者……
選項 2:編寫您自己的非托管堆棧遍歷器以填補漏洞。
當 DoStackSnapshot 遇到非托管幀塊時,如前所述,它將調用 StackSnapshotCallback 函數,并且 funcId 設置為 0。如果執行了選項 1,則當 funcId 為 0 時在回調中無需進行任何操作。CLR 會再次調用下一個托管幀,并且您可在此時進行喚醒操作。
如果非托管塊由多個非托管幀組成,則 CLR 仍會只調用 StackSnapshotCallback 一次。請記住,CLR 并未做任何努力來解碼非托管塊 — 它擁有特別的內部信息,可以幫助其略過到下一個托管幀的塊,這就是它前進的方式。CLR 不必知道在非托管塊中存在什么。那是需要您考慮的事情,因此涉及到選項 2。
第一步非常顯眼
無論您選擇了哪一選項,填補非托管漏洞都不是唯一困難的部分。剛剛開始進行遍歷可能就是一個挑戰?匆幌律厦娴亩褩。頂部有非托管代碼。有時您會很幸運,非托管代碼是 COM 或 PInvoke 代碼。如果是這樣,CLR 能夠知道如何特別巧妙地略過它,并開始遍歷第一個托管幀(示例中的 D)。但是,您可能仍想遍歷頂級非托管塊以便盡可能完整地報告堆棧。
即使您不想遍歷頂級塊,有可能不管怎樣還會強迫您這么做 — 如果您不是很幸運,非托管代碼不是 COM 或 PInvoke 代碼,而是在 CLR 自身中的幫助器代碼,例如執行 JIT 編譯或垃圾收集的代碼。如果是這種情況,則 CLR 在沒有您的幫助下無法找到 D 幀。所以對 DoStackSnapshot 的未播種調用將導致錯誤 CORPROF_E_STACKSNAPSHOT_UNMANAGED_CTX 或 CORPROF_E_STACKSNAPSHOT_UNSAFE(順便提一句,真的很值得去訪問 corerror.h)。
注意到我使用了“未播種”一詞。DoStackSnapshot 采用了使用 context 和 contextSize 參數的種子上下文!吧舷挛摹币辉~蘊涵著多種意思。在本例中所談論的是寄存器上下文。如果您細讀依賴體系結構的 windows 頭文件(例如 nti386.h),就會發現名為 CONTEXT 的結構。它含有 CPU 寄存器的值,并及時描述某一特定時刻 CPU 的狀態。這就是我所談論的上下文類型。
如果為 context 參數傳遞空值,則堆棧遍歷為未播種,且 CLR 在棧頂開始遍歷。但是,如果您為 context 參數傳遞非空值(代表堆棧下部某點的 CPU 的狀態,例如指向 D 幀),則 CLR 執行使用上下文播種的堆棧遍歷。它忽略了堆棧的真正棧頂,并從您所指的位置開始遍歷。
當然,并非總是這種情況。您傳遞到 DoStackSnapshot 的上下文更多為暗示而不是明確的指示。如果 CLR 確定其可以找到第一個托管幀(因為頂級非托管塊為 PInvoke 或 COM 代碼),則它會這么做并忽略種子。盡管這樣,但還是不要親自操作。CLR 將嘗試通過它所能提供的最為精確的堆棧遍歷來幫助您。只有頂級非托管塊是 CLR 自身中的幫助器代碼時種子才有用,因為我們沒有信息幫助我們略過它。因此,只有當 CLR 無法自己確定從何處開始遍歷時才使用種子。
首先,您可能想知道您如何能夠將種子提供給我們。如果目標線程尚未掛起,您不能僅遍歷目標線程的堆棧就找到 D 幀并從而計算您的種子上下文。我要告訴您的是可通過在調用 DoStackSnapshot 之前,進而在 DoStackSnapshot 為您處理掛起目標線程之前,進行非托管遍歷來計算種子上下文。需要由您及 CLR 來掛起目標線程嗎?實際上是這樣的。
我認為到了該精心設計的時候了。但在我進行深入設計之前,請注意是否以及如何播種僅應用到異步遍歷的堆棧遍歷這一問題。如果執行的是同步遍歷,則 DoStackSnapshot 在沒有您的幫助下仍可找到到達頂級托管幀的路徑 — 不需要種子。
即時匯總
對于在填補非托管漏洞時執行異步、跨線程、播種堆棧遍歷的真正了不起的分析器,其堆棧遍歷類似于下圖。假定這里所示的堆棧與圖 2 中看到的是同一堆棧,只是有些分散。
堆棧內容 |
分析器和 CLR 操作 |
![]() |
1. 掛起目標線程(目標線程的掛起計數當前為 1)。 2. 獲取目標線程的當前寄存器上下文。 3. 確定寄存器上下文是否指向非托管代碼 - 即,調用 ICorProfilerInfo2::GetFunctionFromIP 并檢查返回的 FunctionID 值是否為 0。 4. 因為在此例中寄存器上下文確實是指向非托管代碼的,所以執行非托管堆棧遍歷直到找到頂級托管幀為止(函數 D)。 |
![]() |
5. 使用種子上下文調用 DoStackSnapshot,且 CLR 再次掛起目標線程(掛起計數現在為 2)。開始進入三明治結構。
a. CLR 使用 D 的 FunctionID 調用 StackSnapshotCallback 函數。 |
![]() |
b. CLR 調用 StackSnapshotCallback 函數,且 FunctionID 等于 0。您必須自己遍歷此塊。當到達第一個托管幀時可以停止。換句話說,您可以欺騙并延遲非托管遍歷直到下一次回調之后的某個時刻,因為下一次回調會準確地告訴您下一個托管幀是從哪里開始的,從而知道非托管遍歷應在哪里結束。 |
![]() |
c. CLR 使用 C 的 FunctionID 調用 StackSnapshotCallback 函數。 |
![]() |
d. CLR 使用 B 的 FunctionID 調用 StackSnapshotCallback 函數。 |
![]() |
e. CLR 調用 StackSnapshotCallback 函數,且 FunctionID 等于 0。您必須再次自己遍歷此塊。 |
![]() |
f. CLR 使用 A 的 FunctionID 調用 StackSnapshotCallback 函數。 |
![]() |
g. CLR 使用 Main 的 FunctionID 調用 StackSnapshotCallback 函數。
h. DoStackSnapshot 通過調用 Win32 ResumeThread() API 來“重新開始”目標線程,這將減少線程的掛起計數(其掛起計數當前為 1)并返回。三明治結構完成。 |
6. 重新開始目標線程。掛起計數當前為 0,所以線程自然地重新開始。 |

做出最佳的表現
很好,這在沒有嚴重警告的情況下是一種比較有功效的方法。在最高級的情況中,您要對記時器中斷信號做出響應并果斷地掛起應用程序線程以遍歷堆棧。呀!
要做好很難,還涉及到一些起初并不明顯的規則。所以我們來進行進一步的研究。
壞種子
讓我們從一個簡單的規則開始:不使用壞種子。如果在調用 DoStackSnapshot 時分析器提供了一個無效(非零)種子,則 CLR 將產生壞的結果。它將查看您將其指向到的堆棧,并假設要對堆棧上的哪些值進行表示。這將導致 CLR 廢棄假定要在堆棧上尋址的值。倘若是壞種子,CLR 會將這些值廢棄到內存中某些未知的地方。CLR 執行所能夠完成的每件事情來避免全部的二次 AV,這些 AV 可以毀掉您正在分析的進程。但您真正應該努力的是保證種子完好。
掛起的災難
掛起線程的其他方面非常復雜以至于需要有多重規則。在決定進行跨線程遍歷時,您至少已經決定要求 CLR 為了您的利益掛起線程。而且,如果您想要在堆棧棧頂遍歷非托管塊,則您已決定自己掛起線程,而不調用 CLR 來判斷這在當時是否是個好主意。
如果您上過計算機科學課,則您可能會記起“哲學家就餐”問題。一群哲學家坐在桌子旁邊,每個人的右邊和左邊各有一把叉子。根據問題,他們每個人需要兩把叉子進餐。每位哲學家拿起他右邊的叉子,但隨后卻沒人能夠拿到他左邊的叉子,因為每位哲學家都在等待他左邊的哲學家放下所需的叉子。而且如果哲學家們是坐在圓桌旁,就會產生循環等待并都餓著肚子。他們所有人都餓著的原因就是他們違反了避免死鎖的簡單規則:如果您需要多重鎖,則務必使它們處于同一順序中。遵循此規則可以避免出現 A 等待 B,B 等待 C,并且 C 等待 A 的循環。
假定應用程序遵循此規則并且讓所有鎖始終處于同一順序中,F在出現了一個組件(例如分析器),并開始隨意地掛起線程。這真正地增加了復雜性。如果現在掛起者需要拿到被掛起者持有的鎖怎么辦?或者如果掛起者需要某個線程所持有的鎖,而該線程正等待另一個線程所持有的鎖,此另一線程又在等待被掛起者所持有的鎖,這該怎么辦?掛起會添加一個新的邊到依賴線程的圖形上,此操作可以引導循環。讓我們看一些具體的問題。
問題 1:被掛起線程擁有掛起線程或掛起線程依賴的線程所需的鎖。
問題1a:鎖為 CLR 鎖。
可以想象,CLR 執行了許多線程同步,因而具有內部所使用的多個鎖。當您調用 DoStackSnapshot 時,CLR 會檢測目標線程是否擁有當前線程(正在調用 DoStackSnapshot 的線程)為了執行堆棧遍歷而需要的 CLR 鎖。當出現該狀況時,CLR 將拒絕執行掛起,并且 DoStackSnapshot 會立即以錯誤 CORPROF_E_STACKSNAPSHOT_UNSAFE 而返回。此時,如果您在調用 DoStackSnapshot 之前已自行掛起了線程,則您將會親自恢復該線程,這樣就避免了問題。
問題 1b:鎖為自身分析器的鎖。
此問題其實更像是一個常識問題。您可能會在各處執行自己的線程同步。設想一下,某個應用程序線程(線程 A)遇到分析器回調并運行了取得其中一個分析器鎖的某些分析器代碼。隨后,線程 B 需要遍歷線程 A,這意味著線程 B 將會掛起線程 A。需要記住的是,盡管線程 A 被掛起,但您不應促使線程 B 取得線程 A 可能擁有的任何一個分析器自身的鎖。例如,在堆棧遍歷期間,線程 B 將執行 StackSnapshotCallback,因此,在該回調期間,您不應取得線程 A 可能擁有的任何鎖。
問題 2:盡管您掛起了目標線程,但目標線程會試圖將您掛起。
您可能會說,“不可能發生這種情況!”但是不管您是否相信,在以下情況,這確實會發生:
- 您的應用程序運行在多處理器盒上,并且
- 線程 A 運行在一個處理器上,而線程 B 運行在另一個處理器上,并且
- 在線程 A 試圖掛起線程 B 的同時,線程 B 也試圖掛起線程 A。
在此情況下,有可能兩個掛起均會成功,從而最終兩個線程均被掛起。由于每個線程都在等待另一方將其喚醒,因此它們將永遠處于掛起狀態。
與問題 1 相比,這個問題更令人不安,因為您無法在調用 DoStackSnapshot 之前,依靠 CLR 來檢測線程是否會相互掛起。在您執行了掛起之后,那就太晚了!
為什么目標線程會試圖掛起分析器?假如分析器編寫得不好,堆棧遍歷代碼連同掛起代碼可能會被任意數量的線程執行任意多次。設想線程 A 正試圖遍歷線程 B,而同時線程 B 也在試圖遍歷線程 A。這兩個線程會同時試圖將對方掛起,因為它們都在執行分析器堆棧遍歷例程的 SuspendThread 部分。如果兩者都獲得成功,則正在分析的應用程序將發生死鎖。此處的規則很明顯 — 不允許分析器在兩個線程上同時執行堆棧遍歷代碼(進而是掛起代碼)!
目標線程可能會試圖將遍歷線程掛起的原因不太明顯,它是因 CLR 的內部工作方式而造成的。CLR 會掛起應用程序線程以幫助執行諸如垃圾收集等任務。如果遍歷器試圖遍歷(進而掛起)正在執行垃圾收集的線程,而同時垃圾收集器線程也試圖將遍歷器掛起,則進程將發生死鎖。
不過,這個問題很容易避免。CLR 僅會掛起為了完成工作而需要掛起的線程。設想在堆棧遍歷中涉及到兩個線程。線程 W 為當前線程(執行遍歷的線程)。線程 T 為目標線程(其堆棧被遍歷的線程)。只要線程 W 從未執行過托管代碼,因而不會進行 CLR 垃圾收集,CLR 就決不會試圖掛起線程 W。這就意味著分析器讓線程 W 掛起線程 T 是安全的。
如果您正在編寫采樣分析器,很自然要確保這一切。通常,您將自行創建一個單獨的線程來響應計時器中斷和遍歷其他線程的堆棧。此線程稱為采樣器線程。由于是您自己創建采樣器線程并且控制著它所執行的工作(因而它決不會執行托管代碼),所以 CLR 沒有理由會將其掛起。設計您的分析器以使它創建自己的采樣線程來執行所有的堆棧遍歷,還可避免先前所述的“分析器編寫不佳”問題。采樣器線程是分析器唯一試圖遍歷或掛起其他線程的線程,因此分析器決不會試圖直接掛起采樣器線程。
這是我們的第一個重要規則,因此,為了強調起見,讓我們再重復一遍:
規則 1:只有從未運行過托管代碼的線程才可以掛起另一個線程。
無人愿意在尸上行走
如果您正在執行跨線程堆棧遍歷,則必須確保目標線程在整個遍歷期間都一直存活。這只不過是因為您將目標線程作為參數傳遞給 DoStackSnapshot 調用,并不意味著您已為其隱式添加了任何種類的生存期引用。應用程序隨時都可以使線程消亡。如果在您試圖遍歷線程時發生這種情況,則很容易會造成訪問違例。
幸運的是,當線程即將被銷毀之時,CLR 會使用以 ICorProfilerCallback(2) 接口定義的適當命名的 ThreadDestroyed 回調來通知分析器。由您負責實現 ThreadDestroyed,并讓其等到遍歷該線程的任何進程均完成為止。這一點相當令人關注,夠格作為我們的下一條規則:
規則 2:重寫 ThreadDestroyed 回調,讓您實現的回調等到您對所要銷毀線程的堆棧完成遍歷為止。
按照規則 2,在您對線程的堆棧完成遍歷之前,將會阻止 CLR 銷毀該線程。
垃圾收集有助于循環利用
此刻,事情可能變得有點令人迷惑不解。讓我們先來看看下一條規則的正文,然后從該處對其進行解釋:
規則 3:不要在可能觸發垃圾收集的分析器調用期間持有鎖。
我先前提到過,當擁有線程可能會被掛起以及線程可能會被需要同一個鎖的另一線程遍歷時,讓分析器持有鎖(如果該鎖是其自己的鎖),并不是一個好主意。規則 3 可幫助您避免更微妙的問題。在此,我說的是,如果擁有線程即將調用一個可能會觸發垃圾收集的 ICorProfilerInfo(2) 方法,則您不應持有自己的任何鎖。
舉幾個例子進行說明,應該會有所幫助。在第一個示例中,假定線程 B 正在執行垃圾收集。順序如下:
- 線程 A 取得且現在擁有其中一個分析器鎖。
- 線程 B 調用分析器的 GarbageCollectionStarted 回調。
- 線程 B 阻塞于來自步驟 1 的分析器鎖。
- 線程 A 執行 GetClassFromTokenAndTypeArgs 函數。
- GetClassFromTokenAndTypeArgs 調用試圖觸發垃圾收集,但是檢測到垃圾收集已在進行中。
- 線程 A 阻塞,等待當前正在進行的垃圾收集(線程 B)完成。然而,線程 B 由于分析器鎖而正在等待線程 A。
圖 3 說明了本例中的情況:

圖 3. 分析器與垃圾收集器之間的死鎖
第二個示例的情況稍微有些不同。順序如下:
1.線程 A 取得且現在擁有其中一個分析器鎖。
2.線程 B 調用分析器的 ModuleLoadStarted 回調。
3.線程 B 阻塞于來自步驟 1 的分析器鎖。
4.線程 A 執行 GetClassFromTokenAndTypeArgs 函數。
5.GetClassFromTokenAndTypeArgs 調用觸發垃圾收集。
6.線程 A(它現在正在執行垃圾收集)等待線程 B 準備好進行收集。但是,線程 B 由于分析器鎖而正在等待線程 A。
7.圖 4 說明了第二個示例。
圖 4. 分析器與待處理的垃圾收集之間的死鎖
您心頭的迷霧已然化解了嗎?問題的關鍵在于垃圾收集具有自身的同步機制。之所以會出現第一個示例中的結果,其原因是一次只能進行一個垃圾收集。誠然,這只是一種邊緣情況,因為垃圾收集通常不會如此頻繁地發生,以至于其中一個必須等待另一個,除非您是在緊張條件下進行操作。即使如此,如果分析時間足夠長,這種情況也會發生,您需要對此做好準備。
之所以會出現第二個示例中的結果,其原因是執行垃圾收集的線程必須等待其他應用程序線程準備好進行收集。當由于您向混合體中引入自己的其中一個鎖而形成循環時,便會出現該問題。這兩種情況都破壞了規則 3,因為它們都允許線程 A 先擁有其中一個分析器鎖,然后再調用 GetClassFromTokenAndTypeArgs(實際上,調用任何可能觸發垃圾收集的方法都足以毀滅進程)。
到目前為止,您可能產生了若干疑問。
問:如何知道哪個 ICorProfilerInfo(2) 方法可能觸發垃圾收集?
答:我們計劃在 MSDN 上或至少在我的博客或 Jonathan Keljo 的博客上撰文對此進行論述。
問:這與堆棧遍歷有何關系?絲毫也沒有提及 DoStackSnapshot。
答:確實如此。DoStackSnapshot 就連一個可以觸發垃圾收集的 ICorProfilerInfo(2) 方法都算不上。我之所以在此討論規則 3,其原因在于,確實有一些冒險的編程人員會從任意的樣本中異步遍歷堆棧,而且他們極有可能會實現各自的分析器鎖,從而往往會掉進這個陷阱。的確,規則 2 實質上告訴您要向分析器中添加同步。很可能采樣分析器還會有其他的同步機制,也許是用以隨時協調對共享數據結構的讀寫。當然,從未觸及 DoStackSnapshot 的分析器仍然有可能遇到這個問題。
返回頁首
夠了夠了
在結束之前,讓我扼要做個要點概括。以下是需要記住的幾個重點:
- 同步堆棧遍歷包括應分析器回調的要求而遍歷當前線程。它們并不需要做種、掛起或任何特殊規則。
- 如果堆棧頂端為非托管代碼,且不是 PInvoke 或 COM 調用的一部分,則異步遍歷需要種子。提供種子的方法是直接掛起目標線程,然后自行對其進行遍歷,直至您找到最頂端的托管幀。如果在此情況下未提供種子,則 DoStackSnapshot 可能會返回失敗代碼或跳過堆棧頂端的一些幀。
- 如果您需要掛起線程,請記住,只有從未運行過托管代碼的線程才可以掛起另一線程。
- 執行異步遍歷時,始終都要重寫 ThreadDestroyed 回調,以便在線程的堆棧遍歷完成之前,阻止 CLR 銷毀該線程。
- 不要在分析器調用到可以觸發垃圾收集的 CLR 函數之中時持有鎖。
有關分析 API 的詳細信息,請參閱 MSDN 網站上的 Profiling (Unmanaged)(英文)。
應得到榮譽的功臣
在這里,我要萬分感謝 CLR 分析 API 團隊的其余同仁,因為編寫這些規則確實是團隊努力的結果。特別要感謝 Sean Selitrennikoff,他提供了本文中大量內容的雛形。
關于作者
David 在 Microsoft 擔任開發人員已有很長時間,比您想象的還要久,這使他具備了一定范圍的知識和成熟經驗。雖然他不再獲許簽入代碼,但是他仍然提供了一些有關新變量名的想法。David 是一位狂熱的 Count Chocula 迷,擁有自己的私家車。
文章來源于領測軟件測試網 http://www.kjueaiud.com/