phần page fault ta thấy mmap là bộ nhớ lười — trang chỉ cấp khi chạm. Nhưng trong code hằng ngày bạn gọi malloc, không phải mmap trực tiếp. Vậy mallocmmap khác nhau ra sao, và khi nào nên dùng cái nào? Trực giác thường nói "malloc luôn nhanh, mmap luôn chậm" hoặc "malloc chỉ là vỏ bọc quanh mmap". Cả hai đều chưa đúng. Tôi đo trong container gcc:13 (ARM), và phát hiện malloc nhanh vì một lý do cụ thể — không phải phép màu — mà một tinh chỉnh nhỏ có thể tắt đi.

mmap vs malloc

Hai tầng cấp phát

mmap là một system call: bạn xin nhân một vùng trang mới thẳng từ hệ điều hành, và trả lại bằng munmap. Mỗi lần cấp là một syscall (~100 ns) cộng page fault khi chạm (~vài trăm ns), và một syscall nữa khi trả.

malloc không phải syscall — nó là một bộ cấp phát trong thư viện (glibc) đứng trên mmap/brk. Bí quyết tốc độ của nó là tái dùng: glibc giữ một "arena" các khối bộ nhớ đã xin từ OS; khi bạn free, khối không trả về OS mà quay về arena; lần malloc sau lấy lại từ arena — không syscall, không page fault. Cái giá đắt của OS chỉ trả một lần rồi amortize qua hàng nghìn lần cấp/trả, đúng như chi phí ẩn của thư viện mà sê-ri đồng thời đã đo.

Còn một tầng nữa: malloc cho khối nhỏ còn gộp nhiều yêu cầu vào một vùng lớn xin từ OS (qua brk, mở rộng heap), chia nhỏ trong thư viện. Nên hàng nghìn malloc(48) không phải hàng nghìn syscall, mà vài lần mở rộng heap rồi cắt nhỏ — lại một dạng amortize. mmap trực tiếp không có tầng gộp này: mỗi lời gọi đi thẳng xuống nhân, làm tròn lên cả trang (4 KB) dù bạn chỉ xin 48 byte, nên vừa chậm vừa phí bộ nhớ cho khối nhỏ.

Đo: malloc 5ns, mmap trực tiếp 1374ns

Tôi đo cấp+trả một khối (chạm 1 byte để công bằng), ở hai cỡ — nhỏ (48 byte) và lớn (256 KB, trên ngưỡng mmap mặc định 128 KB của glibc):

CỠ NHỎ 48 byte:
  malloc+free            :    5,4 ns
  mmap+munmap trực tiếp  : 1374,5 ns   -> mmap chậm 254×
CỠ LỚN 256 KB:
  malloc+free (default)  :   10,4 ns
  mmap+munmap trực tiếp  : 1963,8 ns

Với khối nhỏ, malloc (5,4 ns) nhanh hơn mmap trực tiếp (1374 ns) 254 lần — vì malloc tái dùng khối từ arena còn mmap trả một syscall + fault + munmap mỗi lần. Không bất ngờ.

Bất ngờ là cỡ lớn: malloc 256 KB chỉ 10,4 ns — gần nhanh như khối nhỏ, và nhanh hơn mmap trực tiếp ~190 lần. Nhưng 256 KB trên ngưỡng 128 KB mà sách nói glibc sẽ dùng mmap. Nếu malloc-lớn thật sự mmap mỗi lần, nó phải ~1400 ns, không phải 10 ns. Vậy chuyện gì xảy ra?

Một lần tôi đo hớ: ngưỡng mmap là động

Tôi vào đo với niềm tin từ sách: "malloc cho khối lớn hơn M_MMAP_THRESHOLD (128 KB) luôn dùng mmap, nên nó chậm ngang mmap trực tiếp". Con số 10,4 ns bác bỏ ngay. Để hiểu, tôi ép ngưỡng mmap về 0 (mallopt(M_MMAP_THRESHOLD, 0)) — buộc mọi malloc lớn phải mmap thật:

malloc 256KB mặc định (ngưỡng động): 12,5 ns
malloc 256KB ép MMAP_THRESHOLD=0    : 1444,6 ns   -> chậm 116×

Ép mmap làm malloc-256KB chậm 116 lần — thành ~1445 ns, gần bằng mmap trực tiếp. Điều này lộ ra sự thật: ở chế độ mặc định, malloc-256KB không mmap mỗi lần. Vì ngưỡng mmap của glibc là động, không cố định: khi glibc thấy bạn free các khối mmap'd lặp đi lặp lại (dấu hiệu một vòng cấp/trả nóng), nó nâng ngưỡng lên (tới tối đa 32 MB), giữ những khối đó trong arena để tái dùng thay vì trả OS rồi xin lại. Nhờ vậy vòng lặp cấp/trả 256 KB của tôi nhanh như cấp nhỏ — glibc đã học rằng mmap mỗi lần là lãng phí.

Bài học đo lường: "malloc nhanh" không phải một thuộc tính cố hữu — nó là kết quả của tái dùng, và tái dùng có điều kiện. Khi buộc malloc phải thật sự xin OS (ngưỡng=0), nó chậm y hệt mmap trực tiếp — chứng minh tốc độ đến từ né syscall, không từ malloc "giỏi hơn" mmap. Nếu tôi tin "malloc lớn = mmap = chậm" mà không đo, tôi đã tránh malloc-lớn một cách vô căn cứ; và nếu tin "malloc luôn nhanh" mà không hiểu vì sao, tôi sẽ ngạc nhiên khi một mẫu cấp phát không cho glibc tái dùng (ví dụ cấp nhiều khối lớn cùng sống rồi mới free) làm malloc chậm hẳn.

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

Hệ quả đầu tiên: đừng gọi mmap/munmap trực tiếp trong vòng nóng cho khối nhỏ hay vừa — dùng malloc. malloc amortize chi phí OS qua tái dùng; mmap trực tiếp trả syscall + fault + munmap mỗi lần (~1,4 µs). mmap trực tiếp chỉ đáng cho những vùng lớn, sống lâu (ánh xạ file, bộ đệm khổng lồ cấp một lần), nơi bạn muốn kiểm soát trực tiếp và chi phí một lần không đáng kể.

Hệ quả thứ hai: mẫu cấp phát quyết định malloc nhanh hay chậm, không chỉ kích thước. Một vòng cấp/free lặp lại cùng cỡ thì cực nhanh (glibc tái dùng); nhưng cấp nhiều khối lớn cùng sống rồi mới free tất cả buộc glibc xin OS cho từng khối (không có gì để tái dùng), và bạn trả full giá mmap mỗi khối. Nếu profiler chỉ malloc là điểm nóng, hãy nhìn mẫu cấp phát, không chỉ số lần gọi.

Hệ quả thứ ba là tinh thần đo lường: tốc độ đến từ tái dùng, không từ phép màu — và điều kiện tái dùng có thể tắt. Con số mang theo: malloc nhanh (48B: 5,4 ns; 256KB: 10,4 ns) vì tái dùng khối trong arena, KHÔNG syscall — trong khi mmap trực tiếp trả syscall + page fault + munmap mỗi lần (48B: 1374 ns, chậm 254×); malloc-lớn KHÔNG luôn mmap vì ngưỡng M_MMAP_THRESHOLD của glibc là ĐỘNG (nâng khi thấy free lặp lại, giữ khối trong arena), và ép MMAP_THRESHOLD=0 làm malloc 256KB chậm 116× (1445 ns) — ngang mmap trực tiếp — chứng minh tốc độ đến từ né syscall. Dùng malloc mặc định cho hầu hết; dành mmap cho vùng lớn sống lâu.

Thử ba mươi giây

Viết một vòng lặp malloc(size) rồi free ngay, hàng trăm nghìn lần, và bấm giờ — bạn sẽ thấy vài ns mỗi lần dù size lớn tới đâu, vì glibc tái dùng. Rồi đổi mẫu: malloc một mảng nhiều khối lớn (giữ hết con trỏ, chưa free), rồi mới free tất cả — bấm giờ lại. Lần này glibc không có gì để tái dùng giữa các lần cấp, nên với khối trên ngưỡng mmap bạn sẽ thấy mỗi cấp chậm hàng trăm ns (mmap thật). Ba mươi giây so hai mẫu đó cho bạn hiểu điều mà "malloc nhanh" giấu đi: tốc độ của malloc phụ thuộc bạn có cho nó tái dùng hay không — và khi không, nó chỉ nhanh bằng mmap trần bên dưới.