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, каналы — в зависимости от дизайна.
Чтобы было проще ориентироваться, вот компактная таблица «какой инструмент о чём»:
| Инструмент | На что рассчитан | Типичный пример |
|---|---|---|
|
один раз выполнить кусок кода | лениво создать map, загрузить конфиг |
|
защитить инвариант при чтении/записи | map, слайсы, несколько связанных полей |
|
простая независимая переменная | счётчик, флаг «готово» |
Поведение 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”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ