Trong Go bạn không viết malloc hay new bắt buộc, không khai báo biến "trên stack" hay "trên heap". Bạn chỉ viết x := Diem{} và trình biên dịch tự quyết định biến đó nằm ở đâu. Quyết định này — gọi là escape analysis — có ảnh hưởng trực tiếp và lớn tới hiệu năng: stack gần như miễn phí, heap tốn cấp phát và tạo việc cho bộ thu gom rác. Bài này giải thích cơ chế, và quan trọng nhất, dạy bạn đọc chính quyết định đó bằng -gcflags=-m.
Stack và heap: vì sao khác nhau về chi phí
Mỗi lời gọi hàm có một khung stack. Biến cục bộ đặt trên khung này được cấp phát và thu hồi miễn phí: cấp phát chỉ là dời con trỏ stack, thu hồi là tự động khi hàm trả về (khung biến mất). Không cần bộ thu gom rác đụng vào.
Heap thì khác. Biến trên heap phải được cấp phát qua bộ cấp phát bộ nhớ của runtime (tốn thời gian), và về sau phải được bộ thu gom rác (GC) quét và giải phóng (tốn thêm CPU). Nên câu hỏi "biến này ở stack hay heap" là câu hỏi hiệu năng cốt lõi.
Quy tắc quyết định: nếu trình biên dịch chứng minh được biến không "sống lâu hơn" hàm tạo ra nó, nó ở lại stack. Nếu biến có thể còn được tham chiếu sau khi hàm trả về (hoặc trình biên dịch không chứng minh được điều ngược lại), nó thoát (escape) lên heap.

Hình 1: Bốn hàm với bốn số phận khác nhau, và output thật của -gcflags="-m" cho biết trình biên dịch quyết định thế nào.
Đọc output -gcflags="-m"
Đây là kỹ năng thực dụng nhất của bài. Chạy go build -gcflags="-m" (thêm một -m nữa, -m -m, để chi tiết hơn), trình biên dịch in ra từng quyết định escape:
$ go build -gcflags="-m" ea.go
ea.go:13:2: moved to heap: d # taoConTro trả con trỏ → d lên heap
ea.go:30:11: make([]int, 16) does not escape # cỡ hằng số → ở stack
ea.go:35:11: make([]int, n) escapes to heap # cỡ biến → lên heap
Ba loại dòng cần nhớ:
moved to heap: x: biếnx(thường vì bị lấy địa chỉ và địa chỉ đó thoát ra ngoài) buộc phải lên heap.... does not escape: biểu thức này ở lại stack — tốt.... escapes to heap: biểu thức cấp phát trên heap.
Một chi tiết tinh tế và quan trọng: make([]int, 16) (kích thước hằng số) không thoát, nhưng make([]int, n) (kích thước là biến) thoát lên heap — dù n lúc chạy có thể cũng là 16. Vì trình biên dịch chỉ đặt slice trên stack khi chứng minh được kích thước lúc biên dịch; kích thước chỉ biết lúc chạy thì nó không dám dành chỗ stack cố định.
Đo thật: chênh lệch hiệu năng
Sự khác biệt stack/heap không phải lý thuyết. Benchmark bốn hàm trên, chú ý cột B/op (byte cấp phát mỗi lần) và allocs/op:

Hình 2: Stack: 0,23 ns, 0 cấp phát. Heap slice: 27,94 ns, 128 B, 1 cấp phát — chậm ~120 lần. Con trỏ thoát: 9,24 ns, 16 B. Cùng logic, khác nơi cấp phát.
Con số rõ ràng:
sliceTinh(cỡ 16 hằng số, stack): 0,23 ns/op, 0 B/op, 0 allocs/op.sliceDong(16)(cỡ biến, heap): 27,94 ns/op, 128 B/op, 1 allocs/op — chậm ~120 lần, dù cùng tạo 16 số nguyên.tongCucBo(trả giá trị, stack): 0,23 ns/op, 0 cấp phát.taoConTro(trả con trỏ, heap): 9,24 ns/op, 16 B/op, 1 cấp phát — chậm ~40 lần.
Điểm mấu chốt: sliceDong(16) và sliceTinh() tạo cùng số phần tử, cùng số byte logic, nhưng một cái miễn phí còn cái kia tốn 128 byte cấp phát + một lần chạm bộ cấp phát + về sau tạo việc cho GC. Sự khác biệt duy nhất là trình biên dịch có chứng minh được kích thước lúc biên dịch hay không.
Ứng dụng thực tế
-benchmem là la bàn. Khi tối ưu một hàm nóng, luôn chạy benchmark với -benchmem và nhìn allocs/op. Con số đó là số lần thoát heap mỗi thao tác. Giảm nó thường là cách tăng tốc trực tiếp nhất — mỗi cấp phát heap tránh được là hàng chục nanosecond cộng bớt áp lực GC.
Ưu tiên trả giá trị thay vì con trỏ cho struct nhỏ. Với struct nhỏ (vài field), trả giá trị thường giữ nó trên stack (0 cấp phát), còn trả con trỏ buộc nó lên heap. Đây là trực giác ngược với nhiều ngôn ngữ khác, nơi truyền con trỏ luôn "rẻ hơn". Trong Go, con trỏ có thể đắt hơn vì kéo theo escape.
Kích thước hằng số giúp trình biên dịch giữ trên stack. Nếu bạn make một slice/mảng tạm với kích thước biết trước, dùng hằng số (hoặc mảng [16]int) thay vì biến giúp nó ở lại stack. Đây là lý do các thư viện hiệu năng cao hay dùng buffer kích thước cố định.
Đánh đổi cần cân nhắc
Đừng tối ưu escape một cách mù quáng. Phần lớn code không nằm trên đường nóng; ép mọi thứ ở stack làm code khó đọc mà không lợi ích đo được. Chỉ săn escape ở nơi profiler chỉ ra là nóng. 0.23 ns so với 27.94 ns nghe kinh khủng, nhưng nếu hàm chạy 1000 lần mỗi request thì tiết kiệm chỉ ~27 µs — có thể không đáng công.
Escape analysis là bảo thủ. Trình biên dịch chỉ giữ trên stack khi chứng minh chắc chắn an toàn. Nhiều trường hợp nó "đầu hàng" và cho lên heap dù thực tế an toàn — ví dụ khi có interface, closure, hoặc kích thước động. Đừng ngạc nhiên nếu -m báo escape ở chỗ bạn nghĩ là an toàn; đó là nó chọn phía an toàn.
Stack cũng có giới hạn. Cấp phát quá lớn trên stack (mảng khổng lồ) có thể bị đẩy lên heap dù không "thoát", vì runtime không muốn khung stack phình quá cỡ. Escape không phải yếu tố duy nhất quyết định nơi cấp phát — kích thước cũng vậy.
Ba ý mang về
- Escape analysis là bước trình biên dịch quyết định biến ở stack hay heap: ở lại stack nếu chứng minh được biến không sống lâu hơn hàm, ngược lại thoát lên heap — đọc quyết định này bằng
go build -gcflags="-m"(các dòngmoved to heap,does not escape,escapes to heap). - Chênh lệch chi phí rất lớn: đo thật, slice cỡ hằng số ở stack tốn 0,23 ns và 0 cấp phát, còn cỡ biến lên heap tốn 27,94 ns và 128 byte — chậm ~120 lần cho cùng logic, cộng áp lực GC về sau.
- Dùng
-benchmemđể sănallocs/op: giảm số biến thoát heap là cách tối ưu hiệu năng Go trực tiếp nhất — ưu tiên trả giá trị cho struct nhỏ và dùng kích thước hằng số — nhưng chỉ ở đường nóng profiler chỉ ra.
Phần sau ta đào sâu vào loại escape phổ biến và hay gây bất ngờ nhất: Phần sau mổ xẻ vì sao con trỏ thoát ra heap — những mẫu code cụ thể khiến biến bị moved to heap, từ trả con trỏ, lưu vào interface, tới closure bắt biến.