Hãy hình dung một người quản lý kho vật tư trong công ty. Khi phòng ban cần đồ, anh ta không chạy ra nhà máy mua từng món — anh ta mượn hẳn cả pallet lớn về kho của mình rồi phát lẻ. Khi phòng ban "trả lại" một món, nó về kho của anh ta, không về nhà máy. Nên nếu bạn hỏi nhà máy "công ty này đang giữ bao nhiêu hàng", con số vẫn là cả pallet — dù trong nội bộ mọi món đã được trả sạch. malloc chính là anh quản lý kho đó, và bài này đo cái khoảng cách giữa "chương trình đã trả" và "hệ điều hành vẫn thấy chiếm" — khoảng cách khiến người ta bị gọi dậy lúc ba giờ sáng vì một cái "rò rỉ" không hề tồn tại.
Phần 11 đã nói RSS không phải bộ nhớ thật. Đây là một nguyên nhân nữa khiến nó lệch, và lần này lệch theo hướng đánh lừa.
Rò rỉ không phải rò rỉ
Chương trình cấp 400.000 khối 1 KB rồi giải phóng theo ba bước:
| Bước | RSS | malloc đang giữ cho bạn |
|---|---|---|
| Cấp 400.000 khối | 410.552 KB | 406.255 KB |
| Giải phóng một nửa (xen kẽ) | 410.552 KB | 203.137 KB |
| Giải phóng tất cả | 410.552 KB | 12 KB |
malloc_trim(0) |
4.320 KB | 12 KB |
Dòng thứ ba là dòng đáng nhìn: chương trình không còn giữ byte nào, và ps, top, docker stats, Kubernetes đều vẫn thấy nó chiếm 410 MB. Một lời gọi malloc_trim(0) trả lại 95 lần.
Đây là hình dạng của phần lớn những "rò rỉ bộ nhớ" được báo cáo trong các dịch vụ chạy lâu ngày: bộ nhớ đã được giải phóng đúng cách, chỉ là nó nằm lại trong kho của anh quản lý thay vì quay về nhà máy.
Vì sao free không trả lại
malloc xin bộ nhớ từ nhân theo lô lớn rồi tự cắt nhỏ — mượn cả pallet. free trả khối về danh sách trống của malloc, không phải về nhân. Nó làm vậy vì đúng: gọi lại nhân cho mỗi lần cấp phát sẽ tốn 990 ns mỗi trang (phần 10 đã đo), trong khi cắt từ vùng có sẵn tốn vài chục nano giây.
Vùng heap chính chỉ co lại được từ đỉnh xuống. Tôi từng tin — và đọc ở nhiều nơi — rằng một khối còn sống nằm ở đỉnh sẽ chặn toàn bộ phần trống bên dưới, nên tôi dựng riêng một phép thử: giữ lại đúng một khối ở cuối rồi gọi malloc_trim. Kết quả không như tôi đoán — RSS vẫn tụt xuống 2.740 KB. glibc hiện đại không chỉ hạ đỉnh heap; nó còn dùng MADV_DONTNEED để đục lỗ ở giữa. Giả thuyết "một khối ở đỉnh chặn tất cả" đúng với sách giáo khoa cũ, không đúng với glibc 2.36 mà tôi đo. Tôi ghi lại vì nó là lời giải thích tôi từng tin và truyền lại — kiểm mới biết nó lỗi thời.
Ngưỡng 128 KB
Đây là phép đo bất ngờ nhất của bài. Cấp 200 MB, đổi kích thước từng khối:
| Kích thước khối | Số khối | Sau khi cấp | Sau khi free hết |
|---|---|---|---|
| 16 KB | 12.800 | 206.224 KB | 206.256 KB |
| 64 KB | 3.200 | 206.012 KB | 206.044 KB |
| 127 KB | 1.612 | 205.892 KB | 205.924 KB |
| 128 KB | 1.600 | 212.344 KB | 1.176 KB |
| 256 KB | 800 | 209.140 KB | 1.172 KB |
| 1.024 KB | 200 | 206.732 KB | 1.164 KB |
Chênh 1 KB trong kích thước khối quyết định 200 MB có được trả về hay không.
Nguyên nhân là M_MMAP_THRESHOLD, mặc định 128 KB. Khối nhỏ hơn ngưỡng được cắt ra từ heap (vào kho chung); khối từ ngưỡng trở lên được cấp bằng một lời gọi mmap riêng, và free gọi munmap trả về ngay lập tức — món lớn giao thẳng, nhận thẳng về nhà máy. Nói lại vì nó không trực giác chút nào: cùng một tổng dung lượng, cùng một mẫu cấp phát, chỉ đổi kích thước khối 1 KB, và kết quả bộ nhớ chênh 175 lần.
Ngưỡng này còn tự điều chỉnh: glibc tăng nó lên khi thấy chương trình cấp rồi giải phóng khối lớn liên tục. Nghĩa là hành vi có thể đổi giữa chừng trong cùng một lần chạy. Đặt cứng bằng biến môi trường:
MALLOC_MMAP_THRESHOLD_=131072 ./chuong-trinh # khoa nguong, tat tu dieu chinh
MALLOC_TRIM_THRESHOLD_=131072 ./chuong-trinh # tu trim khi dinh heap du trong
Mỗi luồng một vùng cấp phát riêng
glibc tạo nhiều arena để các luồng không tranh khoá với nhau:
| Số luồng | RSS khi đang giữ 20.000 × 512 B mỗi luồng | Số vùng ẩn danh |
|---|---|---|
| 1 | 11.656 KB | 6 |
| 4 | 43.092 KB | 12 |
| 16 | 168.856 KB | 36 |
Số vùng tăng theo số luồng — đó là các arena phụ. glibc cho phép tối đa 8 lần số nhân CPU, nên một ứng dụng 64 luồng trên máy 16 nhân có thể sinh ra hàng trăm megabyte chỉ vì mỗi arena giữ phần đệm riêng. Giới hạn lại nếu bộ nhớ quan trọng hơn tranh chấp khoá:
MALLOC_ARENA_MAX=2 ./chuong-trinh
Đây là biến mà nhiều hướng dẫn triển khai Java và Ruby khuyên đặt, và lý do là đúng — nhưng nó đánh đổi: ít arena hơn nghĩa là nhiều luồng tranh nhau một khoá hơn.
Ba cách xử lý trong thực tế
Gọi malloc_trim định kỳ. Rẻ và hiệu quả với dịch vụ có lúc rảnh:
#include <malloc.h>
malloc_trim(0); /* goi sau moi dot xu ly lon */
Redis làm đúng chuyện này trong vòng lặp sự kiện, và đó là một phần lý do nó giữ được RSS gọn.
Đổi bộ cấp phát. jemalloc và tcmalloc trả bộ nhớ về hệ điều hành chủ động hơn glibc:
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./chuong-trinh
Đây là lý do Redis khuyến nghị jemalloc, và MySQL, Rust, Firefox đều dùng nó.
Chấp nhận và đo đúng chỗ. Nếu RSS cao nhưng ổn định và không tăng theo thời gian, đó không phải rò rỉ. Con số cần theo dõi là uordblks trong mallinfo2() — lượng malloc thật sự đang giữ cho ứng dụng.
Phân biệt rò rỉ thật với phân mảnh
| Dấu hiệu | Rò rỉ thật | Phân mảnh |
|---|---|---|
| RSS theo thời gian | tăng không ngừng | tăng rồi bằng phẳng |
mallinfo2().uordblks |
tăng cùng RSS | thấp và ổn định |
Sau malloc_trim(0) |
không đổi | tụt mạnh |
| Sau khi tải giảm | vẫn cao | vẫn cao |
Hai dòng giữa là hai phép thử quyết định, và cả hai đều làm được trong vài dòng mã.
Mẫu số chung: một lớp đệm giữ tài nguyên lại để tái dùng, và cái nhìn từ ngoài đánh lừa bạn
Cái lõi ở đây không riêng gì malloc: rất nhiều lớp đệm giữ lại tài nguyên đã "trả" để tái dùng cho nhanh — và người quan sát từ bên ngoài lớp đệm đó thấy tài nguyên vẫn bị chiếm, tưởng nhầm là rò rỉ. Cùng cái bẫy ở mọi tầng.
Trong thế giới Java, đây đúng là quan hệ giữa heap của JVM và RSS mà OS thấy: sau một đợt tải, free trong ứng dụng trả object về heap, GC dọn, nhưng JVM giữ lại heap đã cấp và không trả về OS — nên docker stats vẫn thấy RSS cao, y hệt bài này. Phải bật -XX:+UseG1GC -XX:+ShrinkHeapInSteps hoặc dựa vào các GC mới (ZGC uncommit) mới trả bộ nhớ về; và MALLOC_ARENA_MAX ở trên chính là mẹo quen thuộc cho JVM chạy trong container bị OOMKill.
Trong Go, cùng chuyện: runtime trả bộ nhớ về OS bằng MADV_FREE một cách lười, nên RSS tụt chậm sau một đỉnh tải và người ta hoảng tưởng rò rỉ; đặt GODEBUG=madvdontneed=1 buộc trả sớm hơn — đúng vai trò malloc_trim. Và sync.Pool là một lớp đệm tự tay bạn dựng với đúng đặc tính này.
Còn ở cơ sở dữ liệu, đây là connection pool và buffer pool: pool giữ kết nối/trang đã "trả" để tái dùng, nên đếm kết nối ở tầng OS hay đo RSS của Postgres (shared_buffers cấp sẵn) đều cho con số cao ổn định không phản ánh tải tức thời — đo nhầm chỗ là báo động nhầm.
Mẫu số chung: một lớp đệm giữ tài nguyên đã trả để tái dùng luôn tạo khoảng cách giữa "ứng dụng đang dùng bao nhiêu" và "hệ thống bên ngoài thấy bị chiếm bao nhiêu" — và cái nhìn từ ngoài (RSS, số kết nối OS) gần như luôn cao hơn và ổn định hơn thực tế, khiến phân mảnh/giữ-đệm bị chẩn đoán nhầm thành rò rỉ. Nên khi một dịch vụ trông như đang rò rỉ, câu hỏi đúng không phải "con trỏ nào bị mất" mà là "tôi đang đo ở tầng nào — cái ứng dụng thật sự giữ, hay cái lớp đệm dưới nó đang ôm để tái dùng — và hai con số đó có cùng tăng mãi không" — vì chỉ khi cái ứng dụng giữ tăng không ngừng thì mới là rò rỉ thật.
Muốn tự soi, xem một tiến trình đang chạy có bao nhiêu bộ nhớ nằm trong heap chưa trả lại:
p=<pid>
echo "RSS: $(awk '/^VmRSS/{print $2}' /proc/$p/status) KB"
awk '/\[heap\]/{print "heap:", $0}' /proc/$p/maps
awk '/^Rss:/{s+=$2} /^\[heap\]/{h=1} END{print "tong Rss:", s, "KB"}' /proc/$p/smaps 2>/dev/null
# ep mot tien trinh dang chay tu trim (can gdb va quyen)
gdb -p $p -batch -ex 'call (int)malloc_trim(0)' 2>/dev/null && \
echo "RSS sau trim: $(awk '/^VmRSS/{print $2}' /proc/$p/status) KB"
Nếu dòng cuối cho con số thấp hơn hẳn dòng đầu, cái bạn đang nhìn không phải rò rỉ — và mọi giờ bỏ ra đi tìm con trỏ bị mất sẽ là công cốc.
Phần sau: cgroup v2 — cách nhân thực sự giới hạn CPU, bộ nhớ và I/O.