Một trong những điều làm Go dễ triển khai là binary tĩnh duy nhất: scp một file lên máy chủ là chạy được, không cần cài runtime, không cần copy thư mục assets/. Nhưng ứng dụng thật luôn có tệp tĩnh — template HTML, file cấu hình mặc định, chứng chỉ, migration SQL, logo. Nếu chúng nằm cạnh binary dưới dạng file rời, ưu thế "một binary" biến mất. //go:embed (từ Go 1.16) đóng khoảng trống đó: nhúng tệp tĩnh thẳng vào binary lúc biên dịch. Bài này mổ xẻ ba dạng nhúng, và đo thật hai đánh đổi quan trọng: kích thước binary và tốc độ đọc.
Ba dạng nhận dữ liệu nhúng
//go:embed là một chỉ thị compiler (directive), không phải hàm. Đặt ngay trên một biến cấp gói, nó bảo compiler đọc tệp lúc build và gán nội dung vào biến. Có ba kiểu đích, mỗi kiểu cho một nhu cầu:
import (
"embed"
"io/fs"
)
//go:embed assets/loichao.txt
var loiChao string // nhúng thẳng vào string
//go:embed assets/data.csv
var dataCSV []byte // nhúng vào []byte
//go:embed assets
var assetFS embed.FS // nhúng CẢ thư mục thành hệ tệp ảo
string và []byte cho đúng một tệp — hợp khi bạn cần nội dung một file cụ thể (một template, một khóa công khai). embed.FS nhúng cả thư mục (đệ quy) thành một hệ tệp chỉ-đọc trong bộ nhớ, dùng khi có nhiều tệp hoặc cần duyệt cây.

Hình 1: Ba dạng đích — string/[]byte cho một tệp, embed.FS cho cả thư mục. embed.FS thỏa interface fs.FS nên cắm thẳng vào http.FileServer, template.ParseFS mà không cần đĩa.
embed.FS là một fs.FS thật
Điểm mạnh nhất của embed.FS là nó thỏa interface fs.FS của thư viện chuẩn. Nghĩa là mọi thứ nhận fs.FS — http.FileServer, template.ParseFS, fs.WalkDir — dùng được ngay với dữ liệu nhúng, không cần đĩa:
b, _ := assetFS.ReadFile("assets/data.csv") // đọc một tệp
fs.WalkDir(assetFS, "assets", func(p string, d fs.DirEntry, e error) error {
// duyệt cây thư mục nhúng như os.DirFS
return nil
})
Đo thật (Go 1.23): nhúng một thư mục assets chứa ba tệp, chương trình đọc và duyệt được cả cây — blob.bin (102400 byte), data.csv (23 byte), loichao.txt (29 byte). Đây là nền cho pattern phổ biến: nhúng cả thư mục templates/ hay static/ rồi phục vụ qua http.FileServerFS(assetFS) — server web một-binary không cần copy file tĩnh.
Đo thật đánh đổi 1: embed không nén
Đây là điều nhiều người tưởng nhầm. //go:embed không nén dữ liệu — nó lưu byte thô vào một section của binary. Để chứng minh, cùng một đoạn mã, chỉ đổi kích thước tệp nhúng:

Hình 2: Cùng mã, đổi tệp nhúng: 1 byte → binary 2.155.517; 1 MB toàn số 0 → 3.204.093 (+1.048.576); 1 MB ngẫu nhiên → 3.204.093 (+1.048.576). Nhúng 1 MB làm binary tăng đúng 1 MB, dù dữ liệu nén được (toàn 0) hay không (ngẫu nhiên) — chứng tỏ embed lưu thô, không nén.
Nhúng 1 MB làm binary tăng chính xác 1.048.576 byte — và con số này giống hệt cho dữ liệu toàn số 0 (nén được cực tốt) lẫn dữ liệu ngẫu nhiên (không nén được). Nếu embed có nén, hai trường hợp sẽ khác nhau rất xa. Chúng bằng nhau tuyệt đối → embed lưu byte thô. Hệ quả thực tế: nhúng một video 50 MB làm binary phình thêm 50 MB. Nếu bạn cần nén, phải tự nén tệp trước khi nhúng (ví dụ nhúng file .gz) rồi giải nén lúc chạy.
Đo thật đánh đổi 2: đọc nhanh hơn đĩa
Đổi lại kích thước, dữ liệu nhúng nằm sẵn trong RAM (là một phần image của tiến trình), nên đọc nó không cần mở/đọc/đóng tệp qua syscall. Benchmark đọc một tệp 64 KB:
- embed.FS.ReadFile: 4.636 ns/op, 65.552 B, 2 cấp phát.
- os.ReadFile (đĩa): 7.990 ns/op, 74.048 B, 5 cấp phát.
embed nhanh hơn ~1,7 lần và ít cấp phát hơn (2 so với 5). Lý do: os.ReadFile phải gọi open, read, close — mỗi cái là một syscall, cộng cấp phát cho đường dẫn xử lý file descriptor. embed.FS.ReadFile chỉ cắt một lát (slice) từ dữ liệu đã có trong bộ nhớ. Cả hai vẫn cấp phát buffer 64 KB để trả về nội dung, nhưng embed bỏ được chi phí syscall. Với ứng dụng đọc template/asset liên tục, khác biệt này cộng dồn.
Đánh đổi cần cân nhắc
Tệp nhỏ, cần một-binary → embed thắng rõ. Template, cấu hình mặc định, migration SQL, khóa công khai, logo nhỏ — tổng cỡ vài trăm KB. Nhúng chúng làm binary lớn không đáng kể mà đổi lại triển khai cực gọn (một file, không lo thiếu asset lúc runtime). Đây là ca dùng đúng nhất của embed.
Tệp lớn, hay đổi → cân nhắc kỹ. Video, dataset, ảnh độ phân giải cao làm binary phình và tốn RAM thường trú (dữ liệu nhúng luôn trong bộ nhớ suốt đời tiến trình, không như file đĩa chỉ nạp khi đọc). Tệp thay đổi thường xuyên mà nhúng thì mỗi lần đổi phải build lại. Với những thứ này, phục vụ từ đĩa hoặc object storage (CDN, S3) thường đúng hơn.
Không có nén — tự lo nếu cần. Vì embed lưu thô, muốn tiết kiệm dung lượng binary với tệp nén được (JSON lớn, SQL), hãy nhúng bản .gz và gzip.NewReader lúc chạy. Đổi CPU giải nén lấy binary nhỏ hơn — cân theo bài toán. Với asset đã nén sẵn (PNG, JPEG, woff2), nhúng thẳng là đúng vì nén lại vô ích.
Ba ý mang về
//go:embednhúng tệp tĩnh vào binary dưới ba dạng:stringvà[]bytecho một tệp,embed.FScho cả thư mục —embed.FSthỏafs.FSnên cắm thẳng vàohttp.FileServer,template.ParseFS,fs.WalkDirmà không cần đĩa.- embed lưu thô, KHÔNG nén: đo thật, nhúng 1 MB làm binary tăng đúng 1.048.576 byte — giống hệt cho dữ liệu toàn 0 (nén được) lẫn ngẫu nhiên (không) — nên tệp lớn làm binary phình đúng bằng kích thước gốc; muốn nén phải tự nhúng bản
.gz. - Đọc từ embed nhanh hơn đĩa ~1,7x (4.636 ns so với 7.990 ns, ít cấp phát hơn) vì dữ liệu nằm sẵn trong RAM, không syscall mở/đọc/đóng — đổi lại là RAM thường trú suốt đời tiến trình; hợp cho asset nhỏ cần triển khai một-binary.
Phần sau ta xét một chỉ thị biên dịch khác định hình chính cái binary được tạo ra: Phần sau mổ xẻ build tags — cách biên dịch có điều kiện theo hệ điều hành, kiến trúc, và cờ tùy biến để một mã nguồn ra nhiều binary khác nhau.