Сигналы Unix

Сигнал Unix — асинхронное уведомление процессу или потоку о событии: завершении, ошибке, таймере, изменении размера терминала или запросе остановки. Это не надёжная очередь сообщений и не замена IPC. У сигнала есть номер, действие по умолчанию и состояние доставки; обычные (standard) сигналы могут сливаться в один pending-сигнал, поэтому в обработчик не приходит счётчик одинаковых событий.

Зачем это на интервью

Сигналы проверяют понимание жизненного цикла процесса в Linux и production-шutdown. Интервьюер может спросить, почему контейнер не завершился за grace period, чем SIGTERM отличается от SIGKILL, как корректно остановить Go HTTP-сервер и почему нельзя делать сложную работу в C signal handler. Хороший ответ связывает модель ядра, supervisor (systemd/Kubernetes) и контекст отмены приложения.

Минимум для E4

  • Различать SIGTERM (вежливая просьба завершиться), SIGINT (обычно Ctrl-C), SIGKILL (немедленное неперехватываемое завершение) и SIGSTOP (неперехватываемая остановка).
  • Знать, что SIGKILL и SIGSTOP нельзя поймать, заблокировать или проигнорировать.
  • Понимать, что default action может завершить процесс, создать core dump, остановить, продолжить или игнорировать его.
  • Запускать graceful shutdown по SIGTERM: перестать принимать новую работу, отменить фоновые операции, дождаться ограниченное время и закрыть ресурсы.
  • Не выполнять в async signal handler небезопасные операции: выделение памяти, printf, mutex и большинство библиотечных вызовов.

В оркестрируемом сервисе последовательность обычно такова: supervisor посылает SIGTERM, ждёт заданный deadline, затем применяет SIGKILL. SIGKILL не даёт приложению сделать cleanup, поэтому корректность должна опираться на атомарность, идемпотентность и внешние гарантии, а не только на финальный обработчик.

Углубление для E5/Senior

Генерация, pending и доставка

Сигнал можно направить процессу (kill(pid, sig)), process group, конкретному потоку (pthread_kill, Linux tgkill) или получить от ядра. Если сигнал заблокирован mask-ой, он становится pending до разблокирования. У process-directed сигнала ядро выберет подходящий поток, который не блокирует этот сигнал; thread-directed сигнал доставляется адресному потоку. В многопоточном процессе маска сигналов — свойство потока, а disposition (ignore/default/handler) — свойство процесса.

Для обычных сигналов ядро обычно хранит факт pending, а не все повторения. Десять SIGTERM, пришедших во время блокировки, могут превратиться в одну доставку. Realtime-сигналы (SIGRTMIN…SIGRTMAX) ставятся в очередь в порядке приоритета и могут нести sigqueue value, но имеют системные ограничения и редко нужны прикладному серверу.

Сигнал может прервать блокирующий системный вызов, вернув EINTR. С sigaction флаг SA_RESTART просит ядро автоматически перезапускать часть вызовов, но не все и не во всех условиях. Нельзя писать логику как «все вызовы всегда перезапускаются»: контракт конкретного syscall важнее. При уже прочитанных байтах следует обработать результат, а не безусловно повторять операцию. Связь прерываний с I/O разобрана в файловых дескрипторах.

Почему обработчик должен быть минимальным

Во время signal handler процесс может быть прерван посреди malloc, printf или захваченного mutex. Повторный вход в эти функции способен повредить состояние или навсегда заблокироваться. POSIX разрешает в async handler только небольшой набор async-signal-safe функций; на практике самый надёжный C-паттерн — установить volatile sig_atomic_t флаг либо записать байт в заранее созданный pipe, а полную работу оставить основному event loop.

Linux предоставляет альтернативы, которые упрощают серверный код:

  • signalfd превращает доставку сигналов в читаемый FD; нужные сигналы надо заблокировать во всех потоках и читать из event loop.
  • self-pipe trick будит poll/select записью из handler, но pipe должен быть non-blocking, а переполнение обработано.
  • pidfd_send_signal снижает риск послать сигнал уже переиспользованному PID при управлении дочерними процессами.

Выбор зависит от среды. signalfd — Linux-specific, обычный sigaction переносимее POSIX. Для маленького Go-сервиса предпочтительнее os/signal и context, а не raw handler.

Process group, session и контейнеры

Терминал отправляет Ctrl-C как SIGINT foreground process group, а не обязательно одному процессу. Shell применяет job control через process groups и SIGTSTP/SIGCONT. SIGHUP исторически означал потерю controlling terminal; daemon-ы часто используют его для reload, но это лишь соглашение, которое нужно документировать и тестировать.

PID 1 имеет особое значение в namespace контейнера: его обработка сигналов и reaping дочерних процессов влияют на весь контейнер. Если shell-скрипт является entrypoint и не делает exec приложения, сигнал может остаться у shell, а дочерний сервис не получит его. Entrypoint должен передавать сигналы и пожинать потомков либо заменяться приложением через exec; для нескольких процессов используют init/supervisor. Нельзя предполагать, что Kubernetes или systemd сами исправят неверную process tree.

Reload, observability и контракт остановки

SIGHUP для reload конфигурации безопасен только при транзакционном подходе: прочитать и валидировать новую конфигурацию отдельно, собрать новый immutable snapshot, затем атомарно заменить ссылку. Не мутируйте глобальную конфигурацию по частям из signal handler. SIGUSR1/SIGUSR2 оставляют приложению семантику, но зависят от документации команды; для административного API часто лучше аутентифицированный HTTP/RPC endpoint с аудитом.

Shutdown должен иметь бюджет: прекратить ingress, объявить readiness false, закрыть listener, дождаться in-flight запросов, остановить consumers и flush логов в пределах deadline. Метрики и логи фиксируют момент получения сигнала, число активных запросов, превышение deadline и причину forced termination. Это связывает сигналы с проектированием Go-приложений и Linux, сетями и cloud.

Ключевые понятия

Важные сигналы

СигналОбычное назначениеМожно обработать?Практическая семантика
SIGINTпрерывание с терминаладалокально остановить процесс; в сервисе обрабатывать как shutdown
SIGTERMзапрос завершениядаштатное завершение от supervisor-а
SIGQUITвыход с диагностикойдаdefault обычно core dump; не путать с SIGTERM
SIGHUPhangup/reload по соглашениюдатолько явно реализованный reload
SIGCHLDизменилось состояние потомкадавызвать wait/waitpid, чтобы не копились zombie
SIGPIPEзапись в закрытый pipe/socketдаdefault завершает процесс; часто игнорируют и обрабатывают EPIPE
SIGKILLбезусловно завершитьнетпоследняя мера, cleanup не гарантирован
SIGSTOP / SIGCONTостановить / продолжитьSTOP — нет, CONT — даjob control и управление процессами

SIGPIPE — частая неожиданность в Unix-программах: запись в peer, который уже закрыл соединение, может одновременно дать EPIPE и вызвать сигнал. Политика зависит от runtime: Go для сетевых соединений обычно возвращает ошибку записи вместо внезапного завершения, но прикладной код всё равно обязан обработать эту ошибку и отмену контекста.

Go: один сигнал — одна причина отмены

func run(ctx context.Context) error {
    srv := &http.Server{Addr: ":8080", Handler: handler()}
    errCh := make(chan error, 1)
 
    go func() {
        errCh <- srv.ListenAndServe()
    }()
 
    select {
    case err := <-errCh:
        if !errors.Is(err, http.ErrServerClosed) {
            return err
        }
        return nil
    case <-ctx.Done():
        timeoutCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
        defer cancel()
        return srv.Shutdown(timeoutCtx)
    }
}
 
func main() {
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()
    if err := run(ctx); err != nil {
        log.Fatal(err)
    }
}

signal.NotifyContext отменяет context при первом указанном сигнале. Вызов stop важен: он освобождает регистрацию и возвращает последующие сигналы к обычной семантике. Нередко полезно после начала shutdown вызвать stop, чтобы второй Ctrl-C завершил процесс без ожидания; при этом политику forced exit нужно явно решить и покрыть тестами. Не вызывайте os.Exit из goroutine shutdown: deferred cleanup не выполнится, а main не сможет вернуть ошибку.

Для дочерней команды exec.CommandContext отменяет context, но конкретные последствия зависят от платформы и процесса. Если команда создаёт потомков, может потребоваться управление process group и отдельная стратегия остановки; отправка сигнала только родителю не обязательно останавливает всё дерево.

Типовые вопросы

  1. Почему SIGTERM не гарантирует завершение процесса?
    • Его можно обработать, заблокировать или проигнорировать; процесс может зависнуть. Supervisor ограничивает ожидание и затем применяет SIGKILL, который гарантирует прекращение, но не cleanup.
  2. Почему SIGKILL нельзя использовать для normal shutdown?
    • Его нельзя перехватить: не выполнятся обработчики, defer, закрытие listener-а и flush. Данные и внешние операции должны переживать его без опоры на финализацию.
  3. Что происходит, если несколько SIGTERM пришли быстро?
    • Для standard signal они могут coalesce в одно pending-состояние. Нельзя строить протокол «второй сигнал всегда будет получен» без явного канала/политики.
  4. Почему в C нельзя логировать из signal handler?
    • printf и логгер обычно не async-signal-safe, могут использовать lock или allocator в момент, когда прерванный поток уже держит их. Handler ставит флаг/пишет в pipe, логирование делает нормальный поток.
  5. Как Go HTTP-сервис должен реагировать на SIGTERM?
    • Отменить root context, остановить ingress/закрыть listener, через Server.Shutdown дождаться активных запросов до deadline, завершить workers и вернуть результат из main.
  6. Чем process-directed сигнал отличается от thread-directed?
    • Первый адресован процессу и доставляется одному подходящему потоку, второй — указанному потоку. Маска сигналов у каждого потока своя, disposition общая для процесса.
  7. Когда выбрать signalfd?
    • В Linux event loop, который уже мультиплексирует FD и может централизованно блокировать сигналы во всех потоках. Для переносимого или обычного Go-кода он часто излишен.

Практика

  • Проверьте graceful shutdown. Запустите Go HTTP-сервис с обработчиком, который ждёт несколько секунд, отправьте запрос и затем SIGTERM. Критерий готовности: новый запрос после начала shutdown не принимается, начатый либо завершается до deadline, либо логируется как отменённый, а процесс возвращает контролируемый код.
  • Наблюдайте default actions. В отдельном тестовом процессе отправьте SIGTERM, SIGINT, SIGSTOP/SIGCONT и SIGKILL. Критерий готовности: таблица результатов содержит состояние процесса и объясняет, почему два последних сигнала нельзя перехватить одинаково.
  • Разберите EINTR. Напишите C или Go+syscall эксперимент с блокирующим вызовом и доставкой сигнала, затем изучите trace через strace. Критерий готовности: зафиксирован прерванный или перезапущенный вызов и указано, какая настройка/обёртка определила поведение.
  • Проверьте process tree. Создайте entrypoint-скрипт, сначала без exec, затем с exec, и пошлите сигнал родительскому PID. Критерий готовности: показана разница PID/process group и сделан вывод, какой вариант пригоден для контейнера.

Частые ошибки и ловушки

  • Называть сигнал «сообщением» и ожидать, что каждый повтор будет доставлен.
  • Считать SIGTERM и SIGKILL взаимозаменяемыми или ожидать cleanup после SIGKILL.
  • Делать reload, сетевые запросы или захват mutex прямо в native handler.
  • Не вызывать wait для завершившихся дочерних процессов и оставлять zombie.
  • Делать shell PID 1 родителем приложения без exec или корректной signal forwarding/reaping логики.
  • Завершать Go-процесс через os.Exit в shutdown-goroutine и случайно пропускать defer.
  • Полагаться только на сигнал для консистентности: любой процесс может быть убит или аварийно завершён в любой точке.

Связанные темы

База разработки и Computer Science · Процессы, потоки и goroutine · Файловые дескрипторы и stdio · Системные вызовы · Go: runtime и стандартная библиотека · Проектирование Go-приложений · Linux, сети и cloud

Источники

  • POSIX.1-2024: sigaction, sigprocmask, kill, signal, wait, fork и список async-signal-safe функций.
  • Linux man-pages: signal(7), signal-safety(7), sigaction(2), signalfd(2), pidfd_send_signal(2), credentials(7).
  • The Linux Programming Interface, главы о сигналах, process groups, sessions и daemon-процессах.
  • Документация Go: пакеты os/signal, context, net/http, os/exec.