当我们提到 .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](https://github.com/dotnet/aspnetcore/blob/main/src/Hosting/Hosting/src/Internal/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 支持第三方组件多样性的问题。
从上一节我们可以知道,目前制约APM支持组件不够丰富的原因之一就是很多组件库都没有进行可观测性的适配,没有在关键路径进行埋点。
那么要解决这个问题其实很简单,我们只需要修改组件库关键路径代码给加上一些埋点就可以了,那应该如何给这些第三方库的代码加点料呢?聊到这个问题我们需要知道一个 .NET 程序是怎么从源代码变得可以运行的。
通常情况下,一个 .NET 程序从源码到运行会经过两次编译(忽略 ReadyToRun 、NativeAOT 、分层编译等情况)。如下图所示:
第一次是使用编译器将 C#/F#/VB/Python/PHP 源码使用 Roslyn 等对应语言编译器编译成 CIL(Common 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 是如何实现方法注入,以及 CLR Profile API 是如何使用的。
聊到 CLR 探查器,我们首先就得知道 CLR 是什么,CLR(Common 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
提供的事件非常多,常用的主要是下方提到这几类:
ICorProfilerInfo
提供了很多查询和命令的接口,主要是下方提到的这几类:APM 使用 .NET Profiler API 对应用程序进行代码插桩方法注入,以监控方法调用和性能指标从而实现自动化探针。下面详细介绍这一过程:
通过使用 .NET Profiler API 对应用程序进行方法注入插桩,APM 可以实现对 .NET 程序的详细性能监控,帮助开发者和运维人员发现并解决潜在问题。
第一步,向 CLR 注册分析器的步骤是很简单的,CLR 要求分析器需要实现COM组件接口标准,微软的 COM(Component Object Model)接口是一种跨编程语言的二进制接口,用于实现在操作系统中不同软件组件之间的通信和互操作。通过 COM 接口,组件可以在运行时动态地创建对象、调用方法和访问属性,实现模块化和封装。COM 接口使得开发人员能够以独立、可复用的方式构建软件应用,同时还有助于降低维护成本和提高开发效率。COM 一般需要实现以下接口:
比如 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 和 data-dog 等 APM 中都有这样的设置。
那后面的JIT编译拦截以及其它功能如何实现呢?我们举一个现实存在的例子,如果我们需要跟踪每一次 Reids 操作的时间和执行命令的内容,那么我们在应该修改StackExchange.Redis
ExecuteAsyncImpl
方法,从message
中读取执行命令的内容并记录整个方法耗时。
那么APM如何实现对Redis ExecuteAsyncImpl
进行注入的?可以打开dd-trace-dotnet仓库也可以打开opentelemetry-dotnet-instrumentation仓库,这两者的方法注入实现原理都是一样的,只是代码实现上有一些细微的差别。这里我们还是以 dd-trace-dotnet 仓库代码为例。
打开tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation
目录,里面所有的源码都是通过方法注入的方式来实现APM埋点,有非常多的组件埋点的实现,比如 MQ 、Redis 、 CosmosDb 、Couchbase 等等。
打开 Redis 的文件夹,可以很容易找到 Redis 进行方法注入的源码,这相当于是一个 AOP 切面实现方法:
[InstrumentMethod(
AssemblyName = "StackExchange.Redis",
TypeName = "StackExchange.Redis.ConnectionMultiplexer",
MethodName = "ExecuteAsyncImpl",
ReturnTypeName = "System.Threading.Tasks.Task`1<T>",
ParameterTypeNames = new[] { "StackExchange.Redis.Message", "StackExchange.Redis.ResultProcessor`1[!!0]", ClrNames.Object, "StackExchange.Redis.ServerEndPoint" },
MinimumVersion = "1.0.0",
MaximumVersion = "2.*.*",
IntegrationName = StackExchangeRedisHelper.IntegrationName)]
[InstrumentMethod(
AssemblyName = "StackExchange.Redis.StrongName",
TypeName = "StackExchange.Redis.ConnectionMultiplexer",
MethodName = "ExecuteAsyncImpl",
ReturnTypeName = "System.Threading.Tasks.Task`1<T>",
ParameterTypeNames = new[] { "StackExchange.Redis.Message", "StackExchange.Redis.ResultProcessor`1[!!0]", ClrNames.Object, "StackExchange.Redis.ServerEndPoint" },
MinimumVersion = "1.0.0",
MaximumVersion = "2.*.*",
IntegrationName = StackExchangeRedisHelper.IntegrationName)]
[Browsable(false)]
[EditorBrowsable(EditorBrowsableState.Never)]
public class ConnectionMultiplexerExecuteAsyncImplIntegration
{
/// <summary>
/// OnMethodBegin callback
/// </summary>
/// <typeparam name="TTarget">Type of the target</typeparam>
/// <typeparam name="TMessage">Type of the message</typeparam>
/// <typeparam name="TProcessor">Type of the result processor</typeparam>
/// <typeparam name="TServerEndPoint">Type of the server end point</typeparam>
/// <param name="instance">Instance value, aka `this` of the instrumented method.</param>
/// <param name="message">Message instance</param>
/// <param name="resultProcessor">Result processor instance</param>
/// <param name="state">State instance</param>
/// <param name="serverEndPoint">Server endpoint instance</param>
/// <returns>Calltarget state value</returns>
internal static CallTargetState OnMethodBegin<TTarget, TMessage, TProcessor, TServerEndPoint>(TTarget instance, TMessage message, TProcessor resultProcessor, object state, TServerEndPoint serverEndPoint)
where TTarget : IConnectionMultiplexer
where TMessage : IMessageData
{
string rawCommand = message.CommandAndKey ?? "COMMAND";
StackExchangeRedisHelper.HostAndPort hostAndPort = StackExchangeRedisHelper.GetHostAndPort(instance.Configuration);
Scope scope = RedisHelper.CreateScope(Tracer.Instance, StackExchangeRedisHelper.IntegrationId, StackExchangeRedisHelper.IntegrationName, hostAndPort.Host, hostAndPort.Port, rawCommand);
if (scope is not null)
{
return new CallTargetState(scope);
}
return CallTargetState.GetDefault();
}
/// <summary>
/// OnAsyncMethodEnd callback
/// </summary>
/// <typeparam name="TTarget">Type of the target</typeparam>
/// <typeparam name="TResponse">Type of the response, in an async scenario will be T of Task of T</typeparam>
/// <param name="instance">Instance value, aka `this` of the instrumented method.</param>
/// <param name="response">Response instance</param>
/// <param name="exception">Exception instance in case the original code threw an exception.</param>
/// <param name="state">Calltarget state value</param>
/// <returns>A response value, in an async scenario will be T of Task of T</returns>
internal static TResponse OnAsyncMethodEnd<TTarget, TResponse>(TTarget instance, TResponse response, Exception exception, in CallTargetState state)
{
state.Scope.DisposeWithException(exception);
return response;
}
}
这段代码是一个用于监控和跟踪 StackExchange.Redis 库的 APM(应用性能监控)工具集成。它针对 StackExchange.Redis.ConnectionMultiplexer
类的 ExecuteAsyncImpl
方法进行了注入以收集执行过程中的信息。
InstrumentMethod
属性,分别指定 StackExchange.Redis
和 StackExchange.Redis.StrongName
两个程序集。属性包括程序集名称、类型名、方法名、返回类型名等信息以及版本范围和集成名称。ConnectionMultiplexerExecuteAsyncImplIntegration
类定义了 OnMethodBegin
和 OnAsyncMethodEnd
方法。这些方法在目标方法开始和结束时被调用。OnMethodBegin
方法创建一个新的 Tracing Scope
,其中包含了与执行的 Redis 命令相关的信息(如 hostname
, port
, command
等)。OnAsyncMethodEnd
方法在命令执行结束后处理 Scope
,在此过程中捕获可能的异常,并返回结果。CallTargetState state
中其实包含了上下文信息,有 Span Id 和 Trace Id ,就可以将其收集发送到 APM 后端进行处理。但是,仅仅只有声明了一个 AOP 切面类不够,我们还需将这个 AOP 切面类应用到 Redis SDK 原有的方法中,这又是如何做到的呢?那么我们就需要了解一下 CLR Profiler API 实现方法注入的原理了。
如果您发现该资源为电子书等存在侵权的资源或对该资源描述不正确等,可点击“私信”按钮向作者进行反馈;如作者无回复可进行平台仲裁,我们会在第一时间进行处理!
加入交流群
请使用微信扫一扫!