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.
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.