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ồ.

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

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ơ (
ValueOfmỗ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ề
- 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.Callchậm ~85x và cấp phát 2 lần vì phải đóng hộp tham số vào[]reflect.Value. - 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.
- 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.
Bài tập làm thử
Bài 1 (đọc hiểu). Bài đo thật cho ba cách đọc field: trực tiếp (0,998 ns), reflect cache Value ngoài vòng lặp (1,677 ns), reflect ngây thơ tạo Value mỗi lần (2,237 ns) — tất cả 0 cấp phát. Giải thích tại sao "reflect cache" nhanh hơn "reflect ngây thơ" dù cả hai đều dùng reflect để đọc cùng một field.
Đáp án
"Reflect ngây thơ" gọi reflect.ValueOf(t) lại mỗi lần trong vòng lặp, tốn thêm công tạo một Value mới mỗi lượt. "Reflect cache" tạo Value một lần duy nhất bên ngoài vòng lặp rồi tái sử dụng để đọc field nhiều lần bên trong — tránh lặp lại phần chi phí tạo Value, nên nhanh hơn (giảm từ 2,24 xuống 1,68 ns trong bài đo). Đây là mẹo tối ưu đầu tiên khi buộc phải dùng reflect trong một vòng lặp.
Bài 2 (sửa lỗi/vận dụng). Đoạn mã sau đang dùng reflect.Call để cộng hai số trong một vòng lặp nóng chạy hàng triệu lần mỗi giây. Nêu vấn đề và đề xuất cách viết lại tránh chi phí đó.
fnVal := reflect.ValueOf(cong)
for i := 0; i < n; i++ {
args := []reflect.Value{reflect.ValueOf(i), reflect.ValueOf(1)}
out := fnVal.Call(args)
sink += out[0].Interface().(int)
}
Đáp án
Vấn đề: reflect.Call chậm hơn gọi hàm trực tiếp khoảng 85 lần (theo đo thật trong bài) và cấp phát bộ nhớ (phải đóng hộp tham số vào []reflect.Value và bóc kết quả ra), rất tốn kém khi lặp lại hàng triệu lần trong đường nóng. Cách sửa: gọi hàm cong trực tiếp thay vì qua reflect:
for i := 0; i < n; i++ {
sink += cong(i, 1)
}
Nếu bắt buộc phải gọi động (không biết hàm cụ thể lúc biên dịch), nên cân nhắc type switch trên các kiểu đã biết, hoặc sinh mã (codegen) thay vì dùng reflect.Call ở đường nóng — reflect.Call chỉ nên dùng cho việc hiếm gặp như khởi tạo hoặc setup.
Bài 3 (đọc số liệu đo thật). Bài đo được reflect.Call chậm ~85x và cấp phát 39 B / 2 lần, trong khi đọc field qua reflect chỉ chậm ~2x và 0 cấp phát. Giải thích bằng cơ chế tại sao hai thao tác reflect lại có chi phí chênh lệch lớn đến vậy.
Đáp án
Đọc field chỉ là một phép "offset bộ nhớ + kiểm kiểu" — reflect biết vị trí field trong struct và chỉ cần đọc đúng offset đó, không cần cấp phát gì thêm. Gọi hàm qua reflect.Call phức tạp hơn nhiều: phải đóng hộp (box) từng tham số vào một []reflect.Value (cấp phát slice + box từng giá trị), thực hiện lời gọi gián tiếp, rồi bóc kết quả ra khỏi Value trả về. Sự khác biệt cơ chế — đọc một offset có sẵn so với đóng gói/gọi gián tiếp/bóc gói — dẫn tới chênh lệch hiệu năng gần trăm lần.
Bài 4 (vận dụng thực tế). Đội của bạn dùng json.Marshal/Unmarshal (dựa trên reflect) để xử lý payload cho một API nhận vài trăm request/giây. Dựa vào số liệu bài đo (đọc field chỉ ~2x, 0 cấp phát), bạn có nên vội thay bằng codegen (như easyjson) ngay không? Giải thích.
Đáp án
Không nên vội. Chi phí đọc field qua reflect chỉ ~2x so với trực tiếp và 0 cấp phát — với vài trăm request/giây, chi phí này bị lấn át hoàn toàn bởi I/O mạng/đĩa và các phần xử lý khác của request. Theo bài, codegen chỉ đáng cân nhắc khi serialize cực nóng (hàng triệu lần/giây) và nên đo trước để xác nhận reflect thật sự là nút thắt, tránh thêm phức tạp build (codegen, mã sinh phải đồng bộ khi struct đổi, tăng kích thước binary) một cách không cần thiết.
Bài 5 (bẫy/đánh đổi). Một lập trình viên nói: "Tôi luôn tránh reflect ở mọi nơi trong code vì nó chậm." Chỉ ra điểm chưa chính xác trong phát biểu này dựa theo bài học.
Đáp án
Phát biểu này quá máy móc. Bài học chỉ ra chi phí reflect rất khác nhau tùy thao tác: đọc field chỉ chậm ~2x và không cấp phát — hoàn toàn chấp nhận được cho code chạy ít lần (khởi tạo, config, một request), và là lý do json.Marshal dùng reflect vẫn ổn trong production. Chỉ reflect.Call (~85x, có cấp phát) và việc dùng reflect lặp lại trong vòng lặp siêu nóng mới thật sự đáng lo. Nguyên tắc đúng là "đo trước, đừng sợ reflect một cách máy móc" — tránh reflect không cần thiết ở đường nóng, nhưng không cần loại bỏ nó ở mọi nơi.
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.