Обработка паник в горутинах

от автора

Привет, Хабр!

Сегодня рассмотрим, как безопасно запускать горутины, перехватывать в них паники, логировать их со стек трейсом и не дать одной багнутой функции завалить весь сервис.

Почему recover() не спасёт вас от паники в горутине

Когда ты только знакомишься с panic() и recover(), всё кажется прямолинейным:

«Если где‑то в коде случится паника, я могу поставить defer + recover, и всё будет красиво обработано».

Но в Go есть одно принципиально важное правило: recover() работает только в пределах той горутины, в которой была вызвана panic().

И это не баг. Это часть архитектуры Go — горутины изолированы, и panic не «пузырится» вверх по стеку в другие горутины. Именно поэтому, если поставим recover() в main(), а паника произойдёт внутри go func(), то эту панику не перехватишь. Она просто приведёт к крашу всей программы.

Например:

func main() { defer func() { if r := recover(); r != nil { log.Println("Recovered in main:", r) } }()  go func() { panic("BOOM") }()  time.Sleep(500 * time.Millisecond) } 

Наивное ожидание: recover() в main() перехватит панику и всё залогирует.

Реальность: программа завершится с ошибкой и выведет стек трейс:

SafeGo(func() { panic("что-то пошло не так") })

Почему так происходит?

  • recover() работает только внутри defer, и только если в том же стеке вызовов произошла panic.

  • Анонимная горутина — это другая ветка исполнения. У неё свой стек, своё пространство — и свой defer.

  • Следовательно, recover() в main() никак не связан с паникой в go func().

Это поведение неочевидно, особенно для тех, кто привык к языкам, где есть глобальные обработчики исключений, например, Java с Thread.UncaughtExceptionHandler или Python с sys.excepthook. В Go этого нет. Упала горутина — ты либо сам её ловишь, либо падает вся программа.

А теперь представь, что у тебя не одна горутина, а десятки, сотни. Обработка очереди, параллельные HTTP‑запросы, фоновые задачи, стриминг данных… И в одной из них — неучтённый nil, выход за границы слайса, забытая проверка. Паника. Без recover() в этой конкретной горутине — всё падает.

Поэтому надежда на один глобальный recover() — стратегически ошибочна. И существует множество решений проблемы.

Паттерн: SafeGo(fn func())

Решение — очевидное, но требует привычки: всегда запускать горутину через обёртку, которая ловит панику в той же горутине.

func SafeGo(fn func()) { go func() { defer func() { if r := recover(); r != nil { log.Printf("panic: %v\n%s", r, debug.Stack()) } }() fn() }() }

Использование:

SafeGo(func() { panic("что-то пошло не так") })

Теперь паника будет залогирована, стек сохранится, и процесс продолжит работать.

Поддержка context.Context

Горутины — сущности долгоживущие. Если их не контролировать — получаем утечки, висячие фоновые задачи и проблемы при остановке сервиса. Поэтому обёртка должна учитывать context.Context.

func SafeGoCtx(ctx context.Context, fn func(ctx context.Context)) { go func() { defer func() { if r := recover(); r != nil { log.Printf("panic: %v\n%s", r, debug.Stack()) } }() fn(ctx) }() }

Пример использования:

ctx, cancel := context.WithCancel(context.Background()) defer cancel()  SafeGoCtx(ctx, func(ctx context.Context) { for { select { case <-ctx.Done(): log.Println("shutdown gracefully") return default: // основная логика } } })

Так можно управлять жизненным циклом горутин, завершать их корректно при остановке и не оставлять висеть в памяти.

Логирование с контекстом: requestID, userID и не только

Поймать панику — это хорошо. Но в продакшене одной строки «panic: oh no» мало. Нужно знать, кто вызвал, в каком запросе, с какими параметрами.

Введём передачу метаинформации через context:

type ctxKey string  func SafeGoCtxVerbose(ctx context.Context, fn func(ctx context.Context)) { go func() { defer func() { if r := recover(); r != nil { requestID, _ := ctx.Value(ctxKey("requestID")).(string) userID, _ := ctx.Value(ctxKey("userID")).(string)  log.Printf("[panic] requestID=%s userID=%s error=%v\n%s", requestID, userID, r, debug.Stack()) } }() fn(ctx) }() }

Применение:

ctx := context.WithValue(context.Background(), ctxKey("requestID"), "abc-123") ctx = context.WithValue(ctx, ctxKey("userID"), "42")  SafeGoCtxVerbose(ctx, func(ctx context.Context) { panic("divide by zero") })

Теперь в логах будет не просто сообщение об ошибке, а полный контекст: кто, где, и как туда попал.

Расширение: учёт ошибок и синхронизация с sync.WaitGroup

Иногда горутина должна что‑то вернуть — например, ошибку. Сделаем обёртку, возвращающую канал:

func SafeGoErr(fn func() error) <-chan error { errCh := make(chan error, 1) go func() { defer func() { if r := recover(); r != nil { errCh <- fmt.Errorf("panic: %v", r) } }() errCh <- fn() }() return errCh }

И вызов:

errCh := SafeGoErr(func() error { panic("unexpected state") })  err := <-errCh if err != nil { log.Println("error in goroutine:", err) }

Добавим sync.WaitGroup:

var wg sync.WaitGroup wg.Add(1)  SafeGo(func() { defer wg.Done() doWork() })  wg.Wait()

Для сервисов с длинными горутинами или при graceful shutdown — маст хэв.

Интеграция с Sentry и мониторингом

Вывести панику в лог — полдела. Нужно, чтобы она дошла до алёрта, дашборда, с приоритетом и всей метаинформацией. Подключаем Sentry:

import "github.com/getsentry/sentry-go"  func reportToSentry(r interface{}, stack []byte, ctx context.Context) { sentry.WithScope(func(scope *sentry.Scope) { if requestID, ok := ctx.Value(ctxKey("requestID")).(string); ok { scope.SetTag("request_id", requestID) } if userID, ok := ctx.Value(ctxKey("userID")).(string); ok { scope.SetUser(sentry.User{ID: userID}) } scope.SetExtra("stacktrace", string(stack)) sentry.CaptureException(fmt.Errorf("panic: %v", r)) }) }

В обёртку добавляем:

if r := recover(); r != nil { stack := debug.Stack() log.Printf("panic: %v\n%s", r, stack) reportToSentry(r, stack, ctx) }

Метрики: сколько паник, где именно

Интеграция с Prometheus:

var panicCounter = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "goroutine_panics_total", Help: "Total number of panics in goroutines", }, []string{"component"}, )  func init() { prometheus.MustRegister(panicCounter) }

И инкремент:

panicCounter.WithLabelValues("my_worker").Inc()

Теперь можно построить график: где чаще всего падают горутины, и нужно ли срочно переписать кусок сервиса.

Пример безопасного Kafka-консьюмера

func startKafkaConsumer(ctx context.Context, topic string, handler func(msg Message)) { SafeGoCtxVerbose(ctx, func(ctx context.Context) { for { select { case <-ctx.Done(): log.Println("Kafka consumer shutting down") return case msg := <-consume(topic): SafeGo(func() { handler(msg) }) } } }) }

Каждое сообщение — в отдельной горутине. Все паники перехватываются. Горутины можно остановить по ctx.Done(). Плюс логирование и Sentry‑интеграция.

Оборачивайте горутины с recover(), логируйте с контекстом, добавляйте метрики и мониторинг — иначе единичная паника может завершить весь процесс. А как вы обрабатываете паники в горутинах?


Когда работаешь с конкурентным кодом, важно не только запускать горутины, но и контролировать их поведение в бою: где ловить панику, как сохранить логику, не завалив остальной сервис. Если статья про SafeGo попала в фокус — держим курс на смежные темы. Эти открытые уроки от Otus помогут взглянуть глубже: как управлять асинхронностью, организовать надёжный пайплайн сообщений и не терять стабильность на проде.

Записывайтесь, если интересует:

Чат-радио на Go: брокер сообщений NATS в деле
29 апреля, 20:00
Обработка событий, управление потоками данных, изоляция задач в Go

Эффективная работа с Future в Scala: асинхронное программирование на практике
15 апреля, 18:30
Обработка ошибок, контроль выполнения, концепции безопасного async-кода

Больше уроков по программированию и не только вас ждет в календаре.


ссылка на оригинал статьи https://habr.com/ru/articles/900452/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *