Модуль: Concurrency · Уровень: Middle+/Senior
TL;DR#
sync.Once гарантирует, что переданная функция выполнится ровно один раз за всё время жизни, даже при конкурентных вызовах из множества горутин — основа для потокобезопасной ленивой инициализации. Внутри — атомарный флаг (быстрый путь без блокировки) плюс мьютекс для медленного пути. Go 1.21 добавил эргономичные обёртки OnceFunc, OnceValue, OnceValues.
Теория#
Контракт#
var once sync.Once
var conn *DB
func GetDB() *DB {
once.Do(func() {
conn = connect() // выполнится ровно один раз
})
return conn
}Do(f)вызываетfтолько при первом вызове. Все остальные вызовы (включая конкурентные) блокируются, пока первыйfне завершится, и только потом возвращаются.- Гарантия видимости: записи внутри
fhappens-before возврат любогоDo→ все горутины видят полностью проинициализированное состояние. - Если
fпаникует, она считается «выполненной» — повторныеDoеё не запустят.
Устройство под капотом#
type Once struct {
done atomic.Uint32 // 0 = не выполнено, 1 = выполнено
m Mutex
}
func (o *Once) Do(f func()) {
if o.done.Load() == 0 { // быстрый путь: атомарное чтение
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done.Load() == 0 { // двойная проверка под мьютексом
defer o.done.Store(1) // ставим флаг ПОСЛЕ f (через defer)
f()
}
}Ключевые детали:
- Double-checked locking, корректный благодаря memory model Go. Быстрый путь — атомарный
Loadбез захвата мьютекса (дёшево после инициализации). done.Store(1)стоит вdeferпослеf()— поэтому конкурентные вызовы, ждущие на мьютексе, увидятdone==1только когдаfреально завершилась. Это и даёт happens-before.- Почему именно атомик, а не просто
if o.done == 1? Без атомарногоStore/Loadдругая горутина могла бы увидетьdone==1, но не увидеть записиfиз-за переупорядочивания/кэшей — гонка данных. Атомик с release-семантикой при Store и acquire при Load это исключает.
Go 1.21: OnceFunc / OnceValue / OnceValues#
Удобные обёртки, скрывающие переменную Once:
// OnceFunc: f выполнится один раз; возвращает функцию-обёртку
init := sync.OnceFunc(func() { expensiveSetup() })
init(); init() // setup один раз
// OnceValue: ленивое вычисление значения с кэшированием
getConfig := sync.OnceValue(func() Config { return loadConfig() })
c := getConfig() // loadConfig один раз, дальше кэш
// OnceValues: два возвращаемых значения (часто value+error)
getDB := sync.OnceValues(func() (*DB, error) { return connect() })
db, err := getDB()Семантика паники: если функция в OnceFunc/OnceValue паникует, обёртка запоминает панику и при каждом последующем вызове перевыбрасывает её же (re-panic), а не запускает функцию заново и не возвращает «пустое» значение. Это важное отличие — детерминированное поведение при сбое инициализации.
Подводные камни / gotchas#
- Once не сбрасывается. Нет
Reset. Нужна повторная инициализация (например, переподключение) — это не задача Once; используйте отдельную структуру с мьютексом или новый экземпляр Once. - Паника = «выполнено». Если
fупала,doneвсё равно станет 1 (через defer), и повтор не запустит её. Состояние останется неинициализированным, а ошибки вы не увидите. Для инициализации, которая может сбоить, используйтеOnceValues(вернёт error) или собственную логику с проверкой и возможностью повтора. - Дедлок: вложенный
Doтого же Once. Вызовonce.Doизнутри его жеf→ попытка повторного захватаo.mтой же горутиной → самодедлок (мьютекс не reentrant). - Копирование Once после использования запрещено (
copylocks). Передавайте по указателю; обычно Once — поле структуры или package-level переменная. - Захват результата. Сам
Doничего не возвращает; значение нужно писать в внешнюю переменную (видимость гарантирована happens-before).OnceValueрешает это эргономичнее. - Не для «один раз на запрос». Once — на всё время жизни процесса/экземпляра. Для «один раз на единицу работы» нужен другой механизм.
Вопросы на собеседовании#
В: Как работает быстрый путь Do?
О: Атомарное чтение done. Если уже 1 — мгновенный возврат без захвата мьютекса. Это делает повторные вызовы почти бесплатными после инициализации.
В: Почему нельзя обойтись простым булевым флагом без atomic?
О: Без атомарных операций возможна гонка данных: одна горутина видит done==1, но из-за отсутствия acquire/release-барьеров может не увидеть записи, сделанные в f. Атомарный Store (release) после f и Load (acquire) гарантируют видимость и happens-before.
В: Почему done ставится в defer после f, а не до?
О: Чтобы конкурентные вызовы, заблокированные на мьютексе, увидели done==1 только после реального завершения f. Если бы флаг ставился до вызова, другая горутина могла бы выйти из Do, считая инициализацию готовой, пока f ещё работает.
В: Что будет, если f паникует?
О: В классическом Once.Do флаг всё равно установится (defer), и f больше не запустится — состояние останется неинициализированным. В OnceFunc/OnceValue (1.21) паника кэшируется и перевыбрасывается при каждом следующем вызове.
В: Можно ли вызвать once.Do внутри переданной функции?
О: Нет, это самодедлок: f выполняется под захваченным o.m, повторный Do попытается захватить его снова, мьютекс не реентрантный.
В: В чём разница OnceFunc, OnceValue, OnceValues?
О: OnceFunc оборачивает функцию без возврата. OnceValue[T] кэширует одно значение. OnceValues[T1,T2] — два (обычно value+error). Все скрывают переменную Once и единообразно обрабатывают панику.
В: Как сделать инициализацию, которая может повторяться при ошибке?
О: Once не подходит (он «выполнено» даже при ошибке). Нужна структура с мьютексом и явным флагом успеха, проверяющая ошибку и допускающая повтор, либо OnceValues если повтор не нужен и достаточно вернуть сохранённую ошибку.
В: Дешевле ли Once, чем мьютекс на каждый доступ? О: Да. После инициализации горячий путь — один атомарный Load, без захвата мьютекса. Мьютекс трогается только в первый раз (и конкурентами в момент инициализации).
На что копают на senior+#
- Double-checked locking и почему он корректен в Go (в отличие от старой Java без volatile) — release/acquire семантика atomic.
- Точное место установки
done(defer после f) и его связь с happens-before гарантией для ждущих на мьютексе. - Кэширование паники в
OnceFunc/OnceValue: как реализовано через сохранение recover-значения и re-panic. - Почему в Go нет
Once.Resetи какие паттерны заменяют «переинициализацию» (atomic.Pointer swap, новый экземпляр). - Сравнение с package-level
init()(всегда выполняется, не ленивый) и сvar x = compute()(тоже при инициализации пакета, не лениво).