Photocopy cả một thư viện nghìn cuốn để đưa cho người thứ hai thì tốn cả tuần — nhưng nếu hai người thỏa thuận "cứ dùng chung, chừng nào ai muốn viết lên một cuốn thì mới photo riêng cuốn đó", việc "chia" thư viện xong trong một phút. Chi phí photo không biến mất; nó chỉ dời sang từng lần ai đó cầm bút. fork() của Linux chia bộ nhớ hệt như vậy, và con số 28 micro giây làm tôi suýt reo lên "miễn phí" trước khi kịp hỏi cái giá đã đi đâu. Khi một chương trình gọi fork(), Linux tạo ra một tiến trình con giống hệt cha — cùng mã, cùng dữ liệu, cùng ngăn xếp. Nghe qua thì kernel phải sao chép toàn bộ bộ nhớ của cha, và fork một tiến trình vài trăm MB hẳn phải chậm khủng khiếp. Bài này đo xem fork() thật sự tốn bao nhiêu theo kích thước bộ nhớ.

Copy-on-write: chia sẻ trước, sao chép sau

Kernel không sao chép dữ liệu của cha khi fork. Thay vào đó nó dùng copy-on-write (COW, sao chép khi ghi): cha và con cùng chia sẻ các trang bộ nhớ vật lý ngay sau fork, và mỗi trang được đánh dấu chỉ đọc. Chừng nào cả hai chỉ đọc, chúng dùng chung cùng một bản — không tốn thêm byte nào. Chỉ khi một bên ghi vào một trang chung, phần cứng phát hiện đó là trang chỉ-đọc và gây một lỗi trang (page fault); kernel bắt lấy, sao chép riêng trang đó cho tiến trình vừa ghi, cập nhật bảng trang, rồi cho lệnh ghi chạy tiếp. Việc sao chép vì thế diễn ra lười biếng, từng trang một, đúng lúc cần — chứ không phải một cú sao chép khổng lồ lúc fork.

Cái fork() thật sự làm ngay lập tức chỉ là sao chép bảng trang — siêu dữ liệu mô tả trang nào ánh xạ tới đâu — chứ không phải dữ liệu trong các trang đó.

Đo: fork 512 MB cũng chỉ 28 micro giây

Tôi cấp một vùng heap rồi chạm vào (ghi 1 vào mọi byte để nó thật sự được cấp trang vật lý), sau đó đo thời gian fork() với heap lớn dần, ghim lõi 0:

Kích thước heap Thời gian fork()
0 MB 19,8 µs
64 MB 30,3 µs
256 MB 28,7 µs
512 MB 28,5 µs

Con số gần như phẳng: fork một tiến trình 512 MB (28,5 µs) chỉ nhỉnh hơn fork một tiến trình gần rỗng (19,8 µs) một chút, và các mức 64/256/512 MB đều quanh 28–30 µs. Nếu fork() thật sự sao chép 512 MB, thì ở băng thông bộ nhớ khoảng chục GB/s nó phải mất hàng chục mili giây — tức chậm hơn cả nghìn lần con số 28 micro giây tôi đo được. Bằng chứng rõ ràng: fork không sao chép dữ liệu; nó chỉ dựng bảng trang để chia sẻ COW. Cái chênh nhỏ từ 19,8 lên ~29 µs là chi phí đánh dấu các trang COW — tỉ lệ với số trang, không phải với việc đọc dữ liệu.

Cái giá không mất, nó chỉ dời đi

Nhìn bảng trên, tôi định gõ ngay một kết luận sảng khoái: "fork một tiến trình khổng lồ gần như miễn phí — COW đúng là phép màu". Con số ủng hộ điều đó, và nửa đầu của nó đúng thật: fork không trả cái giá sao chép 512 MB.

Nhưng "gần như miễn phí" là một kết luận thiếu, vì nó bỏ sót câu hỏi chi phí sao chép đi đâu mất rồi. Nó không biến mất — nó bị hoãn sang các lần ghi sau fork. Để đo nửa còn lại, tôi cho tiến trình con ghi một byte vào mỗi trang của heap 64 MB (16.384 trang) rồi đọc getrusage để đếm lỗi trang nhẹ (minor fault):

con ghi 16.384 trang: 20,2 ms, minflt tăng = 16.419

Mỗi trang lần đầu bị ghi gây đúng một lỗi trang copy-on-write — số lỗi (16.419) khớp gần như tuyệt đối số trang (16.384). Và cái giá là thật: 20,2 mili giây để ghi vào 64 MB heap chung. So với việc ghi cùng 64 MB không có COW (các trang đã riêng từ trước, chưa fork): chỉ 0,10 ms và 2 lỗi trang. Nói cách khác, COW làm loạt ghi đầu tiên sau fork chậm khoảng 200 lần so với ghi bình thường, vì mỗi trang phải chịu một cú lỗi-trang-rồi-sao-chép.

Điều rút ra: "fork rẻ" chỉ đúng một nửa; nửa kia là chi phí sao chép bị hoãn, ẩn kín tới khi bạn ghi. Nếu tôi chỉ đo thời gian fork() và dừng lại, tôi đã kết luận sai rằng cái giá đã bị xóa — trong khi thực ra nó chỉ bị dời. Phải đo cả hai vế — thời gian fork (rẻ) và thời gian ghi sau fork (đắt) — mới thấy toàn cảnh của một giao dịch. Và getrusage với bộ đếm lỗi trang chính là bằng chứng cho biết cái sao chép thật sự xảy ra lúc nào: không phải lúc fork, mà lúc ghi.

Về đến code hằng ngày

Điều đầu tiên: fork rẻ khiến fork() + exec() là cách tạo tiến trình hiệu quả, dù tiến trình cha to. Một tiến trình máy chủ chiếm hàng GB vẫn có thể fork một tiến trình con để chạy lệnh khác gần như tức thì, vì con không sao chép cái GB đó — nó chia sẻ COW rồi lập tức exec() (thay toàn bộ ảnh bộ nhớ), nên phần lớn trang chung còn chẳng kịp bị ghi. Đây là lý do mô hình fork-exec của Unix vẫn nhanh dù bộ nhớ tiến trình lớn.

Điều thứ hai: cẩn thận với cái giá COW ẩn sau fork trong tiến trình sống lâu. Một chương trình fork một tiến trình con không exec ngay mà tiếp tục ghi nhiều vào bộ nhớ chung sẽ trả một loạt COW fault khi nó bắt đầu ghi — một đợt chậm và một đợt tăng bộ nhớ đột ngột (vì mỗi trang bị ghi giờ tồn tại hai bản). Nếu bạn thấy một tiến trình con "đứng hình" hay RSS phình lên ngay sau khi fork rồi bắt đầu làm việc, COW fault là nghi phạm số một.

Và cái mẫu "thao tác rẻ vì hoãn chi phí, cái giá thật hiện ra sau, ở chỗ khác" này là một trong những nguồn hiểu lầm hiệu năng dai dẳng nhất ở mọi tầng. Chính COW của fork là lý do các bản Redis/database dùng fork để snapshot (như BGSAVE) gặp "latency spike" và RSS tăng vọt khi tiến trình chính ghi nhiều lúc con đang lưu — đúng cái giá hoãn này. Trong Java/ngôn ngữ GC, cấp object rẻ (chỉ dời con trỏ) nhưng cái giá dời sang lúc GC dọn; một ArrayList cấp sẵn dung lượng rẻ tới khi phải copy khi mở rộng. Ngay cả CSDL: MVCC làm UPDATE không ghi đè tại chỗ mà tạo phiên bản mới (một kiểu copy-on-write), và cái giá dời sang VACUUM dọn phiên bản cũ sau đó. Mẫu số chung: rất nhiều thao tác "nhanh bất ngờ" chỉ đang hoãn chi phí sang một sự kiện sau (lần ghi đầu, đợt GC, lần vacuum) — đo mỗi thao tác lập tức mà bỏ qua cái đuôi bị hoãn là vẽ nửa bức tranh; hãy đo trọn vòng đời. Gói lại bằng số: fork một tiến trình 512 MB chỉ tốn ~28 µs vì copy-on-write chép bảng trang chứ không chép dữ liệu (fork gần-rỗng 19,8 µs, 512MB 28,5 µs — phẳng, nhanh hơn nghìn lần so với thật sự sao chép), nhưng cái giá đó dời sang các lần ghi: ghi vào 64 MB heap chung sau fork tốn 20,2 ms với 16.419 COW fault, chậm ~200 lần so với ghi thường (0,10 ms).

Muốn tự thấy thì viết một chương trình cấp một mảng lớn (vài trăm MB), chạm vào để nó được cấp thật, rồi fork(). Trong tiến trình con, gọi getrusage(RUSAGE_SELF, &r) lấy ru_minflt trước và sau khi bạn ghi một byte vào mỗi trang của mảng, rồi in hiệu. Bạn sẽ thấy số lỗi trang xấp xỉ đúng số trang bạn ghi — mỗi trang một COW fault. Rồi thử time -v ./chuong-trinh và đọc dòng "Minor (reclaiming a frame) page faults": nó khớp. Đó là cái giá sao chép mà fork() đã hoãn lại, hiện ra đúng lúc bạn chạm bút vào bộ nhớ chung — và là cách nhìn tận mắt copy-on-write làm việc.