Ctrl+C в Go: перехват SIGINT и «defer»-очистка под капотом

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Можно ли перехватить 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. Под капотом нет никакой

Блоги

Рекомендаций по книгам