Files

389 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<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` закодированы
сразу два разных случая (нормальный выход и завершение по сигналу) в разных битах:
```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 <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. Материалы (первопартийные)
- `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`?
<details>
<summary>Ответы</summary>
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 принудительно.
</details>
## 10. Задачи дня
- `tasks/03_ring` — кольцевой буфер (база для сетевого кода).
- Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями).
</content>