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.

Ảnh chụp đoạn mã Go nền tối minh hoạ go embed nhúng tệp tĩnh thẳng vào binary ba dạng nhận, import embed và io fs, chỉ thị 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 sửa được bản sao, go embed assets var assetFS embed FS nhúng cả thư mục thành FS ảo, embed FS dùng như hệ tệp read only b bằng assetFS ReadFile assets data csv đọc một tệp, fs WalkDir assetFS assets duyệt cây thư mục nhúng như os DirFS, embed FS thỏa fs FS cắm thẳng vào http FileServer template ParseFS text template mà không cần đĩa, luật của go embed chỉ thị đặt ngay trên biến gói package level var phải import embed kể cả khi chỉ dùng string hoặc byte đường dẫn tương đối thư mục nguồn không ra ngoài string byte đúng một tệp embed FS nhiều tệp hoặc thư mục all assets để gồm cả tệp ẩn bắt đầu bằng chấm hoặc gạch dưới

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:

Ảnh chụp bảng kết quả đo thật nền tối embed không nén 1 MB tệp bằng cộng 1 MB binary đọc nhanh hơn đĩa 1,7x, go run cộng go build cộng go test bench Go 1.23 arm64 10 core, ba dạng nhúng chạy thật string Xin chào từ tệp nhúng byte CSV 23 byte FS ReadFile 23 byte tệp trong assetFS blob bin 102400 byte data csv 23 byte loichao txt 29 byte, embed không nén cùng mã đổi kích thước tệp nhúng 1 byte binary 2155517 nhúng 1 MB toàn số 0 binary 3204093 phần tăng cộng 1048576 nhúng 1 MB ngẫu nhiên binary 3204093 phần tăng cộng 1048576, nhúng 1 MB làm binary tăng đúng 1 MB 1048576 byte dù dữ liệu toàn 0 nén được hay ngẫu nhiên embed lưu thô không nén, đọc embed FS trong RAM vs os ReadFile đĩa tệp 64 KB embed FS ReadFile trong RAM 4636 ns 65552 byte 2 alloc os ReadFile mở đọc đóng đĩa 7990 ns 74048 byte 5 alloc, embed nằm sẵn trong RAM không syscall mở đóng tệp ít cấp phát hơn 2 vs 5 nhanh hơn 1,7x đổi lại tốn RAM thường trú, cốt lõi 3 dạng string byte một tệp và embed FS thư mục không nén 1 MB tệp cộng 1 MB binary đọc nhanh 1,7x đĩa ít syscall alloc nhưng thường trú RAM

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ề

  1. //go:embed nhúng tệp tĩnh vào binary dưới ba dạng: string và []byte cho một tệp, embed.FS cho cả thư mục — embed.FS thỏa fs.FS nên cắm thẳng vào http.FileServer, template.ParseFS, fs.WalkDir mà không cần đĩa.
  2. 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.
  3. Đọ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.