Каждый администратор Linux знает про PID и PPID. Большинство знают, что Ctrl+C отправляет сигнал не только текущему процессу, но и всему конвейеру в терминале. Немногие могут объяснить, почему именно так происходит и какой механизм за этим стоит. Ответ лежит в трёхуровневой иерархии: процесс принадлежит группе процессов, группа принадлежит сессии, сессия привязана к управляющему терминалу. Эти три уровня существуют в ядре с 1980-х годов, работают в каждой Linux-системе прямо сейчас и управляют тем, как сигналы распространяются между процессами, как shell реализует job control и почему daemon должен делать двойной fork.
Понимать эту иерархию нужно не ради академической полноты, а потому что без неё невозможно толком разобраться в поведении shell-скриптов при завершении терминала, объяснить, почему kill -9 $PID иногда оставляет зомби, или написать корректный daemon, который не умирает вместе с терминалом родителя.
Три числа, которые несёт каждый процесс
Помимо привычных PID и PPID, каждый процесс в Linux хранит ещё три числа: PGID (process group ID), SID (session ID) и TPGID (foreground process group ID управляющего терминала). Посмотреть их можно через ps или напрямую через procfs:
# Подробная картина текущего shell и его дочерних процессов
ps -Ho pid,ppid,pgid,sid,tpgid,comm
# Или напрямую из procfs для текущего shell
cat /proc/$$/stat | cut -d ' ' -f 1,4,5,6,7,8 | \
tr ' ' '\n' | paste <(echo -e "pid\nppid\npgid\nsid\ntty\ntpgid") -
# Пример вывода для bash-сессии:
# pid 8415
# ppid 8414
# pgid 8415 <- bash является лидером своей группы
# sid 8415 <- bash является лидером сессии
# tty 34816
# tpgid 9348 <- PID текущей foreground group
Три ключевых правила, которые определяют смысл этих чисел. Первое: если PGID процесса совпадает с его PID, процесс является лидером группы. Второе: если SID процесса совпадает с его PID, процесс является лидером сессии. Третье: лидер группы и лидер сессии не обязаны совпадать, и обычно не совпадают. Когда вы видите bash с PID=SID=PGID, перед вами одновременно лидер группы и лидер сессии. Когда запускаете команду, она получает новую группу, но остаётся в той же сессии.
Диапазон этих отношений хорошо виден в реальном примере с конвейером:
# Запустим конвейер в фоне и посмотрим на структуру
cat /dev/urandom | od -A x | head -n 1000 > /dev/null &
# Теперь смотрим на группы
ps -Ho pid,ppid,pgid,sid,comm
# PID PPID PGID SID COMMAND
# 1684 3057 1684 1684 bash
# 17390 1684 17390 1684 cat <- лидер группы конвейера
# 17391 1684 17390 1684 od <- в той же группе
# 17392 1684 17390 1684 head <- в той же группе
Все три процесса конвейера имеют одинаковый PGID, равный PID первого процесса в конвейере. Bash создал для них отдельную группу именно для того, чтобы можно было отправить сигнал всему конвейеру одним вызовом, а не гоняться за каждым PID по отдельности.
Как сигналы путешествуют через группы и почему Ctrl+C убивает весь конвейер
Управляющий терминал хранит одно число: PGID текущей foreground-группы. Когда ядро получает символ прерывания (Ctrl+C генерирует SIGINT, Ctrl+Z генерирует SIGTSTP, Ctrl+\ генерирует SIGQUIT), оно не ищет конкретный PID. Оно берёт PGID foreground-группы и отправляет сигнал всем процессам с этим PGID через один системный вызов. Это объясняет, почему Ctrl+C гарантированно завершает весь конвейер, а не только первый процесс в нём.
Механизм управления foreground-группой живёт в двух системных вызовах: tcgetpgrp() возвращает текущую foreground-группу терминала, tcsetpgrp() устанавливает новую. Bash вызывает tcsetpgrp() каждый раз, когда переключает задачу между foreground и background, и именно это стоит за командами fg и bg:
# Запустить долгую задачу
sleep 300 &
# [1] 12345
# Переключить в foreground (bash вызывает tcsetpgrp с PGID задачи)
fg %1
# Отправить в background через Ctrl+Z, затем bg
# Ctrl+Z отправляет SIGTSTP всей foreground-группе
# bash видит что группа остановлена, возвращает foreground себе
# bg отправляет SIGCONT группе и снимает флаг foreground
# Посмотреть все job'ы текущей сессии
jobs -l
# [1]+ Running sleep 300 &
# Убить всю группу по PGID (отрицательный PID в kill = PGID)
kill -TERM -12345
Фоновые процессы, пытающиеся читать с терминала, получают SIGTTIN и автоматически останавливаются. Это не ошибка, а намеренный механизм: только одна группа в каждый момент может читать с терминала, иначе несколько процессов начали бы конкурировать за ввод и перемежать его произвольным образом. Запись на терминал из фонового процесса по умолчанию разрешена, хотя её можно запретить флагом TOSTOP через stty tostop.
setpgid и setsid как системные вызовы, которые строят иерархию
Создание групп и сессий управляется двумя системными вызовами. setpgid(pid, pgid) перемещает процесс в группу, setsid() создаёт новую сессию. У обоих есть ограничения, которые соблюдать обязательно, иначе получится EPERM.
setpgid() позволяет процессу изменить свою группу или группу своего дочернего процесса, который ещё не вызвал execve(). Нельзя переместить процесс в группу другой сессии. Нельзя изменить PGID лидера сессии. Именно этим правилам подчиняется bash при создании конвейеров: он вызывает setpgid() для каждого дочернего процесса сразу после fork(), до того как дочерний процесс вызовет execve() с целевой программой:
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
int main(void) {
pid_t child = fork();
if (child == 0) {
/* Дочерний процесс: создать новую группу со своим PID как PGID */
if (setpgid(0, 0) < 0) {
perror("setpgid");
return 1;
}
printf("Child PID=%d, PGID=%d\n", getpid(), getpgrp());
/* exec здесь... */
_exit(0);
}
/* Родитель тоже вызывает setpgid для child: race condition prevention */
/* Оба вызова идемпотентны — кто успеет первым, тот и установит PGID */
setpgid(child, child);
printf("Parent PID=%d, child PGID=%d\n", getpid(), child);
waitpid(child, NULL, 0);
return 0;
}
Двойной вызов setpgid(), и из родителя, и из дочернего процесса, это не ошибка, а стандартная практика для устранения состояния гонки. После fork() ядро планирует родителя и дочернего произвольно, и нет гарантии, кто из них выполнится первым. Оба вызова setpgid(child, child) безопасны: второй просто ничего не меняет.
setsid() создаёт новую сессию: вызывающий процесс становится её лидером, одновременно становится лидером новой группы с PGID=PID, и теряет управляющий терминал. Единственное ограничение: вызывать setsid() нельзя из лидера группы. Именно поэтому канонический паттерн для daemonization начинается с fork() и затем вызывает setsid() из дочернего процесса:
#include <unistd.h>
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
int daemonize(void) {
pid_t pid;
/* Первый fork: родитель завершается, дочерний гарантированно
не является лидером группы и может вызвать setsid() */
pid = fork();
if (pid < 0) return -1;
if (pid > 0) _exit(0); /* родитель выходит */
/* Создаём новую сессию без управляющего терминала */
if (setsid() < 0) return -1;
/* Второй fork: предотвращает повторное получение терминала.
Лидер сессии при открытии tty-устройства может получить
управляющий терминал автоматически. Дочерний лидером не будет. */
pid = fork();
if (pid < 0) return -1;
if (pid > 0) _exit(0); /* первый дочерний выходит */
/* Сменить рабочий каталог чтобы не блокировать unmount */
chdir("/");
/* Установить umask явно */
umask(0);
/* Перенаправить стандартные дескрипторы на /dev/null */
int fd = open("/dev/null", O_RDWR);
dup2(fd, STDIN_FILENO);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
if (fd > 2) close(fd);
return 0;
}
Второй fork() здесь не обязательный, а защитный. Лидер сессии при открытии любого tty-устройства без флага O_NOCTTY автоматически получает его как управляющий терминал. Второй дочерний процесс лидером сессии не является и получить терминал не может, что важно для daemon, который принципиально не должен быть привязан ни к какому терминалу.
Сиротские группы и SIGHUP при закрытии терминала
Закрытие терминала запускает цепочку событий, которую полезно понимать детально. Ядро отправляет SIGHUP лидеру сессии. Если лидер сессии завершается, SIGHUP получает foreground-группа. Процессы в background-группах SIGHUP не получают автоматически. Это объясняет классическое поведение: закрыли терминал, foreground-задача умерла, а фоновые продолжают работать.
Но есть отдельный механизм для осиротевших групп. Группа считается осиротевшей (orphaned process group), если для каждого её члена выполняется одно из двух: либо его родитель тоже в этой группе, либо его родитель в другой сессии. Проще говоря, ни один внешний процесс не наблюдает за группой через waitpid(). Если при создании осиротевшей группы в ней есть остановленные процессы, ядро отправляет им SIGHUP, а следом SIGCONT. Это не случайность, а принципиальное решение: остановленный процесс в осиротевшей группе иначе никогда не получит SIGCONT и навсегда зависнет в состоянии T.
# Наблюдение за сессиями в реальном времени
# Показать все процессы с их SID, отсортировать по сессии
ps axo pid,ppid,pgid,sid,stat,comm | sort -k4 -n | head -40
# Найти все процессы конкретной сессии (например сессии 8415)
ps axo pid,pgid,sid,comm | awk '$3 == 8415'
# Убить всю сессию через kill по отрицательному SID невозможно напрямую,
# но можно убить все группы внутри сессии
ps axo pgid,sid | awk '$2 == 8415 {print $1}' | sort -u | \
xargs -I{} kill -TERM -{}
Инструмент setsid из командной строки позволяет запустить процесс в новой сессии без программирования:
# Запустить процесс в новой сессии, отвязанной от текущего терминала
setsid long_running_job &
# С явным перенаправлением вывода
setsid bash -c 'long_running_job > /var/log/job.log 2>&1' &
# Проверить что процесс в своей сессии
ps -o pid,pgid,sid,comm -p $(pgrep long_running_job)
Как это использует systemd и что означает KillMode
Systemd строит поверх сессий свою систему управления процессами через cgroups, но механизм групп процессов остаётся фундаментом. Каждый юнит-сервис по умолчанию запускается в своей сессии: setsid() вызывается при старте, и все дочерние процессы сервиса получают тот же SID. Это позволяет systemd корректно завершить весь сервис, включая процессы, которые перефorkались и изменили свой PGID.
Параметр KillMode в unit-файле управляет тем, как именно systemd отправляет сигналы при остановке:
[Service]
# control-group (по умолчанию): убить все процессы в cgroup сервиса
# process: убить только основной процесс (ExecStart)
# mixed: SIGTERM основному процессу, SIGKILL всей cgroup через KillSignal timeout
# none: не отправлять никаких сигналов (только для ExecStop)
KillMode=control-group
KillSignal=SIGTERM
TimeoutStopSec=30
Разница между KillMode=process и KillMode=control-group критична для сервисов, которые форкают дочерние процессы. process отправит SIGTERM только ExecStart-процессу, остальные продолжат работу. control-group накрывает всю cgroup целиком, и ни один дочерний процесс не выживет независимо от того, как он изменил свои PGID и SID.
# Посмотреть cgroup сервиса и все процессы в ней
systemctl status nginx.service
# Или напрямую через cgroup
cat /sys/fs/cgroup/system.slice/nginx.service/cgroup.procs
# Проверить SID главного процесса сервиса
PID=$(systemctl show nginx --property=MainPID --value)
cat /proc/$PID/stat | cut -d ' ' -f 1,4,5,6,7,8 | \
tr ' ' '\n' | paste <(echo -e "pid\nppid\npgid\nsid\ntty\ntpgid") -
# tty 0 <- нет управляющего терминала
# tpgid -1 <- нет foreground group
Нулевой tty и tpgid=-1 подтверждают: сервис живёт в своей сессии без управляющего терминала, именно так, как и должен вести себя правильно написанный daemon.
Orphaned group на практике и отладка через procfs
Понять, почему конкретный процесс не получает SIGHUP при закрытии терминала или наоборот, почему умирает раньше времени, можно через прямое чтение procfs:
# Полная картина для процесса с PID 1234
cat /proc/1234/status | grep -E "Pid|PPid|NSpid"
cat /proc/1234/stat | awk '{printf "pid=%s ppid=%s pgid=%s sid=%s tty=%s tpgid=%s\n",$1,$4,$5,$6,$7,$8}'
# Узнать управляющий терминал процесса
ls -la /proc/1234/fd/0 # stdin указывает на tty если есть
# Проверить является ли процесс лидером сессии
PID=1234
SID=$(cat /proc/$PID/stat | awk '{print $6}')
if [ "$PID" = "$SID" ]; then
echo "Процесс $PID является лидером сессии"
else
echo "Лидер сессии: $SID"
fi
# Найти все процессы без управляющего терминала (потенциальные daemons)
ps axo pid,tty,sid,comm | awk '$2 == "?" {print}'
Трёхуровневая иерархия процесс-группа-сессия выглядит как деталь реализации POSIX, но на практике это архитектурный фундамент, на котором стоит job control в каждом shell, корректное завершение сервисов в systemd и устойчивость daemon'ов к закрытию терминала. Знать, что SID лидера сессии совпадает с его PID, а tpgid=-1 означает отсутствие управляющего терминала, важнее, чем кажется при первом знакомстве: именно эти числа объясняют поведение, которое иначе выглядит как магия или непредсказуемость ядра.