Отладка конкурентности в Go: race detector, утечки горутин, дедлоки

Как ловить конкурентные баги в Go: race detector по-настоящему (как читать отчёт, оверхед, место в CI), поиск утечек горутин через goleak и pprof, дедлоки и встроенный детектор, профили блокировок и мьютексов

Конкурентные баги коварны тем, что не воспроизводятся по требованию: тест проходит сто раз, а на сто первый — падает, и то на CI. К счастью, в Go есть инструменты, которые превращают «иногда ломается» в конкретный отчёт: детектор гонок находит data race, goleak и pprof показывают утёкшие горутины, а профили блокировок — где программа стоит на мьютексе. Эта статья — про то, как ими пользоваться системно, а не по наитию.

Это пятая, финальная статья серии «Конкурентность в Go». Она про поиск того, что закладывалось в статьях 1–4: гонок, утечек и дедлоков. Опирается на горутины и каналы, примитивы sync, модель памяти и паттерны из предыдущих статей.

Детектив-gopher с лупой ThreadSanitizer над DATA RACE (WRITE горутина 17 / READ горутина 42 к одной ячейке sharedVar); рядом застывшие навсегда горутины в паутине (goroutine leak) и два gopher с замками A/B, взятыми в обратном порядке (deadlock); дашборд health-check

В статье

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, а Bmu2, каждая ждёт мьютекс, удерживаемый другой: взаимная блокировка. Лекарство — единый глобальный порядок захвата: если любой код, которому нужны оба мьютекса, всегда берёт их в одном и том же порядке (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-counterWARNING: 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…) условны и не являются данными измеренного прогона — при запуске стенда они будут другими.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии