Chi phí ẩn của việc cấp phát trên Heap và áp lực GC
Cách đây vài năm, đội ngũ của chúng tôi đã triển khai một hệ thống xử lý thanh toán thông lượng cao được thiết kế cho tải 25.000 yêu cầu mỗi giây. Trên môi trường staging, mọi bài kiểm thử đều vượt qua một cách xuất sắc. Nhưng khi lượng truy cập dịp Black Friday ồ ạt đổ về môi trường production, thời gian phản hồi ở phân vị thứ 99 (p99) bất ngờ tăng vọt từ 12ms lên hơn 180ms. Dữ liệu thread dump nhanh chóng chỉ ra nguyên nhân cốt lõi: các worker thread liên tục bị đình trệ từ 10 đến 45 mili giây trong các chu kỳ thu gom rác stop-the-world.
Vấn đề không nằm ở việc deadlock cơ sở dữ liệu hay một lệnh gọi HTTP downstream chậm chạp. Nó là tổn thất tích tụ từ hàng ngàn vết cắt nhỏ trên managed heap. Những lệnh gọi quen thuộc như data.Substring(0, 10) hay new byte[1024] nằm trong các vòng lặp hot path đã tạo ra tới 1,2 GB đối tượng tạm thời mỗi giây. Garbage Collector của .NET phải liên tục kích hoạt các đợt dọn dẹp Generation 0 và Generation 1 để theo kịp, lãng phí chu kỳ CPU quý giá chỉ để thu dọn rác.
Các API truyền thống trong C# luôn cấp phát đối tượng chuỗi và mảng hoàn toàn mới mỗi khi bạn tách văn bản, kiểm tra một đoạn payload hoặc sao chép buffer. Nếu muốn nâng thông lượng lên hàng chục nghìn thao tác mỗi giây mà không vượt quá hạn mức bộ nhớ, bạn cần truy cập bộ nhớ liền kề mà không phát sinh cấp phát trên heap. Span<T> và Memory<T> mang lại cho bạn chính xác khả năng đó.
Hiểu rõ sự khác biệt giữa Span<T> và Memory<T>
Span<T>: Phân đoạn bộ nhớ cực nhanh chỉ trên Stack
Span<T> đóng vai trò như một cửa sổ an toàn, được kiểm soát kiểu chặt chẽ nhìn vào bất kỳ khối bộ nhớ liền kề nào. Về bản chất, nó được định nghĩa là một ref struct chỉ gồm hai trường: một con trỏ tham chiếu nội bộ (interior reference pointer) và một độ dài kiểu số nguyên. Do là một ref struct, runtime áp đặt nghiêm ngặt rằng Span<T> chỉ có thể tồn tại trên execution stack. Nó không thể bị boxing, không thể lưu làm trường trong một class thông thường, cũng như không thể chuyển qua lại giữa các thread.
Nhờ vòng đời trên stack có thể dự đoán được, trình biên dịch JIT sẽ tạo mã máy có hiệu năng tương đương với các phép toán con trỏ C thuần túy, hoàn toàn không tốn chi phí cấp phát và hỗ trợ kiểm tra biên nhanh chóng nhờ vector hóa. Bạn có thể trỏ một Span<T> duy nhất tới ba vùng lưu trữ hoàn toàn khác nhau:
- Mảng được quản lý trên heap (
byte[],int[]) - Bộ đệm tạm thời trên stack (
stackalloc byte[128]) - Bộ đệm bộ nhớ unmanaged được cấp phát qua native interop
Memory<T>: Phân đoạn an toàn trên Heap cho mã Async
Ràng buộc chỉ nằm trên stack của Span<T> đi kèm một hạn chế lớn: nó không thể tồn tại qua ranh giới thực thi bất đồng bộ. Khi một phương thức async gặp từ khóa await, trình biên dịch sẽ đưa các biến cục bộ vào một state machine được tự động sinh trên heap. Việc cố gắng giữ một Span<T> qua điểm tạm dừng đó sẽ gây ra lỗi biên dịch CS4013.
Đây là lúc Memory<T> phát huy tác dụng. Khác với Span<T>, nó là một readonly struct tiêu chuẩn được hỗ trợ bởi mảng, chuỗi hoặc memory manager dùng chung. Điều đó có nghĩa là nó có thể nằm an toàn trên heap, được lưu trữ trong các trường của class và tồn tại qua các điểm await. Khi bạn đã sẵn sàng để parse hoặc biến đổi buffer bên dưới một cách đồng bộ, chỉ cần gọi thuộc tính .Span để nhận một lát cắt trên stack mà không tốn cấp phát.
ReadOnlySpan<char> cho các thao tác chuỗi
Chuỗi trong .NET là các đối tượng bất biến được cấp phát trên heap. Mỗi khi bạn gọi string.Substring(), runtime sẽ cấp phát một chuỗi hoàn toàn mới và sao chép các ký tự vào đó. Bằng cách chuyển sang ReadOnlySpan<char> thông qua string.AsSpan(), bạn có thể cắt chuỗi con như các cửa sổ ảo chiếu vào bộ nhớ hiện có mà không phát sinh cấp phát và không cần sao chép chuỗi.
Thực hành: Từ cấp phát nặng nề đến Zero-Allocation
Mẹo 1: Parse văn bản có ký tự phân tách mà không cấp phát chuỗi con
Hãy xem xét một tác vụ thông thường như trích xuất dữ liệu từ dòng log có định dạng TIMESTAMP|SEVERITY|MESSAGE. Cách tiếp cận ngây thơ thường gây lãng phí tài nguyên không cần thiết:
// Cấp phát 1 mảng và 3 đối tượng chuỗi trên mỗi lần gọi
public (string Level, string Message) ParseLogLegacy(string rawLog)
{
string[] parts = rawLog.Split('|');
return (parts[1], parts[2]);
}
Khi bạn truyền 100.000 sự kiện log mỗi giây qua phương thức phụ trợ này, bạn buộc GC phải dọn dẹp tới 400.000 đối tượng heap mỗi giây. Dưới đây là giải pháp zero-allocation tương đương sử dụng ReadOnlySpan<char>:
// Hoàn toàn không cấp phát heap, chỉ phân đoạn qua con trỏ thuần túy
public bool TryParseLog(ReadOnlySpan<char> rawLog, out ReadOnlySpan<char> level, out ReadOnlySpan<char> message)
{
level = default;
message = default;
int firstSeparator = rawLog.IndexOf('|');
if (firstSeparator == -1) return false;
ReadOnlySpan<char> remainder = rawLog.Slice(firstSeparator + 1);
int secondSeparator = remainder.IndexOf('|');
if (secondSeparator == -1) return false;
level = remainder.Slice(0, secondSeparator);
message = remainder.Slice(secondSeparator + 1);
return true;
}
Trong các bài microbenchmark của chúng tôi, việc tái cấu trúc này đã giảm thời gian thực thi từ 42ns xuống còn 9ns, đồng thời loại bỏ hoàn toàn 128 byte cấp phát heap trên mỗi dòng log.
Mẹo 2: Parse số trực tiếp với Utf8Parser
Các HTTP header gửi đến, JSON payload và luồng dữ liệu IoT telemetry thường đến dưới dạng luồng byte UTF-8 thô. Nhiều lập trình viên thường giải mã các byte này thành chuỗi UTF-16 trước, rồi mới đưa chuỗi đó vào int.Parse(). Hãy bỏ qua bước trung gian này. Với System.Buffers.Text.Utf8Parser, bạn có thể parse giá trị trực tiếp từ các span byte:
using System;
using System.Buffers.Text;
ReadOnlySpan<byte> payload = "1048576"u8;
if (Utf8Parser.TryParse(payload, out int parsedValue, out int bytesConsumed))
{
Console.WriteLine($"Số nguyên đã parse: {parsedValue} (đã xử lý {bytesConsumed} byte)");
}
Bằng cách sử dụng trực tiếp UTF-8 literal, bạn loại bỏ bước giải mã chuỗi trung gian, tiết kiệm cả chu kỳ CPU lẫn bộ nhớ.
Mẹo 3: Sử dụng stackalloc cho các bộ đệm tạm thời nhỏ
Khi tính toán checksum, định dạng framing header nhỏ gọn hoặc tạo token HMAC, việc cấp phát các mảng byte ngắn hạn trên heap là hoàn toàn lãng phí. Thay vào đó, hãy mượn bộ nhớ trực tiếp từ stack của thread bằng stackalloc:
using System;
using System.Security.Cryptography;
public static void ComputePayloadToken(ReadOnlySpan<byte> data, Span<byte> destinationHash)
{
// Vùng đệm tạm 64-byte được cấp phát trực tiếp trên stack frame của CPU
Span<byte> scratchBuffer = stackalloc byte[64];
int bytesToCopy = Math.Min(data.Length, scratchBuffer.Length);
data.Slice(0, bytesToCopy).CopyTo(scratchBuffer);
SHA256.HashData(scratchBuffer, destinationHash);
}
Stack của thread có dung lượng khá nhỏ—thường là 1 MB trên hệ thống Linux và Windows 64-bit. Đừng bao giờ dùng stackalloc cho dữ liệu đầu vào động không giới hạn từ người dùng. Hãy giữ dung lượng cấp phát dưới 1 KB để tránh lỗi sập ứng dụng nghiêm trọng do StackOverflowException.
Mẹo 4: Xử lý pipeline bất đồng bộ với Memory<T> và ArrayPool
Socket và network stream đòi hỏi các thao tác đọc bất đồng bộ, đồng nghĩa với việc không thể sử dụng span vốn chỉ tồn tại trên stack. Hãy kết hợp Memory<T> với ArrayPool<T> để giữ cho pipeline async của bạn không phát sinh cấp phát:
using System.Buffers;
using System.IO;
using System.Threading.Tasks;
public async Task ProcessStreamAsync(Stream networkStream)
{
// Thuê một khối 4KB thay vì tạo mới một mảng
byte[] rentedArray = ArrayPool<byte>.Shared.Rent(4096);
Memory<byte> memoryBuffer = rentedArray.AsMemory(0, 4096);
try
{
int bytesRead = await networkStream.ReadAsync(memoryBuffer);
// Chuyển sang Span để parse đồng bộ
ReadOnlySpan<byte> packetSlice = memoryBuffer.Slice(0, bytesRead).Span;
ProcessPacket(packetSlice);
}
finally
{
// Trả mảng về pool để các luồng khác có thể tái sử dụng
ArrayPool<byte>.Shared.Return(rentedArray);
}
}
private static void ProcessPacket(ReadOnlySpan<byte> packet)
{
// Logic parse dữ liệu hoạt động mà không chạm đến heap
}
Khi chúng tôi chuyển đổi pipeline nạp dữ liệu sang mẫu này, số đợt thu gom Gen 0 đã giảm mạnh từ 350 lần mỗi phút xuống gần như bằng 0, giúp giảm 32% tổng tải CPU.
Khi nào nên sử dụng từng kiểu
Bạn không cần phải viết lại toàn bộ codebase của mình bằng span. Mã logic nghiệp vụ thông thường, dashboard quản trị và các tập lệnh báo cáo theo lô hiếm khi tạo ra áp lực bộ nhớ đủ lớn để nhận thấy sự khác biệt. Hãy tập trung sự chú ý vào các hot path: pipeline nạp telemetry, bộ tuần tự hóa tùy chỉnh, middleware và các consumer xử lý lượng thông điệp lớn.
Dưới đây là bảng tra cứu nhanh dành cho bạn:
Span<T>/ReadOnlySpan<T>: Parse đồng bộ, xử lý chuỗi tốn CPU và biến đổi dữ liệu tại chỗ (in-place) trên các buffer không bao giờ vượt qua điểmawait.Memory<T>/ReadOnlyMemory<T>: Phân đoạn buffer qua các thao tác I/O bất đồng bộ (nhưStream.ReadAsync) và các mô hình dữ liệu cần lưu trữ các đoạn cắt bên trong các class trên heap.ArrayPool<T>.Shared: Các bộ đệm tạm có kích thước đáng kể (từ 1 KB đến hơn 64 KB) cần được thuê, điền dữ liệu và nhanh chóng hoàn trả trong các request lặp đi lặp lại.
Bằng cách áp dụng Span<T> và Memory<T> vào các đường dẫn quan trọng về hiệu năng, bạn sẽ có được hiệu quả zero-copy của các con trỏ thô mà không phải đánh đổi sự an toàn kiểu dữ liệu và tính năng bảo vệ bộ nhớ vốn làm nên sự tuyệt vời của C#.

