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ào data word — 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ỏ data vào — một cấp phát mỗi lần.

Ảnh chụp đoạn mã Go nền tối minh hoạ chi phí boxing gán giá trị vào interface có thể cấp phát heap, interface có word data trỏ tới giá trị nếu giá trị không vừa một con trỏ Go phải đóng hộp box nó lên heap một cấp phát bất ngờ mỗi lần gán, var sink any sink bằng 42 int nhỏ 0-255 Go cache sẵn 0 cấp phát sink bằng 1000000 int lớn phải cấp phát để box sink bằng Diem i i struct phải cấp phát để box sink bằng địa chỉ Diem 1 2 con trỏ vừa khít data word 0 cấp phát, khi nào gán vào interface cấp phát box lên heap có cấp phát int lớn hơn 255 float struct mảng giá trị cần chỗ trên heap không cấp phát con trỏ vừa data word int nhỏ 0-255 Go cache staticuint64s, vì sao struct int lớn phải box data word của interface chỉ giữ một con trỏ 8 byte struct 16 byte hay int lớn không vừa nên Go cấp một ô heap chứa giá trị rồi trỏ data word vào đó con trỏ đã là 8 byte đặt thẳng vào data word không cần cấp phát chi phí ẩn của mọi API nhận any fmt Println log container generic cũ

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:

Ảnh chụp bảng kết quả đo thật nền tối boxing vào interface Go 1.23 arm64, gán giá trị vào any cái nào cấp phát chú ý allocs op, BenchmarkBoxIntNho 0.58 ns/op 0 B/op 0 allocs/op int 42 cache, BenchmarkBoxIntLon 7.06 ns/op 8 B/op 1 allocs/op int lớn hơn 255, BenchmarkBoxStruct 9.36 ns/op 16 B/op 1 allocs/op struct 16B, BenchmarkBoxConTro 0.62 ns/op 0 B/op 0 allocs/op con trỏ, cốt lõi gán giá trị vào interface any có thể cấp phát heap để đóng hộp int nhỏ 0-255 Go cache sẵn và con trỏ vừa khít data word đều 0 cấp phát nhưng int lớn 8 byte 1 cấp phát và struct 16 byte 1 cấp phát phải box lên heap đây là chi phí ẩn của mọi lần gán giá trị vào interface đặc biệt trong vòng lặp nóng dùng fmt any hay container generic cũ cách tránh dùng generics Go 1.18 giữ kiểu tĩnh không box

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ề

  1. 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.
  2. 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).
  3. 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.

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.