Concurrency va parallel dasturlash
Kompyuter bir vaqtning o‘zida brauzer, musiqa pleyeri va boshqa dasturlarni ishlatayotgandek ko‘rinadi. Buning ortida vazifalarni navbatlab boshqarish va bir nechta CPU yadrosida ayni paytda bajarish mexanizmlari turadi.
Bu darsda concurrency va parallelism o‘rtasidagi farqni ko‘rib chiqamiz. Shuningdek, Go runtime bu ishlarni goroutine va operatsion tizim oqimlari orqali qanday tashkil qilishini o‘rganamiz.
CPU, yadro va mantiqiy protsessor
CPU dastur buyruqlarini bajaradi. Zamonaviy CPU odatda bir nechta fizik yadroga ega. Har bir yadro boshqa yadro bilan bir paytda buyruq bajara oladi.
Ayrim protsessorlarda bir fizik yadro bir nechta apparat oqimini (hardware thread) yurita oladi. Bu texnologiya SMT deb ataladi. Operatsion tizim dasturlarga odatda fizik yadrolarni emas, mantiqiy protsessorlarni ko‘rsatadi.
Masalan, tizimda sakkizta mantiqiy protsessor bo‘lishi sakkizta vazifa har doim bir xil tezlikda bajariladi degani emas. Vazifalar CPU keshi, xotira, disk va tarmoq kabi umumiy resurslar uchun raqobat qilishi mumkin.
Operatsion tizim rejalashtiruvchisi (scheduler) tayyor oqimlarni mavjud mantiqiy protsessorlarga taqsimlaydi. Shu sabab brauzerning har bir oynasi yoki ishlayotgan har bir dastur alohida yadroga doimiy biriktirilmaydi. Ular bajarilish davomida yadrolar orasida ko‘chishi mumkin.
I/O va CPU bilan band ishlar
Dastur bajaradigan ishlarni kutish xususiyatiga qarab ikki guruhga ajratish foydali.
I/O-bound ish
I/O-bound ish vaqtining katta qismini tashqi operatsiya tugashini kutishga sarflaydi. Masalan:
- HTTP so‘roviga javob kutish;
- ma’lumotlar bazasidan natija olish;
- fayl o‘qish yoki yozish;
- foydalanuvchidan ma’lumot olish.
Bitta vazifa tarmoq javobini kutayotganida CPU boshqa vazifani bajara oladi. Shu sabab concurrency backend serverlar va tarmoq dasturlarida ayniqsa foydali.
CPU-bound ish
CPU-bound ish vaqtining katta qismini hisoblashga sarflaydi. Rasmni qayta ishlash, ma’lumotni siqish va katta sonlar ustida hisoblash shunga misol bo‘ladi.
Bunday ish mustaqil qismlarga bo‘linsa va bir nechta yadro mavjud bo‘lsa, parallel bajarish tezlikni oshirishi mumkin. Ammo vazifalarni bo‘lish, natijalarni birlashtirish va ular orasida ma’lumot uzatishning ham xarajati bor. Kichik ishni parallel qilish ba’zan ketma-ket bajarishdan sekinroq chiqadi.
Protsess, oqim va goroutine
Protsess (process) — operatsion tizim ishga tushirgan dastur nusxasi. Har bir protsess o‘z virtual xotira maydoniga va fayl deskriptori kabi resurslarga ega. Protsesslar orasida ma’lumot almashish uchun tarmoq, pipe yoki umumiy xotira kabi maxsus vosita kerak.
Oqim (thread) — protsess ichidagi operatsion tizim bajarilish birligi. Bir protsess oqimlari kod va heap xotirasini bo‘lishadi, ammo har bir oqim o‘z steki va bajarilish holatiga ega. Umumiy xotira aloqa qilishni osonlashtiradi, lekin bir vaqtda yozish data race keltirib chiqarishi mumkin.
Goroutine — Go runtime boshqaradigan yengil bajarilish birligi. Goroutine operatsion tizim threadining o‘zi emas. Runtime ko‘p goroutinelarni kamroq sondagi operatsion tizim threadlari ustida rejalashtira oladi.
| Tushuncha | Kim boshqaradi? | Xotira xususiyati |
|---|---|---|
| Protsess | Operatsion tizim | Alohida virtual xotira maydoni |
| Thread | Operatsion tizim | Protsess xotirasini boshqa threadlar bilan bo‘lishadi |
| Goroutine | Go runtime | Protsess xotirasini bo‘lishadi, kichik va o‘suvchi stekka ega |
Goroutine yaratish thread yaratishdan odatda arzonroq. Lekin u bepul emas: har bir goroutine stek, scheduler holati va ishlatayotgan resurslari uchun xotira talab qiladi. Nazoratsiz goroutine yaratish xotira sarfini oshirishi yoki goroutine leak holatiga olib kelishi mumkin.
Concurrency va parallelism farqi
Concurrency — bir nechta mustaqil vazifani bir davr ichida boshqaradigan dastur tuzilishi. Vazifalardan biri kutishga o‘tganda boshqasi davom etishi mumkin. Bitta CPU yadrosida ham vazifalarni navbatlab bajarish orqali concurrency mavjud bo‘ladi.
Parallelism — ikki yoki undan ortiq vazifaning aynan bir paytda bajarilishi. Buning uchun bir nechta mantiqiy protsessor kerak.
Tasavvur qiling, qahvaxonada bitta xodim bir nechta mijoz buyurtmasini boshqaradi. Qahva tayyor bo‘lishini kutayotgan paytda u keyingi mijozning buyurtmasini oladi. Bu concurrency. Ikki xodim ikki buyurtma ustida ayni paytda ishlasa, bu parallelism.
| Xususiyat | Concurrency | Parallelism |
|---|---|---|
| Asosiy maqsad | Bir nechta ishni samarali boshqarish | Bir nechta ishni ayni paytda bajarish |
| Bitta yadroda ishlaydimi? | Ha | Yo‘q |
| Ko‘p uchraydigan vazifa | I/O kutadigan server so‘rovlari | CPU talab qiladigan hisoblashlar |
| Asosiy foyda | Javobchanlik va resursdan foydalanish | Hisoblash vaqtini qisqartirish |
Concurrency dastur tuzilishiga, parallelism esa uning ma’lum paytdagi bajarilishiga taalluqli. Concurrent yozilgan dastur muhit va runtime sozlamasiga qarab parallel ishlashi ham mumkin.
flowchart LR
A["Concurrent dastur"] --> B["Go scheduler"]
B --> C["Bitta mantiqiy protsessor: navbatlab bajarish"]
B --> D["Bir nechta mantiqiy protsessor: parallel bajarish mumkin"]
Go’da birinchi concurrent dastur
Quyidagi dastur uchta xizmatdan ma’lumot olishni tasvirlaydi. time.Sleep haqiqiy tarmoq so‘rovi o‘rnidagi kutishdir.
package main
import (
"fmt"
"sync"
"time"
)
func fetch(service string, delay time.Duration) string {
time.Sleep(delay)
return service + " javobi"
}
func main() {
services := []string{"profil", "buyurtmalar", "tavsiyalar"}
delays := []time.Duration{30 * time.Millisecond, 10 * time.Millisecond, 20 * time.Millisecond}
results := make([]string, len(services))
var wg sync.WaitGroup
for i, service := range services {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = fetch(service, delays[i])
}()
}
wg.Wait()
for _, result := range results {
fmt.Println(result)
}
}
Natija:
go func() { ... }() anonim funksiyani yangi goroutineda ishga tushiradi. Sikl natijalar uchun oldindan alohida indeks ajratgan. Har bir goroutine faqat o‘z indeksiga yozadi. sync.WaitGroup esa main funksiyasini barcha ishlar tugaguncha kutdiradi.
So‘rovlar turli vaqtda tugasa ham, natijalar slice tartibida chiqariladi. Agar fmt.Println bevosita goroutine ichida chaqirilsa, satrlar tugash tartibida chiqadi va bu tartib ishga tushirishlar orasida o‘zgarishi mumkin.
Bu misolda uch ishning kutish vaqti ustma-ust tushadi. Haqiqiy dasturda umumiy vaqt eng sekin operatsiyaga, scheduler va boshqa xarajatlarga yaqin bo‘ladi. Bu aniq kafolat emas; vaqtni tekshirish uchun benchmark yoki o‘lchov kerak.
Info
Goroutine, WaitGroup va channel keyingi darslarda alohida o‘rganiladi. Bu yerda ular concurrency va parallelism farqini amalda ko‘rsatish uchun ishlatildi.
Go scheduler qanday ishlaydi?
Go runtime schedulerini ko‘pincha G–M–P modeli bilan tushuntirishadi:
- G — goroutine;
- M — operatsion tizim threadi;
- P — Go kodini bajarish uchun kerak bo‘ladigan runtime resursi.
Scheduler bajarishga tayyor goroutinelarni P orqali Mlarga biriktiradi. Goroutine channel, mutex, taymer yoki tarmoq I/O sabab kutsa, runtime boshqa tayyor goroutineni bajarishi mumkin. Ayrim bloklovchi tizim chaqiruvlarida thread band bo‘lib qolsa, runtime boshqa threaddan foydalanishi mumkin.
runtime.GOMAXPROCS bir paytda Go kodini bajara oladigan Plar sonini belgilaydi. U goroutinelar sonini cheklamaydi. Dastur yuzlab goroutine yaratishi, lekin ulardan faqat GOMAXPROCS miqdoridagisi ma’lum onda Go kodini parallel bajarishi mumkin.
Joriy qiymatni quyidagicha ko‘rish mumkin:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("Mantiqiy CPU:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
}
Natijadagi sonlar kompyuter va dastur ishlayotgan muhitga bog‘liq. runtime.GOMAXPROCS(0) qiymatni o‘zgartirmasdan joriy limitni qaytaradi.
Warning
GOMAXPROCSni mantiqiy CPU sonidan kattalashtirish CPU-bound kodni avtomatik tezlashtirmaydi. Ortiqcha parallelizm rejalashtirish va kesh xarajatini ko‘paytirishi mumkin.
Umumiy xotira va data race
Goroutinelar bir protsess xotirasini bo‘lishadi. Ikki goroutine bir xil o‘zgaruvchiga bir paytda murojaat qilsa, ulardan kamida bittasi yozish bajarsa va ular orasida sinxronizatsiya bo‘lmasa, data race yuz beradi.
Quyidagi kod ataylab noto‘g‘ri yozilgan:
// Noto‘g‘ri misol: bir nechta goroutine count qiymatiga
// sinxronizatsiyasiz yozadi.
// count++ bitta atomar amal emas.
// go func() {
// count++
// }()
count++ qiymatni o‘qish, oshirish va qayta yozish bosqichlaridan iborat. Bosqichlar aralashib ketsa, ayrim oshirishlar yo‘qoladi. Natija ba’zan to‘g‘ri chiqishi kod xavfsizligini isbotlamaydi.
Umumiy holatni channel orqali bitta goroutinega tegishli qilish yoki sync.Mutex va sync/atomic kabi sinxronizatsiya vositalaridan foydalanish kerak. Race detector test va dastur bajarilishida kuzatilgan racelarni topishga yordam beradi:
Race detector barcha mumkin bo‘lgan bajarilish yo‘llarini tekshirmaydi. Shuning uchun u xato topmasa, bu data race yo‘qligiga mutlaq kafolat bermaydi.
Keng tarqalgan xatolar
Concurrencyni tezlik bilan tenglashtirish
Goroutine qo‘shish kodni avtomatik tezlashtirmaydi. Ketma-ket qism, sinxronizatsiya, ma’lumot uzatish va scheduler xarajatlari umumiy natijaga ta’sir qiladi. Avval oddiy va to‘g‘ri yechim yozing, keyin benchmark va profiling bilan tor joyni aniqlang.
Cheksiz goroutine yaratish
Har bir HTTP so‘rovi yoki fayl uchun nazoratsiz goroutine yaratish tashqi xizmat, ma’lumotlar bazasi ulanishlari va xotirani tugatishi mumkin. Bir vaqtda bajariladigan ishlar sonini semaphore yoki worker pool bilan cheklash kerak.
Goroutine tugashini kutmaslik
main funksiyasi qaytsa, dastur boshqa goroutinelarni kutmay yakunlanadi. Ularning tugashini WaitGroup, channel yoki boshqa aniq sinxronizatsiya mexanizmi bilan kutish kerak. time.Sleep bilan taxminiy kutish ishonchli yechim emas.
Goroutine leak
Goroutine hech qachon kelmaydigan channel qiymatini, tugamaydigan I/O operatsiyasini yoki yuborilmaydigan signalni kutib qolishi mumkin. U ishlamasa ham resurslarini saqlab turadi. Uzoq davom etadigan operatsiyalarga bekor qilish signali va timeout berish kerak. Backend dasturlarda buning uchun ko‘pincha context.Context ishlatiladi.
Natija tartibiga tayanish
Scheduler goroutinelarni qaysi tartibda bajarishini kafolatlamaydi. To‘g‘ri natija ma’lum bajarilish tartibiga bog‘liq bo‘lmasligi kerak. Tartib zarur bo‘lsa, uni channel, indeks yoki boshqa sinxronizatsiya bilan dasturda ochiq belgilang.
Ishlash tezligini to‘g‘ri baholash
Parallel yechimni tanlashdan oldin quyidagilarni aniqlang:
- ish I/O-boundmi yoki CPU-boundmi;
- qismlar bir-biridan mustaqilmi;
- umumiy xotiraga xavfsiz murojaat qilinyaptimi;
- parallel ishlar soni cheklanganmi;
- xato, timeout va bekor qilish qanday tarqatiladi;
- ketma-ket va concurrent variant benchmarkda qanday natija beradi.
Tezlikdan tashqari kechikish (latency) va umumiy ishlov hajmi (throughput) farqini ham hisobga oling. Bitta so‘rovning tezroq tugashi latency kamayganini bildiradi. Bir soniyada ko‘proq so‘rov bajarilishi throughput oshganini bildiradi. Birini yaxshilash har doim ikkinchisini yaxshilamaydi.
Interviewda nimalarga e’tibor beriladi?
- concurrency — dastur tuzilishi, parallelism — bir paytda bajarilish xususiyati;
- goroutine operatsion tizim threadi emas, uni Go runtime rejalashtiradi;
GOMAXPROCSgoroutine sonini emas, parallel Go bajarilish imkonini boshqaradi;- umumiy xotiraga sinxronizatsiyasiz yozish data race keltirib chiqaradi;
- concurrency tezlikni kafolatlamaydi va goroutinelar soni nazorat qilinishi kerak.
Xulosa
Concurrency bir nechta mustaqil ishni bir davr ichida boshqaradi. Parallelism esa ulardan bir nechtasini ayni paytda bajaradi. I/O-bound dasturlar kutish vaqtida boshqa ishni davom ettirishdan, CPU-bound dasturlar esa mos sharoitda bir nechta yadrodan foyda ko‘rishi mumkin.
Go runtime goroutinelarni operatsion tizim threadlari ustida rejalashtiradi. To‘g‘ri concurrent dastur umumiy xotirani himoya qiladi, ishlar sonini cheklaydi va tugash yoki bekor qilish yo‘lini aniq belgilaydi. Keyingi darsda goroutine yaratish va uning hayot siklini batafsil ko‘rib chiqamiz.