Модуль: 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 не завершится, и только потом возвращаются.
  • Гарантия видимости: записи внутри f happens-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() (тоже при инициализации пакета, не лениво).