Сервис начинает есть память. Через несколько часов - зависает, не отвечает на запросы, не останавливается по команде. Дебаг показывает: горутин уже тысячи, и они не уходят. Эта статья - практический разбор причин утечек горутин и пошаговая схема graceful shutdown для Go-сервисов.
Почему горутины утекают: три сценария
Горутина - дешёвая абстракция. Рантайм Go мультиплексирует горутины на потоки ОС, стартовый стек - порядка 2–8 КБ. Именно дешевизна создаёт проблему: горутины запускают легко и забывают об их завершении.
Горутина утекает в одном из трёх случаев:
Заблокировалась на чтении из nil-канала.
var ch chan string-chравенnil. Горутина, делающая<-ch, заблокируется навсегда. GC не может освободить ни её стек, ни объекты, на которые она ссылается.Заблокировалась при записи в канал без читателя. Горутина пишет в канал, но получатель уже завершился или переполненный буфер не читают. Отправитель висит бесконечно.
Ждёт данных из сети без таймаута.
net.Conn.Readблокирует горутину до получения данных. Без контекста с дедлайном горутина может жить часами после разрыва соединения.
// ПЛОХО: горутина утечёт, если strings == nil
go func() {
for s := range strings { // заблокируется навсегда на nil-канале
fmt.Println(s)
}
}()
Авторская ремарка. В реальном проекте мы поймали утечку именно так: воркер читал из канала конфигурации, который при ошибке инициализации оставался
nil. Сервис работал часами, пока счётчик горутин не перевалил за 50 000.
Context - стандарт управления горутинами
Пакет context появился в Go 1.7 и решил проблему сигнализации между горутинами. Раньше использовали ручной done-канал - context делает то же самое, но стандартно и с поддержкой таймаутов.
context.WithCancel
Базовый паттерн - передать контекст в горутину и проверять ctx.Done():
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // всегда вызывайте cancel, иначе утечёт сам контекст
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
return // чисто завершаемся
case data := <-inputCh:
process(data)
}
}
}(ctx)
cancel() закрывает канал ctx.Done(). Горутина немедленно выходит из select. Важно: cancel нужно вызывать всегда - иначе утечёт сам контекст со всеми дочерними контекстами.
context.WithTimeout
Используйте для операций с предсказуемым временем выполнения:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// HTTP-запрос автоматически отменится через 5 секунд
resp, err := http.Get("https://example.com") // передайте ctx через req.WithContext(ctx)
Дедлайн истёк → ctx.Err() вернёт context.DeadlineExceeded → горутина завершится.
for-select как сердцевина горутины
Каждая долгоживущая горутина обязана содержать select с case <-ctx.Done():
func runWorker(ctx context.Context, jobs <-chan Job) {
for {
select {
case <-ctx.Done():
log.Println("worker: завершаю работу")
return
case job, ok := <-jobs:
if !ok {
return // канал закрыт
}
job.Process()
}
}
}
Горутина без ctx.Done() не отреагирует ни на cancel(), ни на SIGTERM. Это главное правило конкурентного Go-кода.
Graceful shutdown: сервис останавливается чисто
Graceful shutdown - три обязательных шага:
- Перестать принимать новые запросы.
- Дождаться завершения активных операций.
- Освободить ресурсы в правильном порядке.
Перехват сигналов SIGTERM/SIGINT
С Go 1.16 используйте signal.NotifyContext - он привязывает отмену контекста к OS-сигналу:
ctx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGINT,
syscall.SIGTERM,
)
defer stop()
// Дальше передаём ctx во все подсистемы
<-ctx.Done() // ждём сигнала
stop() // вызываем повторно - второй Ctrl+C завершит принудительно
log.Println("получен сигнал завершения")
Kubernetes посылает SIGTERM при остановке pod, затем ждёт terminationGracePeriodSeconds (по умолчанию - 30 секунд) и убивает процесс через SIGKILL. SIGKILL перехватить невозможно - весь shutdown должен уложиться раньше.
WaitGroup vs errgroup
| Инструмент | Что умеет | Когда использовать |
|---|---|---|
sync.WaitGroup |
Ждёт N горутин | Простые пулы воркеров без обработки ошибок |
golang.org/x/sync/errgroup |
WaitGroup + возврат ошибок + отмена контекста |
Несколько подсистем, любая может упасть |
errgroup - это синтаксический сахар над WaitGroup: позволяет вернуть ошибку из горутины и автоматически отменяет контекст при первой ошибке.
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error {
return runHTTPServer(ctx)
})
g.Go(func() error {
return runGRPCServer(ctx)
})
g.Go(func() error {
return runWorkerPool(ctx)
})
if err := g.Wait(); err != nil {
log.Fatalf("сервис завершился с ошибкой: %v", err)
}
Если runHTTPServer вернёт ошибку - контекст отменится, остальные горутины получат ctx.Done() и тоже завершатся.
Полный пример: HTTP-сервис с graceful shutdown
const (
shutdownPeriod = 15 * time.Second
readinessDrainDelay = 5 * time.Second
)
var isShuttingDown atomic.Bool
func main() {
rootCtx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGINT, syscall.SIGTERM,
)
defer stop()
// Readiness probe - сигнализируем балансировщику раньше shutdown
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
if isShuttingDown.Load() {
http.Error(w, "shutting down", http.StatusServiceUnavailable)
return
}
fmt.Fprintln(w, "OK")
})
// BaseContext - все входящие запросы получат отменяемый контекст
ongoingCtx, cancelOngoing := context.WithCancel(context.Background())
server := &http.Server{
Addr: ":8080",
BaseContext: func(_ net.Listener) context.Context {
return ongoingCtx
},
}
go func() {
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("http server: %v", err)
}
}()
<-rootCtx.Done()
stop()
isShuttingDown.Store(true)
log.Println("shutdown: получен сигнал")
// Даём балансировщику время убрать pod из ротации
time.Sleep(readinessDrainDelay)
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownPeriod)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: не дождались завершения запросов: %v", err)
}
cancelOngoing() // отменяем контекст для долгих хендлеров
log.Println("shutdown: завершено чисто")
}
Этот паттерн взят из практики команды VictoriaMetrics и покрывает все три шага graceful shutdown.
Порядок освобождения ресурсов
Освобождайте в порядке, обратном инициализации. defer делает это автоматически - последний defer выполняется первым:
db := connectDB()
defer db.Close() // выполнится последним
cache := connectRedis()
defer cache.Close() // выполнится вторым
broker := connectKafka()
defer broker.Close() // выполнится первым
Для БД важно закрывать соединение корректно: открытые транзакции нужно откатить. Иначе БД ждёт connection timeout.
Обнаружение утечек: инструменты
Симптомы утечки всегда одинаковые: растёт runtime.NumGoroutine(), растёт RSS процесса, сервис начинает тормозить под нагрузкой. Три инструмента помогают найти источник.
pprof в production
Подключите net/http/pprof - это стандартный пакет:
import _ "net/http/pprof"
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
Снимите два профиля с интервалом в несколько минут:
curl http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines_1.txt
# подождать 5 минут
curl http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines_2.txt
Сравните количество горутин в начале файлов:
grep "goroutine profile:" goroutines_1.txt
grep "goroutine profile:" goroutines_2.txt
Если второй файл значительно больше - ищите повторяющиеся стек-трейсы. Группа одинаковых трейсов укажет на место утечки.
Авторская ремарка. На практике pprof находит утечку за 10 минут там, где без него ушли бы дни на code review. Добавляйте его в каждый Go-сервис с первого дня.
goleak в тестах
goleak от Uber автоматически ловит утечки в unit-тестах:
// main_test.go
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
Если после теста осталась лишняя горутина - тест упадёт с точным стек-трейсом. Для параллельных тестов используйте VerifyTestMain вместо VerifyNone - он проверяет утечки после выполнения всех тестов пакета.
go get go.uber.org/goleak
runtime.NumGoroutine в метриках
Экспортируйте в Prometheus / VictoriaMetrics:
go func() {
for range time.Tick(10 * time.Second) {
metrics.Set("goroutines_total", float64(runtime.NumGoroutine()))
}
}()
Стабильный рост счётчика при постоянной нагрузке - однозначный признак утечки. Настройте алерт на goroutines_total > N для вашего сервиса.
Частые ловушки и как их обходить
time.After и таймеры
До Go 1.23: time.After(d) создаёт таймер, который живёт до истечения d даже если select выбрал другой кейс. В горячем цикле это серьёзная утечка памяти.
// ПЛОХО (до Go 1.23)
select {
case <-time.After(5 * time.Second): // таймер не освобождается немедленно
return ErrTimeout
case data := <-ch:
process(data)
}
// ХОРОШО
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {
case <-timer.C:
return ErrTimeout
case data := <-ch:
process(data)
}
В Go 1.23 и новее time.After исправлен - таймер освобождается сразу.
os.Exit и panic без recover
os.Exit(1) прерывает процесс немедленно: defer не выполняется, соединения не закрываются, транзакции не откатываются. Никогда не используйте его в shutdown-пути.
В горутинах паника убивает весь процесс. Добавляйте recover:
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("горутина восстановилась после паники: %v", r)
}
}()
doWork()
}()
Kubernetes: preStop и grace period
Kubernetes посылает SIGTERM, но внешний load balancer ещё несколько секунд может направлять трафик на pod. Добавьте preStop hook:
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
Учтите: время preStop входит в terminationGracePeriodSeconds. При стандартных 30 секундах у вас остаётся 20 секунд на фактический shutdown.
Альтернативный взгляд
Некоторые команды сознательно отказываются от graceful shutdown в пользу идемпотентности операций: если каждый запрос атомарен и повторяем - os.Exit безопасен, а shutdown добавляет сложность без пользы. Этот подход работает в stateless-сервисах с грамотно спроектированными клиентами, но требует значительно больших усилий на уровне протокола и infrastructure.
Нетривиальный факт
Uber разработал LeakProf - систему обнаружения утечек горутин прямо в production без влияния на производительность. Она использует выборочное профилирование через pprof и статистический анализ стек-трейсов. Стандартные инструменты (goleak, pprof) работают в dev/staging - LeakProf решает задачу на живом трафике.
FAQ
Почему горутина не завершается при закрытии канала?
Горутина не завершается, если не проверяет ctx.Done() или done-канал. Без for-select с сигналом отмены горутина продолжает работать даже после закрытия входного канала.
Чем errgroup лучше WaitGroup?
errgroup позволяет возвращать ошибки из горутин и автоматически отменяет контекст при первой ошибке. WaitGroup только ждёт завершения без механизма обработки ошибок.
Как найти утечку горутин в production?
Используйте pprof: подключите net/http/pprof и снимите два профиля с интервалом. Сравните goroutine?debug=2 - повторяющиеся стек-трейсы укажут на источник утечки.
Что такое graceful shutdown и зачем он нужен?
Graceful shutdown - корректное завершение сервиса: прекратить принимать запросы, дождаться завершения текущих, освободить ресурсы. Без него возможны потеря данных, незакрытые транзакции и ошибки клиентов.
Как Go-сервис должен обрабатывать SIGTERM в Kubernetes?
Перехватите SIGTERM через signal.NotifyContext, добавьте preStop hook (sleep 10) для дренажа трафика, выполните server.Shutdown с таймаутом. Весь процесс должен уложиться в terminationGracePeriodSeconds (по умолчанию 30 секунд).
Что исправили в time.After в Go 1.23?
До Go 1.23 time.After(d) создавал таймер, который не освобождался до истечения d даже если select выбрал другую ветку. В Go 1.23 таймер освобождается немедленно после завершения select.
Как goleak помогает находить утечки в тестах?
goleak.VerifyTestMain(m) проверяет, остались ли горутины после выполнения тестов. Если да - тест падает с точным стек-трейсом
SOURCES
Gorelkin, G. «Goroutine Leaks». ubiklab.net -
VictoriaMetrics Team. «Graceful Shutdown in Go: Practical Patterns». victoriametrics.com -
Habr. «Практические советы по устранению утечек памяти в Go». habr.com -
Habr. «Go profiling lifecycle: от разработки до прода». habr.com -
Uber Engineering. «uber-go/goleak: Goroutine leak detector». github.com -
pkg.go.dev. «goleak package documentation». pkg.go.dev -
Reddit/golang. «How to stop a goroutine stuck on a network call». reddit.com -
StackOverflow. «Context cancellation: WaitGroup vs ErrGroup». stackoverflow.com -
Uber Engineering. «LeakProf: Featherlight In-Production Goroutine Leak Detection». uber.com -




.svg.webp)

