42 / 73

Sinxronizatsiya

Go maqoli — "xotirani ulashib muloqot qilma, muloqot qilib xotirani ulash" — dizayn falsafasi, qat'iy fizika qonuni emas. Ko'plab to'g'ri va idiomatik Go kodlarida xotira to'g'ridan-to'g'ri ulashiladi va u lock (qulf) yordamida himoyalanadi. Channellar qiymatga egalik huquqini topshirish yoki mustaqil ish bosqichlarini muvofiqlashtirish uchun a'lo darajada mos keladi, lekin bir nechta goroutine shunchaki umumiy holat bo'lagiga — hisoblagich, kesh yoki konfiguratsiya struct'iga — xavfsiz murojaat qilishi kerak bo'lsa, mutex ko'pincha to'g'riroq va samaraliroq vositadir. Bu bob sync.Mutex, sync.RWMutex, sync.Once, sync.WaitGroup va sync/atomic paketini yoritadi, so'ng har bir Go dasturchisi ertami-kechmi beradigan savolga javob beradi: mutexmi yoki channel?

sync.Mutex

sync.Mutex o'zaro istisnoni (mutual exclusion) ta'minlaydi: bir vaqtning o'zida ko'pi bilan bitta goroutine qulfni (lock) ushlab turishi mumkin, va Lock()ni chaqirgan har qanday boshqa goroutine qulf bo'shatilguncha bloklanadi. Bir xil lock uchun raqobatlashayotgan uchta goroutineni tasavvur qiling: ular navbatma-navbat lock'ni oladi, o'z amallarini bajaradi va uni bo'shatadi, qolganlari esa navbatda kutadi. Mutex goroutinelar bir vaqtda parallel ishlasa ham, aynan mana shu tartibli ketma-ketlikni kafolatlaydi.

example.go
package main

import (
	"fmt"
	"sync"
)

type SafeCounter struct {
	mu    sync.Mutex
	value int
}

func (c *SafeCounter) Increment() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.value++
}

func (c *SafeCounter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.value
}

func main() {
	counter := &SafeCounter{}
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter.Increment()
		}()
	}
	wg.Wait()
	fmt.Println("Counter:", counter.Value()) // 1000
}

Mutex'ni u himoya qiladigan ma'lumot bilan bir joyda saqlash — uni alohida turgan counter int yonida paket darajasidagi o'zgaruvchi sifatida e'lon qilish o'rniga, SafeCounterga maydon sifatida kiritish (embed qilish) — juda foydali amaliy odatdir. Bu lock va ma'lumot orasidagi bog'liqlikni tip darajasida yaqqol ko'rsatadi hamda valuega har bir murojaat lock qilishni unutmaydigan metod orqali amalga oshishi kerakligini ta'minlaydi. Lock() chaqiruvidan darhol keyingi defer c.mu.Unlock() — idiomatik juftlikdir: u kritik bo'lim o'rtasida panic yuz berib stack yechilganda (unwinding) ham qulf ochilishini kafolatlaydi; funksiya oxiridagi shunchaki c.mu.Unlock() chaqiruvi esa panic holatida ishlamay qolib, tizimni qulflab qo'yishi mumkin edi.

Go 1.18 dan beri Mutexda TryLock() metodi ham mavjud: u lock'ni goroutineni bloklamasdan olishga urinadi va uning muvaffaqiyatli bo'lgan-bo'lmaganini bool sifatida qaytaradi. U kamdan-kam uchraydigan maxsus holatlar uchun mo'ljallangan — Go jamoasining rasmiy hujjatlarida ham uni ishlatish ko'pincha soddalashtirilishi kerak bo'lgan murakkab dizayn belgisi ekani ta'kidlanadi. Shu sababli, odatiy holatda Lock()dan foydalaning, TryLock()ga esa kundalik vosita emas, kamyob istisno sifatida qarang.

sync.RWMutex

sync.RWMutex o'qish va yozish amallarini ajratadi: istalgancha goroutine bir vaqtda o'qish qulfini (RLock/RUnlock) ushlab turishi mumkin, lekin yozish qulfi (Lock/Unlock) ham o'quvchilar, ham boshqa yozuvchilar uchun mutlaq eksklyuzivdir. Bu yondashuv o'qish amallari yozishdan ancha ko'p bo'lgan vaziyatlarda o'zini oqlaydi — masalan, kamdan-kam yangilanadigan, lekin tinimsiz o'qiladigan konfiguratsiya ma'lumotlari yoki xotiradagi keshda. Chunki bir vaqtda kelgan o'quvchi goroutinelar bir-birining ketidan navbatda turmasdan, parallel ravishda ma'lumotni o'qiyveradi.

example.go
type Cache struct {
	mu   sync.RWMutex
	data map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	value, ok := c.data[key]
	return value, ok
}

func (c *Cache) Set(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key] = value
}

Biroq RWMutex oddiy Mutexning tekinga keladigan yaxshilanishi emas. U lock/unlock amallari uchun qo'shimcha hisob-kitob overhead'ini (resurs sarfini) talab qiladi. Shuning uchun o'qish va yozish soni taxminan teng bo'lgan yoki kritik bo'limlari juda qisqa bo'lgan ish yuklarida oddiy Mutex aslida ancha tezroq ishlashi mumkin. O'quvchi va yozuvchi qulflarining ajratilishi faqat o'qish amallari mutlaq ustun bo'lganda va ularni parallellashtirish sezilarli foyda beradigan darajada lock uzoqroq ushlab turilgandagina o'zini oqlaydi. Ikkilansangiz, avval oddiy Mutex bilan yozing va faqat profillash (profiling) natijalari o'qish raqobati haqiqiy to'siq bo'layotganini ko'rsatgandagina RWMutexga o'ting.

sync.Once

sync.Once o'zining Do metodiga uzatilgan funksiyaning qancha goroutine bir vaqtda chaqirishidan qat'i nazar, aynan bir marta bajarilishini kafolatlaydi: birinchi chaqirgan goroutine funksiyani ishga tushiradi, qolgan chaqiruvchilar esa u tugaguncha bloklanadi, so'ngra barchasi funksiyani qayta bajarmasdan o'tib ketadi. Bu Goda dangasa (lazy), concurrency-xavfsiz initsializatsiyani yozishning standart usulidir: u poygalarga (race condition) moyil bo'lgan yoki qulflar bilan noqulay ishlaydigan check-then-act andozasining (if !initialized { ... }) o'rnini bosadi.

example.go
var (
	once     sync.Once
	instance *Database
)

func getDatabase() *Database {
	once.Do(func() {
		instance = &Database{} // qimmat sozlash, masalan ulanishlar pulini ochish
	})
	return instance
}

getDatabase funksiyasini istalgan sondagi goroutinedan bir vaqtda chaqirish mutlaqo xavfsizdir va qimmat resurs talab qiluvchi sozlash ularning qanchasi bir vaqtda murojaat qilishidan qat'i nazar aynan bir marta amalga oshiriladi. Bu paket darajasidagi singletonlar — umumiy konfiguratsiya, ulanishlar puli (connection pool) yoki kompilyatsiya qilingan regular expressionlar uchun ideal shakldir.

Atomik amallar

Bitta oddiy qiymat — hisoblagich, bayroq yoki almashtiriladigan pointer — uchun sync/atomic paketi mutex'dan ko'ra ancha tezroq lock-free amallarni taqdim etadi. Chunki bu amallar operatsion tizim darajasidagi og'ir qulflashlar emas, bevosita apparat (CPU) darajasidagi atomik buyruqlar orqali bajariladi.

example.go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var counter atomic.Int64

	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter.Add(1)
		}()
	}
	wg.Wait()
	fmt.Println("Counter:", counter.Load()) // 1000
}

Ushbu misol Go 1.19 versiyasida kiritilgan tipli atomik turlardan foydalanadi — atomic.Int64, atomic.Bool, atomic.Value va boshqalar. Ular asosiy amallarni metodlarga ega (Add, Load, Store, Swap, CompareAndSwap) strukturalarga o'raydi. Shu bois eski atomic.AddInt64(&counter, 1) uslubidagi kabi paket funksiyasiga pointer uzatib yurish shart emas. Yangi kodlarda aynan ushbu tipli API'lardan foydalanish tavsiya etiladi: ularni noto'g'ri ishlatish ancha qiyin va kod o'qilishi ancha ravon.

Go 1.23 versiyasida tipli atomik butun sonlar uchun qo'shimcha And va Or metodlari taqdim etildi (mavjud Add/Load/Store/Swap/CompareAndSwap amallari yoniga). Ular bitli AND yoki OR amallarini atomik tarzda qo'llaydi va amal bajarilishidan oldingi asl qiymatni qaytaradi. Bu lock-free bayroqlar (bit maskalari)ni boshqarish uchun juda qulay bo'lib, buning uchun alohida mutex ishlatishga hojat qolmaydi:

example.go
var flags atomic.Uint32
flags.Or(1 << 2)  // 2-bitni atomik o'rnatish, lock kerak emas

Atomik amallar bitta son yoki bitta pointer bilan ishlash uchun to'g'ri tanlovdir. Ammo ikkita o'zaro bog'liq maydonni birgalikda, yaxlit holda yangilash kerak bo'lgan zahoti — masalan, hisoblagichni oshirish va bir vaqtning o'zida slice'ga element qo'shish kabi — atomik amallar yetarli bo'lmay qoladi. Chunki ikkita alohida atomik amalni bitta bo'linmas "bir vaqtdagi" amalga aylantirib bo'lmaydi. Bu esa mutex'ga o'tish zarurligini anglatuvchi aniq belgidir: mutex o'zining kritik bo'limidagi barcha amallarni yagona va bo'linmas birlik sifatida himoyalaydi.

sync.WaitGroup

sync.WaitGroup aniq sondagi goroutinelar to'liq yakunlanishini kutish usulidir va shu sababli u ushbu bob va oldingi boblardagi deyarli barcha misollarda uchradi. Add(n) ichki hisoblagichni oshiradi, har bir goroutine o'z ishini tugatganda Done()ni chaqiradi (odatda defer orqali), Wait() esa hisoblagich nolga tushguncha oqimni bloklab turadi.

example.go
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
	wg.Add(1)
	go func(id int) {
		defer wg.Done()
		fmt.Printf("goroutine %d done\n", id)
	}(i)
}
wg.Wait()
fmt.Println("all goroutines completed")

Addni goroutine ichida emas, balki uni ishga tushirishdan oldin chaqiring: agar Wait() hali ishga tushmagan goroutine'ning Add chaqiruvidan o'tib ketsa, hisoblagich kutilmaganda nolni ko'rsatib, Wait() o'sha goroutine hisobga olinmasdanoq o'z ishini tugatishi mumkin. Bu juda keng tarqalgan va nozik xatodir: u odatda lokal testlarda "ishlayotgandek" ko'rinadi (chunki goroutine Wait()ga yetguncha ulguradi), ammo production tizimlaridagi yuklama ostida vaqti-vaqti bilan kutilmagan nosozliklarga olib keladi. Go 1.25 dagi yangi vet analyzer'i (Go 1.26 da waitgroupgo deb qayta nomlangan) aynan shu turdagi xatolarni aniqlaydi: ya'ni tegishli goroutinening Done chaqiruvidan oldin to'g'ri tartiblanmagan Add chaqiruvlarini qidiradi.

Go 1.25 versiyasida shuningdek WaitGroup.Go(f func()) metodi qo'shildi. U yuqoridagi misoldagi Add(1) / go func() { defer wg.Done(); f() }() shablonini bitta qulay chaqiruvga birlashtiradi:

example.go
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    id := i
    wg.Go(func() {
        fmt.Printf("goroutine %d done\n", id)
    })
}
wg.Wait()
fmt.Println("all goroutines completed")

wg.Go(f) funksiyasi fni yangi goroutineda ishga tushiradi va unga mos Add(1)/Done() juftligini ichkarida avtomatik boshqaradi. Bu esa yuqorida aytilgan "Add juda kech chaqirildi" xatosining oldini strukturaviy jihatdan oladi: chunki alohida Add chaqiruvi yo'q, xato qilish ehtimoli ham bartaraf etilgan. Bu kichik qulaylik bo'lsa-da, "buni goroutineda ishga tushir va hisobga ol" talabi uchun eng toza va xavfsiz uslubdir.

Qaysi ish uchun qaysi vosita

To'rtta asosiy sinxronizatsiya primitivi va oldingi bobdagi channellar oldingizda turganda, amaliy savol tug'iladi: aynan qaysi biridan foydalanish kerak? Quyidagi tavsiyalar to'g'ri qaror qabul qilishga yordam beradi:

  • Channel'lar — goroutinelar orasida ma'lumot almashish uchun: qiymatni boshqa oqimga topshirish, ish yakunlanganini bildirish yoki konveyer (pipeline) jarayonlarini muvofiqlashtirish. Bu Go maqolining "muloqot qilib xotirani ulash" tamoyilidir.
  • Mutex — bir nechta goroutine to'g'ridan-to'g'ri o'qiydigan va yozadigan umumiy, o'zgaruvchan holatni himoyalash uchun: kesh, bir nechta bog'liq maydonli hisoblagich yoki atomik amallar bilan yetarlicha qamrab bo'lmaydigan har qanday murakkab struct.
  • RWMutex — Mutex bilan bir xil holatlarda, biroq o'qish amallari yozishdan sezilarli darajada ko'p bo'lganda va parallel o'quvchilar qo'shimcha resurs sarfiga arziganda.
  • Atomik amallar — tez-tez yangilanadigan bitta hisoblagich yoki bayroq (flag) uchun, bunda lock'ning qo'shimcha yuklamasi ortiqcha bo'lib, boshqa murakkab muvofiqlashtirish talab etilmaydi.
  • sync.Once — barcha goroutinelar uchun yagona bo'lgan bir martalik, dangasa (lazy) initsializatsiya amallarida.

Oddiy misol ushbu tanlovni yaqqol ko'rsatadi. Veb-server uchun so'rov hisoblagichini ikki xil yo'l bilan tuzish mumkin:

example.go
// Mutex versiyasi: sodda, tushunarli va yetarlicha tezkor.
type RequestCounter struct {
	mu    sync.Mutex
	count int
}

func (rc *RequestCounter) Increment() {
	rc.mu.Lock()
	rc.count++
	rc.mu.Unlock()
}

func (rc *RequestCounter) Get() int {
	rc.mu.Lock()
	defer rc.mu.Unlock()
	return rc.count
}

Xuddi shu vazifani bajaruvchi channel'ga asoslangan yondashuv — hisoblagich holatini faqat bitta goroutine boshqarib, qolganlar u bilan channel orqali muloqot qilishi — arxitektura jihatidan mumkin bo'lsa-da, oddiy hisoblagich uchun ortiqcha murakkablik keltiradi. Sababi, ikki qatorli mutex hal qiladigan ish uchun alohida goroutine, ikkita qo'shimcha channel va select sikli talab etiladi. Channel'lar tizimda haqiqiy asinxron muvofiqlashtirish bo'lgandagina — masalan, konveyer bosqichi, bekor qilish signali yoki ma'lumotga egalik huquqi almashganda — o'z kuchini ko'rsatadi. Agar vazifa shunchaki umumiy holatni saqlashdan iborat bo'lsa, uni to'g'ridan-to'g'ri mutex (yoki bitta qiymat bo'lsa, atomik amal) bilan himoyalash to'g'riroq yondashuvdir.

Amaliy intizom

Quyidagi qoidalar to'plami qaysi vositani tanlashingizdan qat'i nazar, concurrency bilan bog'liq eng ko'p tarqalgan xatolarning oldini olishga yordam beradi:

Kritik bo'limlarni qisqa saqlang. Qimmat amallarni — tarmoq so'rovlari, disk I/O yoki og'ir hisob-kitoblarni — lock tashqarisida bajaring va lock'ni faqat umumiy holatning o'zini o'qish yoki yozish uchungina ushlab turing. Sekin amal davomida ushlab turilgan lock unga muhtoj barcha goroutinelarni o'sha amal tugaguncha bloklab qo'yadi va tezkor almashinuv bo'lishi kerak bo'lgan joyni jiddiy to'siqqa (bottleneck) aylantiradi.

Lock ushlab turganda boshqa noma'lum kodlarni chaqirmang. Ayniqsa o'zi boshqa lock olishi mumkin bo'lgan kodlarni — jumladan aynan shu mutex'ning o'zini chaqirishdan ehtiyot bo'ling. sync.Mutex reentrant (qayta kirish mumkin bo'lgan) emas, shu sababli lock'ni ushlab turgan goroutine yana Lock() chaqirsa, o'z-o'zini bloklab deadlock holatiga tushadi.

Lock'larni qat'iy bir xil tartibda oling. Agar funksiya bir vaqtda ikkita mutex'ni ushlab turishi zarur bo'lsa, ularni dasturning hamma joyida doimo bir xil tartibda oling. Bir joyda mu1, keyin mu2ni, boshqa joyda esa mu2, keyin mu1ni olish — darsliklardagi klassik deadlock holatiga olib keladi va bu muammo odatda faqat haqiqiy yuklama ostida yuzaga chiqadi.

Testlarni doimo race detector bilan ishga tushiring. go test -race buyrug'ini faqat xatolik sezilgandagina emas, balki muntazam ravishda ishlatib turing. U dasturning ishlash vaqtida xotiraga murojaatlarni tahlil qiladi va xatoliklar hali fojiali oqibatlarga olib kelmasdanoq data race holatlarini aniqlab beradi. CI/CD tizimida poygani aniqlash uni production muhitida qidirishdan ancha arzon tushadi. Concurrency xatolari ko'pincha so'nggi daqiqagacha "hech qanday xatosiz ishlayotgandek" ko'rinish beradi. Keyingi bobda ko'rib chiqiladigan context.Context esa goroutinelarning asossiz cheksiz ishlab turishiga chek qo'yib, ularning xavfsiz va o'z vaqtida to'xtatilishini muvofiqlashtirish imkonini beradi.