28. Сигналы в Linux
Что такое сигналы и зачем они нужны бэкенду
Сигналы (Signals) — это асинхронные программные прерывания, которые ядро ОС отправляет процессу для уведомления о событиях: запрос на завершение, доступ к запрещённой памяти, разрыв канала связи, изменение состояния дочернего процесса и т.д.
"Системный вызов — это когда ваша программа звонит в ядро и просит об услуге. Сигнал — это когда пожарная сирена ядра неожиданно воет прямо у вас над ухом, требуя немедленной реакции."
В отличие от системных вызовов (которые синхронны и инициируются программой явно), сигналы доставляются непредсказуемо во времени. Для Go-разработчика это означает, что обработка сигналов — это не просто «повесить хук», а управление состоянием приложения в условиях гонки данных и прерывания потока исполнения.
Зачем это бэкенду:
- Graceful Shutdown: Корректное завершение работы сервиса (закрытие соединений, drain очереди, запись логов) при получении
SIGTERM. - Межпроцессное взаимодействие: Управление демоном, перезагрузка конфигурации (
SIGHUP), отладка (SIGUSR1). - Обработка ошибок рантайма: Перехват
SIGSEGV(падение),SIGPIPE(запись в закрытый сокет),SIGFPE(деление на ноль).
Механизм доставки: ядро и user space
Чтобы понять, как сигнал достигает вашей программы, нужно заглянуть в состояние потока выполнения (Thread Control Block, TCB) в ядре Linux.
Каждый тред ОС имеет два bitmap-поля в ядре:
sigpending— битовая маска ожидания. Устанавливается, когда сигнал отправлен, но ещё не доставлен.sigmask— битовая маска блокировки. Сигналы, установленные здесь, игнорируются до момента очистки маски.
Когда другой процесс или пользователь вызывает kill(pid, SIGTERM), ядро:
- Ищет целевой тред по PID/TID.
- Проверяет права доступа (UID/GID, capabilities).
- Устанавливает соответствующий бит в
sigpending. - Если тред заблокирован (
sigmask), сигнал остаётся вpending. - Если тред выполняется, ядро не прерывает его мгновенно. Сигнал доставляется только при переходе из Kernel Space обратно в User Space:
- Возврат из системного вызова.
- Разрешение
Page Fault. - Срабатывание таймера (preemption).
В момент доставки ядро:
- Сохраняет регистры CPU и текущий указатель стека.
- Подготавливает Signal Frame на вершине пользовательского стека.
- Меняет
RIP(Instruction Pointer) на адрес обработчика сигнала. - После выполнения обработчика вызывается инструкция
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-хук. Вместо этого:
- Go runtime устанавливает собственные обработчики сигналов через
rt_sigaction(на практике таких вызовов при старте десятки), аrt_sigprocmaskиспользует для управления масками потоков. - Когда сигнал приходит, ядро вызывает обработчик рантайма, и тот кладёт номер сигнала во внутреннюю очередь (
sigsend).sigwaitinfo(то естьrt_sigtimedwait) Go для этого не использует. - Служебная горутина
os/signalзабирает сигналы из этой очереди (signal_recv) и засыпает на примитиве рантайма, а не в системном вызове. - Пакет
os/signalкладёт сигнал в channel каждого подписчика. - Ожидающая горутина разблокируется.
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+) — современный идиоматичный способ. Он создаёт контекст, который отменяется при получении сигнала. Внутренне использует тот же механизм, но привязывает отмену кcontextlifecycle, что идеально ложится на dependency injection и middleware.
Ловушки и каверзные вопросы с собеседований
1. SIGPIPE: Тихий убийца
По умолчанию запись в закрытый pipe или сокет вызывает SIGPIPE, что завершает процесс. В Go действует более тонкое правило (см. документацию os/signal): если запись в закрытый pipe или сокет происходит на файловых дескрипторах 1 и 2 (stdout/stderr), программа завершается по SIGPIPE; на всех остальных дескрипторах запись просто возвращает ошибку EPIPE. Так что падение возможно и без raw socket или CGO — например, при myapp | head.
// Явное игнорирование для чистоты эксперимента или специфичных случаев
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
Классическая ошибка на собеседованиях:
// ПЛОХОЙ ПРИМЕР
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. |
Итоги
- Сигналы — асинхронные события уровня ОС, доставляемые через
sigpendingиsigmask. - Доставка происходит при переходе из Kernel в User Space, с созданием
Signal Frameв стеке. - Go runtime берёт на себя всю работу с
rt_sigactionиrt_sigprocmask, предоставляя безопасный channel-based API (os/signal). SIGKILLнельзя перехватить.SIGTERM— стандарт для graceful shutdown.- Слишком маленький буфер канала
os/signalможет привести к потере сигналов в пиковых нагрузках (пакет не блокируется при отправке; размер буфера задаёте вы). Используйтеsignal.NotifyContextдля интеграции с lifecycle. - Понимание сигналов критично для работы с K8s, Docker, отладки падений и проектирования отказоустойчивых сервисов.
Мы разобрали, как процесс реагирует на внешние события. Но как сам процесс становится фоновым сервисом, который живёт годами, перезапускается и управляется? В следующей статье мы перейдём к теме: 29. Демоны и фоновые процессы.