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.

Bài tập làm thử

Bài 1 (đọc hiểu). Một "func value" trong Go được biểu diễn trong bộ nhớ như thế nào? Con trỏ hàm thuần (không bắt biến) khác gì so với một closure có bắt biến?

Đáp án

Một func value là một 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): [con trỏ mã | biến bắt 1 | biến bắt 2 | ...]. Con trỏ hàm thuần (không bắt biến gì) chỉ có phần con trỏ mã, không có phần môi trường. Một closure bắt biến mang thêm phần môi trường (các biến bắt) đi kèm — và chính phần môi trường này là thứ đôi khi phải cấp phát lên heap.

Bài 2 (đọc hiểu — đoán output của -gcflags=-m). Cho hai hàm sau, cho biết go build -gcflags=-m sẽ báo gì về biến dem, và vì sao chúng khác nhau?

func khongBat() func() int {
    return func() int { return 42 }
}

func demTang() func() int {
    dem := 0
    return func() int {
        dem++
        return dem
    }
}
Đáp án

khongBat: closure trả ra không bắt biến nào (chỉ trả về hằng số 42), nên compiler chỉ báo dạng closure đơn giản, không có biến nào bị đẩy lên heap (thực chất closure ở đây chỉ là con trỏ mã, gần như không có môi trường). demTang: compiler sẽ báo moved to heap: dem — vì dem bị bắt bởi closure và closure này được trả ra khỏi demTang, nghĩa là dem phải sống lâu hơn lời gọi hàm tạo ra nó, nên escape analysis buộc nó lên heap. Cả hai trường hợp closure literal đều được báo escapes to heap vì chúng được trả ra ngoài hàm.

Bài 3 (đọc hiểu số liệu). Giải thích vì sao "Bắt biến, dùng tại chỗ" đo được 0 cấp phát (1,199 ns) trong khi "Bắt biến rồi trả ra" tốn tới 24 B và 2 cấp phát (20,05 ns), dù cả hai đều là closure có bắt biến.

Đáp án

Chi phí không nằm ở việc "có bắt biến hay không" mà ở việc "closure có escape (thoát) khỏi hàm tạo ra nó hay không". Nếu closure và biến nó bắt được dùng tại chỗ rồi bỏ (không thoát khỏi hàm), compiler chứng minh được điều đó qua escape analysis và đặt cả closure lẫn biến bắt trên stack — không tốn heap. Nếu closure sống lâu hơn hàm tạo ra nó (được trả ra, như trong "Bắt biến rồi trả ra"), biến bắt phải chuyển lên heap (1 cấp phát) và bản thân môi trường closure cũng phải cấp phát heap (1 cấp phát nữa) — tổng cộng 2 cấp phát, 24 B.

Bài 4 (vận dụng thực tế). Một hàm được gọi hàng triệu lần mỗi giây, bên trong tạo một closure bắt biến cục bộ rồi lưu closure đó vào một slice dùng ở chỗ khác trong chương trình. Theo số liệu đo được trong bài, đây có phải nguồn cấp phát/rác âm thầm không? Nên kiểm tra bằng công cụ nào?

Đáp án

Có. Vì closure được lưu vào slice (dùng ở nơi khác), nó chắc chắn escape khỏi hàm tạo ra nó — mỗi lần tạo closure như vậy tốn khoảng 2 cấp phát (24 B theo benchmark), và với hàng triệu lần gọi mỗi giây, đây là nguồn cấp phát heap và áp lực GC liên tục dù trông như một đoạn code bình thường. Nên kiểm tra bằng go build -gcflags=-m để xem dòng "moved to heap"/"escapes to heap", hoặc dùng -benchmem / pprof alloc profiling để xác nhận đây thật sự là nút thắt trước khi tối ưu.

Bài 5 (bẫy/đánh đổi). Đo thật cho thấy gọi qua con trỏ hàm (1,444 ns) chậm gấp ~2 lần gọi hàm trực tiếp (0,717 ns). Vì sao bài viết vẫn khuyên "đừ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"?

Đáp án

Vì chênh lệch ~2 lần nghe có vẻ lớn nhưng chênh lệch tuyệt đối chỉ khoảng 0,7 ns — cực nhỏ so với hầu hết công việc thực tế trong một lời gọi hàm (I/O, xử lý dữ liệu, v.v.). Nguyên nhân chậm hơn là phải nạp con trỏ mã vào thanh ghi ngữ cảnh rồi nhảy gián tiếp, khiến CPU khó dự đoán đích nhảy hơn so với gọi trực tiếp có đích cố định — nhưng bản thân lời gọi vẫn 0 cấp phát. Chỉ ở vòng lặp cực nóng (hàng trăm triệu lời gọi/giây) chênh lệch này mới đáng cân nhắc; đánh đổi lấy mất đi sự linh hoạt của hàm bậc cao (callback, bảng dispatch map[string]func(...)) thường không đáng cho phần lớn code.

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.