Bài trước thấy reflect mạnh: xử lý kiểu chưa biết lúc chạy. Nhưng "mạnh" đi kèm "chậm" — reflection nổi tiếng tốn kém, và người ta thường tránh nó ở đường nóng. Câu hỏi là: chậm bao nhiêu? Câu trả lời gây bất ngờ vì nó rất khác nhau tùy thao tác: đọc một field chỉ chậm gấp đôi (chấp nhận được), nhưng gọi một hàm qua reflect chậm gần trăm lần. Bài này đo cả hai bằng benchmark thật, và chỉ cách giảm chi phí.

Hai loại thao tác reflect

reflect có hai thao tác chính, chi phí rất khác nhau:

// ĐỌC FIELD — trực tiếp vs reflect
sink += t.A                       // trực tiếp: một phép đọc bộ nhớ
v := reflect.ValueOf(t)           // reflect: tạo Value rồi đọc
sink += int(v.Field(0).Int())

// GỌI HÀM — trực tiếp vs reflect.Call
sink += cong(n, 1)                // trực tiếp
args := []reflect.Value{reflect.ValueOf(n), reflect.ValueOf(1)}
out := fnVal.Call(args)           // reflect: đóng hộp tham số vào slice

Điểm mấu chốt: đọc field chỉ là "offset bộ nhớ + kiểm kiểu", còn reflect.Call phải đóng hộp tất cả tham số vào một []reflect.Value (cấp phát), gọi gián tiếp, rồi bóc kết quả ra. Sự khác biệt cơ chế này dẫn tới chênh lệch hiệu năng khổng lồ.

Ảnh chụp đoạn mã Go nền tối minh hoạ chi phí reflection đọc field rẻ gọi hàm rất đắt, reflect linh hoạt nhưng có giá đo cả hai loại thao tác chính đọc field và gọi hàm qua reflect, một đọc field trực tiếp vs reflect trực tiếp một phép đọc bộ nhớ sink cộng bằng t.A reflect ngây thơ tạo Value mỗi lần trong vòng lặp v bằng reflect.ValueOf t sink cộng bằng int v.Field 0 Int reflect cache tạo Value một lần ngoài vòng v bằng reflect.ValueOf t ngoài vòng lặp for sink cộng bằng int v.Field 0 Int, hai gọi hàm trực tiếp vs reflect.Call trực tiếp sink cộng bằng cong n 1 reflect.Call phải đóng hộp tham số vào reflect.Value args bằng reflect.Value reflect.ValueOf n reflect.ValueOf 1 out bằng fnVal.Call args cấp phát slice args cộng box kết quả sink cộng bằng int out 0 Int reflect.Call phải gói tham số thành slice interface cấp phát rồi bóc kết quả tốn cấp phát và nhiều bước khác hẳn đọc field chỉ offset cộng kiểm kiểu, ba vì sao Call đắt hơn field read nhiều đọc field offset bộ nhớ cộng kiểm kiểu nhanh 0 cấp phát reflect.Call đóng hộp N tham số vào Value cấp phát cộng gọi gián tiếp cộng bóc kết quả từ Value chậm gấp nhiều lần cộng cấp phát heap, bốn giảm chi phí khi buộc dùng reflect cache Type Value StructField ngoài vòng lặp đừng tạo lại mỗi lần tránh reflect.Call ở đường nóng dùng type switch assertion codegen easyjson thay reflect sinh mã tĩnh 0 reflect

Hình 1: Hai loại thao tác reflect — đọc field (trực tiếp/reflect/cache) và gọi hàm (trực tiếp/reflect.Call), cùng cách giảm chi phí.

Đo thật: đọc field chậm ~2x, 0 cấp phát

Ảnh chụp bảng kết quả đo thật nền tối reflect field khoảng 2x reflect Call khoảng 85x go test bench benchmem Go 1.23 arm64 10 core, đọc field chậm hơn vừa phải 0 cấp phát cách trực tiếp t.A 0,998 ns 0 alloc 1x reflect cache Value 1,677 ns 0 alloc khoảng 1,7x reflect ngây thơ ValueOf mỗi lần 2,237 ns 0 alloc khoảng 2,2x đọc field qua reflect chỉ chậm khoảng 2x và không cấp phát chấp nhận được cho nhiều trường hợp cache Value ngoài vòng lặp giảm từ 2,24 xuống 1,68 ns, gọi hàm rất đắt cộng cấp phát cách gọi trực tiếp cong 1,318 ns 0 alloc 1x reflect.Call 112,5 ns 39 B trên 2 khoảng 85x reflect.Call chậm hơn khoảng 85 lần và cấp phát 2 lần 39 B phải đóng hộp tham số vào reflect.Value và bóc kết quả đây là lý do hệ thống dựa reflect.Call RPC plugin động chậm ở đường nóng, vì sao khác biệt lớn vậy field read offset cộng kiểm kiểu rẻ 2x 0 alloc Call đóng hộp N tham số vào slice cấp phát cộng gọi gián tiếp cộng bóc kết quả đắt 85x 2 alloc, cốt lõi reflect field khoảng 2x 0 cấp phát thường chấp nhận được json reflect Call khoảng 85x cộng 2 cấp phát tránh ở đường nóng cache tạo Value Type ngoài vòng giảm 2,24 xuống 1,68 ns giảm chi phí codegen easyjson thay reflect cho hot serialize quy tắc reflect ổn cho ít lần lúc khởi tạo đắt ở đường nóng

Hình 2: Đọc field — trực tiếp 0,998 ns, reflect cache 1,677 ns (~1,7x), reflect ngây thơ 2,237 ns (~2,2x), tất cả 0 cấp phát. Gọi hàm — trực tiếp 1,318 ns vs reflect.Call 112,5 ns (~85x, 2 cấp phát).

  • Trực tiếp (t.A): 0,998 ns, 0 cấp phát.
  • Reflect cache (tạo Value một lần ngoài vòng): 1,677 ns (~1,7x), 0 cấp phát.
  • Reflect ngây thơ (ValueOf mỗi lần): 2,237 ns (~2,2x), 0 cấp phát.

Đọc field qua reflect chỉ chậm ~2x và không cấp phát — chấp nhận được cho nhiều trường hợp (đây là lý do json.Marshal dùng được trong production). Và cache Value ngoài vòng lặp giảm từ 2,24 xuống 1,68 ns — mẹo tối ưu đầu tiên.

Đo thật: gọi hàm chậm ~85x, có cấp phát

  • Gọi trực tiếp (cong): 1,318 ns, 0 cấp phát.
  • reflect.Call: 112,5 ns (~85x), 39 B / 2 cấp phát.

reflect.Call chậm hơn ~85 lần và cấp phát 2 lần — vì phải đóng hộp tham số vào []reflect.Value (cấp phát slice + box mỗi giá trị) và bóc kết quả. Đây là lý do các hệ thống dựa reflect.Call (RPC động, plugin, dependency injection gọi hàm động) chậm ở đường nóng, và vì sao chúng thường được thay bằng codegen.

Ứng dụng thực tế

Đọc field bằng reflect chấp nhận được cho serialize. ~2x và 0 cấp phát nghĩa là json.Marshal/Unmarshal dùng được trong hầu hết ứng dụng — chi phí reflect field bị lấn át bởi I/O mạng/đĩa. Chỉ ở serialize cực nóng (hàng triệu lần/giây) mới cần codegen (easyjson, ffjson) để bỏ reflect.

Tránh reflect.Call ở đường nóng bằng mọi giá. ~85x + cấp phát khiến reflect.Call không phù hợp cho mã gọi thường xuyên. Nếu cần gọi động, cân nhắc: type switch trên các kiểu đã biết, sinh code, hoặc thiết kế lại để không cần gọi động. reflect.Call chỉ ổn cho việc hiếm (khởi tạo, setup, admin).

Cache mọi thứ reflect ngoài đường nóng. reflect.TypeOf, StructField, index field, method — tính một lần lúc khởi tạo và lưu, đừng tính lại mỗi lần. Nhiều thư viện reflect nhanh (như một số ORM) làm chính điều này: phân tích struct một lần thành "kế hoạch", rồi thực thi kế hoạch nhanh.

Đánh đổi cần cân nhắc

reflect đánh đổi hiệu năng lấy tính tổng quát — biết khi nào đáng. Với code chạy ít lần (khởi tạo, config, một request) reflect hoàn toàn ổn — vài trăm ns không đáng kể. Chỉ ở đường siêu nóng (vòng lặp hàng triệu lần) mới đáng lo. Đừng "sợ reflect" một cách máy móc; đo xem nó có thật sự ở đường nóng không.

Codegen là giải pháp cho reflect nóng, nhưng thêm phức tạp build. Công cụ như easyjson sinh mã Marshal tĩnh (không reflect) từ struct, nhanh gấp nhiều lần. Nhưng chúng thêm bước codegen vào build, mã sinh ra phải cập nhật khi struct đổi, và tăng kích thước binary. Chỉ đáng cho serialize thật sự là nút thắt — đo trước.

Generics thay được nhiều reflect (Go 1.18+), an toàn và nhanh hơn. Nếu bạn dùng reflect chỉ để viết "code chung cho nhiều kiểu" mà các kiểu biết lúc biên dịch, generics làm điều đó với an toàn kiểu và không có chi phí reflect (compiler sinh mã riêng). Reflect chỉ thật cần khi kiểu chỉ biết lúc chạy (JSON động, struct tag). Chọn generics khi được.

Ba ý mang về

  1. Chi phí reflect rất khác nhau tùy thao tác: đo thật, đọc field qua reflect chỉ ~2x trực tiếp và 0 cấp phát (chấp nhận được, là lý do json.Marshal dùng được), nhưng reflect.Call chậm ~85x và cấp phát 2 lần vì phải đóng hộp tham số vào []reflect.Value.
  2. Cache Value/Type ngoài đường nóng để giảm chi phí: đo thật, tạo Value một lần ngoài vòng lặp giảm từ 2,24 xuống 1,68 ns — phân tích struct một lần thành "kế hoạch" rồi thực thi là mẫu của các thư viện reflect nhanh.
  3. reflect ổn cho code ít lần/khởi tạo, đắt ở đường nóng: tránh reflect.Call ở vòng nóng (dùng type switch/codegen), dùng codegen (easyjson) cho serialize cực nóng, và ưu tiên generics khi kiểu biết lúc biên dịch (an toàn + không chi phí reflect) — nhưng đo trước, đừng "sợ reflect" máy móc.

Phần sau ta dùng chính reflect để xây một thứ thực tế: Phần sau hướng dẫn tự viết một decoder dùng struct tag và reflection — biến map[string]any thành struct theo tag, hiểu cách các thư viện như mapstructure hoạt động từ bên trong.