构建混合模式采样分析器






4.90/5 (18投票s)
遍历本机代码和托管代码的调用栈相对容易。遍历混合模式的调用栈则困难得多。现有的文档非常有限。我希望这篇文章及其示例分析器能为此领域带来一些启示。
引言
您是否对分析器的工作原理以及如何遍历混合模式应用程序感兴趣?我们将探讨 ICorProfiler
接口,用于进行混合模式堆栈遍历。由于文档有限,我不得不走一些弯路。在本文中,我将分享一些“应该做”和“不应该做”的事情、局限性、可能性、资源链接以及我学到的知识。
背景
这是之前一篇文章的续篇,我们在其中学习了如何使用 IDebugInterface
和 IXCLRProcessData
来遍历混合模式(非托管/托管)应用程序。我之所以开始写这个,是因为需要优化一个 C++/C# 应用程序,该程序在导入大型数据文件时速度很慢。单独运行本机分析器或 CLR 分析器都无法精确地找到问题所在。我第一次尝试遍历混合模式调用栈的文章 A Mixed-Mode Stackwalk with IDebugClient Interface 取得了一部分成功。我能看到 CLR 库的函数调用,但我的代码却被伪装成一些对 CLR 函数的调用,因为我的 JIT 编译代码正在其中执行。在本文中,我们将改为探讨 CLR 运行时的一个分析器 API,称为 ICorProfiler
。
定义
混合模式调用栈
当我们谈论混合模式调用栈时,指的是非纯托管应用程序。一个纯托管应用程序只包含托管代码。使用某种互操作/ P/Invoke 的托管应用程序不是纯粹的。它是一个混合模式应用程序。COM、C++/CLI、互操作和/或 P/Invoke 可以用来混合这两个世界。本机代码调用托管代码,托管代码又调用本机代码。我们将通过一个示例来说明这一点。
我们从底层开始,一个 C++ 应用程序调用一个 C++/CLI 托管 DLL,托管 DLL 又调用本机代码。这样一来,就已经有了两次转换。如果这个本机代码使用一个用 C# 编写的 COM 对象,就会产生另一次转换,以此类推。在正常情况下,您只会遇到 1-2 次托管/非托管代码之间的转换。如果您遇到 5-6 层,这可能表明您应该重新设计。
分析
性能分析是一种对软件执行的动态分析形式。它可以衡量内存使用量、函数被调用的次数、函数调用的持续时间等等。它通常用于通过精确找出 CPU 占用率最高的部分来指导优化。性能分析可以在源代码级别或二进制级别进行。
我希望优化的应用程序之前已经进行过“性能分析”,是在源代码级别进行的,通过将堆栈跟踪写入磁盘,记录我们认为经常被调用的函数。这不太高级,但奏效了。它的缺点是只支持本机代码。不幸的是,我有理由相信问题出在托管代码中。
插桩分析
我的经验可以追溯到 90 年代。我使用过一个名为 Quantify 的本机分析器,来自 Rational。该分析器通过修补函数入口/出口进行日志记录,在二进制级别进行插桩。结果是一个新的二进制文件,并且所有动态加载的库也都被自动修补。
基于事件的分析
如今,在虚拟机中运行的语言确实获得了巨大的发展。本机分析器不适用于这些语言。插桩分析器也会分析虚拟机。这些语言有自己的分析器,用于分析由 VM 运行的代码。VM 通常提供回调,让分析器通知 JIT 编译、对象创建、进入/退出。
采样分析
采样是指在固定间隔(每 5-20 毫秒)获取样本(遍历堆栈)。这是一种非常快速的方法,因为它允许应用程序以接近全速运行。如果间隔很小,收集到的数据应该相当准确。
ICorProfiler
探索一个示例分析器
现在我们将构建一个同时支持本机和托管代码的分析器。ICorProfiler
的文档非常有限,示例也很少。幸运的是,我在 CodeProject 上找到了一个示例:Creating a Custom NET Profiler。很棒!不幸的是,它只支持托管代码,但有助于理解分析器 API 的用法。
该分析器使用起来非常简单。通过设置两个环境变量来启动分析。
c:\>SET COR_ENABLE_PROFILING=1
c:\>SET COR_PROFILER={C6DBEE4B-017D-43AC-8689-3B107A6104EF}
c:\>SampleApp.exe
第一个很明显。它启用分析。第二个通过其 CLSID 指向一个特定的分析器实现。
该项目附带一个名为 Hello World 的示例应用程序。不幸的是,我因多种原因无法使用这个分析器实现。
- 它只支持托管代码
- 它太慢了
- 我需要控制启动和停止
- 我想避免涉及注册表的部署
- 我想学习如何进行编程
在我的特定情况下,我不需要测量整个执行的性能。大部分时间应用程序都在等待用户输入。我尤其想仔细查看导入/恢复数据库的性能。我需要在导入之前立即开始分析,并在之后立即停止。当应用程序启动时,会从数据库和文件系统中加载大量数据,并构建一些内部数据结构。导入使用相同的组件,并在一定程度上使用相同的函数来检查数据完整性。如果我启动分析器并让它运行直到导入结束,我将得到一个**庞大**的分析跟踪,这对我是无用的。我将无法看到它是来自应用程序启动还是来自导入。
ICorProfiler 文档
一个好的起点是 性能分析概述,我们从中了解到分析器必须实现为 COM DLL。该 DLL 是进程内运行的,这意味着它被加载到要分析的应用程序的进程空间中。分析器使用 CLR 的分析器 API,并接收通知。此外,分析器必须用本机/非托管代码编写,否则其自身的执行会触发 CLR 中的事件。
这些页面描述了两种分析方法。使用 Enter/Leave/Tail 回调或使用 DoStackSnapshot
。我决定采用后者,因为我找到了一个关于如何遍历混合堆栈的指南 Profiler Stack Walking in the .NET Framework 2.0: Basics and Beyond。
探索接口
一个好的起点是 MSDN 上的 性能分析接口。
似乎有很多不同的接口,但实际上只有几个,因为其中许多只是接口的扩展。
ICLRProfiling
ICorProfilerCallback
(1,2,3)ICorProfilerInfo
(1,2,3)
存在同一接口的多个版本的原因是向后兼容性。接口绝不能更改。如果更改了,为旧接口编写的组件就会中断。为了添加功能,可以创建一个包含新函数的接口。还有其他实现版本控制的方法,但这正是 Microsoft 在 ICorProfiler
中实现的方式。
ICorProfilerCallback
来自 CLR V1.0 及更高版本ICorProfilerCallback2
来自 v2.0 及更高版本ICorProfilerCallback3
来自 v4.0 及更高版本。
ICLRProfiling
第一个出现的类是 ICLRProfiling
。它是启动接口。它只有一个方法。
CLRProfiling::AttachProfiler
HRESULT AttachProfiler(
[in] DWORD dwProfileeProcessID,
[in] DWORD dwMillisecondsMax, // optional
[in] const CLSID * pClsidProfiler,
[in] LPCWSTR wszProfilerPath, // optional
[in] size_is(cbClientData)] void * pvClientData, // optional
[in] UINT cbClientData); // optional
它将一个分析器附加到一个正在运行的进程。我们可以选择指定文件路径,这意味着我们可以避免使用注册表。
创建 ICLRProfiling 对象
CLR 宿主 API
我们将使用 CLR 宿主 API。这是一个允许非托管应用程序集成 CLR 运行时的 API。您不仅可以在本机应用程序中启动 CLR,还可以替换垃圾回收器、添加全局异常处理程序等等。有一本关于 CLR 宿主 API 的有趣的书:Customizing the Microsoft® .NET Framework Common Language Runtime。
ICLRMetaHost
CLR 宿主 API 由 MSCorEE.dll 提供。这是一个精简的、与版本无关的 API。首先要做的是创建一个 ICLRMetaHost
对象。该对象是访问 CLR 的入口。
#include <Metahost.h>
ICLRMetaHost* m_metahost = nullptr;
HRESULT hr = CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (LPVOID*)&m_metahost);
ICLRMetaHost::GetRuntTime
ICLRMetaHost
对象包含 EnumerateInstalledRuntimes
和 GetRuntime
等函数,后者会在当前进程中启动 CLR 运行时。我们来做这件事。
ICLRRuntimeInfo* m_runtimeInfo = nullptr;
hr = m_metahost->GetRuntime(_T("v4.0.30319"), IID_ICLRRuntimeInfo, (LPVOID*)&m_runtimeInfo);
ICLRProfiling
我们可以使用 ICLRRuntime
对象创建与正在运行的 CLR 运行时交互的对象。为了进行性能分析,我们将需要 ICLRProfiling
对象。
ICLRProfiling* m_clrProfiling = nullptr;
hr = m_runtimeInfo->GetInterface(CLSID_CLRProfiling, IID_ICLRProfiling, (LPVOID *)&m_clrProfiling);
ICLRProfiling::AttachProfiler
LPVOID pvClientData = NULL;
DWORD cbClientData = 0;
CLSID clsidProfiler;
HRESULT hr;
hr = CLSIDFromString(L"{C6DBEE4B-017D-43AC-8689-3B107A6104EF}", (LPCLSID)&clsidProfiler);
hr = m_clrProfiling->AttachProfiler(
m_processId,
timeout,
&clsidProfiler,
NULL, // or full path "C:\\Development\\DiagProfiler.dll"
pvClientData /* used to send a parameter to the profiler */,
cbClientData /* the size in bytes of pvClientData*/ );
ICLRProfiling
对象有一个 AttachProfiler
方法。由于此时我还没有实现分析器,我尝试附加一个现有的实现,即 Net Profiler。
Attachprofiler
需要分析器 DLL 的 CLSID。如果 CLSID 在注册表中,则文件路径可以是 NULL
,否则必须提供绝对文件路径。
它与 Net Profiler 不兼容!运行代码后返回了失败代码 0x80070002:“系统找不到指定的文件”。在反复验证文件路径一百次、搜索错误代码、仔细检查 CLSID 后,我找到了问题。我没有正确阅读 AttachProfiler
的手册。在其错误代码中,可以找到以下错误代码:
HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND)
The target process does not exist or is not running a CLR that supports attachment.
This may indicate that the CLR was unloaded since the call to the runtime enumeration method.
我希望看到一个更准确的错误代码。问题在于我的进程不支持附加。原因是我想将分析器附加到要被分析的应用程序。API 不允许这样做。
在第二次尝试中,我尝试附加到另一个进程。结果也不行。原因是我的分析器很旧。它是为 CRL v2.0 分析接口编写的。附加到正在运行的进程的分析器仅由 CLR v4.0 及更高版本支持。示例分析器未实现此接口。
解决方案
首先,如果不是因为互联网上的一些优秀资源,例如 Profiler Stack Walking in .NET Framework 2.0: Basics and Beyond 和 Building a mixed mode stack walker,我可能就不会完成自己的实现。这些资源是理论性的,没有提供源代码。我的贡献在于解释如何在实践中做到这一点,并告诉您“应该做”和“不应该做”的事情,以及我的经验。
我们需要实现什么
我们需要实现 ICorProfilerCallback
接口。CLR 将调用我们分析器实现的接口方法,以告知事件。
启动和初始化
ICorProfilerCallback::Initialize
如果分析器 DLL 在应用程序启动时加载,则会调用 ICorProfilerCallback::Initialize()
。
ICorProfilerCallback::InitializeForAttach
如果我们使用 ICLRProfiling::AttachProfiler
方法附加到正在运行的进程,则会调用 ICorProfilerCallback::InitializeForAttach()
。
ICorProfilerInfo::SetEventMask
可能首先要做的是设置事件掩码,这是告诉 CLR 您感兴趣的事件回调的方式。有很多事件可以订阅。
COR_PRF_MONITOR_NONE = 0,
COR_PRF_MONITOR_FUNCTION_UNLOADS = 0x1,
COR_PRF_MONITOR_CLASS_LOADS = 0x2,
COR_PRF_MONITOR_MODULE_LOADS = 0x4,
COR_PRF_MONITOR_ASSEMBLY_LOADS = 0x8,
COR_PRF_MONITOR_APPDOMAIN_LOADS = 0x10,
COR_PRF_MONITOR_JIT_COMPILATION = 0x20,
COR_PRF_MONITOR_EXCEPTIONS = 0x40,
COR_PRF_MONITOR_GC = 0x80,
COR_PRF_MONITOR_OBJECT_ALLOCATED = 0x100,
COR_PRF_MONITOR_THREADS = 0x200,
COR_PRF_MONITOR_REMOTING = 0x400,
COR_PRF_MONITOR_CODE_TRANSITIONS = 0x800,
COR_PRF_MONITOR_ENTERLEAVE = 0x1000,
COR_PRF_MONITOR_CCW = 0x2000,
COR_PRF_MONITOR_REMOTING_COOKIE = 0x4000 | COR_PRF_MONITOR_REMOTING,
COR_PRF_MONITOR_REMOTING_ASYNC = 0x8000 | COR_PRF_MONITOR_REMOTING,
COR_PRF_MONITOR_SUSPENDS = 0x10000,
COR_PRF_MONITOR_CACHE_SEARCHES = 0x20000,
COR_PRF_MONITOR_CLR_EXCEPTIONS = 0x1000000,
COR_PRF_MONITOR_ALL = 0x107ffff,
COR_PRF_ENABLE_REJIT = 0x40000,
COR_PRF_ENABLE_INPROC_DEBUGGING = 0x80000,
COR_PRF_ENABLE_JIT_MAPS = 0x100000,
COR_PRF_DISABLE_INLINING = 0x200000,
COR_PRF_DISABLE_OPTIMIZATIONS = 0x400000,
COR_PRF_ENABLE_OBJECT_ALLOCATED = 0x800000,
COR_PRF_ENABLE_FUNCTION_ARGS = 0x2000000,
COR_PRF_ENABLE_FUNCTION_RETVAL = 0x4000000,
COR_PRF_ENABLE_FRAME_INFO = 0x8000000,
COR_PRF_ENABLE_STACK_SNAPSHOT = 0x10000000,
COR_PRF_USE_PROFILE_IMAGES = 0x20000000,
COR_PRF_MONITOR_THREADS
在我的情况下,我需要 COR_PRF_MONITOR_THREADS
,它在线程创建和销毁时提供回调。
STDMETHODIMP ICorProfilerCallback::ThreadAssignedToOSThread(ThreadID managedThreadID, DWORD osThreadID)
{
m_managed2NativeMap[managedThreadID] = osThreadID;
m_native2ManagedMap[osThreadID] = managedThreadID;
return S_OK;
}
当本机线程被分配托管线程 ID 时会调用它。我使用 ThreadAssignedToOSThread
回调来维护两个 ID 之间的映射。每个线程只调用一次回调。这实际上是我们选择附加到正在运行的进程时的一个问题,因为回调永远不会发生。我还没有找到一种好方法来确定线程是托管的还是非托管的。我们可以使用 ICorDebug
接口,但是您不能将调试器附加到自己身上。
COR_PRF_ENABLE_STACK_SNAPSHOT
我还需要的 COR_PRF_ENABLE_STACK_SNAPSHOT
,它允许我使用 ICorProfilerInfo2::DoStackSnapshot
进行实际的堆栈遍历。它的工作方式类似于 StackWalk64
,但不同的是,您不是为每一帧调用 StackWalk64
,而是调用 DoStackSnapshot
并传入一个回调函数指针,它会为每一帧调用您的回调。
ICorProfilerInfo2::DoStackSnapshot
ICorProfilerInfo2::DoStackSnapshot
例程
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);
调用 DoStackSnapshot
需要一个函数调用指针和一个寄存器上下文 (EIP, EBP, ESP)。当调用 DoStackSnapshot
时,框架会为堆栈上的每个托管帧调用函数回调,并在每个本机代码块之间(可能包含许多本机帧)调用一次。
HRESULT __stdcall StackSnapshotCallback (
[in] FunctionID funcId,
[in] UINT_PTR ip,
[in] COR_PRF_FRAME_INFO frameInfo,
[in] ULONG32 contextSize,
[in] BYTE context[],
[in] void *clientData
);
调用 DoStackSnapShot
将获得一个如下所示的调用序列。
对于所有回调,我们都会得到一个 CPU 上下文 (EIP, EBP, ESP) 参数,它代表当前帧的 CPU 状态。
当帧是托管的时,我们还会获得一个 FuncId,我们可以通过 CLR 将其解析为符号名称。
如果帧是非托管的,我们可以使用上下文参数来遍历调用栈,直到下一个托管帧。不幸的是,我们不知道下一个托管帧何时开始,直到收到下一个回调。所以,最好的做法是推迟堆栈遍历,直到我们知道它何时结束。在我的实现中,我将回调信息 FuncId 和部分 CONTEXT 存储在一个列表中。之后,当 DoStackSnapshot
返回时,我遍历列表,展开所有非托管帧,直到它们结束或我们到达下一个托管 IP。
为了使本机堆栈遍历能够正常进行并获得可读的名称,必须加载符号文件 (.pdb)。这通过调用 SymInitialize
来完成。SymInitialize
的问题在于它不够快。如果分析从进程启动开始,PDB 文件尚未可用,并且 SymGetModuleBase64
和 SymGetSymFromAddr64
会失败。当 PDB 文件最终加载时,它们应该开始工作。
总结
我的分析器的要求
- 应支持 v2.0 和 v4.0 运行时
- 需要启动和停止分析
- 无需注册
强制应用程序使用 v4.0 运行时
ICorProfiler
接口在 v4.0 中有许多在 v2.0 中不可用的强大功能。为了避免支持 v2.0 和 v4.0 的工作,我选择专注于让 v4.0 的支持生效。v2.0 应用程序将通过强制它们在 v4.0 运行时中运行来支持。v2.0 和 v4.0 之间很少有兼容性问题,因此应该在 95% 的情况下都能正常工作。
CLR 已通过名为 COMPLUS_version
的环境变量支持这一点。
C:\>SET COMPLUS_version=v4.0.30319
C:\>MyApp.exe
版本字符串当然必须与您安装的版本匹配。
设置定时器以获取堆栈跟踪
由于计划进行手动堆栈遍历,我需要一个定时器以固定间隔触发。在撰写本文时,它设置为每 40 毫秒触发一次。即每秒 25 次。为了获得更准确的分析,每 5 毫秒或 10 毫秒更好。
启动和停止分析器
由于 DLL 会被加载到目标进程空间中,因此我们需要一种方法与其进行通信。一个不错的解决方案是将 COM 对象注册到运行对象表 (ROT),然后从我希望控制分析器的应用程序中获取该对象。我认为 ROT 需要使用注册表,所以我改用管道进行通信。
能够与分析器通信有一个好处。我们可以让应用程序在分析暂停的情况下启动。这样做可以让我们捕获 CLR 中发生的所有早期事件,如果我们稍后附加,可能会错过这些事件。一个特别引起我们兴趣的回调是 ThreadAssignedToOSThread
。
在分析器回调接口的 InitializeAttach
例程中,我创建了一个新线程,专门用于作为命令监听器。管道通信可能看起来很奇怪,这是因为我对异步 IO 进行了一些实验。
演示应用程序
解决方案中总共有四个子项目
- DiagProfiler.dll
- DiagProfilerClient.dll
- DiagProfilerConsole.exe
- DiagProfilerLauncher.exe
DiagProfiler.dll
这就是分析器。它需要从环境变量进行配置。您必须在启动目标应用程序之前在 Cmd 控制台中进行配置。有一个名为 profile.bat 的文件,您可以对其进行更新以反映您的安装。在 Cmd 窗口中运行它,然后从同一个 Cmd 窗口启动您的应用程序。
REM Microsoft CORProfilerAPI
SET COR_ENABLE_PROFILING=1
SET COR_PROFILER={C6DBEE4B-017D-43AC-8689-3B107A6104EF}
SET COR_PROFILER_PATH=C:\Source\MixedModeProfiler\MixedModeProfiler_demo\DiagProfiler.dll
REM CLR Option
SET COMPLUS_Version=v4.0.30319
REM DiagProfiler
SET DIAG_PRF_SYMBOLPATH=SRV*c:\symbols*http://msdl.microsoft.com/download/symbols;
SET DIAG_PRF_DEBUGTRACE=C:\temp\diag_debugtrace.txt
SET DIAG_PRF_STACKTRACE=C:\temp\diag_stacktrace.txt
SET DIAG_PRF_ONLY_MANAGED_THREADS=1
COR_PROFILER_PATH
必须是绝对路径。相对路径不起作用。如果省略 DIAG_PRF_STACKTRACE
,跟踪将保存在 我的文档 文件夹中。如果省略 DIAG_PRF_SYMBOLPATH
,将使用目标应用程序的当前路径。
DiagProfilerClient.dll
这是一个托管库,我们可以用它来进一步控制分析器。
public ref class ProfileClrApp
{
bool StartProcess(String^ exePath, String^ stackOutputPath, String^ debugOutputPath);
bool AttachProfiler(DWORD pId);
bool ConnectGUI();
bool StartSampling();
bool StopSampling();
int GetSampleCount();
int GetSampleRate();
bool SetMaxSamples(int maxNoSamples);
bool SetSampleRate(int rateMs);
bool SetStackLoggerFile(String^ stackOutputPath);
};
分析器启动器和控制器
我构建了一个小型 GUI 客户端应用程序,以获得对分析器更好的控制。它支持启动/停止、附加、查询当前样本计数等。
堆栈输出文件将保存到当前用户的我的文档。双击该字段可更改文件位置。
DiagProfilerConsole.exe
一个小型控制台应用程序,它使用 DiagProfilerClient.dll 库。该应用程序接受一个 PId 作为参数。不带参数运行它会启动示例应用程序 CppCliApp.exe。
C:\...>DiagProfilerConsole.exe 6292
v2.0.50727
v4.0.30319
AttachProfiler(6292, "C:\...\DiagProfiler.dll") : S_OK
Pipe = \\.\pipe\PipeServer_6292
Pipe = \\.\pipe\PipeServer_6292
Pipe = \\.\pipe\PipeServer_6292
Connected
Number of bytes read: 6
Message = [1][1]
Sampling Rate: 25
Number of bytes read: 6
Message = [2][1]
SetMaxSamples: 100
Number of bytes read: 6
Message = [3][1]
StackLogFile=C:\...\diagstack_6292.txt
Number of bytes read: 8
Message = [4][1]25
Samplerate: 25 ms
Number of bytes read: 6
Message = [5][1]
Started Sampling
Will run #100 samples
Number of bytes read: 6
Message = [6][1]
Stopped Sampling
Number of bytes read: 9
Message = [7][1]100
Got #100 samples
Press any key to continue . . .
Message = [MessageCount=7][CommandSucceded=1]100
"100" is the result from the executed server command.
Two files will be created in the current directory.
diagstack_#pid.txt and diagdebug_#pid.txt
调试
调试进程内分析器有点麻烦。一种方法是使用 OutputDebugString
、printf
,并在代码中插入“__debugbreak()
”。
日志文件可以选择性地回显到 OutputDebug
。文件在发生崩溃时有点难处理,因为文件未刷新和关闭。如果您决定看看它是如何工作的,并遇到问题,请启用调试输出。
g_diagInit.m_debugLogger.Echo2OutputDebug(true);
g_diagInit.m_debugLoggerVerbose.Echo2OutputDebug(true);
DiagInit
是一个应该用于初始化对象的类。我的一些全局变量可能可以移入该类。
通常在创建 COM DLL 时,必须编写其类工厂,以及大量的粘合代码。我遇到了一个奇怪的错误,无法摆脱,所以我决定编写自己的类工厂,而不是使用 ATL COM。该文件名为 DiagProfilerFactory
。我在其中进行了一些健全性检查和日志记录,但后来将代码移到了 DiagInit
中,该代码是静态创建的。也就是说,甚至在动态创建的 DiagProfilerFactory
之前。
待办事项
优化
还有不少优化要做。此时,每个调用帧的符号信息都会被解析,并将堆栈跟踪写入磁盘。这个过程会重复进行,即使之前已经解析过相同的信息。这里有大量时间可以节省,缓存符号信息和映射到托管函数。我建议构建一个缓存,并在每个采样间隔只存储 IP。分析完成后,您可以回到样本数据并解析符号信息。
不要分析所有线程
在某个时候,分析器会对属于进程的所有线程进行采样。在普通的应用程序中,尤其是在托管应用程序中,有大量的后台线程,例如垃圾回收器和终结器线程。通常,这些线程的性能并不重要。这些线程通常处于等待状态(例如,WaitForMultipleObjects
)并且总是会为您提供相同的堆栈跟踪。这些线程也可以被移除,方法是:不分析它们,或者在之后从跟踪中移除它们。在我的实现中,从应用程序启动开始的分析仅分析已分配托管线程 ID 的线程。对于分析现有进程,它会分析所有线程,因为我无法获取托管线程 ID。
修复 AttachProfiler 的托管线程 ID
我尝试使用 ICorDebug
并枚举所有托管线程。这部分工作正常,但线程的 ID 是 Win32 线程 ID,而不是托管线程 ID。最好的方法可能是启用更多的 CLR 回调,并在它们上调用 GetCurrentThreadId()
。该方法的问题在于 SetEvent
只能调用一次,从 Initialize
或 InitializeForAttach
调用。这意味着我无法再次关闭事件。
关注点
两个有趣的资源是 Profiler Stack Walking in .NET Framework 2.0: Basics and Beyond 和 Building a mixed mode stack walker。
前面提到的仅托管分析器 Creating a Custom Net Profiler 实际上有一个由同一作者续写的版本,名为 Slimtune。它也是一个混合模式分析器。我在很晚的阶段才了解到 Slimtune。一开始有点令人沮丧,但我还是决定继续我的实现,因为我不想错过学习的经历。它比我的版本更高级(但您需要自己弄清楚它是如何工作的)。他们的实现以一种聪明的方式结合了 DoStackSnapShot
和 ENTER/LEAVE/TAIL 钩子。他们似乎还跟踪内存分配和垃圾回收。如果您有兴趣学习混合模式堆栈遍历,我的实现可能更容易理解,因为它更小,包含源代码注释,并且有这篇配套文章。它也是 MSDN 上 Profiler Stack Walking in .NET Framework 2.0: Basics and Beyond 指南的直接实现。
另一个关于托管调试、ICorDebug
和各种 .NET 内容的绝佳资源是 Mike Stall 的博客。