EngineeringSenior DeveloperTraining Sessions

Session 05 - Memory, GC & Performance Mindset

Nhiều .NET dev không nghĩ về memory vì "có GC tự dọn". Nhưng GC không miễn phí - mỗi lần GC chạy, ứng dụng có thể pause. Hiểu cách GC hoạt động giúp bạn viết code nhanh hơn và predict được performance dưới load.


Mục tiêu

  • Hiểu GC hoạt động thế nào (không cần sâu, nhưng đủ để ra quyết định)
  • Biết allocation tốn bao nhiêu và cách giảm
  • Hiểu Span<T> và stackalloc ở mức thực dụng
  • Biết khi nào optimize và khi nào stop

1) GC Hoạt Động Thế Nào - Phiên Bản Thực Dụng

Memory trong .NET chia làm 2 vùng:

Stack (per thread):
  - Value types (int, double, struct)
  - References (con trỏ đến heap objects)
  - size nhỏ (~1MB per thread), TỰ ĐỘNG cleanup khi method return
  - → NHANH, KHÔNG cần GC

Heap (shared):
  - Reference types (class, string, array, delegate)
  - Quản lý bởi GC
  - → CHẬM hơn stack, CẦN GC

GC Generations:
  Gen 0: objects mới tạo (short-lived) - GC thường xuyên, NHANH
  Gen 1: objects sống sót Gen 0    - GC ít hơn
  Gen 2: objects sống lâu (singleton, cache) - GC hiếm, CHẬM (full GC)
  LOH:   objects > 85KB           - GC hiếm, dễ gây fragmentation

Tại sao quan trọng?

Mỗi lần GC chạy → App PAUSE (hoặc slow down)
  Gen 0 collection: ~1ms   → gần như không đáng kể
  Gen 1 collection: ~5ms   → OK
  Gen 2 collection: ~50ms+ → CÓ THỂ CẢM NHẬN (jitter, latency spike)

Ứng dụng allocate NHIỀU objects → Gen 0 đầy nhanh → GC chạy liên tục
  → Throughput giảm 10-30%
  → Latency P99 tăng

Ứng dụng allocate ÍT objects → GC hiếm chạy → smooth performance

2) Allocation - Cái Gì Tốn, Cái Gì Không?

csharp
// ✅ KHÔNG allocate (stack)
int x = 42;              // Value type → stack
Span<byte> span = stackalloc byte[256]; // Stack allocation
var point = new Point(1, 2); // struct → stack (nếu local variable)

// ⚠️ ALLOCATE (heap)
var list = new List<int>(); // Class → heap
string name = "hello";      // String → heap (immutable, mỗi modify = new string)
var arr = new int[100];     // Array → heap
var user = new User();      // Class → heap
var lambda = () => { };     // Delegate → heap (có thể)

String - Thủ Phạm Allocation Số 1

csharp
// ❌ String concatenation trong loop
string result = "";
for (int i = 0; i < 1000; i++)
{
    result += i.ToString(); // MỖI LẦN += tạo string MỚI, copy toàn bộ cũ + mới
    // Vòng 999: copy ~5000 chars + thêm "999" → allocate ~5KB string
}
// Tổng: ~500,000 string objects, ~2.5GB memory churn!

// ✅ StringBuilder
var sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
    sb.Append(i);  // Append vào buffer nội bộ, resize khi cần
}
var result = sb.ToString(); // 1 allocate cuối

// ✅ String interpolation cho ít items (compile-time optimize)
var message = $"User {name} has {count} orders"; // OK cho 2-3 items

// ✅ .NET 8+: String.Create cho high-performance

LINQ - Allocation Ẩn

csharp
// LINQ tạo nhiều intermediate objects:
var result = items
    .Where(x => x.IsActive)    // Allocate WhereIterator + lambda
    .Select(x => x.Name)       // Allocate SelectIterator + lambda
    .ToList();                  // Allocate List<T> + copy

// Với hot path (gọi hàng triệu lần):
// → Xem xét dùng for loop thay LINQ
// → Hoặc chấp nhận nếu "hot" chỉ là vài nghìn lần/giây (thường đủ OK)

// ⚠️ LINQ trên small collections (<100 items): overhead KHÔNG đáng kể
// → Đừng "optimize" LINQ trừ khi profiler chỉ ra đó là bottleneck!

3) Span<T> - Cắt Data Không Allocate

csharp
// Bài toán: parse "2024-01-15" thành ngày tháng

// ❌ Substring = allocate string mới
string date = "2024-01-15";
string year = date.Substring(0, 4);  // Allocate "2024"
string month = date.Substring(5, 2); // Allocate "01"
string day = date.Substring(8, 2);   // Allocate "15"
// → 3 string allocations

// ✅ Span = "window" vào string gốc, KHÔNG copy
ReadOnlySpan<char> dateSpan = date.AsSpan();
ReadOnlySpan<char> year = dateSpan[..4];     // "View" vào 4 chars đầu
ReadOnlySpan<char> month = dateSpan[5..7];   // "View" vào 2 chars giữa
ReadOnlySpan<char> day = dateSpan[8..10];    // "View" vào 2 chars cuối
// → 0 allocations!
int yearValue = int.Parse(year);  // Parse trực tiếp từ span

Khi nào dùng Span?

✅ Parsing strings (logs, CSV, protocols)
✅ Binary data processing (image headers, file formats)
✅ Hot paths gọi hàng triệu lần
✅ Library code cần performance tối đa

❌ Business logic thông thường → overhead cognitive không đáng
❌ Khi data cần store/pass qua async → Span KHÔNG dùng với async!
   (Span sống trên stack, async thay đổi stack → Memory<T> thay thế)

4) Object Pooling - Tái Sử Dụng Thay Vì Tạo Mới

csharp
// Vấn đề: tạo object tốn kém lặp đi lặp lại
// Ví dụ: StringBuilder, byte[], MemoryStream

// ✅ ArrayPool: shared pool cho arrays
var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(4096); // Mượn buffer từ pool
try
{
    // Dùng buffer...
    var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, 4096));
}
finally
{
    pool.Return(buffer); // Trả lại pool (KHÔNG clear tự động!)
}

// ✅ ObjectPool: pool cho bất kỳ object nào
builder.Services.AddSingleton<ObjectPool<StringBuilder>>(
    ObjectPool.Create<StringBuilder>());

public class ReportGenerator(ObjectPool<StringBuilder> pool)
{
    public string Generate(ReportData data)
    {
        var sb = pool.Get();
        try
        {
            sb.AppendLine("Report Header");
            // ... build report
            return sb.ToString();
        }
        finally
        {
            sb.Clear();
            pool.Return(sb);
        }
    }
}

5) Performance Mindset - Khi Nào Optimize?

Quy tắc vàng: Đo trước, optimize sau

1. Code hoạt động ĐÚNG trước
2. Profiler chỉ ra BOTTLENECK ở đâu
3. Optimize CHỈ bottleneck đó
4. Đo lại xác nhận cải thiện
5. Dừng khi "đủ nhanh"

Optimize Pyramid

                    ┌─────────┐
                    │ Span<T> │  ← Hiếm khi cần
                    │ stackalloc│
                   ┌┴─────────┴┐
                   │  Pooling  │  ← High-throughput services
                  ┌┴───────────┴┐
                  │ Caching     │  ← Hầu hết apps cần
                 ┌┴─────────────┴┐
                 │ Query optimize│  ← Database là bottleneck #1
                ┌┴───────────────┴┐
                │ Architecture    │  ← Async, giải phóng threads
               ┌┴─────────────────┴┐
               │ Algorithm & Data  │  ← O(n²) → O(n log n)
               │ Structure choice  │
               └───────────────────┘

Optimize TỪ DƯỚI LÊN. 90% performance gains đến từ:
  1. Đúng algorithm/data structure
  2. Async đúng cách (giải phóng threads)
  3. Database queries hiệu quả (N+1, missing index)
  4. Caching (Redis, in-memory)

Benchmarking nhanh

csharp
// dotnet-counters: xem real-time metrics (không cần modify code)
// Terminal: dotnet-counters monitor --process-id <PID>
//
// Metrics quan trọng:
//   GC Heap Size (Gen 0/1/2): memory usage
//   Gen 0/1/2 Collections: frequency → nhiều = allocate nhiều
//   ThreadPool Thread Count: tăng liên tục = thread starvation
//   Exception Count: exceptions tốn kém!
//   Working Set: total memory

// BenchmarkDotNet: so sánh 2 approach (cho library/hot path code)
[MemoryDiagnoser]
public class StringBenchmarks
{
    [Benchmark(Baseline = true)]
    public string Concatenation()
    {
        string s = "";
        for (int i = 0; i < 100; i++) s += i;
        return s;
    }

    [Benchmark]
    public string StringBuilder()
    {
        var sb = new StringBuilder();
        for (int i = 0; i < 100; i++) sb.Append(i);
        return sb.ToString();
    }
}
// Result: StringBuilder 50x nhanh hơn, 100x ít allocation

6) Những Thứ KHÔNG Đáng Optimize

❌ LINQ trên collection nhỏ (<1000 items) → overhead nanoseconds
❌ var vs explicit type → hoàn toàn compile-time, 0 runtime difference
❌ readonly struct cho 1-2 fields → GC handles small structs fine
❌ Premature caching → cache invalidation bugs > performance gain
❌ "Zero allocation" cho business logic → code phức tạp, hard to maintain
❌ String.Intern cho data động → memory leak (interned strings live forever)

Master Checklist ✅

□ String concatenation trong loop → StringBuilder?
□ Read-only queries dùng AsNoTracking? (EF Core)
□ LINQ không chạy trên hot path hàng triệu lần unnecessary?
□ Large arrays dùng ArrayPool thay new?
□ Database là bottleneck? (index, query optimize trước khi code optimize)
□ Đã đo bằng profiler trước khi optimize?
□ Caching có invalidation strategy không?

Tư duy master: "Premature optimization is the root of all evil" - Donald Knuth. Nhưng kiến thức về performance không bao giờ premature. Biết cách GC hoạt động giúp bạn viết code tự nhiên tốt hơn, không cần nghĩ nhiều.