Phần trước ta thấy malloc phần lớn không gọi vào hệ điều hành — nó phục vụ từ một bộ đệm trong user-space, và chỉ đắt khi heap phải "mọc". Câu hỏi tự nhiên tiếp theo: heap mọc bằng cách nào? Câu trả lời là hai lời gọi hệ thống cổ điển — brk và sbrk — dời cái "đỉnh" của heap lên. Nhưng đo kỹ thì cả cách heap lớn lên lẫn cách nó (không) co lại đều khác xa trực giác. Tôi đo trong container gcc:13 bằng cách theo dõi trực tiếp con trỏ đỉnh heap qua sbrk(0).
Program break: cái đỉnh của heap
Heap là một vùng nhớ liền mạch, và đỉnh của nó có một tên: program break. Mọi thứ dưới break là heap đã có; muốn heap lớn hơn, ta dời break lên. Đó là việc của sbrk(increment) (dời break lên increment byte) và brk(addr) (đặt break tới địa chỉ addr). Một mẹo hữu ích: sbrk(0) không dời gì, chỉ trả về vị trí break hiện tại — cho ta một cách nhìn thẳng vào đỉnh heap để quan sát nó di chuyển.
malloc dùng chính cơ chế này để lấy bộ nhớ thô từ nhân (cho các block dưới ngưỡng ~128KB), rồi tự cắt nhỏ phục vụ từng lời gọi. Nhưng câu hỏi là: nó gọi brk bao nhiêu lần? Và khi bạn free, break có tụt xuống trả bộ nhớ về hệ điều hành không? Tôi đo cả hai.
Đo: mọc theo lô, và không co lại
Đầu tiên, cấp 10.000 block nhỏ (64 byte mỗi cái) và in break sau mỗi 2.000 lần:
break ban đầu: 0xe6f2000
sau 2000 malloc: +264 KB <- break dời
sau 4000 malloc: +396 KB (chỉ +132 KB nữa)
sau 6000 malloc: +660 KB
sau 8000 malloc: +792 KB
sau 10000 malloc: +924 KB
tổng: break mọc +924 KB cho 10000×64B = 625 KB dữ liệu
Break dời lên — vậy malloc đúng là mọc heap qua brk. Nhưng nhìn kỹ bước nhảy: giữa 2.000 và 4.000 lần malloc, break chỉ mọc thêm 132 KB — dù đã có 2.000 lời gọi malloc trong khoảng đó. Nghĩa là malloc không gọi brk mỗi lần; nó xin brk một lô lớn (glibc mọc top-chunk từng mảng ~132 KB), rồi cắt hàng nghìn block nhỏ từ lô đó mà không đụng tới nhân. Đây chính là "đường cache" của phần 30: brk chỉ được gọi khi lô hiện tại cạn — vài lần cho 10.000 malloc, không phải 10.000 lần.
Giờ đến câu hỏi gây bất ngờ nhất. free cả 10.000 block, rồi đo lại break và RSS:
Sau khi FREE hết 10000 block:
break: vẫn +924 KB (KHÔNG tụt xuống!)
RSS : 2080 KB -> 2080 KB (gần như không giảm)
Break không tụt. RSS (bộ nhớ vật lý thật) cũng không giảm. Bạn vừa giải phóng toàn bộ 10.000 block, mà tiến trình vẫn giữ nguyên ~2 MB. Vì free một block nhỏ không trả bộ nhớ về hệ điều hành — nó chỉ trả block về free-list của glibc bên trong vùng heap, để lần malloc sau tái dùng ngay. Break chỉ được brk xuống (trim) khi có một khối rảnh đủ lớn ở ngay đỉnh heap — điều hiếm xảy ra vì phân mảnh.
Cuối cùng, một block lớn (2 MB, trên ngưỡng 128 KB):
malloc(2 MB): break TRƯỚC = SAU (không đổi) -> dùng mmap RIÊNG, không qua brk
free(2 MB) : mmap -> munmap, TRẢ về OS ngay
malloc(2 MB) không đụng tới break — nó cấp bằng mmap một vùng riêng biệt, và free nó gọi munmap trả thẳng về hệ điều hành. Đây là lý do block lớn hành xử "sạch sẽ" hơn (trả RAM ngay khi free), còn block nhỏ thì bị giữ lại.
Một lần tôi đo hớ: hai lầm tưởng về brk và free
Tôi vào đo với hai niềm tin. Thứ nhất: "mỗi malloc gọi brk/sbrk để xin bộ nhớ từ OS". Sai — malloc xin brk một lô lớn rồi phục vụ hàng nghìn lời gọi từ lô đó không syscall; brk chỉ khi lô cạn. Thứ hai, nguy hiểm hơn trong thực tế: "free là trả RAM về hệ điều hành". Sai cho block nhỏ — đo cho thấy sau khi free 10.000 block, break và RSS không hề tụt; glibc giữ lại toàn bộ để tái dùng.
Bài học đo lường: malloc mọc heap theo lô (nên phần lớn lời gọi không syscall), và free block nhỏ trả bộ nhớ về bộ cấp phát chứ không về hệ điều hành — break và RSS thường không giảm. Hệ quả rất thực tế: một tiến trình cấp phát nhiều rồi giải phóng hết vẫn có thể giữ RSS cao. Đây không phải rò rỉ bộ nhớ (bộ nhớ đã được free, glibc sẽ tái dùng cho lần cấp sau), nhưng nó trông như rò rỉ nếu bạn chỉ nhìn RSS. Nếu tôi tin "free là trả về OS", tôi đã hoảng loạn khi thấy RSS không giảm sau khi dọn dẹp, hoặc kết luận nhầm có rò rỉ — trong khi đó là hành vi đúng của bộ cấp phát.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: RSS không giảm sau khi free nhiều không có nghĩa là rò rỉ. Trước khi đi săn "memory leak" vì thấy RSS cao dai dẳng, hãy nhớ: glibc giữ lại bộ nhớ đã free của các block nhỏ để tái dùng. Rò rỉ thật là khi bạn quên free (bộ nhớ không quay lại free-list); còn RSS cao sau khi free đúng là do allocator giữ, không phải lỗi của bạn. Công cụ như valgrind/ASan phân biệt được hai ca này; đừng đoán qua RSS.
Hệ quả thứ hai: muốn thực sự trả bộ nhớ về OS, hiểu cơ chế của bộ cấp phát. Nếu bạn cần RSS giảm sau một đợt xử lý lớn (ví dụ một tiến trình dài hạn), có thể gọi malloc_trim(0) để yêu cầu glibc trim heap, hoặc cấu trúc để dữ liệu lớn dùng block mmap (trên ngưỡng 128 KB, tự trả về OS khi free), hoặc cô lập công việc ngốn RAM vào một tiến trình con rồi để nó chết. Chọn công cụ theo việc bạn cần RAM quay lại hệ hay chỉ cần tái dùng nội bộ.
Hệ quả thứ ba là tinh thần đo lường: quan sát trực tiếp cơ chế (break, RSS) thay vì suy từ mô hình đơn giản. Con số mang theo: malloc mọc heap qua brk THEO LÔ (~132KB mỗi lần), nên hàng nghìn malloc nhỏ chỉ tốn vài lần brk, phần lớn không syscall (đo: 10000 malloc(64B) chỉ mọc break vài bước); và free block nhỏ KHÔNG trả RAM về OS — chỉ trả về free-list glibc, break và RSS không tụt (đo: free hết 10000 block, RSS 2080->2080 KB), tiến trình giữ để tái dùng chứ không rò rỉ; chỉ block lớn (>128KB, mmap riêng) mới trả về OS lúc free (munmap). "Xin" và "trả" bộ nhớ với OS thưa thớt hơn nhiều so với số lần bạn malloc/free.
Thử ba mươi giây
Viết một chương trình in sbrk(0) (đỉnh heap hiện tại), rồi malloc vài nghìn block nhỏ và in sbrk(0) lại — bạn sẽ thấy break dời lên, nhưng nếu in sau mỗi vài trăm malloc, nó nhảy theo bậc chứ không tăng mượt: đó là malloc xin brk từng lô. Rồi free hết và in sbrk(0) một lần nữa: nó gần như không đổi — bộ nhớ vẫn thuộc về tiến trình bạn, glibc chỉ cất vào free-list. Nếu muốn thấy nó thật sự trả về OS, thử malloc một block 2 MB và đọc /proc/self/maps — nó nằm ở một vùng mmap riêng, và free sẽ xoá vùng đó. Ba mươi giây đó cho bạn thấy cái ranh giới mà malloc/free che giấu: giữa "bộ nhớ của bộ cấp phát" và "bộ nhớ của hệ điều hành" — và vì sao RSS của một tiến trình kể một câu chuyện khác với số byte nó đang thực sự dùng.