Можно ли перехватить Ctrl+C в Go и выполнить cleanup, как defer? Разбор под капотом
Вы когда-нибудь запускали сервер на Go, жали Ctrl+C и задумывались: «А что, если бы перед выходом можно было бы аккуратно закрыть соединения с БД, сохранить кэш или просто сказать „Пока!“?» Вопрос настолько популярен на Stack Overflow (тысячи просмотров!), что мы решили разобрать его детально. Сегодня мы не просто дадим готовый рецепт, а заглянем под капот — как Go обрабатывает сигналы, что такое signal.Notify и почему defer тут не совсем при чём.
Готовы? Поехали!
Проблема: Ctrl+C убивает программу мгновенно
По умолчанию, когда вы нажимаете Ctrl+C в терминале, ОС посылает процессу сигнал SIGINT. Go-программа, если ничего не предпринимать, завершается аварийно — все горутины обрываются, defer-функции не выполняются. Это как выдернуть вилку из розетки: компьютер выключится, но данные могут потеряться.
Классический пример: вы пишете веб-сервер, открываете соединение с PostgreSQL, и вдруг — Ctrl+C. Если не обработать сигнал, соединение повиснет, а пул соединений не освободится. Через несколько таких перезапусков база данных скажет: «Извини, больше не могу».
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
)
func main() {
// ... ваш код
fmt.Println("Нажмите Ctrl+C, чтобы завершить")
// Ждём сигнал
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
fmt.Println("Получен сигнал, завершаемся...")
}
Этот код уже ловит сигнал, но что, если нам нужно выполнить сложную очистку? Тут и начинается магия.
Как это работает под капотом: signal.Notify и каналы
Функция signal.Notify регистрирует ваш канал для получения определённых сигналов. Когда ОС отправляет сигнал, Go runtime помещает его в канал. Если канал буферизирован (а мы сделали буфер 1), то сигнал не потеряется, даже если горутина-получатель занята.
Важный нюанс: signal.Notify перехватывает сигнал, но не блокирует его распространение. Если вы не обработаете сигнал в канале, программа всё равно завершится после выполнения defer (если они есть в main). Но если вы хотите выполнить cleanup — нужно дождаться его завершения.
Схема работы:
- ОС → посылает SIGINT процессу
- Go runtime → кладёт сигнал в канал, зарегистрированный через
signal.Notify - Ваша горутина → читает из канала и запускает cleanup
- После cleanup → вызываете
os.Exit(0)или просто выходите из main
Но как сделать cleanup «в стиле defer»? Ведь defer выполняется при возврате из функции, а не при получении сигнала. Ответ: используем defer + блокировку main до получения сигнала.
Рецепт: «defer-подобная» очистка при Ctrl+C
Идея проста: мы пишем cleanup-функции, которые должны выполниться при завершении, и «вешаем» их на defer. Затем блокируем main до получения сигнала. Когда сигнал приходит, main завершается, и все defer срабатывают. Элегантно, правда?
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func cleanup() {
fmt.Println("Очистка: закрываем соединения...")
time.Sleep(2 * time.Second) // имитация работы
fmt.Println("Очистка завершена")
}
func main() {
// defer выполнится при любом завершении main
defer cleanup()
// Канал для сигнала
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
fmt.Println("Сервер запущен. Нажмите Ctrl+C для остановки.")
fmt.Println("Выполняется очистка...")
// Блокируем main до получения сигнала
<-sig
fmt.Println("Получен сигнал завершения")
// После этого main завершится, и сработает defer
}
Запустите эту программу, нажмите Ctrl+C — и вы увидите сообщение об очистке. Магия!
Что если cleanup занимает много времени?
В реальном проекте cleanup может длиться секунды (закрытие пулов, запись логов). Пользователь может нажать Ctrl+C ещё раз — и программа завершится принудительно. Чтобы избежать этого, можно игнорировать повторные сигналы или использовать контекст с тайм-аутом.
func main() {
defer cleanup()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
// Игнорируем повторные сигналы
signal.Ignore(syscall.SIGINT, syscall.SIGTERM)
<-sig
// cleanup выполнится, повторный Ctrl+C не прервёт
}
Но будьте осторожны: если cleanup зависнет, программу не убить. Лучше использовать контекст с тайм-аутом:
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
defer cleanup(ctx)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
// cleanup получит контекст и завершится через 5 секунд максимум
}
Почему это не совсем «defer» под капотом?
Теперь давайте заглянем глубже. defer — это механизм Go runtime, который откладывает вызов функции до выхода из окружающей функции. Он не связан с сигналами ОС. Когда вы используете defer cleanup() в main, вы просто гарантируете, что cleanup выполнится при любом завершении main: будь то возврат, паника или os.Exit (хотя os.Exit не выполняет defer — это важно!).
Сигнальный механизм — это отдельная подсистема в пакете os/signal. Она использует горутину runtime, которая слушает сигналы от ОС и отправляет их в зарегистрированные каналы. Когда мы пишем <-sig, мы блокируем main, и сигнал «разблокирует» её. После этого main завершается, и запускается стек defer.
Таким образом, мы эмулируем поведение defer, но на самом деле это просто последовательность: ожидание сигнала → завершение main → выполнение defer. Под капотом нет никакой