Hình dung ba cách lấy tiền tiêu. Rút từ ví trong túi: tức thì, một động tác. Ra cây ATM ngay dưới sảnh: mất vài giây, phải bấm vài nút. Lái xe tới kho tiền của ngân hàng: cả buổi. Điều thú vị là số tiền bạn cần gần như không quyết định bạn mất bao lâu — mà là bạn phải đi xa tới đâu để lấy nó. Cấp phát bộ nhớ có đúng ba nấc như vậy, và cùng một lời gọi malloc có thể ở nấc "ví" hay nấc "kho tiền" tùy hoàn cảnh — chênh nhau tới 166 lần.
Khi bạn viết int buf[128]; bên trong một hàm, hay char *p = malloc(512);, cả hai đều "xin bộ nhớ" — nhưng chúng đi qua hai cơ chế hoàn toàn khác nhau, với chi phí lệch nhau hàng chục lần. Cái đầu cấp trên stack; cái sau trên heap. Nhiều người coi hai kiểu cấp phát là tương đương, hoặc ngược lại tin rằng malloc luôn đắt vì "nó phải xin bộ nhớ từ hệ điều hành". Cả hai niềm tin đều sai theo cách đo được. Tôi đo chi phí cấp phát của stack, của heap nhỏ, và của heap lớn trong container gcc:13.
Hai nơi cấp bộ nhớ
Stack: biến và mảng cục bộ nằm ở đây. "Cấp phát" một biến cục bộ chỉ là trừ con trỏ stack đi một lượng — đúng một lệnh CPU. "Giải phóng" là cộng con trỏ lại khi hàm return. Không có danh sách rảnh, không bookkeeping, không tìm kiếm. Gần như miễn phí, và tự động theo phạm vi hàm. Đây là cái ví trong túi.
Heap: malloc/free. Đây đi qua một bộ cấp phát bộ nhớ (trong glibc là ptmalloc) quản lý một danh sách các block rảnh. malloc phải tìm một block đủ lớn (hoặc tách một block lớn), ghi lại metadata; free phải trả block về danh sách, có thể gộp với block kề. Nhiều việc hơn stack hẳn — nhưng câu hỏi thú vị là: bao nhiêu, và có phải nó gọi vào hệ điều hành không?
Tôi đo bằng cách lặp hàng triệu lần: cấp phát rồi giải phóng, chạm một byte để chắc việc cấp là thật (chống -O2 xóa mất), lấy giá trị nhỏ nhất qua nhiều lần chạy.
Đo: stack ~1 ns, heap ~5-12 ns, và cú 166 lần
STACK alloca(512B) : 0,97 ns/lần (chỉ trừ con trỏ stack)
HEAP malloc(32) + free : 5,45 ns/lần (tcache, user-space)
HEAP malloc(512) + free : 5,46 ns/lần (tcache, user-space)
HEAP malloc(1 MB) + free : 11,7 ns/lần (glibc CACHE block, KHÔNG syscall)
HEAP malloc(1 MB) + free (ép mmap): 1943 ns/lần (mmap+munmap mỗi lần, CÓ syscall)
Cấp phát trên stack tốn 0,97 ns — dưới một nano-giây, đúng như kỳ vọng cho "một phép trừ con trỏ". Heap nhỏ (malloc(32) hay malloc(512)) tốn ~5,45 ns — nhiều hơn stack khoảng 5-6 lần, nhưng vẫn rất rẻ. Đáng chú ý: kích thước 32 hay 512 byte gần như không đổi chi phí, vì cả hai đi qua tcache (thread cache) của glibc — một danh sách rảnh nhỏ, nhanh, ngay trong user-space. Cây ATM dưới sảnh.
Giờ đến dòng gây bất ngờ. malloc(1 MB) — một block lớn — bạn tưởng phải đắt hơn nhiều vì 1 MB thường được cấp bằng mmap (một syscall, đắt hàng trăm ns như phần 1, cộng page fault). Nhưng đo ra chỉ 11,7 ns — gần như ngang heap nhỏ! Vì glibc có một ngưỡng mmap động: khi nó thấy bạn liên tục cấp rồi giải phóng block 1 MB, nó giữ lại block đã free trong arena thay vì trả về hệ điều hành, và lần malloc sau tái dùng ngay block đó — không syscall.
Để thấy cái giá thật của việc chạm nhân, tôi ép glibc mmap mỗi lần (hạ ngưỡng, tắt cache): lúc đó cùng malloc(1 MB)+free nhảy lên 1943 ns — gấp 166 lần đường cache. Cùng một dòng code, cùng một kích thước, nhưng chênh nhau 166 lần chỉ tùy vào việc nó có phải gọi vào hệ điều hành hay không. Đúng cái khác biệt giữa rút ví và lái xe tới kho tiền.
(Môi trường đo: container gcc:13 trên host ARM AArch64, g++ -O2, glibc ptmalloc, mỗi số lấy MIN sau warmup. Con số tuyệt đối và ngưỡng mmap đổi theo máy/allocator; cái cần nhìn là thang bậc: stack < heap-cache << heap phải chạm nhân, và cùng một malloc chênh cả trăm lần tùy đường đi.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với hai niềm tin trái nhau mà cùng lệch. Niềm tin thứ nhất, của người mới: "cấp phát là cấp phát, stack hay heap cũng như nhau". Sai — stack ~1 ns còn heap ~5-12 ns, chênh 5-12 lần; biến cục bộ rẻ hơn hẳn một malloc. Niềm tin thứ hai, của người đã biết chút ít: "malloc đắt vì mỗi lần nó xin bộ nhớ từ OS bằng syscall". Cũng sai — đo cho thấy malloc/free (kể cả block 1 MB!) chỉ tốn ~5-12 ns và không hề syscall, vì glibc giữ block đã free trong user-space (tcache cho block nhỏ, arena cho block lớn) rồi tái dùng.
Cả hai lệch về cùng một sự thật: chi phí của malloc không cố định, và không phải lúc nào cũng là một syscall. Nó rẻ trên "đường cache" (tái dùng block cũ trong user-space) và chỉ đắt khi heap phải mọc thật (gọi brk/mmap vào nhân, lúc cấp lần đầu hoặc khi cache cạn). Cái quyết định malloc rẻ hay đắt không phải kích thước, mà là nó có phải chạm nhân hay không — và phép đo 11,7 vs 1943 ns cho cùng malloc(1 MB) chứng minh điều đó gọn gàng. Nếu tin "malloc luôn syscall nên luôn đắt", bạn tránh nó một cách mê tín; nếu tin "stack và heap như nhau", bạn bỏ qua một khác biệt 5-12 lần ngay ở đường nóng.
Về đến code hằng ngày
Điều đầu tiên: ưu tiên stack cho dữ liệu ngắn hạn, kích thước nhỏ và biết trước. Một mảng cục bộ cỡ vừa là gần như miễn phí và tự dọn khi hàm return — không rò rỉ, không phân mảnh. Nếu một vòng nóng đang malloc/free một buffer nhỏ mỗi vòng, cân nhắc một mảng cục bộ (nếu kích thước biết trước) hoặc tái dùng một buffer cấp một lần ngoài vòng — bạn cắt cả cái ~5-12 ns lẫn áp lực lên bộ cấp phát.
Điều thứ hai: đừng sợ malloc một cách mê tín, nhưng hiểu khi nào nó đắt. Trên đường cache (cấp/giải phóng cùng cỡ lặp lại), malloc chỉ vài ns — dùng thoải mái. Nó đắt khi heap phải mọc: lần cấp đầu tiên một cỡ mới, cấp rất lớn, hay một mẫu cấp phát khiến cache liên tục cạn (cấp nhiều cỡ khác nhau, giữ lâu rồi giải phóng loạn). Nếu profiler chỉ vào malloc, tìm xem bạn có đang ép nó mmap/brk lặp lại không — đó là cái 166 lần, không phải bản thân malloc.
Chỗ này lý giải rất nhiều thứ ở tầng ngôn ngữ cao cấp mà thoạt nhìn như phép màu. Cái thang "stack rẻ, heap đắt hơn" chính là lý do trình biên dịch Java (escape analysis + scalar replacement) và Go (escape analysis) cố hết sức đặt object lên stack khi chứng minh được nó không "thoát" khỏi hàm — một object không thoát biến mất khỏi heap và khỏi cả áp lực GC. Đây cũng là gốc của mọi kỹ thuật pool: sync.Pool của Go, object pool trong game engine, Recycler/ByteBuffer pool của Netty — tất cả để tái dùng thay vì cấp/giải phóng liên tục, đúng như tôi khuyên tái dùng buffer ở trên. Và cả họ arena/bump allocator (Rust bumpalo, arena của trình biên dịch, region trong game) là cách kéo heap về gần tốc độ stack: cấp bằng một phép trừ con trỏ, giải phóng cả vùng một lần. Cùng một bài học cái-thang-chi-phí, lặp lại ở mọi runtime.
Muốn tự thấy thì viết hai vòng lặp và bấm giờ: vòng một cấp một mảng cục bộ (hoặc alloca) cỡ vài trăm byte và chạm một phần tử; vòng hai malloc cùng cỡ đó rồi free. Bạn sẽ thấy vòng malloc chậm hơn nhiều lần dù làm việc "tương đương". Rồi thử malloc một block lớn (1 MB) lặp lại và đo — nó vẫn rẻ bất ngờ, vì glibc tái dùng; nếu muốn thấy cái giá thật của "xin OS", gọi mallopt(M_MMAP_THRESHOLD, 65536) và mallopt(M_TRIM_THRESHOLD, 0) để ép mmap mỗi lần, rồi đo lại — con số nhảy lên hàng nghìn ns. Vài phút đó cho bạn thấy điều mà "malloc là malloc" che giấu: chi phí cấp phát trải từ dưới một nano-giây (stack) tới vài nghìn nano-giây (mọc heap qua syscall), và biết mình đang ở đoạn nào của thang đó quan trọng hơn nhiều so với việc malloc hay không.