Отладка в Go: как понять, что на самом деле происходит внутри программы
Go часто продают как «простой язык»: собрал — запустил — поехали. И в целом это правда. Но ровно до тех пор, пока у тебя не появляется странный баг, который не ловится тестами, не воспроизводится стабильно и внезапно вылезает только под нагрузкой или в проде.
В такие моменты становится понятно: отладка в Go — это не про «поставить breakpoint и посмотреть переменную». Здесь приходится понимать, что делает компилятор, как ведут себя goroutine и почему иногда printf даёт больше пользы, чем debugger.
Давай разберёмся спокойно и по-человечески, как вообще выглядит нормальный процесс отладки в Go — с деталями, которые обычно не пишут в туториалах.
Почему в Go всё ведёт себя «не так»
Если ты привык к Python или JavaScript, там всё довольно прозрачно: код почти напрямую соответствует тому, что исполняется.
Ты пишешь код, но запускаешь уже оптимизированный бинарник. И компилятор не стесняется:
- встраивать функции (inline);
- выбрасывать временные переменные;
- переупорядочивать операции;
- держать значения только в регистрах CPU;
- переносить данные между стеком и кучей (escape analysis);
В итоге debugger иногда показывает странные вещи:
- переменная есть в коде, но
printговорит, что её нет; nextпрыгает через несколько строк;- стек вызовов «схлопнут»;
👉 Это не баг debugger’а — это последствия оптимизаций.
Самая частая ошибка: дебажить не тот бинарник
По умолчанию Go собирает оптимизированный бинарь. Для продакшена — отлично. Для отладки — боль.
go build -gcflags="all=-N -l"
go test -c -gcflags="all=-N -l"
👉 Это соберёт тестовый бинарник, который можно дебажить отдельно.
Если не отключить оптимизации:
Delve — да, но не панацея
Базовые команды ты знаешь. Но есть менее очевидные вещи.
break main.main break file.go:42 break MyFunc if x > 10
Условные breakpoint’ы — очень мощная штука.
print &var
👉 Смотри адреса — это помогает ловить race и aliasing.
stack
Показывает стек текущей goroutine.
threads
Иногда полезно понять, что происходит на уровне OS thread.
Как на самом деле происходит отладка
Реальный workflow обычно такой:
- Есть симптом (например, latency вырос)
- Проверяем метрики
- Смотрим логи
- Добавляем точечные логи
- Пробуем воспроизвести
- Запускаем с
-race - Только потом — debugger
👉 Ключевая мысль: debugger — это финальный инструмент, а не первый.
Почему fmt.Printf до сих пор жив
Есть важный нюанс: Go scheduler чувствителен к задержкам.
👉 Особенно полезно логировать:
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 показывает стек в момент краша, а не после.
Обрати внимание на такие детали:
👉 Это даёт контекст даже без debugger.
Goroutine: где начинаются реальные проблемы
Небольшой «умный» момент, который сильно помогает при отладке:
В рантайме Go используется модель M:N-планировщика (machine ↔ goroutine), где множество goroutine мультиплексируется на ограниченное число OS-потоков. Планировщик оперирует сущностями G (goroutine), M (machine/thread) и P (processor — логический контекст выполнения).
👉 Почему это важно для дебага:
- goroutine не привязана к конкретному потоку
- выполнение может прерываться в неожиданных местах (safe points)
- стек goroutine может расти и сжиматься динамически
- stack trace может выглядеть «неполным»
- локальные переменные могут временно отсутствовать
- порядок выполнения нестабилен даже при одинаковом коде
Если держать эту модель в голове — многие «магические» баги перестают быть магией.
👉 Goroutine — это кооперативная модель
👉 Goroutine — это кооперативная модель
Утечки goroutine
select {
case msg := <-ch:
handle(msg)
}
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) }()
👉 Особенно опасны read-modify-write операции.
Почему debugger «чинит» баг
Breakpoint может просто дать A завершиться раньше.
Deadlock — как читать
Смотри не просто стек, а паттерны:
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 в проде почти не используется.
Реальный кейс: сервис не умирает
👉 Решение почти всегда: правильно прокинуть context.
Что стоит сделать заранее
runtime.NumGoroutine()
Ещё немного практики: вещи, которые реально спасают
Есть несколько приёмов, которые неочевидны, но очень помогают в сложных случаях.
Локализация проблемы через «выключение»
Если система большая, попробуй не искать баг, а отключать части системы:
👉 Если баг исчез — ты уже сильно сузил область поиска.
Это банально, но работает лучше, чем часами смотреть в 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 {
case <-ch:
}
👉 Если ch == nil, кейс просто никогда не выполнится.
Debug через «инварианты»
Вместо логов можно проверять состояние:
if state != expected {
panic("invalid state")
}
👉 Это превращает тихий баг в явный.
Очень полезно на этапе разработки.
Частые анти-паттерны, которые потом больно дебажить
Вот вещи, которые почти гарантированно усложнят тебе жизнь:
1. Глобальные переменные без синхронизации
var cache map[string]string
👉 потом race, и ты ищешь его часами.
2. Нет контекста
go doWork()
👉 и потом это нельзя остановить.
3. Игнорирование ошибок
_ = doSomething()
4. «вечные» goroutine
for {
// ...
}
👉 без условия выхода — это бомба замедленного действия.
5. map в нескольких goroutine
👉 классика, которая ловится только с -race или уже в проде.
Итог
Debugger — это только верхушка айсберга.
👉 наблюдаемость + понимание + правильные инструменты
И да — иногда printf реально быстрее 😺
Если хочется углубиться ещё дальше, логичное продолжение — разобрать: