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 — và con số cuối cùng chênh nhau 166 lần cho cùng một lời gọi malloc.

Stack vs heap: chi phí cấp phát

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.

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.

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 nsgấ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.

Một lần tôi đo hớ: hai niềm tin 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.

Sự thật, và là bài học đo lường: 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 tôi tin "malloc luôn syscall nên luôn đắt", tôi đã tránh nó một cách mê tín; nếu tin "stack và heap như nhau", tôi đã bỏ qua một khác biệt 5-12 lần ngay ở đường nóng.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả 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.

Hệ quả thứ ba là tinh thần đo lường: một lời gọi có thể rẻ hay đắt tùy đường đi bên trong, không tùy cái tên. Con số mang theo: stack cấp phát ~1 ns (chỉ trừ con trỏ), heap malloc/free ~5-12 ns (5-12× stack) — nên biến cục bộ rẻ hơn malloc rõ rệt; và malloc KHÔNG syscall mỗi lần: glibc giữ block đã free trong user-space (tcache/arena) và tái dùng, nên cả malloc(1MB) cũng chỉ ~12 ns, CHỈ khi heap phải mọc (mmap/brk) mới syscall — cùng malloc(1MB) chênh 166× (11,7 ns cache vs 1943 ns ép mmap). Giá của malloc tùy nó có chạm nhân hay không, không tùy kích thước. Stack rẻ nhất; heap rẻ trên đường cache; chỉ đắt khi chạm nhân.

Thử ba mươi giây

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)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. Ba mươi giây đó 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.