Hình dung một công ty thuê văn phòng. Ông quản lý hành chính không chạy đi hỏi chủ tòa nhà mỗi lần có một nhân viên mới — ông thuê nguyên cả tầng một lần, rồi tự kê bàn cho từng người từ chỗ đó. Và đây là chỗ trái khoáy: khi một nhân viên nghỉ việc, ông quản lý không trả lại phần sàn đó cho chủ tòa nhà — ông giữ nguyên cái bàn trống, để sẵn cho người kế tiếp. Nhìn từ ngoài, công ty vẫn "chiếm" nguyên mấy tầng dù nửa số bàn đang trống. malloc và free hành xử y hệt ông quản lý đó — và đó là lý do RSS của một tiến trình thường kể một câu chuyện khác hẳn với số byte nó đang thực sự dùng.

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 — đúng ông quản lý thuê nguyên tầng rồi tự kê bàn. 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. Đúng cái bàn trống ông quản lý giữ lại. 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. Đúng như thuê hẳn một hội trường riêng cho một sự kiện lớn: xong việc là trả ngay cho chủ 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ôi trường đo: container gcc:13 trên host ARM AArch64, glibc malloc, theo dõi break qua sbrk(0) và RSS qua /proc/self/statm. Con số tuyệt đối và ngưỡng mmap (mặc định 128KB) đổi theo cấu hình allocator; cái cần nhìn là hình dạng: mọc theo bậc, free nhỏ không trả về OS, block lớn thì có.)

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

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.

Nhưng có một niềm tin ngược cũng cần chỉnh, và nó khiến người ta hoảng loạn không kém: "RSS không giảm sau khi free hết → chương trình mình bị rò rỉ bộ nhớ". Cũng sai. Đây không phải rò rỉ: bộ nhớ đã được free, glibc sẽ tái dùng nó cho lần cấp sau; nó chỉ trông như rò rỉ nếu bạn nhìn mỗi RSS. Rò rỉ thật là khi bạn quên free — block không bao giờ quay lại free-list. Hai thứ hoàn toàn khác nhau, và phân biệt được chúng là lằn ranh giữa "đi săn con bug không tồn tại suốt một ngày" với "hiểu đúng allocator đang làm gì".

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

Điều đầ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. Công cụ như valgrind/ASan phân biệt được rò rỉ thật (quên free) với RSS cao do allocator giữ; đừng đoán qua RSS.

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

Chỗ này lý giải một loạt hiện tượng "bộ nhớ không chịu trả" mà bạn gặp ở tầng cao. JVM là ví dụ kinh điển: heap đã cấp gần như không bao giờ trả về OS ở các GC cũ (bạn đặt -Xmx4g là nó giữ tới 4G), nên phải tới G1 (-XX:+ShrinkHeapInSteps) và ZGC/Shenandoah mới biết uncommit trả RAM lại. Go có một scavenger chạy nền dùng madvise(MADV_FREE/DONTNEED) để trả trang không dùng — nhưng RSS vẫn có thể trễ nải giảm, gây hoang mang y hệt. Đây cũng là lý do người ta thay glibc malloc bằng jemalloc/tcmalloc cho service dài hạn (chúng trả RAM về OS tích cực hơn). Và cái bẫy đau nhất: một container đặt memory limit sát sao có thể bị OOM-kill vì allocator giữ RSS cao dù ứng dụng đã free — cùng một câu chuyện ông-quản-lý-giữ-bàn-trống, phóng to lên tầng vận hành.

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