Closure là công cụ hằng ngày trong Go: callback, defer, handler HTTP, hàm trả về hàm. Nhưng phía sau cái cú pháp gọn gàng đó là một cấu trúc dữ liệu cụ thể, và nó có thể cấp phát heap — hoặc không, tùy một quyết định của compiler. Bài này mổ xẻ closure được biểu diễn thật thế nào trong bộ nhớ, khi nào nó miễn phí, khi nào tốn 2 cấp phát, và vì sao gọi qua con trỏ hàm đắt gấp đôi gọi thường.
Một func value là gì trong bộ nhớ
Điểm cốt lõi: một func value (giá trị hàm) không phải chỉ là một địa chỉ mã. Nó là con trỏ tới một struct mà word đầu tiên là con trỏ mã, theo sau là các biến mà closure "bắt" (capture):
func value -> [ con trỏ mã | biến bắt 1 | biến bắt 2 | ... ]
^ word đầu ^ môi trường (environment)
Một con trỏ hàm thuần (không bắt biến gì) chỉ có phần con trỏ mã. Một closure bắt biến mang thêm phần môi trường — và chính phần môi trường này là thứ đôi khi phải lên heap.

Hình 1: Ba kiểu closure và cơ chế gọi. Bắt biến rồi trả ra khiến biến moved to heap; dùng tại chỗ thì stack. Gọi = nạp con trỏ func value vào thanh ghi ngữ cảnh rồi nhảy gián tiếp.
Escape analysis: không phải closure nào cũng cấp phát
Ngộ nhận phổ biến: "closure luôn cấp phát heap". Sai. Compiler chạy escape analysis để quyết định. Với ba hàm trên, go build -gcflags=-m cho:
moved to heap: dem // biến bắt phải lên heap
func literal escapes to heap // closure trả ra → heap
func literal does not escape // closure dùng tại chỗ → STACK, 0 cấp phát
Quy tắc: nếu closure (và biến nó bắt) không thoát khỏi hàm — dùng tại chỗ rồi bỏ — compiler đặt cả closure lẫn biến bắt trên stack, không tốn heap. Chỉ khi closure sống lâu hơn hàm tạo ra nó (được trả ra, lưu vào field, đưa vào goroutine) thì môi trường mới phải lên heap.
Đo thật: 0 cấp phát hay 2 cấp phát
Benchmark ba cách tạo closure:

Hình 2: Closure dùng tại chỗ 0 cấp phát dù bắt biến (1,19 ns). Bắt biến rồi trả ra: 24 B, 2 cấp phát, 20,05 ns. Gọi qua con trỏ hàm 1,44 ns ~2x gọi trực tiếp 0,72 ns.
- Không bắt, dùng tại chỗ: 1,190 ns, 0 cấp phát.
- Bắt biến, dùng tại chỗ: 1,199 ns, 0 cấp phát — dù bắt biến, compiler chứng minh được nó không escape nên stack-alloc.
- Bắt biến rồi trả ra: 20,05 ns, 24 B, 2 cấp phát — một cấp phát cho biến bắt lên heap, một cho môi trường closure. Chậm hơn ~17 lần.
Bài học: chi phí không nằm ở "có closure hay không" mà ở "closure có escape không". Closure cục bộ gần như miễn phí; closure escape trả giá heap.
Chi phí gọi gián tiếp
Gọi qua một func value (con trỏ hàm) đắt hơn gọi hàm trực tiếp. Đo thật: gọi trực tiếp 0,717 ns, gọi qua con trỏ hàm 1,444 ns — ~2x. Assembly giải thích:
MOVD R0, R26 // nạp con trỏ func value vào R26 (thanh ghi ngữ cảnh)
CALL (R1) // gọi GIÁN TIẾP qua con trỏ mã
Thân closure đọc các biến bắt qua thanh ghi ngữ cảnh R26. Lời gọi phải nạp con trỏ mã rồi nhảy gián tiếp — CPU khó dự đoán đích nhảy (branch prediction kém hơn gọi trực tiếp có đích cố định). Dù vậy, xét tuyệt đối vẫn cực rẻ (~1,4 ns) và 0 cấp phát: bản thân lời gọi không cấp phát gì.
Ứng dụng thực tế
Callback trong vòng nóng: cẩn thận escape. Nếu một hàm nhận func() và bạn gọi nó hàng triệu lần với closure bắt biến tạo mới mỗi vòng, hãy đảm bảo closure không escape (dùng tại chỗ). Nếu nó escape (ví dụ được lưu vào slice), mỗi closure là 2 cấp phát — đó là nguồn rác âm thầm. Kiểm bằng -gcflags=-m.
defer func(){...}() bắt biến. Một defer với closure bắt biến cục bộ thường không escape (chạy trong cùng hàm), nên rẻ. Nhưng closure bắt biến rồi đưa vào go func(){...}() (goroutine) thì luôn escape — biến phải sống qua ranh giới goroutine.
Hàm bậc cao và bảng dispatch. Một map[string]func(...) để điều phối là mẫu tốt và gọi gián tiếp ~1,4 ns không đáng lo. Chỉ khi bạn ở đường siêu nóng (hàng trăm triệu lời gọi/giây) thì chênh lệch gọi gián tiếp vs trực tiếp mới đáng cân nhắc.
Đánh đổi cần cân nhắc
Closure gọn và biểu cảm — đừng né vì sợ cấp phát. Phần lớn closure trong code thực không ở đường nóng và không escape, nên miễn phí. Tối ưu chỉ khi profiler (-benchmem, pprof alloc) chỉ ra closure escape là nguồn cấp phát thật.
Khi closure escape thành nút thắt, có cách tránh. Nếu một closure bắt biến bị gọi/tạo quá nhiều và escape, cân nhắc: truyền tham số thay vì bắt biến, dùng một struct với phương thức thay closure, hoặc tái dùng closure thay tạo mới mỗi vòng. Nhưng chỉ làm khi đã đo.
Gọi gián tiếp vs trực tiếp hiếm khi là vấn đề. ~2x nghe nhiều nhưng chênh lệch tuyệt đối là ~0,7 ns. Chỉ ở vòng lặp cực nóng mới đáng chuyển từ con trỏ hàm sang gọi trực tiếp (hoặc để compiler inline). Đừng hi sinh tính linh hoạt của hàm bậc cao cho vi tối ưu này ở code thường.
Ba ý mang về
- Một func value là con trỏ tới {con trỏ mã, các biến bắt}: con trỏ hàm thuần chỉ có phần mã; closure bắt biến mang thêm môi trường — chính phần môi trường này cần heap khi escape.
- Không phải closure nào cũng cấp phát: đo thật, closure dùng tại chỗ 0 cấp phát dù bắt biến (compiler stack-alloc); chỉ closure bắt biến rồi trả ra/vào goroutine mới tốn 24 B và 2 cấp phát — kiểm bằng
-gcflags=-m. - Gọi qua con trỏ hàm ~2x gọi trực tiếp (1,44 so 0,72 ns): một lần nạp con trỏ mã + nhảy gián tiếp CPU khó dự đoán — nhưng tuyệt đối vẫn cực rẻ và 0 cấp phát, hiếm khi đáng tối ưu ngoài đường siêu nóng.
Phần sau ta xem chính cái quyết định compiler vừa nhắc — vì sao có hàm được "dán thẳng" vào chỗ gọi: Phần sau mổ xẻ inlining — cost model của Go, cái gì chặn inline, và đo tác động thật lên hiệu năng.