Ba phần đầu sê-ri đo fork — cách Unix nhân bản một tiến trình. Nhưng nhân bản chính mình hiếm khi là mục tiêu; thường bạn muốn chạy một chương trình khác. Đó là việc của exec (execve): thay toàn bộ nội dung tiến trình hiện tại bằng một chương trình mới. Cặp fork rồi exec là cách shell chạy mọi lệnh, cách một server sinh tiến trình con. Câu hỏi: exec tốn bao nhiêu, và nó làm gì trong khoảng đó? Trực giác "exec chỉ là nhảy vào chương trình mới, gần như tức thì" sai xa. Tôi đo trong container gcc:13 (ARM), tách riêng chi phí của execve và so binary tĩnh với động.
execve làm gì trước khi main chạy
execve không "nhảy vào" chương trình mới — nó phải dựng chương trình đó từ đầu. Các bước: đọc và kiểm tra file thực thi (định dạng ELF), dựng một không gian địa chỉ mới, ánh xạ các segment (mã máy, dữ liệu khởi tạo) từ file vào bộ nhớ, dựng stack ban đầu chứa argv, biến môi trường envp, và vector phụ trợ auxv.
Với một binary động (mặc định khi bạn gcc file.c), còn một bước nặng nữa: nhân nạp trình liên kết động ld.so và trao quyền cho nó trước khi main chạy. ld.so phải ánh xạ các thư viện chia sẻ (như libc), phân giải symbol (tìm địa chỉ thật của printf, malloc…), và áp các relocation. Tất cả xảy ra trong khoảng "chương trình đang khởi động" mà bạn không thấy. Một binary tĩnh gộp sẵn libc vào file nên không cần ld.so — đổi lại file to hơn nhiều.
Đo: execve 45µs tĩnh, 73µs động
Tôi đo fork+exec+wait một chương trình tí xíu (int main(){return 0;}) ở hai dạng, và fork+exit thuần (không exec) làm mốc để trừ đi chi phí fork:
fork+exit (KHÔNG exec) : 75,5 µs (= chi phí fork, phần 2)
fork+exec ĐỘNG (dynamic)+wait : 149,0 µs
fork+exec TĨNH (static)+wait : 121,1 µs
execve riêng (trừ fork): động = 73,5 µs | tĩnh = 45,6 µs | động/tĩnh = 1,61×
kích thước file: động = 70 KB | tĩnh = 872 KB
Trừ đi phần fork (~75 µs), execve riêng tốn 45,6 µs cho binary tĩnh — dù chương trình chỉ return 0. Đó thuần là chi phí nạp: đọc ELF, ánh xạ segment, dựng stack. "Nhảy vào chương trình" mà mất 45 µs, gấp ~450 lần một syscall thường.
Binary động tốn 73,5 µs — chậm hơn tĩnh 1,61 lần. Chênh lệch ~28 µs đó chính là công của ld.so: nạp libc, phân giải symbol, relocation — cho một chương trình không dùng một hàm libc nào ngoài phần khởi động. Với chương trình thật gọi nhiều thư viện, phần này còn lớn hơn. Đáng chú ý: đây là chương trình rỗng; một binary động thật liên kết chục thư viện (mỗi cái phải ánh xạ, phân giải hàng trăm symbol) trả ld.so nhiều hơn hẳn — đó là vì sao nhiều ứng dụng lớn khởi động chậm rõ ở lần chạy đầu, khi các trang của libc còn chưa nằm trong bộ nhớ đệm.
Một lần tôi đo hớ: binary nhỏ mà khởi động chậm
Tôi vào đo với niềm tin phổ biến: "exec chỉ là nhảy vào chương trình mới, nên gần như tức thì". Phép đo bác bỏ ngay — execve tốn 45–73 µs trước khi dòng đầu của main chạy, vì nó phải nạp và dựng cả chương trình từ file.
Nhưng chỗ đo hớ thú vị nhất là so tĩnh với động, và nó ngược trực giác về "nhỏ thì nhanh". Binary động chỉ 70 KB, còn binary tĩnh 872 KB — to gấp 12 lần. Trực giác nói cái nhỏ nạp nhanh hơn. Đo ra ngược lại: binary động khởi động chậm hơn (73,5 vs 45,6 µs). Vì kích thước file trên đĩa không phải thứ quyết định thời gian khởi động — công liên kết lúc chạy mới quyết định. Binary động nhỏ vì nó chưa chứa libc; nhưng đúng vì thế, lúc chạy nó phải trả ld.so để nạp và nối libc. Binary tĩnh to vì đã gộp sẵn mọi thứ; đổi lại lúc chạy không phải liên kết gì.
Bài học đo lường: "kích thước file" không đo được "thời gian khởi động" — phải đo cái công thực sự làm lúc chạy, không suy từ cái nhìn thấy trên đĩa. Đây là một đánh đổi kinh điển tĩnh-vs-động: động tiết kiệm đĩa và RAM (nhiều tiến trình chia sẻ một bản libc) nhưng trả giá khởi động; tĩnh tốn đĩa nhưng khởi động nhanh và độc lập. Con số ~1,61× cho biết cái giá của sự tiện lợi động — và chỉ đo mới thấy, vì nhìn hai file thì cái động trông "nhẹ" hơn hẳn.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: khởi động một tiến trình không rẻ — fork + exec ≈ 120–149 µs. Nếu bạn viết thứ gì sinh tiến trình con liên tục (một script gọi hàng nghìn lệnh nhỏ, một server fork-per-request), mỗi lần là ~120–149 µs chỉ để khởi động, chưa tính việc thật. Đây là lý do các mẫu hiệu năng cao tránh sinh tiến trình mỗi việc — dùng tiến trình/luồng thường trú (thread pool) như sê-ri đồng thời đã đo.
Hệ quả thứ hai: cân nhắc liên kết tĩnh cho công cụ khởi động nhiều lần. Với một CLI chạy hàng nghìn lần (trong CI, trong script), ~28 µs ld.so mỗi lần cộng dồn đáng kể, và liên kết tĩnh cắt nó — đổi lại file to hơn và mất khả năng chia sẻ libc. Với dịch vụ chạy dài thì ngược lại: khởi động một lần, nên tiết kiệm RAM của động đáng giá hơn. Đo workload thật của bạn để chọn.
Hệ quả thứ ba là tinh thần đo lường: thời gian khởi động nằm ở công lúc chạy, không ở kích thước file. Con số mang theo: execve không phải "nhảy vào" mà nạp ELF + ánh xạ segment + dựng stack — riêng nó tốn 45,6 µs (binary tĩnh); binary ĐỘNG tốn 73,5 µs, chậm 1,61× vì trình liên kết động ld.so phải ánh xạ libc + phân giải symbol + relocation TRƯỚC main; trớ trêu binary động nhỏ hơn (70KB vs 872KB) nhưng khởi động chậm hơn vì trả ld.so lúc chạy; "khởi động một tiến trình" = fork ~75µs + exec 46-74µs ≈ 120-149µs. Nhỏ trên đĩa không bằng nhanh lúc chạy.
Thử ba mươi giây
Lấy một chương trình nhỏ và chạy LD_DEBUG=statistics ./chương-trình (với binary động) — bạn sẽ thấy ld.so báo cáo thời gian nó dành cho việc phân giải symbol và relocation trước khi main chạy. Rồi biên dịch cùng chương trình đó với -static và so thời gian khởi động (dùng hyperfine hay chỉ một vòng lặp bấm giờ). Bạn sẽ thấy bản tĩnh khởi động nhanh hơn dù file to hơn nhiều — bằng chứng trực tiếp rằng cái quyết định thời gian khởi động là công liên kết lúc chạy, không phải kích thước trên đĩa. Ba mươi giây đó cho bạn một trực giác đúng về "vì sao chương trình của tôi mất một lúc mới bắt đầu": phần lớn thời gian đó trôi qua trước dòng code đầu tiên của bạn, trong tay execve và ld.so.