Bài trước đo cái giá của fork() và thấy copy-on-write (COW) khiến fork không sao chép dữ liệu. Nhưng "không sao chép" nghe trừu tượng — chính xác thì bộ nhớ được chia sẻ tới lúc nào, và mất chia sẻ ra sao? Bài này đo trực tiếp: cho một tiến trình con ghi vào các mức phần trăm khác nhau của bộ nhớ chung, rồi nhìn xem bao nhiêu bộ nhớ vật lý thật sự được sao chép riêng. Và trên đường đo, tôi mắc đúng cái bẫy mà mọi người hay mắc khi nhìn con số bộ nhớ của một tiến trình.

Copy-on-write khi fork

Chia sẻ tới khi ghi

Ngay sau fork(), cha và con trỏ tới cùng những trang vật lý, tất cả bị đánh dấu chỉ-đọc. Chừng nào cả hai chỉ đọc, không có gì được sao chép — hai tiến trình dùng chung từng byte. Khi một bên cố ghi vào một trang, CPU sinh một lỗi trang (page fault), nhân bắt lấy, sao chép riêng đúng trang đó ra, sửa bảng trang của bên ghi trỏ sang bản mới, rồi cho lệnh ghi tiếp tục. Mỗi trang bị ghi tốn một lần sao chép; trang không bao giờ ghi thì chia sẻ mãi.

Vậy để đo COW, tôi cần một thước đo bộ nhớ phân biệt được "trang con chia sẻ với cha" và "trang con đã sao chép riêng". Linux cung cấp đúng thứ đó trong /proc/[pid]/smaps_rollup: Rss (tổng trang tiến trình đang thấy), Shared_Dirty (trang bẩn dùng chung), Private_Dirty (trang bẩn của riêng tiến trình), và Pss (proportional set size — mỗi trang chung được chia đều cho số tiến trình dùng nó).

Đo: con ghi bao nhiêu, chiếm riêng bấy nhiêu

Tôi viết một chương trình C: cha cấp 400 MB, chạm vào mọi trang cho chúng chiếm bộ nhớ vật lý, rồi fork. Con ghi vào một phần trăm cho trước của số trang (0%, 25%, 50%, 100%) rồi dừng lại; cha đọc smaps_rollup của con:

Con ghi Rss Shared Private_Dirty Pss
0% 400 MB 400 MB 0 MB 200 MB
25% 400 MB 300 MB 100 MB 250 MB
50% 400 MB 200 MB 200 MB 300 MB
100% 400 MB 0 MB 400 MB 400 MB

Cột Private_Dirty kể đúng câu chuyện COW: nó bằng 0 MB khi con chưa ghi gì, rồi tăng đúng bằng phần con ghi — 100 MB ở 25%, 200 MB ở 50%, 400 MB ở 100%. Đó chính là lượng bộ nhớ vật lý thật sự bị sao chép: chia sẻ được giữ nguyên cho tới đúng lúc ghi, và mỗi phần ghi "bẻ" đúng phần đó ra riêng. Ở 100%, con đã copy sạch — như thể không có COW. Cột Shared là hình ảnh ngược lại: 400 MB dùng chung ban đầu, teo dần về 0 khi con ghi hết. Còn Pss — chia đôi mỗi trang chung — đi từ 200 MB (con chia sẻ toàn bộ với cha nên chỉ "gánh" một nửa) lên 400 MB (khi không còn gì để chia).

Một lần tôi đo hớ: RSS nói dối về chia sẻ

Lần đầu, tôi đo bộ nhớ của con bằng Rss — cột quen thuộc nhất, cái mà pstop hiển thị. Và tôi hoảng: Rss = 400 MB ngay cả khi con ghi 0%. Phản xạ đầu tiên là kết luận: "COW hỏng rồi! Con vừa fork xong đã ngốn nguyên 400 MB, chẳng chia sẻ gì cả!".

Sai hoàn toàn, và may là bảng số khác (Private_Dirty = 0) mâu thuẫn rõ tới mức buộc tôi xem lại. Rss đếm mọi trang mà tiến trình nhìn thấy, bất kể trang đó là riêng hay đang chia sẻ. Khi cha và con dùng chung 400 MB, Rss của cả hai đều là 400 MB — cùng một bộ nhớ vật lý bị đếm hai lần, một lần cho mỗi tiến trình. Nên Rss của con là 400 MB ở mọi mức ghi, nó không hề phản ánh việc con đang chia sẻ hay đã sao chép. Thước đúng là Private_Dirty (bộ nhớ con thật sự chiếm riêng — bằng 0 khi chưa ghi) hoặc Pss (chia phần chung một cách công bằng).

Bài học đo lường: hai con số mâu thuẫn nhau là dấu hiệu mình đang đo nhầm đại lượng, không phải hiện tượng lạ. Rss nói "400 MB", Private_Dirty nói "0 MB" — cả hai đúng, nhưng chúng đo hai thứ khác nhau, và tôi đã hỏi nhầm cột. Đây chính là gốc của một nỗi hoảng kinh điển trong vận hành: nhìn 50 tiến trình worker mỗi cái Rss 400 MB rồi kêu "hệ thống ăn 20 GB!" — trong khi thực tế chúng fork từ một tiến trình mẹ và chia sẻ phần lớn bộ nhớ qua COW, dùng thật ít hơn nhiều. Muốn biết bộ nhớ thật, phải cộng Pss (hoặc nhìn Private), không phải cộng Rss.

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ớ khi có nhiều tiến trình chia sẻ. Bất cứ khi nào bạn dùng mô hình prefork (nhiều worker fork từ một master — Gunicorn, Unicorn, php-fpm, nginx), các worker chia sẻ code và dữ liệu chỉ-đọc của master qua COW. Cộng Rss của chúng lại sẽ thổi phồng mức dùng bộ nhớ gấp nhiều lần. Công cụ đúng là smem (hiển thị Pss), hoặc đọc Pss trong smaps, hoặc nhìn bộ nhớ toàn hệ thống trong /proc/meminfo — những thứ đếm mỗi trang vật lý đúng một lần.

Hệ quả thứ hai là thiết kế để giữ chia sẻ càng lâu càng tốt. Vì bộ nhớ chỉ bị nhân đôi khi ghi, một mẫu tối ưu quen thuộc là: nạp dữ liệu lớn, bất biến vào tiến trình mẹ trước khi fork (một bảng tra cứu, một mô hình, một cache chỉ-đọc), rồi fork các worker. Chừng nào các worker chỉ đọc dữ liệu đó, tất cả chia sẻ chung một bản duy nhất trong RAM. Ngược lại, nếu worker ghi rải rác khắp vùng đó (kể cả vô tình — ví dụ trình dọn rác của ngôn ngữ chạm vào từng object để đánh dấu), COW bị phá dần và bộ nhớ phình lên. Đây là lý do một số runtime có chế độ "GC thân thiện với COW" tránh ghi vào header của object.

Hệ quả thứ ba là hiểu rằng chi phí COW bị hoãn, không biến mất. COW dời cái giá sao chép từ lúc fork sang lúc ghi lần đầu — nên một worker tưởng nhẹ có thể đột ngột chậm và ngốn RAM đúng lúc nó bắt đầu ghi (mỗi trang ghi lần đầu là một lỗi trang cộng một lần sao chép — một chi phí ẩn dồn lại). Con số mang theo: sau fork, bộ nhớ được chia sẻ thật sự tới khi ghi — con ghi 0% chiếm riêng 0 MB, ghi 100% chiếm 400 MB — nhưng Rss đứng yên 400 MB ở mọi mức vì nó đếm cả trang chung; hãy đo bằng Private_Dirty hoặc Pss. Chia sẻ là thật và đo được; chỉ cần hỏi đúng cột.

Thử ba mươi giây

Trên một máy Linux, chọn một tiến trình đang chạy (ví dụ một worker web) rồi so hai con số: grep -E '^Rss|^Pss' /proc/<pid>/smaps_rollup. Với một tiến trình đơn độc, Rss và Pss gần bằng nhau; nhưng với một worker chia sẻ nhiều với anh em của nó, Pss sẽ nhỏ hơn hẳn Rss — hiệu số đó chính là phần bộ nhớ nó đang chia sẻ qua COW. Hoặc cài smem và chạy smem -t -k: cột PSS cộng lại cho bạn mức dùng bộ nhớ thật của một nhóm tiến trình, thường thấp hơn nhiều so với cộng RSS. Con số chênh lệch đó, tận mắt, là copy-on-write đang lặng lẽ tiết kiệm RAM cho bạn — đúng thứ bài này đo.