Гонки данных в Go: что ломается, что видит -race и чего он не видит
Собеседование по гонкам идёт лестницей: что такое гонка данных — чем она отличается от состояния гонки — как её найти — найдёт ли -race все гонки — можно ли включить его в продакшене. Прогон отвечает на каждый шаг: структура из двух слов без синхронизации отдаёт пары, которых никто не записывал; -race находит гонку, не испортившую результат, и не находит ту, что не выполнилась; а проверка и списание под разными захватами мьютекса уводят баланс в минус в 936 случаях из 1000 при нуле отчётов.
Полное техническое изложение
TL;DR
Гонка данных — это запись в ячейку памяти одновременно с другим обращением к той же ячейке, без синхронизации между ними. Так её определяет модель памяти Go. Определение не говорит ни слова о неверном результате: гонка — это отсутствие порядка между обращениями, а не потерянный инкремент.
Отсюда главное следствие: безобидных гонок не бывает. Структура из двух
слов, в которую писатель всегда кладёт одинаковые числа, у читателя без
синхронизации оказывалась разорванной в 53–75 % копий: пары, которых никто
никогда не записывал. Для интерфейсов, срезов, строк и карт модель памяти
допускает «произвольную порчу памяти». А цикл for !done {} по флагу без
синхронизации, по той же модели, не обязан завершиться вообще.
Дальше — то, что отличает понимание от пересказа. Детектор -race ищет не
неверный ответ, а доступы без порядка: гонку, после которой программа
напечатала правильные n = 2000, он нашёл и завершил процесс с кодом 66 —
это значение по умолчанию. Но он видит только то, что произошло в этом
запуске: гонка на невыполненном пути — ноль отчётов; гоночная программа, чьи
обращения в этом запуске случайно упорядочил замок, — тоже ноль. И не видит состояния гонки без гонки данных: проверка и
списание под разными захватами одного мьютекса увели баланс в минус в
936 случаях из 1000, отчётов — ноль. Цена режима на замеренной нагрузке:
память ×4,8–5,0, время ×7,6–12. Поэтому его место — регулярно в CI и на
стендах, а в продакшене — разве что ограниченным диагностическим запуском, а не
на всём парке.
- что такое горутина и как запустить несколько;
- что
n++из нескольких горутин теряет обновления и что с этим делают мьютекс и атомарные операции — это урок оsyncиatomic; - что такое happens-before хотя бы в общих чертах — оттуда же.
- определение гонки данных из модели памяти и чем оно отличается от «состояния гонки»;
- разорванные значения, цикл по флагу без синхронизации;
- как читать отчёт
-race, чего он не находит и сколько стоит.
Что здесь на самом деле спрашивают
Лестница почти всегда такая:
- «Что такое гонка данных?» — ответ «когда счётчик теряет инкременты» неполон, и на нём ловят.
- «Чем гонка данных отличается от состояния гонки?» — вопрос, ради которого тему и поднимают.
- «Как найти гонку?» —
-race, и это половина ответа. - «Найдёт ли
-raceвсе гонки?» — нет, и надо уметь сказать, каких не найдёт. - «Можно ли включить
-raceв продакшене?» — про цену. - «Гонка на булевом флаге — это же безобидно?» — про то, что безобидных нет.
Стержень у лестницы один: гонка — это не неверный результат, а отсутствие порядка между обращениями. Неверный результат — лишь один из её симптомов, и не самый опасный.
База: определение, в котором нет слова «результат»
Определение модели памяти короткое:
A data race is defined as a write to a memory location happening concurrently
with another read or write to that same location, unless all the accesses
involved are atomic data accesses as provided by the sync/atomic package.
Гонка данных — это запись в ячейку памяти, происходящая одновременно с другим чтением или записью той же ячейки, если только все участвующие обращения не атомарные, как в пакете sync/atomic.
Три слова в нём несут всю тему.
- «Запись». Два одновременных чтения — не гонка. Нужна хотя бы одна запись.
- «Одновременно». Не «в одно и то же мгновение», а без порядка: между
обращениями нет ни мьютекса, ни канала, ни атомарной операции — ничего, что
создаёт happens-before (оно разобрано в уроке о
syncиatomic). - «Если только не атомарные». Если все обращения к ячейке атомарные — это по определению не гонка. Атомарная запись и обычное чтение той же ячейки — гонка.
Слова «неверный результат» в определении нет — и ответ уровня Junior тоже
строится без него:
гонка данных — это когда две горутины обращаются к одной переменной, хотя
бы одна пишет, и между обращениями нет синхронизации. Лечится мьютексом,
каналом или атомарной операцией; находится флагом -race.
Потеря инкрементов в n++ — самый известный симптом, и он разобран в уроке о
sync и atomic. Здесь — то, что из определения следует сверх него.
Механизм 1: значение, которого никто не записывал
Писатель кладёт в структуру из двух слов одно и то же число в оба поля:
{1, 1}, {2, 2}, {3, 3}… Читатель копирует структуру без синхронизации и
проверяет, равны ли поля:
type pair struct{ a, b int64 }
// писатель: shared = pair{i, i}
// читатель: x := shared; if x.a != x.b { torn++ }1) РАЗОРВАННОЕ ЗНАЧЕНИЕ: struct{ a, b int64 }
─────────────────────────────────────────────
писатель всегда кладёт {i, i}; читатель копирует без замка
круг прочитано a != b доля
1 90575872 49682570 54.9%
2 83404800 62725161 75.2%
3 118076416 62609070 53.0%
4 70474752 52215301 74.1%
5 93927424 68372908 72.8%
В этом прогоне больше половины копий — пары вида {17, 16}, которых никто никогда не
записывал. Копия структуры из двух слов не одно неделимое чтение, и писатель
успевает вклиниться посередине. Модель памяти описывает ровно это:
races on multiword data structures can lead to inconsistent values not
corresponding to a single write.
гонки на многословных структурах данных могут дать несогласованные значения, не соответствующие ни одной записи.
С числами это «всего лишь» неверные данные. Но в Go многословны и значения,
которые сами состоят из указателя и длины или указателя и типа: интерфейс,
срез, строка; у карты такие пары лежат во внутренних структурах.
Разорванный срез — это указатель на один массив и длина от другого; модель
памяти называет последствие для всех четырёх прямо —
arbitrary memory corruption
(произвольная порча памяти). Программа не теряет инкремент, а
читает и пишет туда, куда не должна.
Доля в таблице шумит от круга к кругу и от машины к машине. Устойчиво только одно: она не ноль. Этого достаточно, чтобы ответить на «а если гонка безобидная»: безобидной гонке нечем гарантировать, что значение вообще целое.
Механизм 2: флаг, который может не дойти
Самая «безобидная» гонка — булев флаг остановки:
var done bool
go func() {
for !done {
}
// ...
}()
done = trueМодель памяти разбирает такой же цикл — у неё он стоит в main, а флаг
пишет запущенная горутина, — и отвечает жёстко:
The loop in main is not guaranteed to finish.
Цикл в main не обязан завершиться.
Между записью done = true и чтением в цикле нет ни одного события
синхронизации, и ничто не обязывает читающую горутину когда-либо увидеть
запись. А что показывает прогон?
2) ФЛАГ БЕЗ СИНХРОНИЗАЦИИ: for !done {}
───────────────────────────────────────
на этой версии цикл увидел запись и завершился
Это стоит сказать на собеседовании дословно: на go1.24.7 цикл завершился — и
это наблюдение, а не гарантия. Ровно так гонка и выживает в коде: она
работает, пока не изменится компилятор, процессор или нагрузка. Правильный
флаг — atomic.Bool или закрытие канала; context для этого и сделан.
Механизм 3: что видит -race — порядок, а не результат
-race — флаг сборки: go test -race, go run -race, go build -race.
Программа собирается иначе и во время работы следит за обращениями к памяти,
отыскивая пары обращений к одной ячейке без happens-before между ними. Важное
слово здесь — пары обращений, а не неверные ответы.
Две горутины по тысяче раз делают n++. На этой машине результат получился
правильным:
1) ГОНКА, КОТОРАЯ НЕ ИСПОРТИЛА РЕЗУЛЬТАТ
────────────────────────────────────────
без -race: n = 2000, код выхода 0
с -race, код выхода 66:
==================
WARNING: DATA RACE
Read at 0x… by goroutine 8:
main.main.func1()
racy/counter/main.go:18
Previous write at 0x… by goroutine 7:
main.main.func1()
racy/counter/main.go:18
Детектор всё равно нашёл гонку — потому что ищет не ошибку в ответе, а запись и чтение одной ячейки без порядка. Отчёт читается сверху вниз:
Read at … by goroutine 8— где было второе обращение;Previous write at … by goroutine 7— с каким обращением оно не упорядочено;Goroutine … created at(ниже в полном выводе) — откуда взялись обе горутины.
И код выхода 66 — не случайность: так детектор по умолчанию помечает
процесс, в котором нашёл гонку. Это настройка, а не гарантия: переменная
окружения GORACE её меняет (прогон bench/gorace/options.go):
1) КОД ВЫХОДА: ПО УМОЛЧАНИЮ И ЧЕРЕЗ GORACE
──────────────────────────────────────────
без GORACE: отчётов 1, код выхода 66
GORACE=exitcode=13: отчётов 1, код выхода 13
В go test -race то же самое доходит до теста: пакет testing
помечает его упавшим, даже если все проверки прошли.
4) go test -race НА ТЕСТЕ, ВСЕ ПРОВЕРКИ КОТОРОГО ПРОШЛИ
───────────────────────────────────────────────────────
--- FAIL: TestCounter
testing.go:1490: race detected during execution of test
FAIL deepengineering/bench/gorace/racy/testpkg
код выхода go test: 1
Поэтому CI с go test -race краснеет на гонке, а не только на неверном ответе.
exitcode (default 66): The exit status to use when exiting after a detected race
exitcode (по умолчанию 66): код выхода, используемый при завершении после обнаруженной гонки.
И одновременность для отчёта не нужна. Горутина пишет x, main читает
его через 100 мс — обращения разнесены во времени, но порядка между ними нет:
1) ГОНКЕ НЕ НУЖНА ОДНОВРЕМЕННОСТЬ
─────────────────────────────────
racy/seq: горутина пишет x, main через 100 мс читает
GOMAXPROCS=2: отчёт в 5 запусках из 5, коды выхода [66 66 66 66 66]
GOMAXPROCS=1: отчёт в 5 запусках из 5, коды выхода [66 66 66 66 66]
Отчёт есть и на одном потоке. Детектору не нужно, чтобы обращения совпали по времени, — ему нужно, чтобы между ними не было happens-before.
Механизм 4: чего -race не видит
Три границы, и все важнее, чем то, что детектор находит.
Первая: только то, что произошло. Другая программа — racy/branch: гонка в
ней только на редком пути, который включается аргументом:
2) ГОНКА НА ПУТИ, КОТОРЫЙ НЕ ВЫПОЛНИЛСЯ
───────────────────────────────────────
с -race, обычный путь: код выхода 0, отчётов 0
с -race, редкий путь: код выхода 66, отчётов 1
в отчёте: racy/branch/main.go:24
Один и тот же бинарник, собранный с -race. На обычном пути — чисто, на
редком — отчёт. Документация говорит об этом прямо:
The race detector only finds races that happen at runtime, so it can't find
races in code paths that are not executed.
Детектор гонок находит только те гонки, которые случились во время выполнения, поэтому он не может найти гонки в путях кода, которые не выполнялись.
Вторая: только то исполнение, которое случилось. Запись и чтение x — обе
мимо замка, но горутина после записи берёт и отпускает mu, а main берёт
его перед чтением:
2) ГОНОЧНАЯ ПРОГРАММА БЕЗ ГОНКИ В ЭТОМ ИСПОЛНЕНИИ
─────────────────────────────────────────────────
racy/missed: запись и чтение x мимо замка; замок взят
после записи в горутине и перед чтением в main
отчётов за 5 запусков: 0, коды выхода [0 0 0 0 0]
Путь выполнился — отчётов ноль. Важно назвать это точно: в этих запусках
Unlock в горутине случился раньше Lock в main, обращения к x оказались
упорядочены через замок, и в этом исполнении гонки данных не было. Гоночна
сама программа: она ничем не гарантирует такой порядок, и при другом
чередовании гонка была бы. Детектор проверяет исполнение, а не все исполнения,
которые программа допускает.
Отсюда практическое: зелёный go test -race не доказывает отсутствия гонок.
На невыполненных путях он их не видит вовсе, на выполненных — только те, что
проявились в этом исполнении. Поэтому полезны тесты, в которых несколько
горутин работают с общим состоянием, и прогоны с -count больше единицы.
Третья: состояние гонки без гонки данных. Счёт, на котором каждое обращение к балансу под мьютексом:
func (a *Account) Balance() int { a.mu.Lock(); defer a.mu.Unlock(); return a.bal }
func (a *Account) Withdraw(n int) { a.mu.Lock(); defer a.mu.Unlock(); a.bal -= n }
// две горутины одновременно:
if acc.Balance() >= 100 { // проверка — один захват
acc.Withdraw(100) // действие — другой захват
}3) СОСТЯЗАНИЕ БЕЗ ГОНКИ ДАННЫХ
──────────────────────────────
с -race: ушли в минус: 936 из 1000; код выхода 0, отчётов 0
Гонки данных здесь нет: ни одного обращения без синхронизации. Ошибка — выше уровнем: обе горутины проверили баланс, обе увидели 100, обе списали. Это и есть состояние гонки (race condition): результат зависит от того, как чередовались горутины, хотя каждое отдельное обращение корректно. Детектору сообщить нечего.
Лечится это не флагом, а тем, что проверка и действие становятся одним критическим участком:
func (a *Account) TryWithdraw(n int) {
a.mu.Lock()
defer a.mu.Unlock()
if a.bal >= n {
a.bal -= n
}
}С таким методом в прогоне практической задачи ушли в минус 0 счетов из 1000.
Одна оговорка к числу 936. Между проверкой и списанием в программе стоит
runtime.Gosched() — уступка процессора, чтобы окно было видно в каждом
прогоне. Без неё случаев меньше, но не ноль:
3) ОКНО БЕЗ runtime.Gosched()
─────────────────────────────
с Gosched (как в уроке): ушли в минус: 943 из 1000
без Gosched, запуск 1: ушли в минус: 44 из 1000; отчётов 0, код выхода 0
без Gosched, запуск 2: ушли в минус: 20 из 1000; отчётов 0, код выхода 0
без Gosched, запуск 3: ушли в минус: 2 из 1000; отчётов 0, код выхода 0
Окно между двумя захватами существует, и в нём кто-то оказывается — реже, но это та же ошибка. А 943 вместо 936 в отдельном прогоне — обычный разброс.
Глубже: сколько стоит -race и где его включают
Та же нагрузка — четыре горутины и общая карта под мьютексом, гонок нет —
собрана с -race и без:
4) ЦЕНА: ТА ЖЕ НАГРУЗКА С -race И БЕЗ
─────────────────────────────────────
серия время с -race кратн. память с -race кратн.
1 177 мс 1889 мс 10.7x 6.6 МиБ 32.0 МиБ 4.8x
2 159 мс 1905 мс 12.0x 6.6 МиБ 32.1 МиБ 4.8x
3 242 мс 1846 мс 7.6x 6.6 МиБ 33.0 МиБ 5.0x
Три серии подряд на одной машине: время — от ×7,6 до ×12,0, память — ×4,8–5,0. Переносить эти числа на свой сервис нельзя: нагрузка одна, и у другой программы кратность будет другой. Устойчиво здесь направление: режим дорог и по памяти, и по времени. И главное — это цена режима, а не отчёта: гонок в нагрузке нет, а платят всё равно.
Отсюда практика, которую стоит назвать. Это рекомендации, а не правила языка: частоту и охват выбирают по цене прогона и по тому, сколько в коде конкурентности.
go test -race— регулярно в CI. Для конкурентного кода — на каждое изменение, которое его касается; в большом монорепозитории это может быть отдельная или ночная задача. Это основное место детектора.- Тесты, в которых несколько горутин работают с общим состоянием. Детектор найдёт только то, что произошло: тест, где всё делает одна горутина, гонку не породит.
-raceна стенде под нагрузкой — дополнительно, если тесты не воспроизводят реальный параллелизм.- В продакшене — не на всём парке. Держать все экземпляры под
-raceпостоянно обычно слишком дорого. Но ограниченный диагностический запуск — канареечный экземпляр или стенд с нагрузкой, похожей на боевую, — может найти то, до чего тесты не дошли, если цена и требования платформы допустимы.
Требования платформы. -race работает не везде: в документации команды
go перечислены linux/amd64, freebsd/amd64, darwin/amd64, darwin/arm64,
windows/amd64, linux/ppc64le и linux/arm64 (последний — только для 48-битного
VMA). На linux детектор требует cgo, а значит, и C-компилятор на машине сборки:
2) -race БЕЗ cgo
────────────────
CGO_ENABLED=0 go build -race: go: -race requires cgo; enable cgo by setting CGO_ENABLED=1
код выхода go build: 2
Исключение — macOS: по исходнику cmd/go go1.24.7 там -race собирается и
без cgo. Если сервис собирается с CGO_ENABLED=0 — как часто бывает ради
статического бинарника, — для прогона с детектором нужна отдельная сборка.
Гонка на общем массиве двух срезов с отчётом детектора разобрана в статье о срезах: там та же механика на случае, где обе горутины пишут «в свой» срез.
Как отвечать на собеседовании
Короткий ответ: гонка данных — это когда две горутины обращаются к одной
ячейке памяти, хотя бы одна пишет, и между обращениями нет синхронизации.
Лечится мьютексом, каналом или атомарной операцией. Находится флагом
-race: go test -race, и в CI он должен быть.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
На «чем отличается от состояния гонки» — пример со счётом. «Гонка данных —
про отдельные обращения без порядка. Состояние гонки — про логику: проверка и
действие под разными захватами мьютекса. Гонки данных там нет, -race молчит,
а баланс в прогоне ушёл в минус в 936 случаях из 1000. Лечится одним
критическим участком».
На «найдёт ли -race все гонки» — три границы. «Только те, что случились в
этом запуске: гонка на невыполненном пути даёт ноль отчётов. Только в том
исполнении, которое случилось: если замок случайно упорядочил обращения, в
этом исполнении гонки нет — хотя программа гоночная. И только гонки данных —
состояние гонки он не видит».
На «гонка безобидная, результат не важен» — разорванные значения. «Безобидных нет. Структура из двух слов без синхронизации даёт пары, которых никто не писал, — больше половины копий в прогоне. Для срезов, строк и интерфейсов модель памяти допускает порчу памяти».
На флаг остановки — цитата из модели памяти. «Цикл for !done {} не обязан
завершиться. То, что он завершился на моей версии Go, — наблюдение, а не
гарантия. Правильно — atomic.Bool или закрытый канал».
На «включить в продакшене» — цена и граница. «На моей нагрузке без единой
гонки — память ×4,8–5,0, время ×7,6–12. Поэтому весь парк под -race не
держат; основное место — CI и стенды, а в продакшене — ограниченный
диагностический запуск на нагрузке, которую тесты не воспроизводят».
Дальше спросят
А одновременная запись в карту — это тоже гонка данных?
Да, и у карты есть своя защита: рантайм в обычной сборке, без -race, может
поймать одновременную запись и упасть с fatal error: concurrent map writes.
Но это проверка по возможности, а не гарантия: две горутины могут разминуться,
и тогда таблица просто испортится. Подробно — в уроке о картах.
Отсюда правило: падение concurrent map writes — признак гонки, но его
отсутствие ничего не доказывает. Надёжнее ловит -race — и только если путь,
где карта пишется из нескольких горутин, выполнился.
Почему атомарные операции — не гонка, если обращения одновременные?
Потому что так устроено определение: оно исключает обращения, которые все атомарные. Для ответа уровня Junior достаточно сказать: атомарная операция — неделимое действие.
Ответ уровня Senior добавляет, что неделимость — не главное. Атомарные операции синхронизируют:
If the effect of an atomic operation A is observed by atomic operation B, then
A is synchronized before B.
Если эффект атомарной операции A наблюдает атомарная операция B, то A синхронизирована до B.
То есть атомарная запись флага, которую увидело атомарное чтение, упорядочивает
и обычные записи, сделанные до неё: на этом стоит флаг готовности
atomic.Bool. А все атомарные операции программы ведут себя так, будто
выполнены в одном последовательно согласованном порядке.
Но атомарность защищает одну ячейку. Две атомарные операции подряд — «прочитать баланс, потом списать» — дают то же состояние гонки, что и два захвата мьютекса: каждое обращение корректно, логика — нет.
Как правильно сделать флаг остановки?
Три идиомы, от простой к самой богатой:
atomic.Bool— если нужен именно флаг:Store(true)в одной горутине,Load()в цикле другой. Порядок задаёт атомарная операция.- Закрытие канала — если остановить надо многих:
close(stop)видят все, кто ждёт<-stop, и закрытие синхронизировано до приёма, который вернулся из-за закрытия. context.Context— если остановка должна идти по цепочке вызовов, с дедлайном и причиной. Под капотом там тот же закрытый каналDone().
Голый bool — нет, ни при каких условиях: модель памяти не обещает, что цикл
когда-нибудь его увидит.
Если тесты с -race зелёные, гонок нет?
Нет. Зелёный прогон значит одно: гонок данных, которые проявились в этих запусках, нет. Детектор не найдёт гонку в ветке, которую тест не выполнил; не увидит гонки там, где в этом исполнении обращения случайно упорядочил замок; и не найдёт состояния гонки, где каждое обращение под замком.
Поэтому полезные тесты для -race специально запускают несколько горутин на
общем состоянии, а гоняют их не один раз. Тест, который всё делает в одной
горутине, под -race проходит так же, как без него, и ничего не доказывает.
Частые заблуждения
Гонка данных — это когда результат получается неверным
Гонка — это обращения без порядка, а неверный результат — лишь один из симптомов. В прогоне программа с гонкой напечатала правильные n = 2000, а -race всё равно нашёл гонку и завершил процесс с кодом 66.
Если результат не важен до единицы, гонка безобидна
Гонка ломает не только счёт. Структура из двух слов без синхронизации отдавала пары, которых никто не записывал, — в 53–75 % копий. Для срезов, строк, интерфейсов и карт модель памяти допускает «произвольную порчу памяти».
Булев флаг остановки можно не защищать — запись одного bool атомарна
Дело не в атомарности записи, а в порядке. Модель памяти про цикл for !done говорит, что он «не обязан завершиться». То, что на go1.24.7 он завершился, — наблюдение, а не гарантия.
-race находит все гонки в программе
Только те, что случились в этом запуске. Один и тот же бинарник: на обычном пути — ноль отчётов, на редком — отчёт с указанием строки. Документация говорит прямо: в невыполненных путях он гонок не найдёт. И даже на выполненном пути гоночная программа дала ноль отчётов в 5 запусках из 5: в этих исполнениях обращения случайно упорядочил замок.
Если всё под мьютексом, гонок нет
Гонок данных нет. Но проверка и списание под разными захватами — состояние гонки: в прогоне баланс ушёл в минус в 936 случаях из 1000, а -race не выдал ни одного отчёта. Лечится одним критическим участком на проверку и действие.
-race можно держать включённым в продакшене, он почти бесплатный
На замеренной нагрузке без единой гонки: память ×4,8–5,0, время ×7,6–12 в разных сериях. Платят за режим, а не за отчёт. Основное место -race — тесты, CI и стенды; в продакшене — ограниченный диагностический запуск, а не весь парк.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
func (a *Account) TryWithdraw(n int) {
a.mu.Lock()
defer a.mu.Unlock()
if a.bal >= n {
a.bal -= n
}
}
fmt.Println(sawTorn())
fmt.Println(overdrafts(false) > 0)
fmt.Println(overdrafts(true))Практика · оцените
Проверка знаний
Две горутины по тысяче раз делают n++. Программа напечатала n = 2000. Найдёт ли go run -race гонку?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Гонка данных — это запись в ячейку памяти одновременно с другим обращением к той же ячейке, без синхронизации между ними. Так её определяет модель памяти Go. Определение не говорит ни слова о неверном результате: гонка — это отсутствие порядка между обращениями, а не потерянный инкремент.
- Отсюда главное следствие: безобидных гонок не бывает. Структура из двух слов, в которую писатель всегда кладёт одинаковые числа, у читателя без синхронизации оказывалась разорванной в 53–75 % копий: пары, которых никто никогда не записывал. Для интерфейсов, срезов, строк и карт модель памяти допускает «произвольную порчу памяти». А цикл
for !done {}по флагу без синхронизации, по той же модели, не обязан завершиться вообще. - Дальше — то, что отличает понимание от пересказа. Детектор
-raceищет не неверный ответ, а доступы без порядка: гонку, после которой программа напечатала правильныеn = 2000, он нашёл и завершил процесс с кодом 66 — это значение по умолчанию. Но он видит только то, что произошло в этом запуске: гонка на невыполненном пути — ноль отчётов; гоночная программа, чьи обращения в этом запуске случайно упорядочил замок, — тоже ноль. И не видит состояния гонки без гонки данных: проверка и списание под разными захватами одного мьютекса увели баланс в минус в 936 случаях из 1000, отчётов — ноль. Цена режима на замеренной нагрузке: память ×4,8–5,0, время ×7,6–12. Поэтому его место — регулярно в CI и на стендах, а в продакшене — разве что ограниченным диагностическим запуском, а не на всём парке.
На самом деле
- Гонка — это обращения без порядка, а неверный результат — лишь один из симптомов. В прогоне программа с гонкой напечатала правильные
n = 2000, а-raceвсё равно нашёл гонку и завершил процесс с кодом 66. - Гонка ломает не только счёт. Структура из двух слов без синхронизации отдавала пары, которых никто не записывал, — в 53–75 % копий. Для срезов, строк, интерфейсов и карт модель памяти допускает «произвольную порчу памяти».
- Дело не в атомарности записи, а в порядке. Модель памяти про цикл
for !done {}говорит, что он «не обязан завершиться». То, что на go1.24.7 он завершился, — наблюдение, а не гарантия. - Только те, что случились в этом запуске. Один и тот же бинарник: на обычном пути — ноль отчётов, на редком — отчёт с указанием строки. Документация говорит прямо: в невыполненных путях он гонок не найдёт. И даже на выполненном пути гоночная программа дала ноль отчётов в 5 запусках из 5: в этих исполнениях обращения случайно упорядочил замок.
- Гонок данных нет. Но проверка и списание под разными захватами — состояние гонки: в прогоне баланс ушёл в минус в 936 случаях из 1000, а
-raceне выдал ни одного отчёта. Лечится одним критическим участком на проверку и действие. - На замеренной нагрузке без единой гонки: память ×4,8–5,0, время ×7,6–12 в разных сериях. Платят за режим, а не за отчёт. Основное место
-race— тесты, CI и стенды; в продакшене — ограниченный диагностический запуск, а не весь парк.
Что разобрано
- Что здесь на самом деле спрашивают
- База: определение, в котором нет слова «результат»
- Механизм 1: значение, которого никто не записывал
- Механизм 2: флаг, который может не дойти
- Механизм 3: что видит `-race` — порядок, а не результат
- Механизм 4: чего `-race` не видит
- Глубже: сколько стоит `-race` и где его включают
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
4 ИСТОЧНИКА
- The Go Memory ModelОфициальная документация. Определение, с которого начинается тема: «A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package» (Гонка данных — это запись в ячейку памяти, происходящая одновременно с другим чтением или записью той же ячейки, если только все участвующие обращения не атомарные, как в пакете sync/atomic). Что ломается: «races on multiword data structures can lead to inconsistent values not corresponding to a single write» (гонки на многословных структурах данных могут дать несогласованные значения, не соответствующие ни одной записи) — вплоть до «arbitrary memory corruption» (произвольной порчи памяти) для интерфейсов, карт, срезов и строк. И про цикл по флагу: «The loop in main is not guaranteed to finish» (Цикл в main не обязан завершиться).https://go.dev/ref/mem
- Data Race DetectorОфициальная документация. Откуда код выхода 66: «
exitcode(default66): The exit status to use when exiting after a detected race» (exitcode (по умолчанию 66): код выхода, используемый при завершении после обнаруженной гонки). И граница, за которую зелёный прогон не переносится: «The race detector only finds races that happen at runtime, so it can't find races in code paths that are not executed» (Детектор гонок находит только те гонки, которые случились во время выполнения, поэтому он не может найти гонки в путях кода, которые не выполнялись).https://go.dev/doc/articles/race_detector - Команда go — флаг -raceОфициальная документация. Флаг сборки, а не отдельный инструмент: «enable data race detection» (включить обнаружение гонок данных). Там же перечислены платформы: «Supported only on linux/amd64, freebsd/amd64, darwin/amd64, darwin/arm64, windows/amd64, linux/ppc64le and linux/arm64 (only for 48-bit VMA)» (Поддерживается только на linux/amd64, freebsd/amd64, darwin/amd64, darwin/arm64, windows/amd64, linux/ppc64le и linux/arm64 (только для 48-битного VMA)); прогоны урока сняты на linux/amd64.https://pkg.go.dev/cmd/go#hdr-Compile_packages_and_dependencies
- The Go Memory Model — Atomic ValuesОфициальная документация. Что атомарные операции дают сверх неделимости: «If the effect of an atomic operation A is observed by atomic operation B, then A is synchronized before B» (Если эффект атомарной операции A наблюдает атомарная операция B, то A синхронизирована до B). И порядок для всех сразу: «All the atomic operations executed in a program behave as though executed in some sequentially consistent order» (Все атомарные операции программы ведут себя так, будто выполнены в некотором последовательно согласованном порядке).https://go.dev/ref/mem#atomic