Конкурентные баги коварны тем, что не воспроизводятся по требованию: тест проходит сто раз, а на сто первый — падает, и то на CI. К счастью, в Go есть инструменты, которые превращают «иногда ломается» в конкретный отчёт: детектор гонок находит data race, goleak и pprof показывают утёкшие горутины, а профили блокировок — где программа стоит на мьютексе. Эта статья — про то, как ими пользоваться системно, а не по наитию.
Это пятая, финальная статья серии «Конкурентность в Go». Она про поиск того, что закладывалось в статьях 1–4: гонок, утечек и дедлоков. Опирается на горутины и каналы, примитивы sync, модель памяти и паттерны из предыдущих статей.
В статье
- Race detector: как он работает и что гарантирует
- Как читать отчёт детектора
- Утечки горутин
- Дедлоки и встроенный детектор
- Профили блокировок и мьютексов
- Тестирование конкурентного кода
- Типовые баги
- Checklist
- Демо и версии
- Документация и первоисточники
Race detector: как он работает и что гарантирует
Детектор гонок — не статический анализатор и не «умный линтер». Это динамический инструмент: он находит гонки, наблюдая за реально выполнившимися обращениями к памяти во время прогона. Отсюда все его свойства — и сила, и главное ограничение.
Включается флагом -race на любой из команд:
go test -race ./... # тесты под детектором — основной режим
go run -race ./cmd/app # разовый прогон бинарника
go build -race -o app . # собрать инструментированный бинарникФлаг -race заставляет компилятор инструментировать каждое обращение к памяти: вокруг чтений и записей вставляются вызовы в рантайм детектора. Технически это порт ThreadSanitizer (TSan) из LLVM, встроенный в тулчейн Go. Работает он так:
- на каждый доступ к памяти рантайм записывает в теневую память (shadow memory), какая горутина и когда трогала эту ячейку — на чтение или на запись;
- параллельно детектор отслеживает отношение happens-before между горутинами: каждая операция синхронизации (отправка/приём по каналу,
Mutex.Lock/Unlock, атомик, старт горутины,WaitGroup) устанавливает порядок «до»/«после» между событиями в разных горутинах; - если два доступа к одной ячейке идут из разных горутин, хотя бы один из них — запись, и между ними нет отношения happens-before — это data race, и детектор его печатает.
Именно поэтому детектор ловит ровно те гонки, что описаны в модели памяти Go: гонка данных — это конкурентный доступ без happens-before-упорядочивания. Инструмент проверяет буквально определение из спецификации.
Что детектор гарантирует, а что нет
Это критично понимать, чтобы не строить ложное чувство безопасности.
- Гарантия «нет ложных срабатываний». Если детектор сообщил о гонке — это точно гонка данных. Он не выдумывает: отчёт означает, что в этом прогоне реально было два несинхронизированных конкурентных доступа. Найденную гонку можно чинить не сомневаясь.
- Нет гарантии полноты. Детектор находит только те гонки, которые проявились в конкретном прогоне — то есть когда «плохие» обращения действительно выполнились в конкурирующих горутинах на этих данных и этом расписании. Если при данном прогоне спорный код не выполнился, или горутины случайно не пересеклись, или ветка не отработала — гонка останется незамеченной. Детектор не доказывает отсутствие гонок и в принципе не может найти все возможные гонки статически.
Практический вывод из второго пункта: детектор надо нагружать. Гоняйте под -race реальные сценарии, а не только happy-path; повторяйте прогоны (-count), варьируйте число процессоров (-cpu) — про это в разделе про тестирование. Чем разнообразнее расписания горутин увидит детектор, тем больше гонок всплывёт.
Цена инструментирования
-race не бесплатен, и это осознанный компромисс. По официальной документации инструментированный код потребляет примерно в 5–10 раз больше памяти и исполняется в 2–20 раз медленнее обычного. Разброс зависит от того, насколько код интенсивно работает с памятью.
Отсюда — где -race уместен, а где нет:
- В CI — обязателен. Прогон тестов под
-raceдолжен быть частью пайплайна: это дешёвая страховка, ловящая гонки до прода. Замедление тестов вторично по сравнению с ценой гонки в проде. - Локально при разработке конкурентного кода — да, как рабочий режим тестов.
- В проде по умолчанию — нет. Двадцатикратное замедление и кратный рост памяти неприемлемы для боевого трафика. Инструментированный бинарник в прод катят только точечно и осознанно — например, чтобы поймать гонку, которая воспроизводится лишь под реальной нагрузкой, и то на канареечном инстансе с запасом ресурсов.
Как читать отчёт детектора
Когда детектор ловит гонку, он печатает WARNING: DATA RACE и падает с ненулевым кодом возврата (в тестах это делает тест «красным»). Отчёт устроен одинаково: два (или больше) стека — конфликтующие доступы — плюс информация о вовлечённых горутинах. Вывод примерно такой (формат стандартный, адреса и номера строк здесь условные, а не из конкретного измеренного прогона):
==================
WARNING: DATA RACE
Write at 0x00c000123456 by goroutine 8:
main.(*Counter).Inc()
/app/counter.go:14 +0x44
main.worker()
/app/main.go:23 +0x71
Previous read at 0x00c000123456 by goroutine 7:
main.(*Counter).Value()
/app/counter.go:19 +0x38
main.main()
/app/main.go:31 +0xa5
Goroutine 8 (running) created at:
main.main()
/app/main.go:22 +0x88
Goroutine 7 (running) created at:
main.main()
/app/main.go:30 +0x64
==================Как это читать по частям:
Write at 0x… by goroutine 8— адрес спорной ячейки и стек той горутины, где произошла запись. Верхняя строка стека (counter.go:14) — точное место доступа.Previous read at 0x… by goroutine 7— второй, конфликтующий доступ к той же ячейке из другой горутины. Здесь это чтение; один из двух доступов всегда хотя бы запись (иначе гонки нет — два чтения безопасны).Goroutine 8 … created atиGoroutine 7 … created at— где именно каждая из горутин была запущена (go ...). Это помогает понять, кто эти конкурирующие исполнители и почему между ними нет синхронизации.
Алгоритм починки почти всегда один: найти общее состояние, к которому оба стека обращаются (здесь — поле Counter), и защитить его — мьютексом, атомиком или переносом коммуникации в канал (что именно выбрать — тема статьи про примитивы). Гонка на счётчике чинится atomic.Int64; гонка на map — мьютексом или переходом на канал-владелец.
Одна тонкость: адрес ячейки (0x00c000123456) сам по себе мало что говорит и меняется от прогона к прогону. Ориентируйтесь на стеки и имена символов, а не на адрес.
Утечки горутин
Утечка горутины — это горутина, которая никогда не завершится, потому что заблокирована навсегда. В отличие от утечки памяти, компилятор и рантайм тут молчат: заблокированная горутина ничем не отличается от честно ждущей. Со временем такие горутины копятся, удерживают память (стек + захваченные переменные) и другие ресурсы, и сервис деградирует незаметно.
Классические причины блокировки навсегда:
- Отправка в канал, который никто не читает (
ch <- v, а приёмник ушёл или его не было). - Приём из канала, в который никто не отправит и не закроет (
<-chв ожидании данных, которые уже не придут). - Горутина без выхода по отмене контекста: цикл
for { select { case <-work: ... } }, в котором нет веткиcase <-ctx.Done(), — при остановке потребителя такая горутина повиснет. - Ожидание на
sync.WaitGroup/Cond, которое никогда не разблокируется.
Пример утёкшей горутины — типичная ошибка «отправитель не учёл, что читателя может не быть»:
// leak: если получатель прочитает только первое значение и уйдёт,
// вторая отправка заблокируется навсегда — горутина утечёт.
func fanOut(ctx context.Context) <-chan int {
out := make(chan int) // небуферизованный
go func() {
for i := 0; ; i++ {
out <- i // повиснет, как только никто не читает
}
}()
return out
}Правильно — уважать отмену и/или закрывать канал; горутина обязана иметь путь выхода:
func fanOut(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; ; i++ {
select {
case out <- i:
case <-ctx.Done(): // путь выхода — без него была бы утечка
return
}
}
}()
return out
}Как ловить утечки
runtime.NumGoroutine() — грубый симптом. Функция возвращает текущее число горутин. Если под стабильной нагрузкой это число монотонно растёт и не возвращается к базовой линии между запросами — почти наверняка что-то течёт. Полезно вывести его в метрику (в терминах проекта — рядом с бизнес-метриками OTEL) и следить за трендом; сам по себе абсолютный счётчик мало значит, значим именно рост без возврата.
pprof goroutine-профиль — снимок всех горутин. Это главный инструмент диагностики на живом сервисе. Подключается стандартным пакетом net/http/pprof, который регистрирует хендлеры на DefaultServeMux:
import (
"net/http"
_ "net/http/pprof" // регистрирует /debug/pprof/* на DefaultServeMux
)
func main() {
go func() {
// отдельный порт для отладки, не смешивать с прод-трафиком
_ = http.ListenAndServe("localhost:6060", nil)
}()
// ... основная логика
}Дальше снимаем полный дамп всех горутин со стеками:
# текстовый дамп: сгруппированные стеки всех горутин (debug=2 — развёрнуто)
curl "http://localhost:6060/debug/pprof/goroutine?debug=2"
# или в интерактивный pprof для агрегации по местам блокировки
go tool pprof "http://localhost:6060/debug/pprof/goroutine"В дампе каждая горутина показана со своим стеком и состоянием. Утёкшие видно по характерным строкам вида chan send, chan receive, select или semacquire, застрявшим на одном и том же месте кода, — и по тому, что таких горутин много и число растёт. Если в дампе тысячи горутин, висящих на одной и той же строке ch <- ..., — вот она, утечка. go tool pprof удобно агрегирует: команда top покажет, в каких функциях скопилось больше всего горутин.
Профиль goroutine — часть общей темы pprof; про CPU/heap/trace-профили и чтение флейм-графов подробно в статье про профилирование и бенчмарки. Здесь важен именно goroutine-снимок как детектор зависших исполнителей.
go.uber.org/goleak — детект утечек в тестах. Это библиотека, которая в конце теста делает снимок горутин и падает, если остались «лишние» — те, что не завершились к моменту окончания. Идеальна как автоматический страж: любой тест, оставивший после себя висящую горутину, станет красным.
Самый удобный способ — включить проверку на весь пакет одной строкой в TestMain:
package worker
import (
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
// после всех тестов пакета проверить, что лишних горутин не осталось
goleak.VerifyTestMain(m)
}Либо точечно в конкретном тесте:
func TestServer(t *testing.T) {
defer goleak.VerifyNone(t) // в конце теста: не осталось ли утёкших горутин
srv := NewServer()
defer srv.Shutdown() // если Shutdown забыть — goleak это поймает
// ... тело теста
}goleak игнорирует служебные горутины рантайма и умеет через опции игнорировать заведомо-долгоживущие (например, фоновые горутины стороннего пула). Но по умолчанию любая ваша горутина, не завершившаяся к концу теста, будет отловлена — что и делает библиотеку хорошим регрессионным тестом на утечки.
Дедлоки и встроенный детектор
В Go есть встроенный детектор дедлоков, но он ловит ровно один случай — полный, глобальный тупик, когда исполняться больше некому. Рантайм отслеживает число активных горутин, и если оказывается, что все горутины программы заблокированы (спят на каналах, мьютексах, WaitGroup), а значит, разбудить их некому, — рантайм аварийно завершает процесс с точной формулировкой:
fatal error: all goroutines are asleep - deadlock!Минимальный пример, который её вызывает, — приём из канала, в который никто и никогда не отправит:
func main() {
ch := make(chan int)
<-ch // единственная горутина блокируется навсегда → all goroutines asleep
}Ключевое слово в сообщении — all. Детектор основан на простом инварианте рантайма: если ни одна горутина не может продвинуться, программа мертва, и это гарантированно тупик. Поэтому у него нет ложных срабатываний, но и область действия узкая.
Чего детектор НЕ ловит
Частичные дедлоки рантайм не обнаруживает. Если заблокирована часть горутин, а хотя бы одна продолжает работать (пусть даже в бесполезном цикле или обслуживая другой запрос), — с точки зрения рантайма «не все спят», инвариант не нарушен, и deadlock! не сработает. Между тем именно частичные дедлоки — самые частые в реальных сервисах: подвисла обработка одного запроса, зациклились две горутины на встречных мьютексах, а сервер живёт и принимает трафик. Такой тупик выглядит как утечка горутин (см. раздел выше) и ловится тем же goroutine-профилем: в дампе видны застрявшие навсегда стеки.
Классическая причина частичного дедлока — нарушение порядка захвата мьютексов. Две горутины берут два мьютекса в противоположном порядке:
// deadlock-prone: A и B берут mu1/mu2 в разном порядке.
func A() { mu1.Lock(); mu2.Lock(); /* ... */ mu2.Unlock(); mu1.Unlock() }
func B() { mu2.Lock(); mu1.Lock(); /* ... */ mu1.Unlock(); mu2.Unlock() }Если A успела взять mu1, а B — mu2, каждая ждёт мьютекс, удерживаемый другой: взаимная блокировка. Лекарство — единый глобальный порядок захвата: если любой код, которому нужны оба мьютекса, всегда берёт их в одном и том же порядке (mu1, затем mu2), цикла ожидания не возникнет. Это дисциплина, а не инструмент: детектор гонок такое не ловит (тут нет data race — доступ синхронизирован), встроенный детектор дедлоков тоже (тупик частичный). Ловят — обзором кода, дисциплиной порядка и goroutine-дампом при зависании.
Профили блокировок и мьютексов
Между «всё повисло» (дедлок) и «всё быстро» есть серая зона: программа работает, но горутины подолгу ждут — на каналах или на контеншене за мьютекс. Это не баг корректности, а проблема производительности, и для неё есть два профиля.
Block profile показывает, где горутины блокируются и как долго ждут: приёмы/отправки по каналам, Mutex.Lock, WaitGroup.Wait, Cond.Wait. Он не включён по умолчанию — сэмплирование задают через рантайм:
// сэмплировать события блокировки; аргумент — «одно событие на N наносекунд»
// суммарного времени блокировки. 0 — выключено (значение по умолчанию).
runtime.SetBlockProfileRate(1_000_000) // ~1 событие на 1 мс ожиданияMutex profile прицельно про контеншен за мьютексы — где горутины конкурируют за захват и сколько времени на этом теряется:
// профилировать 1/N событий контеншена за мьютекс. 0 — выключено.
runtime.SetMutexProfileFraction(100)Оба профиля отдаются через те же pprof-эндпоинты и читаются в go tool pprof:
go tool pprof "http://localhost:6060/debug/pprof/block"
go tool pprof "http://localhost:6060/debug/pprof/mutex"Дальше — обычная работа с pprof: top покажет самые «ждущие» места, list — по строкам. Практический сигнал: если в mutex-профиле доминирует один мьютекс, это точка контеншена — кандидат на дробление блокировки, переход на RWMutex, шардирование или атомик. Про то, почему RWMutex не всегда выигрывает и что такое false sharing, — в статье про примитивы; про механику чтения профилей — в статье про профилирование.
Важная оговорка: block/mutex-профили сэмплированы и сами добавляют накладные расходы, поэтому их обычно держат выключенными и включают точечно при расследовании, с умеренной частотой сэмплирования.
Тестирование конкурентного кода
Конкурентный код нельзя тестировать как последовательный: один зелёный прогон ничего не доказывает, потому что расписание горутин недетерминировано. Стратегия — заставить детектор увидеть как можно больше разных расписаний и автоматизировать проверку на утечки.
-race в CI — база. Это не опция, а требование к пайплайну конкурентного кода:
go test -race ./...Стресс повторами — -count. Гонка проявляется вероятностно; один прогон под -race мог просто не свести конфликтующие доступы. Повтор десятки-сотни раз повышает шанс, что «неудачное» расписание случится и детектор его поймает:
go test -race -count=100 ./... # сто прогонов подряд под детекторомВарьирование -cpu. Флаг задаёт значения GOMAXPROCS, с которыми гоняются тесты. На разном числе процессоров меняется параллелизм и, значит, расписания — часть гонок всплывает только при GOMAXPROCS>1:
go test -race -cpu=1,2,4 ./... # прогнать тесты при 1, 2 и 4 процессорахКомбинация -race -count=N -cpu=... — стандартный способ «потрясти» конкурентный код: много прогонов, разный параллелизм, детектор включён. Это не гарантирует отсутствие гонок (детектор динамический), но резко повышает вероятность поймать имеющиеся.
goleak как страж утечек — см. раздел про утечки: goleak.VerifyTestMain на пакет ловит горутины, которые тест забыл остановить.
testing/synctest для детерминизма (Go 1.25). Тестировать код с таймаутами, тикерами и ожиданием горутин исторически больно: реальные time.Sleep делают тесты медленными и хрупкими. В Go 1.25 стабилизировали пакет testing/synctest: он запускает горутины в изолированном «пузыре» с фейковым временем, которое двигается только когда все горутины в пузыре заблокированы. Это позволяет детерминированно тестировать логику с контекстными таймаутами и тикерами без реальных задержек и без флаки. Инструмент дополняет -race, а не заменяет его: synctest даёт воспроизводимость расписаний, детектор — проверку на data race.
Общий принцип тестирования недетерминированного кода — тот же, что и для распределённых систем: не полагаться на «прошло один раз», а систематически провоцировать неудачные исходы. Про это шире — в статье тестирование распределённых систем; про механику тестов в Go — в go-testing.
Типовые баги
Сводка конкретных ошибок, которые дают гонки, утечки и дедлоки, и чем каждая ловится.
Горутина без выхода по контексту. Долгоживущая горутина в select без ветки case <-ctx.Done() не завершится при отмене — утечка. Правило: у любой фоновой горутины должен быть путь выхода (отмена контекста, закрытие управляющего канала, стоп-сигнал). Ловится goleak и goroutine-профилем.
WaitGroup.Add внутри горутины. Вызов wg.Add(1) должен произойти до старта горутины, за которой ждут, — то есть до соответствующего wg.Wait(). Если Add вызывать уже внутри запущенной горутины, он гонится с Wait: планировщик может дойти до Wait раньше, чем горутина успеет сделать Add, и Wait вернётся слишком рано (или, наоборот, поймает panic: sync: WaitGroup is reused before previous Wait has returned). Правильно — Add в вызывающей горутине перед go:
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1) // ДО go, в вызывающей горутине
go func(j Job) {
defer wg.Done()
process(j)
}(job)
}
wg.Wait()В Go 1.25 появился метод WaitGroup.Go, который делает Add(1)+go+Done() атомарно и убирает целый класс таких ошибок:
var wg sync.WaitGroup
for _, job := range jobs {
wg.Go(func() { process(job) }) // Add/Done под капотом, ничего не забыть
}
wg.Wait()Забытый close канала. Потребитель в for v := range ch ждёт закрытия канала, чтобы выйти из цикла. Если отправитель никогда не закроет ch, потребитель повиснет навсегда — утечка. Правило: закрывает канал отправитель (и только один), обычно через defer close(ch). При нескольких отправителях закрытие координируют отдельно (например, через sync.WaitGroup над отправителями + закрытие после Wait).
Запись в map без синхронизации. Встроенная map не потокобезопасна: конкурентная запись — это data race, а рантайм при обнаружении конкурентной записи в map может аварийно завершиться с fatal error: concurrent map writes (это отдельная от детектора защита самого рантайма). Лечится мьютексом, sync.Map для подходящих паттернов или каналом-владельцем. Ловится -race.
Отправка в закрытый канал и двойное закрытие. ch <- v в закрытый канал вызывает panic: send on closed channel; повторный close(ch) — panic: close of closed channel. Оба — про нарушенную дисциплину владения каналом. Правило то же: у канала один владелец-отправитель, он и закрывает, ровно один раз.
Захват переменной цикла (историческая ловушка). До Go 1.22 переменная цикла была одна на весь цикл, и замыкание в горутине захватывало её по ссылке — все горутины видели последнее значение:
// ДО Go 1.22 — баг: все горутины печатали одно и то же (последнее) значение
for _, v := range items {
go func() { fmt.Println(v) }() // v — общая на все итерации
}Начиная с Go 1.22 семантика изменилась: переменные цикла имеют область видимости на итерацию, у каждой итерации своя копия, и этот конкретный баг исчез сам собой. Код выше на Go 1.22+ работает корректно. Но помнить о нём стоит: во-первых, много кода писалось до 1.22 (там всё ещё нужна была передача аргументом — go func(v Item){...}(v)); во-вторых, тот же класс ошибки — общий захват изменяемого состояния замыканием — легко воспроизвести и вне цикла. Захват переменной, которую параллельно меняет другая горутина, остаётся гонкой независимо от версии.
Checklist
-
go test -race ./...— обязательная стадия CI для любого пакета с конкурентностью. - Конкурентные тесты гоняются под стрессом:
-race -count=Nи-cpu=1,2,4, а не один happy-path прогон. - Понятно, что зелёный
-raceне доказывает отсутствие гонок: детектор динамический, ловит только проявившееся. - Отчёт детектора чинится через синхронизацию общего состояния (мьютекс/атомик/канал), а не «переставил строки — перестало падать».
-
go.uber.org/goleak(VerifyTestMain/VerifyNone) стоит стражем утечек в тестах пакетов, запускающих горутины. - У каждой фоновой горутины есть явный путь выхода:
case <-ctx.Done(), закрытие управляющего канала или стоп-сигнал. - На проде подключён
net/http/pprofна отдельном (не публичном) порту для снятия goroutine-дампа. -
runtime.NumGoroutine()(или его аналог в метриках) отслеживается на предмет монотонного роста без возврата к базовой линии. -
WaitGroup.Addвызывается доgo/Wait(или используетсяWaitGroup.Goиз Go 1.25); канал закрывает единственный владелец-отправитель ровно один раз. - Для мьютексов, которые берутся парами, зафиксирован единый глобальный порядок захвата (профилактика частичного дедлока).
-
-raceне включён в проде по умолчанию (5–10× память, 2–20× время); инструментированный бинарник катится в прод только точечно и осознанно.
Демо и версии
Живой стенд digital-cookbook/go-concurrency/debugging/ — минимальные программы «один дефект = один инструмент»: гонка на счётчике (go run -race ./debugging/cmd/race-counter → WARNING: DATA RACE); утечка горутины, отлавливаемая goleak и видимая в goroutine-профиле; полный дедлок с fatal error: all goroutines are asleep; частичный дедлок на встречных мьютексах (детектор молчит, помогает goroutine-дамп); block/mutex-профили под контеншеном. Исправленные версии — под проходящими тестами; вывод инструментов в тексте иллюстративен (адреса и номера строк условны).
Целевые версии:
| Компонент | Версия |
|---|---|
| Go | 1.25+ (примеры используют testing/synctest и WaitGroup.Go из Go 1.25) |
go.uber.org/goleak |
v1.3.0 |
| Профили | net/http/pprof, go tool pprof из тулчейна |
Оговорка про точность: примеры вывода race detector и goroutine-профиля в статье приведены в стандартном формате; конкретные адреса памяти, номера горутин и смещения (+0x…) условны и не являются данными измеренного прогона — при запуске стенда они будут другими.
Документация и первоисточники
- Data Race Detector — официальное руководство: флаг
-race, как читать отчёт, оверхед по памяти и времени, ограничения. - Go blog: Introducing the race detector — вводная статья о том, как устроен детектор (ThreadSanitizer, happens-before).
- The Go Memory Model — что такое data race формально и какие операции устанавливают happens-before (основа работы детектора).
- net/http/pprof — эндпоинты
/debug/pprof/goroutine,/block,/mutexи их подключение. - go.uber.org/goleak — детект утёкших горутин в тестах (
VerifyTestMain,VerifyNone, опции игнорирования). - Соседние статьи серии: горутины и каналы (#1), примитивы
syncи атомики (#2), модель памяти и happens-before (#3), паттерны конкурентности (#4). Смежное: профилирование и бенчмарки, тестирование в Go, тестирование распределённых систем.
Комментарии