Tarkibga o'tish

Mutex

Mutex — umumiy o‘zgaruvchiga bir vaqtda faqat bitta goroutine kirishini ta’minlaydigan sinxronizatsiya vositasi. Go’da u sync.Mutex turi orqali ishlatiladi.

Bir nechta goroutine faqat qiymat o‘qisa, odatda muammo bo‘lmaydi. Ammo ulardan kamida bittasi bir xil xotira manziliga yozsa, kirishni muvofiqlashtirish kerak. Aks holda natija noto‘g‘ri chiqishi yoki dasturda data race yuz berishi mumkin.

Tasavvur qiling, bank hisobida 1000 so‘m bor. Bankomat 800 so‘m, mobil ilova esa 700 so‘m yechmoqchi. Ikkala operatsiya ham bir vaqtda eski balansni o‘qisa, har biri mablag‘ yetarli deb o‘ylashi mumkin. Balansni tekshirish va yangilash bitta ajralmas mantiqiy amal sifatida himoyalanishi kerak.

Data race va race condition farqi

Bu ikki atama yaqin, ammo aynan bir xil emas.

Data race — ikki yoki undan ortiq goroutine bir xil xotira manziliga sinxronizatsiyasiz bir vaqtda murojaat qilishi va murojaatlardan kamida bittasi yozish bo‘lishi. Go memory modeli nuqtayi nazaridan bunday kod noto‘g‘ri.

Race condition — dastur natijasi operatsiyalarning kutilmagan bajarilish tartibiga bog‘liq bo‘lib qolishi. Race condition har doim data race bo‘lishi shart emas. Masalan, kanallar bilan sinxronlashtirilgan ikki hodisadan qaysi biri oldin kelishiga noto‘g‘ri tayanish data racesiz ham mantiqiy xato keltirishi mumkin.

Mutex umumiy xotiradagi data raceni bartaraf etishga yordam beradi. Ammo buning uchun shu ma’lumotga murojaat qiladigan barcha kod bir xil mutex va bir xil qulflash qoidasiga amal qilishi kerak.

Nima uchun counter++ xavfsiz emas?

counter++ manba kodida bitta qator bo‘lsa ham, uni concurrent ishlatish uchun atomar amal deb bo‘lmaydi. Mantiqan uch bosqich bor:

  1. counter qiymatini o‘qish;
  2. qiymatni bittaga oshirish;
  3. natijani xotiraga qayta yozish.

Ikki goroutine bir xil eski qiymatni o‘qib, bir xil yangi qiymatni yozishi mumkin. Natijada oshirishlardan biri yo‘qoladi.

Quyidagi dastur ataylab noto‘g‘ri yozilgan:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    counter := 0

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // Noto‘g‘ri: umumiy qiymat himoyalanmagan.
        }()
    }

    wg.Wait()
    fmt.Println("Hisoblagich:", counter)
}

Natija ba’zan 1000 bo‘lishi mumkin, ba’zan esa undan kichik chiqadi. 1000 chiqishi kod xavfsizligini isbotlamaydi. Schedulerning bajarish tartibi, CPU yadrolari soni va boshqa omillar natijaga ta’sir qiladi.

Bu dasturni race detector bilan tekshirish mumkin:

go run -race main.go

Race detector bajarilish paytida kuzatilgan qarama-qarshi xotira murojaatlarini WARNING: DATA RACE xabari bilan ko‘rsatadi. U faqat amalda bajarilgan yo‘llarni tekshiradi. Ogohlantirish chiqmagani data race mutlaqo yo‘qligini kafolatlamaydi; testlar muhim kod yo‘llarini bajarishi kerak.

Testlar, -race parametri va performance o‘lchovi Benchmark va race detector darsida birga ko‘rib chiqiladi.

Mutex qanday ishlatiladi?

sync.Mutexning ikki asosiy metodi bor:

  • Lock() — qulfni oladi; qulf boshqa goroutineda bo‘lsa, bo‘shashini kutadi;
  • Unlock() — olingan qulfni bo‘shatadi.

Lock() va Unlock() orasidagi himoyalangan kod kritik bo‘lim (critical section) deyiladi:

mu.Lock()
counter++
mu.Unlock()

sync.Mutexning nol qiymati (zero value) ishlatishga tayyor. Konstruktor kerak emas:

var mu sync.Mutex

Endi hisoblagich misolini to‘g‘rilaymiz:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var (
        wg      sync.WaitGroup
        mu      sync.Mutex
        counter int
    )

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()

            mu.Lock()
            counter++
            mu.Unlock()
        }()
    }

    wg.Wait()
    fmt.Println("Hisoblagich:", counter)
}

Natija:

Hisoblagich: 1000

Har bir goroutine counterni o‘zgartirishdan oldin bir xil mu qulfini oladi. Qulf band bo‘lsa, goroutine kutadi. Shu sabab oshirish amallari bir-birining ustiga tushmaydi.

WaitGroup boshqa vazifani bajaradi: u main funksiyasini barcha goroutinelar tugaguncha kutdiradi. WaitGroup umumiy qiymatni data racedan himoya qilmaydi, Mutex esa goroutinelarning tugashini kutmaydi.

defer bilan qulfni bo‘shatish

Funksiyada bir nechta return yo‘li bo‘lsa, Unlock()ni unutish oson. Qulf olingandan keyin defer ishlatish uni funksiya qaytayotganda bo‘shatishni kafolatlaydi:

mu.Lock()
defer mu.Unlock()

Bu usul panic paytida stek ochilayotganida ham Unlock()ni chaqiradi. Ammo ushlanmagan panic keyin baribir dasturni to‘xtatadi.

Warning

defer mu.Unlock()ni Lock() muvaffaqiyatli chaqirilgandan keyin yozing. Qulf olinmagan holda Unlock() chaqirish runtime xatosiga olib keladi.

Juda kichik va tez-tez ishlaydigan kritik bo‘limlarda bevosita Unlock() ozroq xarajat qilishi mumkin. Lekin avval kodning to‘g‘riligini ta’minlang, keyin benchmark bilan o‘lchang. Erta optimallashtirish qulf ochilmay qoladigan xatolarga olib kelishi mumkin.

Bank hisobini xavfsiz boshqarish

Umumiy ma’lumot va uni himoya qiladigan mutexni bitta tur ichida saqlash qulf egasini aniq ko‘rsatadi:

package main

import (
    "errors"
    "fmt"
    "sync"
)

var (
    errInvalidAmount     = errors.New("miqdor musbat bo‘lishi kerak")
    errInsufficientFunds = errors.New("hisobda mablag‘ yetarli emas")
)

type Account struct {
    mu      sync.Mutex
    balance int
}

func NewAccount(initialBalance int) (*Account, error) {
    if initialBalance < 0 {
        return nil, errors.New("boshlang‘ich balans manfiy bo‘lishi mumkin emas")
    }

    return &Account{balance: initialBalance}, nil
}

func (a *Account) Withdraw(amount int) error {
    if amount <= 0 {
        return errInvalidAmount
    }

    a.mu.Lock()
    defer a.mu.Unlock()

    if a.balance < amount {
        return errInsufficientFunds
    }

    a.balance -= amount
    return nil
}

func (a *Account) Balance() int {
    a.mu.Lock()
    defer a.mu.Unlock()

    return a.balance
}

func main() {
    account, err := NewAccount(1000)
    if err != nil {
        fmt.Println("Hisob yaratilmadi:", err)
        return
    }

    requests := []struct {
        name   string
        amount int
    }{
        {name: "Bankomat", amount: 800},
        {name: "Mobil ilova", amount: 700},
    }

    var wg sync.WaitGroup
    results := make([]error, len(requests))

    for i, request := range requests {
        wg.Add(1)
        go func(index int, amount int) {
            defer wg.Done()
            results[index] = account.Withdraw(amount)
        }(i, request.amount)
    }

    wg.Wait()

    for i, request := range requests {
        if results[i] != nil {
            fmt.Printf("%s: %v\n", request.name, results[i])
            continue
        }
        fmt.Printf("%s: %d so‘m yechildi\n", request.name, request.amount)
    }

    fmt.Println("Yakuniy balans:", account.Balance())
}

Natijaning mumkin bo‘lgan ko‘rinishi:

Bankomat: 800 so‘m yechildi
Mobil ilova: hisobda mablag‘ yetarli emas
Yakuniy balans: 200

Qaysi goroutine qulfni birinchi olishi kafolatlanmaydi. Mobil ilova oldin bajarsa, u 700 so‘m yechadi, bankomatga mablag‘ yetmaydi va yakuniy balans 300 bo‘ladi. Har ikki holatda ham faqat bitta yechish muvaffaqiyatli tugaydi va balans manfiy bo‘lmaydi.

Withdraw ichida balansni tekshirish va kamaytirish bitta kritik bo‘limda bajarilgan. Agar tekshirish qulfdan oldin, kamaytirish esa qulf ichida bajarilsa, tekshirish bilan yozish orasida boshqa goroutine balansni o‘zgartirishi mumkin edi. Qulf alohida qatorni emas, butun invariantni himoya qilishi kerak. Bu misoldagi invariant — balansning manfiy bo‘lmasligi.

amount <= 0 tekshiruvi umumiy holatga murojaat qilmaydi, shuning uchun qulfdan oldin bajarilgan. Bu qulf ushlab turiladigan vaqtni qisqartiradi. results slicening uzunligi oldindan ajratilgan va har bir goroutine faqat o‘z indeksiga yozadi. wg.Wait()dan keyin main natijalarni o‘qiydi.

Mutexni ma’lumot bilan birga saqlash

Mutex odatda o‘zi himoya qiladigan maydonlar yonida turadi:

type Cache struct {
    mu    sync.Mutex
    items map[string]string
}

Bu tuzilish mu aynan itemsni himoya qilishini ko‘rsatadi. Metodlar pointer receiver bilan yoziladi. Qiymat receiver ishlatilsa, Cache va uning ichidagi mutex nusxalanishi mumkin.

sync.Mutex birinchi ishlatilgandan keyin nusxalanmasligi kerak. Mutexli structni funksiyaga qiymat sifatida uzatish, qiymat receiver ishlatish yoki tayyor obyektni nusxalash qulfning alohida nusxalarini yaratishi mumkin. Natijada goroutinelar bir xil ma’lumotni turli qulflar bilan “himoya” qiladi.

go vet ayrim mutex nusxalash xatolarini topa oladi:

go vet ./...

Mutexning xotira kafolati

Mutex faqat goroutinelarni navbatga qo‘ymaydi. U xotira ko‘rinishini ham sinxronlashtiradi. Bir goroutinedagi Unlock()dan oldin bajarilgan yozuvlar, keyinchalik shu mutexni muvaffaqiyatli Lock() qilgan goroutinega ko‘rinadi.

Sodda qilib aytganda, qulfni olgan goroutine oldingi egasi himoyalangan ma’lumotga kiritgan o‘zgarishlarni ko‘radi. Bu kafolat faqat bir xil mutexdan to‘g‘ri foydalanilganda amal qiladi.

Kritik bo‘limni qisqa saqlash

Mutex ushlab turilgan vaqt ichida boshqa goroutinelar o‘sha qulfni ola olmaydi. Shu sabab kritik bo‘limda faqat umumiy holatni tekshirish va o‘zgartirish uchun zarur kod qolishi kerak.

Qulf ichida quyidagi sekin amallarni bajarishdan ehtiyot bo‘ling:

  • tarmoq so‘rovi;
  • database chaqiruvi;
  • fayl o‘qish yoki yozish;
  • uzoq hisoblash;
  • kanalga bloklanuvchi yuborish;
  • tashqi callback yoki noma’lum metodni chaqirish.

Masalan, HTTP so‘rovini qulf ichida bajarish boshqa goroutinelarni tarmoq javobi kelguncha kutdiradi. Kerakli ma’lumotni qulf ostida nusxalab oling, qulfni bo‘shating, keyin sekin amalni bajaring. Ammo tekshirish va yangilash oralig‘ida holat o‘zgarmasligi kerak bo‘lsa, algoritmni qayta loyihalash talab qilinishi mumkin.

Deadlock va qayta qulflash

Deadlock — goroutinelar bir-biridan hech qachon kelmaydigan qulf yoki signalni kutib qoladigan holat.

Bir mutexni qayta qulflash

Go’dagi sync.Mutex qayta kiriladigan (reentrant) qulf emas. Bir goroutine olgan mutexni Unlock() qilmasdan yana Lock() qilsa, o‘zini kutib qoladi:

// Noto‘g‘ri misol: update mu qulfini olib, value metodini chaqiradi.
// value ham o‘sha mu qulfini olishga urinadi.
func (s *Store) update() {
    s.mu.Lock()
    defer s.mu.Unlock()

    _ = s.value()
}

Bunday holatda qulf talab qilmaydigan ichki yordamchi metod yozish mumkin. Qulfni tashqi metod oladi, ichki metod esa chaqiruvchi qulfni olgan degan aniq qoida bilan ishlaydi.

Qulflarni turli tartibda olish

Ikki mutex bir nechta joyda teskari tartibda olinsa, goroutinelar bir-birini kutib qolishi mumkin:

// Birinchi yo‘l: muA.Lock(), keyin muB.Lock()
// Ikkinchi yo‘l: muB.Lock(), keyin muA.Lock()

Barcha kodda qulflarni bir xil global tartibda oling. Imkon bo‘lsa, bir operatsiyada bir nechta qulf ushlashni kamaytiring. defer qulfni unutmaslikka yordam beradi, lekin noto‘g‘ri qulflash tartibini avtomatik tuzatmaydi.

RWMutex: o‘qish va yozishni ajratish

sync.RWMutex yozuvchi uchun Lock/Unlock, o‘quvchi uchun RLock/RUnlock beradi. Bir nechta o‘quvchi qulfni bir vaqtda ushlashi mumkin. Yozuvchi esa eksklyuziv kiradi.

package main

import (
    "fmt"
    "sync"
)

type Scores struct {
    mu     sync.RWMutex
    values map[string]int
}

func NewScores() *Scores {
    return &Scores{values: make(map[string]int)}
}

func (s *Scores) Set(name string, score int) {
    s.mu.Lock()
    defer s.mu.Unlock()

    s.values[name] = score
}

func (s *Scores) Get(name string) (int, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    score, ok := s.values[name]
    return score, ok
}

func main() {
    scores := NewScores()
    scores.Set("Ali", 95)

    score, ok := scores.Get("Ali")
    if !ok {
        fmt.Println("Natija topilmadi")
        return
    }

    fmt.Println("Ali:", score)
}

Natija:

Ali: 95

Set mapga yozgani uchun eksklyuziv Lock oladi. Get faqat o‘qiydi va RLockdan foydalanadi. Oddiy Go mapi concurrent yozish yoki yozish bilan bir paytdagi o‘qish uchun xavfsiz emas; mutex bu murojaatlarni muvofiqlashtiradi.

RWMutex oddiy Mutexdan har doim tez emas. O‘qishlar qisqa bo‘lsa, goroutinelar soni kam bo‘lsa yoki yozishlar tez-tez bajarilsa, qo‘shimcha boshqaruv xarajati foydani yo‘qqa chiqarishi mumkin. RWMutexni faqat ko‘p concurrent o‘qish va kam yozish holatida benchmarkdan keyin tanlang.

RLockni ushlab turgan holda uni Lockga ko‘tarish (upgrade) qo‘llab-quvvatlanmaydi. Avval RUnlock, keyin Lock qilish oralig‘ida holat o‘zgarishi mumkin. Yozish qarori holatga bog‘liq bo‘lsa, eksklyuziv qulfni olib, shartni qayta tekshiring.

Mutex, channel yoki atomic?

Sinxronizatsiya vositasi ma’lumot oqimiga qarab tanlanadi:

  • bir obyektning kichik umumiy holatini himoya qilish uchun sync.Mutex ko‘pincha tushunarli tanlov;
  • ko‘p o‘qish va kam yozish bo‘lsa, o‘lchovdan keyin sync.RWMutex foydali bo‘lishi mumkin;
  • ish yoki ma’lumot egaligini goroutinelar orasida uzatish uchun channel mos keladi;
  • oddiy hisoblagich yoki flag kabi alohida amallar uchun sync/atomic ishlatilishi mumkin.

atomic bir nechta maydonni qamrab oladigan invariantni o‘z-o‘zidan himoya qilmaydi. Masalan, balansni tekshirish va kamaytirish kabi bog‘liq amallar uchun mutex tushunarliroq. Channel ham har qanday umumiy obyektni avtomatik xavfsiz qilmaydi: channel orqali pointer yuborilgach, obyektni bir nechta goroutine o‘zgartirsa, sinxronizatsiya baribir kerak.

Keng tarqalgan xatolar

  • Faqat yozishni qulflab, o‘qishni qulflamaslik. Yozish bilan bir paytdagi himoyasiz o‘qish ham data race hisoblanadi.
  • Bir xil ma’lumot uchun turli mutexlardan foydalanish. Qulflar bir-biridan bexabar bo‘lgani uchun himoya ishlamaydi.
  • Unlock()ni unutish. Keyingi Lock()lar abadiy kutib qolishi mumkin.
  • Qulf olinmagan mutexni Unlock() qilish. Bu runtime xatosi bilan tugaydi.
  • Mutexli structni nusxalash. Nusxalar umumiy ma’lumotni bitta qulf bilan muvofiqlashtirmaydi.
  • Qulf ichida sekin I/O bajarish. Bu contentionni, ya’ni qulf uchun raqobat va kutishni oshiradi.
  • Bir mutexni o‘sha goroutineda qayta olish. sync.Mutex reentrant emas.
  • RWMutex doim tezroq deb hisoblash. Tanlov benchmark va haqiqiy yuk asosida qilinadi.
  • Race detector barcha muammoni isbotlab beradi deb o‘ylash. U faqat tekshiruv vaqtida bajarilgan yo‘llardagi data racelarni topadi va mantiqiy race conditionlarni to‘liq aniqlamaydi.

Interviewda nimalarga e’tibor beriladi?

  • Mutexning zero value qiymati ishlatishga tayyor.
  • Mutex birinchi ishlatilgandan keyin nusxalanmasligi kerak.
  • Lock va Unlock orasidagi kritik bo‘lim imkon qadar qisqa bo‘lishi kerak.
  • sync.Mutex reentrant emas va goroutine egasini kuzatmaydi.
  • WaitGroup yakunni kutadi, Mutex esa umumiy xotirani himoya qiladi.
  • RWMutex ko‘p o‘quvchiga ruxsat beradi, yozuvchi esa eksklyuziv qulf oladi.
  • To‘g‘ri sinxronizatsiya data raceni yo‘qotadi, lekin noto‘g‘ri biznes mantiqidan keladigan barcha race conditionlarni avtomatik hal qilmaydi.

Xulosa

Mutex umumiy o‘zgaruvchan holatni bir vaqtda faqat bitta goroutine o‘zgartirishini ta’minlaydi. Qulf alohida qatorni emas, ma’lumot invariantini himoya qilishi kerak. Shu ma’lumotga kiradigan barcha kod bir xil mutex va bir xil qulflash qoidasiga amal qilishi zarur.

Mutexni himoyalanadigan ma’lumot bilan birga saqlang, nusxalamang va kritik bo‘limni qisqa tuting. Data racelarni -race bilan tekshiring, performance qarorlarini esa benchmark va profiling asosida qabul qiling.