Функция удерживает мьютекс и вызывает помощник, который пытается захватить тот же мьютекс. Как завершится п...

Функция удерживает мьютекс и вызывает помощник, который пытается захватить тот же мьютекс. Как завершится программа?

package main

import (
	"fmt"
	"sync"
)

var mu sync.Mutex

func helper() {
	mu.Lock()
	defer mu.Unlock()
	fmt.Println("helper")
}

func main() {
	mu.Lock()
	defer mu.Unlock()
	helper()
}
Проходите собеседования с ИИ помощником Hintsage

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

Программа заблокируется при втором вызове mu.Lock() внутри helper. sync.Mutex не является рекурсивным: горутина, уже владеющая блокировкой, не может повторно захватить тот же мьютекс. В данном примере другие горутины отсутствуют, поэтому рантайм завершит программу с ошибкой взаимной блокировки.

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

Мьютекс предназначен для взаимного исключения доступа к общему состоянию: в каждый момент времени критическую секцию выполняет не более одной горутины. Такая модель намеренно не хранит понятие «владелец» на уровне API и не поддерживает рекурсивный захват.

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

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

main сначала успешно захватывает mu, затем вызывает helper. В helper вторая попытка захвата блокируется, потому что мьютекс всё ещё удерживается текущей горутиной.

Отложенный mu.Unlock() из main не помогает: он будет выполнен только после возврата из helper, а возврата не произойдёт. Если такой путь возникает в серверном коде, запрос может зависнуть, а при достаточном распространении блокировки — остановиться вся связанная обработка.

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

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

В этом примере единственный потенциальный Unlock находится в defer функции main. До него выполнение не дойдёт. Поскольку других работающих горутин нет, планировщик обнаружит, что все горутины заблокированы, и сообщит о взаимной блокировке.

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

package main import ( "fmt" "sync" ) var mu sync.Mutex func helperLocked() { fmt.Println("helper") } func helper() { mu.Lock() defer mu.Unlock() helperLocked() } func main() { helper() }

Часто применяют соглашение о суффиксе вроде Locked: функция helperLocked предполагает, что мьютекс уже захвачен, и сама его не блокирует. Это не проверяется компилятором, поэтому соглашение нужно документировать и соблюдать.

Мьютекс также не связан с конкретной горутиной: технически одна горутина может вызвать Unlock, когда блокировку захватила другая. Однако такое разделение владения обычно усложняет доказательство корректности и не решает проблему рекурсивного захвата в данном коде.

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

В обработчике кэша метод Get захватывал мьютекс, а затем вызывал recalculate, который тоже пытался захватить тот же мьютекс. В результате редкий путь промаха кэша зависал.

Рассматривались три варианта: заменить мьютекс на самодельный рекурсивный примитив, убрать внутреннюю блокировку или разделить функцию на публичную блокирующую и внутреннюю recalculateLocked. Первый вариант добавлял сложное отслеживание владельца и не соответствовал обычной модели Go; простое удаление блокировки было безопасным только после проверки всех вызывающих путей.

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

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

  1. Можно ли заменить sync.Mutex на sync.RWMutex, чтобы повторный RLock стал безопасным?

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

  1. Почему добавление defer mu.Unlock() перед вызовом helper не исправляет пример?

defer откладывает вызов до выхода из текущей функции, а не до следующей инструкции. main выйдет только после завершения helper, но helper ожидает освобождения мьютекса из main. Возникает цикл ожидания: helper ждёт Unlock, а main ждёт возврата из helper.

  1. Что изменится, если helper запустить в отдельной горутине?

Само по себе это не исправит проблему. main продолжит удерживать мьютекс и может ждать завершения помощника через WaitGroup или канал, тогда как помощник будет ждать тот же мьютекс. Получится взаимная блокировка между горутинами; безопасным решением остаётся корректно разделить границы критической секции.