Мьютексы, 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 не требует конструктора.
gotype 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.
govar 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.
govar 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 Компромиссы
MutexvsRWMutex: обычный мьютекс проще и часто быстрее на коротких секциях;RWMutexвыигрывает при доминировании чтений и непустой секции, но страдает от частого писателя и вложенныхRLock.- Крупные vs мелкие замки: один замок на структуру прост и безопасен, но сериализует всё; мелкие повышают параллелизм ценой сложности, порядка захвата и риска дедлока.
atomicvs мьютекс: атомик дешевле для одной переменной, но не защищает инвариант из нескольких полей; мьютекс медленнее, зато понятнее.sync.Mapvsmap+RWMutex: первый хорош для «записали один раз и читаем» и независимых ключей; второй — для общего словаря, где нужныlen, обход и типы.sync.Poolvs аллокация: пул помогает на горячих буферах, но добавляет сложность, удержание памяти и риск грязного состояния; для дешёвых объектов он вреден.
💻 Практика
- Поймайте скопированный мьютекс. Сделайте
type cache struct { mu sync.Mutex; m map[string]string }и методfunc (c cache) Get(k string) string. Запуститеgo vet: получитеcopylocks. Затем добавьте параллельныеSetиGetи запустите-race; почините получатель на*cache. - Сравните
MutexиRWMutex. Бенчмарк: 99 % чтений, 1 % записей; затем 80/20; затем писатель каждую десятую операцию. Снимайте-benchmem. Объясните, при каком профилеRWMutexперестаёт выигрывать и почему. - Сделайте
Onceпо ключу.map[string]*sync.Onceпод мьютексом; 100 горутин на каждый из трёх ключей. Проверьте, что загрузчик вызван ровно три раза. Замените на один глобальныйsync.Onceи покажите, что загрузится только первый ключ. - Запустите сниппет модуля. Ожидаемый вывод:
конфиг после обновления: prod-2, затемhits: 50иинициализаций из 10 горутин: 1. Первая строка — сто параллельныхRLock, затем запись; вторая —atomic.Addиз 50 горутин; третья —Onceвыполнил тело один раз. Запустите с-raceиgo vet: замечаний быть не должно.
🧩 Quiz
sync.Pool отличается от кэша?sync.Mutex?RWMutex проигрывает обычному Mutex?atomic?sync.Once.Do(f)?✅ Итоги модуля
Студент должен:
- Держать критические секции короткими и без I/O
- Не копировать структуры с мьютексом и ловить
copylocks - Выбирать
RWMutexпо профилю чтений и записей - Понимать предпочтение писателя и риск вложенных
RLock - Применять
atomicтолько к одной переменной - Делать
Onceпо ключу через реестр под мьютексом - Отличать
sync.Mapиsync.Poolот универсальных замен - Ответить на все вопросы Quiz
🔗 Мостик к Модулю 6
Примитивы по отдельности освоены; дальше они собираются
в структуру. Конвейер с этапами, fan-out, fan-in и сквозной
отменой — следующий модуль, где каналы, select и WaitGroup
работают вместе.