Concurrency va parallel dasturlash
Kompyuterda bir vaqtning o‘zida brauzer, musiqa pleyeri, kod muharriri va boshqa dasturlar ishlayotgandek ko‘rinadi. Aslida buning ortida bir nechta turli mexanizm bor.
Ba’zi vazifalar CPU’da navbat bilan bajariladi. Bir vazifa vaqtincha kutishga o‘tsa, uning o‘rniga boshqa vazifa ishlashi mumkin. Boshqa holatda esa bir nechta CPU yadrosi turli vazifalarni haqiqatan ham ayni paytda bajaradi.
Shu ikki holatni tushunishda concurrency va parallelism tushunchalari muhim.
Bu darsda:
- concurrency va parallelism o‘rtasidagi farqni;
- CPU, yadro va mantiqiy protsessor tushunchalarini;
- I/O-bound va CPU-bound ishlarni;
- process, thread va goroutine farqini;
- Go runtime scheduleri qanday ishlashini;
GOMAXPROCSnimani boshqarishini;- umumiy xotirada data race qanday paydo bo‘lishini;
- concurrent dasturlarda ko‘p uchraydigan xatolarni
bosqichma-bosqich ko‘rib chiqamiz.
CPU, yadro va mantiqiy protsessor
CPU dastur buyruqlarini bajaradi. Zamonaviy CPU odatda bir nechta fizik yadroga ega.
Har bir fizik yadro boshqa yadro bilan bir paytda alohida buyruqlarni bajarishi mumkin. Masalan, to‘rtta fizik yadro bo‘lsa, mos sharoitda bir nechta hisoblash bir vaqtning o‘zida bajarilishi mumkin.
Lekin fizik yadro bilan operatsion tizim ko‘radigan CPU soni har doim bir xil bo‘lavermaydi.
Ayrim protsessorlarda bitta fizik yadro bir nechta apparat oqimini, ya’ni hardware threadni yurita oladi. Bunday texnologiya odatda SMT — Simultaneous Multithreading deb ataladi.
Operatsion tizim dasturlarga ko‘pincha fizik yadrolarni emas, mantiqiy protsessorlarni ko‘rsatadi.
Masalan, kompyuterda:
ko‘rinishi mumkin.
Bu sakkizta mustaqil vazifa har doim sakkiz baravar tez ishlaydi degani emas.
Sababi vazifalar faqat CPU vaqtini emas, boshqa umumiy resurslarni ham ishlatadi. Masalan:
- CPU cache;
- operativ xotira;
- xotira bandwidth’i;
- disk;
- tarmoq;
- boshqa apparat resurslari.
Shuning uchun parallel ishlar bir-biriga xalaqit berishi ham mumkin.
Operatsion tizimning scheduleri bajarishga tayyor threadlarni mavjud mantiqiy protsessorlarga taqsimlaydi.
Bu yerda muhim jihat shuki, brauzerning har bir oynasi yoki ishlayotgan har bir dastur alohida CPU yadrosiga doimiy biriktirib qo‘yilmaydi.
Masalan, bitta thread avval birinchi yadroda ishlashi, keyin scheduler qaroriga ko‘ra boshqa yadroda davom etishi mumkin.
Demak, CPU yadrolari va ishlayotgan vazifalar o‘rtasidagi bog‘lanish doimiy emas. Operatsion tizim ularni bajarilish davomida qayta taqsimlashi mumkin.
I/O va CPU bilan band ishlar
Dasturdagi barcha ishlar bir xil xarakterga ega emas.
Ularni vaqtning katta qismi nimaga sarflanishiga qarab ikki katta guruhga ajratish foydali:
- I/O-bound ish;
- CPU-bound ish.
Bu farq concurrency yoki parallelismdan qayerda foyda olish mumkinligini tushunishda juda muhim.
I/O-bound ish
I/O-bound ish vaqtining katta qismini CPU’da hisoblashga emas, tashqi operatsiya tugashini kutishga sarflaydi.
Masalan:
- HTTP so‘roviga javob kutish;
- ma’lumotlar bazasidan natija kelishini kutish;
- fayldan ma’lumot o‘qish;
- faylga yozish;
- socket orqali ma’lumot kelishini kutish;
- foydalanuvchidan ma’lumot olish.
Masalan, dastur boshqa serverga HTTP so‘rovi yubordi:
So‘rov yuborilgandan keyin dastur darhol natijani olmaydi. Javob tarmoq orqali qaytishi uchun vaqt kerak.
Shu kutish vaqtining katta qismida CPU ushbu vazifa uchun faol hisob-kitob bajarmaydi.
Agar dastur concurrent tuzilgan bo‘lsa, bir vazifa tarmoq javobini kutayotgan paytda CPU boshqa vazifani bajarishi mumkin.
Masalan:
1-vazifa: HTTP javobini kutmoqda
2-vazifa: database natijasini qayta ishlamoqda
3-vazifa: yangi requestni qabul qilmoqda
Shuning uchun concurrency ayniqsa:
- backend serverlarda;
- network dasturlarda;
- database bilan ko‘p ishlaydigan dasturlarda;
- ko‘p mustaqil I/O operatsiyasi mavjud tizimlarda
foydali bo‘ladi.
Bu yerda asosiy foyda har doim bitta operatsiyani tezlashtirish emas. Ko‘pincha maqsad CPU’ni bekor kutib turmasdan boshqa foydali ish bilan band qilishdir.
CPU-bound ish
CPU-bound ish vaqtining katta qismini haqiqiy hisoblashga sarflaydi.
Masalan:
- rasmni qayta ishlash;
- videoni kodlash;
- ma’lumotni siqish;
- kriptografik hisoblash;
- katta sonlar ustida matematik hisoblash;
- katta massivlar ustida murakkab algoritm bajarish.
Bunday vazifada CPU deyarli doim ish bilan band bo‘ladi.
Agar hisoblash bir-biridan mustaqil qismlarga bo‘linishi mumkin bo‘lsa va kompyuterda bir nechta CPU yadrosi mavjud bo‘lsa, ishlarni parallel bajarish umumiy vaqtni qisqartirishi mumkin.
Masalan:
Lekin bu yerda muhim bir cheklov bor.
Parallel ishlashning ham xarajati mavjud:
- vazifalarni qismlarga bo‘lish;
- goroutine yoki threadlarni boshqarish;
- ma’lumot uzatish;
- lock va boshqa sinxronizatsiya;
- natijalarni qayta birlashtirish;
- CPU cache bilan bog‘liq xarajatlar;
- scheduler ishlashi.
Shuning uchun juda kichik hisoblashni parallel qilish ba’zan ketma-ket bajarishdan ham sekinroq chiqishi mumkin.
Demak, “ko‘proq parallelizm = har doim tezroq” degan qoida yo‘q.
Buni benchmark va profiling orqali o‘lchash kerak.
Protsess, oqim va goroutine
Concurrency mavzusida process, thread va goroutine tushunchalarini bir-biridan ajratish kerak.
Ular bir-biriga bog‘liq, lekin bir xil narsa emas.
Protsess
Protsess (process) — operatsion tizim ishga tushirgan dastur nusxasi.
Masalan, terminalda dasturni ishga tushirsak:
operatsion tizim ushbu dastur uchun process yaratadi.
Har bir process odatda o‘z:
- virtual xotira maydoni;
- file descriptorlari;
- operatsion tizim resurslari;
- bajarilish holati
bilan ishlaydi.
Bitta process boshqa processning oddiy xotirasiga to‘g‘ridan-to‘g‘ri murojaat qilmaydi.
Processlar o‘rtasida ma’lumot almashish uchun maxsus vositalar kerak bo‘ladi. Masalan:
- network;
- pipe;
- socket;
- shared memory;
- boshqa IPC mexanizmlari.
Oqim
Oqim (thread) — process ichidagi operatsion tizim bajarilish birligi.
Bitta process ichida bir nechta thread bo‘lishi mumkin.
Ular processning umumiy resurslarini bo‘lishadi. Masalan, bir process ichidagi threadlar odatda:
- bir xil kodga;
- bir xil heap xotirasiga;
- umumiy global ma’lumotlarga
murojaat qila oladi.
Lekin har bir threadning o‘z:
- steki;
- bajarilayotgan instruction holati;
- register holati
bo‘ladi.
Umumiy xotira threadlar o‘rtasida ma’lumot almashishni osonlashtiradi.
Lekin shu bilan birga xavf ham paydo bo‘ladi.
Agar ikki thread umumiy o‘zgaruvchini bir vaqtda o‘zgartirsa va to‘g‘ri sinxronizatsiya bo‘lmasa, data race yuz berishi mumkin.
Goroutine
Goroutine — Go runtime boshqaradigan yengil bajarilish birligi.
Goroutineni operatsion tizim threadi bilan tenglashtirmaslik kerak.
Masalan:
yozganimizda Go har bir doWork() uchun alohida operatsion tizim threadini yaratishi shart emas.
Buning o‘rniga Go runtime ko‘p goroutinelarni kamroq sondagi operatsion tizim threadlari ustida rejalashtirishi mumkin.
Soddalashtirilgan ko‘rinish:
Goroutine 1 ─┐
Goroutine 2 ─┼──> Go runtime scheduler ──> OS threadlar
Goroutine 3 ─┤
Goroutine 4 ─┘
Shuning uchun Go dasturida minglab goroutine mavjud bo‘lishi mumkin, lekin bu minglab OS thread mavjud degani emas.
Quyidagi jadval farqni qisqacha ko‘rsatadi:
| Tushuncha | Kim boshqaradi? | Xotira xususiyati |
|---|---|---|
| Protsess | Operatsion tizim | Alohida virtual xotira maydoni |
| Thread | Operatsion tizim | Process xotirasini boshqa threadlar bilan bo‘lishadi |
| Goroutine | Go runtime | Process xotirasini bo‘lishadi, kichik va o‘suvchi stekka ega |
Goroutine yaratish odatda operatsion tizim threadini yaratishdan arzonroq.
Ammo bu goroutine mutlaqo bepul degani emas.
Har bir goroutine uchun kamida:
- stek;
- runtime holati;
- scheduler bilan bog‘liq ma’lumot;
- ishlatayotgan boshqa resurslar
kerak bo‘ladi.
Shuning uchun nazoratsiz ravishda juda ko‘p goroutine yaratish muammo keltirib chiqarishi mumkin.
Masalan:
kabi boshqarilmaydigan model xotira sarfini keskin oshirishi mumkin.
Bundan tashqari, goroutine hech qachon tugamasa, goroutine leak yuzaga kelishi mumkin.
Concurrency va parallelism farqi
Concurrency va parallelism ko‘pincha bir xil ma’noda ishlatiladi. Lekin ular bir xil tushuncha emas.
Concurrency
Concurrency — bir nechta mustaqil vazifani bir davr ichida boshqaradigan dastur tuzilishi.
Bu vazifalar aynan bir vaqtda ishlashi shart emas.
Masalan, bitta CPU yadrosi quyidagicha ishlashi mumkin:
Bu yerda:
- bir oz vaqt
Abajariladi; - keyin
B; - keyin yana
A; - keyin
C.
Bir vaqtning o‘zida faqat bitta vazifa CPU’da ishlayotgan bo‘lishi mumkin. Lekin bir nechta vazifa bitta vaqt oralig‘ida navbat bilan oldinga siljiydi.
Bu concurrency hisoblanadi.
Parallelism
Parallelism — ikki yoki undan ortiq vazifaning haqiqatan ayni paytda bajarilishi.
Masalan, ikkita mantiqiy protsessor mavjud bo‘lsa:
A va B vazifalari bir vaqtda bajarilishi mumkin.
Bu parallelism hisoblanadi.
Parallelism uchun bir nechta mantiqiy protsessor kerak.
Qahvaxona misoli
Farqni oddiy misolda ko‘ramiz.
Tasavvur qiling, qahvaxonada bitta xodim ishlayapti.
U:
- birinchi mijozning buyurtmasini oladi;
- qahva mashinasini ishga tushiradi;
- qahva tayyor bo‘lishini kutib turish o‘rniga ikkinchi mijozning buyurtmasini oladi;
- keyin yana birinchi buyurtmaga qaytadi.
Bu concurrency.
Bitta xodim bir nechta ishni navbat bilan boshqaryapti.
Endi ikkita xodim bor deb tasavvur qilamiz.
Birinchi xodim birinchi buyurtma ustida, ikkinchi xodim esa ikkinchi buyurtma ustida ayni paytda ishlayapti.
Bu parallelism.
Farqni jadvalda ko‘rsak:
| 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 |
Bu yerda eng muhim farq quyidagicha:
Concurrency ko‘proq dastur qanday tuzilganiga taalluqli. Parallelism esa vazifalar ma’lum bir onda qanday bajarilayotganiga taalluqli.
Concurrent yozilgan dastur har doim parallel ishlashi shart emas.
Masalan, bitta mantiqiy protsessor mavjud bo‘lsa, goroutinelar navbat bilan bajarilishi mumkin.
Bir nechta mantiqiy protsessor mavjud bo‘lsa, ularning ayrimlari parallel bajarilishi 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 turli xizmatdan ma’lumot olishni tasvirlaydi.
Haqiqiy serverga so‘rov yuborish o‘rniga time.Sleep orqali tarmoq yoki boshqa I/O kutishi sun’iy tarzda ko‘rsatilgan.
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:
Endi kodni bosqichma-bosqich ko‘ramiz.
Birinchi slice xizmatlar nomini saqlaydi:
Ikkinchi slice har bir xizmatning qancha vaqt kutishini bildiradi:
Demak:
Natijalar uchun oldindan uchta elementli slice yaratiladi:
Bu yerda natijalarni aynan xizmatlar tartibida saqlash uchun indeks ishlatiladi.
Keyin WaitGroup yaratiladi:
Har bir ish boshlanishidan oldin:
chaqiriladi.
Bu WaitGroupga:
Yana bitta tugashini kutish kerak bo‘lgan ish bor.
degan ma’noni beradi.
Keyin anonim funksiya yangi goroutineda ishga tushadi:
Goroutine ichida birinchi muhim qator:
wg.Done() ushbu ish tugaganini bildiradi.
defer sabab u funksiya yakunlanayotganda chaqiriladi.
Keyin haqiqiy ish bajariladi:
Har bir goroutine natijani o‘z indeksiga yozadi.
Masalan:
Shuning uchun xizmatlar turli vaqtda tugasa ham, natijalar slice ichida oldindan belgilangan joyga yoziladi.
main funksiyasi esa:
qatorida barcha goroutinelar tugashini kutadi.
Shundan keyingina:
natijalarni chiqaradi.
Shu sabab chiqish tugash tartibida emas, results slice tartibida bo‘ladi:
Aslida buyurtmalar faqat 10 ms kutadi va profildan oldin tugashi mumkin.
Agar fmt.Println to‘g‘ridan-to‘g‘ri goroutine ichida chaqirilganida, natijalar boshqa tartibda chiqishi mumkin edi.
Masalan:
Scheduler bajarilish tartibini qat’iy kafolatlamaydi.
Bu misolda uchta ishning kutish vaqti bir-birining ustiga tushadi:
Shuning uchun ketma-ket bajarilgandagi:
o‘rniga umumiy vaqt taxminan eng sekin operatsiyaga yaqinlashishi mumkin:
Ammo bu aniq vaqt kafolati emas.
Scheduler, operatsion tizim va boshqa runtime xarajatlari ham mavjud. Haqiqiy tezlikni aniqlash uchun benchmark yoki boshqa o‘lchov kerak.
Info
Goroutine va WaitGroup oldingi darslarda o‘rganildi. Channel esa keyingi darslarda alohida ko‘rib chiqiladi. Bu
yerda ular concurrency va parallelism farqini amaliy misolda ko‘rsatish uchun birga ishlatilmoqda.
Chuqurlashtirish: Go scheduler qanday ishlaydi?
Concurrency bilan ishlashni boshlash uchun schedulerning ichki modelini yodlash shart emas. Quyidagi bo‘lim goroutinelar runtime darajasida qanday rejalashtirilishini chuqurroq tushunmoqchi bo‘lgan o‘quvchilar uchun berilgan.
Go runtime’da goroutinelarni bajarishga joylashtiradigan scheduler mavjud.
Uni ko‘pincha G–M–P modeli orqali tushuntirishadi.
Bu modelda:
- G — goroutine;
- M — operatsion tizim threadi;
- P — Go kodini bajarish uchun kerak bo‘ladigan runtime resursi.
Soddalashtirilgan ko‘rinish:
Yoki bir nechta ish bo‘lsa:
Scheduler bajarishga tayyor goroutinelarni P orqali Mlarga biriktiradi.
Bu modelning maqsadi ko‘p goroutinelarni samarali ravishda kamroq sondagi operatsion tizim threadlari ustida ishlatishdir.
Masalan, goroutine quyidagi operatsiyalardan biri sabab kutishga o‘tishi mumkin:
- channel operatsiyasi;
- mutex;
- timer;
- network I/O;
- boshqa bloklovchi ish.
Bir goroutine kutayotganida runtime mavjud CPU vaqtini bekor sarflash o‘rniga boshqa tayyor goroutineni bajarishga harakat qilishi mumkin.
Masalan:
Bunday paytda scheduler G2ni ishlatishi mumkin.
Ayrim bloklovchi tizim chaqiruvlarida OS threadning o‘zi band bo‘lib qolishi mumkin. Runtime bunday holatda boshqa threaddan foydalanib, boshqa goroutinelarning ishlashini davom ettirishi mumkin.
GOMAXPROCS
Go runtime’da runtime.GOMAXPROCS degan muhim tushuncha bor.
U bir paytda Go kodini bajarishda qatnasha oladigan Plar sonini belgilaydi.
Bu yerda keng tarqalgan chalkashlik bor:
GOMAXPROCSgoroutinelar sonini cheklamaydi.
Masalan, dasturda:
bo‘lishi mumkin.
Lekin GOMAXPROCS:
bo‘lsa, ma’lum bir onda Go kodini parallel bajarish imkoniyati Plar soni bilan cheklanadi.
Soddalashtirib:
Joriy CPU va GOMAXPROCS qiymatini quyidagicha ko‘rish mumkin:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("Mantiqiy CPU:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
}
Masalan, natija shunday bo‘lishi mumkin:
Lekin bu qiymatlar kompyuter va dastur ishlayotgan muhitga bog‘liq.
Quyidagi qator:
Go runtime ko‘rayotgan mantiqiy CPU sonini qaytaradi.
Bu qator esa:
joriy GOMAXPROCS qiymatini qaytaradi.
Bu yerda 0 qiymati:
Yangi limit o‘rnatma, faqat hozirgi qiymatni qaytar.
degan ma’noda ishlatiladi.
Warning
GOMAXPROCSni mantiqiy CPU sonidan kattalashtirish CPU-bound kodni avtomatik tezlashtirmaydi. Aksincha, ortiqcha parallelizm scheduler ishini, context almashishni va CPU cache bilan bog‘liq xarajatlarni oshirishi mumkin.
Umumiy xotira va data race
Goroutinelar bitta process ichida ishlaydi.
Shuning uchun ular process xotirasini o‘zaro bo‘lishadi.
Bu juda qulay. Masalan, bir nechta goroutine bir xil slice, map yoki struct bilan ishlashi mumkin.
Lekin umumiy xotira bilan ishlashda sinxronizatsiya muhim.
Agar:
- ikki yoki undan ortiq goroutine bir xil xotira joyiga murojaat qilsa;
- ulardan kamida bittasi yozish bajarsa;
- ular orasida kerakli sinxronizatsiya bo‘lmasa;
data race yuzaga kelishi mumkin.
Quyidagi kod ataylab noto‘g‘ri:
// Noto‘g‘ri misol: bir nechta goroutine count qiymatiga
// sinxronizatsiyasiz yozadi.
// count++ bitta atomar amal emas.
// go func() {
// count++
// }()
Birinchi qarashda:
bitta amalga o‘xshaydi.
Lekin konseptual jihatdan uni quyidagi bosqichlarga ajratish mumkin:
Masalan, count = 0 bo‘lsin.
Ikki goroutine bir vaqtda ishlasa:
G1: count ni o‘qidi -> 0
G2: count ni o‘qidi -> 0
G1: 0 + 1 -> 1
G2: 0 + 1 -> 1
G1: count = 1
G2: count = 1
Biz ikkita oshirish bajarildi deb o‘ylagan edik.
Kutilgan natija:
Lekin ayrim bajarilishlarda natija:
bo‘lishi mumkin.
Bu lost update kabi muammolarga olib keladi.
Yana muhim jihat:
Dastur bir necha marta to‘g‘ri natija chiqargani uning race’dan holi ekanini isbotlamaydi.
Data race schedulerning aniq bajarilish tartibiga bog‘liq bo‘lishi mumkin.
Umumiy holatni xavfsiz boshqarish uchun turli yondashuvlar mavjud.
Masalan:
- holatni channel orqali bitta goroutinega berish;
sync.Mutex;sync.RWMutex;sync/atomic;- boshqa mos sinxronizatsiya mexanizmlari.
Go race detector ham race muammolarini topishda yordam beradi.
Testlarni race detector bilan ishga tushirish:
Oddiy dasturni race detector bilan ishga tushirish:
Agar bajarilish vaqtida data race kuzatilsa, race detector bu haqda xabar berishi mumkin.
Lekin bu yerda muhim cheklov bor.
Race detector barcha nazariy bajarilish yo‘llarini avtomatik tekshirib chiqmaydi. U dastur real bajarilish vaqtida kuzatilgan xotira murojaatlarini tahlil qiladi.
Shuning uchun:
degani:
degan kafolat emas.
Testlar race yuz beradigan kod yo‘lini ishlatishi ham kerak.
Keng tarqalgan xatolar
Concurrent dastur yozishda bir nechta xato juda ko‘p uchraydi.
Concurrencyni tezlik bilan tenglashtirish
Goroutine qo‘shish kodni avtomatik tezlashtirmaydi.
Masalan, quyidagi fikr noto‘g‘ri:
Haqiqiy ishlash tezligiga ko‘p omillar ta’sir qiladi:
- ketma-ket bajarilishi shart bo‘lgan kod;
- locklar;
- ma’lumot uzatish;
- scheduler;
- memory allocation;
- CPU cache;
- I/O cheklovlari;
- tashqi servislar limiti.
CPU-bound ishda qo‘shimcha goroutinelar mavjud CPU sonidan foyda olishga yordam berishi mumkin.
Lekin juda ko‘p parallel ish yaratish aksincha overheadni oshiradi.
I/O-bound ishda esa concurrency ko‘pincha kutish vaqtlarini bir-birining ustiga tushirishga yordam beradi.
Shuning uchun amaliy yondashuv odatda quyidagicha:
1. Avval sodda va to‘g‘ri yechim yozish
2. Benchmark yoki profiling qilish
3. Tor joyni aniqlash
4. Faqat kerakli joyda concurrency qo‘llash
5. Yana o‘lchash
Cheksiz goroutine yaratish
Har bir ish uchun nazoratsiz goroutine yaratish xavfli.
Masalan:
agar items soni juda katta bo‘lsa, juda ko‘p goroutine bir vaqtda yaratilishi mumkin.
Bu faqat Go process xotirasiga emas, tashqi tizimlarga ham bosim beradi.
Masalan:
- database connectionlar tugashi;
- tashqi API rate limitga urilishi;
- file descriptorlar tugashi;
- xotira sarfining keskin oshishi;
- schedulerga yuk ortishi.
Shuning uchun bir vaqtda bajariladigan ishlar sonini cheklash kerak.
Buning uchun ko‘pincha:
- semaphore;
- worker pool;
- bounded queue
kabi yondashuvlar ishlatiladi.
Masalan, tizimda 100 000 ta ish bo‘lishi mumkin. Lekin ularning faqat 20 tasi bir vaqtda bajarilishi mumkin.
Bu resurslardan nazoratli foydalanishga yordam beradi.
Goroutine tugashini kutmaslik
Go dasturida main funksiyasi qaytsa, process yakunlanadi.
Runtime qolgan goroutinelarni avtomatik ravishda:
Hamma ishini tugatguncha kutaman.
deb kutib turmaydi.
Masalan:
main() juda tez tugasa, doWork() tugashga ulgurmasligi mumkin.
Shuning uchun goroutinelarning tugashini aniq sinxronizatsiya mexanizmi bilan kutish kerak.
Masalan:
sync.WaitGroup;- channel;
- boshqa mos koordinatsiya usuli.
Quyidagi usul esa ishonchli sinxronizatsiya emas:
Bunda dastur:
Bir soniya yetarli bo‘lsa kerak.
deb taxmin qiladi.
Lekin ish ba’zan 500 ms, ba’zan 2 s davom etishi mumkin.
To‘g‘ri yechim ishning haqiqiy tugash signalini kutishi kerak.
Goroutine leak
Goroutine leak — goroutine endi foydali ish bajarmayotgan bo‘lsa ham, hech qachon tugamay qoladigan holat.
Masalan, goroutine channel’dan qiymat kutmoqda:
Lekin chga hech kim hech qachon qiymat yubormaydi.
Natijada goroutine doimiy kutishda qolishi mumkin.
Shunga o‘xshash muammolar quyidagilarda ham paydo bo‘lishi mumkin:
- tugamaydigan I/O;
- yuborilmaydigan signal;
- to‘xtatilmaydigan retry;
- yopilmay qolgan worker;
- timeout bo‘lmagan tashqi chaqiruv.
Goroutine CPU ishlatmayotgan bo‘lishi mumkin, lekin u baribir runtime holati va boshqa resurslarni saqlab turadi.
Backend dasturlarda uzoq davom etadigan ishlar uchun:
- cancellation;
- deadline;
- timeout
mexanizmlarini aniq belgilash muhim.
Go’da buning uchun ko‘pincha context.Context ishlatiladi.
Natija tartibiga tayanish
Scheduler goroutinelarni qaysi tartibda bajarishini kafolatlamaydi.
Masalan:
yozilgan bo‘lsa, bu:
degani emas.
Hatto first goroutine oldin yaratilgan bo‘lsa ham, second oldin natija chiqarishi mumkin.
Concurrent dastur mantiqini tasodifiy scheduler tartibiga bog‘lamaslik kerak.
Agar ma’lum tartib talab qilinsa, uni dasturda ochiq belgilash kerak.
Masalan:
- channel;
WaitGroup;- indeks;
- mutex;
- boshqa koordinatsiya mexanizmi.
Ishlash tezligini to‘g‘ri baholash
Parallel yoki concurrent yechimni tanlashdan oldin kodning haqiqiy xarakterini tushunish kerak.
Quyidagi savollar foydali:
- Ish I/O-boundmi yoki CPU-boundmi?
- Vazifalarni mustaqil qismlarga bo‘lish mumkinmi?
- Bir qism boshqasining natijasiga bog‘liqmi?
- Umumiy xotira ishlatiladimi?
- Umumiy xotiraga murojaat xavfsizmi?
- Parallel ishlar soni cheklanganmi?
- Tashqi servis qancha parallel so‘rovni ko‘tara oladi?
- Xatolar qanday tarqatiladi?
- Timeout qanday ishlaydi?
- Bekor qilish signali qanday yetkaziladi?
- Ketma-ket va concurrent variant benchmarkda qanday farq qiladi?
Tezlikni baholashda latency va throughput tushunchalarini ham ajratish muhim.
Latency
Latency — bitta operatsiya yoki so‘rovning boshlanishidan tugashigacha ketgan vaqt.
Masalan:
Latency:
Throughput
Throughput — ma’lum vaqt ichida qancha ish bajarilganini bildiradi.
Masalan:
Bu throughput.
Concurrency ba’zan bitta requestning latency qiymatini deyarli o‘zgartirmaydi, lekin tizimning throughputini oshirishi mumkin.
Masalan, har bir request tashqi servisni 50 ms kutadi.
Bitta requestning latency qiymati hali ham taxminan:
bo‘lishi mumkin.
Lekin server bir vaqtning o‘zida ko‘p requestni kutishga qo‘ya olsa, bir sekund ichida bajariladigan requestlar soni oshishi mumkin.
Shuning uchun:
Latency yaxshilandi.
va:
Throughput yaxshilandi.
bir xil fikr emas.
Birini yaxshilash har doim ikkinchisini ham yaxshilamaydi.
Interviewda nimalarga e’tibor beriladi?
Concurrency mavzusidagi interview savollarida odatda faqat ta’rif emas, tushunchalar orasidagi farqni anglash muhim.
Quyidagi fikrlarni aniq tushuntira olish kerak.
Concurrency — dastur tuzilishi, parallelism esa bajarilish xususiyati.
Concurrent dastur bir nechta vazifani bir davr ichida boshqaradi. Ular bitta CPU’da navbat bilan bajarilishi ham mumkin.
Parallelism esa bir nechta vazifaning ayni paytda bajarilishini anglatadi.
Goroutine operatsion tizim threadi emas.
Goroutine Go runtime tomonidan boshqariladi. Runtime ko‘p goroutinelarni OS threadlar ustida rejalashtiradi.
GOMAXPROCS goroutine sonini cheklamaydi.
Dasturda juda ko‘p goroutine bo‘lishi mumkin. GOMAXPROCS esa bir paytda Go kodini parallel bajarishda qatnasha oladigan Plar soniga ta’sir qiladi.
Umumiy xotiraga sinxronizatsiyasiz yozish data race keltirib chiqarishi mumkin.
Ayniqsa bir nechta goroutine bir xil qiymatni o‘zgartirayotgan bo‘lsa, kerakli sinxronizatsiya bo‘lishi kerak.
Concurrency tezlikni kafolatlamaydi.
Goroutine qo‘shishning o‘zi optimizatsiya emas. Har qanday tezlik haqidagi xulosani benchmark yoki profiling bilan tekshirish kerak.
Goroutinelar soni nazorat qilinishi kerak.
Nazoratsiz goroutine yaratish xotira, database connection, tashqi servis yoki boshqa resurslarni tugatishi mumkin.
Misollar
1. Ketma-ket bajarilish vaqtini o‘lchash
Bu misol ikki ishni ketma-ket bajaradi.
Maqsad — keyingi concurrent misol bilan solishtirish uchun boshlang‘ich vaqtni ko‘rish.
package main
import (
"fmt"
"time"
)
func work(name string) {
time.Sleep(100 * time.Millisecond)
fmt.Println(name, "tugadi")
}
func main() {
started := time.Now()
work("birinchi ish")
work("ikkinchi ish")
fmt.Println("Umumiy vaqt:", time.Since(started))
}
work() funksiyasi:
qatori orqali 100 millisekund kutadi.
Birinchi chaqiruv:
tugamaguncha keyingi qator bajarilmaydi.
Shundan keyin:
boshlanadi.
Vaqt oqimini soddalashtirib ko‘rsak:
Shuning uchun umumiy kutish taxminan:
bo‘ladi.
Natija taxminan:
ko‘rinishida chiqadi.
Aniq qiymat aynan 200 ms bo‘lishi shart emas. Scheduler va boshqa tizim xarajatlari sabab biroz katta bo‘lishi mumkin.
Bu misoldagi asosiy qoida:
Ketma-ket bajarilishda ikkinchi ish birinchi ish tugamaguncha boshlanmaydi.
2. Kutish vaqtlarini ustma-ust bajarish
Bu misolda ikki kutuvchi ish alohida goroutineda boshlanadi.
package main
import (
"fmt"
"time"
)
func waitForService(name string) {
time.Sleep(100 * time.Millisecond)
fmt.Println(name, "javob berdi")
}
func main() {
started := time.Now()
go waitForService("API")
go waitForService("ma’lumotlar bazasi")
time.Sleep(150 * time.Millisecond)
fmt.Println("O‘tgan vaqt:", time.Since(started))
}
Bu safar:
va:
alohida goroutinelarda boshlanadi.
Shuning uchun ikkala 100 ms kutish ketma-ket emas, bir vaqt oralig‘ida davom etishi mumkin:
Ketma-ket variantda umumiy kutish:
edi.
Concurrent holatda ikkala kutish bir-birining ustiga tushadi. Shu sabab ikkala ish taxminan 100 ms atrofida tugashi mumkin.
Koddagi:
qatori esa goroutinelarning tugashini kutish uchun qo‘yilgan.
Lekin bu real dastur uchun to‘g‘ri sinxronizatsiya usuli emas.
Sababi Sleep():
Ish tugaguncha kut.
demaydi.
U faqat:
Shu vaqt davomida kut.
deydi.
Real kodda WaitGroup, channel yoki boshqa aniq sinxronizatsiya usuli ishlatilishi kerak.
Bu misoldagi asosiy qoida:
I/O kabi kutuvchi mustaqil ishlar concurrent boshlansa, ularning kutish vaqti bir-birining ustiga tushishi mumkin.
3. Mantiqiy protsessorlar sonini ko‘rish
Bu misolda Go runtime ko‘rayotgan mantiqiy protsessorlar soni chiqariladi.
package main
import (
"fmt"
"runtime"
)
func main() {
logicalCPU := runtime.NumCPU()
fmt.Println("Mantiqiy protsessorlar:", logicalCPU)
}
Asosiy qator:
runtime.NumCPU() dastur ishlayotgan muhitda Go runtime ko‘rayotgan mantiqiy CPU sonini qaytaradi.
Masalan:
chiqishi mumkin.
Boshqa kompyuterda:
bo‘lishi mumkin.
Qiymat dastur ishlayotgan muhitga bog‘liq.
Bu sonni goroutinelar soni bilan tenglashtirmaslik kerak.
Masalan:
mavjud bo‘lsa ham dasturda:
bo‘lishi mumkin.
Goroutinelar Go runtime tomonidan mantiqiy CPUlar ustida rejalashtiriladi.
Bu misoldagi asosiy qoida:
Mantiqiy CPU soni goroutine soni emas. U apparat va runtime bajarilish imkoniyatini ifodalaydi.
4. GOMAXPROCS qiymatini o‘qish
Bu misolda bir paytda Go kodini bajarish uchun ishlatiladigan GOMAXPROCS qiymati ko‘rsatiladi.
package main
import (
"fmt"
"runtime"
)
func main() {
current := runtime.GOMAXPROCS(0)
fmt.Println("GOMAXPROCS:", current)
}
Asosiy qator:
Bu yerda 0 yangi qiymat o‘rnatmaydi.
U faqat joriy GOMAXPROCS qiymatini so‘raydi.
Masalan:
chiqishi mumkin.
GOMAXPROCS goroutine sonini cheklamaydi.
Masalan:
bo‘lishi mumkin.
Bu holatda 5000 goroutine mavjud, lekin ularning barchasi ayni paytda Go kodini parallel bajarmaydi.
Bu misoldagi asosiy qoida:
GOMAXPROCSgoroutine sonini emas, parallel Go bajarilish imkoniyatini boshqaradi.
5. Schedulerga ixtiyoriy navbat berish
Bu misolda runtime.Gosched() ishlatiladi.
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
go func() {
fmt.Println("Yordamchi goroutine ishladi")
}()
runtime.Gosched()
time.Sleep(10 * time.Millisecond)
fmt.Println("main tugadi")
}
Avval yordamchi goroutine yaratiladi:
Keyin:
chaqiriladi.
Gosched() joriy goroutineni tugatmaydi.
U schedulerga boshqa tayyor goroutinelarni bajarish uchun imkon beradi.
Soddalashtirib:
Lekin Gosched() qat’iy bajarilish tartibini kafolatlovchi sinxronizatsiya vositasi emas.
Koddagi:
faqat kichik namunaning chiqishini ko‘rish uchun qoldirilgan.
Real dasturda vazifalar tartibi yoki tugashi Gosched() va Sleep() taxminiga bog‘lanmasligi kerak.
Bu misoldagi asosiy qoida:
runtime.Gosched()schedulerga boshqa goroutinelarni bajarish imkonini beradi, lekin bajarilish tartibini kafolatlamaydi.
6. Mustaqil hisoblarni concurrent boshlash
Bu misolda bir-biriga bog‘liq bo‘lmagan ikkita hisob alohida goroutineda bajariladi.
package main
import (
"fmt"
"time"
)
func square(number int) {
fmt.Println(number, "kvadrati:", number*number)
}
func cube(number int) {
fmt.Println(number, "kubi:", number*number*number)
}
func main() {
go square(4)
go cube(3)
time.Sleep(20 * time.Millisecond)
}
Birinchi funksiya:
quyidagi hisobni bajaradi:
Ikkinchi funksiya:
quyidagi hisobni bajaradi:
Bu ikki hisob bir-biriga bog‘liq emas.
square(4) natijasi cube(3) uchun kerak emas. cube(3) natijasi ham square(4)ga kerak emas.
Shuning uchun ularni mustaqil goroutinelarda boshlash mumkin:
Natija quyidagicha chiqishi mumkin:
Lekin aksincha tartib ham mumkin:
Sababi scheduler qaysi goroutine birinchi ishlashini kafolatlamaydi.
Bu misoldagi asosiy qoida:
Mustaqil ishlarni concurrent boshlash mumkin, lekin ularning tugash tartibiga tayanish kerak emas.
7. Scheduler tartibiga tayanmaslik
Bu misolda ikki goroutine turli davomiylikda kutadi.
package main
import (
"fmt"
"time"
)
func task(name string, delay time.Duration) {
time.Sleep(delay)
fmt.Println(name)
}
func main() {
go task("sekin", 60*time.Millisecond)
go task("tez", 10*time.Millisecond)
time.Sleep(80 * time.Millisecond)
}
Kodda sekin goroutine birinchi yozilgan:
Keyin tez goroutine yaratilgan:
Lekin ularning kutish vaqti har xil:
Vaqt chizig‘i:
Shuning uchun tez odatda oldin chiqadi:
Bu juda muhim qoida.
Kodda goroutine oldin yozilgani uning oldin tugashini anglatmaydi.
Umuman, concurrent kodni:
degan taxminga qurmaslik kerak.
Tartib zarur bo‘lsa, uni alohida sinxronizatsiya qilish kerak.
Bu misoldagi asosiy qoida:
Goroutinelarning kodda yozilish tartibi ularning tugash tartibini kafolatlamaydi.
8. Umumiy xotiradagi data race
Bu misolda ikkita goroutine bir xil counter qiymatini sinxronizatsiyasiz o‘zgartiradi.
package main
import (
"fmt"
"time"
)
func main() {
counter := 0
go func() {
counter++
}()
go func() {
counter++
}()
time.Sleep(20 * time.Millisecond)
fmt.Println(counter)
}
counter boshlang‘ich holatda:
Birinchi goroutine:
bajaradi.
Ikkinchi goroutine ham aynan shu umumiy o‘zgaruvchiga:
bajaradi.
Muammo shundaki, counter++ni umumiy xotira nuqtai nazaridan bitta ajralmas amal deb hisoblash mumkin emas.
Konseptual ravishda:
bosqichlari mavjud.
Masalan:
counter = 0
G1: counter ni o‘qidi -> 0
G2: counter ni o‘qidi -> 0
G1: 1 hosil qildi
G2: 1 hosil qildi
G1: counter = 1
G2: counter = 1
Natijada ikkita oshirish bo‘lsa ham, yakuniy qiymat 1 bo‘lib qolishi mumkin.
Bundan tashqari, bu kodda main ham counterni o‘qiydi:
Bu murojaatlarning o‘zi ham to‘g‘ri sinxronizatsiya qilinmagan.
Muammoni race detector bilan ko‘rish mumkin:
Koddagi:
data raceni bartaraf qilmaydi.
Sleep() faqat vaqt o‘tishini kutadi. U xotira murojaatlari orasida kerakli sinxronizatsiya yaratmaydi.
Bu misoldagi asosiy qoida:
Bir xil umumiy xotiraga sinxronizatsiyasiz concurrent yozish data race keltirib chiqarishi mumkin.
9. Turli xotira manzillariga yozish
Bu misolda ikki goroutine bitta slicening turli elementlariga yozadi.
package main
import (
"fmt"
"time"
)
func main() {
values := make([]int, 2)
go func() {
values[0] = 25
}()
go func() {
values[1] = 50
}()
time.Sleep(20 * time.Millisecond)
fmt.Println("Hisoblar tugadi")
}
Avval uzunligi 2 bo‘lgan slice yaratiladi:
Unda quyidagi indekslar mavjud:
Birinchi goroutine:
yozadi.
Ikkinchi goroutine:
yozadi.
Bu ikki amal bir xil slice obyektiga tegishli bo‘lsa ham, ikki xil elementga yozmoqda:
Ya’ni ular aynan bir xotira manziliga yozmayapti.
Bundan tashqari, bu misolda main() goroutinelar yozayotgan paytda elementlarning qiymatini o‘qimaydi.
U faqat:
qatorini chiqaradi.
Shu sabab bu misol oldingi counter++ holatidan farq qiladi.
Lekin time.Sleep() bu yerda ham ishlarning tugashini to‘g‘ri sinxronizatsiya qiluvchi vosita emas. Real dasturda aniq tugash signali ishlatilishi kerak.
Bu misoldagi asosiy qoida:
Turli goroutinelar bir-biridan mustaqil xotira joylari bilan ishlasa, bir xil o‘zgaruvchiga concurrent yozishdagi muammo yuz bermaydi. Lekin goroutinelarning tugashini baribir to‘g‘ri sinxronizatsiya qilish kerak.
10. Latency va throughputni ajratish
Bu misolda bitta ishning latency qiymati bilan bir nechta concurrent ishning umumiy bajarilish vaqti orasidagi farq ko‘rsatiladi.
package main
import (
"fmt"
"time"
)
func request(id int) {
time.Sleep(50 * time.Millisecond)
fmt.Println("So‘rov tugadi:", id)
}
func main() {
started := time.Now()
for id := 1; id <= 3; id++ {
go request(id)
}
time.Sleep(80 * time.Millisecond)
fmt.Println("Uch so‘rov uchun vaqt:", time.Since(started))
}
Har bir request() funksiyasi:
orqali taxminan 50 ms kutadi.
Demak, bitta so‘rovning latency qiymati taxminan:
bo‘lib qoladi.
Endi uchta request ketma-ket bajarilganini tasavvur qilamiz:
Umumiy vaqt taxminan:
bo‘lardi.
Lekin kodda requestlar goroutine orqali concurrent boshlanadi:
Shuning uchun kutishlar bir-birining ustiga tushishi mumkin:
Bitta requestning latency qiymati taxminan 50 msligicha qoladi.
Lekin uchta requestning bajarilishi 150 msga ketma-ket yig‘ilmaydi.
Bu throughput nuqtai nazaridan foydali bo‘lishi mumkin: bir vaqt oralig‘ida ko‘proq request ustida ishlash imkoniyati paydo bo‘ladi.
Kodda id:
ko‘rinishida 1dan boshlanadi.
Buning texnik zarurati yo‘q. Bu faqat natijani o‘quvchiga qulay:
ko‘rinishida chiqarish uchun tanlangan.
Yana bir bor, bu misoldagi:
faqat kichik demonstratsiya uchun ishlatilgan.
Real dasturda uchta goroutine haqiqatan tugaganini WaitGroup, channel yoki boshqa sinxronizatsiya mexanizmi orqali aniqlash kerak.
Bu misoldagi asosiy qoida:
Concurrency bitta operatsiyaning latency qiymatini o‘zgartirmasdan ham bir vaqt oralig‘ida ko‘proq ishni bajarish, ya’ni throughputni oshirish imkonini berishi mumkin.