Программирование GoГорутины и каналыРазработчик Go серверной части

Разбор ситуации: функция, переданная в sync.Once.Do, запустила инициализацию, но завершилась паникой. Будет...

Разбор ситуации: функция, переданная в sync.Once.Do, запустила инициализацию, но завершилась паникой. Будет ли она вызвана повторно при следующем Do?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Нет. Если функция, переданная в sync.Once.Do, завершилась паникой, sync.Once считает операцию выполненной. Следующий вызов Do не запустит функцию повторно и вернётся без выполнения инициализации.

Исторический контекст

Однократная инициализация нужна, когда несколько горутин могут одновременно обратиться к общему ресурсу: конфигурации, соединению, кешу или лениво создаваемому объекту. Без специального примитива разработчику пришлось бы самостоятельно защищать проверку состояния мьютексом и учитывать гонки между проверкой и инициализацией.

sync.Once предоставляет готовую гарантию: переданная функция будет вызвана не более одного раза, даже при конкурентных вызовах. При этом примитив не определяет, как восстанавливать приложение после ошибки или паники в самой функции.

Постановка проблемы

Паника во время инициализации может оставить ресурс созданным только частично: например, конфигурация прочитана, но соединение с базой данных не установлено. Если после этого ожидать автоматического повторного запуска, программа продолжит работать с предположением, что инициализация уже завершена.

Особенно опасна ситуация, когда паника перехватывается через recover, а затем код повторно вызывает Do. Вызов завершится молча, но функция инициализации не будет выполнена снова.

Подробное решение

Механизм sync.Once гарантирует именно однократный вызов, а не успешное выполнение. После запуска функции состояние Once становится завершённым независимо от того, вернула ли функция управление обычным способом или завершилась паникой.

package main import ( "fmt" "sync" ) func main() { var once sync.Once for i := 0; i < 2; i++ { func() { defer func() { _ = recover() }() once.Do(func() { fmt.Println("init"); panic("failed") }) }() } once.Do(func() { fmt.Println("retry") }) }

В этом примере init печатается один раз, а retry не печатается. Локальный recover нужен только для продолжения выполнения после паники; он не сбрасывает состояние sync.Once.

Если повторная попытка необходима, sync.Once нельзя использовать как единственный механизм управления жизненным циклом. Нужно явно хранить состояние инициализации, например не начиналась, успешна, ошибка, защищая его мьютексом или другой подходящей схемой. При этом следует отдельно решить, допустимы ли повторные попытки, как очищать частично созданный ресурс и сколько попыток разрешено.

Преимущество sync.Once — простота и отсутствие повторной инициализации. Ограничение — невозможность штатно повторить операцию после ошибки или обновить уже созданное значение.

Ситуация из практики

Сервис лениво создаёт клиент внешнего API. В первом запросе DNS временно недоступен, функция создания клиента паникует, а верхний уровень перехватывает панику и возвращает пользователю ошибку. Следующий запрос снова вызывает Do, но клиент уже не создаётся: sync.Once считает попытку завершённой.

Вариант с безусловным повторным вызовом функции опасен: несколько горутин могут одновременно создавать клиентов, а частично созданные ресурсы могут протекать. Вариант с sync.Once безопасен от повторной инициализации, но не подходит для временно недоступной зависимости.

Для такой задачи лучше явно описать состояние инициализации и синхронизировать переходы между состояниями. Тогда можно ограничить число повторов, добавить задержку между ними и корректно освободить частично созданные ресурсы. sync.Once следует выбирать только там, где после первой попытки повторный вызов действительно не нужен или ошибка приводит к остановке компонента.

Что кандидаты часто упускают

  1. Вопрос: Что произойдёт, если два вызова Do одновременно передадут разные функции?

    Ответ: Будет выполнена только одна из функций, а вторая не выполнится. Какая именно функция победит, не следует определять по порядку запуска горутин: он не гарантирован. Все вызовы Do вернутся только после завершения выбранной функции, включая завершение паникой.

  2. Вопрос: Можно ли считать sync.Once средством безопасного хранения результата инициализации?

    Ответ: Сам по себе sync.Once не хранит результат инициализации и не возвращает ошибку. Обычно результат сохраняют во внешних переменных, а доступ к ним после завершения Do безопасен благодаря синхронизации, которую обеспечивает Once. Однако при панике результат может быть частично записан, поэтому состояние данных нужно проектировать так, чтобы неполная инициализация не использовалась как готовая.

  3. Вопрос: Чем отличается повторный вызов Do после обычного возврата функции от повторного вызова после паники?

    Ответ: С точки зрения sync.Once различий нет: в обоих случаях функция больше не вызывается. Важно различать это поведение с recover: перехват паники меняет судьбу текущего вызова и позволяет продолжить выполнение, но не возвращает Once в исходное состояние и не разрешает повторную инициализацию.