使用C#与NativeAOT创建.NET CLR性能分析器

本文详细介绍了如何利用Kevin Gosse的Silhouette库,在.NET生态中使用C#和NativeAOT技术创建一个基本的CLR性能分析器。通过实际示例,演示了分析器的初始化、事件响应及测试流程,展示了如何以托管代码方式调用原生分析API,避免了传统C++开发的复杂性。

使用C#与NativeAOT创建.NET CLR性能分析器

什么是.NET性能分析API?

对于99%的开发者来说,使用.NET意味着在舒适的托管运行时中工作,除了偶尔的P/Invoke外,几乎不需要关心原生代码。然而,.NET基础类库背后经常与原生库交互,.NET运行时本身也是一个原生应用。

更重要的是,.NET Core和.NET Framework都暴露了一整套可以从原生代码调用的非托管API。.NET Core文档中列出了三类主要的非托管API:

  1. 调试API:用于调试在公共语言运行时(CLR)环境中运行的代码。
  2. 元数据API:用于在不将模块和类型加载到CLR的情况下读取或生成其详细信息。
  3. 性能分析API:用于通过CLR监控程序的执行。

本文将重点探讨最后一类——性能分析API,并将元数据API作为辅助角色使用。

当有了NativeAOT,谁还需要C语言?

微软已经为NativeAOT投入了多个版本的努力,随着每个新版本的.NET发布,它都变得更好一些。借助NativeAOT,您可以将.NET应用程序编译为原生的、独立的二进制文件。而一个原生的独立二进制文件正是编写.NET性能分析器所需的一切!

NativeAOT二进制文件的关键在于它是完全自包含的。这意味着当您的分析二进制文件被加载时,它运行的是一个与被分析应用程序完全独立的.NET运行时。是的,从技术上讲,进程中有两个.NET运行时被加载!

当然,仅仅将.NET编译为原生二进制文件并不是唯一的要求。您还需要确保您的原生二进制文件暴露所有正确的入口点和接口,以便.NET运行时能够像加载用C++构建的库一样加载您的库。

使用C#编写.NET性能分析器

作为展示Silhouette如何简化入门过程的示例,在本文的剩余部分,我们将使用.NET编写一个简单的分析器,它仅仅将加载的程序集名称打印到控制台。

创建分析器和测试项目

我们将从一个简单的解决方案开始。该方案包含两个项目:一个是作为分析器的类库,另一个是“Hello World”测试应用程序,我们将对其进行性能分析:

1
2
3
4
5
6
7
8
# 创建两个项目
dotnet new classlib -o SilhouetteProf
dotnet new console -o TestApp

# 将项目添加到解决方案文件
dotnet new sln
dotnet sln add .\SilhouetteProf\
dotnet sln add .\TestApp\

这为我们提供了基本的项目结构。现在将Silhouette库添加到我们的分析器项目:

1
dotnet add package Silhouette --project SilhouetteProf

接下来,我们需要确保使用NativeAOT发布应用程序并允许不安全代码。我们在这个测试中实际上不会自己编写任何不安全代码,但Silhouette包含一个确实使用不安全代码的源生成器。打开SilhouetteProf.csproj,并添加以下两个属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <RootNamespace>SilhouetteProf</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>

    <!-- 👇 添加以下两行 -->
    <PublishAot>true</PublishAot>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Silhouette" Version="3.2.0" />
  </ItemGroup>

</Project>

现在我们已经满足了前提条件,可以开始创建我们的分析器了。

创建基本分析器

要使用Silhouette创建.NET性能分析器,您需要创建一个派生自Silhouette提供的CorProfilerCallbackBase(或CorProfilerCallback2BaseCorProfilerCallback3Base等,取决于您需要的功能)的类。然后,用[Profiler]属性修饰这个类,并提供一个唯一的Guid:

1
2
3
4
5
6
7
8
9
using Silhouette;

namespace SilhouetteProf;

// 👇 使用一个新的随机Guid,不要直接用这个!
[Profiler("9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6")]
internal partial class MyCorProfilerCallback : CorProfilerCallback5Base
{
}

在上面的示例中,我为我的分析器选择了一个随机Guid,并从CorProfilerCallback5Base派生。本文的内容实际上并不需要来自ICorProfilerInfo5的“5”,我在这里包含它只是为了演示模式。

上面的[Profiler]属性驱动了Silhouette附带的源生成器,该生成器生成了.NET运行时创建IClassFactory所需的样板代码。您不一定非要使用这个生成的代码(例如,如果您需要在DllGetClassObject方法中添加额外的逻辑);如果您不想要这段代码,只需省略[Profiler]属性即可。

生成的代码大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
namespace Silhouette._Generated
{
    using System;
    using System.Runtime.InteropServices;

    file static class DllMain
    {
        [UnmanagedCallersOnly(EntryPoint = "DllGetClassObject")]
        public static unsafe HResult DllGetClassObject(Guid* rclsid, Guid* riid, nint* ppv)
        {
            if (*rclsid != new Guid("9fd62131-bf21-47c1-a4d4-3aef5d7c75c6"))
            {
                return HResult.CORPROF_E_PROFILER_CANCEL_ACTIVATION;
            }

            *ppv = ClassFactory.For(new global::SilhouetteProf.MyCorProfilerCallback());
            return HResult.S_OK;
        }
    }
}

我们已经有了分析器的骨架,但在编译之前,我们需要实现Initialize方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
using Silhouette;

namespace SilhouetteProf;

[Profiler("9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6")]
internal partial class MyCorProfilerCallback : CorProfilerCallback5Base
{
    protected override HResult Initialize(int iCorProfilerInfoVersion)
    {
        Console.WriteLine("[SilhouetteProf] Initialize");
        if (iCorProfilerInfoVersion < 5)
        {
            // 我们至少需要ICorProfilerInfo5,但得到的是小于5的版本
            return HResult.E_FAIL;
        }

        // 调用SetEventMask来告诉.NET运行时我们对哪些事件感兴趣
        return ICorProfilerInfo5.SetEventMask(COR_PRF_MONITOR.COR_PRF_MONITOR_ALL);
    }
}

上面的代码是我们可以编写的最简单的Initialize方法版本。Silhouette负责计算出哪个版本的ICorProfilerInfo可用,并将其作为int传递给该方法。在上面的代码中,我们确保至少有ICorProfilerInfo5可用,这样我们就可以调用ICorProfilerInfo5ICorProfilerInfo4ICorProfilerInfo3等暴露的任何方法。

您会注意到很多HResult值被用作返回值。返回错误代码是原生API中主要的错误处理方式,因此您会经常处理这些值。幸运的是,Silhouette将其作为一个方便的枚举暴露给您使用。

一旦我们确认当前的.NET运行时支持我们需要的功能,我们需要使用COR_PRF_MONITOR枚举和SetEventMask()SetEventMask2()方法来告诉运行时我们对哪些事件感兴趣。为了简单起见,我使用了ICorProfilerInfo5.SetEventMask()并启用了所有功能。

ICorProfilerInfo5字段在Initialize被调用之前根据可用的接口版本进行初始化。例如,如果iCorProfilerInfoVersion是7,那么所有ICorProfilerInfo*字段直到ICorProfilerInfo7都会被初始化。非常重要的一点是,您只能调用已被初始化的接口版本。所以,如果iCorProfilerInfoVersion是7,就不要调用ICorProfilerInfo8或更高版本!

在这一点上,我们可以测试我们的分析器,但在进行测试之前,我将继续完成实现。

为我们的分析器添加功能

使用Silhouette分析器响应事件就像重写基类中的一个方法一样简单。例如,我们可以重写Shutdown方法,该方法在运行时关闭时被调用:

1
2
3
4
5
protected override HResult Shutdown()
{
    Console.WriteLine("[SilhouetteProf] Shutdown");
    return HResult.S_OK;
}

为了增加一点趣味性,我们将重写AssemblyLoadFinished方法,该方法在程序集加载完成时被调用(我知道,这很令人震惊):

1
2
3
4
protected override HResult AssemblyLoadFinished(AssemblyId assemblyId, HResult hrStatus)
{
    // ...
}

AssemblyLoadFinished方法提供了一个AssemblyId,我们可以通过调用ICorProfilerInfo5上的另一个方法GetAssemblyInfo(AssemblyId)来检索程序集的名称。

AssemblyId类型是IntPtr的一个非常薄的包装器,充当强类型包装器,用于封装性能分析API中原本普遍使用的IntPtr。我非常喜欢这种方法,因为它消除了一整类可能犯的错误,例如将“类ID”的IntPtr传递给期望“程序集ID”IntPtr的方法。

我们可以使用ICorProfilerInfo5字段轻松调用GetAssemblyInfo(),但这让我们有机会看看Silhouette库中的一个常见模式,即HResult<T>的使用:

1
2
3
4
5
protected override HResult AssemblyLoadFinished(AssemblyId assemblyId, HResult hrStatus)
{
     HResult<AssemblyInfoWithName> assemblyInfo = ICorProfilerInfo5.GetAssemblyInfo(assemblyId);
    // ...
}

HResult<T>实际上是一种简单的结果模式判别联合。它既包含一个HResult,如果HResult表示成功,还包含一个T类型的对象。这种方法是避免性能分析API中常见模式的一种方式,即具有多个“out”参数和一个指示是否可以使用这些值的HRESULT。例如:

1
2
3
4
5
6
7
8
HRESULT GetAssemblyInfo(  
    [in]  AssemblyID  assemblyId,  
    [in]  ULONG       cchName,  
    [out] ULONG       *pcchName,  
    [out, size_is(cchName), length_is(*pcchName)]  
          WCHAR       szName[] ,  
    [out] AppDomainID *pAppDomainId,  
    [out] ModuleID    *pModuleId);

直接使用性能分析API时的典型模式是进行调用,检查返回值,然后决定是否继续。HResult<T>也允许这种模式,但您也可以进入YOLO模式。您可以调用HResult<T>.ThrowIfFailed(),如果调用成功,则返回T,否则抛出Win32Exception。这可以使代码的阅读和编写大大简化,因此这是一个真正的优势。

当然,对于生产级别的分析器,您是否想这样做是另一回事。但话说回来,您真的应该将本文中的任何内容用于生产吗?可能不会😉

使用ThrowIfFailed()方法,我们得到如下代码。我们尝试获取程序集名称,如果可用,就打印它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
protected override HResult AssemblyLoadFinished(AssemblyId assemblyId, HResult hrStatus)
{
    try
    {
        // 尝试获取AssemblyInfoWithName,如果HResult返回非成功状态,则抛出异常
        AssemblyInfoWithName assemblyInfo = ICorProfilerInfo5.GetAssemblyInfo(assemblyId).ThrowIfFailed();

        Console.WriteLine($"[SilhouetteProf] AssemblyLoadFinished: {assemblyInfo.AssemblyName}");
        return HResult.S_OK;
    }
    catch (Win32Exception ex)
    {
        // GetAssemblyInfo()由于某种原因失败了,很奇怪。
        Console.WriteLine($"[SilhouetteProf] AssemblyLoadFinished failed: {ex}");
        return ex.NativeErrorCode;
    }
}

如果只有一个调用,ThrowIfFailed()的优势并不特别明显。当您想要链接多个调用时,它才真正发挥作用。例如,如果我们想实现ClassLoadStarted,我们需要链接多个调用,这正是ThrowIfFailed()的用武之地:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
protected override HResult ClassLoadStarted(ClassId classId)
{
    try
    {
        ClassIdInfo classIdInfo = ICorProfilerInfo.GetClassIdInfo(classId).ThrowIfFailed();

        using ComPtr<IMetaDataImport>? metaDataImport = ICorProfilerInfo2
                                                            .GetModuleMetaDataImport(classIdInfo.ModuleId, CorOpenFlags.ofRead)
                                                            .ThrowIfFailed()
                                                            .Wrap();
        TypeDefPropsWithName classProps = metaDataImport.Value.GetTypeDefProps(classIdInfo.TypeDef).ThrowIfFailed();

        Console.WriteLine($"[SilhouetteProf] ClassLoadStarted: {classProps.TypeName}");
        return HResult.S_OK;
    }
    catch (Win32Exception ex)
    {
        Console.WriteLine($"[SilhouetteProf] ClassLoadStarted failed: {ex}");
        return ex.NativeErrorCode;
    }
}

在上面的代码中,我们有三个ThrowIfFailed()调用,这保持了良好、程序化的流程。我们本可以在代码中添加三个额外的if (result != HResult.S_OK),但这更难理解,特别是如果您正在编写类似的东西或只是进行原型设计。

好的,我们现在已经有足够的功能来测试我们的分析器了!

测试我们的新分析器

为了测试我们的分析器,我们需要做三件事:

  1. 发布我们的测试应用。
  2. 发布我们的分析器。
  3. 设置所需的分析环境变量。

发布测试应用

我们将从发布测试应用开始。严格来说,这不是必需的,我们可以直接使用dotnet run等命令运行应用。困难在于这会调用.NET SDK,而.NET SDK本身就是一个.NET应用程序,这意味着我们最终也会分析它。这没问题,只是不是我们想要做的。

我们可以使用简单的dotnet publish来发布我们的Hello World应用:

1
2
3
❯ dotnet publish .\TestApp\ -c Release
Restore complete (0.6s)
  TestApp net10.0 succeeded (0.9s) → TestApp\bin\Release\net10.0\publish\

发布我们的分析器

发布我们的分析器类似,但由于我们使用NativeAOT,我们还需要提供运行时标识符。在.NET 10中,您也可以使用--use-current-runtime选项来发布适用于“当前正在使用的任何运行时”。如下所示,SDK使用了win-x64,因为我在Windows上运行:

1
2
3
4
5
❯ dotnet publish .\SilhouetteProf\ -c Release --use-current-runtime
Restore complete (0.6s)
  SilhouetteProf net10.0 win-x64 succeeded (4.2s) → SilhouetteProf\bin\Release\net10.0\win-x64\publish\

Build succeeded in 5.5s

由于我们使用了NativeAOT,结果是一个单一的自包含dll(加上单独的调试符号)。这就是我们的.NET应用程序,编译成NativeAOT .NET分析器!

设置性能分析环境变量

要将分析器附加到.NET运行时,您需要设置一些环境变量。这些变量根据您是分析.NET Framework应用还是.NET Core应用而有所不同。需要设置三个不同的变量:

分析.NET Framework应用:

  • COR_ENABLE_PROFILING=1 — 启用分析
  • COR_PROFILER={9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6} — 设置为[Profiler]属性中的GUID值。
  • COR_PROFILER_PATH=c:\path\to\profiler — 指向分析器dll的路径

分析.NET Core/.NET 5+应用:

  • CORECLR_ENABLE_PROFILING=1 — 启用分析
  • CORECLR_PROFILER={9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6} — 设置为[Profiler]属性中的GUID值。
  • CORECLR_PROFILER_PATH=c:\path\to\profiler — 指向分析器dll的路径

如果需要支持多个平台,还可以设置特定于平台的路径变量。

发布分析器和应用程序后,我复制了分析器dll的绝对路径,并使用powershell设置了所需的环境变量。

从技术上讲,您不必为dll使用绝对路径,可以使用相对路径,但这是相对于目标应用程序的吗?还是相对于工作目录?我更喜欢使用绝对路径,因为它们没有歧义!

1
2
3
$env:CORECLR_ENABLE_PROFILING=1
$env:CORECLR_PROFILER="{9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6}"
$env:CORECLR_PROFILER_PATH="D:\repos\temp\silouette-prof\SilhouetteProf\bin\Release\net10.0\win-x64\publish\SilhouetteProf.dll"

请注意,GUID变量包含环绕的{}大括号。一旦变量设置好,我们就可以测试我们的分析器了!

使用我们的NativeAOT分析器测试应用

当我们运行应用程序时,.NET运行时检查CORECLR_变量,加载我们的NativeAOT分析器,并在应用程序执行时发出事件。随着每个事件的触发,我们向控制台写入内容,我们可以看到“Hello World!”应用程序运行时加载的所有程序集!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
❯ .\TestApp.exe
[SilhouetteProf] Initialize
[SilhouetteProf] AssemblyLoadFinished: System.Private.CoreLib
[SilhouetteProf] AssemblyLoadFinished: TestApp
[SilhouetteProf] AssemblyLoadFinished: System.Runtime
[SilhouetteProf] AssemblyLoadFinished: System.Console
[SilhouetteProf] AssemblyLoadFinished: System.Threading
[SilhouetteProf] AssemblyLoadFinished: System.Text.Encoding.Extensions
[SilhouetteProf] AssemblyLoadFinished: System.Runtime.InteropServices
Hello, World!
[SilhouetteProf] Shutdown

我们成功了,我们使用.NET编写的.NET分析器按预期工作了!🎉

显然,这是一个非常简单的实现,但它向我展示了使用Silhouette库启动和运行某些东西是多么容易,如果我不得不折腾C++,速度会慢得多。

需要记住的一点是,虽然Silhouette有助于监听事件和与C++接口互操作的机制,但您仍然需要知道如何使用原生API。Silhouette在那里帮助降低了学习曲线,但您可能仍然需要研究如何实现您想要的目标。

从我的角度来看,Silhouette显然是满足特定需求的一个方便工具。您不一定想用它来生产生产级别的分析器,但对于概念验证或开发工作来说,它似乎是无价的。特别是如果Kevin继续发布他自己使用Silhouette的实际示例!

总结

在本文中,我简要介绍了非托管的.NET性能分析API,以及您通常如何使用C++与这些API交互。然后我描述了如何改用.NET生成可以与这些API交互的二进制文件,从而获得在.NET中工作的所有好处,同时仍然能够调用原生API。

接着,我介绍了Kevin Gosse的Silhouette库,并展示了这个库如何通过派生自基类并重写您感兴趣的方法,使得使用NativeAOT生成分析器变得简单。我创建了一个简单的分析器,发布了它,并用它来显示一个Hello World控制台应用程序加载的所有程序集。

总的来说,我对Silhouette的简单易用印象深刻,未来也很可能会更多地探索它!

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计