Tối ưu Memory & GC trong .NET 10 — Span<T>, ArrayPool, DATAS và Zero-Allocation

Posted on: 4/26/2026 5:15:21 AM

Bạn đã bao giờ thấy ứng dụng .NET của mình ngốn 4GB RAM trên production trong khi chỉ xử lý vài nghìn request/giây? Hay GC pause time đột ngột tăng lên 200ms khiến P99 latency vượt ngưỡng SLA? Nguyên nhân thường không phải memory leak — mà là allocation pattern chưa được tối ưu. .NET 10 mang đến những thay đổi lớn trong Garbage Collection với DATAS, cùng hệ sinh thái zero-allocation API đã trưởng thành, cho phép giảm tới 90% memory allocation trong hot path mà không cần viết unsafe code.

78x Hiệu năng với ArrayPool vs new byte[]
0 B Allocation với Span<T> parsing
75% Giảm Gen2 GC collections
~4ms GC pause time trung bình (.NET 10)

Garbage Collection trong .NET 10 — DATAS thay đổi cuộc chơi

.NET 10 giới thiệu DATAS (Dynamic Adaptation To Application Sizes) — được bật mặc định cho Server GC. Đây không phải một tính năng nhỏ mà là sự thay đổi triết lý hoàn toàn trong cách GC quản lý heap.

DATAS hoạt động như thế nào?

Trước .NET 10, Server GC tạo một heap cho mỗi CPU core. Trên máy 28-core, bạn có 28 heap — mỗi heap có budget riêng, tổng memory footprint có thể lên tới hàng GB ngay cả khi ứng dụng chỉ cần vài trăm MB. DATAS thay đổi hoàn toàn cách tính này: thay vì dựa trên số core, heap size được điều chỉnh động dựa trên Live Data Size (LDS) — lượng data thực sự sống trong heap.

graph LR
    subgraph Before[".NET 9 Server GC"]
        direction TB
        C1["Core 1
Heap: 512MB"] --- C2["Core 2
Heap: 512MB"] C2 --- C3["Core 3
Heap: 512MB"] C3 --- C4["...
Core N"] C4 --- T1["Tổng: N × 512MB"] end subgraph After[".NET 10 DATAS"] direction TB D1["Heap co giãn
theo LDS"] --- D2["Budget tính
bằng công thức"] D2 --- D3["Tự động shrink
khi idle"] D3 --- T2["Tổng: ~LDS × hệ số"] end style Before fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style After fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style T1 fill:#ff9800,stroke:#fff,color:#fff style T2 fill:#4CAF50,stroke:#fff,color:#fff

Hình 1: So sánh mô hình heap sizing giữa Server GC truyền thống và DATAS

DATAS duy trì một mục tiêu gọi là Throughput Cost Percentage (TCP) — mặc định 2%, nghĩa là GC pause time chiếm tối đa 2% tổng execution time. Công thức tính Budget Computed via DATAS (BCD) như sau:

m = (20 - conserve_memory) × 1000 / sqrt(LDS)
m = min(max_m, m)    // max_m mặc định: 10
m = max(min_m, m)    // min_m mặc định: 0.1
BCD = LDS × m

Kết quả thực tế từ Microsoft

Một khách hàng của Microsoft chạy workload production: không có DATAS gen0 budget là 4.22GB với pause time 1.2%. Với DATAS, gen0 budget giảm xuống 1.64GB nhưng pause time tăng lên 2.1%. Sau khi tăng GCDGen0GrowthPercent lên 2.6x, throughput khôi phục về mức cũ trong khi vẫn tiết kiệm đáng kể memory.

Cấu hình DATAS cho Production

DATAS có thể tune qua các tham số trong runtimeconfig.json hoặc environment variables:

Tham sốMặc địnhÝ nghĩa
GCDynamicAdaptationMode1 (bật)Bật/tắt DATAS. Set 0 để tắt
GCDTargetTCP2%Target pause time % — tăng để ưu tiên memory, giảm để ưu tiên throughput
GCDGen0GrowthPercent100Hệ số nhân cho BCD. Tăng 260 = 2.6x budget
GCDGen0GrowthMinFactor0.1Floor cho hệ số m — tăng nếu cần budget tối thiểu lớn hơn
GCHeapCountautoNếu set thủ công, DATAS tự động bị tắt
GCHeapHardLimitkhông setHard cap cho heap size — container nên set theo cgroup limit
{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.Server": true,
      "System.GC.DynamicAdaptationMode": 1,
      "System.GC.HeapHardLimit": 209715200
    }
  }
}

Khi nào KHÔNG dùng DATAS

Bạn nên tắt DATAS (GCDynamicAdaptationMode: 0) trong các trường hợp: (1) Ứng dụng cần startup performance cực nhanh — DATAS bắt đầu với ít heap, gây throughput regression ban đầu. (2) Workload throughput-sensitive không chấp nhận bất kỳ regression nào. (3) Máy chuyên dụng không có kế hoạch tận dụng memory được giải phóng. (4) Workload với nhiều Large Object temporary allocation gây gen2 GC liên tục.

Escape Analysis — Stack Allocation tự động trong .NET 10

JIT compiler trong .NET 10 có khả năng phát hiện các object không thoát ra khỏi scope của method và tự động chuyển chúng lên stack thay vì heap. Điều này có nghĩa là: zero GC pressure cho các allocation cục bộ, mà không cần bạn thay đổi code.

// .NET 9: allocate trên heap — 7.7ns, 72B allocated
// .NET 10: escape analysis → stack — 3.9ns, 0B allocated
int[] ComputeLocal()
{
    var arr = new int[3]; // JIT phát hiện arr không escape
    arr[0] = 1;
    arr[1] = 2;
    arr[2] = arr[0] + arr[1];
    return null; // arr không được return → stack-allocated
}

// Delegate cũng được tối ưu
// .NET 9: 18,983ns, 88B → .NET 10: 6,292ns, 24B
void ProcessWithDelegate()
{
    int factor = 42;
    Func<int, int> multiply = x => x * factor;
    // Closure không escape → stack allocation cho DisplayClass
    Console.WriteLine(multiply(10));
}

Escape Analysis hoạt động tự động

Bạn không cần thay đổi bất kỳ dòng code nào. Chỉ cần nâng cấp lên .NET 10, JIT sẽ tự phân tích và tối ưu. Tuy nhiên, hiểu cơ chế này giúp bạn viết code thân thiện hơn với escape analysis — ví dụ, tránh return object không cần thiết hoặc lưu reference vào field khi chỉ dùng cục bộ.

Span<T> và Memory<T> — Zero-Allocation Data Processing

Span<T>ref struct — một view nhẹ trên vùng memory liên tục, không tạo allocation hay copy data. Đây là công cụ quan trọng nhất để loại bỏ allocation trong hot path.

graph TB
    subgraph Tools["Chọn đúng công cụ"]
        S["Span<T>
Sync, stack-only
Slicing không allocation"] M["Memory<T>
Async-compatible
Heap-safe wrapper"] A["ArrayPool<T>
Buffer reuse ≥ 8KB
Tránh LOH fragmentation"] ST["stackalloc
Ultra-fast < 8KB
Tự giải phóng"] P["Pipelines
Streaming I/O
Backpressure"] end S -->|".Span"| M M -->|"MemoryPool"| A ST -->|"implicit"| S A -->|"auto-pooled"| P style S fill:#e94560,stroke:#fff,color:#fff style M fill:#4CAF50,stroke:#fff,color:#fff style A fill:#2196F3,stroke:#fff,color:#fff style ST fill:#ff9800,stroke:#fff,color:#fff style P fill:#9C27B0,stroke:#fff,color:#fff

Hình 2: Bản đồ lựa chọn API zero-allocation trong .NET

String Parsing với Span — Loại bỏ Substring và Split

Mỗi lần gọi string.Substring() hoặc string.Split(), .NET tạo string mới trên heap. Trong hot path xử lý hàng triệu request, đây là nguồn allocation khổng lồ. ReadOnlySpan<char> cho phép slice string mà không tạo bất kỳ allocation nào:

// ❌ Truyền thống: mỗi Substring tạo string mới trên heap
string ParseTraditional(string line)
{
    var parts = line.Split(',');         // string[] allocation
    var id = parts[0];                   // string allocation
    var name = parts[1].Trim();          // string allocation
    return $"{id}:{name}";              // string allocation
}

// ✅ Zero-allocation với Span
static (int id, ReadOnlySpan<char> name) ParseWithSpan(ReadOnlySpan<char> line)
{
    int comma = line.IndexOf(',');
    var idSpan = line[..comma];
    int id = int.Parse(idSpan);          // 0 allocation
    var name = line[(comma + 1)..].Trim();  // 0 allocation, slice gốc
    return (id, name);
}

Memory<T> cho Async Workflows

Span<T> là ref struct nên không thể dùng across await. Khi cần async, dùng Memory<T>:

// Memory<T> — an toàn qua await boundary
async Task<int> ReadAndProcessAsync(Stream stream)
{
    using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
    Memory<byte> buffer = owner.Memory;

    int bytesRead = await stream.ReadAsync(buffer);

    // Chuyển sang Span khi cần xử lý sync
    ProcessData(buffer.Span[..bytesRead]);

    return bytesRead;
    // owner.Dispose() tự trả buffer về pool
}

ArrayPool<T> — Tái sử dụng Buffer, loại bỏ LOH Fragmentation

Bất kỳ allocation nào ≥ 85KB đều vào Large Object Heap (LOH). LOH không được compact trong GC thông thường, dẫn đến fragmentation — ứng dụng dùng ngày càng nhiều memory dù data thực tế không tăng. ArrayPool<T> giải quyết triệt để vấn đề này.

graph TB
    subgraph Without["Không dùng Pool"]
        R1["Request 1"] --> A1["new byte[64KB]"]
        R2["Request 2"] --> A2["new byte[64KB]"]
        R3["Request 3"] --> A3["new byte[64KB]"]
        A1 --> GC["GC Pressure ↑"]
        A2 --> GC
        A3 --> GC
        GC --> F["LOH Fragmentation"]
    end
    subgraph With["Dùng ArrayPool"]
        R4["Request 1"] --> P["ArrayPool.Rent()"]
        R5["Request 2"] --> P
        R6["Request 3"] --> P
        P --> B["Reuse cùng buffer"]
        B --> RET["Pool.Return()"]
        RET --> P
    end
    style Without fill:#f8f9fa,stroke:#ff9800,color:#2c3e50
    style With fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
    style GC fill:#ff9800,stroke:#fff,color:#fff
    style F fill:#e94560,stroke:#fff,color:#fff
    style B fill:#4CAF50,stroke:#fff,color:#fff

Hình 3: ArrayPool loại bỏ allocation lặp lại và LOH fragmentation

// ✅ Pattern chuẩn với ArrayPool
public async Task ProcessUploadAsync(Stream uploadStream)
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(81920); // 80KB
    try
    {
        int bytesRead;
        while ((bytesRead = await uploadStream.ReadAsync(
            buffer.AsMemory(0, buffer.Length))) > 0)
        {
            await ProcessChunkAsync(buffer.AsMemory(0, bytesRead));
        }
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
    }
}

// ❌ KHÔNG BAO GIỜ: quên Return trong finally
// ❌ KHÔNG BAO GIỜ: Return array không phải từ pool
// ❌ KHÔNG BAO GIỜ: sử dụng buffer sau khi Return

clearArray: true — Bắt buộc cho dữ liệu nhạy cảm

Khi xử lý credentials, PII, hoặc dữ liệu tài chính, luôn dùng clearArray: true khi return buffer về pool. Nếu không, request tiếp theo rent cùng buffer có thể đọc được dữ liệu của request trước — đây là lỗ hổng information leakage nghiêm trọng trong multi-tenant systems.

Benchmark: ArrayPool vs new byte[]

Phương phápThời gianAllocationGC Gen0
new byte[64KB]3,450 ns65,536 B15.2/1000 ops
ArrayPool.Rent(64KB)44 ns0 B0/1000 ops
stackalloc 4KB~0 ns0 B0/1000 ops

stackalloc — Bộ nhớ tạm cực nhanh trên Stack

stackalloc cấp phát memory trực tiếp trên stack — không có GC overhead, tự giải phóng khi method kết thúc. Tuy nhiên, stack size giới hạn (thường 1-4MB, container có thể chỉ 512KB), nên chỉ dùng cho buffer nhỏ dưới 8KB.

// Formatting số mà không tạo string tạm
void LogMetric(int value, ILogger logger)
{
    Span<char> buffer = stackalloc char[16];
    if (value.TryFormat(buffer, out int written))
    {
        logger.LogInformation("Metric: {Value}", buffer[..written].ToString());
    }
}

// Pattern an toàn: fallback khi size quá lớn
void ProcessData(int size)
{
    Span<byte> buffer = size <= 1024
        ? stackalloc byte[size]
        : new byte[size]; // Fallback to heap nếu quá lớn cho stack

    FillBuffer(buffer);
}

Cẩn trọng với stackalloc trong vòng lặp

Mỗi iteration của loop KHÔNG giải phóng stackalloc. Nếu bạn stackalloc trong vòng lặp 1000 lần, mỗi lần 4KB = 4MB stack consumed → StackOverflowException. Luôn stackalloc bên ngoài vòng lặp hoặc sử dụng ArrayPool cho trường hợp này.

System.IO.Pipelines — High-Performance Streaming I/O

Pipelines giải quyết bài toán kinh điển: mỗi lần đọc stream tạo mới byte[8192] → GC pressure tăng tuyến tính với throughput. Pipelines tự động pool buffer, hỗ trợ backpressure, và cho phép zero-copy processing.

// Zero-allocation JSON log ingestion với Pipelines + Utf8JsonReader
[HttpPost("/api/logs/ingest")]
public async Task<IActionResult> IngestLogsAsync()
{
    var reader = Request.BodyReader;

    while (true)
    {
        ReadResult result = await reader.ReadAsync();
        ReadOnlySequence<byte> buffer = result.Buffer;

        // Xử lý từng message trong buffer
        while (TryParseLogEntry(ref buffer, out var entry))
        {
            await _logService.StoreAsync(entry);
        }

        reader.AdvanceTo(buffer.Start, buffer.End);
        if (result.IsCompleted) break;
    }

    return Ok();
}

private bool TryParseLogEntry(
    ref ReadOnlySequence<byte> buffer, out LogEntry entry)
{
    var jsonReader = new Utf8JsonReader(buffer, isFinalBlock: false, default);
    // Utf8JsonReader đọc trực tiếp từ buffer — zero copy
    // ...
}

Benchmark: So sánh 3 phương pháp JSON ingestion

Phương phápAllocation/RequestThroughputP99 Latency
ASP.NET model binding~35 KB5,000 req/s120 ms
ArrayPool optimization~4 KB15,000 req/s40 ms
Pipelines + Utf8JsonReader~100 B52,000 req/s7 ms

Khi nào dùng Pipelines?

Pipelines phù hợp nhất cho: network server tự xây (TCP/UDP), custom protocol parsing, streaming data ingestion (log, metrics, events), và file processing pipeline. Cho REST API thông thường, ArrayPool + Span thường đã đủ tốt — Pipelines thêm complexity mà chỉ cần khi throughput requirement thực sự cao.

Object Pooling với ObjectPool<T>

Ngoài buffer, các object phức tạp cũng có thể pool. ASP.NET Core cung cấp ObjectPool<T> với interface IResettable (mới trong .NET 10) để tự động reset state khi trả về pool:

// Đăng ký pool trong DI
builder.Services.AddSingleton<ObjectPool<StringBuilder>>(sp =>
{
    var policy = new StringBuilderPooledObjectPolicy
    {
        InitialCapacity = 256,
        MaximumRetainedCapacity = 4096
    };
    return new DefaultObjectPool<StringBuilder>(policy, maximumRetained: 64);
});

// Sử dụng trong controller/service
public string BuildReport(IEnumerable<MetricPoint> metrics)
{
    var sb = _pool.Get();
    try
    {
        foreach (var m in metrics)
        {
            sb.Append(m.Name).Append(": ").AppendLine(m.Value.ToString());
        }
        return sb.ToString();
    }
    finally
    {
        _pool.Return(sb); // StringBuilder.Clear() tự động nhờ policy
    }
}

Monitoring GC trong Production

Tối ưu mà không đo lường là mù quáng. .NET 10 cung cấp nhiều công cụ để quan sát GC behavior real-time:

dotnet-counters — Real-time GC metrics

# Monitor GC metrics real-time
dotnet-counters monitor --process-id <PID> \
  --counters System.Runtime[gc-heap-size,alloc-rate,gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,gc-pause-time]

# Export cho Prometheus/Grafana
dotnet-counters collect --process-id <PID> \
  --format json --output gc-metrics.json

DATAS Diagnostic Events

DATAS publish SizeAdaptationTuning events chứa thông tin chi tiết:

Event FieldÝ nghĩaNgưỡng cảnh báo
TotalSOHStableSizeXấp xỉ Live Data SizeTăng liên tục → memory leak
TcpToConsiderThroughput Cost Percentage thực tế> 5% → GC quá tốn
BCDBudget Computed via DATASQuá nhỏ → tăng GrowthPercent
// Programmatic GC monitoring trong app
var listener = new EventListener();
listener.EnableEvents(
    EventSource.GetEventSource("Microsoft-Windows-DotNETRuntime"),
    EventLevel.Informational,
    (EventKeywords)0x1); // GC keyword

// Hoặc dùng .NET Metrics API
var meter = new Meter("App.GC.Monitoring");
var gcPauseHistogram = meter.CreateHistogram<double>("gc.pause.duration");

// Đẩy sang OpenTelemetry → Grafana dashboard

Checklist tối ưu Memory cho Production

Bước 1: Đo lường baseline
Dùng dotnet-counters hoặc Application Insights để ghi nhận alloc-rate, GC pause time, heap size hiện tại. Không tối ưu nếu chưa có số liệu.
Bước 2: Xác định hot path
Chạy profiler (dotMemory, VS Diagnostic Tools) để tìm method nào allocate nhiều nhất. Thường là JSON serialization, string processing, và buffer I/O.
Bước 3: Áp dụng Span<T> cho string parsing
Thay thế Substring(), Split(), string.Format() bằng ReadOnlySpan<char>string.Create() trong hot path.
Bước 4: ArrayPool cho buffer I/O
Mọi new byte[N] với N ≥ 1KB trong request pipeline nên chuyển sang ArrayPool<byte>.Shared.Rent(N). Luôn Return trong finally.
Bước 5: Tune DATAS
Monitor TCP qua SizeAdaptationTuning events. Nếu > 3%, tăng GCDGen0GrowthPercent. Nếu memory vẫn cao, giảm GCDTargetTCP.
Bước 6: Container-specific tuning
Set GCHeapHardLimit = 75% container memory limit. Để DATAS tự điều chỉnh trong phạm vi đó. Monitor OOMKilled events.

Những lỗi phổ biến cần tránh

LỗiHậu quảGiải pháp
stackalloc trong vòng lặpStackOverflowExceptionAllocate bên ngoài loop hoặc dùng ArrayPool
Span qua awaitCompiler error (may mắn!)Dùng Memory<T> cho async code
Quên Return ArrayPoolPool exhaustion, memory leakLuôn dùng try/finally
Boxing value typeHeap allocation ẩnDùng generic constraint hoặc interface
Capture biến trong lambdaTạo DisplayClass trên heapDùng static lambda hoặc truyền state qua parameter
ToString() trên SpanTạo string mới, mất zero-allocGiữ Span càng lâu càng tốt trước khi ToString

Case Study: Giảm 54.7% Memory trong Production

Một hệ thống xử lý log tại quy mô 50,000 req/s đã áp dụng các kỹ thuật trên với kết quả:

54.7% Giảm memory usage hàng ngày
75% Giảm Gen2 GC collections
+22% Tăng throughput
7ms P99 latency (từ 120ms)

Các thay đổi chính: chuyển JSON parsing sang Utf8JsonReader + Pipelines, thay thế string.Split() bằng Span<char> slicing, áp dụng ArrayPool cho tất cả I/O buffer, và bật DATAS với GCDGen0GrowthPercent: 200.

Tổng kết

Tối ưu memory trong .NET 10 không phải là micro-optimization vô nghĩa — ở quy mô hàng chục nghìn request/giây, mỗi KB allocation bị nhân lên thành GB heap pressure. DATAS tự động hóa phần lớn GC tuning, escape analysis giảm allocation mà không cần thay đổi code, và bộ công cụ Span<T> / ArrayPool / Pipelines cho phép bạn kiểm soát chính xác từng byte được cấp phát.

Quy tắc vàng: đo lường trước, tối ưu hot path, để DATAS xử lý phần còn lại.

Tham khảo