使用C#与NativeAOT创建.NET CLR性能分析器
什么是.NET性能分析API?
对于99%的开发者来说,使用.NET意味着在舒适的托管运行时中工作,除了偶尔的P/Invoke外,几乎不需要关心原生代码。然而,.NET基础类库背后经常与原生库交互,.NET运行时本身也是一个原生应用。
更重要的是,.NET Core和.NET Framework都暴露了一整套可以从原生代码调用的非托管API。.NET Core文档中列出了三类主要的非托管API:
- 调试API:用于调试在公共语言运行时(CLR)环境中运行的代码。
- 元数据API:用于在不将模块和类型加载到CLR的情况下读取或生成其详细信息。
- 性能分析API:用于通过CLR监控程序的执行。
本文将重点探讨最后一类——性能分析API,并将元数据API作为辅助角色使用。
当有了NativeAOT,谁还需要C语言?
微软已经为NativeAOT投入了多个版本的努力,随着每个新版本的.NET发布,它都变得更好一些。借助NativeAOT,您可以将.NET应用程序编译为原生的、独立的二进制文件。而一个原生的独立二进制文件正是编写.NET性能分析器所需的一切!
NativeAOT二进制文件的关键在于它是完全自包含的。这意味着当您的分析二进制文件被加载时,它运行的是一个与被分析应用程序完全独立的.NET运行时。是的,从技术上讲,进程中有两个.NET运行时被加载!
当然,仅仅将.NET编译为原生二进制文件并不是唯一的要求。您还需要确保您的原生二进制文件暴露所有正确的入口点和接口,以便.NET运行时能够像加载用C++构建的库一样加载您的库。
使用C#编写.NET性能分析器
作为展示Silhouette如何简化入门过程的示例,在本文的剩余部分,我们将使用.NET编写一个简单的分析器,它仅仅将加载的程序集名称打印到控制台。
创建分析器和测试项目
我们将从一个简单的解决方案开始。该方案包含两个项目:一个是作为分析器的类库,另一个是“Hello World”测试应用程序,我们将对其进行性能分析:
|
|
这为我们提供了基本的项目结构。现在将Silhouette库添加到我们的分析器项目:
|
|
接下来,我们需要确保使用NativeAOT发布应用程序并允许不安全代码。我们在这个测试中实际上不会自己编写任何不安全代码,但Silhouette包含一个确实使用不安全代码的源生成器。打开SilhouetteProf.csproj,并添加以下两个属性:
|
|
现在我们已经满足了前提条件,可以开始创建我们的分析器了。
创建基本分析器
要使用Silhouette创建.NET性能分析器,您需要创建一个派生自Silhouette提供的CorProfilerCallbackBase(或CorProfilerCallback2Base、CorProfilerCallback3Base等,取决于您需要的功能)的类。然后,用[Profiler]属性修饰这个类,并提供一个唯一的Guid:
|
|
在上面的示例中,我为我的分析器选择了一个随机Guid,并从CorProfilerCallback5Base派生。本文的内容实际上并不需要来自ICorProfilerInfo5的“5”,我在这里包含它只是为了演示模式。
上面的[Profiler]属性驱动了Silhouette附带的源生成器,该生成器生成了.NET运行时创建IClassFactory所需的样板代码。您不一定非要使用这个生成的代码(例如,如果您需要在DllGetClassObject方法中添加额外的逻辑);如果您不想要这段代码,只需省略[Profiler]属性即可。
生成的代码大致如下:
|
|
我们已经有了分析器的骨架,但在编译之前,我们需要实现Initialize方法:
|
|
上面的代码是我们可以编写的最简单的Initialize方法版本。Silhouette负责计算出哪个版本的ICorProfilerInfo可用,并将其作为int传递给该方法。在上面的代码中,我们确保至少有ICorProfilerInfo5可用,这样我们就可以调用ICorProfilerInfo5、ICorProfilerInfo4、ICorProfilerInfo3等暴露的任何方法。
您会注意到很多HResult值被用作返回值。返回错误代码是原生API中主要的错误处理方式,因此您会经常处理这些值。幸运的是,Silhouette将其作为一个方便的枚举暴露给您使用。
一旦我们确认当前的.NET运行时支持我们需要的功能,我们需要使用COR_PRF_MONITOR枚举和SetEventMask()或SetEventMask2()方法来告诉运行时我们对哪些事件感兴趣。为了简单起见,我使用了ICorProfilerInfo5.SetEventMask()并启用了所有功能。
ICorProfilerInfo5字段在Initialize被调用之前根据可用的接口版本进行初始化。例如,如果iCorProfilerInfoVersion是7,那么所有ICorProfilerInfo*字段直到ICorProfilerInfo7都会被初始化。非常重要的一点是,您只能调用已被初始化的接口版本。所以,如果iCorProfilerInfoVersion是7,就不要调用ICorProfilerInfo8或更高版本!
在这一点上,我们可以测试我们的分析器,但在进行测试之前,我将继续完成实现。
为我们的分析器添加功能
使用Silhouette分析器响应事件就像重写基类中的一个方法一样简单。例如,我们可以重写Shutdown方法,该方法在运行时关闭时被调用:
|
|
为了增加一点趣味性,我们将重写AssemblyLoadFinished方法,该方法在程序集加载完成时被调用(我知道,这很令人震惊):
|
|
AssemblyLoadFinished方法提供了一个AssemblyId,我们可以通过调用ICorProfilerInfo5上的另一个方法GetAssemblyInfo(AssemblyId)来检索程序集的名称。
AssemblyId类型是IntPtr的一个非常薄的包装器,充当强类型包装器,用于封装性能分析API中原本普遍使用的IntPtr。我非常喜欢这种方法,因为它消除了一整类可能犯的错误,例如将“类ID”的IntPtr传递给期望“程序集ID”IntPtr的方法。
我们可以使用ICorProfilerInfo5字段轻松调用GetAssemblyInfo(),但这让我们有机会看看Silhouette库中的一个常见模式,即HResult<T>的使用:
|
|
HResult<T>实际上是一种简单的结果模式判别联合。它既包含一个HResult,如果HResult表示成功,还包含一个T类型的对象。这种方法是避免性能分析API中常见模式的一种方式,即具有多个“out”参数和一个指示是否可以使用这些值的HRESULT。例如:
|
|
直接使用性能分析API时的典型模式是进行调用,检查返回值,然后决定是否继续。HResult<T>也允许这种模式,但您也可以进入YOLO模式。您可以调用HResult<T>.ThrowIfFailed(),如果调用成功,则返回T,否则抛出Win32Exception。这可以使代码的阅读和编写大大简化,因此这是一个真正的优势。
当然,对于生产级别的分析器,您是否想这样做是另一回事。但话说回来,您真的应该将本文中的任何内容用于生产吗?可能不会😉
使用ThrowIfFailed()方法,我们得到如下代码。我们尝试获取程序集名称,如果可用,就打印它:
|
|
如果只有一个调用,ThrowIfFailed()的优势并不特别明显。当您想要链接多个调用时,它才真正发挥作用。例如,如果我们想实现ClassLoadStarted,我们需要链接多个调用,这正是ThrowIfFailed()的用武之地:
|
|
在上面的代码中,我们有三个ThrowIfFailed()调用,这保持了良好、程序化的流程。我们本可以在代码中添加三个额外的if (result != HResult.S_OK),但这更难理解,特别是如果您正在编写类似的东西或只是进行原型设计。
好的,我们现在已经有足够的功能来测试我们的分析器了!
测试我们的新分析器
为了测试我们的分析器,我们需要做三件事:
- 发布我们的测试应用。
- 发布我们的分析器。
- 设置所需的分析环境变量。
发布测试应用
我们将从发布测试应用开始。严格来说,这不是必需的,我们可以直接使用dotnet run等命令运行应用。困难在于这会调用.NET SDK,而.NET SDK本身就是一个.NET应用程序,这意味着我们最终也会分析它。这没问题,只是不是我们想要做的。
我们可以使用简单的dotnet publish来发布我们的Hello World应用:
|
|
发布我们的分析器
发布我们的分析器类似,但由于我们使用NativeAOT,我们还需要提供运行时标识符。在.NET 10中,您也可以使用--use-current-runtime选项来发布适用于“当前正在使用的任何运行时”。如下所示,SDK使用了win-x64,因为我在Windows上运行:
|
|
由于我们使用了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使用绝对路径,可以使用相对路径,但这是相对于目标应用程序的吗?还是相对于工作目录?我更喜欢使用绝对路径,因为它们没有歧义!
|
|
请注意,GUID变量包含环绕的{}大括号。一旦变量设置好,我们就可以测试我们的分析器了!
使用我们的NativeAOT分析器测试应用
当我们运行应用程序时,.NET运行时检查CORECLR_变量,加载我们的NativeAOT分析器,并在应用程序执行时发出事件。随着每个事件的触发,我们向控制台写入内容,我们可以看到“Hello World!”应用程序运行时加载的所有程序集!
|
|
我们成功了,我们使用.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的简单易用印象深刻,未来也很可能会更多地探索它!