Phần 11 đã nói RSS không phải bộ nhớ thật. Bài này chỉ ra một nguyên nhân nữa khiến nó lệch, và lần này lệch theo hướng khiến bạn bị đánh thức lúc ba giờ sáng.
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 bộ cấp phát của tiến trình thay vì quay về hệ điều hành.
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ỏ. 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. 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.
Tôi đã dựng riêng một phép thử cho giả thuyết đó: 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 chuyện này vì nó là lời giải thích tôi từng đọc ở nhiều nơi và từng tin.
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; 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.
Nói lại lần thứ hai 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ã.
Thử ba mươi giây
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.