1. Навіщо потрібен «один раз» у конкурентному коді
Майже в кожному застосунку є етап підготовки: створити кеш, підготувати map, завантажити конфіг, зібрати індекс, відкрити з’єднання, прогріти дані. В однопотоковому коді ми просто робимо це на початку main() і не думаємо про наслідки. Але щойно з’являються goroutine, виявляється, що підготовка може запускатися з різних місць одночасно: один обробник запиту, другий, третій — і всі вирішили, що саме вони перші.
Небезпека тут не лише в тому, що ініціалізація виконається двічі, хоча це теж неприємно. Гірше інше: хтось може почати працювати з частково ініціалізованим станом. Це якби одна людина вже поставила каструлю на плиту й каже: «суп майже готовий», а друга вже розливає його по тарілках, бо «каструля ж є». У найкращому разі буде дивно, а в найгіршому — станеться panic або з’являться некоректні дані.
Наївна лінива ініціалізація та гонка даних
Дуже природна думка новачка звучить так: «Та що там, я просто перевірю, і якщо не ініціалізовано — ініціалізую». В одній goroutine це нормально. У кількох — майже гарантоване джерело гонки даних.
Ось типова «лінива ініціалізація» глобальної мапи:
package main
import "fmt"
var cache map[string]int
func getCache() map[string]int {
if cache == nil {
cache = make(map[string]int)
}
return cache
}
func main() {
m := getCache()
m["x"] = 1
fmt.Println(m["x"]) // 1
}
Поки ви викликаєте getCache() з одного місця, усе добре. Але якщо дві goroutine одночасно побачать cache == nil, вони обидві спробують записати в cache. І навіть якщо «пощастить» і panic не станеться, у вас буде гонка даних: програма стає недетермінованою, тобто може поводитися по-різному за однакового вводу.
Це той самий класичний випадок, коли «іноді працює» — поганий знак, а не привід радіти.
2. sync.Once: контракт і важливі нюанси
Коли виникає потреба «виконати рівно один раз» у конкурентному коді, у Go майже завжди згадують sync.Once. Це невеликий об’єкт, який гарантує: функція ініціалізації виконається один раз, а інші виклики зачекають, якщо треба, і потім продовжать роботу.
Основний контракт Do
Once.Do(f) викликає f лише під час першого виклику для цього об’єкта Once. Інші виклики Do не запускатимуть f знову, навіть якщо ви передали іншу функцію. Це прямо описано в документації пакета sync.
Once не можна копіювати після початку використання
Також важливо, що Once не можна копіювати після початку використання. Документація формулює це так: «must not be copied after first use».
Рекурсивний Do і deadlock
Ще одна тонкість: якщо f викликає Do повторно, наприклад під час ініціалізації ви випадково викликаєте метод, який знову намагається «ensure-init», ви отримаєте deadlock. Do чекає завершення f, а f чекає, доки Do завершиться. Документація попереджає про це прямо.
Panic усередині Do
Якщо f запанікує, Do вважає, що f завершилася, і майбутні виклики не намагатимуться виконати ініціалізацію знову. Тобто panic під час init — це не «ну потім спробуємо ще раз», а «усе, спробу зафіксовано».
Практичний висновок такий: ініціалізація всередині Once.Do має бути максимально нудною та передбачуваною. Якщо там можливий panic, наприклад ви робите щось, що може впасти, краще переробити код так, щоб поверталася помилка, а не виникав panic.
Видимість пам’яті: чому після Do можна довіряти даним
У конкурентному програмуванні є окрема, майже магічна категорія проблем: навіть якщо все ніби виконалося, інша goroutine може не побачити зміни через переупорядкування та кеші CPU. Це не міф і не страшилка для студентів — це реальність.
Тому в документації sync.Once окремо згадується відношення в моделі пам’яті Go: повернення з f «synchronizes before» повернення з будь-якого once.Do(f). Людською мовою це означає: якщо одна goroutine виконала ініціалізацію всередині Do, то інші goroutine, які повернулися з Do, побачать результати цієї ініціалізації в коректному стані — не частково і не «як пощастить».
Можна зобразити це мінісхемою:
sequenceDiagram
participant G1 as горутина A
participant G2 as горутина B
G1->>G1: once.Do(init)
Note over G1: init() виконується
G2->>G2: once.Do(init)
Note over G2: чекає завершення init()
Note over G1,G2: після повернення Do обидві goroutine бачать ініціалізований стан
Чому Once не замінює Mutex
На цьому місці часто хочеться спитати: «А можна просто всюди Once поставити й забути про блокування?» На жаль, або на щастя, — ні.
Once розв’язує задачу «одноразового виконання». Це ідеально для make(map[...]), лінивого завантаження конфігурації, компіляції регулярки, прогріву кешу. Але щойно після ініціалізації дані продовжують змінюватися, вам знову потрібна звичайна синхронізація: Mutex, RWMutex, канали — залежно від дизайну.
Щоб було простіше орієнтуватися, ось компактна таблиця: «який інструмент для чого».
| Інструмент | Для чого призначений | Типовий приклад |
|---|---|---|
|
один раз виконати шматок коду | ліниво створити map, завантажити конфіг |
|
захистити інваріант під час читання й запису | map, слайси, кілька пов’язаних полів |
|
проста незалежна змінна | лічильник, прапорець «готово» |
Поведінка sync.Once.Do як «виконати рівно один раз» і деталі про deadlock/panic — це не здогадки, а прямий опис контракту в документації.
3. Приклади використання sync.Once
Мінімальний приклад: створюємо мапу рівно один раз
Давайте перепишемо приклад із cache так, щоб він був безпечним за конкурентних викликів.
package main
import (
"fmt"
"sync"
)
var (
once sync.Once
cache map[string]int
)
func getCache() map[string]int {
once.Do(func() {
cache = make(map[string]int)
})
return cache
}
func main() {
m := getCache()
m["x"] = 1
fmt.Println(m["x"]) // 1
}
Тут ключове — once.Do(...): ініціалізація cache = make(...) відбудеться один раз. Важливо правильно відчути ідею: sync.Once — це не «перевірка прапорця», а готовий безпечний шаблон для конкурентного сценарію.
Якщо хочете побачити цю ідею в бойовому фрагменті, у прикладі з матеріалів про трасування Go трапляється такий патерн: once.Do використовується, щоб зняти знімок рівно один раз, навіть якщо кілька goroutine одночасно намагаються це зробити.
sync.Once усередині структури: лінива ініціалізація як частина типу
Коли ви пишете реальний застосунок, глобальні змінні швидко починають заважати: тестувати незручно, розширювати незручно, та й просто це погано пахне. Набагато приємніше тримати Once поруч із тим станом, який він ініціалізує: дані та примітив синхронізації поряд.
Уявімо, що в нашому навчальному застосунку — умовному таск-менеджері — є сховище задач у пам’яті. Ми хочемо, щоб воно могло створюватися ліниво: наприклад, щоб TaskStore можна було створити нульовим значенням, а першу реальну ініціалізацію зробити під час першого використання.
Скелет моделі, дуже простий:
package main
type Task struct {
ID int
Title string
}
Тепер сховище:
package main
import "sync"
type TaskStore struct {
once sync.Once
mu sync.Mutex
nextID int
tasks map[int]Task
}
func (s *TaskStore) ensureInit() {
s.once.Do(func() {
s.nextID = 1
s.tasks = make(map[int]Task)
})
}
Зверніть увагу на комбінацію: once відповідає лише за те, щоб підготувати поля один раз, а mu — за безпечну роботу з map та лічильником надалі. sync.Once не «робить об’єкт потокобезпечним повністю», він розв’язує лише вузьку задачу — ініціалізацію один раз.
Додамо метод створення задачі:
package main
func (s *TaskStore) Create(title string) Task {
s.ensureInit()
s.mu.Lock()
defer s.mu.Unlock()
t := Task{ID: s.nextID, Title: title}
s.tasks[t.ID] = t
s.nextID++
return t
}
І метод читання:
package main
func (s *TaskStore) Get(id int) (Task, bool) {
s.ensureInit()
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
return t, ok
}
Так, тут Lock() навіть під час читання — це нормально для навчального прикладу. Оптимізації за допомогою RWMutex ми обговорювали в сусідній темі, але сенс Once від цього не змінюється.
Де зберігати результат, якщо Do нічого не повертає
Після першої зустрічі з sync.Once майже завжди виникає питання: «Окей, Do запускає функцію, але як повернути з неї значення? Чому Do нічого не повертає?»
Відповідь проста: Once — це примітив синхронізації. Він не про «повернути значення», а про «гарантувати, що виконано один раз». Тому результат потрібно складати у зовнішню змінну, зазвичай у поле структури, а назовні повертати вже після Do.
Зробімо приклад: ми хочемо один раз обчислити «максимальну кількість задач» зі змінної середовища або з якоїсь конфігурації. Припустімо, змінна називається APP_MAX_TASKS. Парсинг може завершитися помилкою, і це теж треба зберегти.
package main
import (
"os"
"strconv"
"sync"
)
type Limits struct {
once sync.Once
n int
err error
}
func (l *Limits) MaxTasks() (int, error) {
l.once.Do(func() {
raw := os.Getenv("APP_MAX_TASKS")
if raw == "" {
l.n = 100
return
}
l.n, l.err = strconv.Atoi(raw)
})
return l.n, l.err
}
Тут важливі дві речі.
Перша: ми кладемо і n, і err у поля структури, бо Do нічого не повертає.
Друга: якщо парсинг один раз завершився помилкою, це стане зафіксованим фактом. Наступні виклики MaxTasks() повертатимуть ту саму помилку, бо Do більше не викличе функцію повторно. Це частина поведінки Once: він не повторює спроби.
4. Типові помилки під час роботи з sync.Once
Помилка № 1: очікувати, що Once повторюватиме спроби, якщо сталася помилка.
Таке очікування з’являється, коли ініціалізація робить щось ненадійне: читає файл, звертається до мережі, парсить конфіг. Але Once не робить повторних спроб — він просто один раз викликає f, а далі більше її не чіпає. Якщо вам потрібна повторна спроба, це вже інший дизайн, наприклад окремий метод Reload() під Mutex. Контракт Do як «перший раз — виконую, решта — ні» описано прямо.
Помилка № 2: викликати once.Do всередині функції, яку ви передали в once.Do.
Іноді це відбувається опосередковано: під час init() ви викликаєте інший метод, а той теж робить ensureInit() і знову лізе в once.Do. Результат — deadlock, бо Do не відпускає тих, хто чекає, до завершення f. Це не рідкісна академічна проблема: у реальних кодових базах так ловлять зависання. Документація попереджає про це прямо.
Помилка № 3: вважати, що Once робить структуру повністю потокобезпечною.
Once розв’язує лише ініціалізацію рівно один раз. Але якщо після цього ви змінюєте map або слайс, ви знову в зоні ризику: у map не можна конкурентно писати без синхронізації. Тож нормальна зв’язка — Once для init, Mutex/RWMutex для подальшого життя даних.
Помилка № 4: не зберігати результат або помилку ініціалізації, а намагатися «повернути з Do».
Do нічого не повертає, і це зроблено навмисно. Тому правильний шаблон — складати результат у поле структури або зовнішню змінну й повертати його після Do. Якщо результат — це (value, error), зберігайте обидва. Це не просто стиль — це єдиний спосіб зробити код читабельним і не вигадувати дивні обхідні шляхи.
Помилка № 5: копіювати структуру, в якій уже використовувався sync.Once.
Копіювання структур із примітивами синхронізації майже завжди закінчується болем: ви отримуєте дві копії з різними замками та різним внутрішнім станом, і далі поведінка стає непередбачуваною. Для Once це прямо сказано: «must not be copied after first use».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ