Tối ưu Memory & GC trong .NET 10 — Span<T>, ArrayPool, DATAS và Zero-Allocation
Posted on: 4/26/2026 5:15:21 AM
Table of contents
- Garbage Collection trong .NET 10 — DATAS thay đổi cuộc chơi
- Escape Analysis — Stack Allocation tự động trong .NET 10
- Span<T> và Memory<T> — Zero-Allocation Data Processing
- ArrayPool<T> — Tái sử dụng Buffer, loại bỏ LOH Fragmentation
- stackalloc — Bộ nhớ tạm cực nhanh trên Stack
- System.IO.Pipelines — High-Performance Streaming I/O
- Object Pooling với ObjectPool<T>
- Monitoring GC trong Production
- Checklist tối ưu Memory cho Production
- Những lỗi phổ biến cần tránh
- Case Study: Giảm 54.7% Memory trong Production
- Tổng kết
- Tham khảo
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.
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 |
|---|---|---|
| GCDynamicAdaptationMode | 1 (bật) | Bật/tắt DATAS. Set 0 để tắt |
| GCDTargetTCP | 2% | Target pause time % — tăng để ưu tiên memory, giảm để ưu tiên throughput |
| GCDGen0GrowthPercent | 100 | Hệ số nhân cho BCD. Tăng 260 = 2.6x budget |
| GCDGen0GrowthMinFactor | 0.1 | Floor cho hệ số m — tăng nếu cần budget tối thiểu lớn hơn |
| GCHeapCount | auto | Nếu set thủ công, DATAS tự động bị tắt |
| GCHeapHardLimit | không set | Hard 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> là 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áp | Thời gian | Allocation | GC Gen0 |
|---|---|---|---|
| new byte[64KB] | 3,450 ns | 65,536 B | 15.2/1000 ops |
| ArrayPool.Rent(64KB) | 44 ns | 0 B | 0/1000 ops |
| stackalloc 4KB | ~0 ns | 0 B | 0/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áp | Allocation/Request | Throughput | P99 Latency |
|---|---|---|---|
| ASP.NET model binding | ~35 KB | 5,000 req/s | 120 ms |
| ArrayPool optimization | ~4 KB | 15,000 req/s | 40 ms |
| Pipelines + Utf8JsonReader | ~100 B | 52,000 req/s | 7 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ĩa | Ngưỡng cảnh báo |
|---|---|---|
| TotalSOHStableSize | Xấp xỉ Live Data Size | Tăng liên tục → memory leak |
| TcpToConsider | Throughput Cost Percentage thực tế | > 5% → GC quá tốn |
| BCD | Budget Computed via DATAS | Quá 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
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.Substring(), Split(), string.Format() bằng ReadOnlySpan<char> và string.Create() trong hot path.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.GCDGen0GrowthPercent. Nếu memory vẫn cao, giảm GCDTargetTCP.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ỗi | Hậu quả | Giải pháp |
|---|---|---|
| stackalloc trong vòng lặp | StackOverflowException | Allocate bên ngoài loop hoặc dùng ArrayPool |
| Span qua await | Compiler error (may mắn!) | Dùng Memory<T> cho async code |
| Quên Return ArrayPool | Pool exhaustion, memory leak | Luôn dùng try/finally |
| Boxing value type | Heap allocation ẩn | Dùng generic constraint hoặc interface |
| Capture biến trong lambda | Tạo DisplayClass trên heap | Dùng static lambda hoặc truyền state qua parameter |
| ToString() trên Span | Tạo string mới, mất zero-alloc | Giữ 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ả:
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
- Preparing for the .NET 10 GC (DATAS) — .NET Blog
- Garbage collector config settings — Microsoft Learn
- What .NET 10 GC Changes Mean for Developers — Roxeem
- Memory Management Masterclass in .NET — DevelopersVoice
- Memory Optimization With ArrayPool in C# — Code Maze
- Garbage Collector & Memory Compaction Improvements in .NET 10 — Steve Bang
Cloudflare R2 - Object Storage Không Phà Egress cho Developer
Strangler Fig Pattern — Hiện đại hóa Legacy an toàn với YARP và .NET
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.