最近,我在微信公眾號和部落格園分享了一篇關於.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,歡迎大家加入。