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

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