65.9K
CodeProject 正在变化。 阅读更多。
Home

构建混合模式采样分析器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (18投票s)

2012年5月14日

Ms-PL

17分钟阅读

viewsIcon

70127

downloadIcon

1973

遍历本机代码和托管代码的调用栈相对容易。遍历混合模式的调用栈则困难得多。现有的文档非常有限。我希望这篇文章及其示例分析器能为此领域带来一些启示。

引言

您是否对分析器的工作原理以及如何遍历混合模式应用程序感兴趣?我们将探讨 ICorProfiler 接口,用于进行混合模式堆栈遍历。由于文档有限,我不得不走一些弯路。在本文中,我将分享一些“应该做”和“不应该做”的事情、局限性、可能性、资源链接以及我学到的知识。

背景

这是之前一篇文章的续篇,我们在其中学习了如何使用 IDebugInterfaceIXCLRProcessData 来遍历混合模式(非托管/托管)应用程序。我之所以开始写这个,是因为需要优化一个 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 对象包含 EnumerateInstalledRuntimesGetRuntime 等函数,后者会在当前进程中启动 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 BeyondBuilding 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 文件尚未可用,并且 SymGetModuleBase64SymGetSymFromAddr64 会失败。当 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

调试

调试进程内分析器有点麻烦。一种方法是使用 OutputDebugStringprintf,并在代码中插入“__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 只能调用一次,从 InitializeInitializeForAttach 调用。这意味着我无法再次关闭事件。

关注点

两个有趣的资源是 Profiler Stack Walking in .NET Framework 2.0: Basics and BeyondBuilding 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 的博客

© . All rights reserved.