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.

Ảnh chụp đoạn mã Go nền tối minh hoạ escape analysis biến ở lại stack hay thoát lên heap, type Diem struct X Y int, hàm tongCucBo không thoát tạo d Diem trả về d.X cộng d.Y trả giá trị ở lại stack, hàm taoConTro thoát tạo d Diem trả về địa chỉ và d trả con trỏ tới biến cục bộ lên heap, hàm sliceTinh make int 16 kích thước hằng số ở stack, hàm sliceDong make int n kích thước biến lên heap, output thật của go build gcflags dấu m ea.go dòng 13 moved to heap d taoConTro trả con trỏ, make int 16 does not escape cỡ hằng số ở stack, make int n escapes to heap cỡ biến 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ến x (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:

Ảnh chụp bảng kết quả đo thật nền tối go test bench benchmem Go 1.23 arm64 10 core về escape analysis, cùng logic khác nơi cấp phát chú ý B/op và allocs/op, BenchmarkSliceStack 0.23 ns/op 0 B/op 0 allocs/op, BenchmarkSliceHeap 27.94 ns/op 128 B/op 1 allocs/op, BenchmarkValueStack 0.23 ns/op 0 B/op 0 allocs/op, BenchmarkPointerHeap 9.24 ns/op 16 B/op 1 allocs/op, ý nghĩa slice cỡ hằng stack 0.23 ns 0 cấp phát, slice cỡ biến heap 27.94 ns 128 B chậm khoảng 120 lần, trả giá trị stack 0.23 ns 0 cấp phát, trả con trỏ heap 9.24 ns 16 B chậm khoảng 40 lần, cùng số phần tử 16 int nhưng cỡ biết lúc biên dịch thì ở stack cỡ chỉ biết lúc chạy thì lên heap và phải cấp phát cộng tạo việc cho GC

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ề

  1. 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òng moved to heap, does not escape, escapes to heap).
  2. 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.
  3. Dùng -benchmem để săn allocs/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.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho đoạn code sau và giả sử bạn chạy go build -gcflags="-m" ea.go. Hãy cho biết dòng output escape analysis tương ứng với mỗi hàm (loại "moved to heap", "does not escape", hay "escapes to heap"), và biến/biểu thức nào bị nhắc tên trong mỗi dòng.

type Diem struct{ X, Y int }

func tongCucBo() int {
	d := Diem{X: 3, Y: 4}
	return d.X + d.Y
}

func taoConTro() *Diem {
	d := Diem{X: 1, Y: 2}
	return &d
}

func sliceTinh() []int {
	return make([]int, 16)
}

func sliceDong(n int) []int {
	return make([]int, n)
}
Đáp án
  • tongCucBo: d không thoát — hàm chỉ trả về giá trị int tính từ d, không có địa chỉ nào rời khỏi hàm. d ở stack.
  • taoConTro: moved to heap: d — hàm trả về &d, địa chỉ của biến cục bộ thoát ra ngoài phạm vi hàm nên d phải chuyển lên heap.
  • sliceTinh: make([]int, 16) does not escape — kích thước là hằng số 16, trình biên dịch chứng minh được kích thước lúc biên dịch nên có thể đặt trên stack.
  • sliceDong: make([]int, n) escapes to heap — kích thước n là biến, dù lúc chạy n có thể bằng 16, trình biên dịch không chứng minh được cỡ cố định nên không dám đặt ở stack.

Bài 2 (sửa lỗi). Hàm dưới đây được gọi rất nhiều lần trong một vòng lặp nóng (hot path) chỉ để cộng hai trường của một struct nhỏ, nhưng lại buộc struct đó thoát lên heap một cách không cần thiết. Hãy chỉ ra chỗ gây escape và sửa lại để Diem ở lại stack.

func tinhTong(x, y int) int {
	d := &Diem{X: x, Y: y}
	return tinh(d)
}

func tinh(d *Diem) int {
	return d.X + d.Y
}
Đáp án

Vấn đề nằm ở việc tinhTong cấp phát Diem rồi lấy địa chỉ truyền qua con trỏ trong khi không có lý do gì cần chia sẻ hay sửa đối tượng gốc — với struct nhỏ như Diem (hai int), truyền giá trị rẻ hơn nhiều so với việc tạo con trỏ (dù ở bài này bản thân việc lấy &Diem{...} chưa chắc đã escape nếu tinh không giữ lại con trỏ, nhưng cách viết an toàn và rõ ràng nhất là bỏ hẳn con trỏ):

func tinhTong(x, y int) int {
	d := Diem{X: x, Y: y}
	return tinh(d)
}

func tinh(d Diem) int {
	return d.X + d.Y
}

Sửa xong nên chạy lại go build -gcflags="-m" để xác nhận không còn dòng moved to heap hay escapes to heap nào liên quan đến d — như bài viết nhấn mạnh, đừng đoán, phải đọc -m để biết chắc.

Bài 3 (vận dụng). Bạn cần một buffer tạm 32 phần tử int bên trong một hàm chạy trên đường nóng. Giữa hai cách viết dưới đây, cách nào nên dùng để buffer ở lại stack, và vì sao?

// Cách A
func lamA(n int) int {
	buf := make([]int, n) // n truyền vào, luôn bằng 32 lúc gọi thực tế
	// ... xử lý buf ...
	return len(buf)
}

// Cách B
func lamB() int {
	buf := make([]int, 32)
	// ... xử lý buf ...
	return len(buf)
}
Đáp án

Dùng Cách B. Dù ở Cách A giá trị n thực tế luôn là 32 lúc chạy, trình biên dịch chỉ nhìn thấy n là một biến — nó không thể chứng minh kích thước cố định tại thời điểm biên dịch, nên make([]int, n) sẽ bị đánh dấu escapes to heap giống như ví dụ sliceDong trong bài viết (đo được 27,94 ns/op, 128 B/op, 1 alloc). Cách B dùng hằng số 32 trực tiếp trong make, trình biên dịch chứng minh được kích thước nên giữ buf ở stack (giống sliceTinh, đo được 0,23 ns/op, 0 B/op, 0 alloc) — nhanh hơn khoảng 120 lần cho cùng một việc.

Bài 4 (bẫy/đánh đổi). Một đồng nghiệp nói: "Tôi thấy -gcflags=-m báo escape ở một chỗ tôi chắc chắn là an toàn để ở stack — chắc trình biên dịch có bug." Nhận định này đúng hay sai? Giải thích dựa trên tính chất của escape analysis được nêu trong bài, và cho một ví dụ về việc tối ưu escape có thể "lỗ vốn".

Đáp án

Nhận định sai (trong đa số trường hợp). Bài viết nêu rõ escape analysis là bảo thủ: trình biên dịch chỉ giữ biến ở stack khi chứng minh chắc chắn an toàn; gặp các trường hợp phức tạp (interface, closure, kích thước động...) nó "đầu hàng" và đẩy lên heap dù thực tế có thể an toàn — đó không phải bug mà là lựa chọn thiên về an toàn.

Về việc tối ưu lỗ vốn: bài viết cảnh báo đừng tối ưu escape một cách mù quáng vì phần lớn code không nằm trên đường nóng. Ví dụ: nếu một hàm chỉ chạy 1000 lần mỗi request, chênh lệch 27,94 ns so với 0,23 ns chỉ tiết kiệm khoảng 27 µs — có thể không đáng để đánh đổi lấy code khó đọc hơn. Chỉ nên săn escape ở nơi -benchmem/profiler thực sự chỉ ra là nóng.

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.