Модуль: 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 мс.

NormalStarvation
Дисциплина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/pprof mutex/block профили, GODEBUG, -mutexprofilefraction.