Модуль: Concurrency · Уровень: Senior
TL;DR#
sync.Mutex — взаимное исключение для защиты разделяемого состояния; устроен на основе атомарного слова состояния + семафора рантайма для парковки горутин. С Go 1.9 у мьютекса есть starvation mode, защищающий «обиженные» горутины от голодания. RWMutex разрешает много читателей или одного писателя, но из-за дороговизны и риска голодания писателей полезен только при сильном перекосе в сторону чтения и длинных критических секциях. Мьютексы нельзя копировать после первого использования.
Теория#
Устройство Mutex#
sync.Mutex — это две машинных слова:
type Mutex struct {
state int32 // битовое поле: locked|woken|starving + счётчик ожидающих
sema uint32 // семафор рантайма для парковки/побудки горутин
}Биты state:
mutexLocked(1) — захвачен.mutexWoken(2) — есть разбуженная горутина, новым не нужно будить.mutexStarving(4) — режим голодания.- старшие биты — счётчик заблокированных ожидающих (
waiters).
Быстрый путь#
func (m *Mutex) Lock() {
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return // unlocked → locked одним CAS, без обращения к рантайму
}
m.lockSlow()
}Неоспариваемый Lock/Unlock — это один атомарный CAS, без syscall и без переключения горутин. Поэтому «дешёвый» мьютекс часто быстрее канала.
Медленный путь и спин#
В lockSlow горутина сначала спинит (runtime_canSpin: не более 4 итераций, есть свободные P, мультиядерность) с инструкцией PAUSE. Спин окупается, когда владелец вот-вот отпустит мьютекс — экономим парковку/побудку. Если спин не помог, горутина увеличивает счётчик ожидающих и паркуется на семафоре (runtime_SemacquireMutex), уходя из планировщика.
Normal mode vs Starvation mode#
Это ключевая деталь Go 1.9+.
Normal (барный) режим: очередь ожидающих — FIFO, но разбуженная горутина не владеет мьютексом автоматически, она конкурирует с новыми горутинами, которые уже выполняются на CPU и спинят. У «свежих» горутин преимущество (они на ядре), поэтому разбуженная часто проигрывает и снова паркуется. Это даёт высокую пропускную способность, но рискует голоданием.
Starvation (честный) режим: если горутина ждёт дольше 1 мс, мьютекс переключается в starving. Тогда Unlock передаёт владение напрямую первой в очереди (handoff), новые горутины даже не спинят, а сразу встают в хвост очереди. Режим выключается, когда горутина, получившая мьютекс, видит, что она последняя в очереди или ждала <1 мс.
| Normal | Starvation | |
|---|---|---|
| Дисциплина | LIFO-подобная (barging) | строгий FIFO (handoff) |
| Пропускная способность | высокая | ниже |
| Латентность хвоста | плохая | ограничена |
| Триггер | по умолчанию | ожидание > 1 мс |
Это компромисс throughput vs tail latency: барьинг даёт скорость, но без честного режима один поток мог бы вечно опережать.
RWMutex#
type RWMutex struct {
w Mutex // мьютекс для писателей между собой
writerSem uint32 // семафор: писатель ждёт ухода читателей
readerSem uint32 // семафор: читатели ждут ухода писателя
readerCount atomic.Int32 // число активных читателей (или смещённое при писателе)
readerWait atomic.Int32 // сколько читателей нужно дождаться писателю
}RLock: атомарноreaderCount++. Если значение стало отрицательным — значит писатель ждёт/работает (он вычелrwmutexMaxReaders), читатель паркуется наreaderSem.Lock: захватываетw(блокирует других писателей), вычитаетrwmutexMaxReadersизreaderCount(сигнал «писатель пришёл»), затем ждётreaderWaitуходящих читателей.- Важная гарантия: когда писатель ждёт, новые
RLockблокируются. Это предотвращает голодание писателя бесконечным потоком читателей, но именно поэтому RWMutex не масштабируется идеально.
Когда RWMutex вреден#
RWMutex кажется бесплатной оптимизацией, но это не так:
RLock/RUnlockдорожеLock/Unlockобычного мьютекса (больше атомарных операций и ветвлений).readerCount— общая атомарная переменная, по которой бьют все читатели → cache-line contention и трафик когерентности кэшей между ядрами. При коротких критических секциях RWMutex может быть медленнее обычного Mutex.- Выгода появляется только когда: читателей сильно больше писателей и критическая секция достаточно длинная, чтобы параллельное чтение перекрыло накладные расходы.
- При высоком write-rate писатели могут голодать (хотя блокировка новых читателей это смягчает).
Альтернативы для read-heavy: atomic.Value/atomic.Pointer для copy-on-write, sharding (разбить состояние на N мьютексов по хэшу ключа), sync.Map для специфичных профилей доступа.
Подводные камни / gotchas#
- Копирование мьютекса. Передача структуры с Mutex по значению копирует его состояние — две копии «защищают» разные слова, инвариант ломается.
go vet/copylocksловит это. Всегда передавайте указатель. - Незащищённый Unlock.
Unlockнезахваченного мьютекса → паникаsync: unlock of unlocked mutex. Это фатально, не recover-абельно по контракту. - Unlock из другой горутины. Mutex не привязан к горутине — отпустить может любая, но это почти всегда ошибка дизайна. Для рекурсии мьютекс не подходит (не reentrant) → самодедлок.
defer mu.Unlock()и горячий путь. defer стоит дёшево с Go 1.14 (open-coded defers), но в очень горячем коде иногда отпускают вручную раньше, чтобы сузить критическую секцию.RLockрекурсивно опасен: если между двумя RLock одной горутины вклинится Lock писателя, второй RLock заблокируется → дедлок. RWMutex не reentrant даже для читателей.- Слишком широкая критическая секция (например, I/O под мьютексом) убивает параллелизм. Держите блокировку минимально.
- Забыли defer при раннем return → утечка блокировки навсегда.
Вопросы на собеседовании#
В: Что происходит при неоспариваемом Lock?
О: Один атомарный CAS 0 → mutexLocked в быстром пути. Никаких обращений к рантайму, syscall или парковки. Поэтому такой мьютекс крайне дёшев.
В: Что такое starvation mode и зачем он нужен? О: С Go 1.9 если горутина ждёт мьютекс дольше 1 мс, он переключается в честный FIFO-режим: Unlock передаёт владение напрямую первому в очереди, новые горутины не спинят и не «перехватывают» мьютекс. Это ограничивает tail latency и предотвращает голодание, жертвуя пропускной способностью normal-режима (где разрешён barging).
В: Почему в normal mode разбуженная горутина может снова заснуть? О: В normal режиме нет прямой передачи владения. Разбуженная горутина конкурирует с уже работающими на CPU горутинами, которые спинят. Свежая часто захватывает мьютекс первой, и разбуженная снова паркуется. Это барьинг — он повышает throughput.
В: Когда RWMutex медленнее обычного Mutex?
О: При коротких критических секциях и/или высокой частоте операций: RLock делает больше атомарных операций по общей readerCount, создавая cache-line contention между ядрами. Накладные расходы перекрывают выгоду от параллельного чтения.
В: Голодают ли писатели в RWMutex? О: Реализация Go это смягчает: как только писатель вызвал Lock и начал ждать, новые RLock блокируются (новые читатели не пускаются вперёд писателя). Старые читатели дорабатывают, писатель получает мьютекс. Полного голодания нет, но при шторме читателей задержка писателя всё равно есть.
В: Можно ли копировать Mutex?
О: Нет. После первого использования копирование разносит состояние по двум словам, ломая взаимное исключение. go vet (copylocks) детектит копирование структур с мьютексами. Передавайте по указателю.
В: Reentrant ли мьютекс в Go? О: Нет. Повторный Lock из той же горутины — самодедлок. В Go это сознательное решение: реентрантность маскирует плохой дизайн владения данными. Нужна рекурсия — переструктурируйте код или вынесите критическую секцию.
В: Чем заменить RWMutex для read-heavy нагрузки?
О: atomic.Pointer/atomic.Value с copy-on-write (читатели вообще без блокировки), шардирование мьютексов по ключу, sync.Map для подходящих профилей. Выбор зависит от соотношения read/write и размера данных.
В: Что делает спин в lockSlow? О: Перед парковкой горутина крутится несколько итераций с PAUSE, если есть свободные P и мультиядерность. Расчёт: владелец вот-вот отпустит, и спин дешевле, чем парковка+побудка через семафор и переключение контекста.
На что копают на senior+#
- Точная семантика битов
stateи переходов normal↔starving, порог 1 мс и условия выхода из starving. - Барьинг vs handoff как компромисс throughput/tail-latency; почему дефолт — normal.
- Cache coherency и false sharing: почему общая атомарная переменная RWMutex плохо масштабируется; padding до cache line.
- Семафоры рантайма (
semacquire/semrelease),runtime_SemacquireMutexи интеграция с планировщиком (горутина уходит из runq). - Альтернативы: atomic snapshot, sharded locks, lock-free структуры и их memory model гарантии.
- Профилирование contention:
runtime/pprofmutex/block профили,GODEBUG,-mutexprofilefraction.