本页内容
简介
谨慎地进行堆栈遍历
同步和异步调用
总结
做出最佳的表现
够了够了
简介
本文面向的是对构建用于检查托管应用程序的分析器感兴趣的读者。我将描述如何编写分析器,以在 .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 的循环。
假定应用程序遵循此规则并且让所有锁始终处于同一顺序中。现在出现了一个组件(例如分析器),并开始随意地挂起线程。这真正地增加了复杂性。如果现在挂起者需要拿到被挂起者持有的锁怎么办?或者如果挂起者需要某个线程所持有的锁,而该线程正等待另一个线程所持有的锁,此另一线程又在等待被挂起者所持有的锁,这该怎么办?挂起会添加一个新的边到依赖线程的图形上,此操作可以引导循环。让我们看一些具体的问题。
问题 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 迷,拥有自己的私家车。
文章来源于领测软件测试网 https://www.ltesting.net/