最近,我在微信公众号和博客园分享了一篇关于.NET微服务系统迁移至.NET 6.0的故事的文章,引起了许多读者的关注。其中,许多人对基于 OpenTelemetry .NET 的观测指标和无侵入自动化探针颇感兴趣。事实上,我已计划抽出时间,与大家分享这方面的内容。
巧合的是,在二月末,我收到了来自 OpenTelemetry 中国社区的蒋志伟大佬的邀请,希望我能就 .NET 实现无侵入自动化探针的方法进行分享。因为关于Java等其他语言的自动化探针实现原理已有大量文章,但.NET领域却鲜有介绍,而社区对此也很感兴趣。
然而,因为 .NET 无侵入自动化探针的实现原理相当复杂,理解和完全掌握原理有很大差别。为确保文章质量和严谨性,撰写过程耗时较长,因此现在才能与大家见面。
当我们提到 .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 实现方法注入的原理了。
在不考虑 AOT 编译和分层编译特性,一个 .NET 方法一开始的目标地址都会指向 JIT 编译器,当方法开始执行时,先调用 JIT 编译器将 CIL 代码转换为本机代码,然后缓存起来,运行本机代码,后面再次访问这个方法时,都会走缓存以后得本机代码,流程如下所示:
由于方法一般情况下只会被编译一次,一种方法注入的方案就是在 JIT 编译前替换掉对应方法的 MethodBody ,这个在 CLR Profile API 中提供的一个关键的回调。
JITCompilationStarted
:通知探查器,即时编译器已经开始编译方法。JITCompilationStarted
事件:当一个方法被即时编译(JIT)时,CLR(Common Language Runtime)会触发JITCompilationStarted
事件。通过使用 Profiler API ,分析器可以订阅这个事件并得到一个回调。JITCompilationStarted
事件回调时,分析器需要检查目标方法元数据,例如方法名称、参数类型和返回值类型等,来确定是否需要对该方法进行修改。GetILFunctionBody
方法来实现。SetILFunctionBody
方法替换目标方法的IL代码。这样,在方法被JIT编译成本地代码时,新的跟踪逻辑也会被包含进去。我们来看看源码是如何实现的,打开 dd-trace-dotnet 开源仓库,回退到较早的发布版本,有一个 integrations.json 文件,在 dd-trace-dotnet 编译时会自动生成这个文件,当然也可以手动维护,在这个文件里配置了需要 AOP 切面的程序集名称、类和方法,在分析器启动时,就会加载 json 配置,告诉分析器应该注入那些方法。
接下来,我们找到cor_profiler.cpp
文件并打开,这是实现 CLR 事件回调的代码,转到关于JITCompilationStarted
事件的通知的处理的源码。
由于代码较长,简单的说一下这个函数它做了什么,函数主要用于在 .NET JIT(Just-In-Time)编译过程中执行一系列操作,例如插入启动钩子、修改 IL(中间语言)代码以及替换方法等,以下是它的功能:
is_attached_
和 is_safe_to_block
变量,如果不满足条件,则直接返回。function_id
获取模块 ID 和函数 token。CallTarget
模式下注入加载器。AppDomain
中的第一个 JIT 编译方法中插入启动钩子。在最低程度上,必须添加AssemblyResolve
事件,以便从磁盘找到 Datadog.Trace.ClrProfiler.Managed.dll
及其依赖项,因为它不再被提供为 NuGet 包。AddIISPreStartInitFlags()
方法来设置预启动初始化标志。CallTarget
模式,将对integrations.json配置的方法进行插入和替换,并处理插入和替换调用。S_OK
表示成功完成操作。其中有两个关键函数,可以对 .NET 方法进行插入和替换,分别是ProcessInsertionCalls
和ProcessReplacementCalls
。
其中ProcessInsertionCalls
用于那些只需要在方法前部插入埋点的场景,假设我们有以下原始 C# 类:
public class TargetClass
{
public void TargetMethod()
{
Console.WriteLine("This is the original method.");
}
}
现在,我们希望在TargetMethod
的开头插入一个新的方法调用。让我们创建一个示例方法,并在WrapperClass
中定义它:
修改后,插入InsertedMethod
调用的TargetMethod
将如下所示:
public class TargetClass
{
public void TargetMethod()
{
WrapperClass.InsertedMethod(); // 这是新插入的方法调用
Console.WriteLine("This is the original method.");
}
}
public class WrapperClass
{
public static void InsertedMethod()
{
Console.WriteLine("This is the inserted method.");
}
}
请注意,上述示例是为了解释目的而手动修改的,实际上这种修改是通过操作IL代码来完成的。在CorProfiler::ProcessInsertionCalls
方法中,这些更改是在IL指令级别上进行的,不会直接影响源代码。
修改方法的 IL 代码.NET官方提供了一个帮助类 ILRewriter ,ILRewriter 是一个用于操作C#程序中方法的中间语言(Intermediate Language,IL)代码的工具类。它会将方法的IL代码以链表的形式组织,让我们可以方便的修改IL代码,它通常用于以下场景:
ILRewriter 类提供了一系列方法用于读取、修改和写回IL指令序列。例如,在上述CorProfiler::ProcessInsertionCalls
方法中,我们使用 ILRewriter 对象导入IL代码,执行所需的更改(如插入新方法调用),然后将修改后的 IL 代码导出并应用到目标方法上。这样可以实现对程序行为的运行时修改,而无需直接更改源代码。
另一个ProcessReplacementCalls
方法就是将原有的方法调用实现一个 Proxy ,适用于那些需要捕获异常获取方法返回值的场景,这块代码比较复杂,假设我们有以下 C# 代码,其中我们想要替换OriginalMethod()
的调用:
public class TargetClass
{
public int OriginalMethod(int a, int b)
{
return a * b;
}
}
public class CallerClass
{
public void CallerMethod()
{
TargetClass target = new TargetClass();
int result = target.OriginalMethod(3, 4);
Console.WriteLine(result);
}
}
在应用方法调用替换后,CallerMethod()
将调用自定义的替换方法WrapperMethod()
而不是OriginalMethod()
。例如,我们可以使用以下替换方法:
public class WrapperClass
{
public static int WrapperMethod(TargetClass instance, int opCode, int mdToken, long moduleVersionId, int a, int b)
{
Console.WriteLine("Method call replaced.");
return instance.OriginalMethod(a, b);
}
}
经过IL修改后,CallerMethod()
看起来大致如下:
public void CallerMethod()
{
TargetClass target = new TargetClass();
int opCode = /* Original CALL or CALLVIRT OpCode */;
int mdToken = /* Metadata token for OriginalMethod */;
long moduleVersionId = /* Module version ID pointer */;
// Call the wrapper method instead of the original method
int result = WrapperClass.WrapperMethod(target, opCode, mdToken, moduleVersionId, 3, 4);
Console.WriteLine(result);
}
现在CallerMethod()
将调用WrapperMethod()
,在这个例子中,我们记录了一条替换消息,然后继续调用OriginalMethod()
。
正如所述,通过捕获JITCompilationStarted
事件并对中间语言(IL)进行改写,我们修改方法行为的基本原理。在 .NET Framework 4.5 之前的版本中,这种方式广泛应用于方法改写和植入埋点,从而实现 APM 的自动化探针。然而,此方法也存在以下一些不足之处:
JITCompilationStarted
在方法被 JIT 编译之前触发,这意味着它只能在初次编译过程中修改 IL。JITCompilationStarted
是一个全局事件,它会在每个需要 JIT 编译的方法被调用时触发。因此,如果在此事件中进行 IL 修改,可能会对整个应用程序产生更大的性能影响。JITCompilationStarted
中重写 IL 时,您不能精确控制何时对某个方法应用更改。JITCompilationStarted
事件。但是我们也无法再其它时间进行重写,因为JIT一般情况下只会编译一次,JIT 已经完成编译以后修改方法 IL 不会再次 JIT ,修改也不会生效。在 .NET Framework 4.5 诞生之前,我们并未拥有更为优美的途径来实现 APM 自动化探测。然而,随着 .NET Framework 4.5 的降临,一条全新的路径终于展现在我们面前。
上文中提到了捕获JITCompilationStarted
事件时进行方法重写的种种缺点,于是在.NET 4.5中,新增了一个名为RequestReJIT
的方法,它允许运行时动态地重新编译方法。RequestReJIT
主要用于性能分析和诊断工具,在程序运行过程中,可以为指定的方法替换新的即时编译(JIT)代码,以便优化性能或修复bug。
RequestReJIT
提供了一种强大的机制,使开发人员能够在不重启应用程序的情况下热更新代码逻辑。这在分析、监视及优化应用程序性能方面非常有用。它可以在程序运行时动态地替换指定方法的 JIT 代码,而无需关心方法是否已经被编译过。RequestReJIT
减轻了多线程环境下的竞争风险,并且可以处理 NGEN 映像中的方法。通过提供这个强大的机制,RequestReJIT
使得性能分析和诊断工具能够更有效地优化应用程序性能及修复bug。
使用RequestReJIT
重写方法IL的流程如下:
RequestReJIT
方法通知 CLR 重新编译目标方法。此时,CLR 会触发ReJITCompilationStarted
事件。ReJITCompilationStarted
事件:分析器订阅ReJITCompilationStarted
事件,在事件回调中获取到修改后的 IL 代码,订阅结束事件,分析器可以获取本次重新编译是否成功。有了RequestJIT
方法,我们可以在任何时间修改方法 IL 然后进行重新编译,无需拦截JIT执行事件,在新版的 dd-trace 触发方法注入放到了托管代码中,托管的 C# 代码直接调用非托管的分析器 C++ 代码进行方法注入,所以不需要单独在 json 文件中配置。
取而代之的是InstrumentationDefinitions.g.cs
文件,在编译时会扫描所有标记了InstrumentMethod
特性的方法,然后自动生成这个类。
当分析器启动时,会调用Instrumentation.cs
类中Initialize()
方法,在这个方法内部就会和分析器通讯,将需要进行方法注入的方法传递给分析器。
因为需要和分析器进行通讯,所以需要在分析器中导出可供 C# 代码调用的函数,源码中是interop.cpp
导出了 C# 和 C++ 代码互操作的几个函数,同样在 C# 中也要使用P/Invoke
技术来定义一个调用类。
分析器接受到需要注入的方法信息以后,会将其加入到方法注入的队列中,然后会重写对应方法至下方这种形式:
/// <摘要>
/// 用calltarget实现重写目标方法体。(这个函数是由ReJIT处理程序触发的)生成的代码结构:
///
/// - 为 TReturn(如果非 void 方法)、CallTargetState、CallTargetReturn/CallTargetReturn<TReturn> 和 Exception 添加局部变量
/// - 初始化局部变量
try
{
try
{
try
{
- 使用对象实例(对于静态方法则为 null)和原始方法参数调用 BeginMethod
- 将结果存储到 CallTargetState 局部变量中
}
catch 当异常不是 Datadog.Trace.ClrProfiler.CallTarget.CallTargetBubbleUpException 时
{
- 调用 LogException(Exception)
}
- 执行原始方法指令
* 所有RET指令都替换为 LEAVE_S。对于非void方法,堆栈上的值首先存储在 TReturn 局部变量中。
}
catch (Exception)
{
- 将异常存储到 Exception 局部变量中
- 抛出异常
}
}
finally
{
try
{
- 使用对象实例(对于静态方法则为null),TReturn局部变量(如果非 void 方法),CallTargetState局部变量和 Exception 局部变量调用 EndMethod
- 将结果存储到 CallTargetReturn/CallTargetReturn<TReturn> 局部变量中
- 如果是非void方法,将 CallTargetReturn<TReturn>.GetReturnValue() 存储到 TReturn 局部变量中
}
catch 当异常不是 Datadog.Trace.ClrProfiler.CallTarget.CallTargetBubbleUpException 时
{
- 调用 LogException(Exception)
}
}
- 如果非 void 方法,则加载 TReturn 局部变量
- RET
最后请求RequestReJIT
来重新编译进行 JIT 编译,完成了整个方法的注入。
以上就是目前 .NET 上 APM 主流的无侵入自动化探针的实现原理的简单科普,总体实现是很复杂的,里面还有诸多细节在本文中并未提到。然而,通过了解这些基本概念和技术原理,希望能为您提供一个较为清晰的认识,让您更好地理解 APM 无侵入式探针是如何在 .NET 平台工作的。
如果大家对此话题有兴趣,并希望建立更深入、全面的了解,那么后续可以更新下一篇文章,在接下来的内容中,我们可以实现一个简单版本的 .NET 无侵入探针,并将深入探讨相关实现细节以及如何在实际场景中应用这些方法。
InCerry,微软最有价值专家,现就职于同程旅行
相信大家在开发中经常会遇到一些性能问题,苦于没有有效的工具去发现性能瓶颈,或者是发现瓶颈以后不知道该如何优化。之前一直有读者朋友询问有没有技术交流群,但是由于各种原因一直都没创建,现在很高兴的在这里宣布,我创建了一个专门交流.NET性能优化经验的群组,主题包括但不限于:
希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET性能问题和宝贵的性能分析优化经验。目前一群已满,现在开放二群。
如果提示已经达到200人,可以加我微信,我拉你进群: ls1075
另外也创建了QQ群,群号: 687779078,欢迎大家加入。