Avatar ·

Можно ли перехватить сигнал Ctrl+C (SIGINT) и выполнить функцию очистки, как при defer?

Всем привет! Я пишу небольшой HTTP-сервер на Go, который обрабатывает запросы и сохраняет данные в файл. Когда я запускаю сервер и нажимаю Ctrl+C, программа завершается мгновенно, и файл с данными остаётся в незавершённом состоянии. Я хочу, чтобы при получении SIGINT (Ctrl+C) сначала выполнялась функция очистки (например, flush данных на диск и закрытие файлов), а только потом программа завершалась. Я знаю про `defer` — он отлично работает, когда функция завершается нормально или через `return`. Но если я нажимаю Ctrl+C, `defer` не срабатывает, потому что программа просто убивается сигналом. Можно ли как-то перехватить этот сигнал и выполнить cleanup-логику в стиле `defer`? Чтобы не писать костыли с ручной проверкой флагов. Вот пример моего кода: ```go package main import ( "fmt" "log" "net/http" "os" "os/signal" "syscall" ) func main() { // Открываем файл для логов f, _ := os.Create("server.log") defer f.Close() http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(f, "Request received") w.Write([]byte("OK")) }) go func() { log.Println("Server started") log.Fatal(http.ListenAndServe(":8080", nil)) }() // Здесь я хочу перехватить Ctrl+C // и выполнить f.Close() + другие cleanup // но как? select {} // блокируем main } ``` Подскажите, как правильно обработать SIGINT и выполнить код очистки, при этом сохранив идиому `defer` для других случаев? Заранее спасибо! <<>>

Решение: перехват сигналов через os/signal и синхронизация с defer

Да, это возможно. Стандартная библиотека Go предоставляет пакет os/signal, который позволяет перехватывать сигналы ОС, включая SIGINT (Ctrl+C). Комбинируя его с каналами и горутиной, можно добиться поведения, аналогичного defer — cleanup выполнится в любом случае: при нормальном завершении, при панике или при получении сигнала.

Ключевая идея

Мы создаём канал для сигналов, подписываемся на SIGINT (и, возможно, SIGTERM), а затем в отдельной горутине ждём сигнал. Когда сигнал приходит, мы вызываем функцию очистки и завершаем программу. Чтобы сохранить идиому defer, мы оборачиваем всю логику в функцию run(), где defer сработает при любом выходе.

Пример реализации

package main

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

func main() {
    // Создаём контекст с отменой для graceful shutdown
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    // Запускаем основную логику
    if err := run(ctx); err != nil {
        log.Fatal(err)
    }
}

func run(ctx context.Context) error {
    // Открываем файл — cleanup выполнится через defer
    f, err := os.Create("server.log")
    if err != nil {
        return fmt.Errorf("create file: %w", err)
    }
    defer f.Close()

    // HTTP сервер
    srv := &http.Server{Addr: ":8080"}
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(f, "Request received at", time.Now())
        w.Write([]byte("OK"))
    })

    // Канал для ошибок от сервера
    errCh := make(chan error, 1)

    // Запускаем сервер в горутине
    go func() {
        log.Println("Server started on :8080")
        errCh <- srv.ListenAndServe()
    }()

    // Канал для сигналов ОС
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

    // Ожидаем либо сигнал, либо ошибку сервера
    select {
    case sig := <-sigCh:
        log.Printf("Received signal: %v. Starting cleanup...", sig)
    case err := <-errCh:
        if err != nil && err != http.ErrServerClosed {
            return fmt.Errorf("server error: %w", err)
        }
    case <-ctx.Done():
        log.Println("Context cancelled")
    }

    // Graceful shutdown сервера (ждём до 10 секунд)
    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer shutdownCancel()

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

    // Здесь сработает defer f.Close() — файл будет закрыт корректно
    log.Println("Cleanup complete. Exiting.")
    return nil
}

Как это работает

  • Сигналы: signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) подписывается на SIGINT (Ctrl+C) и SIGTERM (kill).
  • Блокировка: select ждёт одно из трёх событий — сигнал, ошибка сервера или отмена контекста.
  • Cleanup: После выхода из select мы выполняем graceful shutdown сервера, а затем срабатывает defer f.Close().
  • Контекст: Позволяет отменить операцию извне (например, через cancel() в тестах).

Важные моменты

  • Не забудьте указать буфер 1 для канала сигналов, чтобы не потерять сигнал, если горутина занята.
  • Если вы используете log.Fatal (который вызывает os.Exit(1)), defer не сработает. Вместо этого возвращайте ошибку из run().
  • Для полной идиомы defer можно использовать пакет deferred.SignalHandler из сторонних библиотек, но стандартное решение — предпочтительнее.
<<>>

Альтернативный подход: использование defer с флагом завершения

Если вам не нужен graceful shutdown HTTP-сервера, а только выполнение cleanup-функции при SIGINT, можно использовать более простой подход с флагом и блокирующим чтением из канала сигналов.

package main

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

func main() {
    cleanupDone := make(chan struct{})

    // Перехватываем сигналы
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

    // Запускаем горутину, которая ждёт сигнал
    go func() {
        <-sigCh
        fmt.Println("Received signal, cleaning up...")
        // Здесь ваша логика очистки
        close(cleanupDone)
    }()

    // Ваша основная работа
    fmt.Println("Working... Press Ctrl+C to stop.")

    // Блокируем main до завершения cleanup
    <-cleanupDone
    fmt.Println("Cleanup done. Exiting.")
}

Недостатки этого подхода:

  • Не интегрируется с defer — cleanup вызывается вручную.
  • Не подходит для серверов с длительными запросами — программа завершается сразу после cleanup.
  • Нет graceful shutdown — открытые соединения могут быть разорваны.

Этот вариант хорош для простых скриптов или CLI-утилит, где не требуется сложное управление жизненным циклом. Для production-серверов лучше использовать первый подход с контекстом и Shutdown.