Ở bài về lời gọi hệ thống, ta thấy một syscall tốn khoảng 120 nano giây — đắt hơn hàng chục lần một lời gọi hàm. Vậy malloc, thứ được gọi hàng triệu lần trong một chương trình bình thường, có phải là một syscall không? Nếu có, mỗi lần cấp phát sẽ đắt kinh khủng. Bài này dùng strace soi xem malloc thật sự nói chuyện với kernel thế nào — và câu trả lời giải thích vì sao cấp phát bộ nhớ lại nhanh, cùng một lần tôi đo hớ vì chính trình biên dịch.
malloc là bộ cấp phát ở user space
malloc không phải một syscall. Nó là một bộ cấp phát nằm hoàn toàn trong không gian người dùng (thư viện C, glibc): nó xin bộ nhớ từ kernel theo lô lớn, rồi tự chia nhỏ lô đó ra cho từng lời gọi malloc mà không gọi kernel nữa. Chỉ khi hết bộ nhớ trong kho, nó mới lại xin kernel một lô mới. Nhờ vậy, phần lớn lời gọi malloc chẳng đụng tới hệ điều hành.
Khi cần xin kernel, malloc dùng một trong hai đường, tùy kích thước:
- Khối nhỏ: phục vụ từ heap (còn gọi là arena). Heap là một vùng liền mạch lớn dần bằng cách dời program break — ranh giới cuối của vùng dữ liệu — qua syscall
brk/sbrk. malloc dời break một lần để lấy cả một khối lớn, rồi chia ra cho nhiều lần cấp phát nhỏ. - Khối lớn (vượt một ngưỡng, mặc định khoảng 128 KB nhưng được điều chỉnh động): mỗi khối được cấp riêng bằng một
mmapđộc lập, và khifreethì trả lại kernel bằngmunmap.
Đo: 2000 lần malloc, chỉ 4 syscall
Tôi strace một chương trình gọi malloc(64) hai nghìn lần rồi đếm syscall brk:
2000 lần malloc(64B) -> chỉ 4 lần gọi brk
Hai nghìn lần cấp phát, mà chỉ bốn lần chạm tới kernel. Đó là bằng chứng trực tiếp của việc gom lô: malloc dời program break vài lần để lấy vài khối lớn, rồi cắt chúng ra thành hai nghìn mẩu nhỏ hoàn toàn trong user space. Đo thời gian khẳng định điều này: malloc cộng free một khối 64 byte tốn 9,4 nano giây mỗi lần — nhỏ hơn nhiều con số ~120 ns của một syscall, chứng tỏ tuyệt đại đa số lời gọi không hề vào kernel. Đây là lý do cấp phát bộ nhớ nhỏ rẻ đến mức ta hiếm khi nghĩ tới nó.
Với khối lớn thì khác — strace cho thấy đúng đường mmap:
| Kích thước malloc | Đường tới kernel |
|---|---|
| 64 KB | không mmap riêng (từ heap) |
| 130 KB | không mmap riêng (từ heap) |
| 256 KB | mmap riêng 266.240 byte |
| 1024 KB | mmap riêng 1.052.672 byte |
Khối 256 KB và 1 MB mỗi cái được mmap một vùng riêng (kích thước bằng yêu cầu cộng chút metadata). Đáng chú ý, khối 130 KB vẫn đi qua heap dù nó lớn hơn con số "128 KB" hay được nhắc — vì ngưỡng mmap của glibc được điều chỉnh động lúc chạy chứ không cố định, nên ranh giới thực tế nằm cao hơn một chút.
Một lần tôi đo hớ: optimizer xóa mất malloc
Trước khi có bảng đẹp đẽ trên, tôi vấp một cú lừa. Câu hỏi ban đầu: "malloc(1MB) có dùng mmap không?". Tôi viết một chương trình nhỏ malloc 1 MB rồi free, biên dịch với -O2 cho gọn, và strace. Kết quả: không có một mmap nào cho khối 1 MB — chỉ toàn các mmap của trình liên kết động nạp libc lúc khởi động. Tôi suýt kết luận: "hóa ra malloc khối lớn cũng chẳng gọi mmap".
Con số trống rỗng đó vô lý — malloc 1 MB phải lấy bộ nhớ từ đâu đó. Nhìn lại mã, tôi nhận ra: chương trình malloc(1MB), ghi một byte, rồi free ngay, và không bao giờ đọc lại vùng nhớ đó. Với trình biên dịch, cặp malloc/free mà kết quả không được dùng vào việc gì là mã chết — và gcc ở -O2 xóa sạch cả cặp đó (một tối ưu đã được chuẩn cho phép). Không còn malloc nào chạy thì strace chẳng có gì để bắt. Tôi đang soi một chương trình mà cái mình muốn đo đã bị optimizer bốc hơi.
Biên dịch lại với -O0 — không tối ưu — thì strace lập tức lộ ra mmap(NULL, 1052672, ...) thật cho malloc(1MB). Bài học đo lường, và nó tinh vi hơn tôi tưởng: mức tối ưu -O là một biến ẩn ngay cả trong phép đo bằng strace. Ta hay nghĩ strace đo "chương trình thật làm gì", nhưng nếu trình biên dịch đã xóa mất thao tác ta quan tâm, strace chỉ xác nhận sự vắng mặt do optimizer tạo ra. Muốn đo một thao tác cấp phát thật, phải làm cho nó không thể bị xóa: cho con trỏ thoát ra ngoài (in nó, đưa vào một biến volatile, dùng kết quả thật), hoặc biên dịch -O0. Đây đúng là "kiểm soát biến ẩn như mức -O" mà cả sê-ri lặp lại — lần này áp vào một công cụ tưởng chừng chỉ quan sát thụ động.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: cấp phát nhỏ rẻ, nhưng vẫn có chi phí — đừng cấp phát bừa trong vòng nóng. Vì malloc phần lớn ở user space (~9 ns), nó rẻ hơn syscall nhiều, nhưng không miễn phí: mỗi lần vẫn tốn công tìm khối trống, cập nhật cấu trúc quản lý, và thỉnh thoảng chạm kernel để lấy lô mới. Trong một vòng lặp cực nóng, cấp phát rồi giải phóng liên tục vẫn là gánh nặng; các kỹ thuật như tái dùng bộ đệm (object pool), cấp phát trên ngăn xếp, hay arena thủ công tồn tại chính để né cái ~9 ns đó nhân với hàng triệu lần.
Hệ quả thứ hai: khối lớn đắt hơn bạn nghĩ vì mỗi cái là một cặp mmap/munmap. Một khối vượt ngưỡng (256 KB trở lên ở đây) trả hai syscall cho vòng đời của nó (mmap khi cấp, munmap khi giải phóng), mỗi cái ~120 ns cộng chi phí quản lý bảng trang. Cấp phát rồi giải phóng liên tục các khối lớn trong vòng nóng là một sai lầm hiệu năng thầm lặng — glibc có ngưỡng động một phần để giảm điều này (giữ lại khối lớn vừa free để tái dùng thay vì munmap ngay). Nếu bạn thấy nhiều mmap/munmap trong strace -c, hãy nghi các khối lớn cấp-giải-phóng lặp lại.
Hệ quả thứ ba là bài học mang theo: hiểu tầng nào phục vụ một lời gọi, và đừng để công cụ đo bị optimizer đánh lừa. Con số mang theo: malloc là bộ cấp phát ở user space, gom lô — 2000 khối nhỏ chỉ sinh 4 syscall brk và mỗi malloc+free 64B chỉ 9,4 ns — trong khi khối lớn (256KB+) mỗi cái được mmap riêng; và khi đo bằng strace, nhớ rằng -O2 có thể xóa mất một cấp phát không dùng, làm strace trống rỗng một cách lừa dối. Muốn biết chương trình thật sự làm gì với bộ nhớ, đọc strace/ltrace — nhưng phải chắc cái bạn đo còn tồn tại sau khi trình biên dịch tối ưu.
Thử ba mươi giây
Chạy strace -c -e trace=brk,mmap,munmap ./chuong-trinh-cua-ban trên một chương trình cấp phát nhiều: bạn sẽ thấy số brk/mmap nhỏ đến bất ngờ so với số lần bạn gọi malloc — bằng chứng malloc gom lô. Rồi thử một chương trình chỉ malloc một khối lớn (vài MB) rồi free: nếu bạn biên dịch -O2 mà strace không thấy mmap nào, đừng vội mừng — thêm một dòng dùng thật vùng nhớ đó (in p[0] chẳng hạn) hoặc biên dịch -O0, và mmap sẽ hiện ra. Đó là cách nhìn tận mắt cả hai điều: malloc chọn đường theo kích thước, và optimizer có thể xóa mất thứ bạn tưởng mình đang đo.