JavaRush /Курси /Go SELF /sync.Once — безпечна одноразова ініціалізація

sync.Once — безпечна одноразова ініціалізація

Go SELF
Рівень 68 , Лекція 3
Відкрита

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, канали — залежно від дизайну.

Щоб було простіше орієнтуватися, ось компактна таблиця: «який інструмент для чого».

Інструмент Для чого призначений Типовий приклад
sync.Once
один раз виконати шматок коду ліниво створити map, завантажити конфіг
sync.Mutex
захистити інваріант під час читання й запису map, слайси, кілька пов’язаних полів
sync/atomic
проста незалежна змінна лічильник, прапорець «готово»

Поведінка 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».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ