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

创建自定义 .NET 性能分析器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (38投票s)

2006 年 8 月 30 日

13分钟阅读

viewsIcon

212950

downloadIcon

6966

描述了如何为任何托管应用程序创建自己的自定义分析器。

目录

引言

您是否曾想过性能分析工具如何挂钩到 .NET 应用程序?本文将展示如何为任何托管应用程序创建自己的自定义 .NET 性能分析器。这个性能分析器尽可能地简单,但它展示了如何创建一个并提供了一个可以进一步开发的骨架。

该性能分析器将执行以下功能:

  • 维护已调用函数的内部映射
  • 维护每个函数的调用计数
  • 维护调用堆栈的深度

使用此性能分析器的结果是一个输出文件,报告在应用程序运行过程中发生的情况。在看到此文件的内容深度后,我保证您将再也无法以同样的方式看待您的 .NET 代码。

注意:我想提前说明,此性能分析器的实现适用于 .NET 2.0 应用程序,并且示例项目是 VS 2005 项目。我很想发布一个 .NET 1.1 / VS 2003 的解决方案,但这需要我回滚我的机器才能做到。本文末尾的参考资料指向 .NET 1.1 的示例 SDK 项目。

背景

什么是性能分析器?

性能分析是测量应用程序的各个区域(通常是整个应用程序)的时间,以便发现瓶颈。有几种可用的性能分析器可提供应用程序的完整性能分析。在 .NET 中,性能分析器被编写为 COM DLL。 .NET 性能分析器的最低要求是实现一个名为 ICorProfilerCallback 的接口。在 .NET 2.0 中引入了一个名为 ICorProfilerCallback2 的扩展接口,并在包含的示例中使用。这些接口提供了关于您程序执行几乎所有您想知道的信息的回调:函数进入/退出、线程切换、程序集加载/卸载、类加载/卸载、JIT 编译、托管/非托管代码转换、垃圾回收、异常处理……一切!

CLR 如何实现性能分析?

CLR 是驱动 .NET 应用程序的引擎。它还提供了一个逻辑位置来插入钩子,以确切地了解它在做什么。而 Win32® 调试 API 只提供了八个通知,ICorProfilerCallback2 性能分析接口提供了近 80 个。

当 CLR 开始一个进程时,它会查找两个环境变量:

  1. COR_ENABLE_PROFILING:此环境变量设置为 1 或 0。1 表示 CLR 应使用性能分析器。0(或不存在此环境变量)表示它不应使用性能分析器。
  2. COR_PROFILER:既然我们已经告诉 CLR 我们想要进行性能分析,我们就必须告诉它使用哪个性能分析器。因为性能分析器被实现为 COM 对象,所以此环境变量将被设置为实现 ICorProfilerCallback2 接口的 coclass 的 GUID。您应该能在性能分析器的 IDL 中找到它。对于这个项目,coclass guid 是 {9E2B38F2-7355-4C61-A54F-434B7AC266C0}

有两种方法可以设置这些环境变量。您可以全局设置它们,在这种情况下,您运行的所有 .NET 应用程序都将被分析。显然,这是一个糟糕的主意。另一种方法是使用 Process 对象手动创建一个进程,并仅为该进程设置这些环境变量。如果您曾经在 Visual Studio 2005 中向您的解决方案添加了性能分析会话并使用工具栏按钮运行它,这很可能就是后台发生的事情。

一旦进程启动,CLR 被指示进行性能分析并知道要使用哪个性能分析器,它就会创建性能分析器对象并查询其 ICorProfilerCallback2 接口。一旦获得该接口,它就会开始调用它来通知性能分析器它正在做的所有事情。我试图在下图进行说明。

示例性能分析器

解决方案和项目

示例包含两个解决方案:
  1. DotNetProfiler.sln:此解决方案包含一个名为 DotNetProfiler 的 C++ 项目,该项目使用 ATL 实现实际的性能分析器。
  2. ProfilerTest.sln:此解决方案包含两个项目。ProfilerLauncher 是一个 C# 应用程序,带有一个按钮,该按钮创建进程、设置环境变量,并在该进程中运行另一个应用程序。HelloWorld 是我们将要分析的 C# 测试应用程序。它还包含一个按钮,单击该按钮将显示一个“Hello world”消息框。

输出文件结果

如前所述,本文中的性能分析器会生成一个输出文件。可以使用 LOG_FILENAME 环境变量指定此文件。日志文件的内容由两部分组成:调用堆栈和函数摘要。

  1. 调用堆栈:它只是函数按调用顺序的列表,使用填充来指示调用堆栈的深度。这是调用堆栈部分的示例。
    System.IO.StringWriter.Write, id=70671928, call count = 622
      System.Text.StringBuilder.Append, id=21629200, call count = 635
        System.IntPtr.op_Inequality, id=21665400, call count = 717
        System.String.AppendInPlace, id=9670768, call count = 635
    System.Configuration.XmlUtilWriter.AppendAttributeValue, id=73427152, call count = 14
      System.Xml.XmlTextReader.get_QuoteChar, id=70046432, call count = 14
        System.Xml.XmlTextReaderImpl.get_QuoteChar, id=70049120, call count = 14
    

    此示例显示了两个主要调用:StringWriter.WriteXmlUtilWriter.AppendAttributeValue。如果一个函数比前一个函数缩进,则表示该函数是由该函数调用的。

  2. 函数摘要:这是在运行过程中调用的所有函数的平面列表。我说所有,是指所有……每个程序集中每个函数。此列表中的每个函数还显示了在运行过程中对该函数进行的总调用次数。如果某些函数名显示多次,则表示它们被重载了。这是函数列表部分的示例。
    System.String.Join : call count = 2
    System.String.SmallCharToUpper : call count = 1
    System.String.EqualsHelper : call count = 1106
    

默认情况下,输出文件的名称将是“ICorProfilerCallback Log.log”,并写入执行程序集的目录(在本例中为 HelloWorld\bin\Debug 目录)。

使用代码

DotNetProfiler 项目

此项目构建了一个实现 ICorProfilerCallback2 接口的 COM DLL。本节将介绍一些使用的类。

  • CCorProfilerCallbackImpl:乍一看,这个类似乎什么都不做,您说得对。因为所有性能分析器都必须实现 ICorProfilerCallback 接口,所以我们必须有一个实现该接口定义的所有函数的存根。此接口上的函数太多,如果我们创建我们不需要的存根,会使我们的主要实现臃肿。CCorProfilerCallbackImpl 仅用可能被覆盖的存根实现了 ICorProfilerCallback2 接口。因此,我们的主要实现将从此类派生,而我们只需要覆盖我们感兴趣的函数。
  • CFunctionInfo:这个类旨在表示单个函数原型。CLR 为它调用的每个函数分配唯一的 ID。即使是同一函数的重载版本也有自己的 ID。我们的性能分析器实现维护一个 STL <code><code>map(哈希表)来存储这些对象,并以 ID 作为键。当我们遇到一个我们没有在映射中的函数 ID 时,我们会创建一个 CFunctionInfo 对象,填充它,并使用 CLR 分配的函数 ID 将其添加到映射中。此类对象还维护调用计数,因此当我们收到函数已被调用的通知时,我们可以增加其调用计数。
  • CProfiler:此类是性能分析器的主要实现。它执行性能分析器的初始化,并维护 CFunctionInfo 对象的映射。它还维护输出文件,并在其操作过程中持续写入。

性能分析器初始化

  1. 当 CLR 获取对我们性能分析器的引用时,它首先调用 Initialize 函数,传递一个实现 ICorProfilerInfo 接口的对象的指针。如果使用的是 .NET 2.0,则该对象还将实现 ICorProfilerInfo2 接口。我们的 CProfiler 类为每个对象维护一个 CComQIPtr 智能指针。但是,在此实现中,我们仅使用指向 ICorProfilerInfo 接口的指针。
    // get the ICorProfilerInfo interface
    HRESULT hr = pICorProfilerInfoUnk->QueryInterface(IID_ICorProfilerInfo,
                        (LPVOID*)&m_pICorProfilerInfo);
    if (FAILED(hr))
       return E_FAIL;
    // determine if this object implements ICorProfilerInfo2
    hr = pICorProfilerInfoUnk->QueryInterface(IID_ICorProfilerInfo2,
                        (LPVOID*)&m_pICorProfilerInfo2);
    if (FAILED(hr))
    {
       // we still want to work if this call fails, might be an older .NET version
       m_pICorProfilerInfo2.p = NULL;
    }
    

    此对象用于从 CLR 获取上下文信息,例如根据 CLR 分配的 ID 检索函数的元数据。

  2. 一旦我们获得了 ICorProfilerInfo 接口的引用,我们就需要告诉 CLR 我们对哪种类型的通知感兴趣。我们通过调用 SetEventMask 函数并传递一个位掩码值来完成此操作。CProfiler 中的 SetEventMask 函数仅注册进入/离开通知,但其余的都已枚举并注释掉,以便您可以看到可用选项。
    // set the event mask
    DWORD eventMask = (DWORD)(COR_PRF_MONITOR_ENTERLEAVE);
    m_pICorProfilerInfo->SetEventMask(eventMask);
    
  3. 下一步是注册“函数进入和离开”钩子。这样做会在我们的 CProfiler 对象上注册三个回调函数。
    // set the enter, leave and tailcall hooks
    hr = m_pICorProfilerInfo->SetEnterLeaveFunctionHooks
                    ((FunctionEnter*)&FunctionEnterNaked,
                                     (FunctionLeave*)&FunctionLeaveNaked,
                                     (FunctionTailcall*)&FunctionTailcallNaked);
    
    • FunctionEnterNaked函数进入时调用。
    • FunctionLeaveNaked函数退出时调用。
    • FunctionTailcallNaked:当函数执行的最后一个操作是调用另一个方法时调用。

    在 C++ 中,回调函数必须声明为 __declspec(naked)。此外,例程必须保留它们使用的任何 CPU 寄存器,并在返回前恢复它们。每个回调有三个部分(以“Enter”回调为例)。

    • FunctionEnterNaked:汇编实现,它在调用 FunctionEnterGlobal 函数时保留寄存器。
    • FunctionEnterGlobal:将调用转发到性能分析器对象的全局函数。
    • EnterFunctionEnter 通知性能分析器对象的实现。

    注意:我不得不承认,这让我绞尽脑汁,但是,这就是 Microsoft 实现这些函数的方式。这可能是性能分析器中最难正常工作的 part。我不确定他们为什么不在 ICorProfilerCallback 接口上添加 EnterLeaveTailcall 作为函数。

  4. 我们的最后一步是注册函数映射回调。我已经看到了关于此函数设计用途的多种定义,但它做得很好的一件事是,每当它到达一个以前未执行过的函数时,它就会回调,并将已映射到该函数的 ID 返回。这是创建我们的 CFunctionInfo 对象以放入 STL map 的理想位置。这样,当我们在“enter”或“leave”回调中遇到一个函数时,我们就可以确保在映射中有一个该函数的对象。
    // set the function mapper callback
    hr = m_pICorProfilerInfo->SetFunctionIDMapper((FunctionIDMapper*)&FunctionMapper);
    

性能分析器交互

初始化完成后,我们的 CProfiler 对象就准备好进行性能分析了。因为我们注册了 enter/leave 钩子并在通知标志中指定了 COR_PRF_MONITOR_ENTERLEAVE,所以每当有函数进入或返回时,我们都会被调用。此交互如下所示:

  1. CLR 遇到一个要执行的新函数。
  2. CLR 将其映射到一个数字 ID,并调用 CProfilerFunctionMapper 函数。
  3. CProfiler 使用 ICorProfilerInfo 引用,通过 ID 获取函数名。性能分析器创建一个 CFunctionInfo 对象并用此信息填充它,然后使用函数 ID 作为键将其添加到 STL map 中。
  4. 然后,CLR 调用 CProfilerFunctionEnterNaked 回调函数,传递函数 ID。这会被转发到 CProfilerEnter 函数。
  5. Enter 函数中,CProfiler 在映射中查找函数 ID。如果我们找到了映射中的 CFunctionInfo 对象,那么我们将其记录在输出文件的调用堆栈中,并增加其调用计数。然后,性能分析器会增加调用堆栈的深度。
  6. CLR 执行函数。
  7. 然后,CLR 调用 CProfilerFunctionLeaveNaked 回调函数,传递函数 ID。这会被转发到 CProfilerLeave 函数。
  8. LeaveTailcall 函数中,CProfiler 会减少调用堆栈的深度。

性能分析器关闭

程序完成后,CLR 会调用 CProfilerShutdown 函数。在此函数中,性能分析器会遍历 CFunctionInfo 映射,并将每个函数名及其调用计数输出到日志文件。然后,我们释放映射中的 CFunctionInfo 对象。

ProfilerTest 项目

此项目仅仅是一个为托管应用程序的性能分析设置环境的应用程序。所有工作都在按钮点击处理程序中完成,它创建了一个 ProcessStartInfo 对象并设置了在上一节中讨论过的环境变量。然后,使用此信息启动一个进程,该进程应该会调用示例性能分析器。

这个应用程序,尽管很简单,但最有趣的事情之一是它可以用于分析任何应用程序和任何性能分析器。在 Form1.cs 的顶部声明了两个常量:

// profiler GUID
private const string PROFILER_GUID = "{9E2B38F2-7355-4C61-A54F-434B7AC266C0}";
// executable to run
private const string EXECUTABLE_TO_RUN = "HelloWorld.exe";

要使用另一个性能分析器,只需将 PROFILER_GUID 更改为您想使用的性能分析器。要分析另一个应用程序,请将 EXECUTABLE_TO_RUN 更改为您想分析的应用程序。可能只需要几分钟时间就可以添加一个文件打开对话框和一个文本框,以便在运行时更改这些值。(我想让示例尽可能简单)。

运行预构建示例

  1. dotnetprofiler_demo.zip 文件解压缩到一个目录。
  2. 从命令行使用 RegSvr32 注册 DLL。
    regsvr32.exe DotNetProfiler.dll
    

    您应该会收到 DLL 注册成功的确认。

  3. 运行 ProfilerLauncher.exe。单击“Launch and Profile!”按钮以启动 HelloWorld.exe
  4. HelloWorld 将运行(速度非常慢)。单击其“Say Hello World”按钮,它将显示“Hello world”。
  5. 关闭 HelloWorld,然后关闭 ProfilerLauncher
  6. 一个名为“ICorProfilerCallback Log.log”的文件应该已经写入该目录。可以在记事本中打开此文件。

构建源代码

  1. 在 Visual Studio 2005 中加载 DotNetProfiler.sln 解决方案。该项目链接到 corguids.lib 库,该库应该位于您的 C:\Program Files\Microsoft Visual Studio 8\SDK\v2.0\Lib 文件夹中。
  2. 构建解决方案。
  3. 在 Visual Studio 2005 中加载 ProfilerTest.sln 解决方案。
  4. 构建解决方案。构建它不依赖于 DotNetProfiler.dll
  5. DotNetProfiler.dllProfilerLauncher.exeHelloWorld.exe 复制到它们自己的目录。
  6. 按照运行预构建示例中的步骤操作(包括注册 DLL)。

关注点

  • 首先也是最重要的……您将惊叹于仅仅执行 HelloWorld.exe 就需要进行的调用数量。如果您不想运行示例,可以下载分析 HelloWorld.exe 的示例输出。现在,请考虑您的应用程序的复杂性与 HelloWorld.exe 的复杂性。太棒了……
  • 此性能分析器实现纯粹是为了演示。因为它连续写入文件,所以速度非常慢。它不适合在生产环境中使用……它只是一个构建您自己的性能分析器的模型。可以进行许多修改来改进它。
  • 我仍然没有找到任何方法来调试自定义性能分析器。我所做的所有调试都是通过输出文件消息完成的。如果您找到了调试其中之一的方法,我很想知道。
  • 由于性能分析器已附加到正在执行的进程,因此使用此技术极难分析 ASP.NET 应用程序和服务。

其他参考文献

关于使用 ICorProfilerCallbackICorProfilerCallback2,有一些很好的参考资料。这里有几个:

  • Profiling.doc本文档是关于 ICorProfilerCallback 最全面的信息集合。它包含在 .NET 1.1 SDK 中,但遗憾的是在 .NET 2.0 SDK 中丢失了,所以我提供了一个链接。
  • MSDN ICorProfilerCallback 参考MSDN ICorProfilerCallback2 参考
  • C:\Program Files\Microsoft Visual Studio .NET\FrameworkSDK\Tool Developers Guide\Samples\profiler\hst_profiler 文件夹中有一个实现 ICorProfilerCallback 的优秀示例。HST 代表 Hot Spot Tracker,这是一个比我的示例功能更齐全的性能分析器。我的示例旨在演示基础知识,而不会像 hst_profiler 那样深入。 本文很好地涵盖了该性能分析器的一些细节。
  • C:\Program Files\Microsoft Visual Studio .NET\FrameworkSDK\Tool Developers Guide\Samples\profiler\gcp_profiler 文件夹中,另一个实现 ICorProfilerCallback 的优秀示例。GCP 代表 General Code Profiler,这是一个比我的示例功能更齐全的性能分析器。 本文很好地涵盖了该性能分析器的一些细节。
  • 本文很好地描述了您可以使用 ICorProfilerCallback 订阅的所有不同通知。它还附带了自己的性能分析器实现。

历史

  • 2006 年 8 月 30 日:初版发布
  • 2006 年 9 月 4 日:在其他参考资料部分添加了 Profiling.doc 的链接。
© . All rights reserved.