22 / 73

Goda xotirani tushunish

Siz yozadigan har bir Go dasturi oxir-oqibat qiymatlar qayerda yashashi va qancha vaqt turishi haqidagi qarorlar to'plamiga aylanadi. Bu qarorlarni kamdan-kam aniq qabul qilasiz — x := 42 yoki p := &Point{} deb yozib, davom etasiz — lekin sahna ortida kompilyator bu qiymat stack'ga tegishlimi yoki heap'gami degan qarorni qabul qiladi, va bu qaror dasturingiz qanchalik tez ishlashiga va GC'ga qancha ish topshirishingizga haqiqiy ta'sir qiladi. Sodda Go yozish buni e'tiborsiz qoldirishni anglatmaydi; u bu haqda kamdan-kam o'ylashingiz kerak bo'ladigan darajada yaxshi tushunishni va o'ylash kerak bo'lganda aynan qayerga qarashni bilishni anglatadi.

Qiymat yashashi mumkin bo'lgan ikki joy

Go, aksariyat kompilyatsiya qilinadigan tillar kabi, qiymatlarni ikki joyning birida saqlaydi: stack yoki heap. Bu farq muhim, chunki ikkalasi butunlay boshqacha boshqariladi.

Stack — funksiyalar chaqirilib qaytganda o'sib-qisqaradigan, har goroutine uchun alohida uzluksiz xotira mintaqasi. Har bir goroutine o'zining stack'iga ega bo'ladi, u kichik (bir necha kilobayt) boshlanadi va talab bo'yicha o'sadi — goroutine'lar OS thread'lariga qaraganda arzon bo'lishining sabablaridan biri shu, thread'lar odatda oldindan qat'iy, ancha kattaroq stack ajratadi. Stack'da ajratish deyarli bepul: bu stack pointer'ni siljitish masalasi, funksiya qaytganda esa xotira shunchaki pointer'ni orqaga siljitish bilan qaytariladi. Hisob-kitob yo'q, GC ishtiroki yo'q, sinxronizatsiya yo'q.

Heap esa, aksincha, jarayondagi barcha goroutine'lar o'rtasida ulashiladigan yagona umumiy xotira maydonidir. U yerda xotira ajratish runtime allocator'idan joy topishni so'rashni anglatadi, va bu joy funksiya yakunlanganda avtomatik bo'shatilmaydi — unga qayerdandir havola (reference) mavjud ekan, u tirik qoladi. Buni aniqlash Garbage Collector (GC)ning vazifasi bo'lib, u stack freymini tozalashdan ancha qimmatroq: allocator bo'sh bloklar ro'yxati va o'lcham sinflarini boshqarishi, GC esa vaqti-vaqti bilan hali erishish mumkin bo'lgan barcha obyektlarni tekshirib (trace qilib) chiqishi kerak.

Go 1.26 dan boshlab 64 bitli platformalarda heap'ning asosiy manzili jarayon ishga tushishida tasodifiylashtiriladi (ASLR) — bu asosan cgo bilan ishlashdagi xavfsizlikni kuchaytirish chorasidir, chunki C kodidan Go maydoniga kirib keladigan xotira xatolaridan foydalanishda bashorat qilinadigan heap tuzilmasi buzg'unchilar uchun qulay bo'lgan. Bu sizning Go kod yozishingizga ta'sir qilmaydi va qo'lda sozlashni talab etmaydi; faqat ishlayotgan jarayonda heap manzili haqida biror taxmin qilishdan saqlanish kerakligini eslatadi. (Agar disk raskadrovka yoki profillash vaqtida bu qulay bo'lmasa, GOEXPERIMENT=norandomizedheapbase64 bilan o'chirib qo'yish mumkin.)

Goda o'zgaruvchini majburan stack yoki heap deb e'lon qilib bo'lmaydi. Kompilyator bu qarorni sizning o'rningizga escape analysis (qochish tahlili) yordamida qabul qiladi. Bu ataylab shunday qilingan: xotira joylashuvini C tilidagi malloc/free yoki Rust'dagi ownership annotatsiyalari kabi kalit so'zlarga bog'lash har bir chaqiruv joyida qiymatning umr davomiyligi haqida o'ylashga majbur qiladi. Go esa bu qo'lda boshqaruvni avtomatik intellektual tahlilga topshiradi. Yaxshi tomoni shuki, kompilyator o'z qarorlarini ochiq ko'rsata oladi.

Goda qiymat stack'da yoki heap'da yashashini kompilyator o'zi hal qiladi — escape analysis yordamida.

Amalda escape analysis

Kompilyator qo'llaydigan qoidani tushunish oson: agar qiymatni yaratgan funksiya tugagandan keyin ham unga tashqaridan murojaat qilish imkoni qolsa (yoki kompilyator aksini isbotlay olmasa), qiymat heap'ga ko'chadi (escape qiladi). Aks holda, hamma narsa tezkor stack'da qoladi. Bir necha aniq misol buni yaqqol ko'rsatadi:

Qiymatni nusxa sifatida qaytarish uni stack'da qoldiradi:

example.go
func add(a, b int) int {
    result := a + b // stack'da qoladi
    return result   // chaqiruvchi nusxa oladi
}

Lokal o'zgaruvchiga pointer qaytarish uni heap'ga o'tkazadi, chunki chaqiruvchiga kerak bo'lgan qiymat funksiya chaqiruvidan uzoqroq yashashi shart:

example.go
func newPerson(name string) *Person {
    p := &Person{Name: name} // heap'ga escape qiladi
    return p
}

Bu C/C++ dan kelgan dasturchilar uchun kutilmagan jihat: C tilida lokal o'zgaruvchining manzilini qaytarish xavfli xato (stack freymi bekor qilinib, "dangling pointer" hosil bo'ladi). Goda esa bu mutlaqo xavfsiz va idiomatik amaliyotdir, chunki escape analysis buni oldindan sezib, p ni stack o'rniga bevosita heap'da yaratadi.

Qiymatlar, shuningdek, o'rab turuvchi funksiyadan ko'proq yashaydigan closure tomonidan ushlab qolinganda, o'zi heap'da joylashgan tuzilmalarga yozilganda yoki kompilyator to'liq nazorat qila olmaydigan dinamik interfeys orqali uzatilganda ham escape qiladi:

example.go
func createCounter() func() int {
    count := 0 // escape qiladi: closure unga havola (reference) ushlab turadi
    return func() int {
        count++
        return count
    }
}

Interfeyslar alohida e'tiborga loyiq nozik holatdir. Aniq qiymatni interface o'zgaruvchisiga o'zlashtirish ko'pincha bu qiymatni heap'ga majburlaydi, chunki interface o'zida har qanday turni ushlab turish uchun asosiy ma'lumotga pointer va tip deskriptorini saqlashi shart:

example.go
var w io.Writer = myBuffer // myBuffer bu yerda ehtimol escape qiladi

Yuqori unumdorlik talab qilinadigan kritik yo'laklarda (hot path) dasturchilar interface'lardan chetlanishi yoki interface'ga o'girishni iloji boricha kechiktirishining sababi ham shunda — interfeysning o'zi sekin bo'lgani uchun emas, balki bu o'girish qo'shimcha xotira ajratishni (allocation) keltirib chiqarishi mumkinligi uchundir.

Bularning hech birini taxmin qilish shart emas. -gcflags="-m" flagi kompilyatorga escape analysis qarorlarini chop etishni buyuradi:

bash
go build -gcflags="-m" main.go
matn
./main.go:10:6: x escapes to heap:
./main.go:10:6:   flow: ~r0 = &x:
./main.go:10:6:     from &x (address-of) at ./main.go:10:6
./main.go:10:6:     from return &x (return) at ./main.go:10:6

Flagni ikki marta yozish (-gcflags="-m -m") batafsilroq chiqishni beradi, jumladan kompilyator qiymat escape qilmaydi deb hal qilgan holatlarni va nima uchunligini tushuntiradi. Buni o'zingiz muhim deb bilgan kodda vaqti-vaqti bilan, muntazam ravishda emas, sizning sezgingiz kompilyatornikiga mos kelishini tasdiqlash usuli sifatida ishga tushirish foydali — va ko'pincha ular mos kelmaydi, ayniqsa interface'lar va closure'lar atrofida.

Xotira ajratish (allocation) narxi va uning ahamiyati

Stack'da xotira ajratish deyarli hech qanday resurs talab qilmaydi: bu stack pointer'ni siljitishdan iborat bitta tezkor protsessor buyrug'idir. Heap'da ajratish esa ancha murakkab bo'lib, Go runtime xotira allokatoriga murojaat qilishni talab qiladi: u mos o'lcham toifasini topishi, kerak bo'lsa GC siklini boshlashi va obyektni keyinchalik tozalash uchun ro'yxatga olishi kerak. Buni yuqori yuklamali siklda millionlab marotaba takrorlasangiz, "hamma narsa stack'da qolishi" bilan "hamma narsa heap'ga escape qilishi" o'rtasidagi farq minimal CPU bilan yengil ishlaydigan dastur bilan vaqtining uchdan birini GC pauzalarida o'tkazadigan dastur o'rtasidagi farqqa aylanadi.

Shunday bo'lsa-da, kichik heap ajratishlarining narxi Go versiyalari rivojlangani sari arzonlashib bormoqda. Go 1.27 o'lchamga ixtisoslashtirilgan allokator funksiyalarini qo'shdi — kompilyator endi har bir ajratishni bitta umumiy yo'l orqali o'tkazish o'rniga, kichik obyekt o'lchamlari uchun moslashtirilgan allokator nuqtalarini chaqiradi — bu 80 baytdan kichik ajratishlar sarfini taxminan 30% ga kamaytiradi. Biroq bu beparvo xotira ajratish uchun bahona bo'la olmaydi — asosiy qoidalar o'z kuchida qoladi.

Shuning uchun yuqori unumdorlik talab qiluvchi Go kodida quyidagi amaliy qoidalar qo'llaniladi:

Kichik tiplar uchun pointer o'rniga qiymatning o'zini qaytarish ma'qul. Qiymat sifatida qaytarilgan Point{X, Y float64} qo'shimcha xotira ajratishni talab qilmaydi va stack'da qoladi; xuddi shu struct uchun *Point qaytarish esa heap'ga escape qilishga majbur qiladi, vaholanki ikkita float sonni nusxalash xotira ko'rsatkichiga (pointer) murojaat qilishdan ancha tezroqdir.

Hajmi oldindan ma'lum bo'lsa, slice'larni sig'im bilan (cap) e'lon qiling. Nil yoki sig'imi yetarli bo'lmagan slice'ga append qilinganda, u to'lgan sari massivni qayta-qayta yangidan ajratadi va elementlarni nusxalaydi. make([]int, 0, n) esa xotirani bir martada ajratadi:

example.go
result := make([]int, 0, expectedSize)
for _, v := range source {
    result = append(result, transform(v))
}

Siklda har safar yangi xotira ajratish o'rniga buferlarni qayta ishlating, ayniqsa soniyasiga minglab marta yaratilib tashlanadigan qisqa muddatli bayt slice'lar uchun — bu sync.Pool uchun klassik ssenariydir:

example.go
var bufPool = sync.Pool{
    New: func() any { return make([]byte, 0, 4096) },
}

func handle(data []byte) {
    buf := bufPool.Get().([]byte)
    defer bufPool.Put(buf[:0])
    buf = append(buf, data...)
    // ...
}

sync.Pool aynan shunday qisqa muddatli, tez-tez qayta yaratiladigan obyektlar uchun mo'ljallangan; runtime pool'dagi elementlarni GC sikllari orasida xotiradan tozalashi mumkin, shuning uchun u uzoq muddat saqlanishi kerak bo'lgan ma'lumotlar uchun mos kelmaydi.

Biroq har bir yozilgan funksiyaga ko'r-ko'rona sync.Pool qo'llash yoki ortiqcha optimallashtirish tavsiya etilmaydi. Eng to'g'ri birinchi qadam — profillash: go test -bench . -benchmem orqali amallarning xotira sarfini ko'rish, go tool pprof yordamida esa dasturning heap profilini tahlil qilish lozim.

Struct joylashuvi va maydonlar tartibi (Alignment)

Yana bir muhim tafsilot — struct hajmi shunchaki maydonlar o'lchami yig'indisi emas, u maydonlar tartibiga va xotira tekislanishiga (alignment) bog'liq. Go kompilyatori struct maydonlarini padding (bo'sh baytlar) bilan to'ldiradi, shunda har bir maydon o'z o'lchamiga karrali manzildan boshlanadi (masalan, int64 8 baytli chegaraga tushishi shart). Keling, solishtiramiz:

example.go
type Bad struct {
    A bool  // 1 bayt
    B int64 // 8 bayt
    C bool  // 1 bayt
}

A 1 bayt egallaydi, ammo B 8 baytli chegaradan boshlanishi uchun kompilyator undan keyin 7 bayt padding qo'shadi. C yana 1 bayt oladi, va butun struct hajmi 8 ga karrali bo'lishi uchun oxiriga yana 7 bayt qo'shiladi. Natijada — atigi 10 bayt ma'lumot saqlash uchun 24 bayt xotira sarflanadi. Maydonlarni o'lchami kamayish tartibida joylashtirish bo'shliqlarni bartaraf etadi:

example.go
type Good struct {
    B int64 // 8 bayt
    A bool  // 1 bayt
    C bool  // 1 bayt, plyus 6 bayt oxirgi padding
}

Bu o'zgarish struct hajmini 16 baytga tushiradi. Bir nechta nusxa uchun bu sezilmasligi mumkin, lekin millionlab elementlardan iborat slays yoki ma'lumotlar bazasi yozuvlarida har bir elementdan 8 bayt tejalishi xotirani tejash bilan birga CPU keshidan samarali foydalanishga (cache locality) olib keladi. Buni avtomatik aniqlash uchun fieldalignment analizatoridan foydalanish mumkin.

Xulosa va GC bog'liqligi

Heap'ga tushgan barcha xotira oxir-oqibat Garbage Collector (GC) tomonidan qaytarib olinishi kerak: Go ko'p oqimli, bir vaqtda ishlaydigan tri-color mark-and-sweep GC'dan foydalanadi. Ushbu mavzuning davomi bo'lgan 25-bobda GC qanday ishlashi va uni boshqarish (GOGC va GOMEMLIMIT) batafsil ko'rib chiqiladi. Ushbu bobdan olinadigan asosiy xulosa: har bir heap ajratishi — kelajakdagi GC uchun resurs yuki demakdir, va stack/heap taqsimotini to'g'ri tushunish dasturning barqaror va tez ishlashini ta'minlaydi.