Khi trình liên kết ghép chương trình của bạn với thư viện (libc, và các thư viện khác), nó có hai cách. Liên kết tĩnh chép mã thư viện thẳng vào file thực thi — tự chứa, không phụ thuộc gì lúc chạy. Liên kết động (mặc định) chỉ ghi vào file "tôi cần libc.so.6", để thư viện được nạp lúc chạy bởi trình liên kết động. Bài này đo kích thước file và thời gian khởi động của hai cách — và con số khởi động đảo ngược đúng câu tôi vẫn tin.
Hai cách ghép thư viện vào chương trình
Liên kết động là mặc định của gcc. File thực thi không chứa mã của libc; nó chỉ ghi một danh sách "cần các thư viện chia sẻ này", và mỗi lần chạy, trình liên kết động (ld.so) ánh xạ các thư viện đó vào không gian địa chỉ rồi phân giải các ký hiệu. Một bản libc.so.6 duy nhất trong RAM được mọi tiến trình chia sẻ. Xem phụ thuộc bằng ldd.
Liên kết tĩnh (-static) chép mọi hàm thư viện mà chương trình dùng (và các phụ thuộc của chúng) thẳng vào file thực thi. Kết quả tự chứa: không cần .so nào lúc chạy, chạy được trên máy không có đúng phiên bản thư viện. ldd một bản tĩnh trả về "not a dynamic executable". Đây là điểm hấp dẫn của liên kết tĩnh: một file duy nhất bê đi đâu cũng chạy, không lo thiếu thư viện hay lệch phiên bản — rất tiện cho container tối giản và phân phối công cụ dòng lệnh.
Sách vở thường nói: bản tĩnh khởi động nhanh hơn vì khỏi phải phân giải ký hiệu lúc chạy, còn bản động nhỏ hơn và tiết kiệm RAM nhờ chia sẻ. Tôi tin vế đầu — cho tới khi đo.
Đo: tĩnh to hơn 12 lần, và khởi động chậm hơn
Với một chương trình hello in một dòng, trên aarch64:
| Liên kết động | Liên kết tĩnh | |
|---|---|---|
| Kích thước file | 70 KB | 872 KB |
ldd |
libc.so.6 + ld-linux | not a dynamic executable |
Bản tĩnh to gấp 12,4 lần — đúng như dự đoán, vì nó chép cả một mảng libc vào. Nhưng thời gian khởi động (chạy một chương trình rỗng 1000 lần) mới gây bất ngờ:
động: ~800 ms / 1000 lần
tĩnh: ~1450 ms / 1000 lần (chậm hơn ~1.75 lần!)
Lặp lại ba lần, con số ổn định: bản tĩnh khởi động CHẬM hơn, không phải nhanh hơn. Chênh 650 mili giây trên 1000 lần chạy, tức khoảng 0,65 mili giây mỗi lần khởi động — nhỏ với một chương trình, nhưng cộng dồn lại đáng kể với một dịch vụ khởi chạy tiến trình liên tục. Điều này ngược hẳn với câu "tĩnh nhanh hơn" tôi vẫn tin. Nên tôi đi tìm nguyên nhân.
Một lần tôi đo hớ: cái đắt là kích thước, không phải phân giải
Tôi vào bài với niềm tin: liên kết tĩnh bỏ được bước phân giải ký hiệu lúc chạy, nên phải khởi động nhanh hơn. Đo ra chậm hơn 1,75 lần, nên chi phí phân giải mà tôi sợ hóa ra không phải yếu tố quyết định. Đọc size để tìm thủ phạm thật:
động: .text = 1,3 KB
tĩnh: .text = 522 KB
Đoạn mã (.text) của bản tĩnh là 522 KB — nó đã chép cả một khối libc vào. Mỗi lần exec, hệ điều hành phải ánh xạ và nạp lỗi trang (page fault) cho cái khối 522 KB đó. Còn bản động có .text chỉ 1,3 KB (đúng phần main của tôi), và libc.so.6 mà nó cần đã nằm sẵn trong RAM — vì gần như mọi tiến trình trên hệ thống (shell, các lệnh hệ thống) đều dùng libc.so.6, nên nó luôn thường trú và chia sẻ. Bản động chỉ việc ánh xạ vào thư viện đã có sẵn, khỏi nạp lại.
Vậy cái đắt khi khởi động không phải việc phân giải ký hiệu (nhỏ, và với lazy binding còn hoãn được), mà là việc ánh xạ và nạp một ảnh nhớ lớn. Bản tĩnh trả giá bằng nửa megabyte mã riêng phải nạp mỗi lần; bản động tận dụng libc thường trú nên nhẹ tênh. Bài học đo lường: "tĩnh nhanh khởi động" là một câu truyền miệng đúng trong vài cảnh nhưng sai ở ca thường gặp — khi libc đã thường trú (gần như luôn vậy), bản động khởi động nhanh hơn vì file bé. Tôi đã tin một mệnh đề nghe hợp lý mà chưa đo, và gán chi phí cho sai chỗ (phân giải) thay vì chỗ thật (kích thước ảnh nhớ). Đúng chủ đề của bài mở vòng lặp: to hơn không có nghĩa nhanh hơn.
Cần công bằng: liên kết tĩnh có thể khởi động nhanh hơn trong những cảnh khác — trên máy không có sẵn thư viện trong cache, hoặc chương trình phụ thuộc vào rất nhiều thư viện động phải phân giải (khi đó chi phí ld.so mới lớn). Kết luận đúng không phải "động luôn nhanh hơn" mà là "phải đo trong đúng cảnh của mình, đừng theo câu truyền miệng".
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là chọn kiểu liên kết theo nhu cầu thật, không theo cảm giác. Liên kết động (mặc định) tốt cho hầu hết trường hợp: file nhỏ, libc chia sẻ giữa các tiến trình (tiết kiệm RAM toàn hệ thống), và cập nhật vá lỗi bảo mật trong libc áp dụng ngay mà không phải biên dịch lại chương trình. Liên kết tĩnh đáng dùng khi bạn cần một file duy nhất chạy được mọi nơi — container tối giản, phân phối binary không phụ thuộc, môi trường không có đúng phiên bản thư viện. Đó là đánh đổi tự chứa đổi lấy kích thước và tính chia sẻ, không phải đổi lấy tốc độ khởi động.
Hệ quả thứ hai là hiểu vì sao bản vá bảo mật libc quan trọng với liên kết động. Khi một lỗ hổng trong libc được vá, mọi chương trình liên kết động tự động dùng bản mới ở lần chạy sau — chỉ cần cập nhật một file .so. Chương trình liên kết tĩnh thì đã "đóng băng" bản libc cũ bên trong; muốn vá phải biên dịch lại và phát hành lại từng cái. Đây là một lý do lớn khiến hệ thống ưa liên kết động cho các thư viện nền tảng.
Hệ quả thứ ba là bài học đo lường: một câu "ai cũng biết" về hiệu năng vẫn phải đo trong bối cảnh của bạn. Con số mang theo: liên kết tĩnh cho file to hơn nhiều (12x ở đây) và tự chứa, nhưng KHÔNG đảm bảo khởi động nhanh hơn — đo ra còn chậm hơn 1.75x vì phải nạp ảnh nhớ lớn, trong khi bản động nhỏ và dùng libc đã thường trú; hãy chọn tĩnh vì tính tự chứa, chọn động vì kích thước, chia sẻ RAM và vá bảo mật dễ, chứ đừng chọn theo niềm tin "tĩnh nhanh hơn". Khi ai đó nói X nhanh hơn Y, câu hỏi đầu tiên luôn là "trong điều kiện nào, và bạn đã đo chưa".
Thử ba mươi giây
Biên dịch một chương trình hello hai lần: gcc hello.c -o hi_dyn và gcc -static hello.c -o hi_sta. So kích thước bằng ls -l (bản tĩnh to hơn hẳn), rồi chạy ldd hi_dyn (thấy libc.so.6) và ldd hi_sta (thấy "not a dynamic executable"). Cuối cùng đo khởi động: time bash -c 'for i in {1..1000}; do ./hi_dyn; done' rồi làm lại với hi_sta. Bạn sẽ tận mắt thấy bản tĩnh to hơn nhiều mà khởi động không nhanh hơn — thường còn chậm hơn. Đó là lý do "đo trong cảnh của mình" luôn thắng "câu truyền miệng".