Hình dung mua bàn ghế. Một cửa hàng giao thùng phẳng IKEA — hộp nhỏ gọn, nhẹ, dễ chở; nhưng về tới nhà bạn phải hì hục lắp ráp mới dùng được. Cửa hàng kia giao bàn ghế đã lắp sẵn — cồng kềnh, xe tải to; đổi lại về là kê vào dùng ngay. Nghịch lý: cái hộp nhỏ lại mất nhiều thời gian hơn để thành cái bàn dùng được. Binary động và binary tĩnh của một chương trình chính là hai kiểu giao hàng đó — và exec chính là lúc "về tới nhà".
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 — đúng công lắp ráp bộ thùng phẳng khi về tới nhà. 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, như bộ bàn ghế giao sẵn.
Đ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 hẳn — 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 nhiều, đó 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ôi trường đo: container gcc:13 trên host ARM AArch64, chương trình main(){return 0;}, execve tách bằng cách trừ fork+exit, mỗi số lấy sau warmup. Con số đổi theo máy; điều bền vững là: exec tốn hàng chục µs công nạp, và binary động trả thêm cho ld.so lúc chạy.)
Hai niềm tin đối nghịch, cùng sai
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.
Niềm tin ngược, và là chỗ đo hớ thú vị nhất, là suy từ kích thước file: "file nhỏ thì nạp nhanh hơn". 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 (cái thùng phẳng); nhưng đúng vì thế, lúc chạy nó phải trả ld.so để nạp và nối libc (lắp ráp). Binary tĩnh to vì đã gộp sẵn mọi thứ (giao lắp sẵn); đổi lại lúc chạy không phải liên kết gì. "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.
Về đến code hằng ngày
Điều đầ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.
Điều 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.
Chỗ này cái đánh đổi tĩnh-vs-động và "khởi động tốn công lúc chạy" là trục xoay của cả thế giới hiện đại. Go nổi tiếng biên dịch ra một binary tĩnh — đó là lý do Go CLI khởi động tức thì và Docker image Go dùng scratch/distroless chỉ vài MB, không cần libc trong image. Đây cũng là mấu chốt của cold start serverless: một hàm Lambda phải fork+exec+nạp runtime mỗi lần khởi lạnh, nên runtime nhẹ/tĩnh thắng lớn. JVM là thái cực ngược: classloading + JIT lúc khởi động là "ld.so ở quy mô khổng lồ" — mất hàng trăm ms tới giây, và cả một ngành công cụ (AppCDS chia sẻ class, GraalVM native image biên dịch sẵn thành binary tĩnh) sinh ra chỉ để cắt cái giá khởi động đó. Mẫu số chung khớp đúng phép đo: thời gian tới-khi-sẵn-sàng nằm ở công liên kết/khởi tạo lúc chạy, không ở kích thước file — và tĩnh hóa/biên dịch sẵn là cách đổi đĩa lấy tốc độ khởi động.
Muốn tự thấy thì 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 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. Vài phút đó 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.