.NET无侵入自动化探针原理和主流实现 APM探针#当我们提到 .NET 的 APM 时许多人首先会想到 SkyWalking 。这是因为 SkyAPM-dotnet 是第一个支持.NET应用程序的开源非商业 APM 探针实现目前很多 .NET 项目都采用了它。在此我们要特别感谢刘浩杨等社区领袖的辛勤付出。除了 SkyWalking 之外 Datadog APM 也是一款功能强大的商业应用性能监测工具旨在帮助开发人员跟踪、优化并排查应用程序中的性能问题。Datadog APM 适用于多种编程语言和框架包括 .NET 。通过使用 Datadog 丰富的功能和可视化仪表板我们能够轻松地识别并改进性能瓶颈。另一个比较知名的选择是 OpenTelemetry-dotnet-contrib 这是 CNCF-OpenTelemetry 的 .NET 应用程序 APM 探针实现。虽然它的推出时间比 SkyAPM 和 Datadog APM 稍晚但由于其开放的标准和开源的实现许多 .NET 项目也选择使用它。关于 APM 探针的实现原理我们主要分为两类来介绍平台相关指标和组件相关指标。接下来我们将讨论如何采集这两类指标。平台相关指标采集#那么APM探针都是如何采集 .NET 平台相关指标呢其实采集这些指标在 .NET 上是非常简单的因为.NET提供了相关的API接口我们可以直接获得这些指标这里指的平台指标是如 CPU 占用率、线程数量、GC 次数等指标。比如在 SkyAPM-dotne t项目中我们可以查看 SkyApm.Core 项目中的 Common 文件夹文件夹中就有诸如里面有 CPU 指标、GC 指标等平台相关指标采集实现帮助类。同样在 OpenTelemetry-dotnet-contrib 项目中我们可以在 Process 和 Runtime 文件夹中查看进程和运行时等平台相关指标采集的实现。这些都是简单的 API 调用有兴趣的同学可以自行查看代码本文就不再赘述这些内容。组件相关指标采集#除了平台相关指标采集还有组件相关的指标这里所指的组件相关指标拿 ASP.NET Core 应用程序举例我们接口秒并发是多少、一个请求执行了多久在这个请求执行的时候访问了哪些中间件( Redis 、MySql 、Http 调用、RPC 等等)访问中间件时传递的参数(Redis 命令、Sql 语句、请求响应体等等)是什么访问中间件花费了多少时间。在 SkyAPM-dotnet 项目中我们可以直接在src目录找到这些组件相关指标采集的实现代码。同样在 OpenTelemetry-dotnet-contrib 项目中我们也可以在src目录找到这些组件相关指标采集代码。如果看过这两个APM探针实现的朋友应该都知道组件指标采集是非常依赖DiagnosticSource技术。.NET官方社区一直推荐的的方式是组件开发者自己在组件的关键路径进行埋点使用DiagnosticSource的方式将事件传播出去然后其它监测软件工具可以订阅DiagnosticListener来获取组件运行状态。就拿 ASP.NET Core 来举例组件源码中有HostingApplicationDiagnostics.cs这样一个类这个类中定义了 Hosting 在请求处理过程中的几个事件。internal const string ActivityName “Microsoft.AspNetCore.Hosting.HttpRequestIn”;private const string ActivityStartKey ActivityName “.Start”;private const string ActivityStopKey ActivityName “.Stop”;当 Hosting 开始处理请求时会检测当前是否有监听者监听这些事件如果有的话就会写入事件事件也会携带当前的一些上下文信息代码如下所示以 SkyAPM-dotnet 举例有对应的HostingTracingDiagnosticProcessor.cs监听事件然后获取上下文信息记录 APM 埋点信息代码如下所示这种方式的优点有高效和高性能DiagnosticSource 是 .NET 平台自带的框架使用它硬编码可以享受到编译器和 JIT 相关优化可以避免一些性能开销。组件开发者可以控制事件传递的频率和内容以达到最佳的性能和资源利用率。灵活通过使用 DiagnosticSource组件开发者可以灵活地定义自己的事件模型并按需发布事件。这意味着可以轻松地定制自己的监测需求而不必担心过多的日志数据产生过大的开销。可扩展性使用DiagnosticSource可以让组件的监测需求随着时间的推移而演变而不必担心日志系统的限制。开发者可以根据自己的需要添加新的事件类型以适应不断变化的监测需求。易用性DiagnosticSource的 API 简单易用订阅事件数据也很容易。这使得使用它进行组件监测变得非常容易并且可以快速地集成到现有的监测系统中。可移植性DiagnosticSource可以在多个平台上运行包括 Windows、Linux 和 macOS 等。这意味着可以使用相同的事件模型来监测不同的应用程序和服务从而简化了监测系统的设计和管理。不过这种方式的缺点也很明显就是必须由组件开发者显式的添加事件代码探针的开发者也因此束手束脚这就导致一些没有进行手动埋点的三方组件都无法添加事件监听所以现阶段 SkyAPM-dotnet 支持的第三方组件还不是很丰富。那么其实只要解决如何为没有进行手动埋点的组件库加入埋点就能解决 SkyAPM-dotnet 支持第三方组件多样性的问题。.NET方法注入#从上一节我们可以知道目前制约APM支持组件不够丰富的原因之一就是很多组件库都没有进行可观测性的适配没有在关键路径进行埋点。那么要解决这个问题其实很简单我们只需要修改组件库关键路径代码给加上一些埋点就可以了那应该如何给这些第三方库的代码加点料呢聊到这个问题我们需要知道一个 .NET 程序是怎么从源代码变得可以运行的。通常情况下一个 .NET 程序从源码到运行会经过两次编译忽略 ReadyToRun 、NativeAOT 、分层编译等情况。如下图所示第一次是使用编译器将 C#/F#/VB/Python/PHP 源码使用 Roslyn 等对应语言编译器编译成 CILCommon Intermediate Language公共中间语言。第二次使用 RuyJit 编译器将 CIL 编译为对应平台的机器码以 C# 语言举了个例子如下图所示方法注入也一般是发生在这两次编译前后一个是在 Roslyn 静态编译期间进行方法注入期间目标 .NET 程序并没有运行所以这种 .NET 程序未运行的方法注入我们叫它编译时静态注入。而在 RuyJit 期间 .NET程序已经在运行这时进行方法注入我们叫它运行时动态注入。下表中列出了比较常见方法注入方式框架 类型 实现原理 优点 缺点metalama 静态注入 重写Roslyn编译器运行时插入代码 源码修改难度低兼容性好 目前该框架不开源只能修改源码不能修改已编译好的代码会增加编译耗时Mono.Cecil、Postsharp 静态注入 加载编译后的*.dll文件修改和替换生成后的CIL代码 兼容性好 使用难度高需要熟悉 CIL 会增加编译耗时会增加程序体积Harmony 动态注入 创建一个方法签名与原方法一致的方法修改Jit后原方法汇编插入jmp跳转到重写后方法 高性能使用难度低 泛型、分层编译支持不友好CLR Profile API 动态注入 调用CLR接口重写方法IL代码 功能强大公开的API支持 实现困难需要熟悉 CIL 稍有不慎导致程序崩溃综合各种优缺点现阶段APM使用最多的是 CLR Profile API 的方式进行方法注入比如 Azure AppInsights、DataDog、Elastic等.NET探针都是使用这种方式。基于CLR Profile API 实现APM探针原理#CLR Profile API 简介#在下面的章节中和大家聊一聊基于 CLR Profile API 是如何实现方法注入以及 CLR Profile API 是如何使用的。聊到 CLR 探查器我们首先就得知道 CLR 是什么CLRCommon Language Runtime公共语言运行时可以理解为是托管运行 .NET 程序的平台它提供了基础类库、线程、JIT 、GC 等语言运行的环境如下图所示它功能和 Java 的 JVM 有相似之处但定位有所不同。.NET 程序、CLR 和操作系统的关系如下图所示那么 CLR 探查器是什么东西呢根据官方文档的描述CLR 探查器和相关API的支持从 .NET Framework 1.0就开始提供它是一个工具可以使用它来监视另一个 .NET 应用程序的执行情况它也是一个( .dll )动态链接库CLR 在启动运行时加载探查器CLR 会将一些事件发送给探查器另外探查器也可以通过 Profile API 向 CLR 发送命令和获取运行时信息。下方是探查器和 CLR 工作的简单交互图ICorProfilerCallback提供的事件非常多常用的主要是下方提到这几类CLR 启动和关闭事件应用程序域创建和关闭事件程序集加载和卸载事件模块加载和卸载事件COM vtable 创建和析构事件实时 (JIT) 编译和代码间距调整事件类加载和卸载事件线程创建和析构事件函数入口和退出事件异常托管和非托管代码执行之间的转换不同运行时上下文之间的转换有关运行时挂起的信息有关运行时内存堆和垃圾回收活动的信息ICorProfilerInfo提供了很多查询和命令的接口主要是下方提到的这几类方法信息接口类型信息接口模块信息接口线程信息接口CLR 版本信息接口Callback 事件设置接口函数 Hook 接口还有 JIT 相关的接口通过 CLR Profile API 提供的这些事件和信息查询和命令接口我们就可以使用它来实现一个无需改动原有代码的 .NET 探针。自动化探针执行过程#APM 使用 .NET Profiler API 对应用程序进行代码插桩方法注入以监控方法调用和性能指标从而实现自动化探针。下面详细介绍这一过程Profiler注册在启动应用程序时.NET Tracer 作为一个分析器profiler向 CLRCommon Language Runtime注册。这样可以让它在整个应用程序生命周期内监听和操纵执行流程。JIT编译拦截当方法被即时编译JIT时Profiler API 发送事件通知。.NET Tracer 捕获这些事件如JITCompilationStarted从而有机会在方法被编译之前修改其 ILIntermediate Language代码。代码修改插桩通过操纵IL代码.NET Tracer 在关键方法的入口和退出点插入跟踪逻辑。这种操作对原始应用程序是透明的不需要修改源代码。跟踪逻辑通常包括记录方法调用数据、计时、捕获异常等。上下文传播为了连接跨服务或异步调用的请求链.NET Tracer 会将 Trace ID 和 Span ID在分布式系统中进行传递。这使得在复杂的微服务架构中追踪请求变得更加容易。数据收集插桩后的代码在运行期间会产生跟踪数据包括方法调用时间、执行路径、异常信息等。这些数据会被封装成跟踪和跨度spans并且通过 APM Agent 发送到 APM 平台进行后续分析和可视化。通过使用 .NET Profiler API 对应用程序进行方法注入插桩APM 可以实现对 .NET 程序的详细性能监控帮助开发者和运维人员发现并解决潜在问题。第一步向 CLR 注册分析器的步骤是很简单的CLR 要求分析器需要实现COM组件接口标准微软的 COMComponent Object Model接口是一种跨编程语言的二进制接口用于实现在操作系统中不同软件组件之间的通信和互操作。通过 COM 接口组件可以在运行时动态地创建对象、调用方法和访问属性实现模块化和封装。COM 接口使得开发人员能够以独立、可复用的方式构建软件应用同时还有助于降低维护成本和提高开发效率。COM 一般需要实现以下接口接口InterfacesCOM 组件使用接口提供一套预定义的函数这样其他组件就可以调用这些函数。每个接口都有一个唯一的接口标识IID。对象ObjectsCOM 对象是实现了一个或多个接口的具体实例。客户端代码通过对象暴露的接口与其进行交互。引用计数Reference CountingCOM 使用引用计数管理对象的生命周期。当一个客户端获取到对象的接口指针时对象的引用计数加一当客户端不再需要该接口时引用计数减一。当引用计数减至零时COM 对象会被销毁。查询接口QueryInterface客户端可以通过 QueryInterface 函数获取 COM 对象所实现的特定接口。这个函数接收一个请求的接口 IID并返回包含该接口指针的 HRESULT。类工厂Class Factories为了创建对象实例COM 使用类工厂。类工厂是实现了 IClassFactory 接口的对象允许客户端创建新的对象实例。比如 OpenTelemetry 中的class_factory.cpp就是声明了COM组件其中包括了查询接口、引用计数以及创建实例对象等功能。然后我们只需要设置三个环境变量如下所示COR_ENABLE_PROFILING将其设置为1表示启用 CLR 分析器。COR_PROFILER: 设置分析器的COM组件ID使 CLR 能正确的加载分析器。COR_PROFILER_PATH_32/64: 设置分析器的路径32位或者是64位应用程序。通过以上设置CLR 就可以在启动时通过 COM 组件来调用分析器实现的函数此时也代表着分析器加载完成。在 OpenTelemetry 和>