Mutex и atomic в Go
Замерил на двух ядрах одну и ту же операцию, инкремент счётчика. atomic.Add - 9,6 наносекунды, Mutex - 15,4, RWMutex на запись - 29,7, обмен через канал - 47,6. Спрашивают, когда нужна блокировка, чем RWMutex отличается от Mutex и где хватит atomic. Плюс любимая ловушка про копирование структуры с мьютексом.
Стержень: мьютекс защищает инвариант целиком, atomic - одну переменную, и выбор диктует размер критической секции.
// Формулировки: «зачем Mutex?», «когда RWMutex?», «чем atomic лучше?»
Mutex и дисциплина
Пишешь var mu sync.Mutex - и всё, можно работать. Нулевое значение мьютекса готово к использованию, конструктора нет и не надо.
Взаимное исключение значит, что между Lock и Unlock одновременно работает ровно одна горутина. Этот участок и называют критической секцией. Unlock ставят через defer сразу после Lock, и причина не в красоте: у функции обычно несколько выходов, а при панике управление уходит мимо конца тела вообще. Забытый Unlock оставляет мьютекс захваченным навсегда, и следующая горутина встаёт насмерть - без сообщения, без паники, просто висит.
// Секцию держи короткой. Поход в сеть или вызов чужого кода под Lock превращает случайную задержку соседнего сервиса в остановку твоего собственного.
- критическая секция
- участок между Lock и Unlock, там одна горутина
- defer mu.Unlock()
- разблокировка на любом выходе, включая панику
RWMutex выигрывает не всегда, и я знаю на сколько
Замер на двух ядрах, чистое чтение под блокировкой. Секция на 16 сложений: Mutex 29,4 нс, RWMutex 17,3 - RWMutex почти вдвое лучше. Секция на 4096 сложений: 1721 против 881, разница ровно в два раза, и это потолок, потому что ядер два.
А теперь короткая секция, один поиск в мапе: Mutex 13,1 нс, RWMutex 17,7. Здесь RWMutex проиграл: его учёт читателей стоит дороже самой работы. На двух ядрах перелом приходится примерно на десять наносекунд полезной работы внутри секции.
// Механика простая. RLock пропускает читателей одновременно, Lock эксклюзивен, а писатель ждёт выхода всех читателей и на плотном потоке чтений может голодать.
- RLock
- разделяемая блокировка: читатели идут одновременно
- голодание писателя
- запись ждёт, пока не разойдутся все читатели
Одна строка убивает весь выигрыш RWMutex
Самое интересное в замере вышло случайно. Взял ту же короткую секцию чтения и поменял ровно одно: результат кладу не в локальную переменную, а в общую. RWMutex: 17,7 нс превратились в 48,3. Mutex на том же изменении не шелохнулся - 13,06 против 13,02.
Объяснение видно, если помнить, зачем RWMutex вообще нужен. Он даёт читателям работать одновременно на разных ядрах. Как только они пишут в одну общую переменную, кэш-линия с ней начинает скакать между ядрами, и параллельность превращается в помеху. У Mutex такого не случается: читатели и так идут по очереди, линия переезжает один раз за передачу блокировки.
// Вывод для кода: RWMutex окупается, если секция чтения честно ничего не пишет. Один счётчик обращений внутри RLock - и выигрыша уже нет.
- кэш-линия
- кусок памяти, который ядра гоняют между своими кэшами
- чистое чтение
- секция под RLock без единой записи в общее
atomic и копирование
Пакет sync/atomic работает с одной переменной: счётчик, флаг, указатель. На замере atomic.Add дал 9,6 нс против 15,4 у мьютекса - он дешевле, потому что не выходит в планировщик и никого не усыпляет.
Защищает он ровно одну операцию. Согласовать два поля им нельзя: между двумя атомарными записями посторонняя горутина спокойно увидит половину изменения. С Go 1.19 для этого есть типы atomic.Int64, atomic.Bool, atomic.Pointer с методами Load, Store, Add и CompareAndSwap - они удобнее старых функций и не дают перепутать обычный доступ с атомарным.
// Ловушку с копированием я проверил руками. Структуру с мьютексом внутри копировать нельзя: метод с получателем-значением запирает копию. Сто вызовов такого инкремента оставили счётчик равным нулю. go vet ловит это сообщением passes lock by value: Counter contains sync.Mutex.
- atomic
- неделимая операция над одной переменной
- passes lock by value
- предупреждение vet о копировании блокировки
Как отвечать: «Когда мьютекс, когда RWMutex, а когда atomic?»
Начинаю с обычного Mutex, он проще и предсказуемее. Он нужен, когда защищаемый инвариант шире одной переменной: согласованно изменить два поля, проверить условие и что-то сделать по результату. Разблокировку всегда пишу через defer сразу после Lock, иначе ранний возврат или паника оставят мьютекс захваченным и следующая горутина встанет навсегда. Atomic беру, когда защитить надо ровно одну переменную, счётчик или флаг: на моём замере atomic.Add вышел 9,6 наносекунды против 15,4 у мьютекса, он не выходит в планировщик. Но согласовать им два поля нельзя, между двумя атомарными записями видна половина изменения. RWMutex беру, когда чтений сильно больше записей и секция чтения не самая короткая. Я это мерил: на секции в 16 сложений RWMutex дал 17,3 наносекунды против 29,4 у мьютекса, а вот на одном поиске в мапе проиграл, 17,7 против 13,1 - его учёт читателей стоит дороже работы. И важная деталь оттуда же: если внутри RLock писать в общую переменную, выигрыш исчезает совсем, у меня 17,7 превратились в 48,3, потому что кэш-линия скачет между ядрами. Ну и структуру с мьютексом не копирую: метод на значении запирает копию, сто инкрементов дали ноль, а go vet ругается passes lock by value.
Сильный ответ: выбор идёт от размера защищаемого инварианта, а не по вкусу, цена каждого примитива названа числом, показана граница применимости RWMutex вместе с неочевидным условием про чистое чтение и добавлена ловушка с копированием и конкретным сообщением vet.
На чём валят
- −Забывают defer перед Unlock: ранний возврат вешает мьютекс навсегда.
- −Берут RWMutex по умолчанию. На короткой секции он проиграл мьютексу 17,7 против 13,1 наносекунды.
- −Пишут в общую переменную под RLock и удивляются, что RWMutex не помог.
- −Пытаются согласовать два поля через atomic. Он защищает одну операцию, не инвариант.
- −Копируют структуру с мьютексом: метод на значении запирает копию, оригинал не защищён.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 5, остальные разбираются в тренажёре.
- Зачем нужен sync.Mutex?A)Ускоряет доступ к общей памяти, кэшируя её значение между горутинамиB)Взаимное исключение: секцию Lock–Unlock держит одна горутина за разC)Помечает переменную потокобезопасной, пока с ней работает горутинаD)Создаёт отдельную копию данных каждой горутине, чтобы они не пересекались
показать ответ и разбор
+B)Взаимное исключение: секцию Lock–Unlock держит одна горутина за раз// разбор: Mutex сериализует доступ к общим данным: Lock() входит в критическую секцию, Unlock() выпускает; пока один держит замок, другие горутины на Lock блокируются. Так защищают инвариант, который ломает конкурентная запись (например counter++). Это не кэш и не «магическая» безопасность переменной — защищает именно дисциплина: все идут через один и тот же мьютекс.
- Чем sync.RWMutex полезнее обычного Mutex при частых чтениях?A)Он быстрее обычного Mutex во всех сценариях за счёт lock-free реализацииB)Разрешает нескольким горутинам писать одновременно, если пишут в разные поляC)Пускает много читателей разом, но пишущего — одного и без читателейD)Полностью снимает необходимость блокировки для операций чтения
показать ответ и разбор
+C)Пускает много читателей разом, но пишущего — одного и без читателей// разбор: RWMutex различает чтение и запись: RLock() пускает сколько угодно читателей параллельно (данные не меняются — гонки нет), а Lock() для записи эксклюзивен и ждёт ухода всех читателей. Выигрывает на профиле «много читают, редко пишут». При частой записи преимущество тает, и обычный Mutex проще.
- Когда atomic (sync/atomic) уместнее мьютекса?A)Для одиночной операции над одним словом — счётчик, флаг: дешевле мьютексаB)Для защиты сложного инварианта из нескольких связанных полей одновременноC)Когда критическая секция делает много шагов и вызывает другие функцииD)atomic заменяет мьютекс в коде целиком и выходит быстрее по нагрузке
показать ответ и разбор
+A)Для одиночной операции над одним словом — счётчик, флаг: дешевле мьютекса// разбор: atomic-операции (Add, Load, Store, CompareAndSwap) меняют одно машинное слово атомарно и без блокировки — идеальны для счётчиков, флагов, указателей. Мьютекс нужен, когда под защитой сложный инвариант из нескольких полей или многошаговая секция: атомарностью одного слова его не выразить. Правило: одно слово — atomic, составное состояние — Mutex.
- Почему разблокировку мьютекса принято писать через defer сразу после Lock?A)defer делает разблокировку атомарной относительно других горутинB)Иначе ранний return или паника оставят мьютекс заблокированнымC)Компилятор переставит операции с мьютексом, если писать Unlock вручнуюD)defer снижает стоимость блокировки за счёт отложенного вызова
показать ответ и разбор
+B)Иначе ранний return или паника оставят мьютекс заблокированным// разбор: Функция обычно имеет несколько выходов, и при панике управление уходит вообще мимо конца тела. Забытый Unlock на одной из веток превращается в вечно удержанный мьютекс: следующая горутина встаёт навсегда, а поймать это тяжело — код по основному пути работает. defer привязывает разблокировку к выходу из функции, каким бы он ни был.
- Мьютекс защищает счётчик, который читают в сто раз чаще, чем пишут. Что даст переход на RWMutex и где предел?A)Читатели перестанут блокировать друг друга, но писатель по-прежнему ждёт всехB)И читатели, и писатели перестанут блокироваться — доступ станет свободнымC)Ничего не изменится: RWMutex внутри устроен как обычный мьютексD)Чтения станут атомарными, и синхронизация писателю больше не нужна
показать ответ и разбор
+A)Читатели перестанут блокировать друг друга, но писатель по-прежнему ждёт всех// разбор: RLock пропускает читателей одновременно, поэтому на профиле «много чтений, редкие записи» пропускная способность растёт. Писатель ждёт, пока выйдут все читатели, и на плотном потоке чтений может голодать. Выигрыш не бесплатный: RWMutex тяжелее обычного, и на коротких критических секциях простой Mutex или atomic обгоняют его.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.