Ở bài trước tôi để lại một cảnh báo lơ lửng: RSS của một tiến trình bao gồm cả các trang chia sẻ, nên cộng RSS của nhiều tiến trình có thể ra số lớn hơn cả RAM thật. Bài này mở đúng cái nắp đó ra: /proc/<pid>/smaps cho ta thấy từng vùng bộ nhớ chi tiết tới mức tách được trang riêng khỏi trang chung, và một chỉ số — PSS — được sinh ra để cộng cho đúng. Tôi đo bằng ba tiến trình cùng chia một file, và vấp đúng cái bẫy cộng dồn mà tôi đã tự cảnh báo.
maps liệt kê vùng, smaps kể chi tiết
/proc/<pid>/maps là bản đồ không gian địa chỉ của một tiến trình: mỗi dòng là một vùng (mapping) liên tục — [heap], [stack], mỗi thư viện .so được nạp (thường tách thành vài vùng: mã r-xp, dữ liệu chỉ-đọc r--p, dữ liệu ghi được rw-p), mỗi vùng mmap. Đọc maps của tiến trình đo, tôi thấy rõ [heap], [stack], và libc.so.6 chia làm bốn vùng quyền khác nhau — đúng cấu trúc một chương trình Linux.
/proc/<pid>/smaps là maps phóng đại: với mỗi vùng, nó thêm cả chục dòng chi tiết — Size (kích thước ảo của vùng), Rss (phần đang ở RAM), Pss, và quan trọng nhất là bốn ô tách bạch: Shared_Clean, Shared_Dirty, Private_Clean, Private_Dirty, cộng Swap. "Shared" là trang đang được nhiều tiến trình dùng chung; "Private" là của riêng tiến trình này. "Clean" là trang khớp với nội dung trên đĩa (có thể vứt đi và đọc lại); "Dirty" là đã bị sửa, phải giữ (hoặc đẩy xuống swap). Sự phân biệt này là chìa khóa để hiểu bộ nhớ thật sự được chia sẻ và tiêu tốn ra sao.
Đo: RSS đếm trùng, PSS chia đều
Tôi tạo một file 90MB rồi cho ba tiến trình cùng mmap nó ở chế độ chia sẻ (MAP_SHARED) và chạm hết mọi trang. Ba tiến trình giờ dùng chung đúng 90MB trang vật lý đó. Đọc smaps của từng tiến trình, lấy vùng ánh xạ file:
| Mỗi tiến trình (vùng shared) | Giá trị |
|---|---|
Rss |
92160 kB (~90 MB) |
Pss |
30719 kB (~30 MB) |
Shared_Dirty |
92160 kB |
Mỗi tiến trình báo Rss = 90MB cho vùng đó — hợp lý, vì cả 90MB trang chung đều đang ở RAM và tiến trình nào cũng thấy chúng. Nhưng Pss chỉ 30MB — đúng bằng 90MB chia ba. Và giờ là phép cộng:
Cộng Rss ba tiến trình = 270 MB (?!)
Cộng Pss ba tiến trình = 89 MB (đúng)
File thật chỉ có = 90 MB
Cộng RSS ra 270MB — gấp ba dung lượng thật của file. Cộng PSS ra 89MB — khớp với 90MB thật (lệch chút do làm tròn khi chia ba). Đây chính là lý do PSS tồn tại.
Một lần tôi đo hớ: cộng RSS để tính tổng RAM
Dù đã tự cảnh báo ở bài trước, khi bắt tay đo tôi vẫn làm đúng cái sai đó. Câu hỏi thực tế của tôi là: "nhóm ba tiến trình worker này ngốn tổng cộng bao nhiêu RAM?". Phản xạ tự nhiên — cũng là cách hầu hết mọi người làm với top/ps — là đọc cột RES (chính là RSS) của từng cái rồi cộng lại. Tôi cộng: 90 + 90 + 90 = 270MB, và định ghi con số đó làm "dấu chân bộ nhớ của nhóm".
Nhưng file chia sẻ chỉ có 90MB. Cả ba tiến trình đang nhìn vào cùng 90MB trang vật lý ấy — không hề có 270MB RAM nào bị chiếm. Con số 270MB là bất khả: nó nhiều hơn cả thứ nó đang đo. Theo kỷ luật, một con số bất khả nghĩa là tôi cộng nhầm đại lượng, không phải máy sai.
Lý do rõ khi nhìn vào bản chất RSS: RSS của mỗi tiến trình đếm mọi trang đang ở RAM mà tiến trình đó thấy — kể cả các trang chia sẻ. Khi ba tiến trình cùng chia 90MB, mỗi RSS đếm trọn 90MB đó, nên cộng ba RSS đếm mỗi trang chung ba lần. RSS là con số đúng cho một tiến trình đơn lẻ ("tiến trình này đang chiếm bao nhiêu RAM"), nhưng sai hoàn toàn khi cộng qua nhiều tiến trình dùng chung bộ nhớ.
PSS (Proportional Set Size) được thiết kế đúng để chữa việc này: với mỗi trang chia sẻ bởi N tiến trình, PSS chỉ tính cho mỗi tiến trình 1/N trang đó (cộng toàn bộ trang riêng của nó). Nên khi cộng PSS của tất cả tiến trình dùng chung, mỗi trang chung được đếm đúng một lần (N phần 1/N cộng lại thành 1). Kết quả: tổng PSS = RAM thật, 89MB, khớp file. Bài học đo lường: RSS chỉ đúng cho một tiến trình; muốn tổng RAM của một nhóm tiến trình chia sẻ bộ nhớ, phải cộng PSS chứ đừng cộng RSS — hoặc đọc thẳng /proc/<pid>/smaps_rollup cho tổng PSS toàn tiến trình mà không phải cộng tay từng vùng.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đo dấu chân bộ nhớ thật của một dịch vụ nhiều tiến trình bằng PSS, không phải RSS. Một máy chủ chạy nhiều worker fork từ một tiến trình cha (như nginx, PostgreSQL, Gunicorn) chia sẻ rất nhiều: mã chương trình, thư viện, các trang copy-on-write chưa bị ghi. Cộng RSS của tất cả worker sẽ thổi phồng con số kinh khủng, khiến bạn tưởng cần nhiều RAM hơn thực tế và mua thừa máy. smaps_rollup (cho PSS mỗi tiến trình) hoặc các công cụ như smem cộng PSS mới cho con số RAM thật mà nhóm đang chiếm.
Hệ quả thứ hai: phân biệt Dirty với Clean cho biết cái gì mất được, cái gì phải giữ. Private_Dirty là bộ nhớ riêng đã bị sửa — không thể tái tạo từ đâu, khi thiếu RAM nó phải đẩy xuống swap (và mất tốc độ). Shared_Clean (ví dụ mã thư viện .so) thì ngược lại: nó khớp y file trên đĩa, khi cần RAM nhân chỉ việc vứt nó đi và đọc lại từ file khi cần — không tốn swap. Nên khi tối ưu bộ nhớ, Private_Dirty mới là phần "đắt" thật sự đáng soi; nhầm nó với Shared_Clean (rẻ, tái tạo được) sẽ dẫn tới tối ưu sai chỗ.
Hệ quả thứ ba là bài học tổng quát chốt lại chuỗi bộ nhớ của sê-ri. Con số mang theo: smaps tách mỗi vùng thành riêng/chung và sạch/bẩn; RSS đếm cả trang chia sẻ nên cộng RSS nhiều tiến trình bị trùng (3 tiến trình chia 90MB ra tổng RSS 270MB), còn PSS chia trang chung cho số tiến trình nên cộng PSS khớp RAM thật (89MB). Mỗi con số bộ nhớ trả lời một câu hỏi khác nhau — VSZ (đặt chỗ ảo), RSS (RAM của một tiến trình), PSS (phần công bằng khi chia sẻ) — và dùng nhầm con số cho nhầm câu hỏi là cách chắc chắn để hoặc hoảng loạn vô cớ, hoặc bỏ sót chỗ tốn thật.
Thử ba mươi giây
Trên một máy Linux, chọn một tiến trình lớn và so RSS với PSS của nó: grep -E '^(Rss|Pss):' /proc/<pid>/smaps_rollup. Với một tiến trình chia sẻ nhiều (trình duyệt, một worker trong nhóm), PSS sẽ nhỏ hơn RSS — khoảng chênh đó chính là phần bộ nhớ chia sẻ mà RSS đếm trọn còn PSS chỉ tính phần công bằng. Rồi nếu có smem, chạy smem -t để thấy nó cộng PSS toàn hệ thống ra con số RAM thật, thay vì cộng RSS thổi phồng. Lần sau ai đó cộng cột RES trong top để báo "nhóm này ngốn X GB RAM", bạn sẽ biết con số đó phồng tới mức nào — đúng cái bẫy bài này đo.