
چرا کد Go ما کندتر از چیزیه که باید باشه؟
بررسی عمیق Heap Allocation در Go و راههای بهینهسازی حافظه برای کد سریعتر

علی جولائی راد
مهندس نرمافزار و منتور
هر توسعهدهندهٔ Go بالاخره به این مشکل بر میخوره: کدت کار میکنه، ولی به اندازهٔ کافی سریع نیست. الگوریتمهات رو بهینه کردی، loopهای اضافی رو حذف کردی، ولی بازم یه چیزی درست نیست. خیلی وقتها مشکل جلوی چشمته ولی نمیبینیش: heap allocationهای اضافی.
توی این مقاله بهت نشون میدم که Go دقیقاً چطور تصمیم میگیره متغیرهات رو کجا بذاره، چرا این موضوع برای performance مهمه، و چطور میتونی رایجترین اشتباهات allocation رو که توی production دیدم برطرف کنی.
هزینهٔ پنهان Memory Allocation
شاید تعجب کنی، ولی همهی memory allocationها مثل هم نیستن.
وقتی Go یه متغیر رو روی stack میذاره، عملاً هزینهای نداره. runtime فقط stack pointer رو جابجا میکنه — یه دستور CPU ساده. وقتی function تموم میشه، اون حافظه خودکار آزاد میشه. نه garbage collector درگیره، نه overhead ای وجود داره.
اما Heap allocation داستانش فرق میکنه. هر allocation روی heap یعنی garbage collector باید اون حافظه رو track کنه، مرتب scan کنه، و بالاخره آزادش کنه. توی سیستمهای high-throughput، این overhead سریع زیار میشه. حتی میشه فقط با حذف heap allocationهای غیرضروری، P99 latency رو نصف کرد.
Go چطور تصمیم میگیره؟ Escape Analysis
کامپایلر گولنگ موقع کامپایل یه کاری به اسم escape analysis انجام میده تا ببینه یه متغیر میتونه به صورت امن روی stack بمونه یا باید به heap «فرار» کنه. قانونش سادهست: اگه compiler مطمئن باشه عمر متغیر به scope همون function محدوده، روی stack میمونه. اما اگه شک کنه، میره روی heap.
با این دستور میتونی دقیقاً ببینی compiler چه تصمیمی میگیره:
go build -gcflags="-m" ./...این خروجی escape analysis رو نشون میده. نسخهٔ مفصلترش تصمیمات inlining رو هم نشون میده:
go build -gcflags="-m -m" ./...وقتی اجرا کنی، همچین چیزی میبینی:
./main.go:15:2: moved to heap: u
./main.go:22:6: leaking param: uحالا بریم سراغ الگوهایی که باعث این escapeها میشن و اینکه چطور درستشون کنیم.
الگوی ۱: برگردوندن Pointer به متغیر Local
این رایجترین الگوی escape هست و کاملاً بیگناه به نظر میرسه:
// Every time a heap allocation happens
func createUser() *User {
u := User{Name: "John", Age: 30}
return &u
}متغیر u داخل function ساخته میشه، ولی یه pointer بهش برمیگردونیم. چون pointer عمرش از function بیشتره، Go مجبوره u رو روی heap بذاره.
راهحلش بستگی به use case داره:
روش ۱: بذار کسی که فانکشن رو صدا میزنه صاحب حافظه باشه
func createUser(u *User) {
u.Name = "John"
u.Age = 30
}
// Usage
var u User
createUser(&u) // No allocation — u stays on the caller's stackروش ۲: برای structهای کوچیک، by value برگردون
func createUser() User {
// Gets copied on return, but stays on stack
return User{Name: "John", Age: 30}
}برای structهای زیر ۶۴ بایت، return کردن by value معمولاً سریعتره. ولی حتماً profile بگیر تا مطمئن بشی.
الگوی ۲: ساختن Slice بدون Capacity از پیش تعیینشده
ببین وقتی یه slice میسازی بدون اینکه سایز نهاییش رو بدونی چه اتفاقی میفته:
func collectIDs(n int) []int {
var ids []int
for i := 0; i < n; i++ {
ids = append(ids, i)
}
return ids
}هر بار که slice از capacityش رد میشه، Go یه backing array جدید و بزرگتر allocate میکنه و همه چیز رو کپی میکنه. برای ۱۰۰۰ تا element، شاید بیشتر از ۱۰ بار allocation اتفاق بیفته.
وقتی سایز رو بدونی راهحلش سادهست:
func collectIDs(n int) []int {
ids := make([]int, 0, n) // One allocation, correctly sized
for i := 0; i < n; i++ {
ids = append(ids, i)
}
return ids
}اگه سایز دقیق رو نمیدونی، بالاتر تخمین بزن. over-allocate کردن capacity تقریباً همیشه ارزونتره از reallocationهای مکرره.
الگوی ۳: اینترفیس خالی
این یکی خیلیا رو غافلگیر میکنه:
func process(val int) interface{} {
return val // val escapes to heap
}وقتی یه type اینترفیس خالی assign میکنی، Go مجبوره یه box روی heap بسازه که هم value و هم اطلاعات type رو نگه داره.
راهحل: از generics استفاده کن
func process[T any](val T) T {
return val // Without boxing, stays on stack
}Generics کد اختصاصی برای هر type تولید میکنه و نیاز به interface boxing رو کاملاً حذف میکنه.
الگوی ۴: تلهی fmt.Sprintf
این یکی همهجا استفاده میشه:
func formatUser(name string, age int) string {
return fmt.Sprintf("Name: %s, Age: %d", name, age)
}Functionهای fmt پارامتر interface{} میگیرن، یعنی همهی argumentها به heap فرار میکنن. توی hot pathها، این سریع باعث تجمیع حافظه روی heap میشه.
راهحل برای کد performance-critical:
import (
"strconv"
"strings"
)
func formatUser(name string, age int) string {
var b strings.Builder
b.WriteString("Name: ")
b.WriteString(name)
b.WriteString(", Age: ")
b.WriteString(strconv.Itoa(age))
return b.String()
}برای concatenation ساده، حتی عملگر + هم میتونه از fmt.Sprintf سریعتر باشه:
func formatUser(name string, age int) string {
return "Name: " + name + ", Age: " + strconv.Itoa(age)
}الگوی ۵: Closureهایی که متغیر Capture میکنن
Closureها قدرتمندن، ولی هزینهٔ allocation دارن:
func counter() func() int {
count := 0
return func() int {
count++
return count
}
}متغیر count باید به heap فرار کنه چون closureای که ساختیم بعد از return شدن هنوز به counter() بهش نیاز داره.
گاهی این اجتنابناپذیره و ارزشش رو داره. ولی اگه داری توی یه لوپ closure میسازی بهتره تجدیدنظر کنی :)
// Instead of creating a closure for each iteration
for _, item := range items {
go func() {
process(item) // item escapes
}()
}
// Pass as parameter — no escape
for _, item := range items {
go func(it Item) {
process(it)
}(item)
}الگوی ۶: Bufferهای بزرگ موقتی
وقتی مکرراً به bufferهای بزرگ موقتی نیاز داری:
func processData(data []byte) []byte {
buf := make([]byte, 1024*1024) // 1 megabyte allocation each time
// ... processing with buf
return result
}راهحل: از sync.Pool استفاده کن
var bufferPool = sync.Pool{
New: func() interface{} {
buf := make([]byte, 1024*1024)
return &buf
},
}
func processData(data []byte) []byte {
bufPtr := bufferPool.Get().(*[]byte)
buf := *bufPtr
defer bufferPool.Put(bufPtr)
// ... processing with buf
return result
}sync.Pool یه cache از objectهای allocate شده نگه میداره که میتونن reuse بشن. این توی سناریوهای high-throughput فشار allocation رو بهشدت کاهش میده.
چطور allocationها رو benchmark کنیم؟
پکیج testing گولنگ اندازهگیری allocationها رو آسون میکنه:
func BenchmarkCreateUserPointer(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = createUserBad()
}
}
func BenchmarkCreateUserValue(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = createUserGood()
}
}با فلگ -benchmem اجرا کن:
go test -bench=. -benchmemهمچین خروجیای میبینی:
BenchmarkCreateUserPointer-8 50000000 25.3 ns/op 32 B/op 1 allocs/op
BenchmarkCreateUserValue-8 200000000 6.1 ns/op 0 B/op 0 allocs/op
ستونهای B/op و allocs/op دقیقاً بهت میگن هر operation چقدر حافظه allocate میکنه.
Profiling با pprof
برای applicationهای واقعی، از pprof استفاده کن تا hotspotهای allocation رو پیدا کنی:
import (
"os"
"runtime/pprof"
)
func main() {
f, _ := os.Create("heap.prof")
defer f.Close()
// ... run the application
pprof.WriteHeapProfile(f)
}بعد آنالیز کن:
go tool pprof -http=:8080 heap.profمرجع سریع: چی فرار میکنه و چرا
| الگو | فرار میکنه؟ | راهحل |
|---|---|---|
return &localVar | بله | pointer رو pass کن یا by value برگردون |
append() بدون capacity | اغلب | با make([]T, 0, n) از قبل allocate کن |
پارامترهای interface{} | بله | از generics یا typeهای مشخص استفاده کن |
fmt.Sprintf | بله | از strconv + strings.Builder استفاده کن |
| Closure که متغیر capture میکنه | بله | اگه ممکنه بهعنوان پارامتر پاس بده |
| Arrayهای local بزرگ | گاهی | از sync.Pool استفاده کن |
| map ساختهشده توی function | معمولاً | mapها رو reuse کن، با clear() پاک کن |
میتونی مقاله اصلی که من ازش ترجمه کردم اینجا بخونی.
