phần trước ta đo một system call nhẹ nhất (getpid, ~103 ns). Giờ đến một trong những syscall nặng nhất và nền tảng nhất: fork — cách Unix tạo một tiến trình mới. Mỗi lần bạn chạy một lệnh trong shell, mỗi khi một web server cũ nhận request, fork được gọi. Câu hỏi: tạo một tiến trình tốn bao nhiêu, và vì sao? Trực giác phổ biến — "fork phải sao chép cả bộ nhớ của tiến trình cha, nên rất đắt" — vừa đúng vừa sai một cách thú vị. Tôi đo trong container gcc:13 (ARM), và vấp hai tầng đo hớ.

fork: chi phí tạo tiến trình

Tiến trình vs luồng, và Copy-on-Write

fork() tạo một tiến trình con — một bản sao của tiến trình cha, nhưng với không gian địa chỉ riêng: con có bảng bộ nhớ ảo của chính nó, độc lập với cha. Khác hẳn pthread_create tạo một luồng dùng chung không gian địa chỉ với các luồng khác (đó là lý do luồng chia sẻ biến dễ dàng — và cũng là nguồn mọi bug đồng thời ở sê-ri trước).

Nếu fork phải thật sự sao chép toàn bộ bộ nhớ của cha sang con, nó sẽ đắt kinh khủng — một tiến trình dùng 1 GB thì mỗi fork mất cả giây. Hệ điều hành tránh điều đó bằng Copy-on-Write (CoW): khi fork, cha và con cùng trỏ vào các trang nhớ vật lý, tất cả được đánh dấu chỉ đọc. Không byte dữ liệu nào bị sao chép. Chỉ khi một bên ghi vào một trang, một page fault xảy ra và riêng trang đó mới được nhân bản. Nhờ vậy fork nhanh, và nếu con exec ngay (thay chương trình) thì gần như không trang nào phải sao.

Đo: fork 74µs, nhưng bảng trang vẫn tốn

Ba phép đo, mỗi cái lặp và lấy min:

fork()+waitpid (con thoát ngay)  :  74,1 µs
pthread_create+join              :  45,9 µs
fork sau khi cấp 256MB (rồi fork):  137,7 µs

-> fork/thread = 1,6×  |  fork+256MB / fork = 1,86×

Con số đầu: fork tốn 74,1 µs — và chỉ 1,6 lần một pthread_create (45,9 µs). Nếu fork thật sự chép bộ nhớ, nó phải đắt gấp hàng nghìn lần một luồng, không phải 1,6 lần. Con số này là bằng chứng trực tiếp rằng CoW hoạt động: fork không chép dữ liệu, nó chỉ đắt hơn một luồng một chút vì phải dựng một không gian địa chỉ riêng, trong khi luồng dùng chung không gian sẵn có.

Nhưng dòng thứ ba mới là chỗ bất ngờ. Tôi cấp 256 MB bộ nhớ, chạm vào toàn bộ (để nó thực sự được ánh xạ), rồi mới fork. Nếu CoW làm fork hoàn toàn không phụ thuộc kích thước bộ nhớ, con số này phải bằng 74 µs. Nó không — 137,7 µs, chậm 1,86 lần.

Một lần tôi đo hớ: CoW không làm fork miễn phí theo kích thước

Tôi vào đo với niềm tin thứ nhất, phổ biến nhất: "fork đắt vì phải sao chép cả bộ nhớ". Phép đo bác bỏ ngay: fork chỉ 74 µs, 1,6 lần một luồng — nếu nó chép 256 MB thật thì phải mất nhiều mili-giây (băng thông bộ nhớ ~10 GB/s → 256MB mất ~25 ms), trong khi fork sau khi cấp 256 MB chỉ tăng thêm ~64 µs, không phải 25 ms. Rõ ràng dữ liệu không bị chép. CoW là thật.

Nhưng rồi tôi đo hớ tầng thứ hai — và đây mới là điều tinh tế. Tin rằng CoW đã bác bỏ "fork chép bộ nhớ", tôi kết luận vội: "vậy fork không phụ thuộc kích thước bộ nhớ". Sai. Fork sau khi cấp 256 MB chậm 1,86 lần (137,7 vs 74,1 µs). Vì sao, nếu dữ liệu không bị chép? Vì fork vẫn phải nhân đôi bảng trang (page table) của cha — cấu trúc ánh xạ mỗi trang ảo tới trang vật lý — và đánh dấu mọi mục là chỉ đọc để bật CoW. 256 MB chia cho trang 4 KB là 65.536 mục bảng trang; con càng có nhiều bộ nhớ ánh xạ, bảng trang càng lớn, và việc sao chép + đánh dấu chúng càng lâu. Dữ liệu không bị chép, nhưng siêu dữ liệu ánh xạ thì có.

Bài học đo lường: một tối ưu (CoW) giải quyết một chi phí nhưng không xóa mọi chi phí — phải đo để biết cái gì còn lại. CoW cắt chi phí sao chép dữ liệu xuống gần không, nhưng chi phí dựng không gian địa chỉ và nhân đôi bảng trang vẫn còn, và vẫn tăng theo lượng bộ nhớ ánh xạ. Nếu tôi dừng ở "CoW nên fork rẻ và cố định", tôi đã bỏ lỡ vì sao một tiến trình lớn fork chậm hơn một tiến trình nhỏ — một điều rất thực với các server dùng nhiều RAM rồi fork.

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

Hệ quả đầu tiên: fork rẻ nhờ CoW — đừng ngại fork rồi exec. Mẫu "fork rồi exec" (shell chạy lệnh, server sinh tiến trình con) rất hiệu quả: fork không chép bộ nhớ, và exec ngay sau đó thay toàn bộ không gian địa chỉ nên gần như không trang CoW nào phải nhân bản. Bạn không cần né fork vì sợ nó "chép cả bộ nhớ" — nó không.

Hệ quả thứ hai: tiến trình lớn thì fork đắt hơn — vì bảng trang. Nếu một server đã cấp nhiều GB (ví dụ một tiến trình JVM hay một cache lớn) rồi fork, mỗi fork phải nhân đôi bảng trang cho toàn bộ vùng nhớ đó, và chi phí tăng theo kích thước. Đây là lý do các server hiện đại tránh "fork từ một tiến trình đã béo" — họ fork sớm khi còn nhỏ, hoặc dùng posix_spawn/vfork, hoặc một tiến trình khởi động chuyên trách. Với luồng thì không có chi phí này (chung không gian địa chỉ).

Hệ quả thứ ba là tinh thần đo lường: một tối ưu che một chi phí, không phải mọi chi phí — đo cái còn lại. Con số mang theo: fork tốn ~74 µs, chỉ 1,6 lần một pthread (45,9 µs) — bằng chứng CoW hoạt động, fork KHÔNG chép dữ liệu (nếu chép 256MB phải mất ~25ms, đo chỉ +64µs); nhưng CoW không làm fork độc lập kích thước: fork sau khi cấp 256MB chậm 1,86 lần (137,7µs) vì vẫn phải nhân đôi bảng trang (~65536 mục) và đánh dấu chỉ-đọc. CoW cắt chi phí chép dữ liệu; chi phí bảng trang thì còn, và tăng theo bộ nhớ ánh xạ.

Thử ba mươi giây

Nghĩ về một chương trình bạn biết có fork (một shell, một server kiểu prefork như nginx/Apache, hay một script gọi tiến trình con). Hỏi: tại thời điểm fork, tiến trình cha đang giữ bao nhiêu bộ nhớ ánh xạ? Nếu nó vừa nạp một mô hình lớn, một cache khổng lồ, hay một heap JVM nhiều GB rồi mới fork, thì mỗi fork đang trả chi phí nhân đôi bảng trang cho toàn bộ vùng đó — dù CoW không chép một byte dữ liệu. Ba mươi giây suy nghĩ đó cho bạn một quy tắc thực dụng: fork khi tiến trình còn nhỏ, trước khi phình to; và nhớ rằng "fork rẻ nhờ CoW" đúng về dữ liệu, nhưng bảng trang vẫn là một cái giá tăng theo kích thước.