.NET Framework 2.0 中的探查器堆栈演练:基础知识及更高版本

 

2006 年 9 月

大卫·布罗曼
Microsoft Corporation

适用于:
   Microsoft .NET Framework 2.0
   公共语言运行时 (CLR)

总结:介绍如何对探查器进行编程,以在.NET Framework的公共语言运行时 (CLR) 中访问托管堆栈。 (14 个打印页)

目录

简介
同步和异步调用
混合起来
坚持最佳行为
受够了
信用到期的信用额度
关于作者

简介

本文面向有兴趣生成探查器以检查托管应用程序的任何人。 我将介绍如何对探查器进行编程,以在.NET Framework的公共语言运行时 (CLR) 中访问托管堆栈。 我会尽量保持轻松的心情, 因为主题本身有时可能会变得沉重。

CLR 版本 2.0 中的分析 API 具有名为 DoStackSnapshot 的新方法,该方法允许探查器遍历要分析的应用程序的调用堆栈。 CLR 版本 1.1 通过进程内调试接口公开了类似的功能。 但是,使用 DoStackSnapshot,调用堆栈更轻松、更准确、更稳定。 DoStackSnapshot 方法使用垃圾回收器、安全系统、异常系统等使用的相同堆栈演练程序。 所以 你知道 这一定是正确的。

通过访问完整堆栈跟踪,探查器的用户能够在发生有趣的事情时大致了解应用程序中发生的情况。 根据应用程序以及用户想要分析的内容,可以想象用户在分配对象、加载类、引发异常时等情况下需要调用堆栈。 对于采样探查器来说,即使获取应用程序事件以外的其他事件(例如计时器事件)的调用堆栈也很有趣。 当你可以看到谁调用了调用函数的函数,该函数调用了包含热点的函数时,查看代码中的热点会变得更加启发性。

我将重点介绍使用 DoStackSnapshot API 获取堆栈跟踪。 获取堆栈跟踪的另一种方法是生成阴影堆栈:可以挂钩 FunctionEnterFunctionLeave ,以保留当前线程的托管调用堆栈的副本。 如果在应用程序执行期间需要堆栈信息,并且不介意在每次托管调用和返回时运行探查器代码的性能成本,则影子堆栈生成非常有用。 如果需要稍微稀疏的堆栈报告(例如响应事件), 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.) 在前面的示例中的 回调 参数中传递指向此函数实现的指针。

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 会以相反的顺序通知帧,它们与推送到堆栈的方式相反-叶帧首先 (推送最后一个) ,main帧最后 (推送第一) 。

这些函数的所有参数意味着什么? 我还没有准备好讨论它们,但我会讨论其中的一些,从 DoStackSnapshot 开始。 (稍后我将介绍其余部分。) infoFlags 值来自 Corprof.idl 中的 COR_PRF_SNAPSHOT_INFO 枚举,它使你能够控制 CLR 是否为你注册其报告的帧的上下文。 可以为 clientData 指定所需的任何值,CLR 会在 StackSnapshotCallback 调用中将其返回给你。

StackSnapshotCallback 中,CLR 使用 funcId 参数向你传递当前已访问的帧的 FunctionID 值。 如果当前帧是非托管帧的运行,则此值为 0,我稍后将对此进行介绍。 如果 funcId 为非零值,则可以将 funcIdframeInfo 传递给其他方法(如 GetFunctionInfo2GetCodeInfo2),以获取有关函数的详细信息。 可以在堆栈演练期间立即获取此函数信息,或者保存 funcId 值并稍后获取函数信息,从而减少对正在运行的应用程序的影响。 如果稍后获取函数信息,请记住 frameInfo 值仅在提供给你的回调中有效。 虽然可以保存 funcId 值供以后使用,但不要保存 frameInfo 供以后使用。

StackSnapshotCallback 返回时,通常会返回 S_OK 并且 CLR 将继续执行堆栈。 如果需要,可以返回 S_FALSE,这会停止堆栈演练。 然后 ,DoStackSnapshot 调用将返回 CORPROF_E_STACKSNAPSHOT_ABORTED

同步和异步调用

可以通过两种方式(同步和异步)调用 DoStackSnapshot 。 同步调用是最容易得到的。 当 CLR 调用探查器的 ICorProfilerCallback (2) 方法之一时,将进行同步调用,并响应调用 DoStackSnapshot 以访问当前线程的堆栈。 如果要在有趣的通知点(如 ObjectAllocated)查看堆栈的外观,这非常有用。 若要执行同步调用,请从 ICorProfilerCallback (2) 方法中调用 DoStackSnapshot,为我尚未告知的参数传递零或 null

当遍走不同线程的堆栈或强行中断线程以对自身或另一个线程) 执行堆栈遍 (时,将发生异步堆栈演练。 中断线程涉及劫持线程的指令指针,以强制它在任意时间执行自己的代码。 由于太多原因无法在此处列出,这非常危险。 请不要这样做。 我将对异步堆栈演练的描述限制为 DoStackSnapshot 的非劫持用途,以遍历单独的目标线程。 我之所以称此为“异步”,是因为目标线程在堆栈演练开始时的任意时间点执行。 采样探查器通常使用此方法。

遍历其他人

让我们对跨线程(即异步)堆栈进行一点分解。 你有两个线程:当前线程和目标线程。 当前线程是执行 DoStackSnapshot 的线程。 目标线程是 DoStackSnapshot 正在执行其堆栈的线程。 可以通过将线程参数中的线程 ID 传递给 DoStackSnapshot 来指定目标线程。 接下来发生的事情不是为微弱的心脏。 请记住,当你要求访问其堆栈时,目标线程正在执行任意代码。 因此,CLR 会挂起目标线程,并且它在整个运行期间保持挂起状态。 这可以安全完成吗?

很高兴听到这个问题。 这确实很危险,我稍后将讨论如何安全执行此操作。 但首先,我将进入混合模式堆栈。

混合起来

托管应用程序不太可能将其所有时间都花在托管代码中。 PInvoke 调用和 COM 互操作允许托管代码调用非托管代码,有时使用委托再次调用 。 托管代码直接调用非托管运行时 (CLR) 执行 JIT 编译、处理异常、执行垃圾回收等。 因此,执行堆栈演练时,可能会遇到混合模式堆栈 --有些帧是托管函数,有些帧是非托管函数。

长大了,已经了!

在我继续之前,一个简短的插曲。 每个人都知道,新式电脑上的堆栈 (即“推送”) 到较小的地址。 但是,当我们将这些地址可视化在我们的脑海或白板上时,我们不同意如何垂直排序它们。 我们中的一些人想象 堆栈在) 顶部 (小地址长大:有些人看到 它 (底部) 的小地址下降。 我们团队在此问题上也意见不一。 我选择与我曾经使用过的任何调试器一起 - 调用堆栈跟踪和内存转储告诉我,小地址“高于”大地址。 所以堆栈长大了:main位于底部,叶被调用方位于顶部。 如果你不同意,你必须进行一些心理重新安排才能通过本文的这部分。

服务员, 我的堆栈中有洞

现在,我们讲的是同一种语言,让我们看一下混合模式堆栈。 图 2 演示了混合模式堆栈示例。

图 2. 包含托管帧和非托管帧的堆栈

退后一步,首先了解 DoStackSnapshot 存在的原因是值得的。 它可以帮助你在堆栈上走 托管 帧。 如果尝试自行访问托管帧,将得到不可靠的结果,尤其是在 32 位系统上,因为托管代码中使用了一些古怪的调用约定。 CLR 了解这些调用约定, 因此 DoStackSnapshot 可以帮助你解码它们。 但是,如果希望能够访问整个堆栈(包括非托管帧), DoStackSnapshot 并不是一个完整的解决方案。

下面是你可以选择的地方:

选项 1:不执行任何操作,向用户报告包含“非托管漏洞”的堆栈,或者...

选项 2:编写自己的非托管堆栈演练程序来填充这些漏洞。

DoStackSnapshot 遇到非托管帧块时,它会调用将 funcId 设置为 0 的 StackSnapshotCallback 函数,如前所述。 如果要使用选项 1,只需在 funcId 为 0 时在回调中不执行任何操作。 CLR 将针对下一个托管帧再次调用你,此时你可以唤醒。

如果非托管块由多个非托管帧组成,则 CLR 仍只调用 一次 StackSnapshotCallback 。 请记住,CLR 不费力地解码非托管块 ,它具有特殊的内部信息,可帮助其跳过块到下一个托管帧,这就是它的进度。 CLR 不一定知道非托管块内的内容。 这是由你弄清楚的,因此选项 2。

第一步是 Doozy

无论你选择哪个选项,填充非托管孔并不是唯一难的部分。 刚开始散步可能是一个挑战。 查看上面的堆栈。 顶部有非托管代码。 有时你会很幸运,非托管代码将是 COM 或 PInvoke 代码。 如果是这样,则 CLR 足够聪明,知道如何跳过它,并在示例) 的第一个托管帧 (D 开始演练。 但是,你仍可能希望访问最顶层的非托管块,以便报告堆栈尽可能完整。

即使你不想走最顶层的块,你可能还是被迫这样做 — 如果你 幸运,非托管代码不是 COM 或 PInvoke 代码,而是 CLR 本身的帮助程序代码,例如执行 JIT 编译或垃圾回收的代码。 如果是这种情况,没有你的帮助,CLR 将无法找到 D 帧。 因此,对 DoStackSnapshot 的无序调用将导致 错误CORPROF_E_STACKSNAPSHOT_UNMANAGED_CTXCORPROF_E_STACKSNAPSHOT_UNSAFE。 (顺便说一句,访问 corerror.h.)

请注意,我使用了单词“unseed”。DoStackSnapshot 使用 contextcontextSize 参数获取种子上下文 。 “context”一词重载了许多含义。 在本例中,我谈论的是注册上下文。 例如,如果细读依赖于体系结构的 windows 标头 (nti386.h) 你将找到一个名为 CONTEXT 的结构。 它包含 CPU 寄存器的值,并表示 CPU 在特定时刻的状态。 这就是我所说的上下文类型。

如果为上下文参数传递 null,则堆栈演练将取消子级,CLR 从顶部开始。 但是,如果为 上下文 参数传递非 null 值,表示堆栈 (下部某个位置的 CPU 状态(例如指向 D 帧) ),则 CLR 将执行与上下文一起设定种子的堆栈演练。 它会忽略堆栈的实际顶部,并从指向它的任何位置开始。

好吧,不太真实。 传递给 DoStackSnapshot 的 上下文与其说是直接指令,不如说是提示。 如果 CLR 确定可以找到第一个托管帧 (因为最顶层的非托管块是 PInvoke 或 COM 代码) ,它将执行此操作并忽略种子。 不过,不要亲自接受。 CLR 试图通过提供最准确的堆栈步进来帮助你。 仅当最顶层的非托管块是 CLR 本身的帮助程序代码时,种子才有用,因为我们没有信息可帮助我们跳过它。 因此,仅当 CLR 无法自行确定从何处开始演练时,才会使用种子。

你可能想知道如何首先向我们提供种子。 如果目标线程尚未挂起,则不能只遍查目标线程的堆栈来查找 D 帧,从而计算种子上下文。 然而,我告诉你,在调用 DoStackSnapshot 之前,在 DoStackSnapshot 处理挂起目标线程之前,通过执行非托管的演练来计算种子上下文。 目标线程是否需要由你 CLR 挂起? 事实上,是的。

我想是时候编舞这部芭蕾了。 但在深入了解之前,请注意,是否以及如何为堆栈演练设定种子的问题仅适用于 异步 演练。 如果你正在执行同步演练, DoStackSnapshot 将始终能够在没有你的帮助的情况下找到到达最顶层托管框架的方式—无需种子。

现在在一起

对于在填充非托管孔时执行异步、跨线程、种子堆栈遍历的真正冒险探查器,下面是堆栈遍历的外观。 假设此处所示的堆栈与图 2 中所示的堆栈相同,只是稍微分解了一下。

堆栈内容 Profiler 和 CLR 操作

1.挂起目标线程。 (目标线程的挂起计数现在为 1.)

2. 获取目标线程的当前寄存器上下文。

3. 确定寄存器上下文是否指向非托管代码,即调用 ICorProfilerInfo2::GetFunctionFromIP 并检查是否返回 FunctionID 值 0。

4. 由于在此示例中,寄存器上下文确实指向非托管代码,因此您执行非托管堆栈演练,直到找到函数 D) (最顶层托管帧。

5. 使用种子上下文调用 DoStackSnapshot ,CLR 将再次挂起目标线程。 (它的暂停计数现在是2.) 三明治开始。
a. CLR 使用用于 D 的 FunctionID 调用 StackSnapshotCallback 函数。
b. CLR 调用 FunctionID 等于 0 的 StackSnapshotCallback 函数。 你必须自己走这个块。 到达第一个托管帧时,可以停止。 或者,你可以欺骗和延迟非托管步行,直到下一次回调之后的某个时间,因为下一个回调将告诉你下一个托管帧的确切开始位置,因此非托管的步行应在哪里结束。
c. CLR 使用 C 的 FunctionID 调用 StackSnapshotCallback 函数。
d. CLR 使用 B 的 FunctionID 调用 StackSnapshotCallback 函数。
e. CLR 调用 FunctionID 等于 0 的 StackSnapshotCallback 函数。 同样,你必须自己走这个块。
f. CLR 使用 A 的 FunctionID 调用 StackSnapshotCallback 函数。
g. CLR 使用 Main 的 FunctionID 调用 StackSnapshotCallback 函数。

h. DoStackSnapshot 通过调用 Win32 ResumeThread () API 来“恢复”目标线程,这会递减线程的挂起计数, (其暂停计数现在为 1) 并返回 。 三明治已完成。
6. 恢复目标线程。 其暂停计数现在为 0,因此线程会实际恢复。

坚持最佳行为

好吧,这是太多的权力,没有一些认真的谨慎。 在最高级的情况下,你将响应计时器中断并任意挂起应用程序线程以遍转其堆栈。 是的!

做好是很难的,涉及一开始并不明显的规则。 因此,让我们深入了解一下。

坏种子

让我们从一个简单的规则开始:不要使用糟糕的种子。 如果在调用 DoStackSnapshot 时探查器提供无效的 (非 null) 种子,则 CLR 将产生错误的结果。 它将查看指向它的堆栈,并假设堆栈上的值应该表示什么。 这将导致 CLR 取消引用假定为堆栈上的地址。 如果种子不正确,CLR 会将值取消引用到内存中的某个未知位置。 CLR 会尽其所能避免出现完全二次机会 AV,这会破坏正在分析的进程。 但你真的应该努力把你的种子弄好。

暂停的困境

挂起线程的其他方面非常复杂,需要多个规则。 当你决定执行跨线程行走时,你至少决定要求 CLR 代表你暂停线程。 此外,如果要在堆栈顶部执行非托管块,则决定自行暂停线程,而无需调用 CLR 关于目前这是否是一个好主意的智慧。

如果你上计算机科学课,你可能还记得“餐饮哲学家”的问题。 一群哲学家正坐在一张桌子前,每张桌子右侧有一个叉子,左侧有一个叉子。 根据问题,他们每人需要两个叉子才能吃。 每个哲学家都拿起他的右叉,但没有人能拿起他的左叉,因为每个哲学家都在等待哲学家在他左边放下所需的叉子。 如果哲学家们坐在一张圆形的桌子上,你有一个等待的周期和很多空腹。 他们都饿死的原因是他们打破了避免死锁的简单规则:如果需要多个锁,请始终按相同的顺序使用它们。 遵循此规则可避免 A 在 B 上等待、B 等待 C、C 等待 A 的周期。

假设应用程序遵循规则并始终采用相同顺序的锁。 现在,一个组件 (探查器,例如,) 并开始任意挂起线程。 复杂性大大增加。 如果吊架现在需要采取被吊销者持有的锁怎么办? 或者,如果挂起程序需要一个线程持有的锁,该线程正在等待另一个线程持有的锁,而该线程正在等待被挂起者持有的锁,该怎么办? 挂起会向线程依赖项关系图添加新的边缘,这会引入周期。 让我们来看看一些具体问题。

问题 1:被挂起者拥有挂起程序所需的或挂起程序所依赖的线程所需的锁。

问题 1a:锁是 CLR 锁。

可以想象,CLR 执行大量线程同步,因此有多个锁在内部使用。 调用 DoStackSnapshot 时,CLR 会检测到目标线程拥有一个 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 尝试访问线程 A 的同时尝试访问线程 B。它们都尝试同时挂起彼此,因为它们都在执行探查器的堆栈浏览例程的 SuspendThread 部分。 两者都获胜,正在分析的应用程序是死锁的。 此处的规则是显而易见的 - 不允许探查器 (执行堆栈浏览代码,从而在两个线程上同时挂起代码) !

目标线程可能尝试挂起步行线程的一个不太明显的原因是 CLR 的内部工作。 CLR 会挂起应用程序线程,以帮助执行垃圾回收等任务。 如果步进器尝试 () 执行垃圾回收的线程在垃圾回收线程尝试挂起你的步进器的同时暂停,则进程将死锁。

但很容易避免这个问题。 CLR 仅挂起它为了执行其工作而需要挂起的线程。 假设堆栈演练中涉及两个线程。 线程 W 是执行) (线程的当前线程。 线程 T 是其堆栈) (线程的目标线程。 只要 Thread W 从未执行过托管代码,因此不受 CLR 垃圾回收的约束,CLR 就永远不会尝试挂起 Thread W。这意味着探查器让 Thread W 挂起 Thread T 是安全的。

如果你正在编写采样探查器,则确保这一切非常自然。 你通常会有一个单独的线程,用于响应计时器中断,并遍遍其他线程的堆栈。 将此称为取样器线程。 由于你自行创建采样器线程并控制它 (执行的内容,因此它永远不会) 执行托管代码,因此 CLR 将没有理由暂停它。 设计探查器,使其创建自己的采样线程以执行所有堆栈遍检,还可以避免前面所述的“编写不当的探查器”的问题。 取样器线程是探查器尝试访问或暂停其他线程的唯一线程,因此探查器永远不会尝试直接挂起采样器线程。

这是我们的第一个不平凡的规则,因此为了强调,让我重复它:

规则 1:只有从未运行托管代码的线程应挂起另一个线程。

没有人喜欢走尸体

如果要执行跨线程堆栈演练,必须确保目标线程在演练期间保持活动状态。 仅仅因为你将目标线程作为参数传递给 DoStackSnapshot 调用并不意味着你已经隐式添加了任何类型的生存期引用。 应用程序可以随时使线程消失。 如果在尝试访问线程时发生这种情况,则很容易导致访问冲突。

幸运的是,当线程即将被销毁时,CLR 会使用由 ICorProfilerCallback (2) 接口定义的名为 ThreadDestroyed 的回调通知探查器。 你有责任实现 ThreadDestroyed ,并等待该线程的任何进程完成。 这足够有趣,符合我们的下一条规则:

规则 2:重写 ThreadDestroyed 回调,并让实现等到完成要销毁的线程堆栈。

遵循规则 2 阻止 CLR 销毁线程,直到完成该线程的堆栈。

垃圾回收可帮助你创建一个循环

此时,事情可能会有点混乱。 让我们从下一个规则的文本开始,然后从此处破译它:

规则 3:在探查器调用期间不要锁定可能会触发垃圾回收。

我之前提到,如果探查器自己的锁(如果拥有线程可能挂起)以及线程可能由另一个需要同一锁的线程访问,则保留探查器是一个坏主意。 规则 3 可帮助你避免更微妙的问题。 在这里,我说,如果拥有线程将调用 ICorProfilerInfo (2) 方法,则不应持有任何自己的锁,该方法可能会触发垃圾回收。

几个示例应该会有所帮助。 对于第一个示例,假设线程 B 正在执行垃圾回收。 序列为:

  1. 线程 A 采用探查器锁,现在拥有其中一个探查器锁。
  2. 线程 B 调用探查器的 GarbageCollectionStarted 回调。
  3. 步骤 1 中的探查器锁上的线程 B 块。
  4. 线程 A 执行 GetClassFromTokenAndTypeArgs 函数。
  5. GetClassFromTokenAndTypeArgs 调用尝试触发垃圾回收,但检测到垃圾回收已在进行。
  6. 线程 A 块,等待当前正在进行的垃圾回收 (线程 B) 完成。 但是,由于探查器锁定,线程 B 正在等待线程 A。

图 3 演示了此示例中的方案:

图 3. 探查器和垃圾回收器之间的死锁

第二个示例是略有不同的方案。 序列为:

  1. 线程 A 采用探查器锁,现在拥有其中一个。
  2. 线程 B 调用探查器的 ModuleLoadStarted 回调。
  3. 步骤 1 中探查器锁上的线程 B 块。
  4. 线程 A 执行 GetClassFromTokenAndTypeArgs 函数。
  5. GetClassFromTokenAndTypeArgs 调用会触发垃圾回收。
  6. 正在执行垃圾回收的线程 A () 等待线程 B 准备好进行回收。 但是,由于探查器锁定,线程 B 正在等待线程 A。
  7. 图 4 演示了第二个示例。

图 4。 探查器与挂起的垃圾回收之间的死锁

你消化了疯狂了吗? 问题的症结在于垃圾回收有自己的同步机制。 第一个示例中的结果发生是因为一次只能发生一个垃圾回收。 无可否认,这是一个边缘情况,因为垃圾回收通常不会频繁发生,以至于一个人必须等待另一个,除非你在压力条件下操作。 即便如此,如果你分析的时间足够长,则会出现这种情况,并且你需要为此做好准备。

第二个示例中的结果发生是因为执行垃圾回收的线程必须等待其他应用程序线程准备好进行回收。 将自己的锁之一引入到组合中时,会出现此问题,从而形成一个循环。 在这两种情况下,规则 3 都是通过允许线程 A 拥有探查器锁之一,然后调用 GetClassFromTokenAndTypeArgs 来破坏的。 (实际上,调用任何可能触发垃圾回收的方法都足以使 process.)

到目前为止,你可能有几个问题。

Q. 如何知道哪些 ICorProfilerInfo (2) 方法可能会触发垃圾回收?

A. 我们计划在 MSDN 上记录这一点,或者至少在 我的博客Jonathan Keljo 的博客中记录。

Q. 这与堆栈行走有什么关系? DoStackSnapshot 没有提及。

A. 正确。 DoStackSnapshot 甚至不是 ICorProfilerInfo (触发垃圾回收的 2) 方法之一。 我在此处讨论规则 3 的原因是,正是那些冒险的程序员从任意样本中异步遍历堆栈,他们最有可能实现自己的探查器锁,因此容易陷入此陷阱。 事实上,规则 2 实质上是告知你向探查器添加同步。 采样探查器很可能还具有其他同步机制,也许可以在任意时间协调读取和写入共享数据结构。 当然,从未接触 DoStackSnapshot 的探查器仍可能会遇到此问题。

受够了

我将完成对亮点的快速总结。 以下是要记住的要点:

  • 同步堆栈遍查涉及遍走当前线程以响应探查器回调。 这些规则不需要种子设定、暂停或任何特殊规则。
  • 如果堆栈顶部是非托管代码,而不是 PInvoke 或 COM 调用的一部分,则异步遍视需要种子。 可以通过直接挂起目标线程并自行执行它来提供种子,直到找到最顶层托管的帧。 如果在这种情况下不提供种子, DoStackSnapshot 可能会返回失败代码或跳过堆栈顶部的某些帧。
  • 如果需要挂起线程,请记住,只有从未运行过托管代码的线程应挂起另一个线程。
  • 执行异步演练时,请始终重写 ThreadDestroyed 回调,以阻止 CLR 销毁线程,直到该线程的堆栈遍历完成。
  • 当你的探查器调入可触发垃圾回收的 CLR 函数时,请勿持有锁。

有关分析 API 的详细信息,请参阅 MSDN 网站上的 分析 (非托管)

信用额度到期

我想向 CLR 分析 API 团队的其他成员提供感谢,因为编写这些规则确实是团队的一项工作。 特别感谢Sean Selitrennikoff,他提供了早期化身的大部分内容。

 

关于作者

David 在 Microsoft 当开发人员的时间比你想象的要长,因为他的知识和成熟度有限。 尽管不再允许在代码中检查,但他仍然为新的变量名称提供想法。 大卫是乔库拉伯爵的狂热粉丝,并拥有自己的汽车。