Làm chủ C# Source Generators trong .NET: Sinh mã tại thời điểm biên dịch để loại bỏ Reflection và tối ưu hiệu năng

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Bối cảnh: Cứu vãn sự cố production lúc 2 giờ sáng do Reflection gây ra

Vào lúc 2:14 sáng ngày Cyber Monday, dịch vụ thanh toán của chúng tôi sập hoàn toàn. Lượng traffic tăng vọt lên 18.500 requests/giây trải rộng trên 32 Kubernetes pod. Mức sử dụng CPU chạm ngưỡng 100%, và p99 latency tăng vọt từ 24 mili-giây lên 9,2 giây. Các pod bắt đầu fail liveness probe, kích hoạt chuỗi lặp khởi động lại liên hoàn.

Chúng tôi trích xuất một bản memory dump từ một pod còn sống sót bằng dotnet-dump. Nguyên nhân gốc rễ lập tức lộ rõ: pipeline logging của chúng tôi gọi typeof(T).GetProperties() và PropertyInfo.GetValue() trên từng sự kiện checkout một.

Dưới mức traffic thông thường khoảng 1.200 req/giây, runtime xử lý chi phí reflection đó một cách dễ dàng. Nhưng dưới khối lượng tải đỉnh điểm, việc kiểm tra metadata của type 37.000 lần mỗi giây đã làm cạn kiệt .NET thread pool. Tệ hơn nữa, việc truyền các value type như decimal Amount và DateTime TimestampUtc vào GetValue() đã tạo ra 2,4 GB cấp phát boxing mỗi phút, ép garbage collector liên tục dừng hệ thống để dọn dẹp Gen 1 và Gen 2.

Chúng tôi đã triển khai một hotfix bằng cách cache kết quả tra cứu reflection, nhưng vấn đề cốt lõi vẫn còn đó. Runtime reflection luôn đòi hỏi một khoản chi phí cố định: chi phí boxing, bỏ lỡ các tối ưu hóa inlining, và việc liên tục tra cứu kiểu dữ liệu trong lúc chạy trên từng request.

Roslyn Incremental Source Generators loại bỏ hoàn toàn gánh nặng đó. Ra mắt lần đầu trong C# 9 và được hoàn thiện qua các phiên bản từ .NET 6 đến .NET 9, source generators chạy trực tiếp bên trong trình biên dịch. Thay vì phân tích kiểu dữ liệu động trên production server, compiler sẽ quét qua syntax tree của bạn trong quá trình dotnet build và sinh ra mã C# strongly typed trực tiếp vào pipeline biên dịch. Bạn sẽ không tốn bất kỳ chi phí runtime nào, không còn boxing, và hoàn toàn an toàn về mặt kiểu dữ liệu (type safety).

Thiết lập Project Incremental Generator

Source generators thực thi bên trong tiến trình của trình biên dịch Roslyn trong quá trình build và khi bạn gõ code trên IDE. Để đảm bảo tính tương thích giữa máy của các lập trình viên khác nhau, Visual Studio và Rider, project source generator bắt buộc phải target netstandard2.0.

Chạy các lệnh sau để khởi tạo một solution mới bao gồm một console app sử dụng generator và chính project generator:

# Khởi tạo solution và cấu trúc project
mkdir SourceGenDemo && cd SourceGenDemo
dotnet new sln -n SourceGenDemo

# Tạo console app .NET 8 dùng để tiêu thụ generator
dotnet new console -n OrderApp -f net8.0
dotnet sln add OrderApp/OrderApp.csproj

# Tạo thư viện generator target netstandard2.0
dotnet new classlib -n FastMapper.Generator -f netstandard2.0
dotnet sln add FastMapper.Generator/FastMapper.Generator.csproj

Bây giờ hãy cài đặt các package của trình biên dịch Roslyn vào project generator:

cd FastMapper.Generator
dotnet add package Microsoft.CodeAnalysis.CSharp --version 4.8.0 --no-restore
dotnet add package Microsoft.CodeAnalysis.Analyzers --version 3.3.4 --no-restore
cd ..

Cấu hình Analyzer và Xuất mã được sinh

Một assembly source generator không phải là runtime dependency. Nếu bạn reference nó như một DLL thông thường, ứng dụng sẽ tìm nó lúc runtime và gặp lỗi. Thay vào đó, hãy cấu hình project reference dưới dạng compile-time analyzer.

1. Cấu hình file Project tiêu thụ

Chỉnh sửa OrderApp/OrderApp.csproj để báo cho hệ thống build xử lý generator như một analyzer, đồng thời cấu hình thư mục output để bạn có thể kiểm tra các file C# được sinh ra trên ổ đĩa:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <!-- Lưu các file mã nguồn được sinh ra vào ổ đĩa để kiểm tra -->
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
    <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\FastMapper.Generator\FastMapper.Generator.csproj"
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false" />
  </ItemGroup>
</Project>

2. Cấu hình file Project Generator

Mở FastMapper.Generator/FastMapper.Generator.csproj và kích hoạt các thành phần Roslyn analyzer:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <IsRoslynComponent>true</IsRoslynComponent>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
  </PropertyGroup>
</Project>

3. Viết Incremental Source Generator

Chúng ta cần một bộ định dạng chuỗi không cấp phát bộ nhớ (allocation-free) phục vụ cho log chẩn đoán trên các model. Generator sẽ tạo ra một marker attribute ([GenerateToString]) và tổng hợp một extension method có tên ToFastString() để truy xuất trực tiếp các thuộc tính mà không cần reflection.

Tạo file FastMapper.Generator/ToStringGenerator.cs:

using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace FastMapper.Generator
{
    [Generator]
    public class ToStringGenerator : IIncrementalGenerator
    {
        private const string AttributeSource = @"// <auto-generated />
namespace FastMapper.Attributes
{
    [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct, Inherited = false, AllowMultiple = false)]
    public sealed class GenerateToStringAttribute : System.Attribute
    {
    }
}";

        public void Initialize(IncrementalGeneratorInitializationContext context)
        {
            // Bước 1: Inject marker attribute vào quá trình biên dịch của người dùng
            context.RegisterPostInitializationOutput(ctx => ctx.AddSource(
                "GenerateToStringAttribute.g.cs",
                SourceText.From(AttributeSource, Encoding.UTF8)));

            // Bước 2: Lọc nhanh cú pháp cây để tìm các class có gắn attribute
            IncrementalValuesProvider<ClassDeclarationSyntax> classDeclarations = context.SyntaxProvider
                .CreateSyntaxProvider(
                    predicate: static (node, _) => node is ClassDeclarationSyntax c && c.AttributeLists.Count > 0,
                    transform: static (ctx, _) => (ClassDeclarationSyntax)ctx.Node)
                .Where(static c => c != null);

            // Bước 3: Kết hợp syntax node với semantic model compilation
            IncrementalValueProvider<(Compilation, ImmutableArray<ClassDeclarationSyntax>)> compilationAndClasses =
                context.CompilationProvider.Combine(classDeclarations.Collect());

            // Bước 4: Xuất mã nguồn được sinh ra
            context.RegisterSourceOutput(compilationAndClasses, static (spc, source) => Execute(source.Item1, source.Item2, spc));
        }

        private static void Execute(Compilation compilation, ImmutableArray<ClassDeclarationSyntax> classes, SourceProductionContext context)
        {
            if (classes.IsDefaultOrEmpty) return;

            INamedTypeSymbol? attributeSymbol = compilation.GetTypeByMetadataName("FastMapper.Attributes.GenerateToStringAttribute");
            if (attributeSymbol == null) return;

            foreach (var classSyntax in classes.Distinct())
            {
                SemanticModel model = compilation.GetSemanticModel(classSyntax.SyntaxTree);
                if (model.GetDeclaredSymbol(classSyntax) is not INamedTypeSymbol classSymbol) continue;

                bool hasAttribute = classSymbol.GetAttributes().Any(ad => 
                    SymbolEqualityComparer.Default.Equals(ad.AttributeClass, attributeSymbol));
                
                if (!hasAttribute) continue;

                string namespaceName = classSymbol.ContainingNamespace.IsGlobalNamespace 
                    ? string.Empty 
                    : classSymbol.ContainingNamespace.ToDisplayString();
                string className = classSymbol.Name;

                var sb = new StringBuilder();
                sb.AppendLine("// <auto-generated />");
                sb.AppendLine("#nullable enable");
                if (!string.IsNullOrEmpty(namespaceName))
                {
                    sb.AppendLine($"namespace {namespaceName}");
                    sb.AppendLine("{");
                }
                sb.AppendLine($"    public static class {className}GeneratedExtensions");
                sb.AppendLine("    {");
                sb.AppendLine($"        public static string ToFastString(this {className}? instance)");
                sb.AppendLine("        {");
                sb.AppendLine("            if (instance is null) return \"null\";");
                sb.Append("            return $\"");
                sb.Append(className);
                sb.Append(" {{ ");

                var properties = classSymbol.GetMembers()
                    .OfType<IPropertySymbol>()
                    .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic && p.GetMethod != null)
                    .ToList();

                for (int i = 0; i < properties.Count; i++)
                {
                    var prop = properties[i];
                    sb.Append($"{prop.Name} = {{{{instance.{prop.Name}}}}}");
                    if (i < properties.Count - 1) sb.Append(", ");
                }

                sb.AppendLine(" }\";");
                sb.AppendLine("        }");
                sb.AppendLine("    }");
                if (!string.IsNullOrEmpty(namespaceName))
                {
                    sb.AppendLine("}");
                }

                context.AddSource($"{className}_GeneratedToString.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
            }
        }
    }
}

Đo kiểm hiệu năng (Benchmark) và Kiểm chứng

Bây giờ hãy gắn marker attribute vào một model class trong OrderApp/Program.cs và thực thi method vừa được sinh ra:

using System;
using FastMapper.Attributes;

namespace OrderApp
{
    [GenerateToString]
    public class OrderRecord
    {
        public int Id { get; set; }
        public string CustomerSku { get; set; } = string.Empty;
        public decimal Amount { get; set; }
        public DateTime TimestampUtc { get; set; }
    }

    internal class Program
    {
        static void Main()
        {
            var order = new OrderRecord
            {
                Id = 100249,
                CustomerSku = "SRV-ENTERPRISE-01",
                Amount = 1499.50m,
                TimestampUtc = DateTime.UtcNow
            };

            // Được phân giải tại thời điểm biên dịch: nối chuỗi bằng pure IL hoàn toàn
            string logOutput = order.ToFastString();
            Console.WriteLine(logOutput);
        }
    }
}

1. Kiểm tra các Artifact được sinh ra

Kích hoạt quá trình build từ terminal của bạn:

dotnet build

Roslyn sẽ tạo file mã nguồn tại đường dẫn OrderApp/Generated/FastMapper.Generator/FastMapper.Generator.ToStringGenerator/OrderRecord_GeneratedToString.g.cs. Mở file ra sẽ thấy code C# chuẩn mực và tự nhiên:

// <auto-generated />
#nullable enable
namespace OrderApp
{
    public static class OrderRecordGeneratedExtensions
    {
        public static string ToFastString(this OrderRecord? instance)
        {
            if (instance is null) return "null";
            return $"OrderRecord {{ Id = {instance.Id}, CustomerSku = {instance.CustomerSku}, Amount = {instance.Amount}, TimestampUtc = {instance.TimestampUtc} }}";
        }
    }
}

2. Kết quả Benchmark: Reflection so với Source Generator

Chúng tôi đã đo kiểm cả hai cách tiếp cận bằng BenchmarkDotNet trên vi xử lý Intel Xeon 8375C chạy .NET 8.0.8:

[MemoryDiagnoser]
public class SerializerBenchmark
{
    private static readonly OrderRecord Order = new()
    {
        Id = 100249,
        CustomerSku = "SRV-ENTERPRISE-01",
        Amount = 1499.50m,
        TimestampUtc = DateTime.UtcNow
    };

    // Reflection với bộ nhớ đệm PropertyInfo tại runtime
    private static readonly PropertyInfo[] CachedProperties = typeof(OrderRecord).GetProperties();

    [Benchmark(Baseline = true)]
    public string CachedReflection()
    {
        var sb = new StringBuilder("OrderRecord { ");
        for (int i = 0; i < CachedProperties.Length; i++)
        {
            var p = CachedProperties[i];
            sb.Append(p.Name).Append(" = ").Append(p.GetValue(Order));
            if (i < CachedProperties.Length - 1) sb.Append(", ");
        }
        sb.Append(" }");
        return sb.ToString();
    }

    [Benchmark]
    public string SourceGenerator()
    {
        return Order.ToFastString();
    }
}

Kết quả benchmark cho thấy khoảng cách hiệu năng rõ rệt:

| Method           | Mean      | Ratio  | Gen0   | Allocated |
|----------------- |----------:|-------:|-------:|----------:|
| CachedReflection | 462.10 ns |   1.00 | 0.0896 |     568 B |
| SourceGenerator  |  14.28 ns |   0.03 | 0.0153 |      96 B |

Ngay cả khi metadata của property đã được cache, reflection vẫn mất 462 ns và tiêu tốn 568 bytes bộ nhớ do phải boxing các kiểu int, decimal và DateTime. Trong khi đó, source generator chỉ chạy mất 14,2 ns và chỉ cấp phát 96 bytes—chính là dung lượng của chuỗi kết quả trả về. Đó là mức tăng tốc gấp 32 lần và giảm đến 83% lượng cấp phát trên heap.

3. Các chỉ số Production cần theo dõi

Sau khi triển khai generator lên production, hãy theo dõi ba chỉ số telemetry quan trọng trên Datadog, Prometheus hoặc Application Insights:

  • % Time in GC: Theo dõi chỉ số này giảm xuống dưới 3%. Việc loại bỏ boxing từ reflection sẽ ngăn các value type ngắn hạn làm tràn heap Gen 0.
  • ThreadPool Queued Tasks: Theo dõi ThreadPool.PendingWorkItemCount trong các đợt tăng đột biến tải. Khi serialization bằng reflection đồng bộ được gỡ bỏ, worker thread sẽ dequeue các tác vụ mà không bị nghẽn (blocking).
  • Tail Latency (p99): Lượng request tăng đột biến sẽ không còn gây ra các hiện tượng dị thường kéo dài nhiều giây ở đuôi phân phối (tail anomalies) nữa vì runtime không còn phải tạm dừng để phân tích kiểu dữ liệu.

Việc chuyển việc tổng hợp code sang trình biên dịch Roslyn sẽ thay thế cơ chế duyệt kiểu dữ liệu runtime mỏng manh bằng mã C# strongly typed, nhanh chóng và có thể kiểm chứng ngay tức thì.

Share: