Đợt rồi mình ngồi tối ưu một service Go nhỏ: mỗi request cấp phát hơn 300KB, latency thì không tệ, chỉ có GC chạy liên tục và p99 lúc nào cũng gợn lên. Lôi pprof ra xem thì phần lớn allocation đến từ những chỗ trông rất vô hại: một struct 40 byte, một closure, một chuỗi nối bằng dấu +.

Hôm đó mình mới chịu ngồi đọc escape analysis cho đàng hoàng. Hoá ra trước đó mình đoán sai gần hết.

Stack hay heap — ai quyết định?

Trong Go, mình không được chọn biến nằm ở đâu. Compiler quyết định, thông qua escape analysis: nếu chứng minh được biến chỉ sống trong phạm vi hàm gọi nó, biến nằm trên stack — cấp phát gần như miễn phí, hàm return là xong, GC không biết tới. Còn nếu không chứng minh được, vì có thứ gì đó giữ con trỏ sống lâu hơn, biến "escape" lên heap: mỗi lần tạo là một lần GC phải nhớ, dọn và di chuyển.

Sơ đồ phân loại giá trị: băng chuyền đưa các khối sáng lên chồng ngăn nắp và đưa một số khác bay vào đám mây Ảnh: Ẩn dụ cho escape analysis — phần lớn giá trị nằm gọn trên chồng (stack), số ít bị đẩy bay lên mây (heap) vì có con trỏ giữ chúng sống lâu hơn hàm. — ảnh minh hoạ do AI tạo (Grok Imagine)

Điểm mình thích: nó thuần compile-time, không có bộ đếm runtime nào. Muốn giảm allocation thì viết theo kiểu dễ chứng minh hơn.

Đọc output của compiler cho khỏi rối

go build -gcflags="-m" ./... 2>&1 | grep -E "escapes to heap|moved to heap"

Vài lệnh cần biết: -m in một tầng lý do, -m -m (hoặc -m=2 trên Go mới) in thêm vì sao. Nhớ để ./..., chỉ build một file thì compiler coi mọi thứ bên ngoài là chưa biết.

Output rất dài, đa số là does not escape — tin tốt. Mình chỉ lọc escapes to heap và moved to heap kèm số dòng, rồi soi đúng mấy dòng đó.

Bốn kiểu code hay đẩy giá trị lên heap

  • Trả con trỏ từ hàm: return &user. Nếu hàm nhỏ và được inline thì compiler có thể mổ ra rồi giữ trên stack, còn thường là escape.
  • Nhét vào interface: fmt.Println(user), []any{user}, log.Printf("%v", u). Giá trị bị box, kèm luôn một con trỏ và header.
  • Closure sống lâu hơn hàm: biến ngoài phạm vi bị goroutine khác giữ thì phải nằm trên heap, cùng cả closure.
  • Cấp phát động: make([]byte, n) khi n không biết lúc compile. Cái này thường không tránh được, nhưng số lần gọi thì tránh được.

Sửa thử: từ 8 allocation xuống 1

Chỗ đau nhất trong service của mình là hàm build cache key. Nó nhận struct rồi trả về con trỏ, tức mỗi lần gọi là hai lần cấp phát: một cho chuỗi, một cho biến thoát ra ngoài.

// TRƯỚC
func buildKey(u User) *string {
	k := u.Tenant + ":" + u.ID
	return &k // escape, và cộng chuỗi cũng cấp phát
}

// SAU
func appendKey(dst []byte, u User) []byte {
	dst = append(dst, u.Tenant...)
	dst = append(dst, ':')
	return append(dst, u.ID...)
}

Chỗ gọi giữ một buffer dùng lại — qua sync.Pool hoặc field trong worker struct — rồi truyền vào. Cách này đổi API từ "trả giá trị" sang "ghi vào chỗ có sẵn", đáng đánh đổi ở mấy hàm gọi hàng triệu lần.

Kính lúp soi sơ đồ các khối code, một khối nằm trên kệ sáng xanh, một khối bay lên mây Ảnh: Đọc output `-gcflags=-m` giống như soi từng khối code — chỉ vài khối thật sự rời khỏi hàm, còn lại cứ để yên trên stack cho compiler lo. — ảnh minh hoạ do AI tạo (Grok Imagine)

Đo bằng benchmark, không đo bằng cảm giác

go test -bench=BuildKey -benchmem -run=^$ ./...

Nhìn allocs/op trước, ns/op sau: ns/op nhiễu vì turbo boost với CI chạy chung máy, còn allocs/op gần như hằng số — 8 xuống 1 là 8 xuống 1. Mình đặt thêm test chặn ngưỡng với b.ReportAllocs(), để ai thêm fmt.Sprintf vào đường nóng là CI đỏ ngay.

Mấy chỗ mình từng sửa sai

  • Tối ưu trước khi đo. Mình từng đi đổi string thành []byte khắp nơi rồi benchmark thấy chậm hơn, vì chỗ đó chuyển đổi qua lại mới là chi phí thật.
  • Đổi struct nhỏ thành con trỏ để "tránh copy". Với struct vài chục byte, copy rẻ hơn hẳn một lần cấp phát trên heap cộng công GC sau này.
  • sync.Pool như thuốc tiên. Pool chỉ đáng khi object to, dùng dày, và vòng đời ngắn. Nhét slice 16 byte vào pool thì thêm overhead, lại còn dễ quên Put hoặc Put nhầm object còn ai đó đang đọc.
  • Heap không xấu. Escape là câu trả lời đúng cho thứ sống lâu hơn hàm, không phải lỗi. Chỉ đáng sửa khi nó nằm trên đường nóng và số lần gọi lớn.

Sau khi sửa ba chỗ, allocation mỗi request của mình rơi từ khoảng 300KB xuống 40KB, GC time còn một phần tư và p99 phẳng hơn hẳn.

Hai cột biểu đồ cạnh nhau: một cao và mờ, một thấp và sáng xanh, kèm đồng hồ bấm giây Ảnh: Kết quả sau khi sửa — cột cấp phát cao và tán xạ rơi xuống còn một cột thấp, sáng, đúng thứ mà cột `allocs/op` trong benchmark nói lên. — ảnh minh hoạ do AI tạo (Grok Imagine)

Chốt

Escape analysis không phải mẹo vặt của dân performance. Nó là cách Go nói chuyện với mình về bộ nhớ, và cách rẻ nhất để nghe là mở -gcflags=-m xem compiler phàn nàn gì. Nghe xong, sửa vài chỗ gọi dày, rồi đo lại bằng allocs/op.

Bạn thử chạy go build -gcflags=-m ./... trên repo của mình xem — có dòng escapes to heap nào làm bất ngờ không?

📋 Phụ lục thuật ngữ

Thuật ngữ Giải thích ngắn
Escape analysis Phân tích lúc compile để quyết định biến nằm trên stack hay phải lên heap
Escape to heap Giá trị không chứng minh được là sống trong hàm, nên bị cấp phát trên heap
-gcflags=-m Cờ của go build/go test in lý do compiler quyết định cấp phát ở đâu
Inlining Compiler thay lời gọi hàm bằng chính thân hàm; inline được thì escape analysis dễ chứng minh hơn
Boxing Nhét giá trị cụ thể vào interface, sinh thêm dữ liệu trên heap để mang thông tin type
allocs/op Số lần cấp phát cho mỗi vòng lặp benchmark, xem bằng -benchmem
sync.Pool Chỗ giữ object dùng lại để giảm cấp phát, hợp với object to và vòng đời ngắn
GC churn Tạo rác liên tục khiến GC phải chạy nhiều, tốn CPU dù không rò rỉ bộ nhớ

Xem các bài viết khác