Learning Platform
Глоссарий Troubleshooting
Урок 04.03 · 22 мин
Начальный
LifecycleStateZombieOrphanwaitexit

Lifecycle процесса — ready, running, blocked, zombie

В предыдущем уроке мы поняли, как процессы создаются (fork + exec). Теперь — как они живут и умирают. Процесс не просто «работает» — он постоянно перемещается между состояниями: то выполняется, то ждёт I/O, то остановлен, то ждёт, пока родитель его «похоронит».

В этом уроке: жизненный цикл процесса, что значит каждое состояние, как работают wait() и exit(), что такое zombie и orphan, и почему kill -9 иногда не убивает процесс.


Базовая модель: пять состояний

В классической теории ОС описывают пять состояний процесса:

Жизненный цикл процесса -- классическая модель
CreatedПроцесс только что создан через fork+exec. Структура в ядре есть, но процесс ещё не готов к выполнению. Очень короткое состояние
ReadyПроцесс готов выполняться. Стоит в очереди run queue. Все ресурсы есть, только CPU не дали. Это часто 'R' (runnable) в Linux
dispatch
RunningПроцесс реально выполняется на CPU прямо сейчас. На многоядерной системе одновременно столько Running, сколько CPU. Тоже 'R' в Linux
BlockedПроцесс ждёт чего-то: I/O, lock, сигнал, futex. Не занимает CPU. В Linux это 'S' (interruptible) или 'D' (uninterruptible)
TerminatedПроцесс завершился. Структура в ядре будет удалена после wait() от родителя. До этого -- zombie ('Z' в Linux)

Переходы:

  • Created -> Ready — процесс готов к выполнению, ставится в run queue.
  • Ready -> Running — scheduler выбрал процесс, дал ему CPU.
  • Running -> Ready — истёк time slice, scheduler переключился на другой процесс.
  • Running -> Blocked — процесс сделал syscall, который блокируется (read с пустого pipe, accept, sleep).
  • Blocked -> Ready — то, что процесс ждал, произошло (данные пришли, sleep закончился).
  • Running -> Terminated — процесс вызвал exit() или получил killing signal.

Это универсальная модель, работает во всех ОС. Linux добавляет дополнительные состояния для тонкостей — разберём ниже.


Состояния в Linux: расширенная модель

В Linux вы видите состояния как буквы в ps:

ps -eo pid,stat,cmd | head -10
Linux process states -- что видит ps
R runningRunning или Runnable. Процесс выполняется на CPU или готов. ps не различает 'на CPU сейчас' и 'в очереди run queue'
S sleepInterruptible sleep. Ждёт события, может быть разбужен сигналом. Большинство ваших sleeping процессов. Самое частое состояние
D iowaitUninterruptible sleep. Ждёт I/O от диска или другого устройства. Сигналом не разбудить. Долгий D -- симптом проблемы (медленный диск)
T stoppedОстановлен сигналом SIGSTOP или SIGTSTP (Ctrl-Z). Не выполняется до SIGCONT. Используется shell job control
t tracedTrace stop -- остановлен под отладкой (gdb, strace). Аналог T для трассировки
Z zombieПроцесс завершился, ждёт родителя. Память освобождена, но структура в ядре сохраняется для wait()
X deadПроцесс полностью удалён. ps никогда не покажет X -- к моменту опроса процесса уже нет
I idleIdle. Только для kernel threads, которые не делают работу. Не путать с idle CPU
+Дополнительный флаг: процесс в foreground process group. Связано с управлением терминалом

Дополнительные флаги в выводе ps (после основного state):

  • + — foreground group
  • s — session leader
  • l — multi-threaded
  • < — high priority (negative nice)
  • N — low priority (positive nice)
  • L — locked pages в memory (mlock)

Например, Ss+ — session leader, sleeping, foreground.


Состояние D: uninterruptible sleep

Состояние D (uninterruptible sleep) заслуживает отдельного разговора. Когда процесс в D:

  • Он ждёт что-то от kernel space (обычно I/O от диска).
  • Его нельзя прервать сигналом. Даже SIGKILL не убьёт.
  • Это длится до тех пор, пока I/O не завершится.

Почему так? Потому что некоторые операции должны завершиться атомарно. Например, ядро может удерживать lock на inode и делать blocking read с диска. Если бы сигнал прервал процесс в этом месте — inode lock остался бы захвачен, и весь fs может зависнуть.

Обычно D длится миллисекунды (диск быстро отвечает). Если процесс висит в D несколько секунд или минут — это симптом проблемы:

  • Медленный диск. SSD в режиме degraded, HDD умирает, fsync на сильно загруженный том.
  • Сетевой mount. NFS-том, который перестал отвечать. Все читающие из него процессы висят в D.
  • Контроллер диска подвис. Аппаратные проблемы.
# Найти все D процессы:
ps -eo stat,pid,cmd | awk '$1 ~ /^D/'

# Или через top: красные строки с D в столбце S
top -bn1 | awk '$8=="D" {print}'

# Что процесс ждёт (на каком kernel syscall):
cat /proc/<PID>/wchan
# Например: __vfs_read, ext4_sync_file, sock_recvmsg

# Stack trace в ядре (требует root):
sudo cat /proc/<PID>/stack
# Видно цепочку kernel-функций. Полезно для диагностики глубоких проблем

kill -9 на D-процесс не работает. После завершения I/O процесс выйдет из D, ядро тогда доставит pending SIGKILL и процесс умрёт. Если I/O никогда не завершается — единственный выход reboot.


exit(): как процесс завершается

Когда процесс завершает работу, он вызывает exit() (или просто возвращает из main, что эквивалентно). Что происходит:

  1. Закрытие открытых файлов. Все fd закрываются через close(). Это flush-ит буферы.
  2. Освобождение ресурсов. Память возвращается ядру, mmap’ы отменяются.
  3. Отправка SIGCHLD родителю. Ядро уведомляет родительский процесс, что ребёнок умер.
  4. Сохранение exit status. Код возврата (обычно 0 = успех, 1-255 = ошибка) сохраняется в task_struct.
  5. Переход в zombie состояние. Процесс больше не выполняется, но запись в таблице процессов остаётся, пока родитель не сделает wait().

Что НЕ освобождается до wait:

  • PID процесса.
  • Запись в /proc/[pid]/.
  • Exit status.
  • Resource usage statistics.

Это нужно, чтобы родитель мог узнать, как умер его ребёнок (успех/ошибка, какой именно код).

# Exit code последней команды:
ls /nonexistent
echo $?
# Возможно 2 (No such file or directory)

# Сделать процесс с конкретным exit:
bash -c 'exit 42'
echo $?
# 42

wait(): родитель забирает мёртвого ребёнка

Когда ребёнок умирает, родитель должен сделать wait() (или одну из его вариаций: waitpid(), wait4(), waitid()). Это:

  1. Блокирует родителя, пока какой-то ребёнок не умрёт (если использовать wait()).
  2. Возвращает PID и exit status умершего ребёнка.
  3. Освобождает структуру ребёнка в ядре. Запись из /proc/[pid]/ исчезает. PID может быть переиспользован.

Если родитель не делает wait, ребёнок остаётся zombie — мёртвый, но не до конца. Память освобождена, CPU не использует, но запись в таблице процессов есть.

Жизненный цикл -- что делают exit и wait

В простом случае это автоматизировано: shell делает wait после exec ребёнка. Сам процесс ждать не должен — shell делает всё. Поэтому нормальный ls — никаких zombies не оставляет.


Zombie: процесс-зомби

Zombie появляется, когда:

  1. Ребёнок завершился (exit()).
  2. Родитель ещё жив.
  3. Родитель ещё не сделал wait().

Запись в таблице процессов есть, но процесс не выполняется. Это не баг ядра — это by design: ядро держит запись, чтобы родитель мог узнать exit code.

Как выглядит zombie в ps:

ps -eo pid,stat,cmd | awk '$2 ~ /^Z/'
# Что-то вроде:
# 12345 Z+   [some_program] <defunct>

Слово <defunct> означает: процесс уже не работает, его командная строка стёрта, только структура.

# Сколько zombies сейчас:
ps -e | grep ' Z ' | wc -l

# Иногда zombies нормальны (родитель скоро сделает wait), иногда -- баг

Что плохого в zombie

Сигналы и kill: как правильно убивать процессы

Если zombies накапливаются (родитель никогда не делает wait):

  • PID space занят. Linux может иметь максимум pid_max процессов (обычно 32768 или 4194304). Если zombies занимают много — однажды не сможете запустить новый процесс.
  • Запись в /proc. Каждый zombie — килобайты в ядре, не страшно, но захламляет.

Память zombie освобождена — они не едят RAM. Это часто заблуждение.

Как убить zombie

zombie уже мёртв — kill -9 не действует (нечего убивать). Чтобы избавиться от zombie:

  1. Заставить родителя сделать wait. Послать ему сигнал, чтобы он обработал SIGCHLD.
  2. Убить родителя. Тогда zombie станет orphan, и init/systemd сделает wait автоматически.
# Найти zombie:
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'
# 23456 12345 Z    [bad_app] <defunct>

# Родитель -- 12345. Можно ему послать SIGCHLD (мягко):
kill -CHLD 12345
# Если родитель правильно обрабатывает SIGCHLD -- сделает wait и zombie исчезнет

# Если не помогает, убить родителя:
kill 12345
# zombie перейдёт под init, init его подберёт

Корректный код: автоматическая reap

Чтобы избежать zombies в своём коде:

Вариант 1: SIGCHLD handler. Обрабатывать SIGCHLD и в нём делать waitpid(-1, ..., WNOHANG).

#include <signal.h>
#include <sys/wait.h>

void sigchld_handler(int signo) {
    while (waitpid(-1, NULL, WNOHANG) > 0) {
        // забираем всех мёртвых детей
    }
}

int main() {
    signal(SIGCHLD, sigchld_handler);
    // ... fork() и не беспокоимся о zombies
}

Вариант 2: SIG_IGN. Сказать ядру «я не интересуюсь exit code детей, сам убирай»:

signal(SIGCHLD, SIG_IGN);

Это нестандартное расширение Linux/BSD, но работает на большинстве систем.

Вариант 3: double-fork. Запускать настоящего ребёнка через промежуточный процесс, чтобы он стал orphan и init забрал. Это техника создания daemon.


Orphan: процесс-сирота

Orphan — противоположная ситуация: родитель умер раньше ребёнка. Что делать с ребёнком?

В Unix: каждый процесс должен иметь родителя. Если родитель умер, orphan усыновляется init (PID 1). PPID становится 1.

# Запускаем shell, в нём -- sleep, потом убиваем shell:
bash -c '(sleep 60 &); sleep 0.5' &
# первый sleep -- внуком shell, он будет сиротой когда внутренний bash выйдет

# В другом терминале посмотреть:
ps -eo pid,ppid,cmd | grep 'sleep 60'
# PID=N, PPID=1 (init) -- стал сиротой и был усыновлён init

# Через 60 секунд sleep сам выйдет, init сделает wait, всё ок

Это штатная ситуация, не баг. Init умеет принимать сирот и делать wait. Поэтому zombies от сиротских процессов не накапливаются.

Jobs: foreground, background, nohup, disown

Daemon = намеренный orphan

Классическая техника создания daemon (фонового сервиса):

pid_t pid = fork();
if (pid > 0) {
    // родитель -- выйти
    exit(0);
}
// ребёнок продолжает, PPID станет 1 после смерти родителя
setsid();  // создать новую сессию, отвязаться от tty
chdir("/");  // не блокировать umount
// ... основная работа daemon

Это часто называется «daemonization». Современные supervision-системы (systemd, supervisord) не любят этот подход — они предпочитают, чтобы сервис оставался foreground и систематично управлялся. Но исторически это норма.


Subreaper: настроить кто принимает сирот

В современном Linux есть concept subreaper. Процесс может через prctl(PR_SET_CHILD_SUBREAPER) объявить: «я буду собирать сирот от своих потомков». Тогда сироты идут не до init, а до ближайшего subreaper.

Это нужно supervision-системам: systemd, runit, supervisord. Они хотят быть «init для своих сервисов»: чтобы все процессы сервиса (включая внуков) принадлежали им, и они могли управлять.

# Посмотреть, какие процессы subreaper:
# В современном Linux:
ls /proc/*/status 2>/dev/null | xargs grep -l 'NoNewPrivs' | head -3
# Subreaper флаг тоже есть в /proc/[pid]/status в виде 'subreaper:'

Trapping signals: пример lifecycle с обработкой

Полный жизненный цикл с обработкой сигналов:

import os
import sys
import signal
import time

def handle_sigterm(signum, frame):
    print(f"Got SIGTERM, exiting gracefully...")
    sys.exit(0)

def handle_sigchld(signum, frame):
    # Reap dead children без блокировки
    try:
        while True:
            pid, status = os.waitpid(-1, os.WNOHANG)
            if pid == 0:
                break
            print(f"Reaped child PID={pid}, status={status}")
    except ChildProcessError:
        pass

signal.signal(signal.SIGTERM, handle_sigterm)
signal.signal(signal.SIGCHLD, handle_sigchld)

print(f"Parent PID={os.getpid()}")

# Создать ребёнка
pid = os.fork()
if pid == 0:
    print(f"Child PID={os.getpid()}, will exit in 1s")
    time.sleep(1)
    sys.exit(42)

# Родитель -- ждёт сигналов
print("Parent waiting for SIGCHLD or SIGTERM...")
time.sleep(2)
print("Done")

Запустите, и наблюдайте за выводом. Сначала — ребёнок выйдет, SIGCHLD reap’нет, потом родитель закончит.


Попробуй сам

# 1. Сделать zombie руками (намеренно):
bash << 'EOF'
sleep 0.1 &
CHILD=$!
sleep 0.5  # ждём, пока ребёнок умрёт
echo "Zombie PID: $CHILD"
ps -p $CHILD -o pid,stat,cmd
echo "Now sleeping 5s, in another terminal see ps -ef | grep defunct"
sleep 5
EOF

# 2. Посмотреть на D-процессы прямо сейчас:
ps -eo pid,stat,wchan,cmd | awk 'NR==1 || $2 ~ /^D/' | head

# 3. Найти, на каком syscall застряли S-процессы:
for pid in $(pgrep -f some_program); do
    echo "PID $pid: $(cat /proc/$pid/wchan 2>/dev/null)"
done

# 4. Сделать orphan руками:
bash -c '(sleep 30 &); sleep 0.1' &
sleep 0.5
ps -eo pid,ppid,cmd | grep 'sleep 30'
# Должен показать PPID=1 -- стал сиротой и был принят init

# 5. Корректный wait после fork в Python:
python3 << 'EOF'
import os
print(f"Parent PID={os.getpid()}")
pid = os.fork()
if pid == 0:
    print(f"Child PID={os.getpid()} doing work...")
    os._exit(0)
# Родитель ждёт ребёнка
result = os.waitpid(pid, 0)
print(f"Parent: child {result[0]} exited with status {result[1]}")
EOF

# 6. Что в /proc у zombie процесса (если найдёте):
ZPID=$(ps -eo pid,stat | awk '$2~/^Z/ {print $1; exit}')
if [ -n "$ZPID" ]; then
    ls /proc/$ZPID/ 2>/dev/null
    cat /proc/$ZPID/status | head -10
fi

Проверка знанийKnowledge check
Junior говорит: 'У меня в production висит процесс в состоянии D уже 10 минут. kill -9 не помогает. ssh всё ещё работает, но программа не реагирует. Что делать?'
ОтветAnswer
Это классическая ситуация в production. Несколько уровней анализа: (1) Сначала понять, ЧТО процесс ждёт. Это видно в /proc/[pid]/wchan: cat /proc/<PID>/wchan Если там что-то типа 'jbd2_log_wait_commit' -- ждёт filesystem journal commit. 'sock_recvmsg' -- ждёт сетевой ввод. 'down_read_failed' -- ждёт kernel lock. (2) Stack trace в ядре (нужен root): sudo cat /proc/<PID>/stack Покажет всю цепочку kernel-функций. Например, видно что застрял в ext4_sync_file -> jbd2_log_wait_commit -- значит, fsync ждёт журнальный коммит, который не может пройти из-за проблем с диском. (3) Проверить диск/storage: - iostat -x 1: видно утилизацию дисков, await (latency), %util. Если % util = 100%, await > 1000ms -- диск перегружен. - dmesg | tail: ищите kernel сообщения про I/O ошибки, контроллер, NFS mount issues. - Если NFS-mount: showmount -e <server> -- работает ли сервер. (4) Почему kill -9 не работает. SIGKILL принимается процессом ТОЛЬКО когда он в user mode или interruptible sleep. Процесс в D находится в kernel mode -- сигнал отложен (pending). Как только I/O завершится и ядро вернёт управление, оно увидит pending SIGKILL и убьёт процесс. Если I/O не завершается никогда -- процесс зависший навсегда. (5) Что можно сделать: - Подождать. Если это временный затык -- через несколько секунд/минут пройдёт. - Перезапустить I/O subsystem. Если NFS зависший: umount -f (force) -- может разбудить процессы. - Перезапустить контейнер/VM. Часто это самое быстрое. - Reboot. Крайняя мера, но если железо подвисло -- единственный путь. (6) Профилактика: - Использовать timeouts везде. На NFS mount: hard,intr,timeo=30 -- разрешить прерывания, ограничить retransmissions. - Monitoring: alert если D-процессы > 1 минуты. - Использовать async I/O (io_uring, libaio) для критичных приложений -- меньше блокирующих ситуаций. Итог: D -- это не баг ядра, это симптом проблем с device или kernel locks. Лечится диагностикой стека и устранением корневой причины.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. В каком состоянии находится процесс, который ждёт I/O от диска (например, чтение файла)?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4