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

c03b3d8e9adf5e4c4cf3bd58ee4c534a.jpg

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

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

Почему 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 раз