Ushbu kitobdagi boshqa har bir bob to'g'ri ketma-ket kod yozish haqida edi. Bu bob esa qiziqarli dasturlarning aksariyati umuman ketma-ket emasligini tan olish haqida. Veb-server minglab so'rovni bir vaqtda qayta ishlaydi. Build vositasi mustaqil paketlarni parallel kompilyatsiya qiladi. Log protsessori soketdan o'qiydi va bir vaqtning o'zida diskka yozadi. Go 2007-yilda Google'dagi doskada chizilgan birinchi eskizlaridanoq quyidagi g'oya atrofida loyihalashtirilgan: concurrency tilga yopishtirilgan kutubxona bo'lmasligi, tilning o'zining bir qismi bo'lishi va uni oxirgi chora sifatida emas, sukut bo'yicha ishlatadigan darajada arzon bo'lishi kerak.
Bu dizayn qarori odamlar Goni tanlashining eng katta yagona sababi. U shuningdek odamlar Goda yozadigan nozik xatolarning aksariyatining manbai. Bu bob poydevorni yotqizadi: goroutine aslida nima, runtime uni qanday rejalashtiradi va channel'lar goroutine'larga bir-biriga xalaqit bermasdan qanday muloqot qilish imkonini beradi. Kitobning ushbu qismidagi qolgan hamma narsa — andozalar, sinxronizatsiya primitivlari, bekor qilish — shu asoslarni to'g'ri o'zlashtirishga tayanadi.
Har qanday Go sintaksisiga o'tishdan oldin, Rob Pike odamlarga yillar davomida tushuntirib kelgan farqni aniq aytib o'tish kerak: concurrency tuzilma haqida, parallelizm bajarish haqida. Concurrent dastur — muvofiqlashtirilishi kerak bo'lgan mustaqil bajariladigan bo'laklardan tuzilgan dastur. U bu bo'laklarning hammasini bitta CPU yadrosida navbatma-navbat bajarib ham, oltmish to'rtta yadro bo'ylab yoyib ham ishga tushirishi mumkin. Bu sodir bo'ladimi-yo'qmi, dastur dizaynining emas, uskuna va runtime'ning xususiyati.
Bu amalda muhim, chunki goroutine'lar aslida qanday muammoni hal qilishini ko'rsatadi. Goroutine'ni "ko'proq yadrolardan foydalanmoqchi" bo'lganingiz uchun ishlatmaysiz — mustaqil ish birliklari bersangiz, Go runtime'i buni o'zi avtomatik qiladi. Goroutine'ni muammoingiz tabiiy concurrent tuzilishga ega bo'lgani uchun ishlatasiz: yangi ulanishlarni qabul qilishi va shu bilan birga eskilariga xizmat ko'rsatishi kerak bo'lgan server, bir bosqich ma'lumot ishlab chiqarayotganda boshqasi iste'mol qiladigan konveyer, beshta HTTP so'rovni yuborib, eng oxirgisi tugashini kutishi kerak bo'lgan mijoz.
Goroutine — uni ishga tushirgan funksiyadan mustaqil ishlaydigan funksiya. Uni ishga tushirish funksiyani odatdagidek chaqirishdan faqat bitta kalit so'zga farq qiladi:
go doSomething()
Shuncha: yaratish uchun thread obyekti yo'q, sozlash uchun pool yo'q, sukut bo'yicha aniq join yo'q. go operatori funksiyaning argumentlarini chaqiruvchi goroutine'da hisoblaydi, so'ng bajarilishni yangi yaratilgan goroutine'ga topshiradi va yangi goroutine biror narsa qilishini kutmasdan darhol qaytadi.
package main
import (
"fmt"
"time"
)
func main() {
go printNumbers()
go printLetters()
time.Sleep(time.Second) // goroutine'larga ishlash imkonini berish
}
func printNumbers() {
for i := 0; i < 5; i++ {
fmt.Println(i)
time.Sleep(100 * time.Millisecond)
}
}
func printLetters() {
for _, c := range "abcde" {
fmt.Println(string(c))
time.Sleep(150 * time.Millisecond)
}
}
maindagi time.Sleep ataylab yomon g'oya, va uni keyingi bobda yaxshiroq narsa bilan almashtirishdan oldin nima uchun ekanini tushunish kerak. mainning qaytishi dasturni darhol tugatadi: u goroutine'larning tugashini kutmaydi, ularning chiqishini bo'shatmaydi, shunchaki to'xtaydi. "Yetarlicha uzoq" uxlash — taxmin, vaqt haqidagi taxminlar esa aynan noutbukingizda ishonchli ishlaydigan, yuk ostida production'da vaqti-vaqti bilan muvaffaqiyatsiz bo'ladigan narsa. Bu yerda to'g'ri vosita — sync.WaitGroup, u sinxronizatsiya bobida chuqur yoritilgan. Hozircha shuni eslab qoling: dastur siz aytmaguningizcha goroutine'larini kutmaydi.
"Yengil" so'zi goroutine'larga shunchalik tez-tez yopishtiriladiki, ma'nosini yo'qotib qo'ygan, shuning uchun u sizga aniq nima berishini aytib o'tish kerak. OS thread'i odatda boshidanoq bir dan sakkiz megabaytgacha stack joyini zaxiralaydi, chunki OS sizning chaqiruv stack'ingiz qanchalik chuqur bo'lishini bilmaydi va eng yomon holatga tayyorlanishi kerak. Goroutine taxminan ikki kilobaytlik stack bilan boshlanadi, Go runtime'i esa goroutine'ning chaqiruv chuqurligi o'zgargan sari bu stack'ni dinamik ravishda kengaytiradi va qisqartiradi (to'lganda uni kattaroq joyga nusxalaydi). Amaliy ta'siri: yuz ming goroutine ishga tushirish oddiy holat; yuz ming OS thread'ini ishga tushirishga urinsangiz, aksariyat tizimlarning xotirasi yoki thread chegarasi ungacha tugab qoladi.
Yo'qoladigan yana bir xarajat — kontekst almashtirish (context switching) overhead'i. OS thread'lari orasida almashish yadro orqali "sayohat"ni anglatadi: registrlarni saqlash, scheduler ma'lumotlar tuzilmalarini yangilash, ehtimol CPU keshlarini tozalash. Goroutine'lar orasida almashish butunlay userspace'da, Go runtime'ining o'z scheduler'i tomonidan boshqariladi va OS darajasidagi almashish narxining kichik bir ulushiga tushadi. Aynan shu narsa goroutine-har-bir-ulanish yoki goroutine-har-bir-so'rov andozalarini Goda amalga oshirish mumkin qiladi — arzon concurrency'siz tillarda thread-har-bir-so'rov dizayni esa yomon ishlaydi.
Go runtime'i ixtiyoriy sondagi goroutine'larni (G) mantiqiy protsessorlar (P) orqali muvofiqlashtirilgan, soni kamroq OS thread'lariga (M) taqsimlaydi — odatda G-M-P modeli deb yoziladi. Har bir P bajarishga tayyor goroutine'larning lokal navbatini ushlab turadi va bir vaqtning o'zida ko'pi bilan bitta M'ga bog'langan holda Go kodini ishga tushiradi. P'lar soni sukut bo'yicha GOMAXPROCSga teng, u esa o'z navbatida runtime aniqlagan mantiqiy CPU'lar soniga teng. Shuning uchun sakkiz yadroli mashinada, qancha ming goroutine mavjud bo'lishidan qat'i nazar, odatda Go kodini haqiqiy parallel ishga tushira oladigan sakkizta P bo'ladi. Bu qiymatni aniqlash Go 1.25 da ancha aniqroq bo'ldi: Linux'da sukut bo'yicha endi jarayon aslida ishlayotgan cgroup CPU bandwidth chegarasi ham hisobga olinadi, faqat xostning jismoniy yadro soni emas. 64 yadroli xostdagi "2 yadro" CPU chegarali konteyner avval sodda Go jarayoniga sukut bo'yicha GOMAXPROCS=64 berardi. Bu mashina uchun texnik jihatdan to'g'ri edi, lekin jarayon aslida parallel ishlata oladigan narsa uchun juda noto'g'ri, va konteynerlashtirilgan joylashtirishlarda scheduler overhead'i va throttling bilan bog'liq kutilmagan muammolarning haqiqiy manbai edi. Endi runtime ishga tushirish davomida mavjud CPU'lar yoki cgroup chegarasi o'zgarsa, GOMAXPROCSni davriy ravishda qayta tekshiradi va sozlaydi ham — konteynerning CPU kvotasi qayta ishga tushirishsiz o'zgarishi mumkin bo'lgan avtomatik masshtablash muhitlari uchun foydali. Buni o'chirish mumkin (eski xost bo'yicha xatti-harakatga qaytish): GOMAXPROCSni muhit o'zgaruvchisi orqali aniq belgilash yoki GODEBUG=containermaxprocs=0 / updatemaxprocs=0 bilan. Yangi runtime.SetDefaultGOMAXPROCS funksiyasi esa dasturdagi boshqa narsa GOMAXPROCSni bekor qilgan bo'lsa, runtime'ning avtomatik standartini dasturiy tarzda tiklaydi.
Scheduler'ning ishi — berilgan lahzada qaysi goroutine qaysi P'da ishlashini hal qilish va, muhimi, goroutine bloklanganda nima bo'lishini boshqarish. Agar goroutine channel amalida yoki mutex'da bloklansa, scheduler uni shunchaki to'xtatib qo'yadi va shu P'da boshqa tayyor goroutine'ni ishga tushiradi; hech bir OS thread'i kutib bo'sh turmaydi. Agar goroutine bloklovchi syscall qilsa (aytaylik, yadroni kutishi kerak bo'lgan fayl o'qish), runtime M'ni uning P'sidan ajratadi, shunda boshqa M shu P'ni olib, boshqa goroutine'larni ishga tushirishda davom etishi mumkin. Bloklangan thread syscall qaytguncha shunchaki kutib turadi, lekin boshqa joyda progressni to'xtatmaydi. Aynan shu mexanizm bir necha CPU yadroli Go dasturiga tarmoq I/O'sini kutayotgan o'n minglab goroutine'ni bemalol boshqarish imkonini beradi.
G-M-P haqida kamdan-kam to'g'ridan-to'g'ri o'ylashingiz kerak bo'ladi, lekin bu model amalda ko'radigan xatti-harakatni tushuntiradi: nima uchun runtime.GOMAXPROCS(1) bilan ham goroutine'lar haqiqiy concurrent (parallel bo'lmasa ham) ishlay oladi, nima uchun hech qachon yield qilmaydigan CPU-bound goroutine'lar eski Go versiyalarida ba'zan boshqalarni "och qoldirishi" mumkin edi (Go 1.14+ da runtime'ning asinxron preemption nuqtalari bilan bu muammo asosan hal qilingan) va nima uchun bloklovchi syscall'lar siz ular uchun maxsus kod yozmasdan ham to'g'ri qayta ishlanadi.
Goroutine'lar concurrent bajarish birliklari bo'lsa, channel'lar ularning qanday muloqot qilishini belgilaydi. Channel — tipli quvur: qanday turdagi qiymat oqib o'tishini siz e'lon qilasiz, kompilyator esa buni ikkala uchida ham majburlaydi:
ch := make(chan int) // int'ning buferlanmagan channel'i
ch2 := make(chan int, 10) // buferli channel, sig'imi 10
Yuborish va qabul qilish bir xil operatordan foydalanadi, joylashuvi bilan ajratiladi:
ch <- 42 // yuborish
value := <-ch // qabul qilish
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 42
}()
value := <-ch
fmt.Println(value) // 42
}
Bu kichik misol muhim kafolatni yashiradi. ch buferlanmagan bo'lgani uchun goroutine ichidagi yuborish maindagi qabul qilish tayyor bo'lmaguncha tugay olmaydi — ikkala amal uchrashadi (rendezvous). Odamlar buferlanmagan channel'larni "sinxron" deganda shuni nazarda tutadi: muvaffaqiyatli yuborish receiver uni olish uchun u yerda bo'lganining isboti, va aksincha. Buferli channel bu kafolatni yumshatadi: yuborish faqat bufer to'lgandan keyin bloklanadi, shuning uchun yuboruvchi va receiver bir-biriga nisbatan biroz sinxronsiz ishlashi mumkin. Ikkalasidan biri umuman "yaxshiroq" emas — farq dizayn tanlovi bo'lib, unga keyingi bobda, tanlov aniq andoza uchun haqiqatan muhim bo'lganda qaytamiz.
Channel'ni boshqa qiymat kelmasligini bildirish uchun yopish mumkin:
close(ch)
Yopilgan channel'dan qabul qilish hech qachon bloklanmaydi. Agar buferlangan qiymatlar qolgan bo'lsa, avval ularni tartib bilan olasiz; channel bo'shagach, keyingi har bir qabul qilish darhol nol qiymatni qaytaradi. Ikki qiymatli qabul qilish shakli qaysi holatda ekaningizni aytadi:
value, ok := <-ch
if !ok {
// channel yopilgan va bo'shagan — value nol qiymat
}
Aynan shu narsa for rangeni channel ustida ishlab chiqaruvchi tugaguncha natijalar oqimini iste'mol qilishning tabiiy usuli qiladi:
for v := range ch {
fmt.Println(v) // ch yopilib, bo'shagach, avtomatik to'xtaydi
}
Yopish haqidagi ikki qoidani yodda tutish kerak, chunki ularni buzish kompilyatsiya xatosi emas, ish vaqtidagi panic bo'ladi. Channel'ni faqat yuboruvchi yopishi kerak (receiver boshqa goroutine hamon yubormoqchi ekanini bilishning yo'li yo'q), va allaqachon yopilgan channel'ni yopish panic qiladi, yopilgan channel'ga yuborish ham. Bir nechta goroutine hammasi "yuborishni tugatdim" lahzasiga yetishi mumkin bo'lganda, birinchi tugatgan goroutine closeni shartsiz chaqirishiga yo'l qo'yish o'rniga, yopishning o'zini muvofiqlashtiring — sync.Once bilan yoki yopishga yagona maxsus goroutine egalik qiladigan qilib qayta tuzish orqali.
Yangi Go dasturchilari ko'pincha channel'larning bloklanishini aylanib o'tish kerak bo'lgan to'siq deb his qiladi, aslida esa bu channel'larni muvofiqlashtirish uchun foydali qiladigan mexanizm. Buferlanmagan yuborish kimdir qabul qilmaguncha bloklanadi; qabul qilish kimdir yubormaguncha bloklanadi; buferli yuborish bufer to'lgach bloklanadi. Bularning har biri qandaydir goroutine'ni to'xtatadi, lekin hech qachon xotirani buzmaydi yoki yirtilgan (torn) o'qishni keltirib chiqarmaydi — bloklashning muqobili, oxir-oqibat, data race.
select bir vaqtning o'zida bir nechta imkoniyat ko'rib chiqilayotganda bitta channel'da bloklanib qolishdan qochish usuli:
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() { time.Sleep(50 * time.Millisecond); ch1 <- "from ch1" }()
go func() { time.Sleep(80 * time.Millisecond); ch2 <- "from ch2" }()
for i := 0; i < 2; i++ {
select {
case msg := <-ch1:
fmt.Println(msg)
case msg := <-ch2:
fmt.Println(msg)
case <-time.After(1 * time.Second):
fmt.Println("timeout")
}
}
}
select qaysi case tayyor bo'lsa, shuni tanlaydi; agar bir nechtasi bir vaqtda tayyor bo'lsa, u bir tekis tasodifiy tanlaydi. Bu manba tartibidagi birinchi caseni doim afzal ko'radigan dasturning boshqalarini "och qoldirishi"ning oldini olish uchun ataylab qilingan dizayn qarori. default case qo'shish bloklovchi amalni bloklamaydigan so'rovga aylantiradi — foydali, lekin urinishlar orasida uxlash yoki boshqalarga imkon berishni unutsangiz, uni band-kutish (busy-wait) siklga aylantirib yuborish oson.
Haqiqiy Go dasturlarida uchraydigan concurrency xatolarining aksariyati uchta toifaga to'g'ri keladi:
context yoki alohida to'xtatish kanali) mavjud bo'lishi shart.fatal error: all goroutines are asleep - deadlock! xabari bilan dasturni to'xtatadi.var counter int
func increment() {
counter++ // atomik emas: bir vaqtda bir nechta chaqiruvda data race yuzaga keladi
}
func main() {
for i := 0; i < 1000; i++ {
go increment()
}
time.Sleep(time.Second)
fmt.Println(counter) // natija ko'pincha 1000 chiqmaydi!
}
Go poyga holatlarini aniqlash uchun ajoyib o'rnatilgan vositaga ega: go test -race ./... yoki go run -race main.go. Bu rejimda runtime barcha xotira murojaatlarini tekshirib, poyga holatlarini aniqlaydi.
Haqiqiy concurrent Go kodida har bir vazifa uchun alohida cheksiz goroutine ochish to'g'ri kelmaydi. Cheksiz goroutine'lar tizim xotirasini to'ldirib yuborishi yoki ma'lumotlar bazasi ulanishlarini tugatishi mumkin. Worker pool andozasi bir vaqtda ishlaydigan goroutine'lar sonini qat'iy chegaralaydi:
const numWorkers = 10
jobs := make(chan Item, len(items))
results := make(chan Result, len(items))
for w := 0; w < numWorkers; w++ {
go func() {
for item := range jobs { // jobs yopilib, bo'shagach avtomatik tugaydi
results <- process(item)
}
}()
}
for _, item := range items {
jobs <- item
}
close(jobs) // worker'larga boshqa ish kelmasligini bildiradi
Bu yerda jobs kanali barcha topshiriqlar yuklangandan so'ng asosiy goroutine tomonidan bir marta yopiladi. Har bir worker esa for range jobs orqali ishlar tugagach toza yakunlanadi.
Java va C++ tillarida thread'lar operatsion tizim resurslariga to'g'ridan-to'g'ri bog'liq bo'lib, ularni yaratish qimmat. Shuning uchun u tillarda thread'lar doimiy qulflar (locks/mutex) bilan himoyalanadi.
Go esa mashhur tamoyilga tayanadi: "Xotirani ulashish orqali muloqot qilmang; aksincha, muloqot qilish (channel'lar) orqali xotirani ulashing" (Don't communicate by sharing memory; share memory by communicating). Goroutine'lar juda yengil bo'lgani sababli, ularni minglab ochish va muloqotini channel'lar bilan tashkil qilish idiomatik hisoblanadi.
Pythonning GIL (Global Interpreter Lock) cheklovidan farqli o'laroq, Goda GOMAXPROCS yordamida CPU-og'ir vazifalar haqiqatan ham bir nechta protsessor yadrolarida to'liq parallel bajariladi. JavaScript'dagi bitta thread'li event loop'dan farqli o'laroq, Goda goroutine'lar preemption orqali to'xtatilib, boshqa goroutine'larga navbat beriladi.
Pythonning threading moduli Global Interpreter Lock'ga (GIL) duch keladi. U yadrolar soniga qaramay thread'lar bo'ylab Python bytecode bajarilishini ketma-ketlashtiradi — ya'ni thread'lar I/O-bound ishga yordam beradi, CPU-bound ishga emas, va Pythonda haqiqiy parallelizm odatda multiprocessing degani. Go goroutine'larida bunday cheklov yo'q: GOMAXPROCS birdan katta bo'lganda CPU-bound goroutine'lar haqiqatan alohida yadrolarda parallel bajariladi.
JavaScript'ning async/awaiti va Goning goroutine'lari ikkalasi ham asinxron amallar atrofida ketma-ket o'qiladigan kod yozish imkonini beradi, lekin ularning asosiy modellari farq qiladi. JavaScript'ning concurrency'si sukut bo'yicha kooperativ va bitta thread'li: async funksiya faqat await nuqtalarida yield qiladi va oradagi vaqtda shu thread'da boshqa hech narsa ishlamaydi. Goroutine'lar esa Go scheduler'i tomonidan preempt qilinishi mumkin va yadrolar bo'ylab haqiqatan parallel ishlashi mumkin; yagona event loop yo'q va JS callback'ida ko'pincha bo'lgani kabi "bu ikki qator orasida hech narsa bu ma'lumotga tegmaydi" degan yashirin kafolat ham yo'q.
Bu farqlarni tushunish trivia sifatida emas, balki bir tilda idiomatik bo'lgan andozalar — yaratish overhead'idan qochish uchun o'lchamlangan thread pool'lar yoki bitta thread'li bajarilishni faraz qiladigan async funksiyalar — Goga o'zgartirishsiz ko'chmasligini tushuntirgani uchun muhim. Channel andozalari haqidagi keyingi bob bu yerda kiritilgan qismlarga to'g'ridan-to'g'ri quriladi: konveyerlar, fan-out va fan-in — hammasi tanish shakllarda joylashtirilgan goroutine'lar va channel'lardan boshqa narsa emas.