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.

Ảnh chụp đoạn mã Go nền tối minh hoạ con trỏ hàm và closure biểu diễn thật trong bộ nhớ, một func value bằng con trỏ tới con trỏ mã và các biến bắt bắt biến rồi để nó sống lâu hơn hàm cha thì escape lên heap, một ba kiểu closure escape analysis quyết định heap hay stack func khongBat trả func int return func int return 42 không bắt biến chỉ là con trỏ mã func demTang trả func int dem bằng 0 biến bị bắt return func int dem cộng cộng return dem sống lâu hơn demTang nên dem moved to heap, hai escape analysis thật go build gcflags trừ m 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 không phải mọi closure đều cấp phát nếu không thoát khỏi hàm compiler đặt trên stack miễn phí, ba cơ chế gọi assembly arm64 func value trong thanh ghi ngữ cảnh return f với f là func value MOVD R0 R26 nạp con trỏ closure vào R26 thanh ghi ngữ cảnh CALL R1 gọi gián tiếp qua con trỏ mã word đầu của closure thân closure đọc biến bắt qua R26 lý do gọi qua con trỏ hàm đắt hơn gọi trực tiếp một lần nạp cộng nhảy gián tiếp CPU khó dự đoán đích, bốn bố cục func value con trỏ mã biến bắt môi trường gọi context bằng con trỏ func value jump context

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:

Ảnh chụp bảng kết quả đo thật nền tối chi phí closure và con trỏ hàm Go 1.23 arm64 10 core, tạo closure escape hay không quyết định cấp phát ClosureTaiCho không bắt dùng tại chỗ 1,190 ns/op 0 B/op 0 allocs/op BatTaiCho bắt biến dùng tại chỗ stack 1,199 ns 0 0 BatTraRa bắt biến cộng trả ra heap 20,05 ns 24 B 2 allocs closure dùng tại chỗ 0 cấp phát dù có bắt biến compiler stack-alloc closure bắt biến rồi trả ra 24 B 2 cấp phát một cho biến bắt lên heap một cho môi trường closure chậm hơn khoảng 17 lần, gọi gián tiếp con trỏ hàm vs gọi trực tiếp GoiTrucTiep gọi hàm thường 0,717 ns 0 GoiQuaConTro gọi qua func value 1,444 ns 0 gọi qua con trỏ hàm khoảng 2x gọi trực tiếp 1,44 so 0,72 ns thêm một lần nạp con trỏ mã cộng nhảy gián tiếp mà CPU khó dự đoán đích vẫn rất rẻ tuyệt đối và 0 cấp phát, cốt lõi func value con trỏ tới con trỏ mã biến bắt không bắt tại chỗ 0 cấp phát stack khoảng 1,2 ns bắt biến cộng trả ra 2 cấp phát 24 B heap khoảng 20 ns gọi gián tiếp khoảng 2x gọi trực tiếp nhảy qua con trỏ mã

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ề

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