phần trước ta thấy fork rẻ (~74 µs) vì Copy-on-Write không sao chép dữ liệu ngay. Nhưng câu đó để lại một câu hỏi treo: nếu dữ liệu không bị chép lúc fork, thì cái giá của việc chép đi đâu? Câu trả lời: nó không biến mất, nó được hoãn — và trả dần, mỗi trang một lần, đúng lúc tiến trình con ghi vào trang đó. Bài này đo cái giá hoãn ấy: một CoW page fault tốn bao nhiêu, và nó thay đổi cách ta hiểu "fork rẻ" thế nào. Đo trong container gcc:13 (ARM).

Copy-on-write sau fork

CoW hoạt động thế nào khi con ghi

Ngay sau fork, cha và con cùng trỏ vào chính những trang nhớ vật lý đó, và nhân đánh dấu tất cả là chỉ đọc — kể cả những trang mà logic là "đọc-ghi". Chừng nào cả hai chỉ đọc, không có gì xảy ra: chúng chia sẻ trang, tiết kiệm bộ nhớ và thời gian.

Nhưng khi một bên ghi vào một trang chỉ-đọc, CPU không cho phép — nó phát một page fault (bẫy vào nhân). Nhân nhận ra đây là một trang CoW, bèn: cấp một trang vật lý mới, chép 4 KB nội dung trang cũ sang, sửa bảng trang của bên ghi để trỏ vào bản riêng và cho phép ghi, rồi trả điều khiển. Từ đó bên ghi có bản riêng của mình; lần ghi sau vào cùng trang không còn fault. Cái giá chép mà fork đã tránh, giờ được trả — một page fault + một lần chép 4 KB cho mỗi trang, đúng lần đầu ghi vào nó.

Đo: đọc 15ns, ghi lần đầu 909ns

Tôi cấp 80 MB (20.000 trang), chạm hết để chúng thực sự được ánh xạ, rồi fork. Tiến trình con lần lượt: đọc mỗi trang, ghi lần đầu mỗi trang, ghi lần hai mỗi trang — đo ns cho mỗi thao tác trên mỗi trang:

con sau fork (ns/trang):
  đọc trang (không CoW)     :  15,6 ns
  GHI lần 1 (CoW fault+sao) : 909,4 ns
  GHI lần 2 (đã sao)        :   9,5 ns
mốc: ghi trang KHÔNG qua fork:   5,4 ns

-> CoW fault / ghi lần 2 = 96×  |  tổng CoW cho 80MB ≈ 18,2 ms

Đọc sau fork rẻ như đọc thường (15,6 ns) — cha và con chia sẻ trang chỉ-đọc, không có gì phải làm. Nhưng ghi lần đầu vào mỗi trang tốn 909 ns96 lần so với ghi lần hai (9,5 ns) và ~170 lần một lần ghi bình thường không CoW (5,4 ns). Đó chính là chi phí một CoW fault: bẫy vào nhân, cấp trang, chép 4 KB, cập nhật bảng trang. Ghi lần hai vào cùng trang thì rẻ lại (9,5 ns) vì trang đã thành bản riêng — fault chỉ xảy ra một lần cho mỗi trang.

Cộng dồn: nếu con ghi vào toàn bộ 80 MB, nó trả ~18,2 ms tổng cho CoW. Đây chính là cái giá fork đã hoãn ở phần trước — nó không biến mất, chỉ dời sang các lần ghi đầu tiên của con.

Một lần tôi đo hớ: CoW không miễn phí, chỉ hoãn

Tôi vào đo với hai niềm tin. Thứ nhất: "sau fork, con đọc và ghi đều như thường, chẳng khác gì". Thứ hai, nối từ phần trước: "fork rẻ nhờ CoW, vậy CoW gần như miễn phí".

Phép đo sửa cả hai. Đọc thì đúng là như thường (15,6 ns). Nhưng ghi lần đầu vào mỗi trang chậm 96 lần (909 vs 9,5 ns) — không hề "như thường". Và CoW không miễn phí: nó chỉ hoãn chi phí chép. fork nhanh vì tại thời điểm fork nó chưa chép gì; nhưng nếu con thực sự dùng (ghi) bộ nhớ, nó trả cái giá chép đó dần dần, mỗi trang một CoW fault, tổng ~18 ms cho 80 MB. Cái giá của việc sao chép một tiến trình không bao giờ mất — CoW chỉ dời nó từ "một cục lúc fork" sang "rải ra theo các lần ghi", và né hẳn phần bộ nhớ mà con không bao giờ ghi tới.

Bài học đo lường: một tối ưu "lười" (lazy) như CoW không xóa chi phí, nó dời chi phí sang lúc thực sự cần — và bạn phải đo ở đúng nơi chi phí hiện ra. Đo fork thì thấy nó rẻ; nhưng nếu chỉ đo đó rồi kết luận "tạo tiến trình rẻ", bạn bỏ lỡ 18 ms mà con trả sau đó khi ghi. Đây cùng một bài học với chi phí ẩn xuyên suốt: cái giá thật thường không nằm ở nơi bạn gọi hàm, mà ở nơi hệ điều hành thực sự làm việc — và với lazy allocation, đó là lần chạm đầu tiên.

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

Hệ quả đầu tiên: CoW làm "fork rồi exec" cực rẻ, nhưng "fork rồi ghi nhiều" thì không. Nếu con exec ngay (thay chương trình), gần như không trang nào bị ghi nên gần như không CoW fault — fork gần như free. Nhưng nếu con tiếp tục chạy và ghi vào phần lớn bộ nhớ thừa hưởng (ví dụ một worker xử lý dữ liệu lớn), nó sẽ trả một loạt CoW fault ~909 ns mỗi trang. Biết con của bạn thuộc loại nào quyết định fork có thật sự rẻ hay không.

Hệ quả thứ hai: lần chạm đầu tiên vào bộ nhớ lazy luôn đắt — không riêng CoW. Cùng cơ chế page fault áp cho bộ nhớ vừa malloc/mmap mà chưa chạm (nhân cấp trang thật ở lần ghi đầu). Nếu bạn cần độ trễ ổn định (real-time, đo benchmark), hãy chạm trước (pre-fault) vùng nhớ, hoặc dùng MAP_POPULATE/mlock, để trả cái giá đó trước thay vì giữa đường nóng.

Hệ quả thứ ba là tinh thần đo lường: tối ưu lười dời chi phí, không xóa — đo ở nơi nó hiện ra. Con số mang theo: sau fork, đọc trang rẻ (15,6 ns, chia sẻ trang chỉ-đọc) nhưng GHI lần đầu vào mỗi trang tốn 909 ns — 96× lần ghi thứ hai (9,5 ns) — vì mỗi trang lần đầu chịu một CoW page fault (bẫy vào nhân, cấp trang mới, chép 4KB); CoW không miễn phí mà HOÃN chi phí chép, con ghi hết 80MB trả ~18ms (đúng cái fork đã hoãn), còn phần con không ghi thì né hẳn. "fork rẻ" đúng về thời điểm fork; cái giá chép chỉ chuyển chỗ, không mất.

Thử ba mươi giây

Chạy /usr/bin/time -v ./chương-trình (hoặc đọc /proc/<pid>/stat) và tìm hai con số: minor page faultsmajor page faults. Minor fault là những lần nhân phải làm việc không đụng đĩa — cấp một trang mới, hoặc xử lý một CoW fault như bài này đo. Nếu chương trình của bạn fork rồi con ghi nhiều, hoặc cấp phát bộ nhớ lớn rồi chạm dần, bạn sẽ thấy hàng chục nghìn minor fault — mỗi cái là ~vài trăm ns đến ~1 µs. Ba mươi giây đọc con số đó cho bạn thấy chi phí ẩn của bộ nhớ lười: nó không hiện ra lúc malloc hay fork, mà rải ra ở hàng nghìn lần chạm đầu tiên — và đó thường là phần "chậm không rõ vì sao" của lần chạy đầu.