چرا کد Go ما کندتر از چیزیه که باید باشه؟

چرا کد Go ما کندتر از چیزیه که باید باشه؟

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

انتشار: ۹ دی ۱۴۰۴زمان مطالعه: ~5 دقیقه
علی جولائی راد

علی جولائی راد

مهندس نرم‌افزار و منتور

هر توسعه‌دهندهٔ 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 چه تصمیمی می‌گیره:

bash
go build -gcflags="-m" ./...

این خروجی escape analysis رو نشون می‌ده. نسخهٔ مفصل‌ترش تصمیمات inlining رو هم نشون می‌ده:

bash
go build -gcflags="-m -m" ./...

وقتی اجرا کنی، همچین چیزی می‌بینی:

output
./main.go:15:2: moved to heap: u
./main.go:22:6: leaking param: u

حالا بریم سراغ الگوهایی که باعث این escape‌ها می‌شن و اینکه چطور درستشون کنیم.


الگوی ۱: برگردوندن Pointer به متغیر Local

این رایج‌ترین الگوی escape هست و کاملاً بی‌گناه به نظر می‌رسه:

go
// 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 داره:

روش ۱: بذار کسی که فانکشن رو صدا می‌زنه صاحب حافظه باشه

go
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 برگردون

go
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 می‌سازی بدون اینکه سایز نهاییش رو بدونی چه اتفاقی میفته:

go
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 اتفاق بیفته.

وقتی سایز رو بدونی راه‌حلش ساده‌ست:

go
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‌های مکرره.


الگوی ۳: اینترفیس خالی

این یکی خیلیا رو غافلگیر می‌کنه:

go
func process(val int) interface{} {
    return val  // val escapes to heap
}

وقتی یه type اینترفیس خالی assign می‌کنی، Go مجبوره یه box روی heap بسازه که هم value و هم اطلاعات type رو نگه داره.

راه‌حل: از generics استفاده کن

go
func process[T any](val T) T {
    return val  // Without boxing, stays on stack
}

Generics کد اختصاصی برای هر type تولید می‌کنه و نیاز به interface boxing رو کاملاً حذف می‌کنه.


الگوی ۴: تله‌ی fmt.Sprintf

این یکی همه‌جا استفاده میشه:

go
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:

go
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 سریع‌تر باشه:

go
func formatUser(name string, age int) string {
    return "Name: " + name + ", Age: " + strconv.Itoa(age)
}

الگوی ۵: Closure‌هایی که متغیر Capture می‌کنن

Closure‌ها قدرتمندن، ولی هزینهٔ allocation دارن:

go
func counter() func() int {
    count := 0
    return func() int {
        count++
        return count
    }
}

متغیر count باید به heap فرار کنه چون closure‌ای که ساختیم بعد از return شدن هنوز به counter() بهش نیاز داره.

گاهی این اجتناب‌ناپذیره و ارزشش رو داره. ولی اگه داری توی یه لوپ closure می‌سازی بهتره تجدیدنظر کنی :)

go
// 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‌های بزرگ موقتی نیاز داری:

go
func processData(data []byte) []byte {
    buf := make([]byte, 1024*1024)  // 1 megabyte allocation each time
    // ... processing with buf
    return result
}

راه‌حل: از sync.Pool استفاده کن

go
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‌ها رو آسون می‌کنه:

go
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 اجرا کن:

bash
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 رو پیدا کنی:

go
import (
    "os"
    "runtime/pprof"
)
 
func main() {
    f, _ := os.Create("heap.prof")
    defer f.Close()
 
    // ... run the application
 
    pprof.WriteHeapProfile(f)
}

بعد آنالیز کن:

bash
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() پاک کن

میتونی مقاله اصلی که من ازش ترجمه کردم اینجا بخونی.

مطالب پیشنهادی

مطالب مرتبط برای ادامه مطالعه

این مطلب برات مفید بود؟

آخرین پست‌ها و آموزش‌های جدید را مستقیماً در ایمیل دریافت کن

با عضویت، شرایط و قوانین را می‌پذیرید