Bài interface internals cho thấy interface là cặp {type/itab, data} với data là một con trỏ. Điều này ẩn một chi phí bất ngờ: khi bạn gán một giá trị (không phải con trỏ) vào interface, Go phải đặt giá trị đó ở đâu đó để data trỏ vào — và nếu giá trị không "vừa" một con trỏ, nó bị đóng hộp (box) lên heap, tốn một cấp phát. Bài này đo chính xác cái nào cấp phát, cái nào không, và cách tránh chi phí ẩn này.
Vì sao boxing tốn cấp phát
Word data của interface chỉ là một con trỏ 8 byte. Khi bạn gán một giá trị vào interface, có ba khả năng:
- Giá trị đã là con trỏ (
*T): con trỏ 8 byte đặt thẳng vàodataword — không cấp phát. - Giá trị nhỏ được cache: Go giữ sẵn bảng cho int 0-255 (
staticuint64s), nên box chúng chỉ là trỏ vào bảng — không cấp phát. - Giá trị khác (int lớn, float, struct, mảng): không "vừa" một con trỏ và không được cache, nên Go phải cấp một ô heap chứa giá trị rồi trỏ
datavào — một cấp phát mỗi lần.

Hình 1: Gán vào interface. Con trỏ và int nhỏ 0-255 không cấp phát; int lớn và struct phải box lên heap (1 cấp phát mỗi lần).
Đo thật: cái nào cấp phát
Benchmark bốn loại giá trị gán vào any:

Hình 2: int nhỏ 42 (cache): 0,58 ns, 0 cấp phát. int lớn: 7,06 ns, 8 B, 1 cấp phát. struct: 9,36 ns, 16 B, 1 cấp phát. Con trỏ: 0,62 ns, 0 cấp phát.
Kết quả rõ ràng:
- int nhỏ (42): 0,58 ns, 0 cấp phát — được cache trong
staticuint64s. - int lớn (>255): 7,06 ns, 8 B, 1 cấp phát — phải cấp ô heap 8 byte.
- struct (16 byte): 9,36 ns, 16 B, 1 cấp phát — phải cấp ô heap 16 byte.
- con trỏ: 0,62 ns, 0 cấp phát — con trỏ đặt thẳng vào data word.
Khác biệt lớn: gán int lớn hay struct vào interface mỗi lần tốn một cấp phát heap (~7-9 ns + áp lực GC), trong khi con trỏ và int nhỏ miễn phí.
Ứng dụng thực tế
fmt.Println(x) và bạn bè box mọi tham số. Vì chúng nhận ...any, mỗi tham số giá trị (không phải con trỏ, không phải int nhỏ) bị box — một cấp phát mỗi tham số. Trong vòng lặp nóng, fmt.Sprintf("%d", bigInt) là nguồn cấp phát ẩn. Đây là lý do log ở đường nóng nên dùng thư viện zero-alloc hoặc tránh format khi không cần.
Container generic cũ dùng interface{} box mọi phần tử. Trước generics (Go 1.18), một []interface{} hay container/list box mỗi giá trị đưa vào. Generics giải quyết triệt để: []T giữ giá trị nguyên bản không box, không cấp phát. Đây là một trong những lợi ích hiệu năng lớn nhất của generics.
Truyền con trỏ thay giá trị lớn để tránh box. Nếu bạn phải đưa một struct lớn vào interface (ví dụ vào một API any), truyền *T thay T tránh copy struct vào heap — chỉ trỏ data word vào struct sẵn có. Nhưng cẩn thận vòng đời (con trỏ giữ struct sống).
Đánh đổi cần cân nhắc
Boxing là cái giá của tính linh hoạt interface — thường chấp nhận được. 7-9 ns và một cấp phát mỗi lần box nghe nhiều, nhưng với code không ở đường siêu nóng thì không đáng lo. Chỉ tối ưu box khi profiler cho thấy nó là nút thắt (allocs/op cao ở hàm dùng interface nhiều).
Generics thay interface cho đa hình không-box, nhưng không thay mọi chỗ. Generics giữ kiểu tĩnh nên không box, nhanh hơn. Nhưng interface vẫn cần khi bạn thật sự cần đa hình lúc chạy (kiểu không biết lúc biên dịch, như xử lý giá trị từ JSON động). Chọn generics khi kiểu biết lúc biên dịch, interface khi cần linh hoạt runtime.
Cache int chỉ 0-255, đừng dựa vào nó. Việc int nhỏ không box là chi tiết cài đặt (staticuint64s), chỉ áp cho 0-255 và có thể đổi. Đừng thiết kế code dựa vào "int nhỏ miễn phí" — nếu cần tránh box int, dùng generics hoặc kiểu cụ thể.
Ba ý mang về
- Gán giá trị vào interface có thể cấp phát heap để box: đo thật, int lớn (>255) tốn 8 byte + 1 cấp phát và struct tốn 16 byte + 1 cấp phát mỗi lần — vì giá trị không vừa data word 8 byte của interface.
- Con trỏ và int nhỏ 0-255 không box: đo thật 0 cấp phát — con trỏ đặt thẳng vào data word, int nhỏ được Go cache sẵn (staticuint64s).
- Boxing là chi phí ẩn của
fmt/any/container cũ: mỗi tham số giá trị bị box — tránh bằng generics (Go 1.18+, giữ kiểu tĩnh không box) hoặc truyền con trỏ thay giá trị lớn, nhưng chỉ tối ưu ở đường nóng profiler chỉ ra.
Bài tập làm thử
Bài 1 (đọc hiểu). Vì sao gán một con trỏ vào interface không tốn cấp phát, nhưng gán một struct 16 byte thì tốn?
Đáp án
Word data của interface chỉ là một con trỏ 8 byte. Một giá trị con trỏ (*T) đã có kích thước 8 byte, đặt thẳng vào data mà không cần cấp phát gì thêm. Một struct 16 byte thì không "vừa" một con trỏ 8 byte, nên Go phải cấp một ô heap chứa giá trị struct rồi trỏ data vào đó — tốn một cấp phát mỗi lần gán.
Bài 2 (đọc hiểu — đoán output/số liệu). Cho đoạn benchmark ý tưởng sau, hãy cho biết phát biểu nào đúng: gán sink = 42 và gán sink = 1000000 (cùng là kiểu int) có chi phí cấp phát như nhau không?
var sink any
sink = 42 // (1)
sink = 1000000 // (2)
Đáp án
Không. (1) sink = 42 là int nhỏ trong khoảng 0-255, được Go cache sẵn trong bảng staticuint64s, nên 0 cấp phát. (2) sink = 1000000 là int lớn hơn 255, không được cache, Go phải cấp một ô heap 8 byte để box nó — 1 cấp phát. Cùng kiểu int nhưng giá trị khác nhau dẫn tới hành vi cấp phát khác nhau.
Bài 3 (vận dụng thực tế). Một hàm chạy trong vòng lặp nóng gọi fmt.Sprintf("%d", bigInt) hàng triệu lần với bigInt là số lớn (>255). Theo số liệu đo trong bài (int lớn: 7,06 ns, 8 B, 1 cấp phát mỗi lần), vì sao đây có thể là nguồn cấp phát ẩn đáng chú ý, và nên làm gì để tránh?
Đáp án
fmt.Sprintf nhận tham số kiểu ...any, nên mỗi tham số giá trị (không phải con trỏ, không phải int nhỏ 0-255) bị box vào interface — một cấp phát heap mỗi lần gọi. Trong vòng lặp hàng triệu lần, đây là nguồn cấp phát và áp lực GC liên tục dù trông như chỉ là "format một chuỗi". Cách tránh: dùng thư viện zero-alloc (ví dụ strconv.AppendInt ghi thẳng vào buffer) hoặc tránh format khi không thật sự cần ở đường nóng.
Bài 4 (sửa lỗi/thiết kế). Một API nhận tham số kiểu any và thường được gọi với struct lớn (không phải con trỏ). Đoạn code dưới đây gây box mỗi lần gọi vì truyền giá trị. Sửa lại để tránh cấp phát khi đưa vào interface:
type Diem struct{ x, y, z, w int64 } // 32 byte
func Ghi(v any) { /* ... */ }
func goi(d Diem) {
Ghi(d) // box cả struct 32 byte lên heap
}
Đáp án
Truyền con trỏ thay vì giá trị: Ghi(&d). Con trỏ chỉ 8 byte, đặt thẳng vào data word của interface, không cần box struct lên heap — tránh cấp phát mỗi lần gọi. Lưu ý: khi làm vậy phải cẩn thận vòng đời, vì con trỏ giữ cho struct sống lâu hơn (biến d có thể escape lên heap thay vì nằm trên stack của goi).
Bài 5 (bẫy/đánh đổi). Một lập trình viên viết: "int nhỏ 0-255 không box nên tôi sẽ cố tình giới hạn các bộ đếm trong chương trình ở phạm vi 0-255 để tránh cấp phát khi đưa vào any." Nhận định/chiến lược này có đáng tin cậy không?
Đáp án
Không nên dựa vào đó. Việc int nhỏ 0-255 không box là một chi tiết cài đặt (bảng staticuint64s), không phải một hợp đồng (contract) của ngôn ngữ, và Go có thể thay đổi cách cache này trong tương lai. Nếu thật sự cần tránh chi phí box, giải pháp đúng là dùng generics (Go 1.18+, giữ kiểu tĩnh, không box) hoặc dùng kiểu cụ thể thay vì any, chứ không phải thiết kế logic nghiệp vụ xoay quanh một chi tiết cài đặt của runtime.
Phần sau ta xét thao tác ngược lại — lấy giá trị ra khỏi interface: Phần sau mổ xẻ type assertion và type switch ở tầng thấp — cách Go kiểm kiểu qua itab, chi phí thật, và vì sao type switch nhiều nhánh không tuyến tính như tưởng.