Có một hiểu lầm phổ biến: "dùng con trỏ trong Go là làm biến lên heap". Sai. Con trỏ tự nó không quyết định gì — cái quyết định là biến có sống lâu hơn hàm tạo ra nó hay không. Bài trước ta học đọc -gcflags=-m; bài này đi sâu vào loại escape hay gây bất ngờ nhất: khi nào con trỏ khiến biến thoát, và quan trọng nhất, hiểu khái niệm leaking param mà nhiều người chưa từng để ý.
Nguyên tắc: sống lâu hơn hàm thì mới thoát
Quy tắc gốc rất đơn giản: một biến cục bộ ở lại stack nếu trình biên dịch chứng minh được nó "chết" (không còn ai tham chiếu) trước khi hàm trả về. Con trỏ chỉ gây rắc rối khi nó mang địa chỉ của biến ra ngoài phạm vi hàm — vì lúc đó khung stack biến mất nhưng con trỏ vẫn còn, nên biến buộc phải chuyển sang heap để tồn tại.
Có bốn mẫu kinh điển khiến điều này xảy ra, và một mẫu không xảy ra dù có con trỏ:

Hình 1: Bốn mẫu thoát (trả con trỏ, lưu toàn cục, interface, leaking param) và một mẫu không thoát (con trỏ chỉ dùng nội bộ). Output -m xác nhận từng dòng.
Bốn thủ phạm, đọc từ output -m
Chạy go build -gcflags="-m" cho ta lý do escape chính xác từng dòng:
1. Trả con trỏ tới biến cục bộ — moved to heap: t. func traConTro() *T { t := T{1,2}; return &t }: con trỏ &t được trả ra, nên t phải sống sau khi hàm kết thúc → lên heap. Đây là mẫu rõ ràng nhất.
2. Lưu con trỏ vào biến toàn cục — &T{...} escapes to heap. global = t: biến toàn cục sống suốt chương trình, nên bất cứ thứ gì nó trỏ tới cũng phải sống lâu → heap.
3. Đóng gói vào interface{} — n escapes to heap. Khi bạn đưa một giá trị vào interface{} mà interface đó thoát (như append vào slice toàn cục), Go phải "đóng hộp" (box) giá trị — thường trên heap, vì interface cần một địa chỉ ổn định để trỏ tới. Đây là lý do các hàm nhận ...interface{} như fmt.Println thường làm tham số escape.
4. leaking param — mẫu tinh vi nhất. func giu(p *T) { global = p } sinh ra dòng leaking param: p. Nó nghĩa là: hàm giu giữ lại con trỏ nó nhận vào (lưu vào global). Hệ quả dây chuyền: mọi người gọi truyền địa chỉ một biến cục bộ vào giu đều khiến biến đó bị đẩy lên heap — dòng moved to heap: t ở truyenBiGiu chính là vì thế. Escape "lan" qua ranh giới hàm theo phân tích liên thủ tục.
Và mẫu KHÔNG thoát: conTroCucBo dùng con trỏ p := &t, sửa p.a, rồi chỉ trả về giá trị int. Con trỏ không bao giờ rời hàm, nên t ở lại stack — không xuất hiện trong output escape. Có con trỏ mà vẫn 0 cấp phát.
Đo thật: chi phí cấp phát
Benchmark ba mẫu, chú ý allocs/op:

Hình 2: Con trỏ cục bộ không thoát: 0,23 ns, 0 cấp phát. Trả con trỏ: 10,17 ns, 16 B, 1 cấp phát. Địa chỉ biến vòng lặp ra toàn cục: 5,95 ns, 8 B, 1 cấp phát. Cùng dùng con trỏ, chỉ khác chỗ nó có thoát hay không.
Con số xác nhận nguyên tắc: conTroCucBo dùng con trỏ nhưng 0 cấp phát, 0,23 ns — vì con trỏ không rời hàm. Còn traConTro (trả con trỏ) tốn 16 byte + 1 cấp phát, và AddrEscape (lấy địa chỉ biến vòng lặp lưu ra toàn cục) tốn 8 byte + 1 cấp phát. Sự khác biệt duy nhất là con trỏ có sống lâu hơn hàm hay không.
Một lưu ý trung thực về interface: gán một int vào interface{} cục bộ rồi bỏ đi không luôn cấp phát — nếu trình biên dịch thấy interface đó không thoát, nó tối ưu về 0 cấp phát. Chỉ khi interface bị lưu ra ngoài mới thực sự boxing lên heap. Đừng đoán — đọc -m để biết chắc.
Ứng dụng thực tế
"Truyền con trỏ để tránh copy" không phải luôn thắng. Với struct nhỏ, truyền giá trị (copy vài byte trên stack) thường rẻ hơn truyền con trỏ (có thể gây escape lên heap + 1 cấp phát + áp lực GC). Chỉ truyền con trỏ khi struct lớn hoặc bạn cần sửa đối tượng gốc.
Cảnh giác leaking param trong API của bạn. Nếu một hàm lưu con trỏ nhận vào (vào field struct, biến toàn cục, hay gửi qua channel sống lâu), nó khiến mọi người gọi bị escape. Đây là chi phí ẩn của thiết kế: một hàm tưởng vô hại có thể ép cấp phát ở khắp nơi gọi nó. Xem -m trên hàm của bạn để phát hiện.
fmt.Println và bạn bè làm tham số escape. Vì chúng nhận ...interface{}, mọi giá trị truyền vào bị boxing và thường escape. Trong vòng lặp cực nóng, một fmt.Sprintf có thể là nguồn cấp phát bất ngờ. Đây là lý do log ở đường nóng nên dùng thư viện zero-alloc (như zerolog) hoặc tránh format khi không cần.
Đánh đổi cần cân nhắc
Đừng biến code thành mê cung để tránh escape. Phần lớn escape là hoàn toàn ổn — chương trình cần dữ liệu sống lâu thì heap là đúng chỗ. Chỉ săn escape ở đường nóng profiler chỉ ra. Viết code rối rắm để ép mọi thứ ở stack thường lỗ vốn.
Con trỏ vẫn cần thiết và đúng đắn. Bài này không nói "tránh con trỏ". Con trỏ để chia sẻ trạng thái, sửa đối tượng, biểu diễn cấu trúc liên kết là thiết yếu. Chỉ cần hiểu khi nào chúng gây escape để không dùng bừa ở chỗ giá trị đủ tốt.
Phân tích escape là liên thủ tục nhưng có giới hạn. leaking param lan qua các hàm được phân tích, nhưng khi gặp ranh giới không nội tuyến được hoặc con trỏ hàm/interface động, trình biên dịch phải giả định xấu nhất (escape). Nội tuyến (inlining) giúp escape analysis chính xác hơn — hàm nhỏ được nội tuyến thì phân tích thấy rõ hơn.
Ba ý mang về
- Con trỏ chỉ gây escape khi biến sống lâu hơn hàm — bốn thủ phạm: trả con trỏ ra ngoài (
moved to heap), lưu vào toàn cục (escapes to heap), đóng gói interface bị giữ (n escapes to heap), vàleaking param(hàm nhận giữ lại con trỏ). Con trỏ chỉ dùng nội bộ vẫn ở stack, 0 cấp phát (đo thật 0,23 ns). leaking paramlan escape qua ranh giới hàm: nếu hàm của bạn lưu con trỏ nhận vào, nó ép mọi người gọi truyền biến cục bộ bị đẩy lên heap — một chi phí ẩn cần đọc-mđể phát hiện.- Truyền giá trị cho struct nhỏ thường rẻ hơn con trỏ, và cảnh giác
fmt/interface làm tham số escape ở đường nóng — nhưng đừng tối ưu mù, chỉ săn escape nơi profiler chỉ ra.
Bài tập làm thử
Bài 1 (đọc hiểu). Cho đoạn code sau, hãy cho biết hàm nào sinh ra dòng leaking param, hàm nào sinh moved to heap, và giải thích tại sao truyenBiGiu bị ảnh hưởng bởi chính giu.
type T struct{ A, B int }
var global *T
func giu(p *T) {
global = p
}
func truyenBiGiu() {
t := T{5, 6}
giu(&t)
}
Đáp án
giu: sinh dòngleaking param: p— hàm này giữ lại con trỏ tham số nhận vào bằng cách gán nó cho biến toàn cụcglobal, nên con trỏ có thể sống lâu hơn lần gọi hàm.truyenBiGiu: sinh dòngmoved to heap: t— vìgiuđã được đánh dấu là giữ lại con trỏ nhận vào (leaking param), nên mọi người gọi truyền địa chỉ một biến cục bộ vàogiuđều khiến biến đó bị đẩy lên heap.tbị buộc lên heap không phải vì bản thântruyenBiGiulàm gì đặc biệt, mà vì escape "lan" qua ranh giới hàm theo phân tích liên thủ tục.
Bài 2 (sửa lỗi). Struct Diem nhỏ (hai trường int) chỉ cần đọc trong hàm in, không cần sửa đối tượng gốc và không có lý do gì phải chia sẻ trạng thái. Cách viết dưới đây khiến d thoát lên heap không cần thiết ở mọi nơi gọi in. Hãy sửa lại.
type Diem struct{ X, Y int }
func in(p *Diem) {
fmt.Println(p.X, p.Y)
}
func vidu() {
d := Diem{X: 1, Y: 2}
in(&d)
}
Đáp án
Vì in chỉ đọc dữ liệu chứ không cần sửa struct gốc, nên truyền theo giá trị sẽ tránh việc tham số bị coi là giữ địa chỉ dài hạn:
func in(p Diem) {
fmt.Println(p.X, p.Y)
}
func vidu() {
d := Diem{X: 1, Y: 2}
in(d)
}
Lưu ý: dù đã bỏ con trỏ, fmt.Println vẫn nhận ...interface{}, nên p.X và p.Y (các giá trị int) vẫn có thể bị "đóng hộp" (boxing) qua interface — đây là một nguồn escape khác nằm ngoài tầm kiểm soát trực tiếp của in, như bài viết nêu ở phần "fmt.Println và bạn bè làm tham số escape". Muốn chắc chắn phải đọc lại -gcflags=-m sau khi sửa.
Bài 3 (vận dụng). Giải thích tại sao hàm sau dùng con trỏ nhưng vẫn được đo là 0 cấp phát (0,23 ns/op, 0 B/op) trong bài viết, khác với traConTro (10,17 ns/op, 16 B/op, 1 alloc/op).
func conTroCucBo() int {
t := T{7, 8}
p := &t
p.A = 100
return p.A + p.B
}
Đáp án
Con trỏ p := &t được tạo ra và dùng để sửa p.A, nhưng không bao giờ rời khỏi hàm — hàm chỉ trả về một giá trị int (p.A + p.B), không trả con trỏ, không lưu vào biến toàn cục, không truyền cho hàm khác giữ lại. Vì vậy trình biên dịch chứng minh được t "chết" ngay khi hàm kết thúc, nên t ở lại stack dù có con trỏ trỏ tới nó. Đây chính là điểm mấu chốt của bài: con trỏ tự nó không gây escape — chỉ khi con trỏ mang địa chỉ ra khỏi phạm vi hàm (như traConTro trả &t ra ngoài) thì biến mới buộc phải lên heap.
Bài 4 (bẫy/đánh đổi). Đoạn code dưới đây chạy trong một vòng lặp cực nóng (hàng triệu lần mỗi giây). Nó có vấn đề hiệu năng liên quan tới escape analysis mà bài viết đã cảnh báo. Chỉ ra vấn đề và đề xuất hướng khắc phục.
for i := 0; i < soLuong; i++ {
fmt.Sprintf("xu ly item %d", i)
}
Đáp án
Vấn đề: fmt.Sprintf nhận tham số kiểu ...interface{}. Mỗi lần gọi, giá trị i (kiểu int) phải được "đóng hộp" (boxing) vào interface — theo bài viết, đây là lý do các hàm dạng fmt.Println/fmt.Sprintf thường làm tham số escape lên heap, gây cấp phát ở mỗi lần lặp trong đường nóng.
Hướng khắc phục: bài viết gợi ý dùng thư viện log/format zero-alloc (như zerolog) hoặc tránh gọi format khi không thực sự cần trong đường nóng — ví dụ chỉ format khi có điều kiện log thật sự xảy ra, thay vì format vô điều kiện trong mỗi vòng lặp. Cũng cần lưu ý bài viết nhắc: không phải mọi giá trị đưa vào interface{} đều cấp phát — chỉ khi interface đó thực sự thoát (bị lưu lại, truyền ra ngoài) thì mới boxing lên heap; muốn biết chắc phải đọc -m, không đoán.
Phần sau ta xuống tầng thấp hơn nữa — chính bộ máy cấp phát heap: Phần sau mổ xẻ allocator ba tầng của Go (mcache, mcentral, mheap) — vì sao cấp phát nhỏ nhanh mà không cần khoá, và bộ nhớ được tổ chức theo size class thế nào.