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.

Vòng đời bộ nhớ của một chương trình dùng malloc

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.