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ì có vẻ kernel phải sao chép toàn bộ bộ nhớ của cha, và fork một tiến trình dùng vài trăm megabyte hẳn phải chậm khủng khiếp. Bài này đo trong container xem fork() thật sự tốn bao nhiêu theo kích thước bộ nhớ — và câu trả lời hé lộ một trong những mẹo thanh lịch nhất của hệ điều hành, cùng cái giá bị giấu đi của nó mà tôi suýt bỏ qua.

fork và copy-on-write

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.

Một lần tôi đo hớ: cái giá không mất, nó 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.

Bài học đo lường: "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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả 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 (ví dụ một số bộ thu gom rác kiểu fork, hay một tiến trình con xử lý dữ liệu lớn kế thừa từ cha) 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.

Hệ quả thứ ba là bài học đo lường mang theo: một thao tác "rẻ" có thể chỉ đang hoãn chi phí, không xóa nó — hãy đo cả giao dịch, không chỉ nửa đầu. Con số mang theo: 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 (nhanh hơn nghìn lần so với thật sự sao chép), nhưng cái giá đó được 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 ghi thường. Khi một phép đo cho thấy điều gì đó "gần như miễn phí", hãy hỏi tiếp: chi phí đi đâu? — thường nó chỉ đang nấp ở một chỗ bạn chưa đo.

Thử ba mươi giây

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ướcsau 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.