Hình dung một thư viện muốn mở chi nhánh. Cách tốn kém: photo toàn bộ sách sang chi nhánh mới. Cách khôn: hai chi nhánh dùng chung kho sách thật, chỉ khi ai cần sửa một cuốn mới photo riêng đúng cuốn đó. Nhưng dù dùng chung kho sách, chi nhánh mới vẫn cần một bản mục lục riêng — cái danh sách "cuốn nào nằm kệ nào". Sao chép kho sách thì đã tránh được; nhưng sao chép mục lục thì không, và mục lục càng dài (càng nhiều sách) thì càng lâu. fork của Unix vận hành đúng vậy: nó không chép dữ liệu (kho sách), nhưng vẫn phải chép bảng trang (mục lục) — và đó là cái giá ẩn tăng theo bộ nhớ.
Ở 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ớ.
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 — đúng hai chi nhánh dùng chung kho sách. 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ôi trường đo: container gcc:13 trên host ARM AArch64, mỗi phép lặp lấy min. Con số đổi theo máy; điều bền vững là hình dạng: fork ~ một luồng (không chép dữ liệu), nhưng fork của tiến trình bộ nhớ lớn chậm hơn hẳn do bảng trang.)
Hai niềm tin đối nghịch, cùng sai
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 tin rằng CoW đã bác bỏ "fork chép bộ nhớ", tôi rơi vào niềm tin ngược cũng sai, và tinh tế hơn: "vậy fork không phụ thuộc kích thước bộ nhớ". 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ái mục lụ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ạ, mục lục càng dài, và việc sao chép + đánh dấu chúng càng lâu. Dữ liệu (kho sách) không bị chép, nhưng mục lục ánh xạ thì có. Sự thật: một tối ưu (CoW) giải quyết một chi phí nhưng không xóa mọi chi phí — 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ạ. Dừng ở "CoW nên fork rẻ và cố định" là bỏ lỡ vì sao một tiến trình lớn fork chậm hơn một tiến trình nhỏ.
Về đến code hằng ngày
Điều đầ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.
Điều 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 (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ỉ).
Chỗ này cái giá bảng-trang-theo-kích-thước là một nỗi đau rất thực ở tầng vận hành. Redis fork để BGSAVE, và trên một instance giữ hàng chục GB, chính bước fork gây một latency spike đo được — không phải vì chép dữ liệu (CoW lo), mà vì nhân đôi bảng trang khổng lồ (đó cũng là lý do huge page giúp: ít mục bảng trang hơn để chép). Python multiprocessing với start method fork sao chép bảng trang của cả tiến trình cha nặng nề — nên spawn/forkserver ra đời một phần vì vậy. Go cố tình không phơi fork trần (dùng posix_spawn dưới nền) để né đúng cái bẫy "fork một runtime đa luồng đã béo". Và mẫu chống lại tất cả: fork sớm, khi tiến trình còn gầy — trước khi nạp mô hình/cache lớn. Cùng một bài học thư-viện-và-mục-lục: kho sách chia sẻ được, nhưng mỗi chi nhánh vẫn phải chép mục lục, và mục lục dài thì chép lâu.
Muốn tự thấy thì 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. Vài phút 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.