Lifecycle процесса — ready, running, blocked, zombie
В предыдущем уроке мы поняли, как процессы создаются (fork + exec). Теперь — как они живут и умирают. Процесс не просто «работает» — он постоянно перемещается между состояниями: то выполняется, то ждёт I/O, то остановлен, то ждёт, пока родитель его «похоронит».
В этом уроке: жизненный цикл процесса, что значит каждое состояние, как работают wait() и exit(), что такое zombie и orphan, и почему kill -9 иногда не убивает процесс.
Базовая модель: пять состояний
В классической теории ОС описывают пять состояний процесса:
Переходы:
- 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
Дополнительные флаги в выводе ps (после основного state):
+— foreground groups— session leaderl— 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, что эквивалентно). Что происходит:
- Закрытие открытых файлов. Все fd закрываются через close(). Это flush-ит буферы.
- Освобождение ресурсов. Память возвращается ядру, mmap’ы отменяются.
- Отправка SIGCHLD родителю. Ядро уведомляет родительский процесс, что ребёнок умер.
- Сохранение exit status. Код возврата (обычно 0 = успех, 1-255 = ошибка) сохраняется в task_struct.
- Переход в 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()). Это:
- Блокирует родителя, пока какой-то ребёнок не умрёт (если использовать
wait()). - Возвращает PID и exit status умершего ребёнка.
- Освобождает структуру ребёнка в ядре. Запись из
/proc/[pid]/исчезает. PID может быть переиспользован.
Если родитель не делает wait, ребёнок остаётся zombie — мёртвый, но не до конца. Память освобождена, CPU не использует, но запись в таблице процессов есть.
В простом случае это автоматизировано: shell делает wait после exec ребёнка. Сам процесс ждать не должен — shell делает всё. Поэтому нормальный ls — никаких zombies не оставляет.
Zombie: процесс-зомби
Zombie появляется, когда:
- Ребёнок завершился (
exit()). - Родитель ещё жив.
- Родитель ещё не сделал
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:
- Заставить родителя сделать wait. Послать ему сигнал, чтобы он обработал SIGCHLD.
- Убить родителя. Тогда 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, disownDaemon = намеренный 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