Каждый администратор 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 означает отсутствие управляющего терминала, важнее, чем кажется при первом знакомстве: именно эти числа объясняют поведение, которое иначе выглядит как магия или непредсказуемость ядра.