сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Горутины и каналы

Mutex и atomic в Go

Мьютекс, RWMutex, atomic

Замерил на двух ядрах одну и ту же операцию, инкремент счётчика. 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, остальные разбираются в тренажёре.

  1. #go_mutex_atomic1 / 5
    Зачем нужен sync.Mutex?
    A)Ускоряет доступ к общей памяти, кэшируя её значение между горутинами
    B)Взаимное исключение: секцию Lock–Unlock держит одна горутина за раз
    C)Помечает переменную потокобезопасной, пока с ней работает горутина
    D)Создаёт отдельную копию данных каждой горутине, чтобы они не пересекались
    показать ответ и разбор
    +B)Взаимное исключение: секцию Lock–Unlock держит одна горутина за раз

    // разбор: Mutex сериализует доступ к общим данным: Lock() входит в критическую секцию, Unlock() выпускает; пока один держит замок, другие горутины на Lock блокируются. Так защищают инвариант, который ломает конкурентная запись (например counter++). Это не кэш и не «магическая» безопасность переменной — защищает именно дисциплина: все идут через один и тот же мьютекс.

  2. #go_mutex_atomic2 / 5
    Чем sync.RWMutex полезнее обычного Mutex при частых чтениях?
    A)Он быстрее обычного Mutex во всех сценариях за счёт lock-free реализации
    B)Разрешает нескольким горутинам писать одновременно, если пишут в разные поля
    C)Пускает много читателей разом, но пишущего — одного и без читателей
    D)Полностью снимает необходимость блокировки для операций чтения
    показать ответ и разбор
    +C)Пускает много читателей разом, но пишущего — одного и без читателей

    // разбор: RWMutex различает чтение и запись: RLock() пускает сколько угодно читателей параллельно (данные не меняются — гонки нет), а Lock() для записи эксклюзивен и ждёт ухода всех читателей. Выигрывает на профиле «много читают, редко пишут». При частой записи преимущество тает, и обычный Mutex проще.

  3. #go_mutex_atomic3 / 5
    Когда atomic (sync/atomic) уместнее мьютекса?
    A)Для одиночной операции над одним словом — счётчик, флаг: дешевле мьютекса
    B)Для защиты сложного инварианта из нескольких связанных полей одновременно
    C)Когда критическая секция делает много шагов и вызывает другие функции
    D)atomic заменяет мьютекс в коде целиком и выходит быстрее по нагрузке
    показать ответ и разбор
    +A)Для одиночной операции над одним словом — счётчик, флаг: дешевле мьютекса

    // разбор: atomic-операции (Add, Load, Store, CompareAndSwap) меняют одно машинное слово атомарно и без блокировки — идеальны для счётчиков, флагов, указателей. Мьютекс нужен, когда под защитой сложный инвариант из нескольких полей или многошаговая секция: атомарностью одного слова его не выразить. Правило: одно слово — atomic, составное состояние — Mutex.

  4. #go_mutex_atomic4 / 5
    Почему разблокировку мьютекса принято писать через defer сразу после Lock?
    A)defer делает разблокировку атомарной относительно других горутин
    B)Иначе ранний return или паника оставят мьютекс заблокированным
    C)Компилятор переставит операции с мьютексом, если писать Unlock вручную
    D)defer снижает стоимость блокировки за счёт отложенного вызова
    показать ответ и разбор
    +B)Иначе ранний return или паника оставят мьютекс заблокированным

    // разбор: Функция обычно имеет несколько выходов, и при панике управление уходит вообще мимо конца тела. Забытый Unlock на одной из веток превращается в вечно удержанный мьютекс: следующая горутина встаёт навсегда, а поймать это тяжело — код по основному пути работает. defer привязывает разблокировку к выходу из функции, каким бы он ни был.

  5. #go_mutex_atomic5 / 5
    Мьютекс защищает счётчик, который читают в сто раз чаще, чем пишут. Что даст переход на RWMutex и где предел?
    A)Читатели перестанут блокировать друг друга, но писатель по-прежнему ждёт всех
    B)И читатели, и писатели перестанут блокироваться — доступ станет свободным
    C)Ничего не изменится: RWMutex внутри устроен как обычный мьютекс
    D)Чтения станут атомарными, и синхронизация писателю больше не нужна
    показать ответ и разбор
    +A)Читатели перестанут блокировать друг друга, но писатель по-прежнему ждёт всех

    // разбор: RLock пропускает читателей одновременно, поэтому на профиле «много чтений, редкие записи» пропускная способность растёт. Писатель ждёт, пока выйдут все читатели, и на плотном потоке чтений может голодать. Выигрыш не бесплатный: RWMutex тяжелее обычного, и на коротких критических секциях простой Mutex или atomic обгоняют его.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.