Một dòng log 400 ký tự, phải bóc ra 8 field, nhân với vài chục nghìn request mỗi giây. Mình từng viết đúng kiểu đó: Split rồi Substring, code đọc sướng mắt — cho tới hôm nhìn bảng phân bổ bộ nhớ: rác mỗi request tính bằng trăm KB, GC Gen0 chạy như máy khâu, p99 gợn lên theo từng đợt dọn.

Thuật toán không sai. Chỗ đau nằm ở một đặc tính rất .NET: string là bất biến.

Substring — mỗi nhát cắt là một lần cấp phát

line.Substring(20, 5) trông như đang "nhìn vào" đoạn giữa của chuỗi, nhưng thực tế nó tạo một object string mới, copy 5 ký tự sang vùng nhớ khác rồi để GC dọn. Split thì nhân thêm một mảng, mỗi phần tử lại là một chuỗi con.

Hàm gọi một lần mỗi ngày thì chẳng ai quan tâm. Ở đường nóng, đây là kiểu chi phí tệ nhất: rẻ, đều đặn, vô hình trong profiler — chẳng hàm nào trông "chậm", chỉ có GC là bận.

Span — cửa sổ nhìn vào vùng nhớ đã có

Span<T> ở mức thấp chỉ là một cặp (con trỏ, độ dài), cỡ 16 byte, sống trên stack. Nó không sở hữu dữ liệu — nó trỏ vào vùng nhớ mà người khác đã cấp phát: một string, một mảng, hay một khối trên stack.

Vì không sở hữu gì nên cắt nó cũng chẳng tốn gì: Slice, [..n], Trim() chỉ là phép cộng trừ trên hai con số. string có sẵn AsSpan() để lấy cửa sổ đó, không cấp phát object nào. Cái giá phải trả nằm ở vòng đời: cửa sổ chỉ sống trong lúc vùng nhớ bên dưới còn hợp lệ.

Một khung nhìn mảnh nằm trên cuộn băng dài, phần còn lại của băng vẫn nguyên vẹn Ảnh: Ẩn dụ cho cách Span cắt chuỗi — thay vì chép lại khúc cần dùng, mình mở một cửa sổ nhìn đúng vào đoạn bộ nhớ đã có, nên không sinh thêm object nào cho GC dọn dẹp. — ảnh minh hoạ do AI tạo (Grok Imagine)

Parse log mà không cấp phát gì

Hàm mình sửa nhiều lần nhất trong đợt đó, viết lại bằng span:

// line: "2026-10-25T06:31:02Z warn 503"
public static bool TryParseLogLine(ReadOnlySpan<char> line, out ReadOnlySpan<char> level, out int status)
{
    level = default;
    status = 0;

    var i = line.IndexOf(' ');       // bỏ luôn timestamp, không cần giữ
    if (i < 0) return false;
    var rest = line[(i + 1)..];

    i = rest.IndexOf(' ');           // tách level khỏi status
    if (i < 0) return false;
    level = rest[..i];
    rest = rest[(i + 1)..].Trim();

    return int.TryParse(rest, out status);  // overload nhận ReadOnlySpan<char>
}

Hai điều đáng nhớ: int.TryParse có overload nhận ReadOnlySpan<char> nên parse thẳng trên span, khỏi tạo string trung gian; và level trả ra ngoài là cửa sổ vào buffer của chỗ gọi — đừng nhét vào field, đừng đưa vào Task.

stackalloc — đừng đẩy mọi thứ lên heap

Nhiều chỗ chỉ cần buffer tạm để format một số rồi ghi thẳng ra socket. Không cần string, không cần mảng trên heap:

Span<char> text = stackalloc char[64];
if (!orderId.TryFormat(text, out var n)) return;   // n = số ký tự thật

Span<byte> bytes = stackalloc byte[128];           // buffer phải đủ lớn
var written = Encoding.UTF8.GetBytes(text[..n], bytes);
await stream.WriteAsync(bytes[..written].ToArray(), ct);

stackalloc cấp phát trên stack, ra khỏi hàm là hết, GC không liên quan. (Vì sao stack rẻ hơn heap thì mình có kể kỹ bên bài Escape analysis trong Go — cùng một câu chuyện, khác ngôn ngữ.) Nhưng buffer phải đủ lớn: Encoding.GetBytes ném ArgumentException khi đích không đủ chỗ, với UTF-8 thì tối đa 4 byte một ký tự. Và đừng stackalloc trong vòng lặp lớn — vùng nhớ đó không được trả lại giữa các vòng, cộng dồn là StackOverflowException, lỗi không có stack trace để đọc.

Một chồng khối nhỏ gọn trên kệ sáng bên cạnh bãi khối khổng lồ trôi ở xa Ảnh: Buffer tạm bằng stackalloc giống chồng khối nhỏ nằm gọn trên kệ — hết hàm là xong; còn cấp phát trên heap thì giống bãi khối trôi phía xa, thứ mà GC phải dò và dọn mỗi chu kỳ. — ảnh minh hoạ do AI tạo (Grok Imagine)

Cái bẫy: Span không đi qua được await

Span<T> là ref struct: không làm field của class, không nhét vào closure, không làm tham số generic, và không được tồn tại khi dòng thực thi chạy qua await. Compiler chặn thẳng bằng lỗi CS4012, mà thông báo thì khá khó đoán.

ReadOnlyMemory<T> sinh ra để lấp đúng chỗ đó: một struct bình thường, đóng gói vào Task được, truyền qua async thoải mái:

public static async Task<int> CountAsync(ReadOnlyMemory<char> raw)
{
    await Task.Delay(10);       // qua await với Memory<char> thì OK
    return Count(raw.Span);     // chỉ mở cửa sổ SAU khi await xong
}

private static int Count(ReadOnlySpan<char> s)
{
    var n = 0;
    foreach (var c in s) if (c == ',') n++;
    return n;
}

Quy tắc mình tự rút ra: Memory<T> để truyền đi và lưu lại, Span<T> để dùng ngay trong một hàm đồng bộ, càng ngắn càng tốt.

Băng chuyền tách thành hai làn: một làn chở tấm kính mỏng manh, làn kia bọc tấm kính vào hộp chắc chắn Ảnh: Span và Memory là hai làn của cùng một băng chuyền — làn span chỉ chở được vật mỏng manh trong đoạn ngắn, còn muốn đi xa qua `await` thì phải bọc vào memory cho chắc. — ảnh minh hoạ do AI tạo (Grok Imagine)

Khi nào đừng dùng Span

Đừng đặt ReadOnlySpan<char> ở API public: chỗ gọi khó lưu lại giá trị, khó test, dễ hiểu sai vòng đời — giữ span ở tầng trong, chỗ parse. Cũng đừng span-hoá cả codebase cho "chắc", vì mỗi lần chuyển qua lại string/span là một chỗ dễ sai vòng đời; hàm không nằm trên đường nóng thì lợi ích gần như bằng không.

Sau khi sửa hàm parse và một chỗ build cache key, allocation mỗi request rơi từ khoảng 180KB xuống dưới 30KB, Gen0 collection ít hơn hẳn, p99 phẳng hơn. Không có phép thuật gì ở đây, chỉ là bớt copy những thứ không cần copy. Muốn tự kiểm thì chạy dotnet-counters monitor --counters System.Runtime và nhìn alloc-rate trước — sau: chỉ số đó không nhúc nhích nghĩa là bạn vừa tối ưu đúng chỗ không cần.

Bạn có hàm parse nào đang cắt chuỗi cả triệu lần một ngày không? Thử AsSpan() cho nó rồi xem alloc-rate nói gì.

📋 Phụ lục thuật ngữ

Thuật ngữ Giải thích ngắn
Span<T> Cửa sổ nhìn vào vùng nhớ đã có (con trỏ + độ dài), ref struct, chỉ sống trên stack
Memory<T> Bọc cùng vùng nhớ nhưng là struct thường, truyền được qua async, mở .Span khi dùng
ReadOnlySpan<char> Cửa sổ chỉ đọc trên ký tự — thay string ở chỗ parse, không cấp phát
ref struct Struct buộc phải sống trên stack: không làm field, không vào closure, không qua await
stackalloc Cấp phát buffer trên stack cho vòng đời một hàm, GC không thấy
AsSpan() Lấy cửa sổ span từ string/mảng, không copy, không cấp phát
Gen0 GC Tầng dọn rác cho object vừa sinh; chạy nhiều nghĩa là cấp phát nhiều
alloc-rate Chỉ số tốc độ cấp phát bộ nhớ, xem bằng dotnet-counters monitor
StringComparison.Ordinal So sánh chuỗi theo từng byte, không quan tâm culture — đúng khi parse

Xem các bài viết khác