Ở bài các section ELF ta thấy một mảng toàn cục toàn số 0 nằm trong .bss và không tốn byte nào trong file. Nhưng đó mới là một nửa câu chuyện. Nửa còn lại — thú vị hơn — là chuyện gì xảy ra khi chương trình chạy: một mảng .bss 400MB có tốn 400MB RAM không? Trực giác nói có. Bài này đo bằng cách theo dõi bộ nhớ thật lúc chạy, và câu trả lời làm tôi phải phân biệt ba con số mà trước giờ hay lẫn.
.bss trên đĩa và trong bộ nhớ
Nhắc lại nhanh: .bss là nơi chứa biến toàn cục và static bằng 0 hoặc chưa khởi tạo. Trong file thực thi, .bss chỉ được ghi bằng một con số "cần N byte zero" trong phần đầu, không có N byte thật — vì mọi giá trị đều là 0, lưu chúng vào file là vô nghĩa. Đó là lý do một mảng char big[400MB]; toàn cục vẫn cho file thực thi chỉ ~70 KB.
Nhưng lúc chạy thì sao? Khi hệ điều hành nạp chương trình, nó phải "cấp" 400MB đó ở đâu đó cho chương trình dùng. Câu hỏi tôi muốn đo: 400MB đó có ngay lập tức chiếm 400MB RAM vật lý không? Để trả lời, tôi cần phân biệt hai con số bộ nhớ mà /proc/self/status cung cấp: VmSize (VSZ) — tổng không gian địa chỉ ảo mà tiến trình đặt chỗ, và VmRSS (RSS) — lượng bộ nhớ vật lý thật sự đang thường trú trong RAM.
Đo: 400MB ảo, nhưng chỉ 1MB thật
Tôi khai char big[400MB]; (vào .bss), rồi in VSZ và RSS lúc mới khởi động, sau đó chạm vào từng trang (ghi một byte mỗi 4096 byte) rồi in lại:
mới khởi động: VSZ = 402 MB RSS = 1 MB
sau khi chạm hết: VSZ = 402 MB RSS = 401 MB
Con số lúc khởi động gây bất ngờ: mảng 400MB nằm gọn trong VSZ (không gian ảo, đã đặt chỗ), nhưng RSS chỉ 1 MB — nghĩa là RAM vật lý thật sự dùng gần như bằng không. Chỉ sau khi tôi ghi vào từng trang của mảng, RSS mới leo lên 401 MB. Con số 401 (thay vì tròn 400) là do RSS còn tính cả các phần khác của tiến trình; điều quan trọng là nó gần đúng bằng kích thước mảng, chứng tỏ giờ mỗi trang đã có một trang RAM vật lý riêng. VSZ không đổi suốt (nó là chỗ đã đặt, không phải RAM đã dùng).
Một lần tôi đo hớ: mảng 400MB không tốn 400MB RAM
Tôi vào bài với niềm tin thẳng thắn: khai một mảng toàn cục 400MB thì chương trình tốn 400MB RAM ngay khi chạy — bộ nhớ phải được cấp cho nó chứ. Đo ra RSS chỉ 1 MB lúc khởi động. Sai hoàn toàn.
Lý do là cấp phát lười (demand-zero paging), một cơ chế của bộ nhớ ảo mà sê-ri hệ điều hành đã đo ở góc khác. Khi nạp chương trình, hệ điều hành chỉ đặt chỗ 400MB trong bảng trang (tính vào VSZ), nhưng không cấp một trang RAM vật lý nào. Mọi trang .bss chưa chạm đều trỏ chung tới một trang zero duy nhất ở chế độ chỉ đọc. Chỉ khi chương trình chạm (đọc/ghi) một trang lần đầu, kernel mới cấp một trang RAM thật cho riêng nó (và nếu là ghi thì sao chép trang zero ra — copy-on-write). Nên một mảng 400MB mà bạn chỉ dùng vài KB đầu chỉ tốn vài KB RAM thật; phần còn lại là "hứa hẹn" trong không gian ảo, chưa hiện hình trong RAM. Cơ chế này giải thích luôn con số RSS=1MB tôi đo được: đó không phải mảng của tôi, mà là mã chương trình, stack, và libc đã bị chạm tới trong quá trình khởi động — còn 400MB .bss thì chưa trang nào được đụng, nên đóng góp gần như 0.
Bài học đo lường: kích thước file, VSZ, và RSS là ba con số hoàn toàn khác nhau, và lẫn chúng dẫn tới kết luận sai về bộ nhớ. Ở bài liên kết tĩnh/động tôi thấy "file nhỏ chưa chắc nhẹ RAM"; ở đây là chiều ngược lại — "VSZ to chưa chắc tốn RAM". Con số duy nhất cho biết bộ nhớ vật lý thật sự đang dùng là RSS, không phải VSZ (đặt chỗ ảo) hay kích thước file (byte trên đĩa). Tôi đã lấy VSZ (thứ dễ thấy, "kích thước tiến trình" mà nhiều công cụ hiển thị) làm đại diện cho RAM, và đó là đo nhầm đại lượng.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là đừng hoảng vì VSZ lớn. Khi một công cụ giám sát báo tiến trình của bạn có "kích thước ảo" hàng GB, đó thường không phải RAM thật đang dùng — nó gồm cả .bss chưa chạm, các vùng mmap lười, thư viện chia sẻ ánh xạ nhưng chưa nạp. Muốn biết áp lực RAM thật, nhìn RSS (hay "resident memory"). Nhiều báo động giả về "rò rỉ bộ nhớ" tan biến khi người ta nhận ra họ đang nhìn VSZ. Đặc biệt trên các runtime có bộ gom rác hay cấp phát vùng lớn sẵn, VSZ có thể gấp nhiều lần RSS mà hoàn toàn bình thường.
Hệ quả thứ hai là cấp phát lười cho phép các mẫu lập trình hào phóng. Bạn có thể khai một buffer tĩnh rất lớn "phòng khi cần" mà không trả giá RAM nếu không dùng tới — chỉ phần bạn thật sự chạm mới tốn bộ nhớ vật lý. Đây cũng là lý do malloc một vùng khổng lồ thường thành công tức thì (chỉ đặt chỗ ảo) rồi mới "trả tiền" dần khi bạn ghi vào. Với .bss thì "đặt chỗ" xảy ra ngay lúc nạp chương trình, còn với malloc/mmap thì lúc gọi — nhưng cả hai đều chung một nguyên tắc: đặt chỗ trong không gian ảo là rẻ, chỉ việc chạm mới tốn RAM vật lý. Đổi lại, RAM chỉ được cấp khi chạm, nên nếu chạm hết một vùng lớn hơn RAM vật lý, chương trình sẽ bị OOM killer xử lý tại thời điểm chạm, không phải lúc cấp phát.
Hệ quả thứ ba nối lại toàn cảnh sê-ri: hệ thống trì hoãn công việc tới lúc thật cần, và các con số đo lường phản ánh những thời điểm khác nhau của sự trì hoãn đó. .bss không tốn file (byte 0 vô nghĩa để lưu), không tốn RAM tới khi chạm (cấp phát lười), giống như lazy binding không phân giải ký hiệu tới khi gọi. Con số mang theo: một mảng .bss to nằm trong VSZ ngay từ đầu nhưng RSS chỉ lớn lên theo số trang bạn thật sự chạm — kích thước file, VSZ và RSS là ba đại lượng khác nhau, và RSS mới là RAM vật lý thật đang dùng. Khi đánh giá bộ nhớ, luôn hỏi rõ mình đang nhìn con số nào — vì nhìn nhầm là kết luận nhầm.
Thử ba mươi giây
Viết char big[500*1024*1024]; ở phạm vi toàn cục và một main in hai dòng của /proc/self/status: grep -E 'VmSize|VmRSS'. Dịch, chạy: bạn sẽ thấy VmSize khoảng 500MB nhưng VmRSS chỉ vài MB — mảng khổng lồ mà RAM thật gần như bằng không. Giờ thêm một vòng lặp for(i=0;i<sizeof big;i+=4096) big[i]=1; trước khi in lần hai, chạy lại: VmRSS giờ leo lên ~500MB. Chỉ một lần thêm vòng chạm trang là bạn thấy tận mắt RAM vật lý được cấp khi chạm, không phải lúc khai báo — và hiểu vì sao VSZ và RSS là hai câu chuyện khác nhau.