Hình dung hai người cùng thừa hưởng một tập tài liệu dày. Đọc thì cả hai giở chung thoải mái, chẳng tốn gì. Nhưng khoảnh khắc một người muốn viết thêm vào một trang, họ không được sửa bản chung — phải đi photo riêng nguyên trang đó trước, rồi viết lên bản photo của mình. Đọc: miễn phí. Lần đầu sửa mỗi trang: trả một lần photo. Lần sửa sau vào cùng trang: đã có bản riêng, lại miễn phí. fork và Copy-on-Write vận hành đúng như vậy — và đây là chỗ câu "fork rẻ" cần một dấu hoa thị.
Ở 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).
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. Đúng hai người giở chung tập tài liệu.
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 — đúng động tác photo riêng nguyên trang trước khi viết. 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 ns — 96 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ôi trường đo: container gcc:13 trên host ARM AArch64, 80 MB đã chạm trước khi fork, đo trong tiến trình con, mỗi số sau warmup. Con số đổi theo máy; điều bền vững là: đọc chia sẻ rẻ, ghi lần đầu mỗi trang gánh một CoW fault đắt, ghi sau rẻ lại.)
Hai niềm tin đối nghịch, cùng sai
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ự 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.
Nhưng đừng lật sang niềm tin ngược — "CoW hoãn chi phí đắt thế thì fork chả rẻ thật, cứ chép luôn cho xong". Cũng sai, vì CoW né hẳn phần bộ nhớ mà con không bao giờ ghi tới: nếu con exec ngay hoặc chỉ đọc, gần như không CoW fault nào xảy ra, và fork rẻ thật. Cái giá của việc sao chép một tiến trình không bao giờ mất, nhưng CoW dời nó từ "một cục bắt buộc lúc fork" sang "chỉ trả cho những trang thực sự bị ghi" — với phần lớn workload, đó là một phần nhỏ. Sự thật: 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ỏ hẳn phần không cần — nên bạn phải đo ở đúng nơi chi phí hiện ra. Đo fork thì thấy rẻ; chỉ đo đó rồi kết luận "tạo tiến trình rẻ tuyệt đối" là bỏ lỡ 18 ms con trả sau khi ghi. Đây cùng bài học với chi phí ẩn: 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ới lazy allocation, đó là lần chạm đầu tiên.
Về đến code hằng ngày
Điều đầ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 (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.
Điều 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. Nếu 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.
Chỗ này CoW là con dao hai lưỡi nổi tiếng ở tầng vận hành. Redis BGSAVE fork một tiến trình con để lưu snapshot — CoW cho phép nó chụp ảnh bộ nhớ mà không dừng server; nhưng nếu server ghi nhiều trong lúc con đang lưu, mỗi trang bị ghi lại kích một CoW copy, và RSS có thể phình gần gấp đôi — đúng cái bẫy "fork rồi ghi nhiều" mà tài liệu Redis cảnh báo. Server pre-fork (Gunicorn, Unicorn, Nginx) dựa vào CoW để nhiều worker chia sẻ trang code/dữ liệu chung sau fork, chỉ tách khi ghi. Và một ca kinh điển ngược đời: Python/Ruby phá lợi ích CoW vì đếm tham chiếu (refcount) nằm trong object — chỉ đọc một object cũng ghi vào refcount của nó, kích CoW fault, khiến bộ nhớ "chia sẻ" của các worker pre-fork âm thầm nhân lên (Instagram, Dropbox từng viết bài về đúng vấn đề này). Còn CoW filesystem (btrfs/ZFS snapshot) là cùng ý tưởng ở tầng lưu trữ: snapshot tức thì, chỉ chép block khi bị ghi. Cùng một bài học tập-tài-liệu-chung: chia sẻ đến khi ai đó viết.
Muốn tự thấy thì chạy /usr/bin/time -v ./chương-trình (hoặc đọc /proc/<pid>/stat) và tìm hai con số: minor page faults và major 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 ~vài trăm ns đến ~1 µs. Vài phút đọ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.