JavaRush /Курсы /Go SELF /Server.Shutdown(ctx) и таймаут остановки

Server.Shutdown(ctx) и таймаут остановки

Go SELF
63 уровень , 1 лекция
Открыта

1. Зачем Shutdown и почему таймаут обязателен

Когда вы впервые поднимаете HTTP‑сервер, кажется, что «остановить» его — это просто выйти из main(). Увы, сервер — существо социальное: у него есть активные соединения, запросы «в процессе», буферы, и клиенты, которые не обязаны вести себя прилично. В этом разделе разберём, что именно делает Shutdown, и почему без таймаута вы рискуете получить процесс-зомби: он «вроде должен завершиться», но почему-то нет.

(*http.Server).Shutdown(ctx) — это мягкая остановка. Смысл такой: сервер перестаёт принимать новые соединения и даёт уже начатым запросам шанс завершиться «нормально». Но ключевое слово тут — шанс. Если запрос завис, клиент держит соединение, обработчик слишком долгий или вы где-то ждёте внешний ресурс — ожидание может затянуться.

И вот тут появляется важная идея: контекст задаёт верхнюю границу ожидания. Контекст в Go умеет сообщать «время вышло» через deadline/timeout и отмену. А context.WithTimeout — это стандартный способ создать производный контекст с дедлайном.

Если вызвать Shutdown(context.Background()), вы говорите: «жди сколько угодно». Для продакшна это как «я на пять минут» — и пропал на три дня. Поэтому правило простое и жёсткое:

Shutdown всегда вызываем с контекстом, созданным через context.WithTimeout (таймаут обязателен).

Небольшая таблица для ориентира:

Метод Что делает по смыслу Что будет с активными запросами Где подвох
srv.Shutdown(ctx)
мягкая остановка пытается дождаться завершения без таймаута может ждать бесконечно
srv.Close()
жёсткая остановка соединения рвутся пользователи могут получить обрывы/ошибки

2. Канонический шаблон shutdown с WithTimeout

Базовый паттерн: WithTimeout + defer cancel() + Shutdown

Многие новички пытаются «срезать угол»: написать srv.Shutdown(context.Background()) или создать таймаут, но забыть cancel(). Это нормально: мозг экономит энергию и считает, что «ну оно само». Но у контекста есть ресурсы (таймеры, связи), и правильный шаблон — это не бюрократия, а гигиена кода.

Канонический паттерн для остановки выглядит так: создаём контекст с таймаутом, сразу ставим defer cancel(), затем вызываем Shutdown. WithTimeout создаёт производный контекст, который автоматически отменится по истечении времени.

Мини-пример (в вакууме, без сигналов и без сервера «вокруг»):

package main

import (
	"context"
	"log"
	"net/http"
	"time"
)

func stopServer(srv *http.Server) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		log.Printf("shutdown error: %v", err)
	}
}

Здесь важно не только то, что таймаут есть, но и то, что cancel() вызывается гарантированно. Да, контекст и так отменится по таймауту, но cancel() — это «убрать за собой со стола»: освободить связанные ресурсы раньше, если мы завершились быстрее.

Как логировать ошибку Shutdown

Новички часто ожидают, что Shutdown либо «успешен», либо «сломался». В реальности есть третий режим: «мы старались, но время вышло». И это не всегда катастрофа — но это всегда сигнал, что ваше приложение не идеально уважает отмену или у вас реально слишком маленький таймаут.

Если таймаут истёк, вы обычно увидите ошибку, связанную с context.DeadlineExceeded. А ctx.Err() у такого контекста тоже будет context.DeadlineExceeded. Это штатный механизм: дедлайн наступил — контекст отменился — операции должны завершаться.

Пример более «говорящей» диагностики без лишней драмы:

package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"time"
)

func shutdownWithLog(srv *http.Server) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		if errors.Is(err, context.DeadlineExceeded) {
			log.Printf("shutdown: timeout exceeded")
			return
		}
		log.Printf("shutdown error: %v", err)
	}
}

Важно не путать два слоя ошибок:

  • http.ErrServerClosed — это ожидаемая ошибка из ListenAndServe, когда сервер штатно закрывается (её фильтруем там, где запускаем сервер).
  • context.DeadlineExceeded — это сигнал, что shutdown‑процедура не уложилась в дедлайн (её логируем в shutdown‑ветке).

3. Как выбрать таймаут shutdown

Хочется дать универсальное число: «ставьте 5 секунд и будет счастье». Но в программировании магические числа плохо работают (кроме 42, но это другой курс). Вместо этого нужен способ рассуждать: сколько времени серверу нужно, чтобы корректно завершить запросы?

Логика такая: таймаут shutdown должен быть достаточно большим, чтобы ваши «обычные» запросы успели закончиться, и достаточно маленьким, чтобы процесс не висел вечность. Если вы делаете API, где типичный запрос работает 20–50 миллисекунд, то 30 секунд shutdown звучит щедро. Если у вас есть операции, которые реально занимают 2–3 секунды (например, тяжёлая агрегация), то shutdown в 1 секунду будет регулярно «рубить» процесс раньше времени.

Поскольку в Go context распространяет дедлайны и отмену вниз по стеку вызовов, правильная архитектура обычно приводит к тому, что обработчики и зависимости либо успевают завершиться в разумное время, либо корректно отменяются по контексту.

В учебном сервере задач (условный todo/tasks) можно начать с 5 секунд. Главное — не число, а то, что оно есть и явно написано в коде.

Хороший стиль — вынести таймаут в константу, чтобы он не затерялся среди логики:

package main

import "time"

const shutdownTimeout = 5 * time.Second

4. Graceful shutdown в main(): сигнал, таймаут, Shutdown

Общая схема

В main() сервер обычно запускается и блокирует поток через ListenAndServe(). При этом сигнал завершения прилетает «снаружи», поэтому нам нужно место, где мы дождёмся сигнала и инициируем остановку. Мы делаем это через signal.NotifyContext и ожидание <-sigCtx.Done(), а затем добавляем правильный Shutdown с таймаутом.

Схематично это выглядит так:

flowchart TD
    A[main: запускаем HTTP сервер] --> B[ListenAndServe блокируется]
    A --> C["goroutine: ждём <-sigCtx.Done()"]
    C --> D["создаём shutdownCtx = WithTimeout(...)"]
    D --> E["вызываем srv.Shutdown(shutdownCtx)"]
    E --> F[ListenAndServe завершается]

Запуск сервера и ожидание сигнала

Код запуска (предположим, что mux уже собран):

package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	mux := http.NewServeMux()
	srv := &http.Server{Addr: ":8080", Handler: mux}

	sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	go shutdownOnSignal(sigCtx, srv)

	err := srv.ListenAndServe()
	if err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Printf("server error: %v", err)
	}
}

Самое интересное спрятано в shutdownOnSignal. Реализуем её так, чтобы таймаут был обязателен:

package main

import (
	"context"
	"log"
	"net/http"
	"time"
)

const shutdownTimeout = 5 * time.Second

func shutdownOnSignal(sigCtx context.Context, srv *http.Server) {
	<-sigCtx.Done() // ждём “событие остановки”

	ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		log.Printf("shutdown error: %v", err)
	}
}

Обратите внимание на деталь: мы делаем WithTimeout(context.Background(), ...), а не WithTimeout(sigCtx, ...). Почему так? Потому что sigCtx уже отменён в момент, когда мы дошли до shutdown (мы же вышли из <-sigCtx.Done()), и если сделать его родителем, получим контекст «отменён сразу». Тогда shutdown фактически не будет ждать вообще.

Именно поэтому в shutdown‑ветке часто используют отдельного «нейтрального» родителя — context.Background().

Почему Shutdown нельзя вызывать просто «после ListenAndServe»

Типичная ловушка начинающих выглядит так:

err := srv.ListenAndServe()
srv.Shutdown(...)

Кажется логичным: «когда сервер закончит работать — тогда и shutdown». Но ListenAndServe — блокирующий вызов: пока сервер работает, эта строка не вернётся. Значит, до Shutdown вы просто не дойдёте.

Поэтому shutdown запускается «рядом», чаще всего через горутину: основной поток обслуживает сервер, а параллельный — ждёт сигнал остановки и вызывает shutdown. Это не «магия», а просто две параллельные задачи.

Мини-рефакторинг main() для читабельности

Когда код разрастается, main() превращается в «лапшу»: и маршруты, и DI, и логирование, и shutdown. Мы пока не делаем глубокую слоистую архитектуру, но маленький рефакторинг уже полезен: вынести сборку сервера в функцию и оставить в main() только жизненный цикл.

Пример:

package main

import (
	"net/http"
)

func newServer() *http.Server {
	mux := http.NewServeMux()
	// mux.HandleFunc(...) // ваши handlers уже написаны раньше
	return &http.Server{Addr: ":8080", Handler: mux}
}

Тогда main() становится «чище»:

package main

import (
	"context"
	"os"
	"os/signal"
	"syscall"
)

func main() {
	srv := newServer()

	sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	go shutdownOnSignal(sigCtx, srv)
	runServer(srv)
}

А runServer — это место, где фильтруем http.ErrServerClosed:

package main

import (
	"errors"
	"log"
	"net/http"
)

func runServer(srv *http.Server) {
	err := srv.ListenAndServe()
	if err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Printf("server error: %v", err)
	}
}

Такой разнос по функциям хорош тем, что вы теперь почти физически не сможете «случайно» вызвать Shutdown без таймаута: у вас есть одна функция, где shutdown живёт, и там уже лежит WithTimeout.

5. Типичные ошибки при Server.Shutdown(ctx) и таймаутах

Ошибка №1: Shutdown(context.Background()) «потому что так проще».
Это самая опасная версия «проще». Она превращает завершение процесса в лотерею: если какой-то запрос зависнет, Shutdown может ждать очень долго. Исправление простое и дисциплинарное: shutdown‑контекст создаём только через context.WithTimeout(...), а значение таймаута делаем явным в константе.

Ошибка №2: делают WithTimeout, но забывают cancel().
Даже если таймаут отработает сам, cancel() — это корректное освобождение ресурсов, связанных с контекстом. В Go принято вызывать cancel() всегда, обычно через defer cancel() сразу после создания контекста. Это маленькая привычка, которая неожиданно экономит много нервов в больших системах.

Ошибка №3: используют уже отменённый контекст как родителя для shutdown‑контекста.
Частый сценарий: вы дождались <-sigCtx.Done(), а затем делаете context.WithTimeout(sigCtx, 5*time.Second). Но sigCtx уже отменён, значит и дочерний контекст будет отменён сразу. В итоге shutdown «мгновенный», ожидания не будет. Правильнее в shutdown‑ветке создавать контекст от context.Background().

Ошибка №4: логируют http.ErrServerClosed как «ошибку сервера».
Это ожидаемое завершение ListenAndServe при штатной остановке. Если логировать его как ошибку, вы получите шум в логах и ложные тревоги. Фильтруйте через errors.Is(err, http.ErrServerClosed) там, где запускаете сервер.

Ошибка №5: игнорируют ошибку Shutdown.
Если Shutdown не уложился в таймаут, вы хотите хотя бы знать об этом из логов. Даже в учебном проекте полезно вывести сообщение, иначе вы будете уверены, что «всё красиво выключилось», хотя на самом деле часть запросов обрубилась по дедлайну.

1
Задача
Go SELF, 63 уровень, 1 лекция
Недоступна
Выключатель Enter
Выключатель Enter
1
Задача
Go SELF, 63 уровень, 1 лекция
Недоступна
Таймаут остановки
Таймаут остановки
1
Задача
Go SELF, 63 уровень, 1 лекция
Недоступна
Остановка по сигналу
Остановка по сигналу
1
Задача
Go SELF, 63 уровень, 1 лекция
Недоступна
Двойное завершение
Двойное завершение
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ