Гонки данных в Go: чем платит архитектура за общую память
Гонка данных в Go — это не просто флаг у race-детектора. Она диктует, как вы разбиваете код на горутины, где ставите границы владения данными и почему рефакторинг такой проблемы редко сводится к добавлению мьютекса.

Сервис на Go спокойно работает в тестах и падает раз в сутки в проде с паникой concurrent map read and map write или отдаёт клиенту чужой ответ. Race-детектор в CI зелёный, потому что нагрузочный сценарий с нужным чередованием горутин там просто не воспроизвёлся. Гонка данных — это не баг одной строки, а следствие того, как в вашей архитектуре несколько горутин делят одну память. Разбираемся, какие архитектурные решения порождают гонки, во что обходится каждый способ их убрать и почему мьютекс поверх готового кода — обычно худший из вариантов.
Что именно считается гонкой в модели памяти Go
Формально гонка данных — это когда две горутины обращаются к одной ячейке памяти, хотя бы одно из обращений — запись, и между ними нет отношения happens-before. Модель памяти Go (обновлённая формулировка опубликована для версий начиная с Go 1.19) гарантирует упорядоченность только через явные примитивы синхронизации: каналы, sync.Mutex, sync/atomic, sync.Once, запуск и завершение горутины через WaitGroup.
Практическое следствие жёсткое: если синхронизации нет, компилятор и процессор вправе переупорядочить операции, а результат чтения не определён. Это не «иногда прочитаем старое значение» — это undefined behavior. Программа может увидеть частично записанный интерфейс, где тип уже новый, а указатель на данные ещё старый, и упасть на разыменовании.
Гонка данных — это не про то, что вы прочитали устаревшее значение. Это про то, что после гонки у вашей программы больше нет гарантий вообще ни на что.
Три архитектурных источника гонок
Почти все гонки в реальном коде растут из одного из трёх решений, принятых на уровне структуры, а не строки.
1. Общее изменяемое состояние на весь процесс
Классика — кеш или счётчик в виде глобальной map, к которой ходят обработчики HTTP. Каждый запрос — своя горутина (так работает net/http), и запись в map из двух горутин одновременно — не просто гонка, а гарантированная паника рантайма, потому что для map рантайм ставит явную проверку.
Архитектурная цена: как только у вас есть глобальное изменяемое состояние, вы обязаны синхронизировать каждый путь доступа к нему. Забыли один — гонка. Масштабируется это плохо: чем больше кода трогает состояние, тем выше шанс пропустить точку.
2. Замыкание захватывает переменную цикла (до Go 1.22)
Запуск горутины в цикле с захватом переменной итерации — источник гонок в коде до Go 1.22. В версиях по Go 1.21 включительно переменная цикла была одна на все итерации, и горутины видели её меняющееся значение.
// Go 1.21 и раньше: все горутины делят одну переменную item
for _, item := range items {
go func() {
process(item) // гонка: item меняется в главной горутине
}()
}
С Go 1.22 семантика изменилась: переменная цикла создаётся заново на каждой итерации, и этот конкретный класс гонок исчез. Но если ваш модуль объявляет go 1.21 или ниже в go.mod, старое поведение сохраняется — версия в go.mod управляет семантикой. Проверьте её, прежде чем радоваться.
3. Публикация объекта до завершения его инициализации
Горутина А создаёт структуру, кладёт указатель в общее поле и продолжает заполнять поля. Горутина Б читает указатель и видит полуготовый объект. Без happens-before между записью полей и чтением указателя гарантий нет даже на 64-битной платформе.
Четыре подхода и их цена
Убрать гонку можно по-разному, и выбор — архитектурный, а не косметический. Он определяет, кто владеет данными.
| Подход | Что выигрываете | Чем платите |
|---|---|---|
sync.Mutex вокруг состояния | Минимум изменений в структуре кода | Легко пропустить путь доступа; риск deadlock при вложенных блокировках; сериализация на горячем участке |
| Передача данных через канал (владение) | Один владелец в момент времени, гонка невозможна по построению | Переписывание потока данных; накладные расходы канала на высокой частоте |
sync/atomic / atomic.Pointer | Быстрое чтение без блокировки для отдельного значения | Годится только для простых значений или указателей целиком, не для составного инварианта |
| Иммутабельность + copy-on-write | Читатели вообще не синхронизируются | Аллокации на каждое изменение; неприемлемо при частых записях |
Когда что выбирать
- Мьютекс — когда состояние составное (несколько связанных полей с общим инвариантом) и запись частая. Инкапсулируйте мьютекс и данные в одну структуру, наружу отдавайте только методы. Это единственный способ гарантировать, что все пути доступа проходят через синхронизацию.
- Канал — когда данные логически перемещаются между стадиями (пайплайн, воркер-пул). Здесь модель владения ложится на канал естественно: отправил в канал — больше не трогаешь.
- atomic.Pointer — когда есть один снимок конфигурации или кеша, который целиком заменяется, а читателей много и читают часто. Замена указателя атомарна, читатели всегда видят согласованный снимок.
- Иммутабельность — когда чтений на порядки больше записей и объект небольшой.
Атомарная замена снимка вместо мьютекса на чтении
Частый случай: конфигурация или справочник, который обновляется раз в минуту, а читается тысячи раз в секунду. Мьютекс на чтении сериализует читателей без пользы. atomic.Pointer (доступен с Go 1.19) решает это чисто.
package config
import "sync/atomic"
type Snapshot struct {
Limits map[string]int // после публикации не меняется
}
type Store struct {
current atomic.Pointer[Snapshot]
}
// Reload собирает новый снимок целиком и атомарно публикует его.
func (s *Store) Reload(newLimits map[string]int) {
snap := &Snapshot{Limits: newLimits}
s.current.Store(snap) // одна атомарная запись указателя
}
// Get возвращает согласованный снимок без блокировки.
func (s *Store) Get() *Snapshot {
return s.current.Load()
}
Ключевой инвариант, который держит вся конструкция: после Store никто не мутирует поля Snapshot. Если хоть одна горутина после публикации допишет в snap.Limits, гонка вернётся — только теперь её будет сложнее найти, потому что race-детектор укажет на map внутри снимка, а не на очевидное общее поле.
Где подход ломается
Каждый способ имеет граничные случаи, о которых узнают в проде.
- atomic не спасает составной инвариант. Если вам нужно атомарно обновить два связанных значения (баланс и лог операции), атомарная замена каждого по отдельности не даёт согласованности. Между двумя
Storeчитатель увидит рассогласованное состояние. Нужен мьютекс или замена единого снимка, покрывающего оба поля. - Мьютекс не защищает то, что утекло наружу. Метод под мьютексом вернул ссылку на внутренний слайс — и вызывающий читает его уже без блокировки. Возвращайте копию или неизменяемое представление.
- Канал не отменяет гонку по данным, которые вы отправили. Если после отправки указателя в канал отправитель продолжает писать в тот же объект, канал бесполезен. Отправили — забыли.
- Ложное чувство безопасности от 64-битных чтений. Запись и чтение
int64без atomic на amd64 может выглядеть рабочей годами, но это по-прежнему гонка по модели памяти. Смена архитектуры или обновление компилятора вскроют её.
Как искать гонки, которые не проявились
Race-детектор находит только те гонки, что реально произошли во время прогона. Отсюда дисциплина:
- Гоняйте тесты с флагом
-raceв CI:go test -race ./.... Замедление в 2–10 раз и рост памяти — нормальная плата, в прод бинарь с-raceне собирают. - Пишите тесты, которые реально запускают конкурентный доступ — несколько горутин через
sync.WaitGroup, а не последовательные вызовы. Детектор бесполезен, если тест однопоточный. - Прогоняйте под нагрузкой с
-raceв стейджинге хотя бы периодически: часть гонок вылезает только на реальном чередовании.
Флаг -race инструментирует обращения к памяти и отслеживает отношения happens-before в рантайме, поэтому он не даёт ложных срабатываний — если он что-то нашёл, это настоящая гонка. Обратное неверно: молчание детектора не доказывает отсутствие гонок.
Что делать дальше
Прежде чем добавлять мьютекс к существующей проблеме, ответьте на один вопрос: кто владеет этими данными. Если ответ «все горутины сразу» — это и есть корень, и мьютекс лишь замаскирует его. Перепроектируйте так, чтобы у данных был один владелец в каждый момент: через канал, через атомарную замену снимка или через инкапсуляцию за методами одной структуры.
Конкретные шаги на ближайший спринт: включите go test -race в обязательный набор CI, проверьте версию go в go.mod на предмет старой семантики цикла, и пройдитесь по глобальным изменяемым переменным — каждая из них кандидат в источник гонки. Для деталей модели памяти держите под рукой официальный документ The Go Memory Model.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: The Go Memory Model, Data Race Detector — Go documentation
Частые вопросы
У меня в go.mod указано go 1.20, а компилятор Go 1.23. Изменилась ли семантика переменной цикла?
Race-детектор молчит на моих тестах — значит, гонок нет?
Можно ли собирать прод-бинарь с флагом -race для надёжности?
Достаточно ли atomic.Pointer, чтобы навсегда убрать гонку в кеше?
Чтение и запись int64 без atomic годами работали на нашем сервере — это точно гонка?
Что выбрать для составного состояния — atomic или мьютекс?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.