bài trước đọc /proc/self/status ta thấy hai con số bộ nhớ: VmSizeVmRSS. Nhìn qua chúng như hai cách gọi cùng một thứ — "chương trình dùng bao nhiêu bộ nhớ" — nhưng chúng khác nhau một trời một vực, và lẫn chúng là nguồn hoảng loạn phổ biến nhất khi đọc top. Bài này cấp phát 500MB bằng nhiều cách rồi theo dõi hai con số đó thay đổi, và vấp một cái bẫy về chính chữ "cấp phát".

RSS so với VSZ

VSZ là đặt chỗ ảo, RSS là RAM thật

VSZ (VmSize) là tổng bộ nhớ ảo mà tiến trình đã đặt chỗ trong không gian địa chỉ của nó: mọi vùng nó đã yêu cầu — mã, ngăn xếp, heap, thư viện, mọi lần malloc/mmap. Đây chỉ là những mục ghi trong bảng "các vùng địa chỉ của tôi", chưa chắc tương ứng với RAM thật nào.

RSS (VmRSS, Resident Set Size) là phần bộ nhớ ảo đó thật sự đang nằm trong RAM ngay lúc này. Đây mới là con số RAM vật lý mà tiến trình đang chiếm.

Vì sao chúng lệch nhau? Vì Linux cấp phát bộ nhớ lười (lazy). Khi bạn gọi malloc(500MB), nhân chỉ ghi vào bảng "vùng 500MB này là của anh" và trả về ngay — nó chưa cấp một byte RAM nào. Chỉ khi chương trình thật sự chạm vào một trang (đọc hoặc ghi), CPU sinh một lỗi trang (page fault), nhân mới lúc đó cấp một trang RAM vật lý và ánh xạ vào. VSZ tăng ngay lúc malloc; RSS chỉ tăng dần khi từng trang được chạm tới.

Đo: 500MB ảo, RSS nhích từng chút

Tôi viết một chương trình C malloc 500MB rồi đọc VmSize/VmRSS từ /proc/self/status ở từng bước:

Bước VSZ (ảo) RSS (RAM thật)
nền (trước khi cấp) 2 MB 1 MB
sau malloc(500MB) chưa chạm 514 MB 1 MB
sau khi chạm 250MB đầu 514 MB 259 MB
sau khi chạm hết 500MB 514 MB 513 MB

Dòng thứ hai là điểm mấu chốt: malloc 500MB làm VSZ nhảy vọt thêm 512MB, nhưng RSS vẫn 1MB — gần như không đổi. Nửa gigabyte "đã cấp phát" mà không tốn một chút RAM thật nào, vì tôi chưa chạm vào nó. Khi tôi chạm 250MB đầu (ghi một byte mỗi 4096 byte — một byte mỗi trang), RSS lên 259MB; chạm nốt nửa sau, RSS lên 513MB. VSZ đứng yên ở 514MB suốt — vì tôi không đặt chỗ thêm, chỉ hiện thực hóa chỗ đã đặt. RAM thật chỉ tốn đúng bằng phần tôi thật sự đụng tới.

Một lần tôi đo hớ: tưởng calloc phải tốn RAM ngay

Đến đây tôi đã tin chắc quy luật, và định thử một biến thể để chốt: calloc. Khác malloc, calloc cam kết trả về vùng nhớ đã được xóa về 0. Tôi lập luận: "để đảm bảo mọi byte bằng 0, hệ thống phải ghi số 0 khắp cả 500MB — tức là chạm vào từng trang — nên RSS phải nhảy lên 500MB ngay sau calloc, kể cả khi tôi chưa đụng vào". Nghe rất chặt chẽ.

Tôi chạy, và RSS sau calloc(500MB) là... 1MB. Y hệt malloc chưa chạm. Hai con số chọi thẳng vào lập luận của tôi — và theo kỷ luật, khi kết quả mâu thuẫn với dự đoán, thường là tôi hiểu sai cơ chế, chứ không phải công cụ đo sai.

Sự thật hay hơn tôi tưởng: nhân không ngồi ghi số 0 vào 500MB. Nó dùng một mẹo: mọi trang ẩn danh mới cấp đều được ánh xạ tới một trang-zero chung duy nhất (một trang RAM toàn số 0, chỉ-đọc, dùng chung cho tất cả). Chương trình đọc vùng calloc sẽ thấy toàn số 0 — đúng cam kết — mà chẳng tốn RAM riêng nào, vì tất cả cùng trỏ về một trang. Chỉ khi bạn ghi vào một trang, nhân mới sao trang-zero đó ra một trang RAM thật riêng cho bạn (copy-on-write) rồi mới cho ghi. Nên calloc 500MB chưa ghi = VSZ +500MB, RSS ~0; và đúng như vậy, khi tôi ghi khắp vùng calloc, RSS mới lên 513MB. Cái tôi đo hớ là cho rằng "xóa về 0" bắt buộc phải chạm mọi trang — nhân khôn hơn thế.

Bài học đo lường: VSZ là đặt chỗ ảo, RSS mới là RAM thật, và ngay cả "cấp phát đã zero" cũng không tốn RAM cho tới khi ghi. Đừng nhìn VSZ (hay tin lời hứa "calloc đã zero") mà kết luận chương trình đã ngốn ngần ấy RAM. Con số duy nhất phản ánh RAM vật lý là RSS, và nó chỉ đếm những trang thật sự bị chạm.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: khi lo về bộ nhớ, nhìn RSS chứ đừng nhìn VSZ. Một tiến trình JVM hay Go thường có VSZ khổng lồ (hàng chục GB) vì runtime đặt chỗ ảo rộng rãi cho heap, nhưng RSS thật chỉ vài trăm MB. Người mới đọc top thấy cột VIRT (chính là VSZ) to kinh hoàng và tưởng máy sắp hết RAM — nhầm. Cột RES (RSS) mới là thứ ăn vào RAM vật lý và có thể gây OOM. Đây cũng là lý do bạn malloc được một vùng lớn hơn cả RAM máy mà không lỗi: nó chỉ là đặt chỗ ảo, chưa tiêu gì (cấp phát vượt mức, như bài phần 35 đã đo).

Hệ quả thứ hai: cấp phát lười nghĩa là "hết bộ nhớ" xảy ra lúc CHẠM, không phải lúc cấp. malloc gần như luôn thành công (chỉ đặt chỗ ảo); nhưng khi chương trình chạm vào các trang đó và RAM đã cạn, lỗi trang không cấp nổi RAM và OOM killer ra tay — muộn hơn nhiều so với lời gọi malloc. Nên một chương trình có thể "cấp phát ổn" rồi chết vài phút sau khi bắt đầu điền dữ liệu vào vùng đã cấp. Muốn biết một dịch vụ thật sự cần bao nhiêu RAM, phải đo RSS đỉnh khi nó chạy tải thật, không phải cộng các con số malloc.

Hệ quả thứ ba là một cảnh báo tinh tế về việc cộng RSS. Con số mang theo: VSZ là bộ nhớ ảo đã đặt chỗ (có thể lớn hơn cả RAM máy), RSS là RAM vật lý thật đang chiếm; RAM chỉ tốn khi trang bị CHẠM — malloc chưa chạm và cả calloc chưa ghi đều RSS ~0. Một điều nữa cần nhớ khi đọc RSS của nhiều tiến trình: RSS của mỗi tiến trình bao gồm cả các trang chia sẻ (thư viện .so dùng chung, trang-zero, vùng mmap chia sẻ). Nên cộng RSS của nhiều tiến trình cùng chạy có thể ra số lớn hơn RAM thật của máy — vì cùng một trang thư viện bị đếm nhiều lần. Đó chính là chỗ mà bản đồ bộ nhớ smaps và chỉ số PSS bước vào, nhưng đó là chuyện của bài sau.

Thử ba mươi giây

Trên một máy Linux, chạy ps -o pid,vsz,rss,comm -p $(pgrep -n <tên-tiến-trình>) cho một chương trình lớn (trình duyệt, JVM, database). So cột VSZ với RSS: bạn gần như chắc chắn thấy VSZ lớn hơn RSS nhiều lần — có khi VSZ hàng chục GB còn RSS vài trăm MB. VSZ đó là đặt chỗ ảo; RSS mới là RAM máy bạn thật sự đang trả. Rồi mở top, để ý cột VIRTRES: lần sau thấy một tiến trình VIRT khổng lồ, đừng hoảng — nhìn RES để biết nó thật sự tốn bao nhiêu RAM, đúng khác biệt bài này đo.