Компьютеры
Today

Отладка в Go: как понять, что на самом деле происходит внутри программы

Go часто продают как «простой язык»: собрал — запустил — поехали. И в целом это правда. Но ровно до тех пор, пока у тебя не появляется странный баг, который не ловится тестами, не воспроизводится стабильно и внезапно вылезает только под нагрузкой или в проде.

В такие моменты становится понятно: отладка в Go — это не про «поставить breakpoint и посмотреть переменную». Здесь приходится понимать, что делает компилятор, как ведут себя goroutine и почему иногда printf даёт больше пользы, чем debugger.

Давай разберёмся спокойно и по-человечески, как вообще выглядит нормальный процесс отладки в Go — с деталями, которые обычно не пишут в туториалах.


Почему в Go всё ведёт себя «не так»

Если ты привык к Python или JavaScript, там всё довольно прозрачно: код почти напрямую соответствует тому, что исполняется.

В Go — нет.

Ты пишешь код, но запускаешь уже оптимизированный бинарник. И компилятор не стесняется:

  • встраивать функции (inline);
  • выбрасывать временные переменные;
  • переупорядочивать операции;
  • держать значения только в регистрах CPU;
  • переносить данные между стеком и кучей (escape analysis);

В итоге debugger иногда показывает странные вещи:

  • переменная есть в коде, но print говорит, что её нет;
  • next прыгает через несколько строк;
  • стек вызовов «схлопнут»;

👉 Это не баг debugger’а — это последствия оптимизаций.


Самая частая ошибка: дебажить не тот бинарник

По умолчанию Go собирает оптимизированный бинарь. Для продакшена — отлично. Для отладки — боль.

Используй:

go build -gcflags="all=-N -l"

Дополнительно полезно:

go test -c -gcflags="all=-N -l"

👉 Это соберёт тестовый бинарник, который можно дебажить отдельно.

Если не отключить оптимизации:

  • переменные могут исчезать
  • условия могут «схлопываться»
  • debugger будет вести себя непредсказуемо

Delve — да, но не панацея

Базовые команды ты знаешь. Но есть менее очевидные вещи.

Полезные команды

break main.main
break file.go:42
break MyFunc if x > 10

Условные breakpoint’ы — очень мощная штука.

print &var

👉 Смотри адреса — это помогает ловить race и aliasing.

stack

Показывает стек текущей goroutine.

threads

Иногда полезно понять, что происходит на уровне OS thread.


Как на самом деле происходит отладка

Реальный workflow обычно такой:

  1. Есть симптом (например, latency вырос)
  2. Проверяем метрики
  3. Смотрим логи
  4. Добавляем точечные логи
  5. Пробуем воспроизвести
  6. Запускаем с -race
  7. Только потом — debugger

👉 Ключевая мысль: debugger — это финальный инструмент, а не первый.


Почему fmt.Printf до сих пор жив

Есть важный нюанс: Go scheduler чувствителен к задержкам.

Debugger:

  • стопает мир
  • меняет interleaving
  • влияет на GC

А лог — нет.

👉 Особенно полезно логировать:

log.Printf("goroutine=%d step=%s", runtime.NumGoroutine(), step)

или даже:

buf := make([]byte, 1<<16)
stackSize := runtime.Stack(buf, true)
log.Printf("=== STACK ===\n%s", buf[:stackSize])

Это даёт snapshot всех goroutine без debugger.


Panic — это не враг

Важно: panic показывает стек в момент краша, а не после.

Обрати внимание на такие детали:

  • [running] — где сейчас выполнение
  • [chan receive] — ждёт канал
  • [select] — блок в select

👉 Это даёт контекст даже без debugger.


Goroutine: где начинаются реальные проблемы

Небольшой «умный» момент, который сильно помогает при отладке:

В рантайме Go используется модель M:N-планировщика (machine ↔ goroutine), где множество goroutine мультиплексируется на ограниченное число OS-потоков. Планировщик оперирует сущностями G (goroutine), M (machine/thread) и P (processor — логический контекст выполнения).

👉 Почему это важно для дебага:

  • goroutine не привязана к конкретному потоку
  • выполнение может прерываться в неожиданных местах (safe points)
  • стек goroutine может расти и сжиматься динамически

Из-за этого:

  • stack trace может выглядеть «неполным»
  • локальные переменные могут временно отсутствовать
  • порядок выполнения нестабилен даже при одинаковом коде

Если держать эту модель в голове — многие «магические» баги перестают быть магией.

Главное, что нужно понять:

👉 Goroutine — это кооперативная модель

Главное, что нужно понять:

👉 Goroutine — это кооперативная модель

Они переключаются:

  • на syscall
  • на блокировках
  • на GC

Это значит:

  • порядок выполнения нестабилен
  • баги могут быть «редкими»

Утечки goroutine

Очень частый паттерн бага:

select {
case msg := <-ch:
    handle(msg)
}

👉 Здесь нет ctx.Done()

Правильно:

select {
case msg := <-ch:
case <-ctx.Done():
    return
}

Ещё один кейс:

go func() {
    for {
        doWork()
    }
}()

👉 Нет выхода — гарантированная утечка.


Race condition — глубже

Типичный пример:

var x int

go func() { x = 1 }()
go func() { fmt.Println(x) }()

Race detector покажет.

Но более сложные кейсы:

  • map без mutex
  • slice append из разных goroutine
  • shared struct без синхронизации

👉 Особенно опасны read-modify-write операции.


Почему debugger «чинит» баг

Debugger добавляет latency.

Если у тебя race вида:

  • goroutine A пишет
  • goroutine B читает

Breakpoint может просто дать A завершиться раньше.

👉 И баг исчезает.


Deadlock — как читать

Смотри не просто стек, а паттерны:

  • все goroutine ждут один канал
  • есть mutex без unlock
  • есть WaitGroup без Done

Типичный баг:

wg.Add(1)
go func() {
    defer wg.Done()
    if err != nil {
        return
    }
}()

wg.Wait()

👉 если panic — Done не вызовется


pprof — must have

Кроме базового подключения:

import _ "net/http/pprof"

Смотри:

go tool pprof http://localhost:6060/debug/pprof/goroutine

или:

go tool pprof cpu.prof

👉 Команда top внутри pprof — первое, что нужно знать.


Прод: что реально работает

Debugger в проде почти не используется.

Рабочий стек:

  • логи (обязательно с request id)
  • метрики (Prometheus)
  • tracing (если есть)
  • pprof

👉 Без этого ты слепой.


Реальный кейс: сервис не умирает

Очень частая проблема:

  • SIGTERM пришёл
  • HTTP сервер остановился
  • а процесс висит

Причины:

  • background goroutine
  • kafka/queue consumer
  • незакрытый канал

👉 Решение почти всегда: правильно прокинуть context.


Что стоит сделать заранее

Минимальный набор:

  • structured logging
  • context propagation
  • timeout на всё внешнее
  • pprof endpoint
  • метрика goroutine count
runtime.NumGoroutine()

👉 если растёт — уже тревога


Ещё немного практики: вещи, которые реально спасают

Есть несколько приёмов, которые неочевидны, но очень помогают в сложных случаях.

Локализация проблемы через «выключение»

Если система большая, попробуй не искать баг, а отключать части системы:

  • убери background worker
  • отключи кэш
  • замокай внешние сервисы

👉 Если баг исчез — ты уже сильно сузил область поиска.

Это банально, но работает лучше, чем часами смотреть в debugger.


Проверка гипотез через «искусственные задержки»

Иногда полезно наоборот сломать тайминги вручную:

time.Sleep(10 * time.Millisecond)

или:

runtime.Gosched()

👉 Если баг начинает воспроизводиться чаще — это почти точно race или проблема с синхронизацией.


Детект «подвисших» операций

Очень полезный паттерн — логировать долгие операции:

start := time.Now()

defer func() {
    if time.Since(start) > time.Second {
        log.Println("slow operation")
    }
}()

👉 Это помогает ловить «иногда тормозит».


Контроль каналов

Одна из частых проблем — работа с каналами.

Проверь себя:

  • кто закрывает канал?
  • может ли кто-то писать в закрытый канал?
  • может ли кто-то читать вечно?

👉 Простое правило:

канал закрывает тот, кто его создаёт

Nil channel — скрытая ловушка

Очень неприятный кейс:

var ch chan int

<-ch

👉 Это не panic. Это вечная блокировка.

В select:

select {
case <-ch:
}

👉 Если ch == nil, кейс просто никогда не выполнится.

Это часто ломает логику.


Debug через «инварианты»

Вместо логов можно проверять состояние:

if state != expected {
    panic("invalid state")
}

👉 Это превращает тихий баг в явный.

Очень полезно на этапе разработки.


Частые анти-паттерны, которые потом больно дебажить

Вот вещи, которые почти гарантированно усложнят тебе жизнь:

1. Глобальные переменные без синхронизации

var cache map[string]string

👉 потом race, и ты ищешь его часами.


2. Нет контекста

go doWork()

👉 и потом это нельзя остановить.


3. Игнорирование ошибок

_ = doSomething()

👉 потом panic в другом месте.


4. «вечные» goroutine

for {
    // ...
}

👉 без условия выхода — это бомба замедленного действия.


5. map в нескольких goroutine

👉 классика, которая ловится только с -race или уже в проде.


Итог

Отладка в Go — это смесь:

  • понимания runtime
  • анализа поведения
  • инструментов

Debugger — это только верхушка айсберга.

Настоящая отладка — это:

👉 наблюдаемость + понимание + правильные инструменты

И да — иногда printf реально быстрее 😺

Если хочется углубиться ещё дальше, логичное продолжение — разобрать:

  • как работает scheduler Go под капотом
  • как читать pprof flamegraph
  • как ловить утечки памяти и goroutine в проде