Có một điều kỳ lạ trong cách Unix khởi chạy một chương trình mới. Bạn tưởng sẽ có một lời gọi kiểu run("/bin/ls"). Thực tế lại là hai bước: fork() nhân đôi tiến trình đang chạy thành hai bản y hệt, rồi bản con gọi exec() để vứt bỏ toàn bộ ảnh nhớ vừa được nhân và nạp chương trình mới đè lên. Nghe hoang phí đến vô lý — sao phải sao chép cả một tiến trình chỉ để ném đi ngay sau đó? Bài này đo cái giá thật của forkexec trong container, và phát hiện trực giác "sao chép cả tiến trình thì phải đắt" sai ở đúng chỗ tôi tưởng nó đúng.

fork rồi exec

Hai bước: fork rồi exec

fork() tạo một tiến trình con là bản sao gần như hoàn hảo của tiến trình cha: cùng bộ nhớ, cùng các file đang mở, cùng vị trí đang chạy. Nó trả về hai lần — trong cha trả về PID của con, trong con trả về 0 — nên hai bản đi tiếp theo hai nhánh khác nhau từ cùng một dòng code.

exec() thì ngược lại: nó không tạo tiến trình mới mà thay toàn bộ ruột của tiến trình hiện tại bằng một chương trình khác — bộ nhớ cũ bị bỏ, binary mới được nạp, và tiến trình bắt đầu chạy từ main của chương trình mới, giữ nguyên PID và các file đang mở.

Ghép hai cái lại là công thức chạy chương trình: fork để có một tiến trình mới, rồi trong con exec để nó hóa thân thành chương trình cần chạy. Câu hỏi hiển nhiên — tại sao không gộp làm một lời gọi cho đỡ phí? — có một câu trả lời đẹp mà ta sẽ thấy ở cuối. Nhưng trước hết, cái "sao chép cả tiến trình" kia đắt cỡ nào?

Đo: fork một tiến trình to không hề đắt như tưởng

Tôi đo thời gian riêng lời gọi fork() (con _exit ngay, cha bấm giờ từ trước fork tới khi fork trả về), lấy trung vị 200 lần, ở các mức bộ nhớ khác nhau của tiến trình cha:

Bộ nhớ cha fork() (trung vị)
heap ~0 67 µs
+64MB đã chạm từng trang 93 µs
+256MB đã chạm từng trang 133 µs

Con số đầu tiên đáng dừng lại: fork một tiến trình đang giữ 256MB chỉ tốn 133 micro giây. Nếu fork thật sự sao chép 256MB bộ nhớ, riêng việc chép ngần ấy byte qua RAM đã tốn cỡ hàng chục mili giây — chậm gấp trăm lần con số đo được. Vậy fork rõ ràng không chép dữ liệu. Nó dùng sao chép khi ghi (COW): thay vì nhân đôi từng byte, kernel chỉ nhân đôi bảng trang (bản đồ trang ảo → trang vật lý) và đánh dấu mọi trang chung là chỉ đọc; dữ liệu thật chỉ được chép dần, từng trang một, khi cha hoặc con thực sự ghi vào (đúng cơ chế trang chỉ đọc đã đo).

Nhưng bảng trang cũng lớn dần theo bộ nhớ, nên fork của tiến trình to vẫn chậm hơn: 67 → 93 → 133 µs khi heap tăng. Đây là chỗ tôi suýt rút ra một kết luận gọn mà sai.

Một lần tôi đo hớ: giá fork bám theo bảng trang, không theo byte cấp phát

Nhìn bảng trên — bộ nhớ càng lớn, fork càng chậm — phản xạ của tôi là chốt: "cứ cấp phát nhiều bộ nhớ thì fork chậm đi". Nhưng câu đó lẫn hai đại lượng khác nhau: byte xin cấp pháttrang thật đã tồn tại. Tôi tách chúng ra bằng một phép đo thứ hai: mmap 256MB nhưng không chạm vào (không ghi một byte nào), rồi fork:

Bộ nhớ cha Byte cấp phát fork()
256MB đã chạm từng trang 256 MB 133 µs
256MB mmap chưa chạm 256 MB 49 µs

Cùng 256MB xin cấp phát, nhưng fork chênh nhau 2,7 lần — và bản chưa chạm còn nhanh hơn cả tiến trình heap-rỗng (49 so với 67 µs). Lý do: mmap lười. Xin 256MB mà chưa ghi vào thì kernel chưa cấp trang vật lý nào, chưa dựng bảng trang nào cho vùng đó — nó chỉ ghi nhận "vùng này có tồn tại". Không có bảng trang thì fork chẳng có gì để nhân bản, nên vùng 256MB ấy gần như vô hình với fork. Chỉ khi tôi chạm từng trang (ghi một byte mỗi 4KB), kernel mới thật sự cấp 65536 trang và dựng bảng trang cho chúng — và bấy giờ fork mới phải nhân ngần ấy mục, chậm hẳn lên.

Bài học đo lường: giá của fork bám theo lượng bảng trang phải nhân bản — tức số trang đã thực sự chạm tới — chứ không theo số byte chương trình xin cấp phát. Nếu tôi chỉ đo trên vùng nhớ đã chạm và thấy "to hơn thì chậm hơn", tôi đã kết luận sai rằng thủ phạm là kích thước cấp phát. Chỉ khi tách riêng "đã cấp phát" khỏi "đã chạm" mới lộ ra biến thật. Đây cũng là lời nhắc rằng RSS (bộ nhớ thường trú, tức trang đã chạm) mới là thứ định giá fork, không phải kích thước ảo của tiến trình.

fork là phần rẻ; exec mới đắt

Còn một trớ trêu nữa. Tất cả công sức COW dựng bảng trang cho tiến trình con — dù rẻ — thường bị vứt sạch ngay lập tức, vì bước tiếp theo là exec thay toàn bộ ảnh nhớ. Con vừa thừa hưởng bản đồ 256MB của cha xong thì exec xóa sạch nó để nạp chương trình mới. Vậy nên tối ưu COW ở đây gần như là công cốc cho trường hợp fork-rồi-exec — may là nó quá rẻ để đáng bận tâm.

Cái đắt thật nằm ở exec. Tôi đo trọn gói fork() + exec("/bin/true") (một chương trình chẳng làm gì rồi thoát):

fork() thuần        :  67 µs
fork() + exec       : 309 µs   (gấp ~4,6 lần)

exec cộng thêm khoảng 240 µs — gấp mấy lần bản thân fork. Đó là vì nạp một chương trình mới tốn kém: đọc file thực thi, dựng lại toàn bộ vùng nhớ, chạy trình liên kết động để nạp và nối các thư viện chia sẻ (libc...), rồi mới nhảy vào main. Cái mà trực giác gọi là "hoang phí" — sao chép cả tiến trình — hóa ra là phần rẻ nhất; phần đắt là dựng một chương trình mới từ đĩa, thứ không cách nào tránh dù bạn khởi chạy bằng cơ chế nào.

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

Hệ quả đầu tiên trả lời câu hỏi treo ở đầu bài: tại sao tách fork và exec thay vì gộp một lời gọi? Vì cái khe giữa hai bước là chỗ quý giá. Sau khi fork mà trước khi exec, tiến trình con vẫn là bản sao của cha và có thể tự sắp đặt môi trường cho chương trình sắp chạy: nối đầu ra vào một pipe (dup2), đóng các file thừa, đổi thư mục làm việc, hạ quyền (setuid). Chính cái khe đó khiến vỏ lệnh (shell) làm được ls > out.txt hay a | b: nó fork, trong con chuyển hướng vào/ra, rồi mới exec. Gộp fork với exec làm một sẽ mất chỗ để làm những việc này. Đây là một thiết kế tách bạch có chủ đích, không phải sự thừa thãi.

Hệ quả thứ hai là hiểu vì sao đẻ tiến trình con rẻ hơn ta sợ, nhưng không miễn phí. Nhờ COW, tạo một tiến trình con để chạy song song không kéo theo việc chép toàn bộ bộ nhớ — điều khiến mô hình đa tiến trình của Unix khả thi. Nhưng nếu bạn fork một tiến trình đang giữ rất nhiều bộ nhớ đã chạm (một JVM vài GB chẳng hạn) chỉ để chạy một lệnh con nhỏ, cái fork đó vẫn phải nhân bảng trang khổng lồ và có thể tốn đáng kể — đây là lý do có posix_spawnvfork, các lối tắt tránh nhân bảng trang khi bạn biết chắc sẽ exec ngay. Với tiến trình to, nên khởi chạy tiến trình con trước khi phình bộ nhớ, hoặc dùng một tiến trình trợ giúp nhỏ.

Hệ quả thứ ba là một bài học đo lường chung: đừng gán chi phí cho đại lượng dễ thấy nhất; hãy tách biến để tìm đại lượng thật. "Tiến trình to fork chậm" nghe hợp lý, nhưng thứ định giá không phải kích thước cấp phát mà là số trang đã chạm — hai con số có thể chênh nhau hàng trăm lần với mmap lười. Con số mang theo: fork rẻ nhờ COW (256MB chỉ 133 µs, và chưa chạm thì gần như miễn phí), giá của nó bám theo bảng trang chứ không theo byte cấp phát, còn exec — bước nạp chương trình mới — mới là phần đắt thật (gấp ~4,6 lần fork). Cái nhìn "sao chép cả tiến trình rồi vứt đi" tưởng lãng phí, thực ra là phần rẻ nhất của cả quy trình.

Thử ba mươi giây

Chạy strace -f -e trace=clone,execve ls trên máy Linux (hoặc trong container) và xem: bạn sẽ thấy một lời gọi clone (fork hiện đại gọi qua clone) tạo tiến trình con, rồi ngay sau đó một execve("/bin/ls", ...) trong con — đúng cặp fork-rồi-exec. Thử với strace -f bash -c 'ls > /tmp/x' để thấy giữa cloneexecve có một lời gọi dup2 chuyển hướng đầu ra: đó chính là cái khe giữa hai bước đang được shell tận dụng. Chỉ vài dòng strace là thấy toàn bộ vũ điệu hai bước mà mọi lệnh bạn gõ đều đi qua.