Hình dung xưởng của bạn có một nhà kho dụng cụ riêng ngay góc phòng. Cần cái búa? Lấy trên kệ, dùng xong trả lại kệ — tức thì, không đi đâu cả. So với việc mỗi lần cần búa lại lái xe ra cửa hàng đồ nghề ngoài phố mua, dùng xong lái xe ra trả — cùng cái búa, nhưng một đằng vài giây, một đằng cả buổi. malloc là cái kho dụng cụ trong xưởng (giữ đồ để tái dùng); mmap trực tiếp là chuyến đi ra cửa hàng (xin hệ điều hành) mỗi lần. Và có một chi tiết bất ngờ về việc cái kho quyết định khi nào tự giữ đồ, khi nào bắt bạn ra phố — mà chỉ đo mới thấy.

Ở 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 malloc và mmap 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.

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ả — đúng chuyến đi ra cửa hàng.

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 cái kho dụng cụ giữ đồ trên kệ.

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? Để 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. Đúng ông quản lý kho thấy bạn cứ mượn-trả mãi cái cưa lớn nên thôi giữ nó trên kệ luôn, khỏi bắt bạn ra phố mỗi lần.

(Môi trường đo: container gcc:13 trên host ARM AArch64, cấp+trả chạm 1 byte, glibc ptmalloc, mỗi số lấy sau warmup. Con số tuyệt đối và ngưỡng đổi theo cấu hình allocator; cái bền vững là: malloc rẻ nhờ tái dùng/né syscall, và ép nó phải xin OS thì nó chậm ngang mmap trần.)

Hai niềm tin đối nghịch, cùng sai

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 — ngưỡng là động, glibc giữ khối để tái dùng. Nếu tin điều này mà không đo, bạn sẽ tránh malloc-lớn một cách vô căn cứ, hoặc tự viết pool phức tạp cho thứ glibc đã làm sẵn.

Nhưng niềm tin ngược cũng sai, và tinh vi hơn: "malloc thì luôn nhanh, vô điều kiện". Không — 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 (116×). Tốc độ của malloc không phải 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. 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 tất cả — buộc glibc xin OS cho từng khối (không có gì trên kệ để lấy), và bạn trả full giá mmap mỗi khối. Sự thật nằm giữa: malloc nhanh vì né syscall qua tái dùng, chứ không phải vì "giỏi hơn" mmap — nên nó chỉ nhanh khi bạn cho nó tái dùng, và chỉ nhanh bằng mmap trần khi không.

Về đến code hằng ngày

Điều đầ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ể.

Điều 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, 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.

Chỗ này cái "tốc độ đến từ tái dùng, có điều kiện" giải thích nhiều thứ ở tầng cao. Các runtime quản lý bộ nhớ (Java, Go, C#) né hẳn chi phí per-object bằng bump allocator (cấp bằng một phép cộng con trỏ) cộng GC — về bản chất là một arena khổng lồ tái dùng liên tục, cùng ý tưởng. Các allocator thay thế như jemalloc/tcmalloc (Facebook, Google dùng) tối ưu chính cái arena/tái-dùng này cho đa luồng. Và có một cú vấp production kinh điển: glibc mặc định tạo nhiều arena (tới 8 × số CPU) để giảm tranh khóa, khiến một app Java/native trong container nhiều lõi có RSS phình bất thường — cách chữa nổi tiếng là đặt MALLOC_ARENA_MAX=2 để giới hạn số arena. Cùng một bài học cái-kho-dụng-cụ: hiệu năng cấp phát đến từ tái dùng có tổ chức, và các nút chỉnh của nó (ngưỡng mmap, số arena) là thứ đáng biết khi tối ưu bộ nhớ thật.

Muốn tự thấy thì 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). Vài phút 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.