Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.08.2026
Обновлено:
14.08.2026

Как писать горутины без утечек и корректно завершать Go-сервис

Данил Мануйлов

Сервис начинает есть память. Через несколько часов - зависает, не отвечает на запросы, не останавливается по команде. Дебаг показывает: горутин уже тысячи, и они не уходят. Эта статья - практический разбор причин утечек горутин и пошаговая схема 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 - три обязательных шага:

  1. Перестать принимать новые запросы.
  2. Дождаться завершения активных операций.
  3. Освободить ресурсы в правильном порядке.

Перехват сигналов 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

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также