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

Что произойдёт, если функция, переданная в sync.Once.Do, прямо или косвенно повторно вызовет Do того же объ...

Что произойдёт, если функция, переданная в sync.Once.Do, прямо или косвенно повторно вызовет Do того же объекта?

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

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

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

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

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

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

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

package main import "sync" func main() { var once sync.Once var initFunc func() initFunc = func() { once.Do(initFunc) } once.Do(initFunc) }

В этом примере первая функция Do ещё выполняется, когда initFunc снова вызывает тот же объект once. Вложенный вызов ждёт завершения текущей инициализации, но текущая инициализация ждёт возврата вложенного вызова.

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

При первом вызове Do объект синхронизации запускает переданную функцию и не считает операцию завершённой до её возврата. Другие горутины, вызвавшие тот же Do, ожидают окончания этой функции; после успешного возврата они уже не выполняют её повторно.

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

Паника внутри функции имеет другое поведение: после её завершения sync.Once считается выполненным, и последующие вызовы не запускают функцию снова. Это не отменяет проблему рекурсивного вызова: при рекурсии функция не успевает завершиться, поэтому состояние «выполнено» не устанавливается.

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

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

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

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

Предпочтительное решение — отделить создание клиента от его регистрации и передать готовый клиент в реестр после завершения Do. Тогда инициализация имеет одно направление зависимостей, выполняется ровно один раз, а результат публикуется только после полного завершения функции.

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

  1. Будет ли функция вызвана повторно, если первый вызов завершился паникой?

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

  1. Чем рекурсивный вызов sync.Once.Do отличается от вызова из другой горутины?

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

  1. Можно ли внутри функции Do безопасно вызвать другой sync.Once?

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