Перейти до змісту

Синхронізація: sync та atomic

Канали — це бажаний у Go спосіб координувати горутини, але інколи вам просто треба захистити частину спільного стану. Пакети sync та sync/atomic дають класичні інструменти: м'ютекси, одноразову ініціалізацію та лічильники без блокувань.

Проблема: стани гонитви

Коли дві горутини торкаються тієї самої змінної й принаймні одна пише, без синхронізації результат — це стан гонитви (data race), невизначена поведінка. Уявіть лічильник переглядів сторінки, який збільшують 1000 конкурентних запитів — це виглядає так, ніби він сягне 1000, але надійно цього не робить:

hits := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() { defer wg.Done(); hits++ }()   // ГОНИТВА: конкурентні записи
}
wg.Wait()
fmt.Println(hits)   // непередбачувано: часто < 1000

hits++ — це читання-зміна-запис, три кроки, що можуть переплестися й втратити оновлення, тож деякі запити «рахуються» один поверх одного.

sync.Mutex: взаємне виключення

Mutex пускає в захищену секцію лише одну горутину за раз. Lock перед доступом до спільного стану, Unlock після (зазвичай через defer).

var mu sync.Mutex
hits := 0
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        mu.Lock()
        hits++          // один запит рахується за раз
        mu.Unlock()
    }()
}
wg.Wait()
fmt.Println(hits)   // output: 1000

Тепер інкременти серіалізовано, тож кожен запит порахований — завжди 1000.

sync.RWMutex: багато читачів або один письменник

Коли читань значно більше за записи, RWMutex дозволяє будь-якій кількості читачів працювати паралельно (RLock/RUnlock), тоді як записи (Lock/Unlock) отримують винятковий доступ.

var mu sync.RWMutex
balance := 0
var wg sync.WaitGroup

// один письменник
wg.Add(1)
go func() {
    defer wg.Done()
    for i := 0; i < 100; i++ {
        mu.Lock()           // винятково
        balance++
        mu.Unlock()
    }
}()

// три конкурентні читачі
for r := 0; r < 3; r++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        for i := 0; i < 100; i++ {
            mu.RLock()      // спільно — читачі не блокують одне одного
            _ = balance
            mu.RUnlock()
        }
    }()
}

wg.Wait()
fmt.Println(balance)        // output: 100

Читачі виконуються паралельно один з одним; лише Lock письменника змушує всіх інших чекати. Запустіть із go run -race — і це чисто.

sync.Once: виконати рівно раз

Once.Do(f) виконує f один раз, скільки б горутин його не викликали й як би часто — стандартний спосіб ледачої потокобезпечної ініціалізації.

var once sync.Once
setup := func() { fmt.Println("init") }

for i := 0; i < 3; i++ {
    once.Do(setup)
}
// output:
// init

sync/atomic: лічильники без блокувань

Для одного цілого атомарний тип простіший і швидший за м'ютекс. Типізовані атоміки (atomic.Int64, atomic.Bool, …) несуть власну синхронізацію:

var hits atomic.Int64
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() { defer wg.Done(); hits.Add(1) }()
}
wg.Wait()
fmt.Println(hits.Load())   // output: 1000

Беріть атоміки для простих лічильників і прапорців; беріть м'ютекс, коли треба тримати кілька значень узгодженими разом.

Детектор гонитв

Go постачає детектор гонитв (race detector), який інструментує доступ до пам'яті та повідомляє про гонитви під час виконання. Запускайте програму чи тести з -race:

go run -race .
go test -race ./...

Запуск гонитвового циклу count++ з початку цієї статті з -race повідомляє про неї:

$ go run -race .
==================
WARNING: DATA RACE
Read at 0x00c0000a0068 by goroutine 8:
  main.main.func1()
Previous write at 0x00c0000a0068 by goroutine 9:
  main.main.func1()
==================

Він ловить лише ті гонитви, що насправді трапилися під час запуску, тож використовуйте його з тестами, що навантажують конкурентність. Це один із найцінніших інструментів Go — заведіть звичку запускати тести з -race у CI.

З погляду Python: немає GIL, що серіалізує байткод, тож код Go справді зазнає гонитв. Зворотний бік: справжня паралельність плюс першокласний детектор, щоб ловити помилки, які GIL приховав би.

Швидка довідка

Інструмент Застосування
sync.Mutex (Lock/Unlock) винятковий доступ до спільного стану
sync.RWMutex (RLock/RUnlock) багато читачів або один письменник
sync.Once (Do) виконати ініціалізацію рівно раз
sync.WaitGroup чекати завершення горутин
atomic.Int64 тощо (Add/Load/Store) лічильники й прапорці без блокувань
go run -race / go test -race виявляти стани гонитви

Джерела