# 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`), ребёнок не создан. Оба процесса продолжают исполнение с одной и той же точки кода сразу после вызова — единственный способ понять, в какой копии сейчас исполняется код, это проверить возвращённое значение. Поэтому классический код всегда ветвится: ```cpp 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//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` закодированы сразу два разных случая (нормальный выход и завершение по сигналу) в разных битах: ```cpp 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` из обработчика способно повредить кучу или подвесить процесс). ```cpp 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`. ```cpp 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 строк Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня. ```cpp #include #include #include #include #include #include #include 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 argv; std::string tok; while (is >> tok) argv.push_back(tok); if (argv.empty()) continue; std::vector 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. Материалы (первопартийные) - `man 2 fork`, `man 2 execve`, `man 2 waitpid`, `man 7 signal` — локально, читаются за 10 минут - OSTEP, главы 4–5 (процессы, API процессов) — https://pages.cs.wisc.edu/~remzi/OSTEP/ - Beej, «Processes» — https://beej.us/guide/bgipc/ - Про COW: `man 2 fork` + статья «Anatomy of a Program in Memory» — https://manybutfinite.com/post/anatomy-of-a-program-in-memory/ ## 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-заданиями).