Конкуренция в Go
Теория: sync.Mutex и RWMutex
Гонка данных возникает, когда две или больше горутин одновременно работают с одной и той же переменной в памяти, и хотя бы одна из них что-то в эту переменную записывает.
Процессор выполняет инструкции не по строчкам исходного кода, а на своем низком уровне: может переставлять операции, исполнять их на разных ядрах и чередовать фрагменты разных горутин. Если несколько горутин меняют одну и ту же переменную без синхронизации, результат зависит от того, чья операция успела выполниться первой.
Сценарий с «плавающим» счетчиком выглядит примерно так. Есть переменная count и три горутины, каждая из которых делает count++ в цикле. С точки зрения Go это не одна атомарная операция, а несколько шагов: прочитать count, прибавить единицу, записать обратно. Пока одна горутина читает значение, другая уже успевает его изменить. В итоге часть инкрементов теряется.
Внешне гонка данных проявляется так:
- одинаковый код дает разные результаты при каждом запуске;
- появляются странные, иногда невозможные значения;
- детектор
go test -raceвыводит предупреждения о совместном доступе к памяти.
Гонка не всегда сразу ломает программу. Иногда она годами остается незаметной и проявляется только под нагрузкой. Но как только несколько горутин оказываются в одном и том же участке памяти без защиты, результат становится непредсказуемым.
Использование sync.Mutex
Чтобы устранить гонку, нужно превратить работу с общими данными в критическую секцию — участок кода, который выполняет только одна горутина за раз. В Go эту роль выполняет sync.Mutex.
Мьютекс — это примитив взаимного исключения. Горутине нужно попасть в защищенную секцию — она вызывает Lock(). Если замок свободен, она проходит дальше и становится единственным владельцем. Если замок уже удерживается, горутина ждет, пока его освободят. Когда работа с общими данными закончена, вызывается Unlock(), и следующая горутина может войти.
Например, так выглядит потокобезопасная конфигурация:
Теперь даже если Config передадут по значению, у всех копий будет один общий мьютекс и одна карта, а поведение останется корректным. Это хороший рефлекс: поля из sync внутри структур — почти всегда указатели.
sync.RWMutex для чтения и записи
В реальных приложениях гораздо чаще происходят чтения, чем записи. Например, сервис конфигураций сотни раз в секунду читает текущие настройки и лишь иногда обновляет их.
Обычный Mutex блокирует всех: и читателей, и писателей. Если читать можно параллельно, это создает ненужное ограничение. Для таких ситуаций есть sync.RWMutex — мьютекс с разделением прав доступа.
У RWMutex два режима работы:
- режим чтения (
RLock/RUnlock) позволяет нескольким горутинам одновременно читать данные; - режим записи (
Lock/Unlock) дает эксклюзивный доступ, блокируя всех читателей и других писателей.
Пока никого нет в режиме записи, любое количество горутин может войти в RLock. Но как только нужен режим записи, RWMutex ждет, пока все читатели отпустят RUnlock, и только после этого пропускает писателя.
Пример кэша со справочными данными:
Чтения здесь гораздо чаще, чем записи. Несколько горутин могут вызывать Get одновременно, не мешая друг другу. Как только нужно обновить данные, Set захватывает мьютекс на запись, блокируя новые чтения и дождется завершения уже начавшихся.
RWMutex особенно полезен в сценариях «много чтения — мало записи»: кэши, справочники, конфигурации. Если же записи происходят часто, выигрыш исчезает, а накладные расходы RWMutex могут даже сделать код медленнее обычного мьютекса.
Как избежать взаимоблокировок
Как только в системе появляются несколько замков, возникает риск взаимоблокировки. Это состояние, когда горутины ждут друг друга бесконечно. Одна держит первый мьютекс и ждет второй, другая держит второй и ждет первый. Ни одна не может продвинуться дальше.
Типичный пример — две структуры, каждая со своим мьютексом. Одна функция захватывает сначала a.mu, затем b.mu, другая — в обратном порядке. При неудачном стечении обстоятельств обе горутины останавливаются: первая держит a и ждет b, вторая держит b и ждет a.
Избежать таких ситуаций помогает несколько простых правил.
Во-первых, фиксированный порядок захвата замков. Если в коде потенциально нужно взять два мьютекса A и B, нужно принять одно соглашение: сначала всегда A, потом всегда B. Тогда даже при множестве горутин цикл ожидания не образуется.
Во-вторых, короткие критические секции. Чем меньше кода выполняется под мьютексом, тем ниже шанс, что две горутины застрянут друг в друге. Долгие операции — сетевые запросы, работа с диском, ожидание по каналам — лучше выносить за пределы замка. Под мьютексом извлекается или обновляется минимальный набор данных, после чего замок сразу отпускается.
В-третьих, аккуратное сочетание мьютексов и каналов. Опасная конструкция выглядит так: горутина удерживает мьютекс и отправляет сообщение в канал, а той стороне, которая читает из канала, для обработки нужно взять тот же мьютекс. Получается цикл «мьютекс → канал → мьютекс». Надежнее сначала собрать данные под замком, отпустить замок, а уже затем работать с каналом. при этом, нужно помнить, что "под капотом" в канале тоже есть уже мьютекс, для работы с каналом обычно отдельных блокировок не требуется
И наконец, нельзя вызывать Lock для одного и того же мьютекса дважды подряд из одной горутины. Мьютексы в Go не реентерабельны. Если горутина уже владеет замком и пытается взять его еще раз, она заблокируется сама на себя и никогда не выйдет. В такой ситуации лучше переработать структуру кода: разделить данные или вынести часть логики наружу.
Если правила соблюдаются, мьютексы перестают быть источником сюрпризов. Они становятся предсказуемым инструментом: защищают общие данные от гонок и не приводят программу к тупикам.


