Мьютексы, atomic и sync-примитивы

Перед началом: модули 1–4 (GMP, память, каналы, select). Инструменты: Go 1.22+, go test -race, go vet, -benchmem. Трудоёмкость: ~4 ч (теория 2 ч · практика 1.5 ч · квиз 0.5 ч).

🔥 Инцидент: горячий кэш не спас RWMutex, а go vet нашёл копию мьютекса

Горячий кэш конфигурации читался тысячами горутин в секунду, а писатель обновлял его каждые 20 мс: приходили feature-флаги. Кэш защитили sync.RWMutex — «читателей много, RLock пустит их параллельно». На бенчмарке старый вариант с обычным Mutex оказался быстрее: писатель приходил так часто, что новый RLock почти всегда упирался в ожидающего писателя, а накладные расходы на пару RLock/RUnlock оставались.

Вторая находка была хуже. Один из методов объявили с получателем по значению, и go vet выдал copylocks: структура с sync.RWMutex копировалась при каждом вызове. Каждая копия защищала свою карту, точнее — свой замок, а данные оставались общими. Под нагрузкой это дало гонку и редкие паники concurrent map read and map write.

Мораль: примитив выбирают по профилю нагрузки, а мьютекс — это часть структуры, которую нельзя копировать.

📖 Теория

5.1 Mutex: критическая секция и запрет копирования

sync.Mutex — взаимное исключение: Lock ждёт, пока замок свободен, Unlock отпускает. Нулевое значение готово к работе, поэтому var mu sync.Mutex не требует конструктора.

go
type counter struct {
    mu sync.Mutex
    n  int
}

func (c *counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.n++
}

Правила, которые экономят недели отладки:

  • Защищайте и чтение, и запись. RLock или Lock нужен каждому обращению к полю, которое кто-то меняет; иначе гонка данных (модуль 2).
  • Держите секцию короткой. Не делайте I/O, не вызывайте чужие колбэки и не отправляйте в канал под замком: соседи встанут в очередь, а дедлоки станут вопросом времени.
  • defer Unlock сразу после Lock. Это защищает от забытого Unlock на раннем return и панике.
  • Не копируйте мьютекс. Копия структуры копирует и замок: два замка защищают два разных состояния. go vet ловит это как copylocks; передавайте *T, а не T.
  • Задавайте порядок захвата. Если нужны два мьютекса, всегда берите их в одном порядке; иначе — классический deadlock.

TryLock (Go 1.18) нужен редко: он провоцирует «оптимизации», которые ломают инварианты. Если без него не обойтись, чётко определите, что делать при неудаче.

5.2 RWMutex и голодание писателя

sync.RWMutex разделяет доступ: много RLock одновременно, Lock — монопольно. В Go реализовано предпочтение писателя: как только писатель встал в очередь, новые читатели ждут, иначе поток читателей мог бы никогда не пустить писателя.

Отсюда неочевидные следствия:

  • Частый писатель убивает выигрыш. Если запись идёт каждые десятки миллисекунд, читатели почти всегда видят ожидающего писателя и выстраиваются в очередь — как с обычным Mutex, только с большими накладными расходами.
  • Короткая секция — не повод для RWMutex. Пара атомарных операций RLock/RUnlock может стоить дороже, чем сам доступ к данным; на бенчмарках Mutex часто быстрее при коротких секциях.
  • RLock не рекурсивен. Вложенный RLock может заблокироваться, если между ними встал писатель.
  • Апгрейд невозможен. Из RLock нельзя «повыситься» до Lock: сначала RUnlock, потом Lock, с повторной проверкой данных.
  • Копирование запрещено так же. go vet ругается и на RWMutex.

RWMutex оправдан, когда чтений на порядки больше записей и критическая секция непустая. Если же конфигурация меняется редко, лучше вообще убрать замок с читателей: публиковать неизменяемый снимок через atomic.Pointer (модуль 2).

5.3 atomic: когда хватает одной переменной

atomic работает без блокировок и годится, когда инвариант помещается в одну переменную:

  • счётчик (atomic.Int64);
  • флаг готовности или «остановлен» (atomic.Bool);
  • указатель на неизменяемый снимок (atomic.Pointer[T]);
  • одно поле, которое меняется целиком через Swap/CAS.
go
var hits atomic.Int64
hits.Add(1)         // конкурентно и без мьютекса
n := hits.Load()

Чего atomic не умеет:

  • Согласованно менять два поля. Два Store — не транзакция; читатель увидит промежуточное состояние.
  • Защищать map или срез. Атомарен указатель, а не содержимое структуры; после Store объект должен быть неизменяемым.
  • Давать приоритеты и очереди. Это работа мьютексов и каналов.

Под высокой конкуренцией даже atomic.Add упирается в одну кэш-линию: ядра «пинг-понгуют» ею. Для горячих счётчиков помогает шардирование — N счётчиков, выбор по goroutine id или хешу, суммирование при чтении.

5.4 sync.Once, sync.Map, sync.Pool

sync.Once выполняет функцию ровно один раз, а все вызвавшие Do дожидаются её завершения и видят результат (happens-before). Если функция паникует, Once считается использованным: повторного запуска не будет. Ошибку инициализации нужно сохранять отдельно и проверять после Do.

go
var once sync.Once
var cfg *Config
var err error

func load() (*Config, error) {
    once.Do(func() { cfg, err = parseConfig() })
    return cfg, err
}

По ключу sync.Once не работает: нужен свой реестр map[string]*sync.Once под мьютексом. Так делают ленивую инициализацию по имени тенанта; для «одного общего результата на ключ» с ожиданием чужих вызовов есть singleflight (модуль 15).

sync.Map оптимизирован под два сценария: ключ пишется один раз и потом только читается, либо горутины работают с непересекающимися наборами ключей. В общем случае map + RWMutex проще, даёт len, согласованный Range и контроль типов. sync.Map не заменяет обычную карту «на всякий случай».

sync.Pool переиспользует временные объекты, чтобы снизить нагрузку на GC. Это не кэш: сборщик вправе очистить пул в любой момент, и полагаться на сохранность объектов нельзя. Годится для буферов и временных структур; не годится для соединений, файлов и всего, что требует Close. Перед Put объект сбрасывают, иначе утечёт старое содержимое.

5.5 Компромиссы

  • Mutex vs RWMutex: обычный мьютекс проще и часто быстрее на коротких секциях; RWMutex выигрывает при доминировании чтений и непустой секции, но страдает от частого писателя и вложенных RLock.
  • Крупные vs мелкие замки: один замок на структуру прост и безопасен, но сериализует всё; мелкие повышают параллелизм ценой сложности, порядка захвата и риска дедлока.
  • atomic vs мьютекс: атомик дешевле для одной переменной, но не защищает инвариант из нескольких полей; мьютекс медленнее, зато понятнее.
  • sync.Map vs map + RWMutex: первый хорош для «записали один раз и читаем» и независимых ключей; второй — для общего словаря, где нужны len, обход и типы.
  • sync.Pool vs аллокация: пул помогает на горячих буферах, но добавляет сложность, удержание памяти и риск грязного состояния; для дешёвых объектов он вреден.

💻 Практика

  1. Поймайте скопированный мьютекс. Сделайте type cache struct { mu sync.Mutex; m map[string]string } и метод func (c cache) Get(k string) string. Запустите go vet: получите copylocks. Затем добавьте параллельные Set и Get и запустите -race; почините получатель на *cache.
  2. Сравните Mutex и RWMutex. Бенчмарк: 99 % чтений, 1 % записей; затем 80/20; затем писатель каждую десятую операцию. Снимайте -benchmem. Объясните, при каком профиле RWMutex перестаёт выигрывать и почему.
  3. Сделайте Once по ключу. map[string]*sync.Once под мьютексом; 100 горутин на каждый из трёх ключей. Проверьте, что загрузчик вызван ровно три раза. Замените на один глобальный sync.Once и покажите, что загрузится только первый ключ.
  4. Запустите сниппет модуля. Ожидаемый вывод: конфиг после обновления: prod-2, затем hits: 50 и инициализаций из 10 горутин: 1. Первая строка — сто параллельных RLock, затем запись; вторая — atomic.Add из 50 горутин; третья — Once выполнил тело один раз. Запустите с -race и go vet: замечаний быть не должно.

🧩 Quiz

1. Чем sync.Pool отличается от кэша?
2. Почему нельзя копировать использованный sync.Mutex?
3. Когда RWMutex проигрывает обычному Mutex?
4. Для чего годится atomic?
5. Что гарантирует sync.Once.Do(f)?

✅ Итоги модуля

Студент должен:

  • Держать критические секции короткими и без I/O
  • Не копировать структуры с мьютексом и ловить copylocks
  • Выбирать RWMutex по профилю чтений и записей
  • Понимать предпочтение писателя и риск вложенных RLock
  • Применять atomic только к одной переменной
  • Делать Once по ключу через реестр под мьютексом
  • Отличать sync.Map и sync.Pool от универсальных замен
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 6

Примитивы по отдельности освоены; дальше они собираются в структуру. Конвейер с этапами, fan-out, fan-in и сквозной отменой — следующий модуль, где каналы, select и WaitGroup работают вместе.