1. Навіщо потрібен Mutex і де починається гонка
Якщо ви вперше пишете конкурентний код, дуже легко потрапити в пастку: «я запускав 10 разів — усе ок». А на 11-й запуск раптом з’являється дивний результат, дані зникають або програма зависає. Це не містика і не «комп’ютер втомився». Це звичайний наслідок паралельного виконання: дві goroutine одночасно лізуть у спільну змінну, одна читає, інша пише — і ви отримуєте стан, який в одиночному коді взагалі неможливий.
Важливо: Mutex — не «прискорювач» і не «магічна кнопка потокобезпечності». Це простий і строгий інструмент: в один момент часу всередину критичної секції заходить лише одна goroutine.
Критична секція
Критична секція — це фрагмент коду, де ви працюєте зі спільним змінюваним станом і вам важливо виключити одночасний доступ. Уявіть кухню в гуртожитку: якщо дві людини одночасно мішатимуть один і той самий суп в одній каструлі різними ложками, результат буде… творчим. У програмуванні «каструля» — це змінна, структура або map, а «ложки» — goroutine.
Проблема майже завжди проявляється там, де у вас є операція виду «прочитати → змінити → записати». Навіть якщо це виглядає як один оператор n++, під капотом це кілька кроків: прочитати n, додати 1, записати назад. Між цими кроками інша goroutine може встигнути зробити свою справу — і підсумок буде неправильним.
Міні‑демо гонки: лічильник, який «інколи не вміє рахувати»
Почнемо з короткого прикладу. Тут дві goroutine збільшують спільний лічильник. Наївно здається: «двічі збільшили — буде 2». Але без синхронізації це не гарантовано.
package main
import (
"fmt"
"sync"
)
func main() {
var (
n int
wg sync.WaitGroup
)
inc := func() {
defer wg.Done()
n++ // data race: дві goroutine одночасно змінюють n без узгодження
}
wg.Add(2)
go inc()
go inc()
wg.Wait()
fmt.Println(n) // може надрукувати 1 або 2 (а ви очікували завжди 2)
}
Якщо вам здається, що «в мене завжди виводиться 2», не розслабляйтеся. Це як пасок безпеки: доки не знадобився, здається зайвим. А потім раптово стає найважливішою частиною машини.
2. sync.Mutex: базове використання
sync.Mutex — це примітив взаємного виключення. Логіка проста: поки одна goroutine тримає замок, інші чекають.
У Mutex є два основні методи:
- Lock() — «увійти в критичну секцію»
- Unlock() — «вийти з критичної секції»
Важлива особливість Go: нульове значення sync.Mutex уже готове до роботи. Тобто можна писати var mu sync.Mutex і відразу використовувати, без make і без new.
Зазвичай виконання виглядає так:
flowchart TD
A[goroutine №1] -->|"mu.Lock()"| B[Критична секція]
B -->|"mu.Unlock()"| C[Вихід]
D[goroutine №2] -->|"mu.Lock() блокується"| E[Очікування]
E -->|після Unlock()| B
Це дуже чесна схема: замок не прискорює, він дисциплінує.
Виправляємо лічильник: критична секція навколо n++
Тепер зробімо те саме, але правильно: захистімо n++ за допомогою mutex.
package main
import (
"fmt"
"sync"
)
func main() {
var (
mu sync.Mutex
n int
wg sync.WaitGroup
)
inc := func() {
defer wg.Done()
mu.Lock()
n++
mu.Unlock()
}
wg.Add(2)
go inc()
go inc()
wg.Wait()
fmt.Println(n) // 2
}
Тепер «увійти → збільшити → вийти» відбувається строго по одному. Це і є мінімальна критична секція: тільки те, що реально працює зі спільним станом.
3. Ідіома Lock(); defer Unlock() та її межі
Чому defer mu.Unlock() — добрий стиль
Коли код стає трохи складнішим, майже неминуче з’являються розгалуження та ранні return. І тоді найнебезпечніша помилка — забути Unlock(). Ця помилка зазвичай не кричить одразу: програма просто «стає» (deadlock) і виглядає так, ніби нічого не відбувається.
Саме тому в Go люблять шаблон:
mu.Lock()
defer mu.Unlock()
Він настільки типовий, що в офіційних матеріалах його наводять як класичний випадок застосування defer: «звільнити mutex».
Приклад із defer:
package main
import "sync"
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
Сенс простий: щойно ви побачили Lock(), ви одразу, буквально наступним рядком, «пристібаєте пасок безпеки» — defer Unlock(). Так навіть при return усередині функції замок буде звільнений.
Чому defer Unlock() небезпечний усередині циклу
Тут починається тонкість, через яку деякі розробники спочатку закохуються в defer, а потім раптово починають його боятися — і інколи справедливо.
defer виконується під час виходу з функції, а не «в кінці найближчих фігурних дужок». Тому якщо ви зробили defer mu.Unlock() всередині циклу, ви можете тримати замок набагато довше, ніж думаєте.
Ось приклад того, як можна випадково тримати lock до кінця функції: усередині циклу беремо lock на «кошик», але через defer відпускаємо його лише тоді, коли функція завершиться, а це може статися дуже пізно.
Невдалий патерн (спрощено):
for i := range buckets {
b := &buckets[i]
b.mu.Lock()
defer b.mu.Unlock() // відпуститься лише під час return із функції
// копіюємо дані...
}
Натомість у циклі частіше краще робити звичайний Unlock() одразу після справи:
for i := range buckets {
b := &buckets[i]
b.mu.Lock()
count := b.guesses
b.mu.Unlock()
_ = count // використовуємо далі без lock
}
Мораль не в тому, що defer поганий. Мораль у тому, що defer — це «ввічлива обіцянка відпустити пізніше», і інколи це «пізніше» виявляється надто пізно.
4. Дизайн: «mutex належить даним» на прикладі TaskStore
Щоб приклади не висіли в повітрі, давайте продовжимо розвивати наш навчальний застосунок — умовно невеликий трекер задач. Раніше в нас уже була модель задачі та сховище в пам’яті. Сьогодні наша мета — зробити так, щоб до сховища можна було безпечно звертатися з різних goroutine.
Припустімо, задача виглядає так:
package main
type Task struct {
ID int
Title string
Done bool
}
Тепер сховище. Важливо: mu лежить поруч із даними, які захищає.
package main
import "sync"
type TaskStore struct {
mu sync.Mutex
next int
tasks map[int]Task
}
Ініціалізація: сьогодні ми не використовуємо sync.Once — лише звичайний конструктор.
package main
func NewTaskStore() *TaskStore {
return &TaskStore{
next: 1,
tasks: make(map[int]Task),
}
}
Запис: Add і узгоджений інваріант
Тепер додавання задачі. Тут спільний інваріант: «next збільшується і задача кладеться під новим ID». Отже, це має відбуватися в одній критичній секції.
package main
func (s *TaskStore) Add(title string) int {
s.mu.Lock()
defer s.mu.Unlock()
id := s.next
s.next++
s.tasks[id] = Task{ID: id, Title: title}
return id
}
Зверніть увагу на дві речі. По-перше, метод на *TaskStore (вказівник), бо ми змінюємо стан. По-друге, defer тут дуже доречний: усередині функції є кілька кроків, і дуже легко в майбутньому додати розгалуження.
Читання: чому не можна «повернути map назовні»
Коли ви захищаєте map за допомогою mutex, важливо розуміти: небезпечний не лише запис. Небезпечно ще й віддати назовні посилання на внутрішні дані, після чого зовнішній код почне читати або писати без замка — і ви знову отримаєте гонки.
Тому метод Get має повертати копію значення, а не внутрішні структури.
package main
func (s *TaskStore) Get(id int) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
return t, ok
}
Зауважте: ми повертаємо Task як значення — це зручно. Якби всередині були слайси або мапи, довелося б думати про «глибину копіювання». Але сьогодні нам досить зафіксувати принцип: не віддавайте назовні змінювані внутрішні структури без контролю.
Знімок даних: коротка критична секція
Чим довше ви тримаєте замок, тим більше інших goroutine стоять у черзі. І це вже не «потокобезпечність», а «черга за шаурмою в п’ятницю ввечері».
Найкраще: під lock швидко підготувати дані, відпустити lock і далі робити все важке без нього.
Наприклад, зробімо метод ListSnapshot, який повертає слайс задач. Ми не хочемо повертати внутрішній map, тому робимо «знімок» — копіюємо значення.
package main
func (s *TaskStore) ListSnapshot() []Task {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
out = append(out, t)
}
return out
}
Цей «знімок» безпечний, бо далі слайс живе власним життям. Навіть якщо паралельно хтось додасть задачі в map, ваш out уже сформований.
5. Міні‑перевірка: кілька goroutine пишуть у TaskStore
Зробімо маленьку демонстрацію: кілька goroutine додають задачі паралельно. Ми використовуємо WaitGroup, щоб дочекатися всіх.
package main
import (
"fmt"
"sync"
)
func main() {
store := NewTaskStore()
var wg sync.WaitGroup
add := func(title string) {
defer wg.Done()
id := store.Add(title)
fmt.Println("додано:", id) // додано: 1 (приклад)
}
wg.Add(3)
go add("вивчити mutex")
go add("писати код")
go add("пити чай")
wg.Wait()
fmt.Println(len(store.ListSnapshot())) // 3
}
Тут важливий психологічний момент: навіть якщо ви не бачите проблеми «на око», відсутність гонок — це не «опція», а базова вимога для коду, який переживе більше ніж один запуск.
6. Deadlock: як він виглядає і як не підставитися
Deadlock (взаємне блокування) у роботі з Mutex часто виглядає простіше, ніж звучить. Найчастіший сценарій: ви зробили Lock(), пішли гілкою if і вийшли з функції через return, забувши Unlock(). У результаті замок залишається захопленим назавжди, а всі інші goroutine, яким потрібен цей lock, чекатимуть нескінченно.
З defer ця конкретна проблема розв’язується майже повністю: щойно ви «пристебнули» defer Unlock(), ризик забути відпустити замок різко падає. Тому стиль «Lock() і одразу defer Unlock()» вважається ідіоматичним і широко вживаним.
Але в deadlock є ще й інший варіант: ви можете тримати замок і всередині критичної секції робити щось, що саме по собі може чекати (наприклад, читання з каналу або очікування іншого WaitGroup). Це вже небезпечніше: ви створюєте ситуацію «я тримаю замок і чекаю подію, яка ніколи не станеться, бо вона потребує цей самий замок». Сьогодні ми не розбираємо складніші конкурентні схеми, але правило варто запам’ятати просто зараз: не тримайте lock у місцях, де ви можете надовго заблокуватися.
Пам’ятка: що під lock можна, а що краще не треба
Іноді корисно мати в голові просту пам’ятку. Це не «закон», але хороший старт.
| Дія | Зазвичай можна під Mutex | Чому |
|---|---|---|
| Змінити поле/лічильник/прапорець | Так | У цьому й сенс критичної секції |
| Прочитати/записати map | Так | map не можна безпечно змінювати одночасно без узгодження |
| Зробити копію даних («знімок») | Так | Це швидкий спосіб віддати назовні безпечне значення |
| Друкувати в stdout (fmt.Println) | Краще не робити | I/O може бути повільним і триматиме lock довше |
| Чекати на читання/запис у канал | Небезпечно | Можна випадково зависнути в режимі «тримають lock і чекають» |
| Викликати «чужий» код (callback) | Небезпечно | Ви не знаєте, що він зробить (раптом спробує взяти той самий lock) |
Чому структуру з Mutex не можна копіювати
Це дуже важлива практична деталь. sync.Mutex — це стан. Якщо ви скопіювали структуру з mutex, ви отримали дві копії замка, які не знають одна про одну. А дані, можливо, лишилися спільними або ви очікували, що спільні. Підсумок: синхронізація ламається, і ви отримуєте гонки, які виглядають як «неможливі».
Тому типовий дизайн такий: сховище та інші структури з Mutex живуть за вказівником, а методи в них теж зазвичай мають приймач за вказівником (func (s *TaskStore) ...).
7. Типові помилки під час роботи з sync.Mutex
Помилка № 1: забули Unlock() і отримали зависання.
Найчастіше це стається через ранній return або через те, що код розрісся і Unlock опинився внизу функції, а нових гілок стало багато. Найнадійніше лікування — звичка писати defer mu.Unlock() одразу після Lock(). Цей патерн настільки стандартний, що його наводять як канонічний приклад використання defer.
Помилка № 2: тримаємо lock надто довго, бо «ну я ж підстрахувався».
Іноді розробник ставить Lock() на початку функції «про всяк випадок», а всередині робить усе підряд: сортування, форматування рядків, fmt.Println, навіть мережеві виклики. У результаті інші goroutine стоять у черзі, а програма стає повільною і «смиканою». Лікується дисципліною: під lock — лише робота із захищеними полями, усе важке — після Unlock().
Помилка № 3: defer Unlock() усередині циклу.
Це класична пастка: здається, що defer «відпустить у кінці ітерації», а він відпускає в кінці функції. У реальному коді це може тримати lock до завершення довгої операції та блокувати інші goroutine. Такий сценарій розбирали на прикладі, де defer усередині циклу тримав lock довше, ніж планувалося. Усередині циклів часто краще робити явний Unlock() одразу після потрібної дії.
Помилка № 4: копіюємо структуру з Mutex.
Скопіювали структуру — скопіювали і mutex. У результаті синхронізація перестає захищати те, що, на вашу думку, вона захищає. Це особливо підступно, бо компілятор не зобов’язаний вас попереджати. Зазвичай це розв’язується тим, що такі структури живуть за вказівником і не копіюються після початку роботи.
Помилка № 5: «Mutex захищає змінну», а не інваріант.
Наприклад, у вас є пара полів (next і tasks), які мають змінюватися узгоджено. Якщо ви захистили lock’ом лише оновлення tasks, але не захистили next, ви все одно можете отримати неузгоджений стан: пропуски ID, перезаписи, дублікати. Правильна думка: lock захищає узгодженість (інваріант), а не один рядок коду.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ