1. Зачем нужен go test -race
Если обычный баг похож на дырку в трубе (вода течёт всегда, стабильно, предсказуемо), то гонка данных похожа на кота, который иногда проходит сквозь стену. Вы не можете на неё нормально «пожаловаться»: “ну вот же, минуту назад было!”. Именно поэтому в Go есть специальный инструмент — race detector, который помогает сделать невидимое видимым.
Когда у нас есть конкурентный код (или код, который может стать конкурентным: обработчики HTTP, worker pool, параллельные тесты), мы хотим отвечать на простой вопрос: «нет ли у меня одновременной записи/чтения одной и той же памяти без синхронизации?». Это и есть главный смысл go test -race.
Важно не путать термины. Есть более широкое понятие race condition («состояние гонки»): когда результат зависит от порядка событий. Оно может быть даже без одновременного доступа к памяти. Классический пример — «кто первый успел, тот и молодец» в конкурентной логике, как в ситуации “получить первый ответ и игнорировать остальные”. А data race (гонка данных) — конкретно про память: когда несколько goroutine лезут к одному месту в памяти без договора.
Go‑шный -race помогает ловить именно data race. И уже одно это может сэкономить вам дни жизни, пару нервных клеток и один седой волос (или двадцать).
2. Что такое data race
Чтобы почувствовать, что именно «считается гонкой», полезно проговорить формальное правило простыми словами. Сейчас будет «юридический язык», но я переведу его на человеческий.
Data race возникает, когда:
- есть две (или больше) goroutine,
- они обращаются к одной и той же области памяти (переменная, поле структуры, элемент map, элемент слайса, и т.д.),
- хотя бы одно обращение — запись,
- и между этими обращениями нет синхронизации, то есть нет «договора», который делает порядок безопасным (mutex, каналы, атомики и другие признанные способы).
Две тонкости, которые часто удивляют новичков.
Первая тонкость в том, что «одна и та же область памяти» — это не только «глобальная переменная n». Это может быть поле внутри структуры, лежащей в куче, общий map, общий слайс, общий буфер, общий bytes.Buffer, и так далее. То есть «общий объект» важнее, чем «глобальная переменная».
Вторая тонкость в том, что гонка может происходить даже тогда, когда результат «вроде правильный». Например, вы ожидаете, что два инкремента дадут 2, и иногда действительно получается 2. Но сам факт, что запись была несогласованной — уже дефект. -race как раз ловит дефект модели доступа, а не только «неправильное значение».
3. Как запускать go test -race
Когда вы запускаете тесты с флагом -race, Go собирает и запускает тестовый бинарник в специальном режиме: добавляется инструментирование, которое во время выполнения пытается заметить несогласованные обращения к памяти.
Выглядит это обычно так (как справка, не как «обязательная команда»):
go test -race ./...
Можно запускать и точечно — один пакет:
go test -race ./internal/task
Или один тест (удобно, когда вы уже нашли подозрительное место):
go test -race -run TestInMemoryStore_Add_Race ./internal/task
Есть важное практическое следствие: -race делает программу заметно медленнее и прожорливее по памяти. Это нормально. Он не нужен «всегда в проде», он нужен как диагностический прожектор: включили, посмотрели, выключили. Как фонарик в подвале, куда вы не хотите жить переезжать.
И ещё один момент для понимания картины мира. Race detector — штука не новая и является частью экосистемы Go уже много лет; например, в заметках о Go 1.3 среди улучшений упоминался более быстрый race detector. Это важно психологически: инструмент зрелый и практический, а не «экспериментальная игрушка».
4. Пример гонки в in-memory хранилище задач
Представим, что мы развиваем учебное приложение «таск‑менеджер»: хранилище задач, создание задач, список задач. Допустим, мы начали с простого in‑memory хранилища на map[int]Task и автонумерации nextID. Пока всё однопоточно — красота. Но как только появляются параллельные сценарии (например, несколько worker’ов импортируют задачи, или тесты запускают конкурентные операции), «простое» становится опасным.
Вот очень упрощённый вариант хранилища без защиты:
package task
type InMemoryStore struct {
nextID int
items map[int]string
}
func NewInMemoryStore() *InMemoryStore {
return &InMemoryStore{nextID: 1, items: make(map[int]string)}
}
func (s *InMemoryStore) Add(title string) int {
id := s.nextID
s.nextID++
s.items[id] = title
return id
}
С точки зрения бизнес‑логики всё звучит честно: берём nextID, увеличиваем, кладём задачу в map. Но по модели памяти — тут сразу две общие точки: nextID и items.
Теперь тест, который делает две конкурентные записи. Он маленький, почти смешной — но это как раз хороший учебный момент: гонки часто начинаются с «да что тут может случиться».
package task
import (
"sync"
"testing"
)
func TestInMemoryStore_Add_Race(t *testing.T) {
s := NewInMemoryStore()
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); _ = s.Add("a") }()
go func() { defer wg.Done(); _ = s.Add("b") }()
wg.Wait()
}
Если вы запустите этот тест без -race, он, скорее всего, будет зелёным. И вот тут мозг делает опасный вывод: «значит всё ок». А -race нужен, чтобы объяснить мозгу: «нет, не ок, просто тебе повезло».
5. Как читать отчёт race detector
Отчёт race detector обычно пугает новичка объёмом: там какие-то goroutine, какие-то стеки, какие-то адреса памяти. Хорошая новость: вам не нужно понимать всё. Вам нужно понять две вещи: где была одна сторона конфликта и где была другая.
Типовой отчёт (я намеренно показываю упрощённую «форму», а не копирую полный вывод):
WARNING: DATA RACE
Write at ... by goroutine 7:
... file.go:NN
Previous read at ... by goroutine 8:
... file.go:MM
Смысл такой.
Первая часть показывает один доступ (например, запись), и стек вызовов приводит вас к строчке, где реально произошла запись. Вторая часть показывает другой конфликтующий доступ (например, чтение или тоже запись), и тоже даёт стек и строчку.
Вот простой способ смотреть на это как на схему:
goroutine A: пишет в X ----\
=> конфликт на X (нет синхронизации)
goroutine B: читает X ----/
Что искать глазами в первую очередь.
Смотрите на ваши файлы и ваши строки. Адреса памяти и внутренние вызовы рантайма почти никогда не являются вашей стартовой точкой. Обычно достаточно найти две строчки в вашем коде и задать вопрос: «почему эти два куска кода могут выполняться одновременно и трогать одно и то же?».
И ещё важный практический момент: race report не всегда означает, что «виноваты оба места одинаково». Часто один доступ — это нормальная операция «работать с состоянием», а второй — «случайно полезли туда же параллельно». Исправление обычно состоит в том, чтобы ввести протокол: либо защитить критическую секцию mutex’ом, либо перестроить взаимодействие через каналы, либо убрать разделяемое состояние.
6. Что -race ловит, а что нет
С -race легко начать верить в магию: «включил, прогнал — значит всё правильно». Это очень опасная вера. Race detector — мощный, но у него есть границы: он ловит то, что реально случилось во время запуска, и ловит именно гонки данных.
Удобно держать в голове вот такую таблицу:
| Ситуация | Поймает -race? | Почему |
|---|---|---|
| Две goroutine читают и пишут одну переменную без mutex/канала | Да | Это классическая data race: общий участок памяти + запись без синхронизации |
| Конкурентный доступ к map без защиты | Да (и часто ещё и без -race упадёт) | map не потокобезопасен, и инструмент хорошо видит несогласованные обращения |
| Ошибка логики «кто первый успел — тот и прав», но без общей памяти | Нет | Это race condition как логическая зависимость от порядка, но не data race |
| Дедлок (все ждут друг друга) | Нет | Это не гонка данных, а проблема протокола блокировок/каналов |
| Гонка, которая теоретически есть, но в этом прогоне «не случилась» | Может не поймать | Инструмент динамический: не было конфликтующего interleaving — не было сигнала |
| Баг из-за неправильного порядка действий внутри одной goroutine | Нет | Это просто обычная ошибка, без конкуренции |
Обратите внимание на третью строку: race condition шире, чем data race. Мы уже встречали это, когда говорили про конкурентные паттерны и таймауты: иногда правильность зависит от порядка событий. Но -race — не детектор «плохих сценариев», он детектор «плохих обращений к памяти».
Ещё одна граница, о которой полезно помнить: отсутствие отчёта -race не является доказательством отсутствия гонок. Это доказательство отсутствия гонок в данном конкретном прогоне на данной машине, при данном расписании goroutine. То есть это очень сильный сигнал «вот здесь точно плохо», но не абсолютная гарантия «везде точно хорошо».
7. Как повысить шанс поймать гонку
Проблема гонок в том, что они иногда прячутся. Вы включили -race, прогнали тест — тишина. И возникает соблазн сказать: «ну всё, расходимся». Но правильнее сказать: «а давайте дадим гонке шанс проявиться».
Самый простой усилитель — повторить конкурентную операцию много раз. В тестах это делается банально циклом. Например, мы можем сделать не 2 конкурентных Add, а 1000, распределённых по нескольким goroutine. Тут важен баланс: тест должен оставаться быстрым, но «достаточно шумным», чтобы конфликт был вероятен.
Вот пример «усиленного» теста (обратите внимание: он всё ещё короткий, и в нём нет time.Sleep):
package task
import (
"strconv"
"sync"
"testing"
)
func TestInMemoryStore_Add_RaceMany(t *testing.T) {
s := NewInMemoryStore()
var wg sync.WaitGroup
for g := 0; g < 4; g++ {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := 0; i < 200; i++ {
_ = s.Add("t" + strconv.Itoa(g*1000+i))
}
}(g)
}
wg.Wait()
}
Почему мы избегаем time.Sleep как «усилителя»? Потому что Sleep делает тесты flaky: на одной машине «успели», на другой «не успели». Наша цель — чтобы тест был повторяемым по смыслу: мы запускаем конкурентную нагрузку и проверяем, что при такой нагрузке нет гонок. Если вы добавляете Sleep, вы начинаете тестировать тайминги, а не свой код.
Если вам нужно прогнать тесты несколько раз, часто используют флаг -count=1, чтобы отключить кеширование результатов теста и реально выполнять их каждый запуск. Это не магия и не «фикс гонки», а просто способ быть уверенным, что вы действительно прогоняете тест, а не читаете старый результат.
8. Как исправить гонку: mutex и повторная проверка
Теперь самое приятное: исправление. В нашем InMemoryStore у нас есть общий nextID и общий map. Логика изменения состояния должна стать критической секцией.
Добавим sync.Mutex и защитим участок «взял id → увеличил → записал в map»:
package task
import "sync"
type InMemoryStore struct {
mu sync.Mutex
nextID int
items map[int]string
}
func (s *InMemoryStore) Add(title string) int {
s.mu.Lock()
defer s.mu.Unlock()
id := s.nextID
s.nextID++
s.items[id] = title
return id
}
Это изменение кажется небольшим, но по смыслу оно огромное: мы вводим договорённость. Теперь даже если Add вызовут из 10 goroutine одновременно, доступ к nextID и items станет последовательным.
Дальше вы запускаете те же тесты с -race, и инструмент перестаёт ругаться. Это именно тот цикл, который стоит встроить себе в привычку: «написал конкурентный код → сделал тест с конкурентной нагрузкой → прогнал с -race».
И тут важная ремарка: mutex — не единственное решение. Иногда лучше сделать архитектуру без разделяемого состояния, например, передавать операции через канал в одну goroutine‑владельца состояния. Но на нашем текущем уровне mutex — прямой и честный инструмент: читается легко, работает предсказуемо.
9. Типичные ошибки при работе с go test -race
Ошибка №1: думать, что -race доказывает отсутствие гонок.
Очень хочется жить в мире, где одна команда даёт математическую гарантию. Но -race — динамический инструмент: он показывает то, что случилось во время выполнения. Если гонка зависит от редкого расписания goroutine, она может не проявиться в одном прогоне. Поэтому тишина -race — хороший знак, но не «сертификат безопасности».
Ошибка №2: лечить гонки time.Sleep.
Когда вы видите, что «иногда» два потока мешают друг другу, рука тянется добавить паузу: «пусть один успеет». Это почти всегда ухудшает ситуацию. Вы не вводите протокол, вы вводите гадание на таймингах. На другом CPU, под другой нагрузкой, с другим планировщиком всё снова развалится.
Ошибка №3: защищать только запись, но оставлять чтение без защиты.
Новички часто ставят mutex «в месте записи» и думают, что всё. Но если другая goroutine читает то же поле без mutex — это всё ещё data race. Протокол должен быть симметричным: либо вы договорились, что доступ к полю всегда под lock, либо вы договорились, что поле неизменяемое после инициализации, либо используете другой безопасный механизм.
Ошибка №4: делать параллельные тесты, которые делят общее состояние.
Даже если прод‑код идеальный, тесты могут сами породить гонку: общий глобальный store, общий map с фикстурами, общий счётчик, общий временный файл без изоляции. После этого -race ругается, а вы чините не то место. Дисциплина простая: каждый тест создаёт своё состояние, особенно если есть t.Parallel().
Ошибка №5: игнорировать race report и «чинить наугад».
Отчёт почти всегда даёт две точки: где один доступ, где второй. Если вы начинаете «наугад» обкладывать всё mutex’ами, вы рискуете получить или сложный код, или дедлоки, или просто потерять понимание, что вы защищаете. Лучше идти от отчёта: найти две строчки, понять общий объект, выбрать один понятный протокол.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ