28. Сигналы в Linux

Что такое сигналы и зачем они нужны бэкенду

Сигналы (Signals) — это асинхронные программные прерывания, которые ядро ОС отправляет процессу для уведомления о событиях: запрос на завершение, доступ к запрещённой памяти, разрыв канала связи, изменение состояния дочернего процесса и т.д.

"Системный вызов — это когда ваша программа звонит в ядро и просит об услуге. Сигнал — это когда пожарная сирена ядра неожиданно воет прямо у вас над ухом, требуя немедленной реакции."

В отличие от системных вызовов (которые синхронны и инициируются программой явно), сигналы доставляются непредсказуемо во времени. Для Go-разработчика это означает, что обработка сигналов — это не просто «повесить хук», а управление состоянием приложения в условиях гонки данных и прерывания потока исполнения.

Зачем это бэкенду:

  1. Graceful Shutdown: Корректное завершение работы сервиса (закрытие соединений, drain очереди, запись логов) при получении SIGTERM.
  2. Межпроцессное взаимодействие: Управление демоном, перезагрузка конфигурации (SIGHUP), отладка (SIGUSR1).
  3. Обработка ошибок рантайма: Перехват SIGSEGV (падение), SIGPIPE (запись в закрытый сокет), SIGFPE (деление на ноль).

Механизм доставки: ядро и user space

Чтобы понять, как сигнал достигает вашей программы, нужно заглянуть в состояние потока выполнения (Thread Control Block, TCB) в ядре Linux.

Каждый тред ОС имеет два bitmap-поля в ядре:

  • sigpending — битовая маска ожидания. Устанавливается, когда сигнал отправлен, но ещё не доставлен.
  • sigmask — битовая маска блокировки. Сигналы, установленные здесь, игнорируются до момента очистки маски.

Когда другой процесс или пользователь вызывает kill(pid, SIGTERM), ядро:

  1. Ищет целевой тред по PID/TID.
  2. Проверяет права доступа (UID/GID, capabilities).
  3. Устанавливает соответствующий бит в sigpending.
  4. Если тред заблокирован (sigmask), сигнал остаётся в pending.
  5. Если тред выполняется, ядро не прерывает его мгновенно. Сигнал доставляется только при переходе из Kernel Space обратно в User Space:
    • Возврат из системного вызова.
    • Разрешение Page Fault.
    • Срабатывание таймера (preemption).

В момент доставки ядро:

  1. Сохраняет регистры CPU и текущий указатель стека.
  2. Подготавливает Signal Frame на вершине пользовательского стека.
  3. Меняет RIP (Instruction Pointer) на адрес обработчика сигнала.
  4. После выполнения обработчика вызывается инструкция sigreturn, ядро восстанавливает состояние и продолжает выполнение программы.
flowchart TD
    A["Процесс A"] -->|"kill syscall"| B["Ядро Linux"]
    B --> C["Проверка прав и PID"]
    C --> D["Установка бита в sigpending"]
    D --> E["Ожидание перехода User <-> Kernel"]
    E --> F["Возврат из syscall / таймер"]
    F --> G["Создание Signal Frame в стеке"]
    G --> H["Перехват на обработчик / runtime"]
    H --> I["Восстановление состояния через sigreturn"]
    I --> J["Продолжение исполнения"]

    style A fill:#1e293b,stroke:#818cf8,color:#fff
    style B fill:#1e3a5f,stroke:#38bdf8,color:#fff
    style D fill:#4b1d1d,stroke:#f87171,color:#fff
    style G fill:#064e3b,stroke:#34d399,color:#fff
    style I fill:#2e1065,stroke:#c084fc,color:#fff
  

Ключевые сигналы Linux для разработки

Сигнал Код Поведение по умолчанию Применимость в Go
SIGINT 2 Termination Ctrl+C. Перехватывается для graceful shutdown.
SIGTERM 15 Termination Основной сигнал для остановки сервисов в K8s/Docker.
SIGKILL 9 Termination Невозможно перехватить или проигнорировать. Немедленное убийство тредов.
SIGPIPE 13 Termination Запись в закрытый pipe/сокет. В Go: на fd 1 и 2 завершает программу, на остальных fd запись возвращает ошибку EPIPE (подробнее — ниже).
SIGHUP 1 Termination Закрытие терминала или перезапуск менеджера сервисов. Часто для reload конфига.
SIGUSR1 10 Termination Пользовательский. Используется для отладки, переоткрытия логов.
SIGCHLD 17 Ignore Изменение состояния дочернего процесса. Важно для os/exec.
SIGSTOP 19 Stop Невозможно перехватить. Временная остановка процесса.
sequenceDiagram
    autonumber
    participant K8s as Kubernetes / Docker
    participant Service as Go Микросервис
    participant Handler as Очередь / HTTP Server

    K8s->>Service: Отправка SIGTERM
    Note over Service: signal.NotifyContext отменяет ctx
    Service->>Handler: srv.Shutdown(shutdownCtx)
    Note over Handler: Перестаем принимать новые запросы
    Note over Handler: Дожимаем активные запросы (Drain)
    alt Сервер успел завершиться за таймаут (Graceful)
        Handler->>Service: Соединения закрыты, выход code 0
        Service-->>K8s: Процесс завершился штатно
    else Сервер завис (Превышен terminationGracePeriodSeconds)
        K8s->>Service: Принудительный SIGKILL (Ядро: немедленная смерть)
    end
  

Сигналы в Go: Идиоматичный подход и под капотом

Go сознательно абстрагирует низкоуровневую работу с сигналами, чтобы избежать проблем async-signal-safety. В C/C++ в обработчике можно вызывать только определённый набор функций (например, write, signal), иначе поведение не определено. Go runtime берёт всю грязную работу на себя.

Как работает os/signal

Пакет os/signal не регистрирует прямой C-хук. Вместо этого:

  1. Go runtime устанавливает собственные обработчики сигналов через rt_sigaction (на практике таких вызовов при старте десятки), а rt_sigprocmask использует для управления масками потоков.
  2. Когда сигнал приходит, ядро вызывает обработчик рантайма, и тот кладёт номер сигнала во внутреннюю очередь (sigsend). sigwaitinfo (то есть rt_sigtimedwait) Go для этого не использует.
  3. Служебная горутина os/signal забирает сигналы из этой очереди (signal_recv) и засыпает на примитиве рантайма, а не в системном вызове.
  4. Пакет os/signal кладёт сигнал в channel каждого подписчика.
  5. Ожидающая горутина разблокируется.
Go
package main

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

func main() {
    // 1. Создаём контекст с отменой по сигналу
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()

    // 2. Настраиваем HTTP сервер
    srv := &http.Server{
        Addr:    ":8080",
        Handler: http.NewServeMux(),
    }

    // 3. Запускаем сервер в отдельной горутине
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("HTTP server error: %v", err)
        }
    }()

    // 4. Ждём сигнал или ошибку сервера
    <-ctx.Done()
    log.Println("shutting down gracefully...")

    // 5. Graceful shutdown сервера с таймаутом
    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer shutdownCancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Fatalf("HTTP server forced shutdown: %v", err)
    }

    log.Println("server exited properly")
}

Под капотом os/signal

  • Размер буфера канала определяете вы сами: пакет signal не блокируется при отправке в канал, и ответственность за достаточный буфер лежит на вызывающем коде (документация советует буфер хотя бы на один сигнал).
  • Если сигнал приходит, а channel полон (goroutine не успела считать), сигнал безвозвратно теряется. Это критично в высоконагруженных сервисах.
  • signal.NotifyContext (Go 1.16+) — современный идиоматичный способ. Он создаёт контекст, который отменяется при получении сигнала. Внутренне использует тот же механизм, но привязывает отмену к context lifecycle, что идеально ложится на dependency injection и middleware.

Ловушки и каверзные вопросы с собеседований

1. SIGPIPE: Тихий убийца

По умолчанию запись в закрытый pipe или сокет вызывает SIGPIPE, что завершает процесс. В Go действует более тонкое правило (см. документацию os/signal): если запись в закрытый pipe или сокет происходит на файловых дескрипторах 1 и 2 (stdout/stderr), программа завершается по SIGPIPE; на всех остальных дескрипторах запись просто возвращает ошибку EPIPE. Так что падение возможно и без raw socket или CGO — например, при myapp | head.

Go
// Явное игнорирование для чистоты эксперимента или специфичных случаев
signal.Ignore(syscall.SIGPIPE)

2. EINTR и прерванные системные вызовы

Когда сигнал доставляется, ядро может прервать блокирующий syscall (например, read или accept), вернув ошибку EINTR.

  • В Go это частично скрыто: стандартная библиотека (os, net) сама повторяет прерванные вызовы, а рантайм ставит обработчики с SA_RESTART. Но epoll_wait не перезапускается ни при каких флагах, и при прямых вызовах через syscall и x/sys/unix (включая unix.EpollWait) EINTR возвращается вам: проверяйте его и повторяйте вызов сами.
  • В C/C++ вы обязаны проверять errno == EINTR и вызывать syscall повторно.

3. Гонка данных при cleanup

Классическая ошибка на собеседованиях:

Go
// ПЛОХОЙ ПРИМЕР
var wg sync.WaitGroup
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM)

go func() {
    <-sigCh
    wg.Add(1)
    go cleanup(wg) // Race condition: cleanup может начаться до того, как сервер остановится
}()

Правильный паттерн всегда использует context или sync.Once для координации shutdown фаз.

4. Сигналы vs Context cancellation

context.WithCancel работает в пределах одного процесса. Сигналы — это IPC-механизм уровня ОС. Если ваш сервис развёрнут в K8s, SIGTERM отправляется kubelet. Если вы просто отмените контекст, процесс продолжит жить, но K8s убьёт его через SIGKILL по таймауту. Всегда слушайте оба.


Сравнение с другими языками

Язык Механизм обработки Особенности
Go os/signal + runtime channel Безопасно, асинхронно, буферизованный канал (потеря сигналов при перегрузке), нет проблем с async-signal-safety.
Java JVM catches SIGTERM JVM перехватывает SIGTERM, запускает shutdown hooks, затем вызывает System.exit(). SIGKILL убивает JVM сразу.
Python signal.signal() Из-за GIL обработчики сигналов выполняются в основном потоке. Если основной поток заблокирован (например, в time.sleep или сетевом вызове), сигнал не доставится до разблокировки.
C/C++ sigaction() + manual frame Требуется ручное управление стеком, строгие ограничения на вызываемые функции (async-signal-safe), риск undefined behavior при некорректной регистрации.
PHP CLI signals В SAPI CLI PHP игнорирует большинство сигналов, кроме SIGTERM/SIGINT, которые завершают скрипт. В FPM/PHP-FPM сигналы обрабатываются master-процессом для управления workers.

Итоги

  1. Сигналы — асинхронные события уровня ОС, доставляемые через sigpending и sigmask.
  2. Доставка происходит при переходе из Kernel в User Space, с созданием Signal Frame в стеке.
  3. Go runtime берёт на себя всю работу с rt_sigaction и rt_sigprocmask, предоставляя безопасный channel-based API (os/signal).
  4. SIGKILL нельзя перехватить. SIGTERM — стандарт для graceful shutdown.
  5. Слишком маленький буфер канала os/signal может привести к потере сигналов в пиковых нагрузках (пакет не блокируется при отправке; размер буфера задаёте вы). Используйте signal.NotifyContext для интеграции с lifecycle.
  6. Понимание сигналов критично для работы с K8s, Docker, отладки падений и проектирования отказоустойчивых сервисов.

Мы разобрали, как процесс реагирует на внешние события. Но как сам процесс становится фоновым сервисом, который живёт годами, перезапускается и управляется? В следующей статье мы перейдём к теме: 29. Демоны и фоновые процессы.