Files
eduplan-cpp-eltex/lessons/D1_linux.md
T

34 KiB
Raw Blame History

D1, часть 2. Процессы: fork, exec, wait, сигналы (урок)

Модель одна на весь урок: процесс = адресное пространство (mm_struct в ядре) + поток выполнения + таблица открытых файловых дескрипторов + запись в таблице процессов (task_struct). fork копирует эту запись и помечает память как copy-on-write, exec заменяет содержимое адресного пространства, оставляя PID и дескрипторы, wait забирает у ядра код возврата и освобождает запись, сигнал — асинхронное прерывание исполнения ядром.

1. fork(): что реально происходит

pid_t fork(void) создаёт новый процесс — копию вызывающего: своё адресное пространство, свой PID, свой поток. Родитель продолжает с того же места.

Механически ядро: (1) выделяет новый task_struct и PID; (2) копирует таблицу файловых дескрипторов — обе записи после fork указывают на те же открытые файловые описания в ядре, а не на независимые; (3) копирует таблицу страниц родителя, помечая все страницы данных и кучи как read-only в обеих копиях; (4) добавляет новый процесс в очередь планировщика. Само копирование данных при этом не происходит — см. COW ниже.

Возвращает дважды — и это ключ к пониманию:

  • в родителе — PID ребёнка (> 0);
  • в ребёнке — 0;
  • при ошибке — −1 (и errno), ребёнок не создан.

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

pid_t pid = fork();
if (pid == 0) {
    // это ребёнок
    execlp("ls", "ls", "-l", nullptr);
    _exit(127);                      // exec не вернулся — значит не смог
} else if (pid > 0) {
    // это родитель: pid — номер ребёнка
    int status = 0;
    waitpid(pid, &status, 0);        // дождаться и забрать код возврата
} else {
    perror("fork");                  // ошибка
}

Copy-on-write. Память физически не копируется: обе стороны смотрят на одни физические страницы, помеченные «только чтение» в таблице страниц каждого процесса. При первой записи в такую страницу возникает page fault, ядро перехватывает его, выделяет новую физическую страницу (обычно 4 КБ на x86-64), копирует туда содержимое и переписывает таблицу страниц только пишущего процесса — только тогда. Поэтому fork дешёвый, даже если процесс занимает гигабайты: копируется не память, а только записи таблицы страниц. Именно это спрашивают в формулировке «что происходит со страницами памяти при fork?» — ответ: COW, копируются при первой записи, а не при самом fork.

Причина именно такого устройства — частый паттерн «fork сразу за которым exec»: если бы ядро копировало всё адресное пространство заранее, эта работа почти всегда оказывалась бы выброшенной, ведь exec тут же заменяет содержимое памяти новой программой.

Совет: fflush(stdout) перед fork, иначе непустой буфер stdout скопируется в ребёнка вместе с памятью (COW это не мешает) и будет сброшен на диск/терминал дважды.

Ловушки

  • Не проверить возвращаемое значение fork() и не разветвить логику по нему → родитель и ребёнок выполняют один и тот же код дважды → видно как задвоенный вывод или два PID в ps aux, делающих одну и ту же работу.
  • Ребёнок долго не вызывает exec(), активно пишет в большие структуры данных → всплеск реального потребления памяти именно в момент записи (COW-копирование страниц), а не в момент fork → видно по росту RSS в top/ps уже после fork, а не сразу.
  • Вызвать exit() вместо _exit() в ребёнке после неудачного exec → exit() сбрасывает стандартные буферы stdio, которые ребёнок унаследовал от родителя через COW, и может продублировать ранее не выведенный текст родителя.

Факты для карточек

  • base | Что возвращает fork() в родителе и в ребёнке? — в родителе PID ребёнка (>0), в ребёнке 0, при ошибке −1
  • core | Что происходит со страницами памяти при fork()? — ничего сразу: COW, физическая копия страницы создаётся при первой записи в неё (page fault)
  • core | Почему fork дешёвый даже для процесса с гигабайтами памяти? — копируются не данные, а только записи таблицы страниц; сами страницы данных не дублируются, пока их не изменят
  • base | Что нужно сделать с stdout перед fork, если он не пуст? — вызвать fflush(stdout), иначе буфер продублируется в ребёнке
  • deep | Какой размер страницы памяти на x86-64, о которой копия делается при COW? — 4 КБ

Почему дальше: раз память после fork временно общая и почти всегда тут же заменяется — что конкретно делает exec с этим адресным пространством?

2. exec(): замена образа

exec* не создаёт процесс, а заменяет содержимое текущего: код, данные, стек — всё новое. PID и открытые дескрипторы сохраняются. Возврата при успехе нет никогда: при успехе функция не возвращается (программа уже другая), при ошибке возвращает −1.

Механизм: execve (системный вызов, к которому в итоге сводится всё семейство exec*) загружает исполняемый файл с диска, разбирает его как ELF, строит новый mm_struct — новые сегменты кода и данных, новую кучу, новый стек — и подменяет им адресное пространство текущего task_struct, не трогая PID и таблицу файловых дескрипторов. Дескрипторы, открытые до exec, остаются открытыми в новой программе кроме помеченных флагом FD_CLOEXEC — это то, чем перенаправление ввода-вывода (dup2 на 0/1/2 перед exec) переживает замену образа, а служебные дескрипторы, которые новой программе видеть не нужно, — нет.

Отсюда рабочий шаблон: fork + exec в ребёнке = запуск внешней программы: fork даёт новый процесс с независимой копией состояния (в том числе уже перенастроенные дескрипторы для редиректа), а exec в этом новом процессе подгружает нужную программу, не трогая родителя. Если вызвать exec без предварительного fork, текущая программа заменится и не вернёт управление — например, exec внутри shell-скрипта заменяет саму оболочку.

Семейство: execlp ищет в PATH, execv — по полному пути, execvp — по имени в PATH.

Ловушки

  • Забыть _exit(127) (или любой выход) после неудачного exec* в ребёнке → код продолжает исполняться как будто это родительская логика → дублирование родительской работы в дочернем процессе, видно по неожиданным побочным эффектам после «сбоя» запуска.
  • Не поставить FD_CLOEXEC на служебный/секретный дескриптор перед exec → он утекает в запущенную внешнюю программу → видно в /proc/<pid>/fd запущенного процесса — там лишний открытый файл, которого «не должно быть».

Факты для карточек

  • base | Чем exec отличается от fork? — fork создаёт новый процесс, exec заменяет образ текущего процесса, не создавая нового
  • core | Что сохраняется у процесса после успешного exec? — PID и таблица открытых файловых дескрипторов, кроме помеченных FD_CLOEXEC
  • core | Почему exec при успехе никогда не возвращает управление? — старого кода, куда можно было бы вернуться, больше не существует — адресное пространство заменено новым образом
  • base | Разница между execlp, execv, execvp? — execlp ищет в PATH, execv — по полному пути, execvp — по имени в PATH

Почему дальше: ребёнок исполнился и завершился — как родитель узнаёт, чем это закончилось, и что мешает ему узнать об этом мгновенно?

3. wait/waitpid и коды возврата

Завершившийся ребёнок не исчезает: когда он вызывает exit(), ядро не удаляет его task_struct немедленно, а сохраняет минимальную запись (PID, код возврата, статистику использования ресурсов), пока родитель не заберёт её через wait/waitpid. Такой процесс называется зомби (состояние Z в ps). Зомби не занимает память данных, но занимает слот в таблице процессов — их накопление плохо, вплоть до упора в лимит PID на системе. Зомби не исчезает сам именно потому, что ядру физически некуда передать код возврата, кроме как дождаться, когда родитель за ним придёт — сам процесс уже не исполняется и ничего сообщить не может.

  • wait(&status) — ждёт любого ребёнка;
  • waitpid(pid, &status, 0) — конкретного; WNOHANG — не блокироваться.

Разбор status делается макросами, а не вручную, потому что в одном int закодированы сразу два разных случая (нормальный выход и завершение по сигналу) в разных битах:

if (WIFEXITED(status))        printf("exit code %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status));

Дополнительно есть WIFSTOPPED/WSTOPSIG (ребёнок остановлен, например, по SIGSTOP) и WCOREDUMP(status) (завершение по сигналу сопровождалось дампом памяти на диск, core).

Откуда 137 и 139. Оболочка показывает код как 128 + номер сигнала:

  • 137 = 128 + 9 → SIGKILL (убит kill -9, часто OOM-killer);
  • 139 = 128 + 11 → SIGSEGV (падение по памяти);
  • 143 = 128 + 15 → SIGTERM (корректный запрос на завершение).

Сирота — процесс, чей родитель умер раньше него: его усыновляет init/systemd (PID 1, либо выделенный subreaper), который в цикле собирает статусы всех своих детей — поэтому сирота гарантированно не застревает зомби навсегда, в отличие от зомби при живом, но нерадивом родителе. Разница именно в том, кто виноват: зомби — родитель жив, но не вызвал wait; сирота — родитель умер, но дождаться его теперь придётся init.

Ловушки

  • Родитель никогда не вызывает waitpid для завершившихся детей → записи зомби копятся → видно как растущий список ps aux | grep Z, в пределе — упор в лимит PID.
  • Сравнивать status напрямую с кодом возврата вместо WEXITSTATUS(status) → в status закодированы и код выхода, и флаг сигнала одновременно, сырое значение не совпадает с тем, что вернула программа.
  • Долгоживущий процесс с fork-воркерами не занимается сбором детей вообще → зомби накапливаются постепенно, а не сразу → проявляется не в первый час работы, а через дни аптайма ростом числа Z-процессов.

Факты для карточек

  • base | Зачем нужен waitpid? — забрать код возврата завершившегося ребёнка и позволить ядру освободить его запись в таблице процессов
  • core | Что такое зомби и почему он не исчезает сам? — процесс, завершивший exit(), чью запись ещё не забрал родитель через wait; ядру некуда передать код возврата, кроме как дождаться wait
  • core | Чем зомби отличается от сироты? — зомби: родитель жив, но не вызвал wait; сирота: родитель умер, процесс усыновлён init/systemd (PID 1)
  • base | Откуда код возврата 137 и 139? — 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так оболочка кодирует завершение по сигналу
  • core | Как правильно проверить, что ребёнок убит сигналом, а не завершился штатно? — макросами WIFSIGNALED(status)/WTERMSIG(status), не сравнением status напрямую
  • deep | Что показывает WCOREDUMP(status)? — что завершение по сигналу сопровождалось записью core-дампа на диск

Почему дальше: коды 137/139/143 — это сигналы, доставленные процессу; что вообще такое сигнал и какие из них процесс может перехватить, а какие — нет?

4. Сигналы

Сигнал — асинхронное уведомление, которое ядро доставляет процессу, прерывая его обычное исполнение и передавая управление либо зарегистрированному обработчику, либо выполняя действие по умолчанию (завершить, завершить с core-дампом, игнорировать, приостановить). Основные: SIGINT (2, Ctrl+C, по умолчанию завершает, перехватывается), SIGKILL (9, нельзя перехватить или проигнорировать), SIGTERM (15, «завершись корректно», перехватывается), SIGSEGV (11, обращение к недопустимой памяти), SIGPIPE (13, запись в закрытый сокет/pipe), SIGCHLD (17 на Linux/x86, ребёнок изменил состояние — завершился или остановился).

Разделение на перехватываемые и неперехватываемые сигналы существует ради надёжности управления системой: администратору и супервизору всегда нужен гарантированный способ остановить процесс, даже если тот завис в бесконечном цикле или его собственный обработчик сигналов содержит баг — отсюда SIGKILL, который ядро обрабатывает на уровне планировщика, снимая процесс с исполнения без единой инструкции пользовательского кода в ответ. SIGTERM устроен наоборот — он предполагает, что процесс жив и способен среагировать: закрыть файлы, сбросить буферы, освободить ресурсы. Отсюда практика эксплуатации: сначала всегда посылают SIGTERM и ждут; так, systemctl stop/docker stop по умолчанию ждут несколько секунд (в systemd таймаут задаётся TimeoutStopSec, по умолчанию около 90 секунд) и только затем, если процесс не завершился, посылают SIGKILL — потому что SIGKILL не даёт дописать данные на диск, и незавершённая операция может остаться в неконсистентном состоянии.

Обработчик ставится sigaction (надёжнее устаревшего signal — поведение signal исторически различалось между Unix-системами), внутри обработчика можно менять только volatile sig_atomic_t — никаких printf/malloc (не async-signal-safe: malloc не реентерабелен и может быть прерван сигналом посреди изменения своих внутренних структур, что при вызове malloc/printf из обработчика способно повредить кучу или подвесить процесс).

static volatile sig_atomic_t stop = 0;
static void on_term(int) { stop = 1; }        // минимум действий

int main() {
    struct sigaction sa{}; sa.sa_handler = on_term;
    sigaction(SIGTERM, &sa, nullptr);          // ловим мягкое завершение
    while (!stop) { /* работа */ }
}

Ловушки

  • Вызвать printf/malloc/free внутри обработчика сигнала → не async-signal-safe → в редких случаях повреждение кучи или взаимная блокировка, если сигнал прервал программу ровно во время работы аллокатора — воспроизводится нестабильно, под нагрузкой.
  • Использовать signal() вместо sigaction() → поведение (сброс обработчика в default после первого срабатывания, поведение при повторном сигнале) исторически различается между реализациями Unix → код, проверенный на одной системе, ведёт себя иначе на другой.
  • Ждать, что демон корректно остановится по SIGTERM, не поставив на него обработчик → docker stop/systemctl stop в итоге шлют SIGKILL по таймауту → в логах виден резкий обрыв процесса без финализации (незакрытые файлы, недописанные данные).

Факты для карточек

  • base | Номера сигналов SIGINT/SIGKILL/SIGTERM/SIGSEGV/SIGPIPE/SIGCHLD? — 2/9/15/11/13/17
  • core | Чем SIGTERM отличается от SIGKILL? — SIGTERM перехватывается и позволяет корректно завершиться, SIGKILL не перехватывается и не игнорируется, ядро снимает процесс с исполнения напрямую
  • core | Что можно делать внутри обработчика сигнала? — только менять volatile sig_atomic_t; printf/malloc небезопасны (не async-signal-safe)
  • base | Чем sigaction лучше signal? — поведение signal исторически различается между Unix-системами, sigaction даёт предсказуемую и переносимую семантику
  • deep | Что происходит, если сервис игнорирует SIGTERM при systemctl stop? — по истечении TimeoutStopSec (по умолчанию ~90 c) systemd посылает SIGKILL принудительно

Почему дальше: сигнал может прервать системный вызов на середине — как код узнаёт об этом и что делать дальше?

5. errno и возвраты системных вызовов

Системные вызовы сообщают об ошибке через возврат (−1 или NULL) и errno. Читать errno можно только сразу после ошибки, до вызова любой другой функции, которая может сама переписать errno (например, printf при внутренней ошибке форматирования). EINTR — вызов прерван сигналом, надо повторить; EAGAIN — данных сейчас нет на неблокирующем дескрипторе, повторить позже; EINPROGRESS — неблокирующее соединение в процессе; EMFILE — процесс упёрся в лимит открытых дескрипторов (ulimit -n), диагностируется через lsof -p PID или /proc/PID/fd.

ssize_t n = read(fd, buf, sizeof buf);
if (n < 0) {
    if (errno == EINTR) continue;      // прервали сигналом — просто повторить
    perror("read");                    // иначе настоящая ошибка
    break;
}

Ловушки

  • Проверить errno без предварительной проверки, что вызов вообще вернул ошибку → errno может быть ненулевым от предыдущего, уже обработанного вызова → ложное срабатывание.
  • Вызвать любую функцию (даже printf) между системным вызовом и чтением errno → промежуточный вызов может переписать errno → в обработчике окажется код чужой ошибки.

Факты для карточек

  • base | Что означает EINTR и как на него реагировать? — вызов прерван доставкой сигнала; корректная реакция — повторить вызов
  • base | Чем EAGAIN отличается от обычной ошибки чтения? — данных сейчас нет на неблокирующем дескрипторе, это не сбой, а сигнал «попробуй позже»
  • core | Когда безопасно читать errno? — сразу после ошибки вызова, до любого другого вызова, способного его перезаписать
  • deep | Какой errno сигнализирует об исчерпании лимита открытых дескрипторов процесса? — EMFILE (лимит задаётся ulimit -n)

Почему дальше: fork/exec/wait/сигналы/errno вместе — из этого уже можно собрать минимальный shell; что там на практике ломается первым?

6. Разбор примера: мини-шелл на 40 строк

Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня.

#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <string>
#include <vector>
#include <sstream>

int main() {
    std::string line;
    while (std::printf("sh> "), std::fflush(stdout), std::getline(std::cin, line)) {
        if (line == "exit") break;
        if (line.empty()) continue;

        std::istringstream is(line);
        std::vector<std::string> argv; std::string tok;
        while (is >> tok) argv.push_back(tok);
        if (argv.empty()) continue;

        std::vector<char*> cargv;
        for (auto& a : argv) cargv.push_back(a.data());
        cargv.push_back(nullptr);

        pid_t pid = fork();
        if (pid == 0) {
            execvp(cargv[0], cargv.data());
            std::fprintf(stderr, "не могу запустить %s\n", cargv[0]);
            _exit(127);                       // exec не удался
        } else if (pid > 0) {
            int status = 0;
            waitpid(pid, &status, 0);
            if (WIFEXITED(status))
                std::printf("код возврата: %d\n", WEXITSTATUS(status));
            else if (WIFSIGNALED(status))
                std::printf("убит сигналом: %d (код %d)\n",
                            WTERMSIG(status), 128 + WTERMSIG(status));
        } else {
            std::perror("fork");
        }
    }
}

Что тут проверить руками: ls -l работает; sleep 5 в фоне (& — уже доработка); kill -9 по своему процессу из другого терминала даёт «убит сигналом 9 (код 137)»; несуществующая команда даёт 127. Дочерний процесс перед execvp уже унаследовал от родителя дескрипторы 0/1/2 (stdin/stdout/stderr) через fork, поэтому вывод запущенной программы сразу идёт в тот же терминал — отдельно настраивать редирект не нужно, пока не требуется перенаправление в файл или pipe.

Проверка на утечки и падения — санитайзеры: g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer shell.cpp -o shell

Ловушки

  • Не проверять, пуст ли argv перед execvp → execvp(nullptr, ...) на пустой строке → неопределённое поведение вместо ожидаемого «ничего не делать» (в коде это уже предусмотрено проверкой if (argv.empty()) continue;, но при рефакторинге легко потерять).
  • Забыть _exit(127) после неудачного execvp → дочерний процесс продолжит исполнять тело цикла while наравне с родителем → двойной ввод команд из одного терминала.

Факты для карточек

  • base | Какой код возврата у шелла даст несуществующая команда? — 127
  • base | Какие дескрипторы дочерний процесс получает от родителя при fork и почему вывод команды сразу виден в терминале? — 0/1/2 (stdin/stdout/stderr) наследуются через копию таблицы дескрипторов при fork
  • core | Каким флагом собрать бинарник для проверки на утечки и UB? — -fsanitize=address,undefined -fno-omit-frame-pointer

Почему дальше: те же вопросы про fork/exec/wait/сигналы задают на собеседовании почти дословно — какие формулировки ждут в ответ?

7. Что спросят на собеседовании (готовые ответы)

  • «Что делает fork и что возвращает?» — создаёт копию процесса; в родителе PID ребёнка, в ребёнке 0, при ошибке −1.
  • «Что с памятью при fork?» — copy-on-write, страницы копируются при первой записи.
  • «Чем exec отличается от fork?» — fork создаёт новый процесс, exec заменяет образ текущего, не создавая процесса.
  • «Зачем waitpid?» — забрать код возврата и не оставлять зомби.
  • «Как узнать, что процесс убит сигналом?» — WIFSIGNALED/WTERMSIG, код оболочки 128 + сигнал, например 137 = SIGKILL, 139 = SIGSEGV.
  • «SIGTERM против SIGKILL?» — SIGTERM можно перехватить и завершиться корректно, SIGKILL не перехватывается и не игнорируется.

8. Материалы (первопартийные)

9. Проверь себя

  1. Что вернёт fork() в ребёнке и в родителе?
  2. Что происходит со страницами памяти при fork()?
  3. Что такое зомби и кто его убирает?
  4. Откуда код возврата 137 и 139?
  5. Почему exec не возвращает управление при успехе?
  6. Какие файловые дескрипторы сохраняются после exec и какой флаг это меняет?
  7. Что произойдёт с процессом, который игнорирует SIGTERM, при systemctl stop?
Ответы
  1. В ребёнке 0, в родителе — PID ребёнка, при ошибке −1.
  2. Ничего сразу: copy-on-write, копия страницы создаётся при первой записи.
  3. Завершившийся процесс, чья запись ждёт wait/waitpid; убирает родитель (или init, если родитель умер).
  4. 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка.
  5. Потому что адресное пространство заменено новым образом: старого кода больше нет.
  6. Все дескрипторы, открытые до exec, кроме помеченных FD_CLOEXEC.
  7. По истечении таймаута остановки (TimeoutStopSec, по умолчанию ~90 c) systemd пришлёт SIGKILL принудительно.

10. Задачи дня

  • tasks/03_ring — кольцевой буфер (база для сетевого кода).
  • Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями).