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 не случится, у вас будет data race: программа становится недетерминированной, то есть может вести себя по-разному при одинаковом вводе.

Это тот самый классический момент, когда «иногда работает» — плохой знак, а не повод радоваться.

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 goroutine A
    participant G2 as goroutine 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 — это не догадки, а прямое описание контракта в документации.

4. Примеры использования 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: он не «повторяет попытки».

5. Типичные ошибки при работе с 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”.

1
Задача
Go SELF, 68 уровень, 3 лекция
Недоступна
Ленивый баннер
Ленивый баннер
1
Задача
Go SELF, 68 уровень, 3 лекция
Недоступна
Лимит из окружения
Лимит из окружения
1
Задача
Go SELF, 68 уровень, 3 лекция
Недоступна
Нулевой TaskStore
Нулевой TaskStore
1
Задача
Go SELF, 68 уровень, 3 лекция
Недоступна
Кэш простых
Кэш простых
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ