Tạo .NET CLR Profiler bằng C# và NativeAOT với thư viện Silhouette

Trong bài viết này, tôi sẽ dùng thử thư viện Silhouette của Kevin Gosse để xem việc xây dựng một .NET CLR profiler cơ bản dễ dàng như thế nào. Và khi tôi nói cơ bản, ý tôi là thực sự cơ bản—trong bài viết này, chúng ta chỉ đơn giản là ghi log khi một assembly được load và thế là xong. Tôi chủ yếu muốn xem việc bắt đầu sử dụng thư viện này dễ dàng đến mức nào.

Kevin đã có loạt bài gồm 5 phần về viết .NET profiler bằng C#, trong đó anh ấy mô tả thư viện Silhouette của mình, cùng với các bài viết tiếp theo sử dụng thư viện này để thực hiện các công việc thực tế, như đo lường độ phản hồi UI trong Resharpersử dụng function hooks với profiler. Bài viết này sẽ đơn giản hơn nhiều so với những bài đó, chỉ là một minh họa về cách thư viện hoạt động.

Tôi cũng sẽ không đi sâu vào chi tiết về các profiling APIs. Kevin đã đề cập một số nội dung này trong các bài viết trên, hoặc bạn có thể xem loạt bài của Christophe Nasarre về profiling APIs. Trong bài viết này, chúng ta thực sự không đi sâu như vậy, chúng ta chỉ thử nghiệm một chút và xem những gì có thể làm được!

Trước khi bắt đầu, chúng ta hãy điểm lại: .NET profiling APIs là gì?

.NET Profiling APIs là gì?

Đối với 99% mọi người, làm việc với .NET có nghĩa là ở trong một managed runtime tốt đẹp và không bao giờ phải lo lắng về native code, ngoại trừ thỉnh thoảng sử dụng P/Invoke. Tuy nhiên, đằng sau hậu trường, các thư viện lớp cơ sở của .NET thường tương tác với các thư viện native, và bản thân .NET runtime là một ứng dụng native. Hơn nữa, cả .NET Core và .NET Framework đều cung cấp một bộ unmanaged APIs mà bạn có thể gọi từ native code.

.NET Core ghi nhận ba loại unmanaged APIs chính:

  • Debugging APIs — để debug code chạy trong môi trường common language runtime (CLR).
  • Metadata APIs — để đọc hoặc tạo thông tin chi tiết về các module và type mà không cần load chúng trong CLR.
  • Profiling APIs — để giám sát việc thực thi chương trình bởi CLR.

Trong bài viết này, chúng ta chủ yếu xem xét loại cuối cùng, profiling APIs, và sẽ sử dụng metadata APIs như một công cụ hỗ trợ.

Khi bạn nghĩ về “profiling”, có lẽ bạn nghĩ đến các công cụ profiling được tích hợp trong Visual Studio, dotTrace của JetBrains, hoặc xem dấu vết dotnet-trace trong PerfView. Các profiler “truyền thống” này có thể sử dụng profiling APIs cho chức năng của chúng (mặc dù cũng có các cách tiếp cận khác), nhưng profiling APIs thực sự tổng quát hơn nhiều. Ví dụ, chúng tôi sử dụng chúng trong thư viện client .NET của Datadog để viết lại các phương thức nhằm thêm instrumentation.

Profiling APIs rất mạnh mẽ, nhưng khó khăn thực sự là chúng là các unmanaged APIs, điều này thường có nghĩa là phải viết code C/C++ để sử dụng chúng. Chán thật. Vâng, đó là lúc Silhouette xuất hiện.

Ai cần C khi bạn có NativeAOT?

Microsoft đã phát triển NativeAOT qua nhiều phiên bản, và với mỗi phiên bản .NET mới, nó ngày càng tốt hơn. Với NativeAOT, bạn có thể biên dịch ứng dụng .NET của mình thành một tệp nhị phân native, độc lập. Và một tệp nhị phân native độc lập là tất cả những gì bạn cần để viết một .NET profiler!

Điểm mấu chốt của một tệp nhị phân NativeAOT là nó hoàn toàn độc lập. Điều đó có nghĩa là khi tệp nhị phân profiling của bạn được load, nó đang chạy một runtime .NET hoàn toàn riêng biệt so với ứng dụng đang được profile. Đúng vậy, về mặt kỹ thuật, điều đó có nghĩa là có hai .NET runtime được load trong cùng một tiến trình!

Tất nhiên, chỉ biên dịch .NET dưới dạng tệp nhị phân native thôi chưa đủ. Bạn cũng cần đảm bảo rằng tệp nhị phân native của bạn hiển thị tất cả các entrypoint và interface chính xác để .NET runtime có thể load thư viện của bạn như thể nó được xây dựng bằng C++. Trích dẫn từ bài viết của Kevin về Silhouette:

Tóm lại, chúng ta cần hiển thị một phương thức DllGetClassObject sẽ trả về một instance của IClassFactory. .NET runtime sẽ gọi phương thức CreateInstance trên class factory, phương thức này sẽ trả về một instance của ICorProfilerCallback (hoặc ICorProfilerCallback2, ICorProfilerCallback3, …, tùy thuộc vào phiên bản profiling API mà chúng ta muốn hỗ trợ). Cuối cùng nhưng không kém phần quan trọng, runtime sẽ gọi phương thức Initialize trên instance đó với tham số IUnknown mà chúng ta có thể sử dụng để lấy một instance của ICorProfilerInfo (hoặc ICorProfilerInfo2, ICorProfilerInfo3, …) mà chúng ta cần để truy vấn profiling API.

Nếu đoạn trên lướt qua đầu bạn, điều đó là bình thường 😅 Đây là những API mà rất ít kỹ sư .NET cần phải làm việc, và vì chúng là các API C++, điều đó thậm chí còn tệ hơn! Nhưng đó chính là vấn đề; với Native AOT và thư viện Silhouette, bạn không cần phải hiểu tất cả các tương tác này. Thư viện Silhouette xử lý công việc phức tạp về thiết lập entrypoint của bạn và hiển thị các type .NET dưới dạng interface C++.

Tất nhiên, Silhouette không giúp bạn thoát hoàn toàn—bạn vẫn cần biết các unmanaged APIs dùng để làm gì, cách sử dụng chúng và cách kết nối chúng lại với nhau. Nhưng Silhouette giúp việc bắt đầu dễ dàng hơn và cho phép bạn viết logic bằng C#, ngôn ngữ bạn biết rõ nhất, thay vì phải vật lộn với C++.

Viết .NET Profiler bằng C#

Như một ví dụ về việc Silhouette giúp việc bắt đầu dễ dàng như thế nào, trong phần còn lại của bài viết, chúng ta sẽ viết một profiler đơn giản sử dụng .NET chỉ để in ra các assembly được load ra console.

Tạo profiler và dự án kiểm thử

Chúng ta sẽ bắt đầu bằng cách tạo một solution đơn giản. Solution này sẽ bao gồm hai dự án: một thư viện class là profiler của chúng ta, và một ứng dụng “hello world” để kiểm thử:

# Tạo hai dự án
dotnet new classlib -o SilhouetteProf
dotnet new console -o TestApp

# Thêm các dự án vào tệp sln
dotnet new sln
dotnet sln add .\SilhouetteProf\
dotnet sln add .\TestApp\

Điều này tạo ra cấu trúc dự án cơ bản của chúng ta. Bây giờ chúng ta sẽ thêm thư viện Silhouette vào dự án profiler:

dotnet add package Silhouette --project SilhouetteProf

Tiếp theo, chúng ta cần đảm bảo rằng chúng ta publish ứng dụng bằng NativeAOT và cho phép code unsafe. Chúng ta thực sự sẽ không viết bất kỳ code unsafe nào trong bài kiểm thử này, nhưng Silhouette bao gồm một source generator có sử dụng unsafe.

Mở tệp SilhoutteProj.csproj và thêm hai thuộc tính:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <RootNamespace>SilhouetteProf</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>

    <!-- 👇 Thêm hai dòng này  -->
    <PublishAot>true</PublishAot>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Silhouette" Version="3.2.0" />
  </ItemGroup>

</Project>

Bây giờ chúng ta đã có các điều kiện tiên quyết, chúng ta có thể bắt đầu tạo profiler của mình.

Tạo profiler cơ bản

Để tạo một .NET profiler với Silhouette, bạn tạo một class kế thừa từ CorProfilerCallbackBase (hoặc CorProfilerCallback2Base, CorProfilerCallback3Base, v.v., tùy thuộc vào chức năng bạn cần). Sau đó, bạn trang trí class này với thuộc tính [Profiler] và cung cấp một Guid duy nhất:

using Silhouette;

namespace SilhouetteProf;

// 👇 Sử dụng một Guid ngẫu nhiên mới, đừng dùng Guid này!
[Profiler("9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6")]
internal partial class MyCorProfilerCallback : CorProfilerCallback5Base
{
}

Trong ví dụ trên, tôi đã chọn một Guid ngẫu nhiên cho profiler của mình và kế thừa từ CorProfilerCallback5Base. Không có gì trong bài viết này thực sự cần đến “5” từ ICorProfilerInfo5, tôi chỉ đưa nó vào để minh họa mẫu.

Thuộc tính [Profiler] ở trên điều khiển một source generator được bao gồm trong Silhouette, nó tạo ra các boilerplate cần thiết cho .NET runtime để tạo IClassFactory. Bạn không phải sử dụng code được tạo này (ví dụ, nếu bạn cần logic bổ sung trong phương thức DllGetClassObject của mình); nếu bạn không muốn code này, chỉ cần bỏ qua thuộc tính [Profiler]. Code được tạo ra trông như thế này:

namespace Silhouette._Generated
{
    using System;
    using System.Runtime.InteropServices;

    file static class DllMain
    {
        [UnmanagedCallersOnly(EntryPoint = "DllGetClassObject")]
        public static unsafe HResult DllGetClassObject(Guid* rclsid, Guid* riid, nint* ppv)
        {
            if (*rclsid != new Guid("9fd62131-bf21-47c1-a4d4-3aef5d7c75c6"))
            {
                return HResult.CORPROF_E_PROFILER_CANCEL_ACTIVATION;
            }

            *ppv = ClassFactory.For(new global::SilhouetteProf.MyCorProfilerCallback());
            return HResult.S_OK;
        }
    }
}

Chúng ta đã có khung của profiler, nhưng trước khi có thể biên dịch, chúng ta cần triển khai phương thức Initialize:

using Silhouette;

namespace SilhouetteProf;

[Profiler("9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6")]
internal partial class MyCorProfilerCallback : CorProfilerCallback5Base
{
    protected override HResult Initialize(int iCorProfilerInfoVersion)
    {
        Console.WriteLine("[SilhouetteProf] Initialize");
        if (iCorProfilerInfoVersion < 5)
        {
            // chúng ta cần ít nhất ICorProfilerInfo5 và chúng ta có < 5
            return HResult.E_FAIL;
        }

        // Gọi SetEventMask để báo cho .NET runtime biết sự kiện nào chúng ta quan tâm
        return ICorProfilerInfo5.SetEventMask(COR_PRF_MONITOR.COR_PRF_MONITOR_ALL);
    }
}

Đoạn code trên là phiên bản đơn giản nhất của phương thức Initialize mà chúng ta có thể viết. Silhouette xử lý việc xác định phiên bản nào của ICorProfilerInfo có sẵn và truyền nó dưới dạng int cho phương thức. Trong code trên, chúng ta đảm bảo rằng chúng ta có ít nhất ICorProfilerInfo5 để có thể gọi bất kỳ phương thức nào được hiển thị bởi ICorProfilerInfo5, ICorProfilerInfo4, ICorProfilerInfo3, v.v.

Bạn sẽ nhận thấy nhiều giá trị HResult được sử dụng làm giá trị trả về. Trả về mã lỗi là cách xử lý lỗi chủ yếu với các native API, vì vậy bạn sẽ phải làm việc với chúng rất nhiều. May mắn thay, Silhouette hiển thị điều này dưới dạng một enum tiện lợi để bạn sử dụng.

Sau khi đã xác nhận rằng .NET runtime hiện tại hỗ trợ các tính năng chúng ta cần, chúng ta cần báo cho runtime biết sự kiện nào chúng ta quan tâm bằng enum COR_PRF_MONITOR và các phương thức SetEventMask() hoặc SetEventMask2(). Để đơn giản, tôi đã sử dụng ICorProfilerInfo5.SetEventMask() và chỉ bật tất cả các tính năng.

Trường ICorProfilerInfo5 được khởi tạo trước khi Initialize được gọi, dựa trên phiên bản interface có sẵn. Ví dụ, nếu iCorProfilerInfoVersion7, thì tất cả các trường ICorProfilerInfo* lên đến ICorProfilerInfo7 sẽ được khởi tạo. Điều rất quan trọng là bạn chỉ gọi các phiên bản interface đã được khởi tạo. Vì vậy, nếu iCorProfilerInfoVersion7, đừng gọi ICorProfilerInfo8 hoặc cao hơn!

Tại thời điểm này, chúng ta có thể kiểm thử profiler của mình, nhưng tôi sẽ tiếp tục triển khai thêm một chút trước khi chúng ta tiến hành kiểm thử.

Thêm chức năng cho profiler

Phản hồi các sự kiện với Silhouette profiler dễ dàng như ghi đè một phương thức trong class cơ sở. Ví dụ, chúng ta có thể ghi đè phương thức Shutdown, được gọi khi runtime tắt:

protected override HResult Shutdown()
{
    Console.WriteLine("[SilhouetteProf] Shutdown");
    return HResult.S_OK;
}

Để thêm một chút thú vị, chúng ta sẽ ghi đè phương thức AssemblyLoadFinished, được gọi khi một assembly đã load xong:

protected override HResult AssemblyLoadFinished(AssemblyId assemblyId, HResult hrStatus)
{
    // ...
}

Phương thức AssemblyLoadFinished cung cấp một AssemblyId mà chúng ta có thể sử dụng để lấy tên của assembly bằng cách gọi một phương thức khác trên ICorProfilerInfo5, GetAssemblyInfo(AssemblyId).

Kiểu AssemblyId là một wrapper rất mỏng xung quanh IntPtr, hoạt động như các wrapper được định kiểu mạnh xung quanh các IntPtr phổ biến được sử dụng trong profiling APIs. Tôi rất thích cách tiếp cận này vì nó loại bỏ toàn bộ một lớp lỗi mà bạn có thể mắc phải khi truyền IntPtr “Class ID” cho một phương thức mong đợi IntPtr “Assembly ID” (ví dụ).

Chúng ta có thể gọi GetAssemblyInfo() khá dễ dàng bằng cách sử dụng trường ICorProfilerInfo5, nhưng đây là cơ hội tốt để xem xét một mẫu phổ biến trong thư viện Silhouette, việc sử dụng HResult<T>:

protected override HResult AssemblyLoadFinished(AssemblyId assemblyId, HResult hrStatus)
{
     HResult<AssemblyInfoWithName> assemblyInfo = ICorProfilerInfo5.GetAssemblyInfo(assemblyId)
    // ...
}

HResult<T> thực chất là một result pattern đơn giản dưới dạng discriminated union. Nó chứa cả HResult và, nếu HResult biểu thị thành công, một đối tượng T. Cách tiếp cận này là một cách để tránh mẫu phổ biến trong profiling APIs có nhiều tham số “out” và một HRESULT để chỉ ra liệu có hợp lệ để sử dụng các giá trị đó hay không, ví dụ:

HRESULT GetAssemblyInfo(  
    [in]  AssemblyID  assemblyId,  
    [in]  ULONG       cchName,  
    [out] ULONG       *pcchName,  
    [out, size_is(cchName), length_is(*pcchName)]  
          WCHAR       szName[] ,  
    [out] AppDomainID *pAppDomainId,  
    [out] ModuleID    *pModuleId);

Mẫu điển hình khi làm việc trực tiếp với profiling APIs là thực hiện cuộc gọi, kiểm tra giá trị trả về và sau đó quyết định có tiếp tục hay không. HResult<T> cũng cho phép mẫu đó, nhưng bạn cũng có thể đi theo phong cách YOLO. Thay vì tự thực hiện tất cả các kiểm tra, bạn có thể gọi HResult<T>.ThrowIfFailed(), nó trả về T nếu cuộc gọi thành công và ném ra Win32Exception nếu không. Điều này có thể làm cho code trở nên đơn giản hơn nhiều để đọc và viết, vì vậy đó là một lợi thế thực sự.

Tất nhiên, liệu bạn có muốn làm điều này với một profiler chất lượng sản xuất hay không lại là một câu chuyện hoàn toàn khác. Nhưng liệu bạn có thực sự nên sử dụng bất cứ điều gì từ bài viết này cho sản xuất không? Có lẽ là không 😉

Sử dụng cách tiếp cận ThrowIfFailed() cho chúng ta đoạn code dưới đây. Chúng ta cố gắng lấy tên assembly và nếu có sẵn, in nó ra:

protected override HResult AssemblyLoadFinished(AssemblyId assemblyId, HResult hrStatus)
{
    try
    {
        // Cố gắng lấy AssemblyInfoWithName, và nếu HResult trả về không thành công, hãy ném ngoại lệ
        AssemblyInfoWithName assemblyInfo = ICorProfilerInfo5.GetAssemblyInfo(assemblyId).ThrowIfFailed();

        Console.WriteLine($"[SilhouetteProf] AssemblyLoadFinished: {assemblyInfo.AssemblyName}");
        return HResult.S_OK;
    }
    catch (Win32Exception ex)
    {
        // GetAssemblyInfo() thất bại vì lý do nào đó, kỳ lạ.
        Console.WriteLine($"[SilhouetteProf] AssemblyLoadFinished failed: {ex}");
        return ex.NativeErrorCode;
    }
}

Lợi ích của ThrowIfFailed() không thực sự rõ ràng nếu bạn chỉ có một cuộc gọi duy nhất. Nó thực sự tỏa sáng khi bạn muốn xâu chuỗi nhiều cuộc gọi. Ví dụ, nếu chúng ta muốn triển khai ClassLoadStarted, chúng ta cần xâu chuỗi nhiều cuộc gọi và đó là lúc ThrowIfFailed() phát huy tác dụng:

protected override HResult ClassLoadStarted(ClassId classId)
{
    try
    {
        ClassIdInfo classIdInfo = ICorProfilerInfo.GetClassIdInfo(classId).ThrowIfFailed();

        using ComPtr<IMetaDataImport>? metaDataImport = ICorProfilerInfo2
                                                            .GetModuleMetaDataImport(classIdInfo.ModuleId, CorOpenFlags.ofRead)
                                                            .ThrowIfFailed()
                                                            .Wrap();
        TypeDefPropsWithName classProps = metaDataImport.Value.GetTypeDefProps(classIdInfo.TypeDef).ThrowIfFailed();

        Console.WriteLine($"[SilhouetteProf] ClassLoadStarted: {classProps.TypeName}");
        return HResult.S_OK;
    }
    catch (Win32Exception ex)
    {
        Console.WriteLine($"[SilhouetteProf] ClassLoadStarted failed: {ex}");
        return ex.NativeErrorCode;
    }
}

Chúng ta có ba cuộc gọi ThrowIfFailed() trong code trên, giúp duy trì luồng xử lý mượt mà, tuần tự. Thay vào đó, chúng ta có thể thêm ba câu lệnh if (result != HResult.S_OK) trong code, nhưng điều đó khó theo dõi hơn, đặc biệt nếu bạn đang viết một cái gì đó tương tự hoặc chỉ đang tạo nguyên mẫu.

OK, bây giờ chúng ta đã có đủ chức năng để chạy thử profiler của mình!

Kiểm thử profiler mới

Để kiểm thử profiler của chúng ta, chúng ta cần làm ba việc:

  • Publish ứng dụng kiểm thử.
  • Publish profiler.
  • Thiết lập các biến môi trường profiling cần thiết.

Publish ứng dụng kiểm thử

Chúng ta sẽ bắt đầu bằng cách publish ứng dụng kiểm thử. Về mặt kỹ thuật, điều này không bắt buộc, chúng ta có thể chạy ứng dụng bằng dotnet run chẳng hạn. Khó khăn là điều này sẽ gọi .NET SDK, mà bản thân nó là một ứng dụng .NET, điều đó có nghĩa là chúng ta sẽ profile cả SDK đó nữa. Điều này cũng ổn, nhưng không phải là những gì chúng ta đang cố gắng làm.

Chúng ta có thể publish ứng dụng hello world bằng một lệnh dotnet publish đơn giản:

❯ dotnet publish .\TestApp\ -c Release 
Restore complete (0.6s)
  TestApp net10.0 succeeded (0.9s) → TestApp\bin\Release\net10.0\publish\

Publish profiler của chúng ta

Publish profiler của chúng ta cũng tương tự, nhưng vì chúng ta sử dụng NativeAOT, chúng ta cũng cần cung cấp một runtime ID. Trong .NET 10, bạn cũng có thể sử dụng tùy chọn --use-current-runtime để publish cho “runtime bạn đang sử dụng”. Như bạn thấy bên dưới, SDK đã sử dụng win-x64 vì tôi đang chạy trên Windows:

❯ dotnet publish .\SilhouetteProf\ -c Release --use-current-runtime
Restore complete (0.6s)
  SilhouetteProf net10.0 win-x64 succeeded (4.2s) → SilhouetteProf\bin\Release\net10.0\win-x64\publish\

Build succeeded in 5.5s

Vì chúng ta sử dụng NativeAOT, kết quả là một tệp dll duy nhất, độc lập (cộng với các symbol debug riêng biệt). Đây là ứng dụng .NET của chúng ta, được biên dịch dưới dạng một NativeAOT .NET profiler!

Thiết lập các biến môi trường profiling

Để gắn một profiler vào .NET runtime, bạn cần thiết lập một số biến môi trường. Các biến này khác nhau tùy thuộc vào việc bạn đang profile ứng dụng .NET Framework hay .NET Core. Có ba biến khác nhau cần thiết lập:

Để profile ứng dụng .NET Framework:

  • COR_ENABLE_PROFILING=1 — Bật profiling
  • COR_PROFILER={9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6} — Đặt giá trị GUID từ thuộc tính [Profiler].
  • COR_PROFILER_PATH=c:\path\to\profiler — Đường dẫn đến tệp dll profiler

Để profile ứng dụng .NET Core/.NET 5+:

  • CORECLR_ENABLE_PROFILING=1 — Bật profiling
  • CORECLR_PROFILER={9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6} — Đặt giá trị GUID từ thuộc tính [Profiler].
  • CORECLR_PROFILER_PATH=c:\path\to\profiler — Đường dẫn đến tệp dll profiler

các phiên bản dành riêng cho nền tảng của biến đường dẫn mà bạn có thể thiết lập nếu cần hỗ trợ nhiều nền tảng.

Sau khi publish profiler và ứng dụng, tôi đã sao chép đường dẫn tuyệt đối đến tệp dll profiler và thiết lập các biến môi trường cần thiết bằng powershell.

Về mặt kỹ thuật, bạn không phải sử dụng đường dẫn tuyệt đối cho tệp dll, bạn có thể sử dụng đường dẫn tương đối, nhưng đường dẫn đó có liên quan đến ứng dụng đích không? Hay đến thư mục làm việc? Tôi thích sử dụng đường dẫn tuyệt đối vì chúng không có sự mơ hồ!

$env:CORECLR_ENABLE_PROFILING=1
$env:CORECLR_PROFILER="{9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6}"
$env:CORECLR_PROFILER_PATH="D:\repos\temp\silouette-prof\SilhouetteProf\bin\Release\net10.0\win-x64\publish\SilhouetteProf.dll"

Lưu ý rằng biến GUID bao gồm dấu ngoặc nhọn {}. Sau khi các biến được thiết lập, chúng ta có thể chạy thử profiler của mình!

Kiểm thử ứng dụng với NativeAOT profiler

Khi chúng ta chạy ứng dụng, .NET runtime kiểm tra các biến CORECLR_, và load NativeAOT profiler của chúng ta, phát ra các sự kiện khi ứng dụng thực thi. Khi mỗi sự kiện được kích hoạt, chúng ta ghi ra console và chúng ta có thể thấy tất cả các assembly được load khi ứng dụng “Hello World!” chạy!

❯ .\TestApp.exe
[SilhouetteProf] Initialize
[SilhouetteProf] AssemblyLoadFinished: System.Private.CoreLib
[SilhouetteProf] AssemblyLoadFinished: TestApp
[SilhouetteProf] AssemblyLoadFinished: System.Runtime
[SilhouetteProf] AssemblyLoadFinished: System.Console
[SilhouetteProf] AssemblyLoadFinished: System.Threading
[SilhouetteProf] AssemblyLoadFinished: System.Text.Encoding.Extensions
[SilhouetteProf] AssemblyLoadFinished: System.Runtime.InteropServices
Hello, World!
[SilhouetteProf] Shutdown

Và chúng ta đã có nó, .NET profiler của chúng ta, được viết bằng .NET, hoạt động như mong đợi! 🎉 Rõ ràng, đây là một triển khai rất đơn giản, nhưng nó cho tôi thấy việc sử dụng thư viện Silhouette để có được một thứ gì đó hoạt động nhanh hơn nhiều so với việc phải loay hoay với C++ dễ dàng như thế nào.

Một điều cần lưu ý là trong khi Silhouette giúp ích về mặt cơ chế lắng nghe sự kiện và tương tác với các interface C++, bạn vẫn cần biết cách sử dụng các native API. Silhouette giúp giảm độ dốc học tập, nhưng bạn có thể vẫn cần nghiên cứu để đạt được những gì bạn muốn.

Theo quan điểm của tôi, Silhouette rõ ràng là một công cụ hữu ích để đáp ứng một nhu cầu cụ thể. Bạn không nhất thiết muốn sử dụng nó để tạo ra một profiler chất lượng sản xuất, nhưng đối với proof of concept hoặc công việc phát triển, nó dường như là vô giá. Đặc biệt nếu Kevin tiếp tục đăng các ví dụ thực tế về việc tự mình sử dụng Silhouette!

Tổng kết

Trong bài viết này, tôi đã giới thiệu ngắn gọn về unmanaged .NET profiling APIs và cách bạn thường tương tác với các API này bằng C++. Sau đó, tôi mô tả cách bạn có thể sử dụng .NET để tạo ra một tệp nhị phân có thể tương tác với các API này thay vào đó, mang lại tất cả lợi ích của việc làm việc trong .NET, trong khi vẫn có thể gọi các native API.

Tôi sau đó đã giới thiệu thư viện Silhouette của Kevin Gosse và chỉ ra cách thư viện này làm cho việc tạo profiler với NativeAOT trở nên đơn giản, bằng cách kế thừa từ một class cơ sở và ghi đè các phương thức bạn quan tâm. Tôi đã tạo ra một profiler đơn giản, publish nó và sử dụng nó để hiển thị tất cả các assembly được load bởi một ứng dụng console hello world. Nhìn chung, tôi khá ấn tượng với sự đơn giản của Silhouette và có thể sẽ khám phá nó nhiều hơn trong tương lai!

Chỉ mục