Процессы

Предпосылки: режимы CPU и системные вызовы (ring 0/ring 3, syscall, стоимость переключения в ядро).

Режимы CPU и системные вызовы | Потоки

Системные вызовы дают программе доступ к ядру: прочитать файл, отправить пакет, выделить память. Но ядро обслуживает не один вызов за раз — на машине одновременно работают десятки и сотни программ. Каждой нужна своя память, свои открытые файлы, свой счётчик инструкций. Ядру нужна единица управления — процесс (process).

Программа и процесс — разные вещи

Программа — это файл на диске: /usr/bin/bash занимает 1.2 МБ в файловой системе и ничего не делает, пока кто-то не запустит его. Процесс — это работающий экземпляр программы. Один и тот же /usr/bin/bash может породить десять процессов: десять открытых терминалов — десять независимых оболочек, каждая со своей историей команд, своими переменными, своим текущим каталогом.

Проверить легко: открыть три терминала и выполнить ps aux | grep bash. В выводе будет три строки с разными PID (Process ID), но одинаковым путём к исполняемому файлу.

Из чего состоит процесс

Каждый процесс — это набор ресурсов, которые ядро поддерживает от момента создания до завершения.

Адресное пространство разделено на сегменты. Сегмент кода (text) содержит машинные инструкции — он доступен только для чтения, и если несколько процессов запустили один и тот же бинарник, ядро может разделить эти страницы между ними. Сегмент данных (data/bss) хранит глобальные и статические переменные. Куча (heap) растёт вверх через brk()/mmap() — сюда попадают динамические аллокации (malloc). Стек (stack) растёт вниз и хранит локальные переменные и адреса возврата из функций; по умолчанию ядро ограничивает его 8 МБ (ulimit -s).

Регистры процессора — контекст выполнения. Указатель инструкции (rip на x86-64) говорит, какую инструкцию выполнять следующей. Указатель стека (rsp) указывает на вершину стека. Базовый указатель (rbp) обозначает начало текущего фрейма. Когда ядро переключает процессор с одного процесса на другой, оно сохраняет все регистры первого процесса и восстанавливает регистры второго — это контекстное переключение (context switch). Для процессов оно стоит порядка 3-10 мкс, потому что помимо сохранения регистров ядро перезагружает регистр CR3 (базовый адрес таблицы страниц), что сбрасывает TLB (Translation Lookaside Buffer — кеш трансляций адресов); последующие обращения к памяти проходят через полную аппаратную трансляцию, пока TLB не прогреется заново. Переключение между потоками одного процесса дешевле (1-5 мкс), потому что они разделяют адресное пространство и TLB остаётся валидным.

Таблица файловых дескрипторов — массив указателей на открытые файлы, сокеты, пайпы. Дескриптор 0 — stdin, 1 — stdout, 2 — stderr. Лимит по умолчанию — 1024 дескриптора на процесс (ulimit -n), можно поднять до сотен тысяч.

Метаданные ядро хранит в структуре task_struct (определена в include/linux/sched.h). Она занимает порядка 6-8 КБ и содержит PID (идентификатор процесса), PPID (Parent PID — PID родителя), UID/GID (User ID/Group ID — владелец), текущее состояние (running/sleeping/zombie), приоритет, указатели на таблицу страниц, сигнальные маски и многое другое. task_struct — центральная структура ядра: планировщик, подсистема памяти, файловая система — все работают через неё.

Как появляются новые процессы: fork()

Откуда берётся процесс? В Unix ответ один: из другого процесса. Системный вызов fork() клонирует вызвавший процесс, создавая его почти точную копию. Родитель и потомок отличаются ровно одним: значением, которое вернул fork().

Идея кажется расточительной — зачем копировать всё, если потомку нужно сделать что-то другое? Философия Unix: проще скопировать всё и изменить нужное, чем передавать десятки параметров о том, что именно создать. Это разделение ответственности: fork() создаёт процесс, exec() загружает новую программу. Между ними потомок может перенастроить себя: перенаправить файловые дескрипторы, сменить рабочий каталог, изменить переменные окружения.

fork() возвращает значение дважды — один раз в родительском процессе, один раз в дочернем:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
 
int main(void) {
    printf("до fork: PID = %d\n", getpid());
 
    pid_t pid = fork();
 
    if (pid < 0) {
        perror("fork");           /* ошибка: не хватило ресурсов */
        return 1;
    }
 
    if (pid == 0) {
        /* потомок: fork() вернул 0 */
        printf("потомок: PID = %d, PPID = %d\n", getpid(), getppid());
    } else {
        /* родитель: fork() вернул PID потомка */
        printf("родитель: PID = %d, потомок = %d\n", getpid(), pid);
        waitpid(pid, NULL, 0);    /* ожидаем завершения потомка */
    }
 
    return 0;
}

После fork() оба процесса продолжают выполнение с той же строки — инструкции после вызова fork(). Родитель получает PID потомка (положительное число), потомок получает 0. Это единственный способ для каждого из них понять, кто он.

Почему родителю возвращается PID потомка? Родитель может породить много потомков, и ему нужно различать их — например, чтобы вызвать waitpid() для конкретного. Потомку же не нужно знать свой PID через возврат fork() — он всегда может вызвать getpid().

Copy-on-Write (копирование при записи): почему fork() быстрый

Процесс веб-сервера для конвертации изображений занимает 2 ГБ оперативной памяти. Наивное копирование 2 ГБ при каждом fork() заняло бы десятки миллисекунд и удвоило бы расход памяти. На практике fork() выполняется за 50-100 мкс даже для такого процесса.

Ядро не копирует 2 ГБ. Оно копирует только таблицу страниц — компактную структуру, которая описывает, какие виртуальные адреса указывают на какие физические страницы памяти. После fork() обе копии — родитель и потомок — ссылаются на одни и те же физические страницы, но все эти страницы помечаются как read-only. Когда один из процессов пытается записать данные, процессор генерирует page fault, ядро перехватывает его и копирует ровно одну затронутую страницу (4 КБ). Только она становится независимой копией — остальные продолжают разделяться.

Если потомок сразу вызывает exec() и загружает новую программу, большинство страниц родителя вообще никогда не будут скопированы — адресное пространство потомка полностью заменяется. Это основной сценарий: fork() + exec() создаёт новый процесс за микросекунды, независимо от размера родителя. Полный механизм Copy-on-Write разберём в виртуальной памяти.

exec(): замена программы

fork() создал копию текущего процесса. Но нам нужен не клон — нам нужно запустить другую программу. Системный вызов execve() (и его обёртки execl, execvp, execlp) заменяет адресное пространство процесса новым: ядро уничтожает сегменты кода, данных, кучу, стек и загружает на их место содержимое указанного исполняемого файла. Регистры сбрасываются. Указатель инструкции устанавливается на точку входа новой программы.

При этом PID не меняется — это тот же процесс, просто с другим содержимым. Файловые дескрипторы по умолчанию сохраняются (если на них не стоит флаг FD_CLOEXEC — Close On Exec, закрыть при вызове exec). Благодаря этому родитель может до exec() перенаправить stdin/stdout потомка на файл или пайп — и новая программа унаследует эти перенаправления, ничего о них не зная.

Важная деталь: exec() не возвращается при успехе. Возвращаться некуда — старая программа уничтожена. Если exec() вернулся — произошла ошибка (файл не найден, нет прав на выполнение).

Паттерн fork+exec

Соединим обе операции. Веб-сервер получает запрос на конвертацию изображения. Ему нужно запустить /usr/bin/convert (ImageMagick) как отдельный процесс:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
 
void handle_request(const char *input, const char *output) {
    pid_t pid = fork();
 
    if (pid < 0) {
        perror("fork");
        return;
    }
 
    if (pid == 0) {
        /* потомок: перенаправляем stderr в лог-файл */
        freopen("/var/log/convert.log", "a", stderr);
 
        /* заменяем программу на convert */
        execlp("convert", "convert", input, "-resize", "800x600", output, NULL);
 
        /* если мы здесь — exec() не сработал */
        perror("exec");
        _exit(127);     /* _exit(), не exit(): не сбрасываем буферы родителя */
    }
 
    /* родитель: ждём завершения потомка */
    int status;
    waitpid(pid, &status, 0);
 
    if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
        printf("конвертация завершена: %s\n", output);
    } else {
        printf("ошибка конвертации, код: %d\n", WEXITSTATUS(status));
    }
}

Между fork() и exec() потомок перенаправил stderr в лог-файл. Программа convert об этом не знает — она пишет в дескриптор 2, а он уже указывает на файл. Этот промежуток между fork и exec — окно для любой настройки: сменить рабочий каталог (chdir), закрыть ненужные дескрипторы, установить переменные окружения, понизить приоритет (nice), изменить UID (setuid).

Потомок вызывает _exit(), а не exit(), если exec() не сработал. Разница важна: exit() сбрасывает буферы stdio, которые потомок унаследовал от родителя — это может привести к дублированию вывода. _exit() завершает процесс немедленно, без побочных эффектов.

Контраст с Windows: Windows не разделяет создание процесса и загрузку программы. CreateProcess() принимает путь к исполняемому файлу, аргументы командной строки, дескрипторы безопасности, переменные окружения, флаги создания — всё одним вызовом с 10 параметрами. Это быстрее в простых случаях, но менее гибко: промежутка для произвольной настройки нет.

Состояния процесса

Процесс не всегда выполняет инструкции. Большую часть времени он ждёт: данных с диска, пакета из сети, ввода пользователя, таймера. Ядро отслеживает состояние каждого процесса, и это состояние определяет, будет ли планировщик выделять ему процессорное время.

Running (R) — процесс либо выполняется на CPU прямо сейчас, либо стоит в очереди планировщика, готовый к выполнению. На машине с 4 ядрами одновременно в состоянии R на процессоре могут быть максимум 4 процесса, остальные ожидают в очереди.

Sleeping, interruptible (S) — процесс ждёт события: данных от сокета, завершения таймера, нажатия клавиши. Сигнал (например, SIGTERM) разбудит его. Это самое частое состояние: запустите ps aux и большинство процессов будут в состоянии S.

Sleeping, uninterruptible (D) — процесс ждёт завершения операции ввода-вывода, которую нельзя прервать: обычно это дисковый I/O или обращение к NFS (Network File System). Сигналы не действуют — даже kill -9 не завершит процесс в состоянии D. Он выйдет из этого состояния только когда I/O завершится. Массовое появление процессов в D — признак проблем с диском или сетевой файловой системой.

Stopped (T) — процесс приостановлен сигналом SIGSTOP или SIGTSTP (Ctrl+Z в терминале). Продолжить можно сигналом SIGCONT (команда fg в shell). Отладчики (gdb, strace) используют SIGSTOP, чтобы остановить процесс и исследовать его состояние.

Zombie (Z) — процесс завершился, но его запись в таблице процессов ещё существует. Подробнее — в следующем разделе.

Состояние видно в колонке STAT вывода ps aux:

USER       PID  STAT  COMMAND
root         1  Ss    /usr/lib/systemd/systemd
postgres   950  Ss    /usr/lib/postgresql/15/bin/postgres
postgres   951  Ss    postgres: checkpointer
www-data   900  Sl    nginx: worker process
www-data  1500  R     convert input.jpg -resize 800x600 output.jpg
www-data  1501  Z     [convert] <defunct>

Дополнительные буквы после основного состояния: s — лидер сессии, l — имеет потоки, + — в группе переднего плана (foreground) терминала.

Жизненный цикл процесса:

stateDiagram-v2
    [*] --> RUNNING: fork()
    RUNNING --> SLEEPING: ожидание I/O
    SLEEPING --> RUNNING: I/O завершён / сигнал
    RUNNING --> STOPPED: Ctrl+Z / SIGSTOP
    SLEEPING --> STOPPED: SIGSTOP
    STOPPED --> RUNNING: SIGCONT
    RUNNING --> ZOMBIE: exit() / сигнал
    ZOMBIE --> [*]: родитель вызвал wait()

    RUNNING: RUNNING (R)
    SLEEPING: SLEEPING (S/D)
    STOPPED: STOPPED (T)
    ZOMBIE: ZOMBIE (Z)

Зомби: почему wait() необходим

Когда процесс вызывает exit(), ядро освобождает практически все его ресурсы: адресное пространство, открытые файлы, сокеты. Но task_struct остаётся — маленькая запись в ядре (6-8 КБ), содержащая PID и код завершения (exit status). Процесс в этом состоянии называется зомби (zombie).

Зачем хранить запись мёртвого процесса? Потому что родитель может захотеть узнать, как завершился потомок. Код возврата 0 означает успех, 127 — программа не найдена, 137 — убит сигналом SIGKILL. Пока родитель не вызовет waitpid() и не прочитает этот код, ядро обязано хранить запись.

Вернёмся к веб-серверу. Если он порождает процессы для конвертации, но не вызывает waitpid():

/* Опасный код: нет wait() */
for (int i = 0; i < 1000; i++) {
    pid_t pid = fork();
    if (pid == 0) {
        execlp("convert", "convert", "in.jpg", "out.jpg", NULL);
        _exit(127);
    }
    /* родитель сразу продолжает, не дожидаясь потомка */
}

Каждый завершившийся convert станет зомби. 1000 запросов — 1000 зомби. Каждый зомби занимает ~6-8 КБ ядерной памяти (сам task_struct) и удерживает один PID. Лимит PID по умолчанию — 32768 (/proc/sys/kernel/pid_max), на серверах часто поднимается до 4194304. Тысяча зомби — это ~6-8 МБ ядерной памяти и тысяча занятых PID. Зомби невозможно убить: kill -9 не действует, потому что процесс уже мёртв. Единственный способ очистить — заставить родителя вызвать wait(), или завершить самого родителя.

Обнаружить зомби можно через ps aux | grep Z — в колонке STAT будет буква Z, а в колонке COMMAND — [convert] <defunct>.

Три способа справиться с зомби:

Первый — вызывать waitpid() после каждого fork(), как в примере с handle_request выше. Простой, но блокирует родителя на время выполнения потомка.

Второй — обрабатывать сигнал SIGCHLD. Ядро посылает родителю SIGCHLD при завершении любого потомка. В обработчике сигнала вызывается waitpid(-1, NULL, WNOHANG) в цикле, собирая всех завершившихся потомков:

#include <signal.h>
#include <sys/wait.h>
 
void sigchld_handler(int sig) {
    (void)sig;
    /* WNOHANG: не блокироваться, если нет завершившихся потомков */
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;
}
 
/* в main() или при инициализации: */
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;     /* перезапускать прерванные syscall */
sigaction(SIGCHLD, &sa, NULL);

Третий — установить signal(SIGCHLD, SIG_IGN). Явное игнорирование SIGCHLD говорит ядру: не создавать зомби вообще, сразу очищать запись при завершении потомка. Просто, но лишает возможности узнать код возврата.

Сироты: когда родитель умирает первым

Обратная ситуация: родитель завершился раньше потомка. Потомок стал сиротой (orphan). Осиротевшие процессы не остаются без присмотра — ядро немедленно переназначает им нового родителя. По умолчанию это init (PID 1, systemd), но если один из предков процесса установил себя subreaper’ом (заместителем родителя) через prctl(PR_SET_CHILD_SUBREAPER, 1) — сироту усыновит он. Контейнерные рантаймы (Docker, systemd-nspawn) используют subreaper, чтобы забирать зомби внутри контейнера, не доводя их до PID 1 хоста.

Init/systemd периодически вызывает wait() для своих потомков. Поэтому если осиротевший процесс впоследствии завершится, он станет зомби на очень короткое время — init его быстро соберёт. Благодаря этому механизму сироты не представляют угрозы: они нормально работают и нормально завершаются.

Проверить усыновление: запустить процесс в фоне (sleep 300 &), узнать его PID, убить родительский shell и посмотреть PPID через grep PPid /proc/<pid>/status — он станет равен 1.

Есть частный случай: двойной fork. Серверные демоны намеренно используют два последовательных вызова fork, чтобы отвязаться от родителя. Первый fork создаёт промежуточный процесс, который немедленно завершается. Внук становится сиротой и усыновляется init. Двойной fork отвязывает от родителя (внук — сирота, усыновлён init), но сам по себе не отвязывает от терминала. Для полного отделения промежуточный процесс вызывает setsid() — создаёт новую сессию без управляющего терминала. Классическая последовательность демонизации: fork → setsid → второй fork (чтобы лидер сессии не мог случайно открыть терминал через open("/dev/tty")) → закрыть stdin/stdout/stderr → работать.

Дерево процессов

Каждый процесс знает своего родителя (PPID). Из-за этого все процессы в системе образуют дерево с корнем в init (PID 1). Увидеть дерево можно командой pstree:

systemd(1)─+─sshd(820)───sshd(1234)───bash(1235)───vim(1300)
            ├─nginx(900)─+─nginx(901)
            │             └─nginx(902)
            ├─postgres(950)─+─postgres(951)
            │                ├─postgres(952)
            │                └─postgres(953)
            └─cron(500)

systemd запустил sshd, nginx, postgres, cron. SSH-сессия породила bash, из которого запущен vim. nginx запустил два рабочих процесса. PostgreSQL — три дочерних процесса (checkpointer, background writer, WAL writer). Вся система — одно дерево.

Завершение процесса-предка не убивает потомков автоматически. Убить bash(1235) не убьёт vim(1300) — vim станет сиротой и будет усыновлён systemd(1). Если нужно завершить всё дерево, используется группа процессов (process group): kill -TERM -<pgid> посылает сигнал всем процессам группы.

Сценарий целиком: жизнь процесса конвертации

Соберём все компоненты вместе. Веб-сервер (PID 900) получает запрос на конвертацию изображения:

  1. Сервер вызывает fork(). Ядро копирует task_struct, создаёт новую таблицу страниц (Copy-on-Write), назначает PID 1500. Занимает ~50 мкс.

  2. Потомок (PID 1500) в состоянии R. Он перенаправляет stderr в лог-файл и вызывает execve("/usr/bin/convert", ...). Ядро уничтожает адресное пространство веб-сервера в потомке и загружает бинарник convert. PID остаётся 1500.

  3. Convert начинает обработку: читает входной файл (состояние D на время дискового I/O), выполняет трансформации (состояние R), записывает результат (снова D).

  4. Convert вызывает exit(0). Ядро освобождает память, закрывает дескрипторы, но оставляет task_struct с кодом завершения 0. PID 1500 — зомби (Z).

  5. Сервер (PID 900) вызывает waitpid(1500, &status, 0). Ядро возвращает код завершения, удаляет task_struct. PID 1500 свободен для повторного использования.

Весь цикл — от fork до очистки зомби — занял, скажем, 2 секунды. Из них 50 мкс — fork, несколько микросекунд — exec, остальное — работа convert. Главное: ни на каком этапе ядро не копировало 2 ГБ памяти веб-сервера.

Процесс — единица изоляции: собственная память, собственные дескрипторы, собственный идентификатор. Но у полной изоляции есть цена. Если веб-серверу нужны параллельные обработчики, которые разделяют кеш изображений или пул соединений к базе данных, создавать отдельный процесс для каждого обработчика расточительно: каждый fork дублирует таблицу страниц, каждый процесс имеет собственный task_struct, а обмен данными между процессами требует IPC (Inter-Process Communication, межпроцессное взаимодействие — пайпы, разделяемая память, сокеты). Когда нужен параллелизм с общими данными — нужна более лёгкая единица выполнения: поток.

Sources


Режимы CPU и системные вызовы | Потоки