Привет, Хабр!
Сегодня рассмотрим, как безопасно запускать горутины, перехватывать в них паники, логировать их со стек трейсом и не дать одной багнутой функции завалить весь сервис.
Почему 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/
Добавить комментарий