.NET無侵入自動化探針原理和主流實現

2023-04-17 12:02:13

前言

最近,我在微信公眾號和部落格園分享了一篇關於.NET微服務系統遷移至.NET 6.0的故事的文章,引起了許多讀者的關注。其中,許多人對基於 OpenTelemetry .NET 的觀測指標和無侵入自動化探針頗感興趣。事實上,我已計劃抽出時間,與大家分享這方面的內容。

巧合的是,在二月末,我收到了來自 OpenTelemetry 中國社群的蔣志偉大佬的邀請,希望我能就 .NET 實現無侵入自動化探針的方法進行分享。因為關於Java等其他語言的自動化探針實現原理已有大量文章,但.NET領域卻鮮有介紹,而社群對此也很感興趣。

然而,因為 .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 專案中,我們可以在 ProcessRuntime 資料夾中,檢視程序和執行時等平臺相關指標採集的實現。

這些都是簡單的 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 支援第三方元件多樣性的問題。

.NET方法注入

從上一節我們可以知道,目前制約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 實現APM探針原理

CLR Profile API 簡介

在下面的章節中和大家聊一聊基於 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提供的事件非常多,常用的主要是下方提到這幾類:

  • CLR 啟動和關閉事件
  • 應用程式域建立和關閉事件
  • 程式集載入和解除安裝事件
  • 模組載入和解除安裝事件
  • COM vtable 建立和解構事件
  • 實時 (JIT) 編譯和程式碼間距調整事件
  • 類載入和解除安裝事件
  • 執行緒建立和解構事件
  • 函數入口和退出事件
  • 異常
  • 託管和非受控程式碼執行之間的轉換
  • 不同執行時上下文之間的轉換
  • 有關執行時掛起的資訊
  • 有關執行時記憶體堆和垃圾回收活動的資訊
    ICorProfilerInfo提供了很多查詢和命令的介面,主要是下方提到的這幾類:
  • 方法資訊介面
  • 型別資訊介面
  • 模組資訊介面
  • 執行緒資訊介面
  • CLR 版本資訊介面
  • Callback 事件設定介面
  • 函數 Hook 介面
  • 還有 JIT 相關的介面
    通過 CLR Profile API 提供的這些事件和資訊查詢和命令介面,我們就可以使用它來實現一個無需改動原有程式碼的 .NET 探針。

自動化探針執行過程

APM 使用 .NET Profiler API 對應用程式進行程式碼插樁方法注入,以監控方法呼叫和效能指標從而實現自動化探針。下面詳細介紹這一過程:

  1. Profiler註冊:在啟動應用程式時,.NET Tracer 作為一個分析器(profiler)向 CLR(Common Language Runtime)註冊。這樣可以讓它在整個應用程式生命週期內監聽和操縱執行流程。
  2. JIT編譯攔截:當方法被即時編譯(JIT)時,Profiler API 傳送事件通知。.NET Tracer 捕獲這些事件,如JITCompilationStarted,從而有機會在方法被編譯之前修改其 IL(Intermediate Language)程式碼。
  3. 程式碼修改插樁:通過操縱IL程式碼,.NET Tracer 在關鍵方法的入口和退出點插入跟蹤邏輯。這種操作對原始應用程式是透明的,不需要修改原始碼。跟蹤邏輯通常包括記錄方法呼叫資料、計時、捕獲異常等。
  4. 上下文傳播:為了連線跨服務或非同步呼叫的請求鏈,.NET Tracer 會將 Trace ID 和 Span ID在分散式系統中進行傳遞。這使得在複雜的微服務架構中追蹤請求變得更加容易。
  5. 資料收集:插樁後的程式碼在執行期間會產生跟蹤資料,包括方法呼叫時間、執行路徑、異常資訊等。這些資料會被封裝成跟蹤和跨度(spans),並且通過 APM Agent 傳送到 APM 平臺進行後續分析和視覺化。

通過使用 .NET Profiler API 對應用程式進行方法注入插樁,APM 可以實現對 .NET 程式的詳細效能監控,幫助開發者和運維人員發現並解決潛在問題。

第一步,向 CLR 註冊分析器的步驟是很簡單的,CLR 要求分析器需要實現COM元件介面標準,微軟的 COM(Component Object Model)介面是一種跨程式語言的二進位制介面,用於實現在作業系統中不同軟體元件之間的通訊和互操作。通過 COM 介面,元件可以在執行時動態地建立物件、呼叫方法和存取屬性,實現模組化和封裝。COM 介面使得開發人員能夠以獨立、可複用的方式構建軟體應用,同時還有助於降低維護成本和提高開發效率。COM 一般需要實現以下介面:

  1. 介面(Interfaces):COM 元件使用介面提供一套預定義的函數,這樣其他元件就可以呼叫這些函數。每個介面都有一個唯一的介面標識(IID)。
  2. 物件(Objects):COM 物件是實現了一個或多個介面的具體範例。使用者端程式碼通過物件暴露的介面與其進行互動。
  3. 參照計數(Reference Counting):COM 使用參照計數管理物件的生命週期。當一個使用者端獲取到物件的介面指標時,物件的參照計數加一;當用戶端不再需要該介面時,參照計數減一。當參照計數減至零時,COM 物件會被銷燬。
  4. 查詢介面(QueryInterface):使用者端可以通過 QueryInterface 函數獲取 COM 物件所實現的特定介面。這個函數接收一個請求的介面 IID,並返回包含該介面指標的 HRESULT。
  5. 類工廠(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 和 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 方法進行了注入以收集執行過程中的資訊。

  1. 使用了兩個 InstrumentMethod 屬性,分別指定 StackExchange.RedisStackExchange.Redis.StrongName 兩個程式集。屬性包括程式集名稱、型別名、方法名、返回型別名等資訊以及版本範圍和整合名稱。
  2. ConnectionMultiplexerExecuteAsyncImplIntegration 類定義了 OnMethodBeginOnAsyncMethodEnd 方法。這些方法在目標方法開始和結束時被呼叫。
  3. OnMethodBegin 方法建立一個新的 Tracing Scope,其中包含了與執行的 Redis 命令相關的資訊(如 hostname, port, command 等)。
  4. OnAsyncMethodEnd 方法在命令執行結束後處理 Scope,在此過程中捕獲可能的異常,並返回結果。
  5. 而這個 CallTargetState state 中其實包含了上下文資訊,有 Span Id 和 Trace Id ,就可以將其收集傳送到 APM 後端進行處理。

但是,僅僅只有宣告了一個 AOP 切面類不夠,我們還需將這個 AOP 切面類應用到 Redis SDK 原有的方法中,這又是如何做到的呢?那麼我們就需要了解一下 CLR Profiler API 實現方法注入的原理了。

方法注入底層實現原理

在不考慮 AOT 編譯和分層編譯特性,一個 .NET 方法一開始的目標地址都會指向 JIT 編譯器,當方法開始執行時,先呼叫 JIT 編譯器將 CIL 程式碼轉換為本機程式碼,然後快取起來,執行本機程式碼,後面再次存取這個方法時,都會走快取以後得本機程式碼,流程如下所示:

攔截JIT編譯

由於方法一般情況下只會被編譯一次,一種方法注入的方案就是在 JIT 編譯前替換掉對應方法的 MethodBody ,這個在 CLR Profile API 中提供的一個關鍵的回撥。

  • JITCompilationStarted:通知探查器,即時編譯器已經開始編譯方法。
    我們只需要訂閱這個事件,就可以在方法編譯開始時將對應的 MethodBody 修改成我們想要的樣子,在裡面進行 AOP 埋點即可。在JITCompilationStarted事件中重寫方法IL的流程大致如下:
  1. 捕獲JITCompilationStarted事件:當一個方法被即時編譯(JIT)時,CLR(Common Language Runtime)會觸發JITCompilationStarted事件。通過使用 Profiler API ,分析器可以訂閱這個事件並得到一個回撥。
  2. 確定要修改的方法:在收到JITCompilationStarted事件回撥時,分析器需要檢查目標方法後設資料,例如方法名稱、引數型別和返回值型別等,來確定是否需要對該方法進行修改。
  3. 獲取方法的原始 IL 程式碼:如果確定要對目標方法進行修改,分析器需要首先獲取該方法的原始 IL 程式碼。這可以通過使用Profiler API 提供的GetILFunctionBody方法來實現。
  4. 分析和修改 IL 程式碼:接下來,分析器需要解析原始 IL 程式碼,找到適當的位置以插入新的跟蹤邏輯。這通常包括方法的入口點(開始執行時)和退出點(返回或丟擲異常)。分析器會生成一段新的 IL 程式碼,用於記錄效能指標、捕獲異常等。
  5. 替換方法的 IL 程式碼:將新生成的 IL 程式碼插入到原始 IL 程式碼中,並使用SetILFunctionBody方法替換目標方法的IL程式碼。這樣,在方法被JIT編譯成原生程式碼時,新的跟蹤邏輯也會被包含進去。
  6. 繼續JIT編譯:完成IL程式碼重寫後,分析器需要通知CLR繼續JIT編譯過程。編譯後的原生程式碼將包含插入的跟蹤邏輯,並在應用程式執行期間執行。

我們來看看原始碼是如何實現的,開啟 dd-trace-dotnet 開源倉庫,回退到較早的釋出版本,有一個 integrations.json 檔案,在 dd-trace-dotnet 編譯時會自動生成這個檔案,當然也可以手動維護,在這個檔案裡設定了需要 AOP 切面的程式集名稱、類和方法,在分析器啟動時,就會載入 json 設定,告訴分析器應該注入那些方法。

接下來,我們找到cor_profiler.cpp檔案並開啟,這是實現 CLR 事件回撥的程式碼,轉到關於JITCompilationStarted事件的通知的處理的原始碼。

由於程式碼較長,簡單的說一下這個函數它做了什麼,函數主要用於在 .NET JIT(Just-In-Time)編譯過程中執行一系列操作,例如插入啟動勾點、修改 IL(中間語言)程式碼以及替換方法等,以下是它的功能:

  1. 函數檢查 is_attached_ 和 is_safe_to_block 變數,如果不滿足條件,則直接返回。
  2. 使用互斥鎖保護模組資訊,防止在使用過程中解除安裝模組。
  3. 通過給定的 function_id 獲取模組 ID 和函數 token。
  4. 根據模組 ID 查詢模組後設資料。
  5. 檢查是否已在CallTarget模式下注入載入器。
  6. 如果符合條件且載入器尚未注入,則在AppDomain中的第一個 JIT 編譯方法中插入啟動勾點。在最低程度上,必須新增AssemblyResolve事件,以便從磁碟找到 Datadog.Trace.ClrProfiler.Managed.dll 及其依賴項,因為它不再被提供為 NuGet 包。
  7. 在桌面版 IIS 環境下,呼叫 AddIISPreStartInitFlags() 方法來設定預啟動初始化標誌。
  8. 如果未啟用CallTarget模式,將對integrations.json設定的方法進行插入和替換,並處理插入和替換呼叫。
  9. 返回 S_OK 表示成功完成操作。

其中有兩個關鍵函數,可以對 .NET 方法進行插入和替換,分別是ProcessInsertionCallsProcessReplacementCalls

其中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程式碼,它通常用於以下場景:

  1. 程式碼注入:在方法體中插入、刪除或修改 IL 指令。
  2. 程式碼優化:優化 IL 程式碼以提高效能。
  3. 執行 AOP(面向切面程式設計):通過動態操縱位元組碼實現橫切關注點(如紀錄檔記錄、效能度量等)。

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 的自動化探針。然而,此方法也存在以下一些不足之處:

  1. 不支援動態更新:JITCompilationStarted 在方法被 JIT 編譯之前觸發,這意味著它只能在初次編譯過程中修改 IL。
  2. 更大的效能影響:由於JITCompilationStarted是一個全域性事件,它會在每個需要 JIT 編譯的方法被呼叫時觸發。因此,如果在此事件中進行 IL 修改,可能會對整個應用程式產生更大的效能影響。
  3. 無法控制執行時機:在JITCompilationStarted中重寫 IL 時,您不能精確控制何時對某個方法應用更改。
  4. 某些情況下,執行時可能選擇跳過JIT編譯過程,例如對於 NGEN(Native Image Generator,俗稱AOT編譯)生成的本地映像,此時無法捕獲到JITCompilationStarted事件。
  5. 在多執行緒環境下,可能會出現競爭條件,導致一些方法執行的是未更新的程式碼。

但是我們也無法再其它時間進行重寫,因為JIT一般情況下只會編譯一次,JIT 已經完成編譯以後修改方法 IL 不會再次 JIT ,修改也不會生效。在 .NET Framework 4.5 誕生之前,我們並未擁有更為優美的途徑來實現 APM 自動化探測。然而,隨著 .NET Framework 4.5 的降臨,一條全新的路徑終於展現在我們面前。

重新JIT編譯

上文中提到了捕獲JITCompilationStarted事件時進行方法重寫的種種缺點,於是在.NET 4.5中,新增了一個名為RequestReJIT的方法,它允許執行時動態地重新編譯方法。RequestReJIT主要用於效能分析和診斷工具,在程式執行過程中,可以為指定的方法替換新的即時編譯(JIT)程式碼,以便優化效能或修復bug。

RequestReJIT提供了一種強大的機制,使開發人員能夠在不重啟應用程式的情況下熱更新程式碼邏輯。這在分析、監視及優化應用程式效能方面非常有用。它可以在程式執行時動態地替換指定方法的 JIT 程式碼,而無需關心方法是否已經被編譯過。RequestReJIT減輕了多執行緒環境下的競爭風險,並且可以處理 NGEN 映像中的方法。通過提供這個強大的機制,RequestReJIT使得效能分析和診斷工具能夠更有效地優化應用程式效能及修復bug。

使用RequestReJIT重寫方法IL的流程如下:

  1. Profiler 初始化:當.NET應用程式啟動時,分析器(profiler)會利用Profiler API向CLR(Common Language Runtime)註冊。這允許分析器在整個應用程式生命週期內監聽和操縱程式碼執行流程。
  2. 確定要修改的方法:分析器需要識別哪些方法需要進行修改。這通常是通過分析方法後設資料(如方法名稱、引數型別和返回值型別等)來判斷的。
  3. 為目標方法替換 IL 程式碼:首先,分析器獲取目標方法的原始 IL 程式碼,並在適當位置插入新的跟蹤邏輯。接著,使用 SetILFunctionBody 方法將修改後的 IL 程式碼設定為目標方法的新 IL 程式碼。
  4. 請求重新 JIT 編譯:使用RequestReJIT方法通知 CLR 重新編譯目標方法。此時,CLR 會觸發ReJITCompilationStarted事件。
  5. 捕獲ReJITCompilationStarted事件:分析器訂閱ReJITCompilationStarted事件,在事件回撥中獲取到修改後的 IL 程式碼,訂閱結束事件,分析器可以獲取本次重新編譯是否成功。
  6. 生成新的原生程式碼:CLR 會根據修改後的 IL 程式碼重新進行 JIT 編譯,生成新的原生程式碼。這樣,新的 JIT 程式碼便包含了插入的跟蹤邏輯。
  7. 執行新的原生程式碼:之後,當目標方法被呼叫時,將執行新生成的原生程式碼。這意味著插入的跟蹤邏輯會在應用程式執行期間起作用,從而收集效能資料和診斷資訊。

有了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 無侵入探針,並將深入探討相關實現細節以及如何在實際場景中應用這些方法。

參考文獻

.NET探查器檔案

深入Java自動化探針技術的原理和實踐

作者介紹

InCerry,微軟最有價值專家,現就職於同程旅行

.NET效能優化交流群

相信大家在開發中經常會遇到一些效能問題,苦於沒有有效的工具去發現效能瓶頸,或者是發現瓶頸以後不知道該如何優化。之前一直有讀者朋友詢問有沒有技術交流群,但是由於各種原因一直都沒建立,現在很高興的在這裡宣佈,我建立了一個專門交流.NET效能優化經驗的群組,主題包括但不限於:

  • 如何找到.NET效能瓶頸,如使用APM、dotnet tools等工具
  • .NET框架底層原理的實現,如垃圾回收器、JIT等等
  • 如何編寫高效能的.NET程式碼,哪些地方存在效能陷阱

希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET效能問題和寶貴的效能分析優化經驗。目前一群已滿,現在開放二群。
如果提示已經達到200人,可以加我微信,我拉你進群: ls1075
另外也建立了QQ群,群號: 687779078,歡迎大家加入。