Мьютексы и примитивы синхронизации
«Не общайтесь разделяемой памятью — разделяйте память общением» — известный совет про Go, но он не отменяет того, что мьютексы в стандартной библиотеке существуют не зря. Канал — отличный инструмент для передачи владения данными, но защитить счётчик или кэш в горячем цикле дешевле обычным sync.Mutex. Серьёзный Go-разработчик владеет обоими подходами и знает, когда какой уместен.
Сложность пакета sync не в API — Lock/Unlock, Add/Done/Wait, Do выглядят тривиально. Сложность в том, что эти примитивы не прощают ошибок. Скопированная после первого использования структура с мьютексом тихо копирует состояние блокировки. Счётчик WaitGroup, ушедший ниже нуля, роняет программу через panic. sync.Mutex не реентерабельный — повторный Lock в той же goroutine — это deadlock. А конкурентная запись во встроенную map — не гонка, которую можно «иногда поймать», а fatal error, который валит процесс и не ловится через recover. Эта тема разбирает синхронизацию по слоям — от устройства мьютекса до потокобезопасного доступа к map. Атомарные операции и модель памяти вынесены в отдельную тему Атомики и модель памяти.
Карта темы
- Обзор пакета sync — какой примитив под задачу:
atomicдля счётчика,Mutexдля инварианта,RWMutexдля read-heavy, плюсOnce,WaitGroup,Map,Pool,Cond. - sync.Mutex — взаимное исключение, нулевое значение как готовый мьютекс, нереентерабельность и режим starvation для честности.
- sync.RWMutex — много читателей ЛИБО один писатель, выигрыш на read-heavy нагрузке и защита писателя от голодания.
- sync.WaitGroup — ожидание набора goroutine, контракт
Add/Done/Waitи почемуAddобязан опережать запуск goroutine. - sync.Once — гарантия однократного выполнения через атомарный быстрый путь и медленный путь под мьютексом.
- Конкурентный доступ к map — встроенная
mapне потокобезопасна: конкурентная запись роняет процесс черезfatal error; защита мьютексом илиsync.Map. - sync.Map — две внутренние map (
readбез блокировки иdirtyпод мьютексом); выигрывает только на read-mostly нагрузке.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
Скопировать структуру с sync.Mutex после первого использования | Копируется состояние блокировки; go vet ловит это, но не всегда читают вывод |
Считать sync.Mutex реентерабельным | Повторный Lock на своём мьютексе в той же goroutine — deadlock |
Брать RWMutex по умолчанию | При низкой конкуренции он медленнее обычного sync.Mutex |
Вызвать Add внутри уже запущенной goroutine | Гонка с Wait; счётчик может ещё не учитывать goroutine |
Передать WaitGroup по значению в goroutine | Done обновляет копию; Wait блокируется навсегда |
Увести счётчик WaitGroup ниже нуля лишним Done | panic — программа падает |
Писать во встроенную map из нескольких goroutine | fatal error: concurrent map writes — процесс падает, recover не ловит |
Брать sync.Map для обычной смешанной нагрузки | Она выигрывает лишь на read-mostly; иначе map под RWMutex быстрее |
Значение для собеседований
Синхронизация — обязательная тема на любом серьёзном Go-интервью. Каналы кандидат обычно знает; пакет sync отделяет того, кто умеет рассуждать о корректности конкурентного кода, от того, кто заучил go func(){}().
Что обычно проверяют:
- Что даёт
sync.Mutex, почему его нельзя копировать и почему он не реентерабельный. - Когда
sync.RWMutexвыигрывает уsync.Mutexи какова его цена. - Контракт
Add/Done/Waitи почемуAddобязан выполниться до запуска goroutine. - Как
sync.Onceгарантирует однократный запуск и почему ему нужны и атомик, и мьютекс. - Почему конкурентная запись во встроенную
mapвалит процесс и как её защитить. - Когда
sync.Mapоправдана и почему на смешанной нагрузкеmapподRWMutexбыстрее.
Типичный неверный ответ: «встроенную map можно безопасно читать и писать из разных goroutine, если ключи разные». Это запускает разбор того, что конкурентная запись в map детектируется рантаймом и роняет процесс через fatal error независимо от ключей, а защита — мьютекс или sync.Map.