Khi một tiến trình "dùng 4 GB bộ nhớ", con số đó nghĩa là gì? Có hai câu trả lời rất khác nhau: bộ nhớ ảo (VmSize/VIRT) — lượng không gian địa chỉ nó đã đặt trước; và bộ nhớ thường trú (VmRSS/RES) — lượng RAM vật lý nó đang thật sự chiếm. Hai con số này có thể lệch nhau cả trời một vực, và hiểu sai chúng là gốc của vô số nỗi hoảng "chương trình ăn hết RAM". Bài này đo trực tiếp khoảng cách đó khi cấp phát một vùng lớn — và phát hiện một tầng lười biếng thứ ba mà tôi không ngờ tới, cũng do chính vòng đo của mình để lộ.

Bộ nhớ ảo và RSS

VmSize và VmRSS

malloc() hay mmap() một vùng lớn không lấy RAM. Nó chỉ ghi vào bản đồ địa chỉ của tiến trình rằng "vùng này tồn tại" — tức là nới rộng bộ nhớ ảo (VmSize). RAM vật lý chỉ được cấp khi bạn chạm tới từng trang, qua cơ chế demand paging và lỗi trang đã đo trước đó — và lượng RAM thật đó là bộ nhớ thường trú (VmRSS).

Vì thế VmSize có thể lớn hơn VmRSS rất nhiều, thậm chí lớn hơn cả RAM vật lý: bạn "đặt trước" một vùng địa chỉ khổng lồ nhưng chỉ dùng một phần nhỏ. top hiển thị hai cột này là VIRTRES; /proc/<pid>/status gọi chúng là VmSizeVmRSS. Câu hỏi tôi đo: khi cấp phát một vùng lớn, hai con số này diễn biến ra sao?

Đo: xin 2GB tức thì, RAM chỉ cấp khi ghi

Tôi cấp phát 2 GB theo ba cách, đọc VmSize/VmRSS từ /proc/self/status trước và sau, kèm thời gian:

Thao tác Thời gian VmSize VmRSS
malloc 2GB (chỉ xin) 0,00 ms +2048 MB +0 MB
calloc 2GB (xin + "zero") 0,00 ms +2048 MB +0 MB
malloc 2GB + memset (ghi hết) 58 ms +2048 MB +2048 MB

Hai dòng đầu xác nhận điều cốt lõi: cả malloc lẫn calloc 2 GB đều xong tức thì (0,00 ms) và làm VmSize nhảy +2 GB, nhưng VmRSS không tăng chút nào — không một byte RAM vật lý nào được cấp. Dòng thứ ba: chỉ khi tôi memset (ghi giá trị vào từng byte), RAM thật mới được cấp (+2048 MB), và thao tác đó tốn 58 ms. Con số nói rõ: xin bộ nhớ là ảo và tức thì; dùng bộ nhớ (ghi) mới tốn RAM và thời gian.

Điều bất ngờ nằm ở chỗ calloc — hàm được cho là "cấp phát và xóa trắng" 2 GB — cũng chỉ mất 0,00 ms và 0 MB RAM. Xóa trắng 2 GB trong 0 giây? Đó là lúc tôi đào sâu và vấp một tầng lười thứ ba.

Một lần tôi đo hớ: đọc không cấp RAM, chỉ ghi mới cấp

Để kiểm chứng demand paging, tôi calloc 2 GB rồi chạm hết mọi trang — chắc mẩm VmRSS sẽ vọt lên 2 GB (chạm là cấp, đúng như bài lỗi trang). Nhưng nó đứng im ở ~0. Phản xạ đầu tiên: "demand paging hỏng à? Chạm hết rồi mà RAM không cấp?".

Sai, và cái sai nằm ở chính vòng chạm của tôi. Tôi "chạm" bằng cách đọc (cộng từng byte để tổng khỏi bị trình biên dịch tối ưu đi). Nhưng đọc một trang bộ nhớ ẩn danh còn trắng thì không cấp RAM: nhân trỏ mọi trang chưa ghi vào một trang zero chung (shared zero page) duy nhất, chỉ-đọc — mọi lần đọc đều trả về số 0 từ trang đó, không cần cấp trang vật lý riêng. Nên đọc cả 2 GB mà VmRSS vẫn 0.

Khi tôi đổi vòng chạm sang ghi (p[i] = 1), mọi thứ đúng như mong đợi: mỗi lần ghi đầu tiên vào một trang buộc nhân sao chép trang zero ra một trang riêng (copy-on-write) rồi cho ghi vào đó — và VmRSS nhảy lên 2049 MB. Đây cũng chính là lý do calloc "xóa trắng" 2 GB trong 0 giây: nó không ghi số 0 nào cả, chỉ trỏ mọi trang vào trang zero chung; bộ nhớ đọc ra là 0 mà chẳng tốn công zero hóa gì.

Bài học đo lường: "chạm bộ nhớ" không đủ để cấp RAM — phải ghi; và một lần nữa, chính vòng đo của tôi làm sai điều tôi nghĩ (đọc thay vì ghi) nên con số ra lệch hẳn. Đây là họ hàng với những cái bẫy trước: vòng cộng byte làm nút thắt ở bài mmap, trình biên dịch xóa mất memcpy ở bài fork. Công cụ đo — kể cả cách bạn "chạm" bộ nhớ — là một biến, và một con số phản trực giác (chạm 2 GB mà RAM không lên) là dấu hiệu cần soi lại chính phép chạm.

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

Hệ quả đầu tiên là đọc đúng con số bộ nhớ, đừng hoảng vì VIRT. Một tiến trình có VIRT hàng chục GB là bình thường — nó chỉ đặt trước địa chỉ ảo (thư viện ánh xạ, vùng heap dự phòng, ngăn xếp luồng như bài tạo luồng đã đo). Con số cần theo dõi là RES (RSS) — RAM thật; và nhớ rằng ngay cả RSS cũng đếm cả trang chia sẻ (bài copy-on-write). Khi cảnh báo "hết bộ nhớ", nhìn MemAvailable và RSS, không phải VIRT.

Hệ quả thứ hai là tận dụng cấp phát lười để rẻ, nhưng biết khi nào cái giá tới. Bạn có thể malloc/calloc những vùng lớn gần như miễn phí và chỉ trả tiền cho phần thật sự ghi vào — một mảng thưa khổng lồ, một bộ đệm dự phòng lớn, đều không tốn RAM cho tới khi dùng. Nhưng cái giá bị hoãn: một vòng lặp khởi tạo lần đầu ghi khắp một vùng lớn sẽ bất ngờ chậm (58 ms cho 2 GB ở đây) vì đang trả thuế lỗi trang cho từng trang. Biết điều này giúp bạn không ngạc nhiên khi "lần chạy đầu chậm", và pre-fault (memset sớm, MAP_POPULATE) nếu cần độ trễ ổn định.

Hệ quả thứ ba là calloc rẻ hơn malloc+memset một cách bất ngờ, và biết vì sao. Nếu bạn cần một vùng lớn bắt đầu bằng 0, calloc là lựa chọn đúng: nó tận dụng trang zero chung, cho bạn bộ nhớ đọc-ra-0 mà không tốn công ghi. malloc rồi memset(0) thì thật sự ghi 0 khắp nơi, cấp RAM ngay và chậm. Con số mang theo: cấp phát 2GB làm VmSize +2GB nhưng VmRSS +0 (tức thì); RAM thật chỉ được cấp khi GHI (memset 58ms, RSS +2GB) — còn ĐỌC vùng mới cấp thì chạm trang zero chung, VmRSS vẫn 0; VIRT là địa chỉ đặt trước, RSS là RAM thật, và chỉ ghi mới biến ảo thành thật. "Cấp phát" không phải "dùng"; và "chạm" cũng chưa chắc là "dùng" — chỉ ghi mới thật sự tốn RAM.

Thử ba mươi giây

Viết một chương trình nhỏ cấp phát một vùng lớn rồi ngủ, ví dụ Python: python3 -c "import time; x=bytearray(2*1024**3); time.sleep(60)" (dùng bytearray để ghi 0, khác malloc thuần). Trong 60 giây đó, chạy grep -E 'VmSize|VmRSS' /proc/<pid>/status (hoặc xem VIRT/RES trong top). Với bytearray (có ghi), bạn sẽ thấy cả hai +2 GB. Thử lại nhưng chỉ đặt trước mà không ghi — ví dụ một mảng thưa trong C với malloc mà không chạm — và bạn sẽ thấy VmSize +2 GB nhưng VmRSS ~0. Khoảng cách giữa hai con số đó chính là bộ nhớ bạn đã xin nhưng chưa dùng — đúng thứ bài này đo.