Гонки данных в Go: детектор и типовые случаи
Race detector в Go находит гонки за секунды, но только на тех путях, которые прошёл тест. Разбираем, что именно он ловит, четыре типовых источника гонок и почему проблема остаётся даже после зелёного прогона.

Сервис на Go под нагрузкой начинает отдавать некорректные счётчики: метрика запросов то отстаёт, то показывает отрицательное значение. Локально всё зелёное, в CI тесты проходят. Причина — гонка данных: две горутины пишут в одну переменную без синхронизации, и результат зависит от того, кто успел первым. В Go для таких случаев есть встроенный инструмент — race detector, который включается флагом -race. Он не находит все гонки, но те, что попали в исполнявшийся код, показывает точно, с двумя стеками и адресом переменной. Разберём, что он ловит, как читать его вывод и какие четыре паттерна дают гонки чаще всего. Актуально для Go 1.22–1.23.
Что такое гонка данных и что детектор считает гонкой
Гонка данных (data race) — это ситуация, когда два обращения к одной ячейке памяти происходят из разных горутин, хотя бы одно из них на запись, и между ними нет отношения happens-before*. Модель памяти Go гарантирует корректность только при наличии такого отношения — его создают каналы, мьютексы, sync/atomic и sync.WaitGroup. Без них компилятор и процессор вправе переупорядочить операции, а вы получаете значение, которого «не должно быть».
Детектор основан на алгоритме ThreadSanitizer. Он инструментирует каждое обращение к памяти и во время выполнения строит граф happens-before. Ключевой момент: детектор реагирует не на потенциальную гонку в коде, а на фактически произошедшее конкурентное обращение во время прогона. Если тест не запустил обе горутины одновременно на спорной переменной — гонка не сработает.
Как запустить
Флаг -race добавляется к обычным командам:
go test -race ./...
go run -race main.go
go build -race -o app .
Бинарник с -race работает в 2–20 раз медленнее и потребляет заметно больше памяти, поэтому в продакшене его не держат. Его место — тесты, CI и нагрузочные прогоны на стенде.
Как читать вывод детектора
Отчёт всегда содержит блок WARNING: DATA RACE, два (иногда больше) стека с пометками Read at / Write at и указанием горутины, а также место, где каждая горутина была создана. Читать нужно снизу вверх по конкретному конфликту: сначала адрес переменной, затем — кто пишет, кто читает.
Детектор не гадает и не даёт ложных срабатываний по своей природе алгоритма: если он сообщил о гонке, гонка есть. Обратное неверно — молчание не значит её отсутствие.
Практический вывод: go test -race без нагрузки на нужный участок кода бесполезен. Полезен он тогда, когда тест реально создаёт параллелизм — через t.Parallel(), пул горутин или прогон под -count.
Четыре типовых источника гонок
1. Общий счётчик без atomic
Самый частый случай. Несколько горутин инкрементируют одну переменную:
package main
import (
"sync"
)
func main() {
var счётчик int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
счётчик++ // гонка: чтение-модификация-запись без синхронизации
}()
}
wg.Wait()
}
Операция счётчик++ не атомарна: это чтение, инкремент и запись. Две горутины читают одно значение, обе пишут — один инкремент теряется. Итог почти никогда не равен 1000. Исправление — sync/atomic:
var счётчик int64
// внутри горутины:
atomic.AddInt64(&счётчик, 1)
// чтение результата после wg.Wait():
atomic.LoadInt64(&счётчик)
В Go 1.19+ доступны типы atomic.Int64 и подобные — они не дают случайно прочитать поле напрямую в обход атомарной операции, что и есть частая причина остаточной гонки.
2. Захват переменной цикла в горутине (до Go 1.22)
Классическая ловушка: переменная цикла до Go 1.22 была одна на все итерации.
// поведение Go 1.21 и раньше
for _, значение := range список {
go func() {
обработать(значение) // все горутины видят одну переменную
}()
}
В Go 1.22 семантика изменилась: переменная цикла теперь создаётся заново на каждой итерации, и этот конкретный случай перестал быть гонкой. Но если ваш go.mod указывает версию языка ниже 1.22, старое поведение сохраняется. Проверьте строку go 1.xx в go.mod — именно она определяет семантику, а не установленный компилятор.
3. Общая map без мьютекса
Конкурентная запись в map — это не просто гонка данных, а фатальная ошибка рантайма: программа падает с fatal error: concurrent map writes даже без флага -race. Детектор поймает и одновременное чтение с записью.
var mu sync.RWMutex
кэш := make(map[string]int)
// запись
mu.Lock()
кэш[ключ] = значение
mu.Unlock()
// чтение
mu.RLock()
v := кэш[ключ]
mu.RUnlock()
Если карта только читается конкурентно, но пишется до старта горутин, мьютекс не нужен. Если чтение и запись смешаны — либо мьютекс, либо sync.Map для случая «много чтений, редкие записи».
4. Гонка на записи результата в поле структуры
Несколько воркеров пишут результат в поля общей структуры без синхронизации. Гонка возникает не только на самом поле, но и на срезе, если воркеры пишут в один и тот же индекс. Безопасный вариант — каждый воркер пишет в свой индекс среза, который был выделен заранее:
результаты := make([]int, len(задачи))
var wg sync.WaitGroup
for i := range задачи {
wg.Add(1)
go func(idx int) {
defer wg.Done()
результаты[idx] = вычислить(задачи[idx]) // каждый — свой индекс
}(i)
}
wg.Wait()
Запись в разные индексы одного среза без синхронизации корректна: горутины не пересекаются по памяти, а wg.Wait() создаёт happens-before перед чтением всего среза. Индекс передаётся параметром — не полагайтесь даже на исправленную семантику цикла для наглядности.
Сравнение способов синхронизации
| Инструмент | Когда применять | Цена |
|---|---|---|
| sync/atomic | Счётчики, флаги, отдельные числовые значения | Быстро, но только примитивы; легко забыть atomic-чтение |
| sync.Mutex | Составное состояние, несколько связанных полей | Простой, но сериализует доступ; риск дедлока при вложенных блокировках |
| sync.RWMutex | Много чтений, редкие записи | Выигрыш только при явном перевесе чтений; дороже Mutex на записи |
| Каналы | Передача владения данными между горутинами | Идиоматично, но медленнее мьютекса на горячем пути |
Где детектор бессилен
Race detector не находит:
- Гонки на непройденных путях. Если ветка кода не выполнилась во время прогона, конфликт в ней не всплывёт. Отсюда практика запуска под
-raceс реалистичной нагрузкой и-count=10для повторов. - Логические гонки без обращения к одной памяти. Например, два процесса пишут в один файл или ходят в одну строку БД — это гонка на уровне ресурса, детектор её не видит.
- Взаимоблокировки. Дедлок на мьютексах — не гонка данных; его ловит рантайм только когда все горутины заснули, и то не всегда.
Инвалидация и версионирование состояния между сервисами — отдельная тема, здесь речь только о гонках внутри одного процесса.
Что делать дальше
Включите -race в CI для пакетов с конкурентным кодом — как отдельный шаг, а не вместо обычных тестов, из-за замедления. Добавьте t.Parallel() и -count к тестам, которые проверяют многопоточные участки, чтобы детектор увидел реальный параллелизм. На стенде прогоните нагрузочный сценарий бинарником, собранным с -race, — именно там всплывают гонки, которые unit-тесты не воспроизводят. И проверьте строку go в go.mod: от неё зависит семантика переменной цикла и, значит, часть ваших старых горутин.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Data Race Detector — The Go Programming Language, The Go Memory Model
Частые вопросы
Можно ли включать -race в продакшене?
Тест с -race прошёл зелёным — значит, гонок нет?
У меня Go 1.21, а не 1.22 — код с переменной цикла всё ещё в гонке?
Детектор молчит, но данные всё равно портятся — что делать?
Чем atomic.Int64 лучше обычного int64 с atomic.AddInt64?
Почему запись в разные индексы одного среза из горутин безопасна, а в map — нет?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.