Goroutinelar, channellar, mutexlar va context — alohida olinganda har biri tushunish uchun sodda. Concurrent Go dasturlashda eng qiyin jihat primitivlarning o'zida emas, balki ularni bir-biri bilan to'g'ri uyg'unlashtirishda: resurslarni sizdirmaslik (leak), quyi oqim tizimlariga haddan tashqari yuklama bermaslik va to'g'ri ishlashiga hech kim (ayniqsa testlar) kafolat bera olmaydigan chalkash arxitekturaga tushib qolmaslikdir. Ushbu bob yangi sintaksis haqida emas, balki shunchaki ishlaydigan concurrent kodni production muhitida chinakam ishonchli concurrent tizimlardan ajratib turuvchi muhandislik intizomi haqidadir.
Goroutine leak — goroutine hech qachon blokdan chiqmaydigan holatda abadiy qolib ketganda yuzaga keladi. U na dasturni crash qiladi, na biror foydali ish bajaradi; shunchaki to'xtab turadi va dastur ishini tugatguncha operativ xotirada stack hajmini egallab yotaveradi. Klassik xato ko'rinishi:
func leak() {
ch := make(chan int)
go func() {
ch <- 42 // abadiy bloklanadi: hech kim bu qiymatni qabul qilmaydi
}()
// funksiya qaytadi; yuqoridagi yuborish hech qachon tugamaydi
}
Bu yerda hech qanday panic yuz bermaydi va hatto xatolik log ham qilinmaydi. Goroutine shunchaki ch <- 42 qatorida cheksiz bloklanib qoladi, chunki leak funksiyasi channeldan ma'lumot o'qimasdan chiqib ketgan. Agar bu funksiya siklda chaqirilsa — masalan, har bir HTTP so'rov handlerida — har bir so'rov uchun bittadan goroutine xotirada qolib ketaveradi (leak bo'ladi). Natijada xotira hajmi sekin va sezilmas tarzda o'sib boradi va bu muammoni faqat go tool pprof goroutine profili yordamida, odatda server ishdan chiqqandan keyingina payqash mumkin bo'ladi.
Bu xatoni tuzatishning asosiy qoidasi: har bir goroutine dasturning qolgan qismi nima qilishidan qat'i nazar, o'z ishini yakunlashning kafolatlangan yo'liga ega bo'lishi shart.
func noLeak() {
ch := make(chan int)
go func() {
ch <- 42
}()
<-ch // yuqoridagi yuborish muvaffaqiyatli tugashini kafolatlaydi
}
Haqiqiy loyihalarda bu kafolatlangan yo'l odatda goroutine o'z amallari bilan bir qatorda tekshirib boradigan context.Context yoki butun operatsiya yakunlanganda yopiladigan maxsus done channel'i orqali tashkil etiladi — yuqoridagi sodda misoldagidek chaqiruvchining o'sha zahoti ma'lumotni o'qib olishiga tasodifan ishonib qolish orqali emas. Har safar go func() { ... }() yozganingizda o'zingizga aniq savol bering: har qanday mumkin bo'lgan vaziyatda — muvaffaqiyat, xatolik, chaqiruvchining timeout'i yoki bekor qilinishida — bu goroutine o'z ishini to'xtatish yo'liga egami? Agar bu savolga javob berish uchun bir nechta murakkab shartlarni kuzatish talab etilsa, demak goroutinening chiqish shartini soddaroq va aniqroq qilish kerak, odatda unga to'g'ridan-to'g'ri context.Context uzatish eng to'g'ri yechimdir.
Channellarni yopish ham xuddi shunday mas'uliyatni talab qiladi. Channelga ma'lumot chiqaruvchi konveyer bosqichi uni har doim — har qanday chiqish yo'lida (muvaffaqiyatda ham, xatoda ham) — yopishi kerak; ko'pincha channel yaratilishi bilanoq defer close(out) qo'yiladi. Shunda o'sha channeldan for range orqali ma'lumot o'qiyotgan iste'molchi hech qachon kelmaydigan qiymatni cheksiz kutib o'tirmasdan, siklni xavfsiz yakunlay oladi.
Umumiy goroutine profili goroutinelar sonini va ular qayerda yaratilganini ko'rsatadi, ammo ulardan qaysilari haqiqatan tiqilib qolgani va qaysilari shunchaki o'z navbatini kutayotganini aniq ajratib bermaydi — buni bilish uchun dasturchilar odatda stack tracelarni qo'lda o'qib chiqishga majbur bo'lardi. Go 1.26 versiyasida buning uchun maxsus diagnostika qo'shildi: runtime/pprofdagi goroutineleak profili turi (Go 1.26 da eksperimental, Go 1.27 da to'liq barqaror). U concurrency primitivlarida bloklangan va blokdan chiqish yo'li qolmagan goroutinelarni aniq ko'rsatib beradi — bu shunchaki stack nusxasi emas, balki bloklanish grafigi bo'yicha erishuvchanlik (reachability) tahlilidir. Agar loyihaga net/http/pprof ulangan bo'lsa, u /debug/pprof/goroutineleak HTTP endpointi orqali ham taqdim etiladi.
Backpressure — ma'lumotni tez ishlab chiqaruvchi (producer) uni sekin qayta ishlovchi iste'molchi (consumer) bilan to'qnashganda yuzaga keladi. Tizim bu tezliklar tafovutini qandaydir yo'l bilan boshqarishi shart: ishlab chiqaruvchini sekinlashtirish (bloklash), ortiqcha kelgan qiymatlarni tashlab yuborish (drop) yoki ularni buferlash. Cheklanmagan (unbounded) bufer deyarli hech qachon to'g'ri yechim bo'la olmaydi — u muammoni hal qilmaydi, shunchaki xotira to'lib tizim qulaguncha vaqtni cho'zib turadi, xolos. Shuning uchun puxta loyihalangan Go kodlarida backpressure qat'iy nazorat qilinadi, bunda ko'pincha channelning tabiiy bloklanish mexanizmi sekinlashtiruvchi vosita sifatida ishlatiladi.
func processWithBackpressure(in <-chan int, workers int) <-chan int {
out := make(chan int, workers) // kichik, aniq o'lchamli bufer
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
for n := range in {
out <- process(n) // out buferi to'lsa, shu yerda bloklanadi
}
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}
out channelining buferi tasodifiy katta son emas, balki workers soniga teng qilib belgilangan: u har bir workerga sekin iste'molchida to'xtab qolmasdan bitta natija yuborish imkonini beradi, ortig'ini emas. Iste'molchi sekinlashganda out <- process(n) qatorida bloklanish yuzaga keladi, bu esa workerlarni to'xtatadi, ular esa o'z navbatida in channelidan yangi vazifalar olishni to'xtatadi. Mana shu o'zaro bog'liq bloklanishlar zanjiri backpressure mexanizmining aynan ko'zlanganidek ishlashidir: u sekinlashuvni cheksiz navbatlarga to'plab xotirani to'ldirish o'rniga, to'g'ridan-to'g'ri ma'lumot manbaiga (kirish oqimiga) qaytaradi.
Xuddi shu tamoyil konveyerlardan tashqarida ham amal qiladi. Qat'iy belgilangan goroutinelar soniga ega bo'lgan worker pool — tashqi tizimlarni (ma'lumotlar bazasi, tashqi API) haddan tashqari yuklamadan asrovchi kuchli backpressure to'sig'idir. Chunki u har bir kiruvchi so'rovga nazoratsiz yangi goroutine ochib tashlash o'rniga, bir vaqtning o'zida bajarilishi mumkin bo'lgan amallar sonini qat'iy cheklab turadi.
Concurrent kodda "ish o'rtasida biron narsa xato ketsa nima bo'ladi?" degan savolga aniq javob bo'lishi kerak. Chunki ketma-ket (sinxron) koddan farqli o'laroq, bitta goroutinening to'xtab qolishi uning yonidagi boshqa goroutinelarni avtomatik to'xtatmaydi. Qolaversa, ushlab qolinmagan (unrecovered) panic faqat o'sha goroutineni emas, butun dasturni to'xtatadi (crash qiladi). Shu sababli kutilmagan xatolar yuz berishi mumkin bo'lgan har qanday mustaqil goroutineda alohida defer/recover bloki bo'lishi va panic holati channel orqali qaytariladigan toza errorga aylantirilishi maqsadga muvofiqdir.
Concurrent amallardagi xatoliklar ham asosiy natijalar kabi o'z egasiga yetib borishi zarur — odatda natija struct'iga error maydonini qo'shish yoki asosiy ish channeli bilan birga select orqali kuzatiladigan xatoliklar channeli (errCh) orqali. Agar parallel ishlayotgan vazifalarning bittasi muvaffaqiyatsiz tugasa va umumiy natijaga erishish endi imkonsiz bo'lsa, ularning barchasi foydalanayotgan umumiy context.Context bekor qilinishi shart; shunda qolgan goroutinelar endi hech kimga kerak bo'lmagan behuda ishni davom ettirmasdan, zudlik bilan to'xtaydi.
Concurrency xatolarini qo'lda takrorlash juda qiyin — masalan, data race xatosi faqat operatsion tizim va Go schedulerining muayyan rejalashtirish tasodifida, mingta ishga tushirishdan bittasida sodir bo'lishi mumkin. go test, go run yoki go build buyruqlarida -race bayrog'i orqali yoqiladigan race detector dasturning ishlash vaqtida xotiraga bo'lgan barcha murojaatlarni kuzatadi va poyga holati o'sha paytda ko'rinadigan nosozlikka olib kelmagan taqdirda ham, xavfli data race borligini ma'lum qiladi:
go test -race ./...
Buni CI/CD pipeline'ingizning ajralmas qismiga aylantiring: test bosqichida aniqlangan data race bir necha daqiqada tuzatiladi, production tizimlariga sizib o'tgan xuddi shu muammo esa tungi favqulodda chaqiruvlarga (on-call page) va takrorlash nihoyatda mushkul bo'lgan asabiy qidiruvlarga sabab bo'ladi.
Concurrency xususiyatlarini haqiqiy tekshiradigan test quyidagicha ko'rinishga ega bo'ladi:
func TestConcurrentSends(t *testing.T) {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); ch <- 1 }()
go func() { defer wg.Done(); ch <- 2 }()
go func() { wg.Wait(); close(ch) }()
var results []int
for n := range ch {
results = append(results, n)
}
if len(results) != 2 {
t.Errorf("expected 2 values, got %d", len(results))
}
}
E'tibor bering, bu test results massividagi elementlar tartibini tekshirmaydi: bir vaqtda ma'lumot yuboruvchi ikkita goroutinening qaysi biri birinchi kelishi oldindan kafolatlanmaydi. Tartibni tekshiradigan test beqaror (flaky) bo'lib qoladi — haqiqiy xato bo'lmasa ham, oqimlarning tasodifiy navbatiga qarab goh o'tadi, goh yiqiladi. Sifatli concurrent testlar qaysi xatti-harakatlar aniq kafolatlanganini (bu yerda ikkala qiymat yetib kelishi) va qaysilari scheduler ixtiyorida ekanini (ularning kelish tartibi) to'g'ri ajrata olishi va faqat qat'iy kafolatlangan natijalarnigina tekshirishi kerak.
Ushbu va oldingi boblardagi concurrency qoidalarini umumlashtirib, kodni production'ga chiqarishdan oldin o'zingizga quyidagi savollarni berishingiz tavsiya etiladi:
context.Context barcha uzoq davom etuvchi va I/O amallariga uzatilganmi? Va eng muhimi, u funksiyalarda shunchaki parametr bo'lib qolmasdan, haqiqatan tekshirilib, o'z vaqtida to'xtatilyaptimi?-race bilan muntazam tekshiriladimi?Bu savollarga ijobiy javob berish hech qanday g'ayritabiiy murakkablikni talab qilmaydi. Ular shunchaki ketma-ket Go kodida talab qilinadigan ehtiyotkorlik va resurslarni to'g'ri boshqarish intizomini parallel ishlaydigan tizimlarga ham izchil tatbiq etishni anglatadi. Keyingi bobda Goda vaqt bilan ishlash, timelar, timoutlar va ularning concurrency bilan o'zaro bog'liqligini ko'rib chiqamiz.